Skip to content
Fondazione ITS INCOM

Examples · Internet of Things

How many people are in the room?

Twenty minutes of CO2 readings go in, and an estimate of the people in the room comes out, as a number and a range. Python does the physics, a model explains the estimate to a student, and your code checks that the range is still in the explanation.

Intermediate · 35 min

The idea

People breathe out CO2. In a closed room, the more people there are, the faster the CO2 goes up. So a CO2 sensor can tell you, roughly, how many people are in the room, without a camera.

Roughly is the important word. The answer depends on things you do not know exactly: how much CO2 each person breathes out, how much air leaks out of the room. An honest answer is a number and a range. The work is split into four parts:

  1. your code does the physics: the rise of the CO2, the estimate, and the range you get when you change the assumptions;
  2. a model explains the estimate to a student, from those numbers only;
  3. your code checks the explanation: the estimate and the range must be in it, and no number may come from nowhere;
  4. your code compares the estimate with the class register, which the model never sees.

What you need

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

The physics

Every second, the CO2 that the people breathe out goes two ways: part of it stays in the room, and the CO2 goes up; part of it leaves with the air that leaks out.

people x CO2 per person = CO2 that stays in the room + CO2 that leaves with the air

To solve it for the number of people, you need some assumptions. These are the ones in the program:

A ppm is one part in a million: one litre of CO2 in 1,000,000 litres of air. That is where the 1_000_000 in the program comes from. In this room, one person adds about 1.6 ppm a minute, if no air leaves.

The readings

Aula 7, Legnano: a written test starts at 08:30. The readings are invented by this script, make_readings.py. It knows the truth, and the program will not read it:

import csv
import random

random.seed(23)  # the same "random" noise for everyone

# The truth, which people.py never reads: 23 people sitting still for a written test
PEOPLE = 23
PER_PERSON = 0.0045        # litres of CO2 a second, each
AIR_CHANGES = 0.6          # times an hour
VOLUME = 9 * 7 * 3 * 1000  # litres
OUTDOOR = 420              # ppm

co2 = 610.0  # at 08:35; the class came in at about 08:30
with open("readings.csv", "w", newline="") as f:
    out = csv.writer(f)
    out.writerow(["time", "co2"])
    for minute in range(21):
        out.writerow([f"08:{35 + minute}", round(co2 + random.uniform(-10, 10))])  # the sensor: ±10 ppm
        for second in range(60):
            breathed = PEOPLE * PER_PERSON * 1_000_000
            leaving = VOLUME * AIR_CHANGES / 3600 * (co2 - OUTDOOR)
            co2 += (breathed - leaving) / VOLUME

Run it with python3 make_readings.py. It writes readings.csv, one reading a minute from 08:35 to 08:55: from 618 ppm to 1177 ppm.

The program

Save it as people.py, next to readings.csv:

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

from openai import OpenAI

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

# The assumptions: change them for your room
VOLUME = 9 * 7 * 3 * 1000  # litres: a room 9 m x 7 m, 3 m high
OUTDOOR = 420              # ppm of CO2 in the air outside
PER_PERSON = 0.005         # litres of CO2 a second, one person sitting (0.004 to 0.006)
AIR_CHANGES = 0.5          # times an hour the air of the room is replaced (0.3 to 1.0, windows shut)
REGISTER = 23              # people in the class register: the program never tells the model


def people(rise, co2, per_person=PER_PERSON, air_changes=AIR_CHANGES):
    """Mass balance: the CO2 people breathe out = the CO2 that stays in the room + the CO2 that leaves."""
    stays = VOLUME * rise / 60                                 # litres x ppm a second
    leaves = VOLUME * air_changes / 3600 * (co2 - OUTDOOR)     # litres a second x ppm
    return (stays + leaves) / (per_person * 1_000_000)


with open("readings.csv", newline="") as f:
    rows = list(csv.DictReader(f))
co2 = [int(row["co2"]) for row in rows]
rise, _ = linear_regression(range(len(co2)), co2)  # ppm a minute: the best straight line
level = mean(co2)

estimate = people(rise, level)
low = people(rise, level, per_person=0.006, air_changes=0.3)   # more CO2 each, less air out: fewer people
high = people(rise, level, per_person=0.004, air_changes=1.0)  # less CO2 each, more air out: more people
print(f"Rise: {rise:.1f} ppm a minute. Estimate: {estimate:.1f} people, between {low:.1f} and {high:.1f}.")
print(f"Register: {REGISTER} people.", "Inside the range." if low <= REGISTER <= high else "OUTSIDE the range!")

