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:
- your code does the physics: the rise of the CO2, the estimate, and the range you get when you change the assumptions;
- a model explains the estimate to a student, from those numbers only;
- your code checks the explanation: the estimate and the range must be in it, and no number may come from nowhere;
- 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:
- The room: 9 m x 7 m, 3 m high, so 189 m³ or 189,000 litres. The air is well mixed, so one sensor speaks for the whole room.
- CO2 per person: about 0.005 litres a second for an adult sitting at a desk. It is 0.004 to 0.006, depending on the person and on what they are doing.
- Fresh air: windows and door shut, the air of the room is replaced about 0.5 times an hour through gaps and vents. It can be anything from 0.3 to 1.0.
- Outside: 420 ppm of CO2.
- Nobody comes in or goes out during the readings.
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:
- Qwen wrote numbers of its own. All of them were right, and none was in the facts: “1,177 ppm”, “about 5 millilitres per second”, “that 559 ppm rise”, “roughly 0.088 litres of CO2 being added to the room each second”. In one run the range 16 to 28 was gone.
- Gemma wrote no numbers at all: “a calculated guess that accounts for these moving parts”. And in all three runs it said that the air changes are the assumption that matters most. The facts it received say the opposite: a difference of 8 people for the CO2 per person, 4 for the air changes.
What the check cannot see
- A number in the wrong place. “Over the 20 minutes from 08:35 to 08:55” passed the check because 20 is a fact: it is the estimate of people. The 20 minutes are right, but they passed by chance: with an estimate of 21, the check would have flagged them.
- A wrong claim in words. Gemma's answers about the air changes were flagged only because numbers were missing. With all the numbers there, the same claim would pass.
Good to know
- Name your keys for the reader. The model copies them: in our runs Qwen wrote “0.005 l_per_s” and Gemma “the co2_per_person_l_per_s”.
"litres_per_second"reads better in a sentence. - A real room is not this tidy. People come in late, the door opens, the sensor sits near a window. Use the readings from a quiet stretch, after everybody is in.
- The model never sees the register. If it did, it could write 23 and look clever. Keep the answer you want to test away from the model.
- This is a count of lungs, not of people. A sensor cannot tell twenty quiet students from fifteen students doing a lab.
Exercise
- In
make_readings.py, setPEOPLE = 8and run both programs. Is 8 inside the range? Try 3. - Change
AIR_CHANGESinmake_readings.pyto 2.0, as if the windows were tilted open. The program still assumes 0.5: what happens to the estimate? - Use only the first 10 readings:
rows = rows[:10]. How much does the estimate change? - Rename the keys of
factsso that they read well in a sentence, and run the program again. Does the model still copy them? - With a real sensor: an SCD41 in your classroom, one reading a minute. Count the people, and see how close the program gets.
