Skip to content
Fondazione ITS INCOM

Examples · Internet of Things

Calibrate a cheap sensor

Twelve paired readings from a cheap DHT22 and a good thermometer go in. Your code fits a straight-line calibration by hand and measures the error before and after; a model writes the MicroPython function for the ESP32, and your code tests it on the same pairs, at the edges and with fake sensors that fail like the real one.

Intermediate · 30 min

The idea

A cheap temperature sensor is often consistent but wrong: ours reads a bit high when it is cold and a bit low when it is warm, and it makes the same error every time. Wrong in the same way every time can be corrected.

Put the cheap sensor next to a good thermometer for an afternoon, write down both readings in a few places, and find the straight line that turns one into the other:

reference = a × cheap + b

Then:

  1. your code finds a and b with least squares, in plain Python, and measures the error before and after;
  2. a model writes the MicroPython function that applies them on the ESP32;
  3. your code tests that function in ordinary Python, before it goes anywhere near the board.

What you need

A key and the openai library, as in Your first API call in Python. No numpy: the fit is six lines of sums. For the last part, an ESP32 with MicroPython and a DHT22 on GPIO 4.

The readings

Save them as pairs.csv. In each place the two sensors sat side by side for ten minutes before we wrote anything down; for the warm water, both were in a sealed plastic bag.

place,cheap,reference
fridge,5.4,4.2
fridge door open,8.8,7.9
cellar,13.4,12.6
corridor at 7:00,16.5,16.3
lab 2,20.0,19.8
classroom 12,21.4,21.4
classroom 12 at noon,22.9,23.1
server room,25.5,25.7
window sill,28.2,28.9
car at noon,32.6,33.4
warm water,35.6,36.8
warm water,38.2,39.6

The program

Save it as calibrate.py, next to pairs.csv:

import csv
import os
import re
import sys

from openai import OpenAI

client = OpenAI(
    base_url="https://api.ai.itsincom.org/v1",
    api_key=os.environ["API_KEY"],
)
MODEL = sys.argv[1] if len(sys.argv) > 1 else "qwen3-coder-30b"

with open("pairs.csv", newline="") as f:
    pairs = [(float(row["cheap"]), float(row["reference"])) for row in csv.DictReader(f)]

# 1. Least squares by hand: the line reference = a * cheap + b with the smallest squared errors
n = len(pairs)
sx = sum(x for x, y in pairs)
sy = sum(y for x, y in pairs)
sxx = sum(x * x for x, y in pairs)
sxy = sum(x * y for x, y in pairs)
a = round((n * sxy - sx * sy) / (n * sxx - sx * sx), 4)
b = round((sy - a * sx) / n, 4)
low = min(x for x, y in pairs)
high = max(x for x, y in pairs)


def errors(correct):
    diffs = [abs(correct(x) - y) for x, y in pairs]
    return sum(diffs) / n, max(diffs)


print(f"reference = {a} * cheap {b:+}   ({n} pairs, cheap from {low} to {high} °C)")
print("before: mean error {:.2f} °C, worst {:.2f} °C".format(*errors(lambda x: x)))
print("after:  mean error {:.2f} °C, worst {:.2f} °C".format(*errors(lambda x: a * x + b)))

# 2. The model writes the MicroPython functions
answer = client.chat.completions.create(
    model=MODEL,
    temperature=0,
    messages=[{"role": "user", "content": f"""Write two MicroPython functions for an ESP32 with a DHT22.
1. calibrate(raw): raw is a temperature in °C from the DHT22. Return a * raw + b, with a = {a} and b = {b}.
   The calibration was measured only for raw from {low} to {high}, both included: outside, return None.
2. read_calibrated(sensor): sensor is a dht.DHT22 object. Call sensor.measure(), then return the calibrated
   sensor.temperature(). If the reading fails, return None.
No imports and no code that runs at import time: only the two functions, in one ```python block."""}],
)
text = answer.choices[0].message.content
code = re.search(r"```(?:python)?\n(.*?)```", text, re.S)
with open("calibration.py", "w") as f:
    f.write(code.group(1) if code else text)
print(f"\ncalibration.py, by {MODEL}:\n")
print(open("calibration.py").read())


# 3. Your code tests the functions in CPython: on the pairs, at the edges, and with fake sensors
from calibration import calibrate, read_calibrated  # the file the model just wrote


class FakeDHT22:
    def __init__(self, value, error=None):
        self.value = value
        self.error = error

    def measure(self):
        if self.error:
            raise self.error

    def temperature(self):
        return self.value

    def __repr__(self):
        return f"sensor reading {self.value}" if self.error is None else f"sensor raising {self.error!r}"


