Compare commits

...
3 Commits
Author SHA1 Message Date
goodwe-agent 0f9c119bcf SAFETY-136 round 2: tests for override and direction, true wording
checks / unit (pull_request) Successful in 26m2s
infra-pin / fresh (pull_request) Successful in 2s
F1 the on-change case is tested as slow, F2 override and direction checks,
F3 the 3.75 s rule in the texts, F4 the quarter figures and the live note.
No behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:51:06 +01:00
goodwe-agent c50e2279dd SAFETY-136: a gentler loop on a slow meter until the last move shows
checks / unit (pull_request) Successful in 26m18s
infra-pin / fresh (pull_request) Successful in 2s
On a meter whose readings come 4 s or more apart, the loop runs at half its
gain after a move of 150 W or more, until the meter has fallen by half of the
move or 2 cycles have passed. With readings 10 s late on a 5 s meter the export
falls 44 % over oven, kettle and hob, owner shape, steps; 1-2 s meters are
untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 14:49:26 +01:00
admin 8508ac33f0 Merge pull request 'MAINT-21: split app/main.py Controller into ctl_*.py mixins' (#243) from MAINT-21 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-09 08:06:31 +01:00
8 changed files with 450 additions and 8 deletions
@@ -71,7 +71,8 @@ The fits run all the time; the pull request has the counts.
**What it costs.**
- Found 36-68 s after a lag starts at 1-2 s meters; at a 5 s meter 30 s late
is found within 100 s after it starts (88 s measured) (the loop first moves 100 W after 60 s,
SAFETY-129) with the export above -200 W, and 10 s late is not found. Until
SAFETY-129) with the export above -200 W, and 10 s late is not found (SAFETY-136 calms
the loop at a slow meter instead). Until
then control is as before. In a quiet house the "readings late" row stays
about 2 minutes longer after the lag ends (126 s against 32 s on base); no
harm measured, and it clears as early as on base when the load changes.
@@ -0,0 +1,81 @@
# SAFETY-136 - a slow meter gets a gentler loop until the last move shows
**Status: for review.** Code: `app/settle.py`, a call in `Controller.cycle()`
(`app/ctl_cycle.py`). Tests: `test_safety136.py`. No option, no text change.
**Problem.** On a 5 s meter, Home Assistant readings 10-15 s late are not
found (SAFETY-129): they look like a slow battery (R2-1), and a hold does not
help (a perfect one cut the export of the oven by 7 %, SAFETY-129 review).
With moving loads the loop answers the same stale error at each cycle and
overshoots: an oven, a kettle and hob, the owner's day or a step export 3-10
times what they do with no lag. The fix is a calmer loop, not a detector.
**Decision.** After a move of 150 W or more, a loop on a slow meter runs at half
its gain until the meter has fallen by half of that move, or for 2 cycles.
Slow is judged from the readings that arrive: the usual gap
between them, over the last 60 s and at least 5 readings, is over 3.75 s (the
limit of SAFETY-128's fast meter fit). It counts arrivals, not changes of
value: on the recorded day at 1-2 s the value-based test (`MeterFit.slow()`)
called 24 % of the cycles slow, because the recorded house repeats a value
for 5 s or more, and the commands then differed from base. So there is no
option. At a meter with a usual gap of 3.75 s or less (1-3 s) it is false, the tuning is never touched and the
commands are those of the tree before. A meter that Home Assistant passes on
only when its value changes looks as slow as its changes are far apart, as it
does to SAFETY-128's fits: a 1-2 s meter with a value that holds for 5 s or
more is then treated as slow, and the loop is gentler for it (the cost below).
With no lag the meter shows a move at the next reading, so the wait ends at
once.
**Candidates** (SAFETY-129 sim, real `cycle()`, 5 s meter, sum over oven,
kettle and hob, owner shape over the oven and over the heat pump, step down,
step up, flat; export Wh, lag 0 / 10 s / 15 s; base 64 / 340 / 363):
| Candidate, only at a slow meter | 0 s | 10 s | 15 s | Import Wh, 0 / 10 / 15 s |
|---|---|---|---|---|
| gain 0.4 | 97 | 201 | 299 | 97 / 224 / 301 |
| gain 0.3 | 128 | 185 | 322 | 132 / 232 / 293 |
| step limit 500 W | 118 | 285 | 293 | 110 / 325 / 324 |
| cut fast, raise slowly (0.6 / 0.3) | 55 | 140 | 226 | 140 / 332 / 345 |
| ignore 1 cycle after a move | 93 | 216 | 325 | 90 / 240 / 291 |
| ignore 2 cycles after a move, unless the meter showed half | 69 | 206 | 277 | 62 / 240 / 354 |
| **half gain for 2 cycles after a move, unless the meter showed half** | **67** | **189** | **296** | **62 / 223 / 288** |
A lower gain for good, a step limit and a fixed ignore all make a 5 s meter
with no lag worse (export +30-110 Wh, quarter peak up to 150 W): that is the
common case. Cutting fast and raising slowly moves export to import and peak.
Waiting only while the last move has not shown costs nothing with no lag.
Ignoring the meter (the 2-cycle line) was worse than half gain where the plant
is slow, not the readings late: a 12 s dead time with one inverter went from
250 to 841 Wh import and a quarter peak of 206 to 1 509 W, so the loop keeps
following at half gain.
**What it gives** (5 s meter; the pull request and the report have each
scenario). Export summed over the seven houses: 10 s late 340 -> 189 Wh
(-44 %, import 363 -> 223), 15 s late 363 -> 296 Wh (-19 %, import 281 ->
288), together -31 %. At 15 s the oven and the owner's oven are not better
(92 / 39 Wh against 91 / 37), the kettle and the steps are. No lag: export
64.4 -> 66.5 Wh (kettle +2), import and peak the same.
**What it costs.** The loop follows a real step more slowly: half gain for up
to two cycles after each move of 150 W or more. Of the 136 sim runs (every
SAFETY-129 review scenario and its 15 harm runs), 56 at 4-10 s meters differ
from base. Together their export is 10 363 -> 9 559 Wh (-8 %) and import
8 389 -> 7 835 Wh (-7 %). Only 5 export more than 1 Wh and 14 import more:
export by at most 11.5 Wh (a 10 s battery lag, +10 %), import by at most
17 Wh (a 12 s dead time, one inverter, +7 %), and the quarter average by at
most 35 W, except two import quarters: +73 W (10 s meter, 10 s dead time,
export -59 Wh) and +40 W (`dead10_p6`, -6 to 34 W, export -68 Wh). Slow
batteries with a dead time of 8-10 s export 5-39 % less; a 12 s dead time is
the same. `READINGS_LATE` holds are the same seconds in every run.
**Live, a 1-2 s meter can be judged slow.** Through Home Assistant on change,
a value that holds for 4-10 s makes a 1 s meter slow. In 20 rig scenarios the
commands were those of base. On the recorded day (1 s, N=1, 1 day, repeats
dropped; its values hold about 5 s) it was slow in 33 % of cycles, half gain
in 83 cycles, 15 422 commands in both trees, 4 016 with another value (at
most 190 W apart), cost 0.61 EUR in both (review of SAFETY-136).
**Does not change.** Meters of 1-2 s: the same commands in all 63 runs (34
at 1 s, 29 at 2 s), and the recorded-day digests (see the report). The late
verdict, the hold and the freeze tests. 15 s late is only partly helped, and
30 s late is the hold's (SAFETY-129).
+14 -6
View File
@@ -734,15 +734,21 @@ class CycleMixin:
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
# AGAINST. (DECISIONS/MAIN-orchestration-archive.md)
loop_prev = self.target
# SAFETY-95: a drain's tail is not the loop's to chase.
# OPT-15: `loop_grid` is the grid, or the target while a run
# edge is held.
grid_in = (self._drain_grid_w(_live96) if self._drain_tail
else loop_grid)
# SAFETY-136: on a slow meter, a gentler loop until the last move
# shows.
slow_meter = self.settle.slow()
if moved or mode_trade:
self.settle.reset()
decision = compute(
prev_w=loop_prev,
# SAFETY-95: a drain's tail is not the loop's to chase.
# OPT-15: `loop_grid` is the grid, or the target while a run
# edge is held.
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
else loop_grid),
grid_w=grid_in,
actual_w=site_batt,
tuning=self.tuning,
tuning=self.settle.tuning(self.tuning, grid_in, slow_meter),
sat_count=self.sat_count,
i_w=self.i_w,
# SAFETY-52 at one inverter only: with several, OPT-09's pack
@@ -772,6 +778,7 @@ class CycleMixin:
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
f"{site_batt:.0f} W)",
hint=messages.hint("not_keeping_up"))
self.settle.moved(grid_in, loop_prev, decision.target_w, slow_meter)
self.sat_count = decision.sat_count
self.i_w = decision.i_w
self.loop_w = decision.target_w
@@ -811,6 +818,7 @@ class CycleMixin:
else:
# Stopped or blind: no accumulation may survive the outage.
self.i_w = 0.0
self.settle.reset()
self.loop_w = None
self._live_was = frozenset(_live96)
# TEL-10, and it is the same zero the line above writes rather than
+1
View File
@@ -197,6 +197,7 @@ class InputsMixin:
# correlates 0.998 with battery power and 0.09 with the real meter
# (a controller would regulate against its own output).
self.grid = self.p1.net_w
self.settle.arrive(now_mono, self.p1.samples) # SAFETY-136
else:
self.grid = await self.hass.number(o.get("meter_entity", ""),
bool(o.get("meter_invert")))
+2
View File
@@ -63,6 +63,7 @@ from .clamp import (
)
from . import freshness
from . import lateness
from .settle import Settle
from . import dayview, homeview
from .daycurve import SolarToday
from . import handback
@@ -741,6 +742,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._arb_action = None
self.inputs_bad_since = None
self.answer = lateness.Answer() # SAFETY-128
self.settle = Settle() # SAFETY-136
self._late_logged = self._late_zero = False
# SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged,
# note, over_rating)` (SAFETY-40 splits the last out). Site-level: the
+74
View File
@@ -0,0 +1,74 @@
"""SAFETY-136: on a slow meter, follow the meter gently until the last move shows.
A meter whose usual gap between readings is over 3.75 s (a 4-5 s meter), read
through Home Assistant, can be 10-15 s late. The loop then answers the same error again at each cycle
and overshoots. After a move of MOVE_W or more the loop therefore runs at
WAIT_SHARE of its gain until the meter has fallen SHOWN of the move, or for at
most WAIT_CYCLES cycles. Off for a meter with a usual gap of 3.75 s or less, so those sites run as
before. DECISIONS/SAFETY-136-slow-meter-settle.md.
"""
from collections import deque
from dataclasses import replace
from .lateness import M_FIT_S, M_ROWS
# A meter is slow when its readings come further apart than this (the usual
# gap, over the last 60 s and at least 5 readings): the fast meter fit's own
# limit (SAFETY-128), 3.75 s.
SLOW_GAP_S = M_FIT_S / M_ROWS
SLOW_ROWS = 5
# A move this big is waited for (above the house noise of about 80 W).
MOVE_W = 150.0
# The meter has shown a move once it fell by this share of it.
SHOWN = 0.5
# Meanwhile the loop runs at this share of its gain.
WAIT_SHARE = 0.5
# Wait at most this many cycles (10 s at a 5 s meter).
WAIT_CYCLES = 2
class Settle:
def __init__(self):
self.pend = None # (meter at the move, the move in W, cycles waited)
self._n = None # readings taken in so far
self._at = deque() # when the last ones arrived, over M_FIT_S
def arrive(self, now, n):
"""Each read: `n` is the count of meter readings taken in. A reading
counts when it arrives, not when its value changes, so a steady load on
a 1 s meter is not a slow meter."""
if n != self._n:
self._n = n
self._at.append(now)
while self._at and self._at[0] < now - M_FIT_S:
self._at.popleft()
def slow(self):
"""The usual gap between readings is over SLOW_GAP_S."""
ts = list(self._at)
if len(ts) < SLOW_ROWS:
return False
gaps = sorted(b - a for a, b in zip(ts, ts[1:]))
return gaps[len(gaps) // 2] > SLOW_GAP_S
def reset(self):
self.pend = None
def tuning(self, tuning, grid_w, slow):
"""The tuning for this cycle: `tuning`, or with the gain cut while the
last move has not shown on the meter (`grid_w`)."""
p = self.pend
if p is None:
return tuning
g0, move, waited = p
if not slow or (g0 - grid_w) / move >= SHOWN or waited >= WAIT_CYCLES:
self.pend = None
return tuning
self.pend = (g0, move, waited + 1)
return replace(tuning, gain=tuning.gain * WAIT_SHARE)
def moved(self, grid_w, prev_w, new_w, slow):
"""After a cycle: start waiting if the loop moved by MOVE_W or more."""
if slow and self.pend is None and abs(new_w - prev_w) >= MOVE_W:
self.pend = (float(grid_w), float(new_w - prev_w), 0)
+13 -1
View File
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
import safety128_rig as R # noqa: E402
import app.main as app_main # noqa: E402
from app import lateness # noqa: E402
from app import settle # noqa: E402
fails = []
@@ -58,6 +59,17 @@ def no_gate(fn):
app_main.Controller._readings_late = real
def no_settle(fn):
"""`fn()` without SAFETY-136's gentler loop: the tree this card was written
against (with it the same run exports -645 W, not -1 325 W)."""
real = settle.Settle.moved
settle.Settle.moved = lambda self, *a, **k: None
try:
return fn()
finally:
settle.Settle.moved = real
def run_late(**kw):
"""Run the rig; (rig, seconds the verdict stood, first second it stood)."""
dur = kw.pop("dur")
@@ -91,7 +103,7 @@ for units, house in ((AB, 1800.0), (A, 1500.0)):
if len(units) == 2:
old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0,
units=units, p1_s=5.0)
no_gate(lambda: old.run(ON + 300.0))
no_settle(lambda: no_gate(lambda: old.run(ON + 300.0)))
check(f"{n}: without the gate the export goes below -1 kW",
grid_min(old.rows, ON, ON + 300.0) < -1000.0)
+263
View File
@@ -0,0 +1,263 @@
"""Runnable check for SAFETY-136: a calmer loop on a slow meter.
`python3 test_safety136.py`
With a 5 s meter and Home Assistant readings 10-15 s late the loop answers the
same error again at each cycle and overshoots: with an oven, a kettle and hob,
or a step the export was 3-10 times that of a meter with no lag. After a move
of 150 W or more `app/settle.py` now runs the loop at half its gain (at most
2 cycles) until the meter shows half of it. It is off for a meter under 4 s.
DECISIONS/SAFETY-136-slow-meter-settle.md.
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Off" is the
same run with `Settle.moved` doing nothing: the tree before this card.
1. `Settle` alone.
2. 5 s meter, readings 10 s late: oven, kettle and hob, a step down and a step
up export clearly less than with it off.
3. 5 s meter, no lag: the same houses are no worse than a small margin.
4. 1 s and 2 s meters: the commands are those of the run with it off, and the
loop never waits.
5. The slow meter is told by its observed interval, at 4 s and 5 s.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import safety128_rig as R # noqa: E402
from app.control import Tuning # noqa: E402
from named_absence import nth, present # noqa: E402
from app import settle # noqa: E402
from app.lateness import MeterFit # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def off(fn):
"""`fn()` with the settle wait off: the tree before this card."""
real = settle.Settle.moved
settle.Settle.moved = lambda self, *a, **k: None
try:
return fn()
finally:
settle.Settle.moved = real
def oven(t):
return 1800.0 + (2000.0 if (t >= 300.0 and (t - 300.0) % 105 < 45) else 0.0)
def kettle(t):
w = 600.0
if t % 400 < 120:
w += 2200
if t >= 900 and t % 90 < 30:
w += 1500
return w
def step(t):
return 1800.0 if t < 320.0 else 300.0
def stepup(t):
return 300.0 if t < 320.0 else 2500.0
# name: (house, noise, run length, lag end)
HOUSES = {"oven": (oven, 80.0, 900.0, 900.0),
"kettle": (kettle, 80.0, 1500.0, 1500.0),
"step": (step, 50.0, 700.0, 600.0),
"stepup": (stepup, 50.0, 700.0, 600.0)}
ON = 300.0
def wh(rows, t0, t1, sign):
"""Export (sign -1) or import (sign +1) energy in Wh over [t0, t1)."""
return sum((-min(r[1], 0.0) if sign < 0 else max(r[1], 0.0))
for r in rows if t0 <= r[0] < t1) / 3600.0
def run(name, lag, p1_s=5.0, units=("a", "b"), dur=None):
house, noise, length, lag_off = HOUSES[name]
r = R.Rig(house=house, noise=noise, p1_s=p1_s, units=units, lag_s=lag,
lag_on=ON, lag_off=lag_off)
waits = []
r.run(dur or length + 20.0,
lambda rig, c, t: waits.append(1) if c.settle.pend else None)
return r, len(waits)
# --------------------------------------------------------------------------- #
print("1. Settle alone")
T = Tuning()
s = settle.Settle()
check("no move: the tuning is untouched", s.tuning(T, 500.0, True) is T)
s.moved(500.0, 0.0, 100.0, True)
check("a move under 150 W is not waited for", s.pend is None)
s.moved(500.0, 0.0, 600.0, False)
check("a meter that is not slow is not waited for", s.pend is None)
s.moved(500.0, 0.0, 600.0, True)
check("a move of 600 W is waited for: half the gain",
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
check("the meter fell 200 W of 600 W: still half the gain",
s.tuning(T, 300.0, True).gain == T.gain * 0.5)
check("two cycles waited: the third has the whole gain",
s.tuning(T, 300.0, True) is T and s.pend is None)
s.moved(500.0, 0.0, 600.0, True)
check("the meter went the other way by half the move: the wait stands",
s.tuning(T, 800.0, True).gain == T.gain * 0.5)
s.reset()
s.moved(500.0, 0.0, -600.0, True)
check("a move down, the meter rose half the move: the whole gain",
s.tuning(T, 800.0, True) is T)
s.reset()
s.moved(500.0, 0.0, 600.0, True)
check("the meter fell half the move: the whole gain at once",
s.tuning(T, 200.0, True) is T and s.pend is None)
s.moved(500.0, 0.0, -600.0, True)
check("a move down is waited for too",
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
s.reset()
check("reset ends the wait", s.pend is None)
s.moved(500.0, 0.0, 600.0, True)
check("a meter that turns out not to be slow ends the wait",
s.tuning(T, 500.0, False) is T and s.pend is None)
check("the numbers are pinned",
(settle.MOVE_W, settle.SHOWN, settle.WAIT_SHARE, settle.WAIT_CYCLES)
== (150.0, 0.5, 0.5, 2))
# --------------------------------------------------------------------------- #
print("\n2. 5 s meter, readings 10 s late")
tot_on = tot_off = 0.0
for name, (_h, _n, length, lag_off) in HOUSES.items():
a, waited = run(name, 10.0)
b, _w = off(lambda: run(name, 10.0))
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
tot_on, tot_off = tot_on + ea, tot_off + eb
print(f" {name}: export {ea:.1f} Wh, off {eb:.1f} Wh, waited {waited} s")
check(f"{name}: waits at some point", waited > 0)
check(f"{name}: exports at most 75 % of the run with it off",
ea <= 0.75 * eb)
print(f" sum: {tot_on:.1f} Wh, off {tot_off:.1f} Wh")
check("the four together export at most 65 % of the run with it off",
tot_on <= 0.65 * tot_off)
# --------------------------------------------------------------------------- #
print("\n2b. Another writer overrides the loop: the wait ends")
halved = []
real_tuning = settle.Settle.tuning
def watch_tuning(self, tuning, grid_w, slow):
r = real_tuning(self, tuning, grid_w, slow)
halved.append(r is not tuning)
return r
settle.Settle.tuning = watch_tuning
try:
mark = []
def bump(rig, c, t):
# right after a cycle that started a wait, another writer moves the
# command, so the next cycle sees `target != loop_w`
if not mark and c.settle.pend and t > 330.0:
mark.append(len(halved))
c.target = c.target + 300.0
r = R.Rig(house=oven, noise=80.0, p1_s=5.0, lag_s=10.0, lag_on=ON,
lag_off=900.0)
r.run(500.0, bump)
finally:
settle.Settle.tuning = real_tuning
nxt = nth(halved, mark[0] if mark else 10 ** 6)
check("the cycle after an override has the whole gain",
bool(mark) and present(nxt) and nxt is False)
# --------------------------------------------------------------------------- #
print("\n3. 5 s meter, no lag")
for name in ("oven", "kettle", "step", "stepup"):
_h, _n, length, lag_off = HOUSES[name]
a, _w = run(name, 0.0)
b, _w = off(lambda: run(name, 0.0))
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
ia, ib = wh(a.rows, ON, lag_off, 1), wh(b.rows, ON, lag_off, 1)
print(f" {name}: export {ea:.1f} Wh (off {eb:.1f}), import {ia:.1f} Wh "
f"(off {ib:.1f})")
check(f"{name}: export at most 6 Wh over the run with it off",
ea <= eb + 6.0)
check(f"{name}: import at most 1 Wh over the run with it off",
ia <= ib + 1.0)
# --------------------------------------------------------------------------- #
print("\n4. 1 s and 2 s meters")
for p1 in (1.0, 2.0):
for lag in (0.0, 10.0):
for units in (("a", "b"), ("a",)):
a, waited = run("oven", lag, p1_s=p1, units=units, dur=700.0)
b, _w = off(lambda: run("oven", lag, p1_s=p1, units=units,
dur=700.0))
same = ([(r[0], r[1], r[2]) for r in a.rows]
== [(r[0], r[1], r[2]) for r in b.rows])
check(f"{p1:.0f} s meter, {lag:.0f} s late, N={len(units)}: "
f"same commands, never waits", same and waited == 0)
# --------------------------------------------------------------------------- #
print("\n5. The slow meter is told by its observed interval")
s = settle.Settle()
for k in range(30):
s.arrive(float(k), k)
check("a reading each second is not a slow meter", not s.slow())
s = settle.Settle()
for k in range(30):
s.arrive(5.0 * k, k)
check("a reading every 5 s is a slow meter", s.slow())
s = settle.Settle()
for k in range(4):
s.arrive(5.0 * k, k)
check("fewer than 5 readings in a minute tell nothing", not s.slow())
s = settle.Settle()
for k in range(30):
s.arrive(5.0 * k, 0 if k else 1)
check("the same count again is no new reading", not s.slow())
for p1, expect in ((1.0, False), (2.0, False), (3.0, False), (4.0, True),
(5.0, True), (10.0, True)):
r = R.Rig(house=1800.0, p1_s=p1)
seen = []
r.run(120.0, lambda rig, c, t: seen.append(c.settle.slow()))
last = nth(seen, -1)
check(f"{p1:.0f} s meter: slow is {expect}",
present(last) and last is expect)
# A 1 s meter on a house that changes every 5 s repeats its value 4 times in 5.
# Where every reading arrives (the rig, a polled meter) it is not slow; where
# Home Assistant sends a reading only when the value changes (ha_signed) the
# same meter is slow (T3 of the review; DECISIONS).
s, m, h = settle.Settle(), MeterFit(), settle.Settle()
for k in range(60):
s.arrive(float(k), k)
h.arrive(float(k), k // 5)
m.add(float(k), 0.0, 1800.0 + 100.0 * (k // 5))
check("every reading arrives, value changes every 5 s: not slow (the value "
"fit says slow)", not s.slow() and m.slow())
check("the same meter through Home Assistant on change (a reading per "
"change): slow", h.slow())
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")