Skip to content
Fondazione ITS INCOM

Examples · Internet of Things

When to open the windows

The CO2 readings of six classrooms go in. A rule in Python decides which rooms need fresh air, a model writes the message for each teacher in Italian and English, and your code checks the length and that the room and the value are in it.

Beginner · 25 min

The idea

Every classroom has a CO2 sensor, and every teacher has a small display on the desk. When the air gets stale, the display should say so, in a few plain words.

The work is split into three parts:

  1. a rule decides which rooms get a message: CO2 above 1000 ppm, or rising fast. A rule gives the same answer every time, and you can read it;
  2. a model writes the message, in Italian and in English;
  3. your code checks each message: at most 160 characters, with the room name and the CO2 value in it. If a message fails, a fixed template goes out instead.

What you need

A key and the openai library, as in Your first API call in Python.

The readings

Save them as co2.csv: two readings for each room, ten minutes apart.

room,co2_10_min_ago,co2_now
Aula 12,1180,1240
Lab 2,690,910
Aula 14,820,850
Aula Magna,560,575
Aula 21,980,1130
Lab 3,1320,940

Before you run the program, decide yourself: which rooms need a message?

The program

Save it as windows.py, next to the CSV:

import csv
import json
import os

from openai import OpenAI

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

LIMIT = 1000  # ppm
FAST = 150    # ppm in 10 minutes
TEMPLATE = {"it": "{room}: CO2 a {ppm} ppm. Aprite le finestre per 5 minuti.",
            "en": "{room}: CO2 at {ppm} ppm. Please open the windows for 5 minutes."}

schema = {
    "type": "object",
    "properties": {"it": {"type": "string"}, "en": {"type": "string"}},
    "required": ["it", "en"],
}

with open("co2.csv", newline="") as f:
    rooms = list(csv.DictReader(f))

for room in rooms:
    name, before, now = room["room"], int(room["co2_10_min_ago"]), int(room["co2_now"])
    rise = now - before

    # 1. The rule decides
    reasons = []
    if now > LIMIT:
        reasons.append(f"above {LIMIT} ppm")
    if rise >= FAST:
        reasons.append(f"rising fast: +{rise} ppm in 10 minutes")
    if not reasons:
        print(f"{name}: {now} ppm, no message")
        continue

    # 2. The model writes the message
    answer = client.chat.completions.create(
        model="gemma-4-26b",
        messages=[
            {"role": "system", "content": "You write a message for the teacher of a classroom, shown on a "
                                          "small display: the CO2 is too high, or rising fast. Say what to do "
                                          "and why it matters, in plain words, with no technical terms. Write it "
                                          "in Italian and in English, at most 160 characters each. Use the room "
                                          "name exactly as given, and write the CO2 value exactly as given, in "
                                          "ppm."},
            {"role": "user", "content": json.dumps({"room": name, "co2_ppm": now, "why": reasons})},
        ],
        response_format={"type": "json_schema", "json_schema": {"name": "message", "schema": schema}},
    )
    if answer.choices[0].finish_reason != "stop":
        raise SystemExit("The answer was cut off.")
    message = json.loads(answer.choices[0].message.content)

    # 3. Your code checks each message; if one fails, the template goes out instead
    print(f"{name}: {now} ppm, {'; '.join(reasons)}")
    for lang in ("it", "en"):
        text = message[lang].strip()
        problems = []
        if len(text) > 160:
            problems.append(f"{len(text)} characters")
        if name not in text:
            problems.append(f"no «{name}»")
        if str(now) not in text:
            problems.append(f"no «{now}»")
        if problems:
            print(f"  {lang} model: {text}")
            print(f"  {lang} rejected ({', '.join(problems)}), template instead:")
            text = TEMPLATE[lang].format(room=name, ppm=now)
        print(f"  {lang} ({len(text)}): {text}")

Run it with python3 windows.py. Three messages take about 3 seconds.

What you should see

Aula 12: 1240 ppm, above 1000 ppm
  it (126): Aula 12: l'aria è pesante (1240 ppm). Apri le finestre per far entrare aria fresca e aiutare la concentrazione degli studenti.
  en (106): Aula 12: The air is stale (1240 ppm). Open the windows to let in fresh air and help students stay focused.
Lab 2: 910 ppm, rising fast: +220 ppm in 10 minutes
  it (123): Lab 2: CO2 a 910 ppm e in rapida crescita. Apri le finestre subito per far entrare aria fresca e aiutare la concentrazione.
  en (111): Lab 2: CO2 is 910 ppm and rising fast. Open the windows now to let in fresh air and help everyone stay focused.
Aula 14: 850 ppm, no message
Aula Magna: 575 ppm, no message
Aula 21: 1130 ppm, above 1000 ppm; rising fast: +150 ppm in 10 minutes
  it (139): Aula 21: CO2 alta (1130 ppm) e in rapida crescita. Apri le finestre per far entrare aria fresca e aiutare la concentrazione degli studenti.
  en (117): Aula 21: CO2 is high (1130 ppm) and rising fast. Open the windows to let in fresh air and help students stay focused.
Lab 3: 940 ppm, no message

The number in brackets is the length of the message. We ran the program four times: the words changed, and all 24 messages passed the checks. The longest had 146 characters.

Why a rule decides

Whether to send a message is a yes or a no, and it must be the same yes every time. LIMIT and FAST are two numbers you can read, test and change. A model asked “should this room open the windows?” could say yes to 940 ppm in one run and no in the next.

What the checks catch

The system message asks two things that are easy to lose: the length, and the exact room name and value. We took each one away, and ran the program three times.

Without “Use the room name exactly as given, and write the CO2 value exactly as given, in ppm”, the checks rejected all 18 messages. 17 had neither the room nor the value, for example:

  it model: Aria poco fresca: la CO2 è alta. Apri le finestre per far entrare aria pulita e aiutare la concentrazione degli studenti.
  it rejected (no «Aula 12», no «1240»), template instead:
  it (57): Aula 12: CO2 a 1240 ppm. Aprite le finestre per 5 minuti.

We think the model followed “no technical terms”: “1240 ppm” looks like one. The 18th message had the room but not the value. And one English message had the room, but translated: “CO2 high in Room 21”. A teacher in Aula 21 might not know that it is for them.

Without “at most 160 characters each”, the messages for Lab 2 and Aula 21 were too long in all three runs, from 164 to 241 characters:

  it model: Attenzione in Lab 2: l'aria è diventata pesante (910 ppm) e la concentrazione sta aumentando molto velocemente. Apri subito le finestre per far entrare aria fresca; questo aiuterà gli studenti a rimanere concentrati e a non sentirsi stanchi.
  it rejected (241 characters), template instead:

Each time the template went out, and the teacher still got a short, correct message.

What the checks cannot see

The checks count characters and look for two pieces of text. They cannot read Italian.

We ran the same program with qwen3.8-27b, twice. Every message passed the checks. But some of its Italian was odd, and nothing in the program could see it: «L'aria vinta toglie le forze», «Respirate arie fresche», and «L'aria viziata vi fa stare male», which says more than the CO2 does. It was also slower: about 20 seconds a message, against about one second for gemma-4-26b. For short messages in two languages, Gemma is the better choice here.

Gemma has a habit of its own: it writes «Apri le finestre», to one person, while the template says «Aprite». If it matters to you, say it in the system message.

Good to know

Exercise

All examples