SAFETY-95: a maintenance drain that ends hands back smoothly (N>=2)

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>
This commit is contained in:
glenn schrooyen
2026-10-06 12:44:25 +02:00
co-authored by Claude Opus 5.5
parent b2f55732b6
commit bdec66e97a
5 changed files with 677 additions and 12 deletions
@@ -0,0 +1,66 @@
# 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:
1. 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.
2. 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.
3. 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.
4. 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.