554  Sensor Calibration Lab

Hands-On Wokwi Workshop

sensing
lab
calibration
wokwi
Author

IoT Textbook

Published

January 19, 2026

Keywords

sensor calibration, Wokwi, ESP32, two-point calibration, signal conditioning, hands-on lab

554.1 Learning Objectives

By completing this lab, you will be able to:

  1. Understand calibration fundamentals: Learn why sensors need calibration and how raw readings differ from true values
  2. Perform two-point calibration: Implement offset and gain correction using low and high reference points
  3. Apply signal conditioning: Use moving average filtering to reduce noise in sensor readings
  4. Compare raw vs calibrated data: Visualize the impact of calibration on measurement accuracy
  5. Store and retrieve calibration coefficients: Persist calibration data for production deployments

554.2 Prerequisites

  • Basic understanding of Arduino/C++ programming
  • Familiarity with analog inputs and ADC concepts
  • Completion of the Sensor Data Processing chapter (recommended)
TipInteractive Browser-Based Lab

This lab uses Wokwi, a free online electronics simulator. No physical hardware required! You can experiment with sensor calibration techniques directly in your browser.

554.3 Why Calibration Matters

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#2C3E50', 'primaryTextColor': '#fff', 'primaryBorderColor': '#16A085', 'lineColor': '#16A085', 'secondaryColor': '#E67E22', 'tertiaryColor': '#fff'}}}%%
flowchart LR
    subgraph PROBLEM["Without Calibration"]
        R1["Raw: 512"] --> E1["Shows: 50.0%"]
        R2["Actual: 45%"] --> X1["Error: 5%"]
    end

    subgraph SOLUTION["With Calibration"]
        R3["Raw: 512"] --> C1["Apply Offset<br/>and Gain"]
        C1 --> E2["Shows: 45.2%"]
        R4["Actual: 45%"] --> X2["Error: 0.2%"]
    end

    PROBLEM --> |"Two-Point<br/>Calibration"| SOLUTION

    style PROBLEM fill:#E74C3C,stroke:#2C3E50,color:#fff
    style SOLUTION fill:#16A085,stroke:#2C3E50,color:#fff
    style C1 fill:#E67E22,stroke:#2C3E50,color:#fff

Figure 554.1: Calibration transforms inaccurate raw sensor readings into precise, reliable measurements

Real sensors have manufacturing variations that cause:

  • Offset errors: Sensor reads non-zero when it should read zero
  • Gain errors: Sensor’s sensitivity differs from the ideal specification
  • Non-linearity: Response curve deviates from expected linear relationship

Two-point calibration corrects both offset and gain errors by measuring at two known reference points.

554.4 Part 1: Circuit Setup

554.4.1 Wokwi Simulator

Use the embedded simulator below to build and test your sensor calibration circuit. Click “Start Simulation” after entering your code.

NoteSimulator Tips
  • The potentiometer simulates an “uncalibrated” sensor with offset and gain errors
  • Adjust the potentiometer during simulation to test different readings
  • Watch the Serial Monitor to compare raw vs calibrated values
  • LED brightness indicates calibration mode (blinking) vs normal mode (steady)

554.4.2 Component Connections

ESP32 Pin Component Connection Purpose
GPIO 34 Potentiometer Wiper (middle) Simulated sensor input
3.3V Potentiometer One outer pin Reference voltage
GND Potentiometer Other outer pin Ground reference
GPIO 2 LED Anode (long leg) Calibration status indicator
GND LED Cathode (via 220 ohm resistor) Complete LED circuit

