SAFETY-128 round 2: find late readings from the batteries, not the meter alone
checks / unit (pull_request) Successful in 26m50s
infra-pin / fresh (pull_request) Successful in 2s

Review B1: the meter fit took repeating house loads (ovens, a heat pump)
for late readings on healthy 1-2 s sites, about 9 % of the recorded
house. B2: at a 5 s meter it never ran. M1: no hysteresis. M2: an
unbounded check.

lateness.Delay now fits how long each battery's reading takes to show
its own request, which the house does not move, from the 1 s reads
(read_inputs -> Answer.watch). The meter fit stays for speed at 1-3 s
meters, but counts only when an ask was slow to start and no battery
fit said on time. Late from 9.5 s after 2 fits, on time under 6.5 s,
dropped after 120 s without a fit. Fits are bounded (a few ms).

Readings on time: no verdict in any reviewer scenario (22 shapes at
1/2/5 s meters, 7 harm runs) nor in 2 days of the recorded house at
1, 2 and 5 s ticks; digests equal to 77fe31b. Late: found 36-68 s after
the start at 1-2 s, about 3 min at 5 s. The row gives the batteries'
delay, or no figure while only the meter has shown it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-11-02 04:32:04 +01:00
co-authored by Claude
parent 09e1fbb898
commit d3f4f9f48b
5 changed files with 462 additions and 196 deletions
@@ -2,72 +2,80 @@
**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).
`Controller._readings_late()`, `Controller.read_inputs()` (feeds the fits).
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
changing (SAFETY-18, SAFETY-37), so a late reading passes them all. 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.
1. *Find late readings* (`Answer.watch()`, every 2 s, from the 1 s reads).
- **Batteries** (`Delay`): fit one `d` so that each battery's reading minus
its own request at `t - d` (averaged over 2 s for the battery's lag and
the read) is flattest. The house does not move that reading, so a
repeating load cannot line up with it (review round 1, B1). Needs 5 new
readings in 60 s and the request and the reading each to move 300 W.
Late from 9.5 s, after 2 late fits in a row. A late fit must explain 75 %
and beat every fit up to 6.5 s by 2x; the shortest delay that fits about
as well (within 2x, or within 2 500 W^2) is taken instead, since a swing's
period fits again. A slow battery (3-20 s lag) answering at once gives no
late fit (checked in the test). No late battery fit counts for 60 s
after a battery gave nothing under its ask: an emptied battery lined its
old movement up as 85 s late.
- **Meter** (`MeterFit`, round 1's fit): `meter(t) + request(t - d)` is the
house alone at the right `d`. Quick, but confounded by the house. Needs
16 new values in 60 s, so only a meter of about 3.5 s or faster: at 5 s
it gave false delays. Late over 8 s.
- A meter fit says late only with evidence from the batteries' side too:
a request more than 100 W from the battery reading took 3 s or more to
move a reading towards it (`SLOW_START_S`; on time they start within
0-1 s in the recorded house at 1 and 2 s ticks), and no battery fit in
the last 60 s said on time (under 9.5 s). An ask during which a battery
gave nothing under its ask is no slow evidence. A repeating load fools
the meter fit but not these. No meter verdict in the first 150 s.
- On time again on a battery fit under 6.5 s, or a meter fit within 8 s
unless a battery fit said late (hysteresis). With no fit for 120 s the
verdict is dropped.
2. *Hold.* While late, a request more than 100 W from the battery reading is
pending until that reading comes within 100 W of it, or half way. Meanwhile
the loop may not lead the reading by more than 100 W that way, and the peak
guard's floor is capped at the request that stands. The loop is not held
while a battery gives nothing under its ask or refuses (SAFETY-52/94).
3. *Late verdict.* Pending for `LATE_S` (23 s) with no reading moving 100 W
towards it: control holds, then after `stale_input_s` goes to 0 W
("readings late"), as for a missing input.
4. *Seen.* While late an amber row says "Home Assistant readings show requests
about N seconds late, so control holds the batteries." (NL too). N is the
batteries' fitted delay: Home Assistant's lag plus the battery's own
answer. While only the meter has shown it the row gives no figure. One
WARNING (`READINGS_LATE`) per episode and one line 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. |
**With readings on time** no verdict was reached in any reviewer scenario
(owner-shaped house, ovens 20/40 to 60/60 s, clouds, a 300 W/s battery, 6-10 s
dead times, HA jitter 0-4 s, an EV following the meter, capped batteries, HA
restarts, meter pauses; 1, 2 and 5 s meters) nor in 2 days of the recorded
house at 1, 2 and 5 s ticks, so the commands are the same as without the gate.
The fits run all the time; the pull request has the counts.
**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.
- Found 36-68 s after a lag starts at 1-2 s meters, about 3 minutes at a 5 s
meter (battery fit only; the meter fit needs a faster meter). Until then control is as before. At 5 s and 30 s
late the export is cut to about a third, not removed.
- A battery whose own answer takes about 10 s is close to the threshold: an
8 s dead time reads 7, 10 s reads 9, HA 10 s late reads 10.
- The row can stay up to 180 s after the readings are on time on a steady
site. While only the meter has shown the delay it gives no figure.
- A battery that cannot follow while the readings are late (at a limit, not
reading 0 W) is held, then set to 0 W after 38 s.
- The fits cost a few ms every 2 s (x86, measured after 3 idle days); the
history they read is bounded (60 s of readings, 150 s of requests).
**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).
**Left out.** The setpoint -> Home Assistant round trip: the fitted delay gives
the figure from readings the add-on already has. The hold's step-and-pull-back
at extreme lag and the arbitrage charge's sizing on late readings
(`charge_room_w()`) are follow-up cards.