Skip to content
Fondazione ITS INCOM

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:

Then:

  1. code computes the three numbers for each day, and the trend of the RMS;
  2. code decides which actions the thresholds allow;
  3. the model writes the note for the technician and picks one action from a list;
  4. 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:

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

Exercise

All examples