F1: test_safety95 checks that a dropped draining battery is this rule's (stale link, N=3, 4000 W: grid import 676 W vs base 786 W); kills R2. F2: _charge_track tested directly with a contradicting drain (SAFETY-91's pin as a floor against a closed pack); kills R3. Today a drain contradicts only through a refusal; the clause stays, as a contradiction sends 0 W. F3: a drain ends on a refusal only when the battery will not give (refused > 0, as _held_back()); one that will not take still drains. Tested both signs; kills R8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3.8 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 will not give (SAFETY-73's limit outranks the pin
without a contradiction, so the refusal is read from the battery; +1 only,
as _held_back()). Today a contradiction on a drain only arises through
a refusal; the clause stays because a contradiction sends A 0 W, which is
an end (tested directly). 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.