Examples · Internet of Things
Spot a drifting sensor
Four weeks of daily means from two CO2 sensors side by side go in, and a maintenance note comes out. Python computes the difference, its trend and which sensor is wrong; a model writes the note, and your code checks the sensor and the drift it names. Qwen3.8 and Gemma compared.
Intermediate · 35 min
The idea
A CO2 sensor does not break with a bang. It can drift: a few ppm a week, every week, and after a few months it says the air is fine when it is not.
One sensor alone cannot tell you that it drifts. Two sensors side by side can: they should read the same, and if the gap between them grows day after day, one of them is drifting. Which one? The empty school at the weekend tells you. So the work is split into three parts:
- your code computes the daily difference, its trend in ppm a week, and which sensor is wrong;
- a model writes the maintenance note for the technician, starting with a fixed sentence: “Sensor X drifts by N ppm per week.”;
- your code checks that sentence: the right sensor, and the drift within rounding.
What you need
A key and the openai library, as in Your first API call in Python.
The readings
Two CO2 sensors, A and B, hang next to each other on the same wall of Aula 12. Every evening the school's server saves the mean of each one from 08:00 to 18:00. Save four weeks of them as daily.csv:
date,sensor_a,sensor_b
2026-09-07,878,880
2026-09-08,789,795
2026-09-09,826,830
2026-09-10,905,911
2026-09-11,789,794
2026-09-12,424,437
2026-09-13,417,424
2026-09-14,852,858
2026-09-15,766,775
2026-09-16,772,785
2026-09-17,773,786
2026-09-18,823,836
2026-09-19,413,430
2026-09-20,415,433
2026-09-21,889,908
2026-09-22,748,770
2026-09-23,983,1003
2026-09-24,829,852
2026-09-25,762,788
2026-09-26,406,430
2026-09-27,399,424
2026-09-28,860,889
2026-09-29,829,855
2026-09-30,865,899
2026-10-01,736,768
2026-10-02,883,919
2026-10-03,391,426
2026-10-04,400,438
On school days the two are close, and the gap grows slowly. Now look at the weekends, 12–13 September and 3–4 October: who is wrong?
The program
Save it as drift.py, next to the CSV:
import csv
import datetime
import json
import os
import re
from statistics import linear_regression, mean
from openai import OpenAI
client = OpenAI(
base_url="https://api.ai.itsincom.org/v1",
api_key=os.environ["API_KEY"],
)
MODEL = "qwen3.8-27b"
OUTDOOR = 420 # ppm: an empty building cannot be much below the air outside
with open("daily.csv", newline="") as f:
days = list(csv.DictReader(f))
for d in days:
d["a"], d["b"] = int(d["sensor_a"]), int(d["sensor_b"])
d["weekend"] = datetime.date.fromisoformat(d["date"]).weekday() >= 5
# 1. Your code computes: the daily difference, its trend, and the empty-school weekends
difference = [d["a"] - d["b"] for d in days]
per_day, _ = linear_regression(range(len(days)), difference)
per_week = per_day * 7
weekends = [d for d in days if d["weekend"]]
last = weekends[-2:]
far_a = abs(mean(d["a"] for d in last) - OUTDOOR)
far_b = abs(mean(d["b"] for d in last) - OUTDOOR)
suspect = "A" if far_a > far_b else "B" # the one that is farther from the outdoor air, last weekend
summary = {
"room": "Aula 12, two CO2 sensors side by side",
"values": "daily means from 08:00 to 18:00, in ppm",
"outdoor_ppm": OUTDOOR,
"days": [{"date": d["date"], "A": d["a"], "B": d["b"], "A_minus_B": d["a"] - d["b"]} for d in days],
"trend_of_A_minus_B_ppm_per_week": round(per_week, 1),
"mean_A_minus_B_first_week": round(mean(difference[:7]), 1),
"mean_A_minus_B_last_week": round(mean(difference[-7:]), 1),
"weekends_empty_school": [{"date": d["date"], "A": d["a"], "B": d["b"]} for d in weekends],
}
print(f"Trend of A - B: {per_week:+.2f} ppm a week. Suspect, from the weekends: sensor {suspect}.")
# 2. The model writes the maintenance note
answer = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "You write maintenance notes for the technician of a school. Two CO2 "
"sensors hang side by side, so they should read the same; the data "
"shows they do not. Decide which sensor is drifting, and say why. "
"Start the note with exactly this sentence: «Sensor X drifts by N ppm "
"per week.», with X the sensor and N the drift, as a positive number. "
"Then 3 to 5 short sentences in plain English: the evidence, and what "
"the technician should do."},
{"role": "user", "content": json.dumps(summary)},
],
)
if answer.choices[0].finish_reason != "stop":
raise SystemExit("The answer was cut off.")
note = answer.choices[0].message.content.strip()
print()
print(note)
print()
# 3. Your code finds the sentence, and checks it against its own numbers
found = re.search(r"Sensor ([AB]) drifts by (\d+(?:\.\d+)?) ppm per week", note)
if not found:
print("Check: the sentence we asked for is not in the note.")
else:
sensor, claim = found.group(1), found.group(2)
decimals = len(claim.split(".")[1]) if "." in claim else 0
print(f"Check, sensor: {sensor}, code says {suspect}:", "OK" if sensor == suspect else "WRONG")
print(f"Check, drift: {claim}, code says {abs(per_week):.2f}:",
"OK" if round(abs(per_week), decimals) == float(claim) else "WRONG")
Run it with python3 drift.py. With qwen3.8-27b it takes 20 to 40 seconds or so: the model thinks before it writes.
Within rounding means: round the code's number the way the model rounded its own. The code says 9.06. “9.1” is right to one decimal, “9” to none, “9.2” is wrong.
What you should see
Trend of A - B: -9.06 ppm a week. Suspect, from the weekends: sensor A.
Sensor A drifts by 9.1 ppm per week. On empty-school days the room should sit at the outdoor level of roughly 420 ppm; sensor B stays within 4–18 ppm of that value the entire four weeks, whereas sensor A slides from 424 down to 391. The A-minus-B gap widens steadily from about –6 ppm in week one to –35 ppm by week four, a rate of roughly 9 ppm per week. This pattern points to a slow downward drift in sensor A, not a fault in B. Recalibrate sensor A against a reference analyser, and if the drift reappears within a few days, replace the unit.
Check, sensor: A, code says A: OK
Check, drift: 9.1, code says 9.06: OK
Which sensor is wrong
The difference A − B goes from −2 to −38 ppm in four weeks. That says the two sensors disagree more and more. It does not say who is right: A could be drifting down, or B up.
The weekends decide. The school is empty: nobody breathes in Aula 12, and the air inside slowly becomes the air outside, about 420 ppm. Sensor B reads 424 to 438 on all eight weekend days. Sensor A goes from 424 down to 391: less CO2 than the air outside, in an empty room. That cannot be true, so sensor A is the one drifting.
Your code does the same with one line: the sensor that was farther from 420 ppm on the last weekend is the suspect.
Two models
We ran the program eight times with qwen3.8-27b, and six times with MODEL = "gemma-4-26b". Five of the Qwen runs and three of the Gemma runs were with the first version of the check (see “The format” below); the notes are counted here as they were written:
qwen3.8-27b |
gemma-4-26b |
|
|---|---|---|
| names sensor A, the right one | 8 out of 8 | 0 out of 6 |
| drift within rounding | 8 out of 8 | 6 out of 6 |
| time for one note | 20 to 42 s | 1.3 to 1.9 s |
Both copied the drift right: the code had already computed it. The difference is in the reasoning. Gemma named sensor B every time, and its evidence was the opposite of the data:
Sensor B drifts by 9.1 ppm per week. The difference between the two sensors is increasing steadily over time. While Sensor A stays close to the outdoor levels during weekends, Sensor B shows consistently higher readings. Please recalibrate Sensor B or replace it.
Check, sensor: B, code says A: WRONG
Check, drift: 9.1, code says 9.06: OK
We tried to help Gemma, three runs each time: with the difference from the outdoor air written next to each weekend value; with a sentence in the system message, “At weekends the school is empty: a correct sensor then reads close to the outdoor air, and never much below it”; and with the fixed sentence at the end of the note instead of the start. It named sensor B in all nine runs. For a note in seconds, Gemma is fine; for a decision that needs reading numbers, use a model that reasons first, and let your code check its decision.
What the check cannot see
The check reads one sentence. The rest of the note is for a person to read:
- Numbers in the evidence. In the note above, “–35 ppm by week four” is not what the code computed: the mean of the last week is −32.9. −35 is the difference of one day, 3 October.
- Invented details. In another run Qwen explained the drift with “a slow downward bias in the electrochemical cell”. Most classroom CO2 sensors measure with infrared light (NDIR), and nothing in the data says what is inside the sensor.
- The format. In our first version the program used
re.match, which looks only at the very start of the note. In one run out of five Qwen also copied the quotation marks of the system message, so the note began«Sensor A drifts by 9.1 ppm per week.», and the check refused a right answer.re.searchfinds the sentence anywhere.
Good to know
- The trend uses all 28 days. One noisy day changes the first or the last value a lot, and the straight line very little.
- Many CO2 sensors correct themselves. They take the lowest value they have seen over a set period, often about a week, as fresh air. In a room that is never aired, that is not true, and the correction itself can cause a drift. Read the datasheet of yours.
- The fix. Put the sensor in fresh air, outside or at an open window, and calibrate it there to the outdoor value. Then check it again after a week.
- Two sensors are a test, not a fix. When they disagree you know that one is wrong. A third sensor, or the empty weekend, tells you which one.
Exercise
- Swap the names in the first line of the CSV,
date,sensor_b,sensor_a: now B is the one drifting. Does the code still find the right suspect? Does Qwen? - Ask also for the date when the difference will reach 50 ppm. Compute it in Python first, then check the model's date against yours.
- Move the suspect rule into a function, and test it with
pyteston three small tables: A drifting, B drifting, nothing drifting. What should it say when nothing drifts? - Use real sensors: two SCD41 on an ESP32, side by side for a week. Is the difference flat? The two have the same I2C address, so give each one its own I2C bus.
