Skip to content
Fondazione ITS INCOM

Examples · Internet of Things

A daily report from your sensors

A day of classroom readings goes in, and five sentences for the teacher come out. Your code computes every number, the model only writes the sentences, and your code checks that each number in the report is one of its facts.

Intermediate · 30 min

The idea

A sensor that sends a reading every 10 minutes sends 144 a day. Nobody reads 144 rows. The teacher wants five sentences: was the air good, when was it not, what to do tomorrow.

A model writes good sentences. It can also write a wrong number, and sound just as sure. So the work is split into three parts:

  1. your code computes the facts: lowest, highest and mean values, minutes above 1000 ppm of CO2, the hottest hour;
  2. the model writes the report from those facts, and from nothing else;
  3. your code checks the report: every number in it must be one of the facts.

What you need

A key and the openai library, as in Your first API call in Python. The readings come from a short script, so you need no sensor.

A day of readings

Room 12, Wednesday 7 October 2026. The heating is still off (in most of Lombardy the heating season starts on 15 October), 24 students have a lesson from 08:30 to 12:30 with the windows shut, and the windows face south: in the afternoon the sun heats the room.

Save this as make_day.py. It writes day.csv: temperature in °C, humidity in % and CO2 in ppm, every 10 minutes from 00:00 to 23:50.

import csv
import math
import random

random.seed(7)  # the same "random" day for everyone

co2 = 480.0
with open("day.csv", "w", newline="") as f:
    out = csv.writer(f)
    out.writerow(["time", "temperature", "humidity", "co2"])
    for i in range(144):  # one reading every 10 minutes
        hour, minute = divmod(i * 10, 60)
        hm = f"{hour:02d}:{minute:02d}"
        # Who is in the room, and how much fresh air comes in every 10 minutes
        if "08:30" <= hm < "10:30" or "10:50" <= hm < "12:30":
            people, fresh_air = 24, 0.15  # morning lesson, windows shut
        elif "10:30" <= hm < "10:50":
            people, fresh_air = 4, 0.5  # break, windows open
        elif "12:30" <= hm < "14:00":
            people, fresh_air = 0, 0.3  # lunch, door open
        elif "14:00" <= hm < "17:00":
            people, fresh_air = 16, 0.35  # afternoon lesson, windows tilted
        else:
            people, fresh_air = 0, 0.05  # empty school
        co2 += people * 10 - fresh_air * (co2 - 420)  # each person: about 10 ppm more every 10 minutes
        sun = 7.5 * math.exp(-(((hour + minute / 60) - 15) / 2.2) ** 2)  # south-facing windows
        temperature = 19.0 + 0.0015 * (co2 - 420) + sun + random.uniform(-0.15, 0.15)
        humidity = 52 + 0.006 * (co2 - 420) - 1.4 * (temperature - 19) + random.uniform(-0.8, 0.8)
        reading = co2 + random.uniform(-15, 15)
        out.writerow([f"2026-10-07 {hm}", f"{temperature:.1f}", f"{humidity:.1f}", round(reading)])

Run it with python3 make_day.py. Because of random.seed(7), everyone gets the same file. This is how the lesson starts:

time,temperature,humidity,co2
…
2026-10-07 08:20,19.1,51.3,410
2026-10-07 08:30,19.5,52.8,653
2026-10-07 08:40,19.7,53.0,868
2026-10-07 08:50,20.1,54.8,1046
2026-10-07 09:00,20.1,54.9,1177
…

The program

Save it as daily_report.py, next to day.csv:

import csv
import json
import os
import re
from statistics import mean

from openai import OpenAI

client = OpenAI(
    base_url="https://api.ai.itsincom.org/v1",
    api_key=os.environ["API_KEY"],
)

with open("day.csv", newline="") as f:
    rows = list(csv.DictReader(f))
for row in rows:
    row["hm"] = row["time"][11:]  # "14:30"
    for key in ("temperature", "humidity", "co2"):
        row[key] = float(row[key])

# 1. Your code computes the facts
facts = {"room": "Room 12", "date": "7 October 2026", "readings": len(rows)}
for key, unit, digits in (("temperature", "°C", 1), ("humidity", "%", 1), ("co2", "ppm", None)):
    low = min(rows, key=lambda r: r[key])  # the first reading with the lowest value
    high = max(rows, key=lambda r: r[key])
    facts[key] = {"unit": unit,
                  "min": round(low[key], digits), "min_at": low["hm"],
                  "max": round(high[key], digits), "max_at": high["hm"],
                  "mean": round(mean(r[key] for r in rows), digits)}  # digits=None gives a whole number

