Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md
adminandClaude Opus 5.5 214051d1a8
checks / unit (pull_request) Successful in 27m5s
infra-pin / fresh (pull_request) Failing after 1s
SAFETY-93 review: a charge that ends by a stale link or a refusal hands back smoothly too
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>
2026-10-03 00:10:21 +02:00

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.