F1: resetting the count on each refused write hid a battery that really refuses behind a link refusing 1 write in 3 (never learned; learned at 358 s with the count kept). F2: a board at 0 W before its link is stale learned no refusal untested (mutation R7 survived); now a 1 s-lag case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
60 lines
3.0 KiB
Markdown
60 lines
3.0 KiB
Markdown
# SAFETY-96 - a battery the loop cannot move does not freeze it
|
|
|
|
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
|
|
|
|
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
|
|
the site target with the site sum of the readings. After A's link went
|
|
stale, A was out of the split but its reading (-2000 W, still charging) was
|
|
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
|
|
~236 cycles until the circulation trip. After a charge timeout or Off at
|
|
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
|
|
drop, B also took A's whole share in one step.
|
|
|
|
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
|
|
A battery is held out of the freeze test, counted at its command instead of
|
|
its reading, while:
|
|
|
|
- it is out of the split (writes refused for `LINK_STALE_S`);
|
|
- its last write was refused (its board may have gone to 0 W on its own
|
|
watchdog);
|
|
- it settles after a step the loop did not make: a charge end (SAFETY-93:
|
|
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
|
|
as the split is made again). Settling ends when its reading is within the
|
|
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
|
|
measured plant settle).
|
|
|
|
Its real power is not lost and not counted twice: the meter sees it, and
|
|
the loop follows the meter. Only the freeze stops reading it as the loop's.
|
|
The freeze still fires on the other batteries' own lag and on unplaced
|
|
watts.
|
|
|
|
At a drop the battery's share leaves the loop's start point, so the others
|
|
take over one slew step a cycle. At a rejoin it enters at its reading.
|
|
|
|
**Not a drop.** A missing reading or a stale register stops the site
|
|
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
|
|
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
|
|
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
|
|
battery whose writes are refused; its board going to 0 W is no refusal. Its
|
|
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
|
|
a battery that really refuses (review).
|
|
|
|
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
|
|
about the remaining battery's own lag):
|
|
|
|
| case | before | after |
|
|
|---|---|---|
|
|
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
|
|
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
|
|
| A drops while charging, board to 0 W | 5 | 0 |
|
|
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
|
|
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
|
|
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
|
|
| N=1, N=2 with no drop | | byte-identical |
|
|
|
|
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
|
|
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
|
|
charging on its own after its link drops still trips the circulation check
|
|
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
|
|
from the full site sum.
|