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:
- your code computes the facts: lowest, highest and mean values, minutes above 1000 ppm of CO2, the hottest hour;
- the model writes the report from those facts, and from nothing else;
- 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:
- “…keep the room ventilated through the mid-afternoon so the temperature does not again push past 27 °C.”
- “Tomorrow, open the windows around 08:30 so CO2 does not climb above the limit during the first two hours of the school day.”
Two different problems:
- 27 is a rounded number. The fact is 27.2. Harmless here, but a report that says “27” on Monday and “27.2” on Tuesday makes the teacher think something changed.
- 08:30 is an invented number. No fact says when the lesson starts: the model guessed, and it guessed well. A good guess is still a guess, and the teacher cannot tell it from a measurement.
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
- A right number in the wrong place. The check says that every number is a fact, not that it is the right fact. “1807 ppm at 10:10” would pass, because 10:10 is in the facts, as the time of the highest humidity.
- Numbers in words. “Two periods” and “the first two lessons” have no digits, so the check does not see them.
- Words. Without the last sentence, Gemma wrote “a dangerous peak of 1807 ppm”. 1807 ppm is stale air that can make people tired, not a danger. Only a person reading the report sees that.
Good to know
- The model only writes. Every number comes from your code: if a number is wrong, you know where to look.
min()gives the first one. The lowest temperature, 18.9 °C, was measured 18 times between 00:20 and 08:10; the facts say 00:20, the first. Say so to the teacher if it matters.- A report a day is one call a day. Run it at 18:00 with
cronand send the text by e-mail or to a chat.
Exercise
- Add
"periods": len(above)to the CO2 facts, and ask for the number of periods in the report. Does the check still pass? - Ask for the report in Italian. Italian writes “27,2”, not “27.2”: what does the check say? Make it accept both.
- Add a fact for the afternoon: the minutes above 26 °C. Does the model use it?
- Change the seed in
make_day.py, or the number of students, and run both programs again. The facts change; does the advice change too?