554.4.3 Wiring Diagram

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#2C3E50', 'primaryTextColor': '#fff', 'primaryBorderColor': '#16A085', 'lineColor': '#16A085', 'secondaryColor': '#E67E22', 'tertiaryColor': '#fff'}}}%%
flowchart TB
    subgraph ESP32["ESP32 DevKit"]
        P34["GPIO 34<br/>(ADC Input)"]
        P2["GPIO 2<br/>(LED)"]
        V33["3.3V"]
        GND1["GND"]
    end

    subgraph POT["Potentiometer<br/>(Simulated Sensor)"]
        PT["Top Pin"]
        PW["Wiper<br/>(Output)"]
        PB["Bottom Pin"]
    end

    subgraph LED_CKT["Status LED"]
        LED["LED"]
        RES["220 ohm"]
    end

    V33 --> PT
    PW --> P34
    PB --> GND1
    P2 --> LED
    LED --> RES
    RES --> GND1

    style ESP32 fill:#2C3E50,stroke:#16A085,color:#fff
    style POT fill:#E67E22,stroke:#2C3E50,color:#fff
    style LED_CKT fill:#16A085,stroke:#2C3E50,color:#fff

Figure 554.2: Circuit schematic showing ESP32 connections to potentiometer (sensor) and calibration status LED

554.5 Part 2: Complete Calibration Code

Copy this code into the Wokwi editor. It demonstrates comprehensive sensor calibration with filtering.

/*
 * Sensor Calibration Workshop
 * Interactive Lab: Two-Point Calibration with Signal Conditioning
 *
 * This code demonstrates:
 * - Raw ADC reading and conversion
 * - Two-point calibration (offset + gain correction)
 * - Simple moving average filter for noise reduction
 * - Interactive calibration procedure
 *
 * Components:
 * - ESP32 DevKit
 * - Potentiometer on GPIO 34 (simulates uncalibrated sensor)
 * - LED on GPIO 2 (calibration status indicator)
 */

// ============ PIN DEFINITIONS ============
const int SENSOR_PIN = 34;    // Analog input (ADC1_CH6)
const int LED_PIN = 2;        // Built-in LED for status

// ============ ADC CONFIGURATION ============
const float ADC_MAX = 4095.0;        // 12-bit ADC resolution
const float VREF = 3.3;              // Reference voltage
const int FILTER_SIZE = 10;          // Moving average window size

// ============ CALIBRATION VARIABLES ============
float calOffset = 0.0;       // Offset correction (additive)
float calGain = 1.0;         // Gain correction (multiplicative)

// Reference values for two-point calibration
float lowRefActual = 10.0;   // Known low reference (e.g., 10%)
float highRefActual = 90.0;  // Known high reference (e.g., 90%)
float lowRefRaw = 0.0;       // Raw reading at low reference
float highRefRaw = 0.0;      // Raw reading at high reference

// ============ FILTER VARIABLES ============
float filterBuffer[FILTER_SIZE];
int filterIndex = 0;
bool filterFilled = false;

// ============ STATE MACHINE ============
enum CalibrationState {
    NORMAL_MODE,
    WAIT_LOW_POINT,
    CAPTURE_LOW_POINT,
    WAIT_HIGH_POINT,
    CAPTURE_HIGH_POINT,
    CALCULATE_COEFFICIENTS
};

CalibrationState currentState = NORMAL_MODE;
unsigned long lastPrintTime = 0;
const unsigned long PRINT_INTERVAL = 500;

// ============ FUNCTION DECLARATIONS ============
float readRawSensor();
float applyFilter(float newValue);
float applyCalibration(float rawValue);
void calculateCalibrationCoefficients();
void printCalibrationStatus();
void handleSerialCommands();
void blinkLED(int times, int delayMs);

void setup() {
    Serial.begin(115200);
    delay(1000);

    // Configure pins
    pinMode(LED_PIN, OUTPUT);
    analogReadResolution(12);  // 12-bit ADC (0-4095)

    // Initialize filter buffer
    for (int i = 0; i < FILTER_SIZE; i++) {
        filterBuffer[i] = 0.0;
    }

    // Welcome message
    Serial.println("\n========================================");
    Serial.println("   SENSOR CALIBRATION WORKSHOP");
    Serial.println("   Interactive Two-Point Calibration Lab");
    Serial.println("========================================\n");

    Serial.println("COMMANDS:");
    Serial.println("  'c' - Start calibration procedure");
    Serial.println("  'l' - Capture LOW reference point");
    Serial.println("  'h' - Capture HIGH reference point");
    Serial.println("  'r' - Reset calibration to defaults");
    Serial.println("  's' - Show current calibration status");
    Serial.println("\nTurn the potentiometer to simulate sensor readings.\n");

    // Initial LED flash to indicate startup
    blinkLED(3, 200);
    digitalWrite(LED_PIN, HIGH);  // LED on = normal mode
}

