Examples · Internet of Things
Anomalies in sensor readings
A CSV from a classroom sensor goes in, and JSON comes out with the suspicious rows and a reason for each. The schema is built from your own data, so the model can name only rows that exist; your code puts the real values next to each reason, and two rules in Python check what is certain.
Intermediate · 40 min
The idea
A sensor that sends a reading every 10 minutes sends 144 a day. Most of them are boring, and a few are wrong: a spike, a sensor that froze, a value that cannot exist, a hole in the data.
Some of these are easy to write as a rule: humidity cannot go over 100 %. Others are patterns, and a rule for them takes some thinking. A model reads the table the way a person does, and says what looks wrong and why. Here they work together:
- the model lists the suspicious rows, as JSON, with a reason for each;
- your code puts the real values next to every reason, so you can check it;
- two rules check what is certain, the same way every time.
What you need
A key and the openai library, as in Your first API call in Python.
The readings
Save them as readings.csv. Four problems are hidden in there: can you find them before the model does?
time,temperature,humidity
2026-10-06 08:00,19.8,48.2
2026-10-06 08:10,19.9,48.0
2026-10-06 08:20,20.1,48.5
2026-10-06 08:30,20.6,49.8
2026-10-06 08:40,21.2,51.0
2026-10-06 08:50,21.7,51.9
2026-10-06 09:00,22.1,52.6
2026-10-06 09:10,22.4,53.1
2026-10-06 09:20,47.9,53.4
2026-10-06 09:30,22.8,53.6
2026-10-06 09:40,23.0,53.9
2026-10-06 09:50,23.1,54.2
2026-10-06 10:00,23.3,54.4
2026-10-06 10:10,23.3,54.4
2026-10-06 10:20,23.3,54.4
2026-10-06 10:30,23.3,54.4
2026-10-06 10:40,23.3,54.4
2026-10-06 10:50,23.9,55.3
2026-10-06 11:00,22.0,50.9
2026-10-06 11:10,21.1,48.0
2026-10-06 11:20,21.0,104.0
2026-10-06 11:30,21.4,48.9
2026-10-06 12:00,22.3,50.2
2026-10-06 12:10,22.5,50.6
The program
Save it as check_readings.py, next to the CSV:
import csv
import json
import os
from datetime import datetime, timedelta
from openai import OpenAI
client = OpenAI(
base_url="https://api.ai.itsincom.org/v1",
api_key=os.environ["API_KEY"],
)
with open("readings.csv", newline="") as f:
text = f.read()
rows = list(csv.DictReader(text.splitlines()))
by_time = {row["time"]: row for row in rows}
schema = {
"type": "object",
"properties": {
"suspicious": {
"type": "array",
"items": {
"type": "object",
"properties": {
# The times of the file are the only answers allowed: the model cannot name a row
# that does not exist, or write «10:00 to 10:40» where your code expects one time.
"time": {"type": "string", "enum": list(by_time)},
"problem": {"type": "string", "enum": ["spike", "stuck", "impossible", "gap", "other"]},
"reason": {"type": "string"},
},
"required": ["time", "problem", "reason"],
},
},
},
"required": ["suspicious"],
}
answer = client.chat.completions.create(
model="gemma-4-26b",
temperature=0,
messages=[
{"role": "system", "content": "You check the readings of a classroom sensor: temperature in °C and "
"humidity in %, one row every 10 minutes. List the suspicious rows: a value "
"that jumps and comes straight back (spike), values that do not change at "
"all for several readings (stuck), values that are physically impossible, "
"and missing readings (gap). For a group of rows, give the first one; for "
"missing readings, the row after them. In the reason, say what is wrong, "
"with the values."},
{"role": "user", "content": text},
],
response_format={"type": "json_schema", "json_schema": {"name": "anomalies", "schema": schema}},
)
# "length" means the answer was cut off: its JSON is not complete.
if answer.choices[0].finish_reason != "stop":
raise SystemExit("The answer was cut off: send fewer rows.")
suspicious = json.loads(answer.choices[0].message.content)["suspicious"]
print("The model:")
for item in suspicious:
row = by_time[item["time"]]
# The real values next to the reason: the schema checks the time, only you can check the reason.
print(f" {item['time']} {row['temperature']:>5} °C {row['humidity']:>6} % "
f"{item['problem']}: {item['reason']}")
# What is certain needs no model: two rules that give the same answer every time.
print("The rules:")
previous = None
for row in rows:
now = datetime.strptime(row["time"], "%Y-%m-%d %H:%M")
if not 0 <= float(row["humidity"]) <= 100:
print(f" {row['time']} humidity {row['humidity']} % is impossible")
if previous and now - previous > timedelta(minutes=10):
minutes = int((now - previous).total_seconds() // 60)
print(f" {row['time']} {minutes} minutes since the previous reading")
previous = now
Run it with python3 check_readings.py. It takes a few seconds.
What you should see
The model:
2026-10-06 09:20 47.9 °C 53.4 % spike: temperature jumped to 47.9 and returned to 22.8
2026-10-06 10:00 23.3 °C 54.4 % stuck: temperature and humidity remained at 23.3 and 54.4 for several readings
2026-10-06 11:20 21.0 °C 104.0 % impossible: humidity value of 104.0 is physically impossible
2026-10-06 11:30 21.4 °C 48.9 % gap: missing reading between 11:30 and 12:00
The rules:
2026-10-06 11:20 humidity 104.0 % is impossible
2026-10-06 12:00 30 minutes since the previous reading
- The spike at 09:20: 47.9 °C between 22.4 and 22.8. No classroom warms up by 25 degrees in ten minutes and cools down again in the next ten: it is a bad reading, and it must not end up in the daily average.
- The stuck sensor from 10:00 to 10:40: five identical readings, both values, while the room was warming up. The sensor, or the code that reads it, kept sending the last value.
- The impossible humidity at 11:20: relative humidity cannot go over 100 %.
- The gap: the readings of 11:40 and 11:50 never arrived. Gemma named 11:30, the row before, in all our runs, although the system message asks for the row after. The rule finds the time and the length the same way every time.
The model found the two problems that are hard to write as rules; the rules found the two that are certain, and a rule gives the same answer every time. Use both.
Why the times are an enum
Your code looks up every time the model names: by_time[item["time"]]. In one of our runs without the enum, Gemma named 11:40 for the gap: the missing reading itself, which is not in the file. Clear for a person; for your program, by_time["2026-10-06 11:40"] is a KeyError.
"enum": list(by_time) builds the schema from your file, and the school's machines make the answer follow it: as long as the answer is complete, the model can only choose one of the times that are really there. When your program is going to look something up, put the list of valid answers in the schema.
The schema checks the time. It cannot check the reason: that is why the program prints the real values next to it. When we tried the same file on four other models, three runs each, ministral-3-14b and apertus-70b-instruct reported a gap where there is none, qwen3-coder-30b did it once, and Apertus missed the 47.9 °C spike altogether. Gemma too, with the windows sentence of the exercise below, twice wrote “missing reading at 11:30” for a row that is there. qwen3.6-35b found all four problems in every run, and named the row after the gap, as asked.
Good to know
- Every row costs tokens, at every call. A day is 144 rows, a week is 1,008. Let your code do the first pass, with rules, and send the model only the rows around something strange.
temperature=0makes answers alike, not identical. In our runs the reasons changed their words from one run to the next, and with the windows sentence of the exercise, the stuck sensor started at 10:10 instead of 10:00 in two runs out of three.- Mark, do not delete. Keep the original readings, and save the suspicious rows next to them, with the reason. A wrong flag can be undone; a deleted reading cannot.
Exercise
- At 11:00 the windows are opened for the break. Change the 11:00 row to
2026-10-06 11:00,19.0,44.0and run the program: the model calls it a spike. Now add “The windows are opened during the break, from 11:00 to 11:10.” to the system message, and run it again. A model knows only what you tell it. - Write a third rule, for the spike: a temperature more than 5 degrees away from both the reading before and the reading after.
- Use real data: a DHT22 on an ESP32 or a Raspberry Pi, one reading a minute for an hour, saved with the same three columns. Breathe on the sensor for a few seconds: what does the model say?
- Try
qwen3.6-35b, thenministral-3-14b. Do they find the same four rows? Are their reasons right?
