When a battery's drain ended (done, Off, timeout, abort, stale link, refusal), its drain floor stayed in the site target and landed on the others: B 0 -> +1600 W in one cycle at a 1200 W house, exporting; under a solar surplus B gave into the export. Now, on that cycle, the loop starts from what the others stood at plus the battery's new command, raised to the house's own point when the house needs more (never above last cycle's start). While the battery settles, the loop counts the batteries at its start instead of chasing the drain's tail on the meter. Then one slew step a cycle. N=1 is unchanged. test_safety95.py; DECISIONS/SAFETY-95-drain-end.md; one DOCS line. test_modes.py: opt11 n2_maint re-pinned on purpose (its drain ends by a refusal at 1655 s; B now stays at the house's 1420 W instead of 2500 W). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3.7 KiB
SAFETY-95 - a maintenance drain that ends hands back smoothly
Status: implemented 2026-10-06, for review. Tests: test_safety95.py.
The defect (SAFETY-93 review, O1). At N=2, when A's drain ended (A switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one cycle and exported. It is the mirror of SAFETY-93 on the drain end.
Cause. During a drain the site target carries A's drain floor
(SAFETY-78, drain, 2000 W) above what the house needs. At the end A's
command drops (pin, Off, abort, stale link, refusal), but the loop slews
from last cycle's target (2000 -> 1600) and only B is left to place it.
Under a solar surplus B gave 1600 W into the export.
The rule (main.py, _reseed_drain_end(), _drain_grid_w(), N>=2
only). On the cycle a drain ends:
- The start point is what the others stood at plus A's new command (its pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
- If the house needs more, the start is raised to the house's own point
(live batteries' readings + grid - target, as
_reseed_run_edge()), but never above last cycle's start. So the others cover the house at once and never take over the drain's export. - While A settles (SAFETY-96's window: within the deadband, at most
PLANT_SETTLE_DOWN_S), the loop's error is the house's point minus its start, not the meter. The meter still shows A's tail and the others' climb; chasing it sent B down and then up again. - Then the loop moves one slew step a cycle.
A drain ends when its battery leaves DRAIN, is not live, its claims
contradict, or it refuses (SAFETY-73's limit outranks the pin without a
contradiction, so the refusal is read from the battery). The end is kept
until the loop runs (a missed poll). A dropped draining battery is this
rule's, not _reseed_drop()'s. Order in one cycle: drop/rejoin
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
start by its own battery, and the drain end's raise reads the start the
others left.
Why not SAFETY-93's rule alone (step 1, then slew). Measured on the same plant: at 1200 W the grid imports 849 W and B goes to -465 W (charging) while the loop chases A's tail; at N=3 and 4000 W it imports 784 W where base imports 42 W, as the others give up A's share of the house. The house point keeps the grid at its aim; the cap at last cycle's start keeps it from exporting.
Before -> after (test_safety95.py, 2500 W batteries, 10 s lag, 2 s
meter; base = the same tree with the change taken out):
| case | before | after |
|---|---|---|
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
| stale link (board watchdog 25 s) | as base, but N=3 4000 W: grid +676 W (base +786) | |
| N=1, every drain end | byte-identical |
tools/opt11_identity.py n2_maint moved on purpose (A's drain refused at
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
test_modes.py. The other 45 digests are byte-identical.
Not changed. The peak guard's floor still binds (arbiter, after the loop). Held batteries still give last; the idle clamp and the freeze test are as they were. A charge end (SAFETY-93) and a trade end are unchanged.