void loop() {
    // Handle serial commands for calibration
    handleSerialCommands();

    // Read and process sensor
    float rawPercent = readRawSensor();
    float filteredRaw = applyFilter(rawPercent);
    float calibratedValue = applyCalibration(filteredRaw);

    // Print values periodically
    if (millis() - lastPrintTime >= PRINT_INTERVAL) {
        lastPrintTime = millis();

        if (currentState == NORMAL_MODE) {
            Serial.print("RAW: ");
            Serial.print(rawPercent, 1);
            Serial.print("%  |  FILTERED: ");
            Serial.print(filteredRaw, 1);
            Serial.print("%  |  CALIBRATED: ");
            Serial.print(calibratedValue, 1);
            Serial.print("%  |  ERROR: ");
            Serial.print(abs(filteredRaw - calibratedValue), 2);
            Serial.println("%");
        }
    }

    // Update LED based on mode
    if (currentState != NORMAL_MODE) {
        // Blink LED during calibration
        digitalWrite(LED_PIN, (millis() / 250) % 2);
    } else {
        digitalWrite(LED_PIN, HIGH);  // Steady on in normal mode
    }

    delay(50);
}

// ============ SENSOR READING ============
float readRawSensor() {
    int rawADC = analogRead(SENSOR_PIN);
    float percent = (rawADC / ADC_MAX) * 100.0;
    return percent;
}

// ============ MOVING AVERAGE FILTER ============
float applyFilter(float newValue) {
    filterBuffer[filterIndex] = newValue;
    filterIndex = (filterIndex + 1) % FILTER_SIZE;

    if (filterIndex == 0) {
        filterFilled = true;
    }

    int count = filterFilled ? FILTER_SIZE : filterIndex;
    if (count == 0) return newValue;

    float sum = 0.0;
    for (int i = 0; i < count; i++) {
        sum += filterBuffer[i];
    }
    return sum / count;
}

// ============ CALIBRATION APPLICATION ============
float applyCalibration(float rawValue) {
    return (rawValue * calGain) + calOffset;
}

// ============ CALIBRATION PROCEDURE ============
void calculateCalibrationCoefficients() {
    if (abs(highRefRaw - lowRefRaw) < 0.001) {
        Serial.println("ERROR: Reference points too close together!");
        return;
    }

    calGain = (highRefActual - lowRefActual) / (highRefRaw - lowRefRaw);
    calOffset = lowRefActual - (lowRefRaw * calGain);

    Serial.println("\n========================================");
    Serial.println("   CALIBRATION COMPLETE!");
    Serial.println("========================================");
    Serial.print("   Gain (slope):  ");
    Serial.println(calGain, 4);
    Serial.print("   Offset (intercept): ");
    Serial.println(calOffset, 4);
    Serial.println("----------------------------------------");
    Serial.println("   Calibration Equation:");
    Serial.print("   Calibrated = Raw * ");
    Serial.print(calGain, 4);
    Serial.print(" + ");
    Serial.println(calOffset, 4);
    Serial.println("========================================\n");

    // Verification
    float verifyLow = applyCalibration(lowRefRaw);
    float verifyHigh = applyCalibration(highRefRaw);

    Serial.println("VERIFICATION:");
    Serial.print("   Low point:  Raw=");
    Serial.print(lowRefRaw, 1);
    Serial.print("% -> Calibrated=");
    Serial.print(verifyLow, 1);
    Serial.print("% (Expected: ");
    Serial.print(lowRefActual, 1);
    Serial.println("%)");

    Serial.print("   High point: Raw=");
    Serial.print(highRefRaw, 1);
    Serial.print("% -> Calibrated=");
    Serial.print(verifyHigh, 1);
    Serial.print("% (Expected: ");
    Serial.print(highRefActual, 1);
    Serial.println("%)\n");

    currentState = NORMAL_MODE;
    blinkLED(5, 100);  // Success indication
}

