SAFETY-93: the maintenance charge end hands back smoothly, with no grid charging at a high house
checks / unit (pull_request) Successful in 26m55s
infra-pin / fresh (pull_request) Failing after 1s

At N>=2, when a battery's maintenance charge ended, its watts stayed in the
loop's start point and landed on the other battery (B 0 -> -1600 W in one
cycle, charging from the grid at a 6000 W house). On the cycle a charge
ends, the loop now starts from what the others stood at plus that
battery's new command. N=1 and all identity digests unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 22:38:42 +02:00
co-authored by Claude Opus 5.5
parent f3e409cf76
commit 05eb93f139
4 changed files with 454 additions and 2 deletions
@@ -0,0 +1,52 @@
# 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.
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 |
| 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.