A battery still in CHARGE but dropped by the allocator (stale link, or a SAFETY-73 refusal contradicting its charge) left its -2000 W in the loop's start point, and B went 0 -> -1600 W in one cycle. The end is now noted after the unit claims are filed: in CHARGE, live and not contradicted. Tests: part 6 (stale link, refusal) and a spy on the guard suspension. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3.3 KiB
SAFETY-93 - the maintenance charge end hands back smoothly
Status: implemented 2026-10-02, for review. Tests: test_safety93.py.
The defect (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
from the grid with the house at 6000 W. The HOLD end was already smooth
(SAFETY-85).
Cause. During the charge the site target holds A's charge (SAFETY-84's
charge ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
loop starts its next step from last cycle's site target. The slew limit
moves the site total by 400 W (-2000 -> -1600), and the only battery left
to place it on is B. The same happens when the charge times out or the
battery is switched Off (A free, its -2000 W shared out). With the charge
suspended by the guard and A giving under the loop, B took A's share in
one step (675 W).
The fix (main.py, _reseed_charge_end(), N>=2 only). On the cycle a
battery leaves CHARGE, the loop's start point (self.target, and the
integrator with it) is what the other batteries stood at plus that
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
last discharge (a charge stops). The loop then moves from there one slew
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
pin, as before; it lowers the grid import. The end is kept until the loop
next runs, so a missed poll on that cycle (the input hold) does not lose it.
Review: a charge also ends, for the site, when the battery is still in CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73) drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a 1200 W house). So the end is noted after the unit claims are filed: a battery leaves the charge when it leaves CHARGE, is not live, or its own claims contradict. The guard suspending the charge (SAFETY-84) is no end.
Why a start-point reseed, and not a per-battery slew in the allocator: the
jump comes from one stale number at one edge, the kind of edge SAFETY-73
(_unit_refusal_end) and OPT-11 P3 (_reseed_run_edge) already reseed.
The change stays at that edge; nothing else at N>=2 moves.
Before -> after (test_safety93.py, 2 x 2500 W, 10 s lag, 2 s meter):
| case | before | after |
|---|---|---|
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
| N=1, both charge ends above | byte-identical |
No pinned digest moved: the 46 digests of tools/*identity*.py (N=1 and
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
charge end.
Not changed. The peak guard's floor still moves the site in one step when it engages (a house step, a quarter edge): the guard outranks the slew (owner order). A HOLD pin still drops the holding battery to 0 W at once and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping above the aim) is untouched.