Examples · Internet of Things
A motor that is about to fail
Forty days of vibration from a fan motor go in. Plain Python computes the RMS, the peak and the trend; a model writes the maintenance note and picks an action from a list; your code checks that the action is allowed by simple thresholds before anyone acts on it.
Advanced · 45 min
The idea
A worn bearing usually gives a warning before it breaks. Weeks before, it starts to knock: a small hit every time a ball rolls over the damaged spot, about 88 times a second in this fan. A vibration sensor on the motor sees it long before a person hears it.
Each day the sensor records one second of vibration. Your code turns those 1,000 samples into three numbers:
- RMS: the average strength of the vibration. It is the number that vibration standards use.
- peak: the biggest single swing.
- crest factor, peak / RMS: about 1.4 for a smooth sine wave, about 2 with some noise. Sharp hits make it climb, often before the RMS moves.
Then:
- code computes the three numbers for each day, and the trend of the RMS;
- code decides which actions the thresholds allow;
- the model writes the note for the technician and picks one action from a list;
- code checks that the action is one of the allowed ones.
The thresholds: a teaching simplification
The fan of this example uses two numbers, RMS in mm/s:
- below 1.8: good;
- 1.8 to 4.5: acceptable only for a limited time;
- above 4.5: can damage the machine.
These two numbers are the zone boundaries often quoted for small machines, up to 15 kW, in ISO 10816-1, which has since been replaced by ISO 20816-1. This is a simplification for the classroom. The real standards depend on the type and size of the machine, how it is mounted, where and how you measure; for a real machine, the manufacturer and the maintenance company set the limits. The rule about the trend, “14 days”, is ours.
From those, the program allows:
| Situation | Allowed actions |
|---|---|
| RMS 4.5 or more | stop_now |
| the trend reaches 4.5 within 14 days | repair_within_a_week |
| RMS 1.8 to 4.5 | inspect_within_a_month, repair_within_a_week |
| RMS below 1.8 | keep_running, inspect_within_a_month |
What you need
A key and the openai library, as in Your first API call in Python. Nothing else: RMS, peak and trend are plain Python.
Forty days of vibration
A real sensor samples faster and over a wider band; one second at 1,000 samples per second keeps the file small. This program makes forty days, from 29 August to 7 October 2026, for the extraction fan of a school lab. Save it as make_vibration.py:
import csv
import math
import random
from datetime import date, timedelta
random.seed(43) # the same seed gives the same forty days, on every computer
RATE = 1000 # samples per second
SHAFT = 1470 / 60 # the fan turns at 1470 rpm: 24.5 turns per second
DEFECT = 88.0 # a damaged outer ring is hit about 88 times per second
DAYS = 40 # 29 August to 7 October
start = date(2026, 8, 29)
with open("vibration.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["day", "ms", "velocity"]) # velocity in mm/s, one second of samples per day
for d in range(DAYS):
# From day 18 a bearing starts to wear, and the damage grows a little faster every day.
damage = 0.0 if d < 18 else 0.08 * math.exp((d - 18) / 7.5)
for i in range(RATE):
t = i / RATE
v = 1.45 * math.sin(2 * math.pi * SHAFT * t + 0.4) # a little unbalance, always there
since_hit = t % (1 / DEFECT) # time since the last hit
# Each hit makes the housing ring at 230 Hz, and the ringing fades in a few milliseconds.
v += damage * 9 * math.exp(-since_hit / 0.002) * math.sin(2 * math.pi * 230 * since_hit)
v += random.gauss(0, 0.2) # noise
writer.writerow([(start + timedelta(days=d)).isoformat(), i, f"{v:.3f}"])
print(f"vibration.csv: {DAYS} days, {RATE} samples each")
Run it once with python3 make_vibration.py. It writes vibration.csv: 40,000 rows, about 0.9 MB.
The program
Save it as motor_note.py, next to vibration.csv:
import csv
import json
import math
import os
import sys
from collections import defaultdict
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"
UNTIL = sys.argv[2] if len(sys.argv) > 2 else "9999" # a day like 2026-10-01: run as if it were that day
# Simplified zones for this fan, RMS velocity in mm/s: a teaching version, not a standard
GOOD, LIMIT = 1.8, 4.5
ACTIONS = ["keep_running", "inspect_within_a_month", "repair_within_a_week", "stop_now"]
samples = defaultdict(list)
with open("vibration.csv", newline="") as f:
for row in csv.DictReader(f):
if row["day"] <= UNTIL:
samples[row["day"]].append(float(row["velocity"]))
# 1. Plain Python: RMS, peak and crest factor of each day
days = []
for day, v in samples.items():
rms = math.sqrt(sum(x * x for x in v) / len(v))
peak = max(abs(x) for x in v)
days.append({"day": day, "rms": rms, "peak": peak, "crest": peak / rms})
# 2. The trend: a straight line through the RMS of the last 7 days, in mm/s per day
ys = [d["rms"] for d in days[-7:]]
n = len(ys)
sx, sy = sum(range(n)), sum(ys)
sxx, sxy = sum(x * x for x in range(n)), sum(x * y for x, y in enumerate(ys))
slope = (n * sxy - sx * sy) / (n * sxx - sx * sx)
now = days[-1]
days_left = (LIMIT - now["rms"]) / slope if slope > 0 else math.inf
# 3. The thresholds decide which actions are allowed; the model chooses among them
if now["rms"] >= LIMIT:
allowed = ["stop_now"]
elif days_left <= 14:
allowed = ["repair_within_a_week"]
elif now["rms"] >= GOOD:
allowed = ["inspect_within_a_month", "repair_within_a_week"]
else:
allowed = ["keep_running", "inspect_within_a_month"]
table = "\n".join(f"{d['day']} RMS {d['rms']:.2f} peak {d['peak']:.2f} crest {d['crest']:.2f}" for d in days)
facts = (f"Trend of the last 7 days: {slope:+.3f} mm/s per day. "
f"At this rate the RMS reaches {LIMIT} mm/s in about {days_left:.0f} days.")
first_crest = next((d["day"] for d in days if d["crest"] >= 3), "not yet")
first_rms = next((d["day"] for d in days if d["rms"] >= GOOD), "not yet")
print(f"Today, {now['day']}: RMS {now['rms']:.2f} mm/s, peak {now['peak']:.2f}, crest {now['crest']:.2f}")
print(f"First day with crest factor over 3: {first_crest}. First day with RMS over {GOOD}: {first_rms}.")
print(facts)
print("Allowed by the thresholds:", ", ".join(allowed))
# 4. The model writes the note, and proposes an action from the list
schema = {
"type": "object",
"properties": {"note": {"type": "string"}, "action": {"type": "string", "enum": ACTIONS}},
"required": ["note", "action"],
}
answer = client.chat.completions.create(
model=MODEL,
temperature=0,
max_tokens=6000,
messages=[
{"role": "system", "content": "You write maintenance notes about the extraction fan of a school lab "
"(1470 rpm, small motor). Each day a sensor records one second of vibration "
"velocity; you get the RMS, the peak and the crest factor (peak / RMS) of each "
"day, in mm/s. Zones for this fan: RMS below 1.8 is good; 1.8 to 4.5 is "
"acceptable only for a limited time; above 4.5 can damage the machine. The "
"zones are for the RMS only, not for the peak. Write a note of four to six "
"sentences for the technician: what changed and when, and what to check. Then "
"choose one action: keep_running, inspect_within_a_month, repair_within_a_week, "
"stop_now."},
{"role": "user", "content": f"{table}\n\n{facts}"},
],
response_format={"type": "json_schema", "json_schema": {"name": "maintenance", "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.")
result = json.loads(answer.choices[0].message.content)
print(f"\nNote, by {MODEL}:\n{result['note']}\n")
if result["action"] in allowed:
print(f"Action: {result['action']}")
else:
print(f"NOT ALLOWED: the model chose {result['action']}. A person reads the note before anyone acts.")
Run it with python3 motor_note.py. With qwen3.8-27b it took about 17 seconds in our runs; most of that time the model reasons silently, which is why max_tokens leaves it room.
What you should see
Today, 2026-10-07: RMS 2.57 mm/s, peak 8.99, crest 3.51
First day with crest factor over 3: 2026-09-27. First day with RMS over 1.8: 2026-10-04.
Trend of the last 7 days: +0.180 mm/s per day. At this rate the RMS reaches 4.5 mm/s in about 11 days.
Allowed by the thresholds: repair_within_a_week
Note, by qwen3.8-27b:
The fan ran normally (RMS ≈ 1.05 mm/s) from 29 Aug through 15 Sep, but from 16 Sep the peak and crest factor began climbing while the RMS was still flat — a classic early sign of a developing bearing fault or a loose blade. From about 23 Sep the RMS itself started rising, and the rate has been accelerating: it crossed the 1.8 mm/s 'limited-time' threshold on 4 Oct and is now at 2.57 mm/s on 7 Oct, climbing roughly 0.18 mm/s per day. The crest factor has more than doubled from its healthy baseline of ~1.85 to 3.5, confirming that impulsive events (bearing spalling, a loose or unbalanced blade, or a worn motor mount) are getting worse. At the current rate the RMS will reach the 4.5 mm/s damage threshold in about 11 days, so the fan should not be left running without intervention. Please inspect the motor bearings, check blade balance and fastening, and verify the motor mounting bolts; the accelerating trend suggests the fault is progressing, not static. Schedule the repair within the next week to stay ahead of the 4.5 mm/s limit.
Action: repair_within_a_week
The crest factor passed 3 on 27 September; the RMS passed 1.8 a week later. The crest factor warns first. The note found the right start too, 16 September, the day the generator starts the damage. Read it to the end anyway: “The crest factor has more than doubled from its healthy baseline of ~1.85 to 3.5” is not true, because 3.5 is less than twice 1.85. The program checks the action, not the words around the numbers.
When the check says no
With this program qwen3.8-27b chose repair_within_a_week in all four of our runs. gpt-oss-120b did it in two runs out of three; gemma-4-26b in none of three:
Note, by gpt-oss-120b:
… The 7‑day trend of +0.18 mm/s per day means the RMS will cross the 4.5 mm/s damage threshold in roughly 11 days if the rise continues. Check the fan’s bearing lubrication, alignment, and any loosened mounting hardware, and verify that the motor’s speed controller is not drifting.
NOT ALLOWED: the model chose inspect_within_a_month. A person reads the note before anyone acts.
The note says eleven days; the action says a month. Gemma wrote “Please check the motor mounts and the fan blade balance immediately…”, and chose inspect_within_a_month too. A note and an action that do not agree are easy to miss when you read, and easy to catch in code: the enum makes the action a word your program can compare, and the thresholds say which words are allowed.
One sentence in the system message
Our first system message did not have “The zones are for the RMS only, not for the peak.” Run as if it were 1 October (python3 motor_note.py qwen3.8-27b 2026-10-01), when the RMS was 1.48 and the thresholds allow only keep_running or inspect_within_a_month, Qwen3.8 and Gemma chose repair_within_a_week in three runs out of three each, and the program printed NOT ALLOWED. Their notes said why:
… yet the peak has hit 4.92 mm/s—already above the 4.5 damage threshold— …
The zones are for the RMS, and the model had compared the peak with them. gpt-oss-120b chose inspect_within_a_month in all three. With the sentence, Qwen3.8 chose inspect_within_a_month in five runs out of five and Gemma in three out of three. The same sentence moved Gemma on 7 October too, the other way: before it, repair_within_a_week in three runs out of three; with it, inspect_within_a_month in three out of three. A sentence can fix one case and break another. The check stays the same, and it catches both.
Who was right on 1 October?
The thresholds said “inspect within a month”, and the straight line said 57 days to 4.5 mm/s. Change DAYS = 40 to DAYS = 45 in the generator and look: the RMS passes 4.5 on 12 October, eleven days after the 1st. The models that wanted a repair were right to worry, for a wrong reason.
The straight line is the weak part of the rule: through a curve that bends upward, it is always optimistic. On 1 October it said 57 days; on 7 October it said 11; the real answer, on the 7th, was 5. That is why NOT ALLOWED does not throw the note away: it sends it to a person, who reads the note, looks at the table and decides.
Good to know
- Send numbers, not samples. On a real device, compute RMS, peak and crest factor on the board and send three numbers a day, not a thousand.
- The enum is what makes the check possible. “Schedule the repair within the next week” in free text is hard for a program to compare;
repair_within_a_weekis not. - Times in our runs: about 17 seconds for
qwen3.8-27b, 13 forgpt-oss-120b, 2 forgemma-4-26b. - Not a real diagnosis. A crest factor that climbs is a classic sign of hits, from a bearing or something loose. The note can only suggest where to look; the technician's hands and ears decide.
- A flat or falling trend. When the slope is 0 or below,
days_leftismath.inf, and the message to the model says “in about inf days”. It did not happen with this data; with yours, write “the trend is not rising” instead of a number of days.
Exercise
- Run it as if it were 24 September, 1 October and 5 October:
python3 motor_note.py qwen3.8-27b 2026-09-24. Which actions are allowed each day? Does the model stay inside them? - Remove “The zones are for the RMS only, not for the peak.” and run it for 1 October with
gemma-4-26b. - Add a rule for the crest factor: above 3,
keep_runningis not allowed. On which day does it change the allowed actions? - Set
DAYS = 45, run the generator and the program again. What does it say on 12 October?