// ============ SERIAL COMMAND HANDLER ============
void handleSerialCommands() {
    if (Serial.available() > 0) {
        char cmd = Serial.read();

        switch (cmd) {
            case 'c':
            case 'C':
                Serial.println("\n========================================");
                Serial.println("   CALIBRATION PROCEDURE STARTED");
                Serial.println("========================================");
                Serial.println("\nStep 1: Set the potentiometer to the LOW reference point");
                Serial.print("        (This represents ");
                Serial.print(lowRefActual, 0);
                Serial.println("% actual value)");
                Serial.println("        Press 'l' when ready to capture.\n");
                currentState = WAIT_LOW_POINT;
                break;

            case 'l':
            case 'L':
                if (currentState == WAIT_LOW_POINT) {
                    lowRefRaw = applyFilter(readRawSensor());
                    Serial.print("\nLOW POINT CAPTURED: Raw = ");
                    Serial.print(lowRefRaw, 1);
                    Serial.print("% (Actual = ");
                    Serial.print(lowRefActual, 0);
                    Serial.println("%)\n");

                    Serial.println("Step 2: Set the potentiometer to the HIGH reference point");
                    Serial.print("        (This represents ");
                    Serial.print(highRefActual, 0);
                    Serial.println("% actual value)");
                    Serial.println("        Press 'h' when ready to capture.\n");
                    currentState = WAIT_HIGH_POINT;
                } else {
                    Serial.println("Press 'c' first to start calibration!");
                }
                break;

            case 'h':
            case 'H':
                if (currentState == WAIT_HIGH_POINT) {
                    highRefRaw = applyFilter(readRawSensor());
                    Serial.print("\nHIGH POINT CAPTURED: Raw = ");
                    Serial.print(highRefRaw, 1);
                    Serial.print("% (Actual = ");
                    Serial.print(highRefActual, 0);
                    Serial.println("%)\n");

                    Serial.println("Calculating calibration coefficients...\n");
                    calculateCalibrationCoefficients();
                } else {
                    Serial.println("Capture low point first with 'l'!");
                }
                break;

            case 'r':
            case 'R':
                calOffset = 0.0;
                calGain = 1.0;
                lowRefRaw = 0.0;
                highRefRaw = 0.0;
                currentState = NORMAL_MODE;
                Serial.println("\nCalibration RESET to defaults (gain=1.0, offset=0.0)\n");
                break;

            case 's':
            case 'S':
                printCalibrationStatus();
                break;
        }
    }
}

// ============ STATUS DISPLAY ============
void printCalibrationStatus() {
    Serial.println("\n========================================");
    Serial.println("   CURRENT CALIBRATION STATUS");
    Serial.println("========================================");
    Serial.print("   Gain:   ");
    Serial.println(calGain, 4);
    Serial.print("   Offset: ");
    Serial.println(calOffset, 4);
    Serial.println("----------------------------------------");
    Serial.print("   Low Ref:  Raw=");
    Serial.print(lowRefRaw, 1);
    Serial.print("% -> Actual=");
    Serial.print(lowRefActual, 0);
    Serial.println("%");
    Serial.print("   High Ref: Raw=");
    Serial.print(highRefRaw, 1);
    Serial.print("% -> Actual=");
    Serial.print(highRefActual, 0);
    Serial.println("%");
    Serial.println("========================================\n");
}

// ============ LED INDICATOR ============
void blinkLED(int times, int delayMs) {
    for (int i = 0; i < times; i++) {
        digitalWrite(LED_PIN, HIGH);
        delay(delayMs);
        digitalWrite(LED_PIN, LOW);
        delay(delayMs);
    }
}

554.6 Part 3: Step-by-Step Calibration Procedure

Follow these steps to perform two-point calibration:

ImportantUnderstanding the Simulation

In this lab, the potentiometer position represents the sensor reading. We are pretending that when the potentiometer is at ~10% position, the “true” value is 10%, and at ~90% position, the “true” value is 90%. The calibration corrects any discrepancies.

554.6.1 Step 1: Run the Simulation

  1. Click Start in Wokwi to run the simulation
  2. Open the Serial Monitor (bottom panel)
  3. Observe the output showing RAW, FILTERED, and CALIBRATED values