above = []  # the periods with CO2 over 1000 ppm
for i, row in enumerate(rows):
    if row["co2"] > 1000:
        if i > 0 and rows[i - 1]["co2"] > 1000:
            above[-1]["to"] = row["hm"]
        else:
            above.append({"from": row["hm"], "to": row["hm"]})
facts["co2"]["limit"] = 1000
facts["co2"]["minutes_above_limit"] = 10 * sum(r["co2"] > 1000 for r in rows)  # a reading every 10 min
facts["co2"]["periods_above_limit"] = above

hours = {}
for row in rows:
    hours.setdefault(row["hm"][:2], []).append(row["temperature"])
hottest = max(hours, key=lambda h: mean(hours[h]))
facts["hottest_hour"] = {"from": f"{hottest}:00", "to": f"{int(hottest) + 1:02d}:00",
                         "mean_temperature": round(mean(hours[hottest]), 1)}
facts_json = json.dumps(facts, ensure_ascii=False)

# 2. The model writes the report, from the facts only
answer = client.chat.completions.create(
    model="qwen3.8-27b",
    messages=[
        {"role": "system", "content": "You write the daily air report of a classroom for its teacher. "
                                      "Use only the facts in the JSON. Write 4 to 6 short sentences in plain "
                                      "English, with no headings and no lists: say what matters and what to "
                                      "do differently tomorrow. Write every number exactly as it is in the "
                                      "facts: do not round it, do not convert it, and do not compute new "
                                      "numbers."},
        {"role": "user", "content": facts_json},
    ],
)
if answer.choices[0].finish_reason != "stop":
    raise SystemExit("The answer was cut off.")
report = answer.choices[0].message.content.strip()
print(report)

# 3. Your code checks: every number in the report must be one of the facts
number = r"(?<![\w.])\d+(?:[.:]\d+)?"  # 23.4, 1000, 14:30; not the 2 of CO2
allowed = set(re.findall(number, facts_json))
unknown = [n for n in re.findall(number, report) if n not in allowed]
print()
print("Numbers not in the facts:", ", ".join(unknown) if unknown else "none")

Run it with python3 daily_report.py. It takes 6 or 7 seconds.

The model gets about 300 tokens of facts instead of the 144 rows, which are about 4,500. It is cheaper, and the model has nothing to add up.

What you should see

The words change at every run. This is one of ours:

Room 12 on 7 October 2026 recorded 144 readings. The main concern was CO2, which exceeded the 1000 ppm limit for 230 minutes across two periods, from 08:50 to 10:30 and from 10:50 to 12:40, peaking at 1807 ppm at 10:20. The room also ran hot in the afternoon, with the hottest hour from 15:00 to 16:00 averaging 26.9 °C and the daily maximum reaching 27.2 °C at 15:00. Humidity was not a problem, staying between 42.8 % and 57.8 % with a mean of 51.4 %. Tomorrow, open windows or boost ventilation before 08:50 and again before 10:50 so CO2 stays under the limit, and start cross-ventilation earlier in the afternoon to prevent the mid-afternoon temperature spike.

Numbers not in the facts: none

In seven runs with this system message, qwen3.8-27b never wrote a number that is not in the facts.

What the check catches

Remove the last sentence of the system message, the one that starts with “Write every number exactly”, and run the program again. We did it six times. Four reports were clean, and in two the check found a number that is not in the facts:

Two different problems:

We also asked for three sentences “for a busy teacher, who reads it on the phone”, again without the last sentence. All three reports had a number that is not in the facts: “at 3 pm” for 15:00, “8:50” for 08:50, “1 807 ppm” with a space. They are right, only written differently, and the check flags them all. The check compares text with text: that is why the system message asks for every number exactly as it is in the facts.

gemma-4-26b, with the program as it is, wrote in one run out of three “There were 2 periods above the limit”. It is true, and it is not a fact: the model counted. If the teacher needs a number, compute it in code and put it in the facts.

What the check cannot see

Good to know

Exercise

All examples