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>
61 lines
3.3 KiB
Markdown
61 lines
3.3 KiB
Markdown
# 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.
|