by_person = [round(people(rise, level, per_person=p)) for p in (0.004, 0.006)]  # one assumption at a time
by_air = [round(people(rise, level, air_changes=a)) for a in (0.3, 1.0)]
facts = {
    "readings": f"{rows[0]['time']} to {rows[-1]['time']}, one a minute",
    "co2_first_and_last_ppm": [co2[0], co2[-1]],
    "rise_ppm_per_minute": round(rise, 1),
    "room_volume_m3": VOLUME // 1000,
    "assumed": {"co2_per_person_l_per_s": PER_PERSON, "air_changes_per_hour": AIR_CHANGES,
                "outdoor_ppm": OUTDOOR},
    "estimate_people": round(estimate),
    "range_people": [round(low), round(high)],
    "if_only_co2_per_person_changes": {"l_per_s": [0.004, 0.006], "people": by_person,
                                       "difference": abs(by_person[0] - by_person[1])},
    "if_only_air_changes_change": {"per_hour": [0.3, 1.0], "people": by_air,
                                   "difference": abs(by_air[0] - by_air[1])},
}
facts_json = json.dumps(facts)

answer = client.chat.completions.create(
    model="qwen3.8-27b",
    messages=[
        {"role": "system", "content": "A student asks how many people are in a classroom, from the CO2 "
                                      "readings. Explain the estimate in plain English, in 5 to 8 sentences: "
                                      "how a rise of CO2 becomes a number of people, why the answer is a "
                                      "range, and which assumption matters most. Use only the facts in the "
                                      "JSON, and write every number exactly as it is there."},
        {"role": "user", "content": facts_json},
    ],
)
if answer.choices[0].finish_reason != "stop":
    raise SystemExit("The answer was cut off.")
text = answer.choices[0].message.content.strip()
print()
print(text)
print()

# Your code checks: the estimate and the range must be there, and no number may come from nowhere
number = r"(?<![\w.])\d+(?:\.\d+)?"
said = re.findall(number, text)
problems = [f"missing: {n}" for n in (facts["estimate_people"], *facts["range_people"]) if str(n) not in said]
problems += [f"not in the facts: {n}" for n in sorted(set(said) - set(re.findall(number, facts_json)))]
print("Check:", "; ".join(problems) if problems else "the estimate and the range are there, every number is a fact")

Run it with python3 people.py. It takes 12 to 23 seconds: qwen3.8-27b thinks before it writes.

linear_regression draws the best straight line through the 21 readings. Its slope is the rise in ppm a minute, and one noisy reading does not change it much. It is in the standard library from Python 3.10.

What you should see

Rise: 27.8 ppm a minute. Estimate: 20.0 people, between 15.9 and 28.2.
Register: 23 people. Inside the range.

Over the 20 minutes from 08:35 to 08:55, the CO2 in the room climbed from 618 ppm to 1177 ppm, a rise of 27.8 ppm per minute. The logic is straightforward: each person is assumed to exhale 0.005 L/s of CO2, the room (189 m³) is ventilated at 0.5 air changes per hour, and the outside air sits at 420 ppm. By balancing the CO2 the people add against the CO2 the ventilation removes, you can work backward to the number of people needed to produce that 27.8 ppm per minute rise. That calculation gives an estimate of 20 people. The answer is given as a range, 16 to 28, because the assumed values are not exact. The assumption that matters most is the CO2 per person: shifting it from 0.004 to 0.006 L/s moves the estimate from 25 to 17, a difference of 8 people. Changing the air changes from 0.3 to 1.0 per hour only moves the estimate from 19 to 23, a difference of 4. So the biggest source of uncertainty is how much CO2 each person actually breathes out.

Check: the estimate and the range are there, every number is a fact

We ran the program six times. The words changed, and the check passed every time.

20 people, and the register says 23

The estimate is 3 people short, and the register is inside the range. That is the method working as it should.

The script made the readings with 23 people breathing out 0.0045 litres a second each: they were sitting still for a test, and breathed out less than the 0.005 the program assumes. The fresh air was a little more than assumed, too: 0.6 changes an hour, not 0.5.

Put the true values in the formula, and it gives 22.8 people: the formula is right, the assumptions were not. Correct only the CO2 per person, and you get 22.3; correct only the air changes, and you get 20.5. Nearly all of the error comes from the CO2 per person.

Why does the air matter less? Look at the two parts of the formula: with these readings, the CO2 that stays in the room is worth 17.5 people, the CO2 that leaves only 2.5. At the start of a lesson, the air in the room is still close to the air outside, and little CO2 leaves. That is why you measure in the first twenty minutes.

What the check catches

gemma-4-26b, with the same program, wrote good explanations, but in one run out of three without the estimate: “The result is not a single certain number, but rather a range of 16 to 28 people.” Then the check said missing: 20.

We also removed the last sentence of the system message, “Use only the facts in the JSON, and write every number exactly as it is there”, and ran both models three times. All six answers failed the check:

What the check cannot see

Good to know

Exercise

All examples