SAFETY-128: control does not act on late Home Assistant readings
checks / unit (pull_request) Successful in 19m36s
infra-pin / fresh (pull_request) Successful in 1s

Readings that Home Assistant delivers late but still changing passed
every staleness gate, so the loop corrected again each cycle an error
its last command had already fixed (10 s late: the give run swung and
exported to -1.9 kW), and the peak guard pushed to the inverter limit
on the same late figures (OPT-34 F-2).

app/lateness.py fits how long after a power request the meter shows
it. Over 8 s the readings are late: a request the battery reading has
not answered holds the loop (it may not lead the reading by more than
100 W) and caps the peak guard at what stands; unanswered for 23 s it
holds, then 0 W after stale_input_s. An amber row and one log line per
episode say so. With readings on time nothing changes (digests equal).

DECISIONS/SAFETY-128-late-readings.md; test_safety128.py.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-11-01 23:45:03 +01:00
co-authored by Claude
parent 77fe31b161
commit 09e1fbb898
14 changed files with 1100 additions and 2 deletions
@@ -0,0 +1,73 @@
# SAFETY-128 - a reading that does not answer the last command is not current
**Status: FIXED on release/1.0 (after 0.20.0).** Code: `app/lateness.py`,
`control.compute(hold=, hold_w=)`, `peakguard.cap_floor()`,
`Controller._readings_late()`. Tests: `test_safety128.py`
(`tools/safety128_rig.py`, TEST-23's plant).
**Cause** (SAFETY-127 report). Home Assistant can deliver readings late but
still changing. Every gate judges arrival (SAFETY-01) or a value that stops
changing (SAFETY-18, SAFETY-37), so a late reading passes all of them. The loop
then corrects, each cycle, an error its last command has already fixed: a loop
with a dead time far longer than its cycle, which swings and grows. The peak
guard projects the quarter from the same late figures and pushes to the
inverter limit in its last seconds (OPT-34 rig F-2).
**Rule.**
1. *Find late readings.* `Delay` fits, every 4 s over the last 40 s of new
meter values, how long after a power request the meter shows it: the meter
is the house less the batteries, so `meter(t) + request(t - d)` is flattest
at the right `d`. Over `DELAY_S` the readings are late (`late_seen`). The
fit counts only if the requests explain half the meter's swing; a late `d`
only if it fits 30 % better than any fresh one and better than a slow
battery answering at once (10 or 20 s lag, the SAFETY-85/94 rigs). A
silent meter adds no values (SAFETY-99/125). A battery that gives nothing
under its ask, or refuses, spoils the fit and is left out for 40 s. With no
fit for 120 s (a steady site shows nothing) the verdict is dropped.
Nothing below acts unless `late_seen`, so with readings on time control is
unchanged.
2. *Hold.* While `late_seen`, a request more than `ANSWER_W` from the battery
reading is pending until that reading comes within `ANSWER_W` of it, or half
way. Meanwhile the loop may not lead the reading by more than `ANSWER_W`
that way (`compute(hold=, hold_w=)`; it may come back), and the peak
guard's floor is capped at the request that stands (`cap_floor()`). The
loop is not held while a battery gives nothing under its ask or refuses:
SAFETY-52/94 need it to go on asking.
3. *Late.* Pending for `LATE_S` with no reading moving `ANSWER_W` towards it is
the late verdict: control holds, then after `stale_input_s` goes to 0 W
("readings late"), as for a missing input. It ends when a reading starts
towards the request.
4. *Seen.* While `late_seen` an amber row says "Home Assistant readings arrive
about N seconds late, so control holds the batteries." (NL too), and
"every inverter is at 0 W" past the hold. One WARNING (`READINGS_LATE`)
and one action-log line per episode, and one when it ends.
**Constants** (`app/lateness.py`).
| | Value | Why |
|---|---|---|
| `ANSWER_W` | 100 W | `control.PROBE_ANSWER_W`: a reading this far towards the ask has answered it (SAFETY-52's probe). |
| `DELAY_S` | 8 s | `freshness.PLANT_SETTLE_S` 6 s + one `LOOP_TICK_S` 2 s: a fresh meter shows a request within the plant's settle (SAFETY-75's 6 s dead time) and one meter tick. The fit reads 0 s at 1/2/5 s meters, 5-7 s with a 6 s dead time, 9 s at 10 s late. |
| `LATE_S` | 23 s | 2 x `PLANT_SETTLE_S` + `PUBLISH_PERIOD_S` 10 s + `READ_PERIOD_S` 1 s: SAFETY-75's 6 s dead time, the 6 s settle, one publish and one read, the slowest healthy battery answer. |
| `FIT_S`, `FIT_EVERY_S` | 40 s, 4 s | Short enough to find 10 s and 30 s late within 50-60 s of the start (the swing has not grown yet); one 2 s cycle in two. |
| `MAX_DELAY_S` | 90 s | F-2 measured 17-78 s. |
| `FIT_SHARE`, `FIT_BETTER` | 0.5, 0.7 | Below these a battery that stops following (a refusal at N=2) was read as 23 s late. |
| `SLOW_TAUS_S` | 10 s, 20 s | The SAFETY-85/94 lagging battery: without this check 16 of 36 of its reversals read as 11-56 s late. |
| `FIT_GONE_S` | 120 s | The verdict outlives the lag by at most `FIT_S` + this when the site goes steady. |
**What it costs.**
- Late readings are found only after the requests have moved 100 W and the
meter has shown it late: 48-68 s after the lag starts in the tests. Until
then control is as before.
- The row can stay up to 160 s after the readings are on time again, while
the site is steady.
- A battery that cannot follow while readings are late (at a limit, not
reading 0 W) is held, then set to 0 W after 38 s, until it moves.
- The fit costs about 93 x 20 sums every 4 s.
- With readings late, a large step is followed in steps of the lag: the loop
waits for each request to show before the next.
**Left out.** The round trip setpoint -> Home Assistant: the fitted delay gives
the same figure from readings the add-on already has. The arbitrage charge's
own sizing on late readings (`charge_room_w()`) is not capped here; the test
shows it swinging while the peak guard holds (see the pull request).