Files
glenn schrooyenandClaude Opus 5.5 4707d37019
checks / unit (pull_request) Successful in 32m34s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-95 review: test the stale-link and contradiction legs; refusal +1 only
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>
2026-10-07 16:41:16 +02:00

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:

  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 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.