554.6.2 Step 2: Observe Raw Readings

  1. Turn the potentiometer to different positions
  2. Notice the RAW values in the Serial Monitor
  3. Before calibration, RAW and CALIBRATED values are identical

554.6.3 Step 3: Start Calibration

  1. Type c in the Serial Monitor and press Enter
  2. You will see instructions for the calibration procedure

554.6.4 Step 4: Capture Low Reference Point

  1. Turn the potentiometer to approximately 10% position
  2. Let the reading stabilize for 2-3 seconds
  3. Type l (lowercase L) and press Enter
  4. The system captures this as the “low reference” point

554.6.5 Step 5: Capture High Reference Point

  1. Turn the potentiometer to approximately 90% position
  2. Let the reading stabilize for 2-3 seconds
  3. Type h and press Enter
  4. The system captures this as the “high reference” point

554.6.6 Step 6: Verify Calibration

  1. The system calculates and displays calibration coefficients
  2. Move the potentiometer through its range
  3. Observe how CALIBRATED values now differ from RAW values
  4. The ERROR column shows the correction being applied

554.7 Part 4: Understanding the Math

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#2C3E50', 'primaryTextColor': '#fff', 'primaryBorderColor': '#16A085', 'lineColor': '#16A085', 'secondaryColor': '#E67E22', 'tertiaryColor': '#fff'}}}%%
flowchart TB
    subgraph INPUT["Reference Points"]
        LP["Low Point<br/>(rawLow, actualLow)<br/>e.g., (15%, 10%)"]
        HP["High Point<br/>(rawHigh, actualHigh)<br/>e.g., (85%, 90%)"]
    end

    subgraph CALC["Calculate Coefficients"]
        G["Gain = (actualHigh - actualLow) / (rawHigh - rawLow)<br/>= (90 - 10) / (85 - 15) = 1.143"]
        O["Offset = actualLow - (rawLow * gain)<br/>= 10 - (15 * 1.143) = -7.14"]
    end

    subgraph APPLY["Apply Calibration"]
        F["Calibrated = Raw * Gain + Offset<br/>e.g., 50% raw -> 50 * 1.143 + (-7.14) = 50%"]
    end

    LP --> G
    HP --> G
    G --> O
    O --> F

    style INPUT fill:#2C3E50,stroke:#16A085,color:#fff
    style CALC fill:#E67E22,stroke:#2C3E50,color:#fff
    style APPLY fill:#16A085,stroke:#2C3E50,color:#fff

Figure 554.3: Two-point calibration calculates gain (slope) and offset (intercept) from two known reference points

The Two-Point Calibration Formula:

Given two reference points:

  • Low point: (raw_low, actual_low) - e.g., sensor reads 15% when true value is 10%
  • High point: (raw_high, actual_high) - e.g., sensor reads 85% when true value is 90%

Calculate:

  1. Gain (slope): gain = (actual_high - actual_low) / (raw_high - raw_low)
  2. Offset (intercept): offset = actual_low - (raw_low * gain)

Apply calibration:

calibrated_value = raw_value * gain + offset

554.8 Part 5: Challenge Exercises

Goal: Extend the calibration to use three reference points for improved accuracy across the range.

Tasks:

  1. Add a third reference point at 50% (midpoint)
  2. Capture three points: low (10%), mid (50%), high (90%)
  3. Use piecewise linear interpolation:
    • For values < 50%: use low-to-mid segment
    • For values >= 50%: use mid-to-high segment
  4. Compare accuracy against two-point calibration

Hint: Store two sets of gain/offset coefficients and select based on input value.

Goal: Persist calibration coefficients so they survive power cycles.

Tasks:

  1. Add EEPROM library and save calibration after calculation
  2. Load calibration automatically at startup
  3. Add validity check (magic number) to detect uncalibrated state
  4. Add a ‘w’ command to write calibration and ‘e’ command to erase
#include <EEPROM.h>

