SAFETY-103: no swing on old battery readings after a drain ends
checks / unit (pull_request) Failing after 35m9s
infra-pin / fresh (pull_request) Successful in 2s

While a battery settles from its ended drain (SAFETY-95), the house point
was grid + every battery's reading. A register can be 10 s older than the
meter, so B swung 860 -> 1650 -> 870 W on A's old reading (rig check 5).

Now the house point is held and moved only as far as the meter proves:
each battery is between its last changed reading and its commands since
(a reading that changed this cycle, or a refused side, as it reads).
N=1 and 1 s registers are byte-identical to release/1.0.

DECISIONS/SAFETY-103-old-readings-after-a-drain.md, test_safety103.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 02:25:55 +02:00
co-authored by Claude Opus 5.5
parent 451729c9b5
commit 138e45b53f
5 changed files with 727 additions and 4 deletions
@@ -0,0 +1,79 @@
# SAFETY-103 - no swing on old battery readings after a drain ends
**Status: implemented 2026-10-08, for review.** Tests: `test_safety103.py`.
**The defect** (SAFETY-95 rig test, check 5, F1). While a battery settles
from its ended drain, SAFETY-95 steers on the house's point: grid + every
battery's reading - aim. The meter is 1 s old; a battery register can be
10 s old (the SAFETY-90 register model). At E+13 A's reading said 1238 W
(6.8 s old) while A was at 616 W, so the house looked ~620 W bigger: B went
860 -> 1650 -> 870 W. B's own old reading does the same the other way.
**The rule** (`main.py`, `_track_reads()`, `_drain_grid_w()`, N>=2, only
while a drain's tail settles):
1. Each cycle, per battery: the range its power can be in. That is its last
changed reading, widened by every command on its wire since (a lagging
battery moves from one toward the others, never beyond). A new reading
starts the range again.
2. A reading that changed this cycle is taken as it reads. So is the side a
battery refuses (SAFETY-73): it is not heading that way.
3. The house point is held (first cycle: the loop's start from SAFETY-95).
It moves only as far as the meter proves: to the nearest value of grid +
the batteries' ranges - aim.
So the loop no longer moves on a reading that has not caught up. A real
house step still moves it, as soon as the meter shows more than the
batteries can explain.
**Why not the tester's rule** ("a battery whose reading is older than its
last command counts at its command"). B's command changes almost every
cycle, so B is almost always at its command. That is the plain meter, which
chases A's tail: B swings 1420 W. A lag model needs the plant's time
constant, which is not known for a client's inverter. Measured on the plant
below (N=2, 1200 W, A Off; swing = a move of the others later undone):
| rule | 10 s lag | 5 s lag | 20 s lag | surplus, 5 s lag |
|---|---|---|---|---|
| release/1.0 (readings) | 760 | 840 | 150 | 800 |
| tester's: at the command | 1420 | 1020 | 1600 | 800 |
| lag model, tau 10 s | 70 | 140 | 80 | 580 |
| lag model, tau 5 s, plant 20 s | | | 600 | |
| hold all window | 0 | 0 | 0 | 0 |
| **this rule** | **0** | **0** | **0** | **130** |
Holding all window ignores a house step: a 2 kW kettle at E+5 s imported
14.4 Wh over the next 30 s (release 11.5, this rule 11.3); 2 kW more sun
exported 16.9 Wh (release 8.5, this rule 8.9).
**Before -> after** (`test_safety103.py`, `tools/safety103_plant.py`: 800
W/s ramp, 10 s lag, meter 1 s, registers 10 s at the rig's phases, B idles
at -100 W; summed swing of the others in the 23 s after the end):
| case | release/1.0 | after |
|---|---|---|
| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870, swing 760 W | B holds 1140 W |
| N=3, 1200 W / 4000 W, A Off | 680 / 630 W | 0 / 0 W |
| solar surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W |
| lag 5 or 20 s, registers 5 or 10 s, N=2 and N=3 | | at most 10 W |
| registers as fresh as the meter (1 s) | | rows byte-identical |
| refusal end | | rows byte-identical |
| N=1, every end | | rows byte-identical (also against the release/1.0 tree) |
**Cost.** In the window, a house change smaller than what the batteries
could still be doing is not followed until a reading catches up (at most a
register period) or the window ends (23 s). Under a surplus the others
still swing up to 130 W, a third of a slew step.
**Not covered.**
- Outside the drain tail the loop steers on the meter; readings only reach
the saturation freeze (SAFETY-96), unchanged.
- A lost link or a refusal ends the tail at once (A is out of the split, or
reads ~0 W). The swing after them is the loop's own on a lagging plant,
the same with 1 s registers and on release/1.0 (up to 1.9 kW at N=3 under
a surplus): not this card.
- A battery that overshoots its command, or a reading offset beyond the
range, can put the true power outside the range; the meter then moves the
point, as before.
- The end cycle's start (SAFETY-95 step 2) still uses the readings; the
batteries were steady during the drain, so they are current there.