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. It charged from the grid with the house at 6000 W.
Cause. During the charge, the site target holds A's charge (SAFETY-84's ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the loop slews from last cycle's site target (-2000 -> -1600). B is the only battery left to place that on. A charge timeout (abort) or switching A Off does the same.
Fix (main.py_reseed_charge_end(), N>=2 only). On the cycle a battery leaves CHARGE, the loop's start point and its integrator become what the others stood at plus that battery's new command. That command is its pin (HOLD or abort, 0 W), or, if the battery is free, its last discharge (a charge stops). The loop then moves one slew step a cycle. The end is kept until the loop next runs, so a missed poll on that cycle does not lose it. DECISIONS/SAFETY-93-charge-end.md; one line in DOCS. No user-facing strings.
Before -> after (test_safety93.py: real Controller, 2 x 2500 W, plantsim lag 10 s, 2 s meter, cap 3500, margin 50). The test is red on f3e409c (7 checks).
rig case: house 6000, A charges 2000 W until the end
B 0 -> -1600, charges from the grid for 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 has suspended the charge, A gives 1825 W
B step 675 W
B step 395 W
missed poll on the end cycle
B -1600 one cycle later
B 0 -> 400
N=1 identity. Part 5 runs N=1 charge ends at a high and a low house, with and without the reseed: byte-identical. All 46 digests of tools/*identity*.py (N=1 and N=2) are byte-identical to f3e409c. No pinned digest was re-pinned: no identity scenario reaches a charge end.
Mutations (tools/mutation_run.sh on fresh purged copies, test_safety93.py):
#
mutation
result
M1
reseed call removed
killed (9 checks)
M2
integrator not reseeded
killed (3)
M3
a free battery keeps its charge in the start
killed (2)
M4
the HOLD pin ignored
killed (8)
M5
applied at N=1 too
killed (2, N=1 identity)
M6
only a charge->HOLD end counts
killed (2, timeout and Off)
M7
charging set never filled
killed (9)
M8
sign of the drop flipped
killed (10)
M9
the end lost on a missed poll (= for |=)
killed (1)
M10
pending end never cleared
killed (2)
Full suite: 60/60 local in the CI image.
For the reviewer / owner
The ending battery's own step (A -2000 -> 0 W at its HOLD pin) is still one step. That is maintenance's pin, and it lowers the grid import. Only the batteries the loop places are slewed.
Not changed: the peak guard's floor still moves the site in one step when it engages, for example on a house step or at a quarter edge. The guard outranks the slew. Example: house 6000 from 1500 s and HOLD at 1504 s: B +878 W, "limited by peak"; before, 1275 W. At a HOLD start under the guard, SAFETY-86 still has the other battery give first.
In the timeout case, the harness store has no last_start_attempt, so A restarts its drain right away. This is a fixture artifact, not graded.
Out-of-scope effects: SAFETY-88 (charge flapping above the aim) is untouched. Each flap is CHARGE->CHARGE, so it does not trigger this reseed. This PR does not make -89/-90/-92/-94, OPT-16 or UX-17/19 easier or harder. In the rig case, no new saturation freeze appears at the end (the loop reason reads slew-limited, then mode-bound).
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. It charged from the grid with the house at 6000 W.
**Cause.** During the charge, the site target holds A's charge (SAFETY-84's ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the loop slews from last cycle's site target (-2000 -> -1600). B is the only battery left to place that on. A charge timeout (abort) or switching A Off does the same.
**Fix** (`main.py` `_reseed_charge_end()`, N>=2 only). On the cycle a battery leaves CHARGE, the loop's start point and its integrator become what the others stood at plus that battery's new command. That command is its pin (HOLD or abort, 0 W), or, if the battery is free, its last discharge (a charge stops). The loop then moves one slew step a cycle. The end is kept until the loop next runs, so a missed poll on that cycle does not lose it. DECISIONS/SAFETY-93-charge-end.md; one line in DOCS. No user-facing strings.
**Before -> after** (`test_safety93.py`: real Controller, 2 x 2500 W, plantsim lag 10 s, 2 s meter, cap 3500, margin 50). The test is red on f3e409c (7 checks).
| case | f3e409c | this PR |
|---|---|---|
| rig case: house 6000, A charges 2000 W until the end | B 0 -> -1600, charges from the grid for 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 has suspended the charge, A gives 1825 W | B step 675 W | B step 395 W |
| missed poll on the end cycle | B -1600 one cycle later | B 0 -> 400 |
**N=1 identity.** Part 5 runs N=1 charge ends at a high and a low house, with and without the reseed: byte-identical. All 46 digests of `tools/*identity*.py` (N=1 and N=2) are byte-identical to f3e409c. **No pinned digest was re-pinned**: no identity scenario reaches a charge end.
**Mutations** (`tools/mutation_run.sh` on fresh purged copies, test_safety93.py):
| # | mutation | result |
|---|---|---|
| M1 | reseed call removed | killed (9 checks) |
| M2 | integrator not reseeded | killed (3) |
| M3 | a free battery keeps its charge in the start | killed (2) |
| M4 | the HOLD pin ignored | killed (8) |
| M5 | applied at N=1 too | killed (2, N=1 identity) |
| M6 | only a charge->HOLD end counts | killed (2, timeout and Off) |
| M7 | charging set never filled | killed (9) |
| M8 | sign of the drop flipped | killed (10) |
| M9 | the end lost on a missed poll (`=` for `\|=`) | killed (1) |
| M10 | pending end never cleared | killed (2) |
Full suite: 60/60 local in the CI image.
**For the reviewer / owner**
- The ending battery's own step (A -2000 -> 0 W at its HOLD pin) is still one step. That is maintenance's pin, and it lowers the grid import. Only the batteries the loop places are slewed.
- Not changed: the peak guard's floor still moves the site in one step when it engages, for example on a house step or at a quarter edge. The guard outranks the slew. Example: house 6000 from 1500 s and HOLD at 1504 s: B +878 W, "limited by peak"; before, 1275 W. At a HOLD start under the guard, SAFETY-86 still has the other battery give first.
- In the timeout case, the harness store has no `last_start_attempt`, so A restarts its drain right away. This is a fixture artifact, not graded.
- Out-of-scope effects: SAFETY-88 (charge flapping above the aim) is untouched. Each flap is CHARGE->CHARGE, so it does not trigger this reseed. This PR does not make -89/-90/-92/-94, OPT-16 or UX-17/19 easier or harder. In the rig case, no new saturation freeze appears at the end (the loop reason reads slew-limited, then mode-bound).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
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>
Review round 1 (SAFETY-93). Probes and logs: scratch/SAFETY-93/review/. All runs use the real Controller on the 10 s lagging plant with a 2 s meter, in the CI image.
Verified
Rig case, house 1200, timeout, Off, guard-suspended and missed poll: reproduced. Red without the reseed, green with it.
N=3 (rig patched to abc; A charges, B and C free; house 6000 and 1200). With the fix: B and C step 0 -> 200 -> 400 ..., nobody charges. Without it: B and C go to -800 W each.
N=1 identity. All 46 tools/*identity*.py digests are byte-identical to f3e409c. I re-ran them myself on base and on head.
Peak guard at a HOLD start (your open point; house 6000 from 1500 s, HOLD at 1504 s). At 1504 B goes 1225 -> 2103 with "limited by peak(quarter peak)". The guard's floor is the binder, not the reseed. Without the fix B goes to 2500, so the fix makes this step smaller. Accepted, per owner order.
No spurious fires. I put a spy on _charge_ended:
house at the aim (SAFETY-88 conditions), 2700 s: 0 fires
house stepping across the aim (86 suspended cycles): 0 fires
guard suspends, then HOLD: 1 fire, at the HOLD
SAFETY-85 / -86 interaction. No double reseed: the only fire is at the CHARGE edge. A HOLD end (HOLD -> IDLE) does not fire.
Mutants re-run on purged copies: M1, M3, M4, M6 and M9 are all killed, matching your table.
F1 (must fix): a charge that ends by a stale link or a refusal still lands on B in one step.
In both cases A is still in CHARGE, so _charging never changes, but the allocator drops A.
Stale link (dropped inverter-a (stale link)): at house 1200, B goes 0 -> -1600 at 1330 s.
Refusal (A reads 0 W while asked to charge; dropped inverter-a (contradicts itself)): B goes 0 -> -1600 at 1322 s, then charges from the grid for 4 cycles.
This is the same stale-target mechanism, and the brief lists both as ways a charge ends.
Fix: track the end after the unit claims are filed. A battery is charging if it is in CHARGE, live, and its own claims do not contradict. With that, B goes 0 -> 400 in both cases.
Refusal: the spy shows one fire at the drop. A second fire at a probe edge (2256 s) leaves every row identical to base.
The guard suspension does not count as an end.
I'll push this, with tests (part 6).
F2 (minor): tests. Nothing graded the suspension case for spurious fires. I'll add a spy on part 4.
Mutants that survive, for the record:
R5 (reseed also on a DRAIN end) survives. That is acceptable, see O1.
R6 (pin read as 0) is an equivalent mutant: every pin is 0 W.
O1 (out of scope, follow-up ticket): the mirror case on a drain end. A switched Off mid-drain at house 1200: B goes 0 -> +1600 in one cycle (it exports). The drain floor's +2000 W stays in the start point. Not fixed here: it is a drain end, not a charge end.
O2 (pre-existing, not this PR): after a stale-link drop, the site sum still counts the dropped battery's power, so the loop can saturate-freeze. This happens on base too.
**Review round 1 (SAFETY-93).** Probes and logs: `scratch/SAFETY-93/review/`. All runs use the real Controller on the 10 s lagging plant with a 2 s meter, in the CI image.
**Verified**
- Rig case, house 1200, timeout, Off, guard-suspended and missed poll: reproduced. Red without the reseed, green with it.
- **N=3** (rig patched to `abc`; A charges, B and C free; house 6000 and 1200). With the fix: B and C step 0 -> 200 -> 400 ..., nobody charges. Without it: B and C go to -800 W each.
- **N=1 identity.** All 46 `tools/*identity*.py` digests are byte-identical to f3e409c. I re-ran them myself on base and on head.
- **Peak guard at a HOLD start** (your open point; house 6000 from 1500 s, HOLD at 1504 s). At 1504 B goes 1225 -> 2103 with "limited by peak(quarter peak)". The guard's floor is the binder, not the reseed. Without the fix B goes to 2500, so the fix makes this step smaller. Accepted, per owner order.
- **No spurious fires.** I put a spy on `_charge_ended`:
- house at the aim (SAFETY-88 conditions), 2700 s: 0 fires
- house stepping across the aim (86 suspended cycles): 0 fires
- guard suspends, then HOLD: 1 fire, at the HOLD
- **SAFETY-85 / -86 interaction.** No double reseed: the only fire is at the CHARGE edge. A HOLD end (HOLD -> IDLE) does not fire.
- **Mutants re-run** on purged copies: M1, M3, M4, M6 and M9 are all killed, matching your table.
**F1 (must fix): a charge that ends by a stale link or a refusal still lands on B in one step.**
In both cases A is still in CHARGE, so `_charging` never changes, but the allocator drops A.
- Stale link (`dropped inverter-a (stale link)`): at house 1200, B goes 0 -> **-1600** at 1330 s.
- Refusal (A reads 0 W while asked to charge; `dropped inverter-a (contradicts itself)`): B goes 0 -> **-1600** at 1322 s, then charges from the grid for 4 cycles.
This is the same stale-target mechanism, and the brief lists both as ways a charge ends.
Fix: track the end after the unit claims are filed. A battery is charging if it is in CHARGE, live, and its own claims do not contradict. With that, B goes 0 -> 400 in both cases.
- Refusal: the spy shows one fire at the drop. A second fire at a probe edge (2256 s) leaves every row identical to base.
- The guard suspension does not count as an end.
I'll push this, with tests (part 6).
**F2 (minor): tests.** Nothing graded the suspension case for spurious fires. I'll add a spy on part 4.
**Mutants that survive, for the record:**
- **R5** (reseed also on a DRAIN end) survives. That is acceptable, see O1.
- **R6** (pin read as 0) is an equivalent mutant: every pin is 0 W.
**O1 (out of scope, follow-up ticket):** the mirror case on a drain end. A switched Off mid-drain at house 1200: B goes 0 -> **+1600** in one cycle (it exports). The drain floor's +2000 W stays in the start point. Not fixed here: it is a drain end, not a charge end.
**O2 (pre-existing, not this PR):** after a stale-link drop, the site sum still counts the dropped battery's power, so the loop can saturate-freeze. This happens on base 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>
F1 fixed. The new _charge_track() runs after the unit claims and the mode trade, just before the loop. A battery counts as charging only if it is in CHARGE, live, and its own claims do not contradict. _reseed_charge_end() is unchanged.
Stale link at house 1200: B now goes 0 -> 400 -> 800. Before the fix it went to -1600.
Refusal at house 1200: B now goes 0 -> 400 -> 800 -> 1200. Before the fix it went to -1600.
F2 fixed. test_safety93.py has a new part 6: the stale-link and refusal cases (both red on 05eb93f), and a spy showing the guard suspension is no end.
DECISIONS has one paragraph and one table row for this. DOCS is unchanged, since its sentence already covers it.
N=1 unchanged._charge_track returns at once at N=1. The 46 identity digests are still byte-identical to f3e409c, and the full suite passes 60/60 in the CI image.
Mutants on 214051d (purged copies, test_safety93.py)
Killed:
M1, M3, M4 and M6
R1: the other batteries' sign flipped
R7: a stale link not counted as an end
R8: a refusal not counted as an end
R9: tracking back at the old place, before the claims
R10: tracking also at N=1
Survived, all equivalent:
M9 (= for |=) and R3 (pending end cleared while inputs are missing) are now equivalent. A missed poll returns before the tracking runs, so the end is seen on the next cycle. On every other path where the loop does not run (control stopped, a long blind spell), everything is commanded to 0 W, so a pending reseed adds 0. I checked this: the rows are byte-identical across three control-off gaps and a 30 s blind spell over the end.
R5 (reseed also on a DRAIN end) survives on purpose. See O1 in the round-1 comment.
R6 (pin read as 0) is equivalent.
**Review round 1, fixes pushed: 214051d** (ls-remote checked).
- **F1 fixed.** The new `_charge_track()` runs after the unit claims and the mode trade, just before the loop. A battery counts as charging only if it is in CHARGE, live, and its own claims do not contradict. `_reseed_charge_end()` is unchanged.
- Stale link at house 1200: B now goes 0 -> 400 -> 800. Before the fix it went to -1600.
- Refusal at house 1200: B now goes 0 -> 400 -> 800 -> 1200. Before the fix it went to -1600.
- **F2 fixed.** test_safety93.py has a new part 6: the stale-link and refusal cases (both red on 05eb93f), and a spy showing the guard suspension is no end.
- DECISIONS has one paragraph and one table row for this. DOCS is unchanged, since its sentence already covers it.
- **N=1 unchanged.** `_charge_track` returns at once at N=1. The 46 identity digests are still byte-identical to f3e409c, and the full suite passes 60/60 in the CI image.
**Mutants on 214051d** (purged copies, test_safety93.py)
Killed:
- M1, M3, M4 and M6
- R1: the other batteries' sign flipped
- R7: a stale link not counted as an end
- R8: a refusal not counted as an end
- R9: tracking back at the old place, before the claims
- R10: tracking also at N=1
Survived, all equivalent:
- **M9** (`=` for `|=`) and **R3** (pending end cleared while inputs are missing) are now equivalent. A missed poll returns before the tracking runs, so the end is seen on the next cycle. On every other path where the loop does not run (control stopped, a long blind spell), everything is commanded to 0 W, so a pending reseed adds 0. I checked this: the rows are byte-identical across three control-off gaps and a 30 s blind spell over the end.
- **R5** (reseed also on a DRAIN end) survives on purpose. See O1 in the round-1 comment.
- **R6** (pin read as 0) is equivalent.
admin
merged commit 858f77cfb5 into release/1.02026-10-03 00:58:38 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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. It charged from the grid with the house at 6000 W.Cause. During the charge, the site target holds A's charge (SAFETY-84's ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the loop slews from last cycle's site target (-2000 -> -1600). B is the only battery left to place that on. A charge timeout (abort) or switching A Off does the same.
Fix (
main.py_reseed_charge_end(), N>=2 only). On the cycle a battery leaves CHARGE, the loop's start point and its integrator become what the others stood at plus that battery's new command. That command is its pin (HOLD or abort, 0 W), or, if the battery is free, its last discharge (a charge stops). The loop then moves one slew step a cycle. The end is kept until the loop next runs, so a missed poll on that cycle does not lose it. DECISIONS/SAFETY-93-charge-end.md; one line in DOCS. No user-facing strings.Before -> after (
test_safety93.py: real Controller, 2 x 2500 W, plantsim lag 10 s, 2 s meter, cap 3500, margin 50). The test is red onf3e409c(7 checks).f3e409cN=1 identity. Part 5 runs N=1 charge ends at a high and a low house, with and without the reseed: byte-identical. All 46 digests of
tools/*identity*.py(N=1 and N=2) are byte-identical tof3e409c. No pinned digest was re-pinned: no identity scenario reaches a charge end.Mutations (
tools/mutation_run.shon fresh purged copies, test_safety93.py):=for|=)Full suite: 60/60 local in the CI image.
For the reviewer / owner
last_start_attempt, so A restarts its drain right away. This is a fixture artifact, not graded.🤖 Generated with Claude Code
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
Review round 1 (SAFETY-93). Probes and logs:
scratch/SAFETY-93/review/. All runs use the real Controller on the 10 s lagging plant with a 2 s meter, in the CI image.Verified
abc; A charges, B and C free; house 6000 and 1200). With the fix: B and C step 0 -> 200 -> 400 ..., nobody charges. Without it: B and C go to -800 W each.tools/*identity*.pydigests are byte-identical tof3e409c. I re-ran them myself on base and on head._charge_ended:F1 (must fix): a charge that ends by a stale link or a refusal still lands on B in one step.
In both cases A is still in CHARGE, so
_chargingnever changes, but the allocator drops A.dropped inverter-a (stale link)): at house 1200, B goes 0 -> -1600 at 1330 s.dropped inverter-a (contradicts itself)): B goes 0 -> -1600 at 1322 s, then charges from the grid for 4 cycles.This is the same stale-target mechanism, and the brief lists both as ways a charge ends.
Fix: track the end after the unit claims are filed. A battery is charging if it is in CHARGE, live, and its own claims do not contradict. With that, B goes 0 -> 400 in both cases.
I'll push this, with tests (part 6).
F2 (minor): tests. Nothing graded the suspension case for spurious fires. I'll add a spy on part 4.
Mutants that survive, for the record:
O1 (out of scope, follow-up ticket): the mirror case on a drain end. A switched Off mid-drain at house 1200: B goes 0 -> +1600 in one cycle (it exports). The drain floor's +2000 W stays in the start point. Not fixed here: it is a drain end, not a charge end.
O2 (pre-existing, not this PR): after a stale-link drop, the site sum still counts the dropped battery's power, so the loop can saturate-freeze. This happens on base too.
Review round 1, fixes pushed:
214051d(ls-remote checked)._charge_track()runs after the unit claims and the mode trade, just before the loop. A battery counts as charging only if it is in CHARGE, live, and its own claims do not contradict._reseed_charge_end()is unchanged.05eb93f), and a spy showing the guard suspension is no end._charge_trackreturns at once at N=1. The 46 identity digests are still byte-identical tof3e409c, and the full suite passes 60/60 in the CI image.Mutants on
214051d(purged copies, test_safety93.py)Killed:
Survived, all equivalent:
=for|=) and R3 (pending end cleared while inputs are missing) are now equivalent. A missed poll returns before the tracking runs, so the end is seen on the next cycle. On every other path where the loop does not run (control stopped, a long blind spell), everything is commanded to 0 W, so a pending reseed adds 0. I checked this: the rows are byte-identical across three control-off gaps and a 30 s blind spell over the end.