// EEPROM addresses
const int EEPROM_MAGIC_ADDR = 0;
const int EEPROM_GAIN_ADDR = 4;
const int EEPROM_OFFSET_ADDR = 8;
const int EEPROM_MAGIC_VALUE = 0xCAFE;

void saveCalibrationToEEPROM() {
    EEPROM.begin(64);
    EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC_VALUE);
    EEPROM.put(EEPROM_GAIN_ADDR, calGain);
    EEPROM.put(EEPROM_OFFSET_ADDR, calOffset);
    EEPROM.commit();
    Serial.println("Calibration saved to EEPROM!");
}

void loadCalibrationFromEEPROM() {
    EEPROM.begin(64);
    int magic;
    EEPROM.get(EEPROM_MAGIC_ADDR, magic);

    if (magic == EEPROM_MAGIC_VALUE) {
        EEPROM.get(EEPROM_GAIN_ADDR, calGain);
        EEPROM.get(EEPROM_OFFSET_ADDR, calOffset);
        Serial.println("Calibration loaded from EEPROM");
    } else {
        Serial.println("No valid calibration found, using defaults");
        calGain = 1.0;
        calOffset = 0.0;
    }
}

Goal: Implement automatic baseline drift correction for long-term deployments.

Background: Sensors drift over time due to aging, temperature changes, and contamination. Automatic Baseline Correction (ABC) can compensate by assuming the sensor occasionally sees a known reference (e.g., CO2 sensors assume 400ppm outdoor air).

Tasks:

  1. Track the minimum reading over a 24-hour window
  2. Assume this minimum represents the “baseline” reference value
  3. Automatically adjust offset to correct drift
  4. Add drift alarm if correction exceeds threshold

554.9 Key Calibration Concepts Summary

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#2C3E50', 'primaryTextColor': '#fff', 'primaryBorderColor': '#16A085', 'lineColor': '#16A085', 'secondaryColor': '#E67E22', 'tertiaryColor': '#fff'}}}%%
mindmap
    root((Sensor<br/>Calibration))
        Error Types
            Offset Error
                Zero point shift
                Additive correction
            Gain Error
                Sensitivity change
                Multiplicative correction
            Non-linearity
                Polynomial fit
                Lookup tables
        Calibration Methods
            Single-Point
                Offset only
                Quick but limited
            Two-Point
                Offset + Gain
                Most common
            Multi-Point
                Non-linear sensors
                Piecewise linear
        Signal Conditioning
            Filtering
                Moving average
                Median filter
            Noise Reduction
                Oversampling
                RC low-pass
        Production Considerations
            EEPROM Storage
            Field Recalibration
            Drift Monitoring

Figure 554.4: Mind map of key sensor calibration concepts covered in this lab
Concept Description When to Use
Offset Error Sensor reads non-zero when true value is zero Always needs correction
Gain Error Sensor’s sensitivity differs from specification When readings scale incorrectly
Two-Point Calibration Uses two reference points to calculate offset and gain Linear sensors (most common)
Multi-Point Calibration Uses 3+ reference points with interpolation Non-linear sensors (thermistors, pH)
Moving Average Filter Averages N recent readings to reduce noise Noisy environments, slow-changing signals
EEPROM Storage Persists calibration across power cycles Production deployments
TipBest Practices for Sensor Calibration
  1. Use reference standards that bracket your expected measurement range
  2. Allow sensor warm-up time before calibration (typically 5-30 minutes)
  3. Document environmental conditions during calibration (temperature, humidity)
  4. Recalibrate periodically based on manufacturer recommendations
  5. Store calibration metadata including date, conditions, and number of points
  6. Validate calibration by checking known reference values after applying coefficients

554.10 Summary

This lab covered hands-on sensor calibration techniques:

  • Two-Point Calibration: Offset and gain correction using reference points
  • Moving Average Filter: Noise reduction through sample averaging
  • Interactive Procedure: Command-driven calibration workflow
  • Calibration Math: Gain and offset calculation from reference measurements
  • Production Considerations: EEPROM storage, drift compensation

554.11 What’s Next

Return to the Sensor Interfacing Overview for a summary of all sensor interfacing topics, or explore Multi-Sensor Fusion for advanced sensor combination techniques.