tests = [(calibrate, x, a * x + b) for x, y in pairs]
tests += [(calibrate, round(low - 0.1, 1), None), (calibrate, round(high + 0.1, 1), None)]
tests += [(read_calibrated, FakeDHT22(21.4), a * 21.4 + b),
          # The two ways MicroPython's dht.py fails: a timeout, and a bad checksum
          (read_calibrated, FakeDHT22(21.4, OSError(110)), None),
          (read_calibrated, FakeDHT22(21.4, Exception("checksum error")), None)]
failed = 0
for function, given, expected in tests:
    try:
        got = function(given)
    except Exception as error:
        got = f"{type(error).__name__}: {error}"
    ok = got is None if expected is None else isinstance(got, float) and abs(got - expected) < 0.001
    if not ok:
        failed += 1
        show = [f"{v:.3f}" if isinstance(v, float) else v for v in (got, expected)]
        print(f"FAIL  {function.__name__}({given}) = {show[0]}, expected {show[1]}")
print(f"{len(tests) - failed} of {len(tests)} tests passed")

Run it with python3 calibrate.py. It takes a few seconds.

What you should see

reference = 1.0791 * cheap -1.6699   (12 pairs, cheap from 5.4 to 38.2 °C)
before: mean error 0.65 °C, worst 1.40 °C
after:  mean error 0.10 °C, worst 0.19 °C

calibration.py, by qwen3-coder-30b:

def calibrate(raw):
    a = 1.0791
    b = -1.6699
    if raw < 5.4 or raw > 38.2:
        return None
    return a * raw + b

def read_calibrated(sensor):
    try:
        sensor.measure()
        return calibrate(sensor.temperature())
    except:
        return None

17 of 17 tests passed

From an error of up to 1.4 °C to one under 0.2 °C, with two numbers.

The fit, in six lines

The four sums give the line that makes the squared vertical distances from the twelve points as small as possible:

a = (n·Σxy − Σx·Σy) / (n·Σx² − (Σx)²)        b = (Σy − a·Σx) / n

The fit goes from cheap to reference, because on the board you know the cheap reading and want the true one. a and b are rounded to four decimals, and the same rounded numbers go into the request and into the tests. So the tests ask one clear question: did the model use our coefficients?

What the tests caught

The coefficients. qwen3-coder-30b (five runs), qwen3.8-27b (four), gemma-4-26b and ministral-3-14b (two each) all used 1.0791 and -1.6699 exactly, and passed 17 of 17. apertus-70b-instruct wrote the same code in all three of its runs:

def calibrate(raw):
    if 5.4 <= raw <= 38.2:
        return raw + 1.0791 - 1.6699
    return None

def read_calibrated(sensor):
    try:
        return calibrate(sensor.measure().temperature())
    except:
        return None

It adds a instead of multiplying by it. At 21.4 it gives 20.809 instead of 21.423: a believable temperature, 0.6 degrees off, and the tests print a FAIL for each of the twelve pairs. Its read_calibrated is worse: in MicroPython measure() returns None, so .temperature() raises an AttributeError, and the bare except: turns it into None. On a board that looks like a loose wire: a sensor that never gives a reading. The test with a working fake sensor shows that the bug is in the code.

The checksum. The fake sensors fail the way the real driver does. In MicroPython's dht.py, a sensor that does not answer raises OSError, but a reading with a bad checksum raises a plain Exception:

        if (buf[0] + buf[1] + buf[2] + buf[3]) & 0xFF != buf[4]:
            raise Exception("checksum error")

An earlier version of our request said “If measure() raises OSError, return None”. qwen3-coder-30b and qwen3.8-27b, two runs each, did exactly that, except OSError:, and the checksum test failed: 16 of 17. The model did what we asked; we had asked the wrong thing. With “If the reading fails”, every run of every model caught both.

What the tests do not see

qwen3-coder-30b, ministral-3-14b and Apertus wrote a bare except: in every run; Qwen3.8 and Gemma wrote except Exception:. The tests cannot tell the two apart. The difference is Ctrl-C: in MicroPython, as in Python, it raises KeyboardInterrupt, which a bare except: catches and except Exception: lets through. Press Ctrl-C while measure() is running, and a bare except: returns None and your loop carries on. It is rare, because the loop below spends most of its time in time.sleep(2), not in measure(); it is confusing when it happens. Tests check what you thought of; reading checks the rest.

On the board

We tested the functions in CPython only; we did not flash a board. To try them, copy calibration.py to the ESP32 with mpremote, mpremote cp calibration.py :, and save this as main.py:

import time

import dht
import machine

from calibration import read_calibrated

sensor = dht.DHT22(machine.Pin(4))
while True:
    print(read_calibrated(sensor))
    time.sleep(2)  # the DHT22 needs about 2 seconds between readings

Run it with mpremote run main.py. None means a failed reading, or a temperature outside 5.4 to 38.2 °C.

Good to know

Exercise

All examples