Skip to content
Fondazione ITS INCOM

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:

  1. the model lists the suspicious rows, as JSON, with a reason for each;
  2. your code puts the real values next to every reason, so you can check it;
  3. 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 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

Exercise

All examples