Examples · Internet of Things
What uses power at night
A week of hourly readings from a school's electricity meter goes in. Your code finds the night baseline and the hours above it; the model groups those hours into events, guesses a cause and writes a checklist for the caretaker; your code checks every hour the model names and does the sums itself.
Intermediate · 35 min
The idea
A school is empty at night, but its electricity meter never stops: the server room, the network, the fridges, the emergency lights. That steady part is the baseline. When a night costs more than the baseline, something was left on.
Finding the hours is arithmetic, and code does it the same way every time. Guessing what was left on, and telling the caretaker where to look, is a job for a model. So:
- your code computes the night baseline and the hours above it;
- the model groups those hours into events, proposes a likely cause for each and writes a checklist for the evening round;
- your code checks every hour the model names, and computes the kWh and the euro itself.
What you need
A key and the openai library, as in Your first API call in Python.
A week of readings
A smart meter, or an energy monitor with a current clamp around each phase wire of the main supply, gives you two columns: the hour and the kWh used in that hour. This program makes a realistic week for a school, Monday 28 September to Sunday 4 October 2026. Save it as make_energy.py:
import csv
import random
from datetime import datetime, timedelta
random.seed(2026) # the same seed gives the same week, on every computer
start = datetime(2026, 9, 28) # Monday 28 September 2026, midnight
rows = []
for h in range(7 * 24):
t = start + timedelta(hours=h)
weekday = t.weekday() < 5 # Monday to Friday
kwh = 3.0 + random.uniform(-0.15, 0.15) # always on: server room, network, fridges, emergency lights
if weekday and 7 <= t.hour < 17:
kwh += 14 + random.uniform(-2, 2) # lessons: lights, computers, projectors
elif weekday and t.hour in (17, 18, 19):
kwh += 4 + random.uniform(-1, 1) # cleaning, evening courses
elif weekday and t.hour == 6:
kwh += 2 + random.uniform(-0.5, 0.5) # the caretaker opens the building
# A 2 kW heater left on in the B wing: Tuesday night and Thursday night.
# Its thermostat switches it off when the room is warm, so it is not 2 kWh in every hour.
for evening in (datetime(2026, 9, 29, 19), datetime(2026, 10, 1, 20)):
if evening <= t < evening.replace(hour=6) + timedelta(days=1):
kwh += random.choice([2.0, 2.0, 2.0, 2.0, 1.4, 1.4, 0.0])
# Sunday night: the 25 computers of lab 3 wake up for their updates, 01:00 to 03:00.
if datetime(2026, 10, 4, 1) <= t < datetime(2026, 10, 4, 3):
kwh += 1.5 + random.uniform(-0.2, 0.2)
rows.append([t.strftime("%Y-%m-%d %H:%M"), f"{kwh:.2f}"])
with open("energy.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["hour", "kwh"])
writer.writerows(rows)
print(f"energy.csv: {len(rows)} hours")
Run it once with python3 make_energy.py. It writes energy.csv, 168 rows that start like this:
hour,kwh
2026-09-28 00:00,2.89
2026-09-28 01:00,3.00
2026-09-28 02:00,3.00
The comments tell you what is hidden in the week. The model never sees them: it gets only the numbers, like the caretaker.
The program
Save it as night_load.py, next to energy.csv:
import csv
import json
import os
import statistics
import sys
from datetime import datetime
from openai import OpenAI
client = OpenAI(
base_url="https://api.ai.itsincom.org/v1",
api_key=os.environ["API_KEY"],
)
MODEL = sys.argv[1] if len(sys.argv) > 1 else "qwen3.8-27b"
PRICE = 0.28 # euro per kWh: put your school's price here
MARGIN = 0.5 # kWh above the baseline before an hour counts as "too much"
with open("energy.csv", newline="") as f:
rows = list(csv.DictReader(f))
kwh = {row["hour"]: float(row["kwh"]) for row in rows}
# 1. Code: the night baseline and the hours above it
night = [hour for hour in kwh if int(hour[11:13]) >= 20 or int(hour[11:13]) < 6] # 20:00 to 05:59
baseline = statistics.median(kwh[hour] for hour in night)
flagged = [hour for hour in night if kwh[hour] > baseline + MARGIN]
print(f"Night baseline: {baseline:.2f} kWh per hour. Hours above {baseline + MARGIN:.2f}: {len(flagged)}")
day = {hour: datetime.strptime(hour, "%Y-%m-%d %H:%M").strftime("%a") for hour in kwh} # Mon, Tue...
table = "\n".join(f"{hour} {day[hour]} {kwh[hour]:5.2f} kWh" + (" ABOVE" if hour in flagged else "")
for hour in night)
# 2. The model: likely causes and a checklist. It may name only hours that are in the file.
schema = {
"type": "object",
"properties": {
"baseline_cause": {"type": "string"},
"events": {
"type": "array",
"items": {
"type": "object",
"properties": {
"hours": {"type": "array", "items": {"type": "string", "enum": list(kwh)}},
"likely_cause": {"type": "string"},
"why": {"type": "string"},
},
"required": ["hours", "likely_cause", "why"],
},
},
"checklist": {"type": "array", "items": {"type": "string"}},
},
"required": ["baseline_cause", "events", "checklist"],
}
answer = client.chat.completions.create(
model=MODEL,
temperature=0,
max_tokens=8000, # room for the silent reasoning, and a ceiling if the answer runs away
messages=[
{"role": "system", "content": "You help the caretaker of a school in Busto Arsizio. Nobody is in the "
"building from 20:00 to 06:00. You get the night readings of the main "
"electricity meter, one per hour, in kWh; ABOVE marks the hours over the "
"night baseline. Answer with:\n"
"- baseline_cause: which devices probably make the baseline, in one or two "
"sentences;\n"
"- events: group the ABOVE hours into events; for each, list every hour, a "
"likely_cause in a few words, and why in one sentence;\n"
"- checklist: five to eight short items for the caretaker's evening round.\n"
"Do not do sums: the program does them."},
{"role": "user", "content": f"Night baseline: {baseline:.2f} kWh per hour.\n\n{table}"},
],
response_format={"type": "json_schema", "json_schema": {"name": "night_load", "schema": schema}},
)
if answer.choices[0].finish_reason != "stop":
sys.exit(f"The answer was cut off ({answer.choices[0].finish_reason}): run it again.")
report = json.loads(answer.choices[0].message.content)
# 3. Code checks every hour the model names, and does the sums itself.
print(f"\nBaseline: {report['baseline_cause']}")
named = set()
for event in report["events"]:
hours = sorted(set(event["hours"]))
named.update(hours)
extra = sum(kwh[h] - baseline for h in hours if h in flagged)
print(f"\n{day[hours[0]]} {hours[0]} to {day[hours[-1]]} {hours[-1]}: {len(hours)} hours, "
f"{extra:.1f} kWh extra, about {extra * PRICE:.2f} euro")
print(f" {event['likely_cause']}: {event['why']}")
for h in hours:
if h not in flagged:
print(f" NOT FLAGGED: {h} ({kwh[h]:.2f} kWh) is not above the baseline")
for h in flagged:
if h not in named:
print(f"NOT EXPLAINED: {h} ({kwh[h]:.2f} kWh) is above the baseline, and no event names it")
print("\nChecklist for the evening round:")
for item in report["checklist"]:
print(f" [ ] {item}")
Run it with python3 night_load.py. With qwen3.8-27b it took 38 to 46 seconds in our runs: the model reasons before it answers. python3 night_load.py gemma-4-26b took 8.
What you should see
Night baseline: 3.03 kWh per hour. Hours above 3.53: 20
Baseline: The ~3 kW night baseline is most likely the school's boiler/heating system running at low power (late September in Busto Arsizio, heating just starting), plus standby loads from security systems, corridor lighting, and possibly a small refrigeration unit in the kitchen or a server/IT room.
Tue 2026-09-29 20:00 to Wed 2026-09-30 05:00: 10 hours, 18.7 kWh extra, about 5.24 euro
Heating zone or large appliance left running: A steady ~2 kW excess for ten consecutive hours points to a single device (a boiler zone, a kitchen heater, or a lab fume hood) that was simply not switched off at closing and ran through the whole night without cycling.
Thu 2026-10-01 20:00 to Fri 2026-10-02 05:00: 8 hours, 14.7 kWh extra, about 4.12 euro
Thermostat-cycling heating or dehumidifier: The same ~2 kW excess appears in blocks with brief drops to baseline at 21:00 and 03:00, the classic on/off pattern of a thermostat or humidity sensor cycling a heating or dehumidification unit.
Sun 2026-10-04 01:00 to Sun 2026-10-04 02:00: 2 hours, 3.2 kWh extra, about 0.88 euro
Short timer-based heating or appliance cycle: Only two hours of ~1.6 kW excess on a quiet Sunday night suggest a scheduled timer (e.g., a boiler pre-heat cycle or a dehumidifier) that ran briefly and then stopped.
Checklist for the evening round:
[ ] Confirm the boiler / heating zones are off or on the programmed timer before leaving.
[ ] Check the kitchen: ovens, heaters, and any refrigeration units are switched off.
…
[ ] Glance at the main electrical panel / sub-meters for any unexpected load.
The Thursday event runs from 20:00 to 05:00 but has 8 hours, not 10: the model left out 21:00 and 03:00, when the heater's thermostat had switched it off. It saw the drops, and it called the pattern a thermostat. Which device, and where, it cannot say: “a boiler zone, a kitchen heater, or a lab fume hood”. It has one meter for the whole building, so a list of candidates is the honest answer.
Two checks: the enum, and your code
The enum says that an hour exists. "enum": list(kwh) puts the 168 hours of the file into the schema, and the school's machines make the answer follow it. The model cannot write “Tuesday night” or “20:00–05:00”, which your program could not look up: every hour it names is a key of kwh.
Your code says whether the hour counts. The enum allows all 168 hours, including Thursday 21:00, which is in the middle of an event but not above the baseline. Two loops check the rest:
NOT FLAGGED: the model named an hour that is not above the baseline;NOT EXPLAINED: an hour is above the baseline, and no event names it.
With the system message above, in our four runs with qwen3.8-27b, neither line ever appeared: all 20 hours, no others, in three events. With our first system message, which did not describe the fields of the answer, we ran it three times. One run named only the Tuesday night, and the program printed ten NOT EXPLAINED lines, for Thursday and Sunday. One named all the hours, but its baseline_cause was a copy of the first event's likely_cause: a paragraph about a heater, not about the baseline. The third had not answered after 400 seconds, and we stopped it. The schema fixes the shape of the answer; say in words what goes in each field.
The sums are your code's. The kWh and the euro are computed from the file, and only for flagged hours. The system message says “Do not do sums”: a model that writes “about 18 kWh” is guessing, and you would have to check it anyway.
What the model cannot know
The causes are guesses, and they read well. gemma-4-26b named the same hours as Qwen in all four of our runs. In all four it also explained the Tuesday night with cleaning machines, vacuum cleaners and floor polishers or scrubbers, in a building that the system message says is empty from 20:00. No rule in the program can catch that: you read the reasons, and the caretaker's walk decides.
For Sunday night the models proposed a boiler cycle, a timer or “a single device”; none guessed the computers of lab 3, and they could not: the update schedule is in the IT office, not in the meter. When you know something, put it in the system message.
Good to know
- The median, not the mean. The mean of the 70 night hours is 3.52 kWh: the heater nights pull it up by half a kWh. With the mean as the baseline and
MARGIN = 1.0, four of the heater's hours would no longer count. The median stays at 3.03. - The margin is a choice. The baseline wanders by about ±0.15 kWh, so 0.5 never gives a false alarm on this week. It also means that a 300 W load left on every night, such as a drinks machine or a few forgotten PCs, is invisible: it becomes part of the baseline. Compare whole nights, week after week, to find those.
max_tokens=8000is a ceiling, not a target. In three of our runs Qwen used between 2,581 and 2,846 completion tokens, most of them for its silent reasoning. A ceiling turns an answer that never ends intofinish_reason"length", which the program reports.- One meter, one number. Which wing, which room: a meter cannot say. A clamp sensor on each wing's panel can.
- An empty list breaks the loop. The schema lets the model return an event with no hours, and then
hours[0]raises anIndexError. It never happened in our runs; add"minItems": 1to thehoursarray in the schema, and the school's machines will not let it happen.
Exercise
- Change
MARGINto 0.2, then to 1.5. How many hours are flagged each time? Which events change? - Put only the flagged hours in the enum:
"enum": flagged. What can the model no longer do? Which of the two checks becomes useless? - Add to the system message: “Every Sunday from 01:00 to 03:00 the computers of lab 3 install their updates.” What does the model say about Sunday now?
- The weekend days are empty too. Change
nightso that Saturday and Sunday count from 00:00 to 23:59, and run it again.
