At N=2, a maintenance CHARGE in a cheap window stalled. The charging battery's own hi = -charge (P_MAINT_SHAPE) makes it charge. The loop's site target sat above that hi, so the other battery had to give. The allocator refuses opposite directions, so the site went to 0 W. On 9d5b7d1, tools/safety84_identity.py n2_charge refuses 436 of 480 cycles; safety79_identity n2_maint refuses 214 of 540. N=1 was fine.
The design
Site ceiling. At N>=2, while a battery's own limits make it charge, main.py files a site limit charge at P_MAINT_SHAPE. Its hi is what that battery's hi places, in whole W (allocator.hi_w(), rounded the way the allocator rounds). The site target never goes above it, so the other battery only adds charge in the same direction. This mirrors SAFETY-78's drain floor.
Peak guard first (owner order). At N>=2 the charge is sized to the quarter's headroom under the aim (peakguard.charge_headroom_w()). The aim is the cap, or a banked month peak, minus peak_margin_w. So the charge charges less instead of stopping, and the meter sits on the aim. When the house alone is over the aim, the guard engages and the shaping is suspended, as before (shaping_suspended()). No charge claim is filed then. So the guard's lo and this hi never meet in one cycle: no lo > hi, no 0 W park.
Safety stop. A site SET above P_MAINT_SHAPE (a safety stop) sizes the charge to 0 W. The unit hi is a hard limit, so a charge under a 0 W stop needs the other battery to give. Before this, it was refused with an ARBITER line on most cycles of the stop.
A battery that can't take the charge (a refusal, a full pack, a stale link) files no ceiling. The other battery follows the loop.
N=1: nothing is filed and nothing is sized. Byte-identical, per tools/safety84_identity.py, which hashes every claim. N=2 outside a cheap window is identical too.
Why size to the headroom: without it the aim still held, because the suspension did it. But at a 3000 W house a battery gave power back on 285 of 540 cycles (charge, then the guard makes both give). With the sizing the count is 0, A charges on every cycle, B stays at 0 W, and every quarter lands on the aim.
N=2, house 3000, month peak 4000 banked (aim 3950)
refused
banked 3950 / 3950
N=2, house 5000 (over the aim alone)
refused
guard wins, banked 3450 / 3450, both batteries give in the same direction, no 'Load too high'
N=2, battery reading lost (safety stop)
0 W + ARBITER line on most cycles
0 W, no refusal; the charge resumes after
N=1 (3 scenarios)
-
byte-identical
No false 'Load too high' (PEAK_GUARD_SATURATED) in any run. The OUTPUT_CLAMP ("Battery is not keeping up") log line comes 2 times in 480 cycles at N=2, the same as at N=1.
Tests
test_safety84.py, 29 checks:
N=2, cheap window, house below, at and above the charge rate, and a solar surplus. The charge ends (HOLD). Non-vacuous: without the ceiling it refuses every cycle.
Peak guard. Normal aim at a 3000 W house, a 5000 W house over the aim, and a banked-peak aim of 3950. Non-vacuous: without the headroom sizing, batteries give back.
Out of a cheap window, nothing changes.
Safety stop: a lost reading, then control switched off. The stop test reads only site SETs.
A refusing to charge, B refusing to give, A dropped (stale link), and B Off with modes on.
Re-pinned on purpose: test_peak_margin.py n2_maint at margin 0 (it was refused on 214 of 540 cycles, now 0). Updated source-set pins: test_arbiter.py and test_peakguard.py 10f (the new charge site source). The full suite passes locally in the CI image, 55 of 55.
Mutation table
Each mutation was run on a fresh copy through tools/mutation_run.sh (cache purged), in goodwe-addon-checks:1, grading test_safety84.py (M13 also test_arbiter.py and test_peakguard.py).
#
mutation
result
M1
never file the charge claim
killed (17 checks: parts 1, 2, 4, 5, 6, digest)
M2
ceiling b.hi instead of allocator.hi_w(b) (no rounding)
killed (part 5 B refuses, part 7 sweep)
M3
hi_w() returns the unrounded b.hi
killed (part 5, part 7)
M4
no headroom sizing at N>=2
killed (part 2, part 5, N=2 digest)
M5
headroom sizing also at N=1
killed (N=1 identity digest)
M6
drop the site-stop check
killed (part 4 stop, direct gate check)
M7
drop u in live
killed (part 5 stale link)
M8
drop not b.contradiction
killed (part 5 refusal, part 7)
M9
drop b.hi < 0.0
killed (part 4)
M10
drop the N<2 guard in _charge_ceiling
survived, equivalent: at N=1 the maintenance claim routes to site, which is never in live_units(), so nothing is filed anyway. The guard is kept so that N=1 does not rest on that.
M11
charge at P_LOOP
killed (part 6 sell trade)
M12
charge at P_MAINTENANCE
killed by the N=2 digest only. Behaviourally equivalent: no site SET lies between 40 and 60, and the peak lo never coexists with it.
M13
charge scope UNIT
killed (part 4, test_arbiter, test_peakguard)
M14
file a 0 W ceiling when nothing charges
killed (part 3, part 5)
M15
stop check > P_LOOP (a trade stops the charge)
killed (part 6)
M16
stop check reads unit SETs too
killed (direct gate check)
M17
stop check reads site LIMITs too
killed (direct gate check)
M18
never call _charge_headroom
killed (parts 2, 4, 5)
M19
drop the Why wording for charge
killed (part 1 Why)
Strings
There are no new user-facing strings. The Why line maps charge to the existing "battery maintenance", so N=2 reads as N=1 does: "held back by battery maintenance". The Why line is English-only today. DOCS gets one plain sentence next to the SAFETY-78 and SAFETY-83 lines.
For the reviewer / owner
N=1 is not sized to the headroom. There the guard still suspends the charge and the battery gives until the quarter is safe. In the identity run the N=1 quarter banks 3462 W (12 W over the aim, under the cap), where N=2 banks 3450. Applying the same sizing at N=1 is an owner question. The ticket asked for N=1 byte-identical, so it is not in this PR.
SAFETY-86 (HOLD): not touched. A HOLD at N>=2 is a unit SET at 0 W, and pins are already weighed against the site priority, so it does not stall. This design neither helps nor hinders it. The "peak guard first" rule for HOLD at N=1 stays open there.
SAFETY-85 ("Battery is not keeping up"): not touched. The ceiling clamps the loop like any limit, and in these runs it added no alarm lines. It neither helps nor hinders SAFETY-85.
A refusing to charge: the first ~35 s go through the existing STALE / 'inputs missing' path at 0 W before SAFETY-73 closes A. That is existing behaviour. There is no "waits" log line for the charge (the drain has one). I did not add one, to avoid a new string. It can be a follow-up if wanted.
## What was wrong
At N=2, a maintenance CHARGE in a cheap window stalled. The charging battery's own `hi = -charge` (P_MAINT_SHAPE) makes it charge. The loop's site target sat above that `hi`, so the other battery had to give. The allocator refuses opposite directions, so the site went to 0 W. On 9d5b7d1, `tools/safety84_identity.py` n2_charge refuses 436 of 480 cycles; `safety79_identity` n2_maint refuses 214 of 540. N=1 was fine.
## The design
- **Site ceiling.** At N>=2, while a battery's own limits make it charge, `main.py` files a site limit `charge` at P_MAINT_SHAPE. Its `hi` is what that battery's `hi` places, in whole W (`allocator.hi_w()`, rounded the way the allocator rounds). The site target never goes above it, so the other battery only adds charge in the same direction. This mirrors SAFETY-78's `drain` floor.
- **Peak guard first (owner order).** At N>=2 the charge is sized to the quarter's headroom under the aim (`peakguard.charge_headroom_w()`). The aim is the cap, or a banked month peak, minus `peak_margin_w`. So the charge charges less instead of stopping, and the meter sits on the aim. When the house alone is over the aim, the guard engages and the shaping is suspended, as before (`shaping_suspended()`). No `charge` claim is filed then. So the guard's `lo` and this `hi` never meet in one cycle: no `lo > hi`, no 0 W park.
- **Safety stop.** A site SET above P_MAINT_SHAPE (a safety stop) sizes the charge to 0 W. The unit `hi` is a hard limit, so a charge under a 0 W stop needs the other battery to give. Before this, it was refused with an ARBITER line on most cycles of the stop.
- **A battery that can't take the charge** (a refusal, a full pack, a stale link) files no ceiling. The other battery follows the loop.
- **N=1:** nothing is filed and nothing is sized. Byte-identical, per `tools/safety84_identity.py`, which hashes every claim. N=2 outside a cheap window is identical too.
Why size to the headroom: without it the aim still held, because the suspension did it. But at a 3000 W house a battery gave power back on 285 of 540 cycles (charge, then the guard makes both give). With the sizing the count is 0, A charges on every cycle, B stays at 0 W, and every quarter lands on the aim.
## Measured (real Controller, goodwe-addon-checks:1)
| case | before (9d5b7d1) | after |
|---|---|---|
| N=2, house 800 / 2000 / -1500, cheap | 0 W, refused every cycle | A -2000 W every cycle, B 0, 0 refused |
| N=2, house 3000, aim 3450 | 0 W, refused | A about -450 W, banked quarters 3450 / 3450 |
| N=2, house 3000, month peak 4000 banked (aim 3950) | refused | banked 3950 / 3950 |
| N=2, house 5000 (over the aim alone) | refused | guard wins, banked 3450 / 3450, both batteries give in the same direction, no 'Load too high' |
| N=2, battery reading lost (safety stop) | 0 W + ARBITER line on most cycles | 0 W, no refusal; the charge resumes after |
| N=1 (3 scenarios) | - | byte-identical |
No false 'Load too high' (`PEAK_GUARD_SATURATED`) in any run. The OUTPUT_CLAMP ("Battery is not keeping up") log line comes 2 times in 480 cycles at N=2, the same as at N=1.
## Tests
`test_safety84.py`, 29 checks:
1. N=2, cheap window, house below, at and above the charge rate, and a solar surplus. The charge ends (HOLD). Non-vacuous: without the ceiling it refuses every cycle.
2. Peak guard. Normal aim at a 3000 W house, a 5000 W house over the aim, and a banked-peak aim of 3950. Non-vacuous: without the headroom sizing, batteries give back.
3. Out of a cheap window, nothing changes.
4. Safety stop: a lost reading, then control switched off. The stop test reads only site SETs.
5. A refusing to charge, B refusing to give, A dropped (stale link), and B Off with modes on.
6. A modes-off sell trade: the charge outranks it.
7. A 20000-case seeded arbiter sweep.
8. Digests: N=1 equals 9d5b7d1, and N=2 is pinned.
Re-pinned on purpose: `test_peak_margin.py` n2_maint at margin 0 (it was refused on 214 of 540 cycles, now 0). Updated source-set pins: `test_arbiter.py` and `test_peakguard.py` 10f (the new `charge` site source). The full suite passes locally in the CI image, 55 of 55.
## Mutation table
Each mutation was run on a fresh copy through `tools/mutation_run.sh` (cache purged), in goodwe-addon-checks:1, grading test_safety84.py (M13 also test_arbiter.py and test_peakguard.py).
| # | mutation | result |
|---|---|---|
| M1 | never file the `charge` claim | killed (17 checks: parts 1, 2, 4, 5, 6, digest) |
| M2 | ceiling `b.hi` instead of `allocator.hi_w(b)` (no rounding) | killed (part 5 B refuses, part 7 sweep) |
| M3 | `hi_w()` returns the unrounded `b.hi` | killed (part 5, part 7) |
| M4 | no headroom sizing at N>=2 | killed (part 2, part 5, N=2 digest) |
| M5 | headroom sizing also at N=1 | killed (N=1 identity digest) |
| M6 | drop the site-stop check | killed (part 4 stop, direct gate check) |
| M7 | drop `u in live` | killed (part 5 stale link) |
| M8 | drop `not b.contradiction` | killed (part 5 refusal, part 7) |
| M9 | drop `b.hi < 0.0` | killed (part 4) |
| M10 | drop the N<2 guard in `_charge_ceiling` | **survived, equivalent**: at N=1 the maintenance claim routes to `site`, which is never in `live_units()`, so nothing is filed anyway. The guard is kept so that N=1 does not rest on that. |
| M11 | `charge` at P_LOOP | killed (part 6 sell trade) |
| M12 | `charge` at P_MAINTENANCE | killed by the N=2 digest only. **Behaviourally equivalent**: no site SET lies between 40 and 60, and the peak `lo` never coexists with it. |
| M13 | `charge` scope UNIT | killed (part 4, test_arbiter, test_peakguard) |
| M14 | file a 0 W ceiling when nothing charges | killed (part 3, part 5) |
| M15 | stop check `> P_LOOP` (a trade stops the charge) | killed (part 6) |
| M16 | stop check reads unit SETs too | killed (direct gate check) |
| M17 | stop check reads site LIMITs too | killed (direct gate check) |
| M18 | never call `_charge_headroom` | killed (parts 2, 4, 5) |
| M19 | drop the Why wording for `charge` | killed (part 1 Why) |
## Strings
There are no new user-facing strings. The Why line maps `charge` to the existing "battery maintenance", so N=2 reads as N=1 does: "held back by battery maintenance". The Why line is English-only today. DOCS gets one plain sentence next to the SAFETY-78 and SAFETY-83 lines.
## For the reviewer / owner
- **N=1 is not sized to the headroom.** There the guard still suspends the charge and the battery gives until the quarter is safe. In the identity run the N=1 quarter banks 3462 W (12 W over the aim, under the cap), where N=2 banks 3450. Applying the same sizing at N=1 is an owner question. The ticket asked for N=1 byte-identical, so it is not in this PR.
- **SAFETY-86 (HOLD):** not touched. A HOLD at N>=2 is a unit SET at 0 W, and pins are already weighed against the site priority, so it does not stall. This design neither helps nor hinders it. The "peak guard first" rule for HOLD at N=1 stays open there.
- **SAFETY-85 ("Battery is not keeping up"):** not touched. The ceiling clamps the loop like any limit, and in these runs it added no alarm lines. It neither helps nor hinders SAFETY-85.
- **A refusing to charge:** the first ~35 s go through the existing STALE / 'inputs missing' path at 0 W before SAFETY-73 closes A. That is existing behaviour. There is no "waits" log line for the charge (the drain has one). I did not add one, to avoid a new string. It can be a follow-up if wanted.
- No TEST-23 run, no contact with live hosts.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
At N>=2 a cheap-window maintenance charge files a site `hi` (`charge`,
P_MAINT_SHAPE) equal to what the charging pack's own `hi` places, so the
other pack never has to give against it. The charge is sized to the
quarter's headroom under the peak aim so it yields to the peak guard, and
to 0 W under a site safety stop. N=1 byte-identical (safety84_identity).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checked on 43a52e7 in goodwe-addon-checks:1. Base has not moved (9d5b7d1).
Correct as designed.
The charge hi and the peak lo cannot meet in one cycle. The peak claim is filed only when peak_guard.engaged. The shaping, and so the ceiling, is filed only when shaping_suspended() is False, which needs not engaged. Both come from the same decision object in the same cycle. The only other site limit is the drain lo, and the maintenance slot interlock (POWER_MOVING includes DRAIN and CHARGE) keeps it out.
Probe (scratch/SAFETY-84/review/probe.py): N=2 at margin 0 and 50, with and without a 4000 W banked peak, on random-walk houses (500–4800 W) and on steps exactly at quarter edges, plus 90–100 s P1 dropouts and a restart 420 s or 880 s into a quarter. Results: 0 cycles with both claims, 0 refusals, 0 opposite splits, 0 give-backs without the guard, no 'Load too high', and every banked quarter at or under its aim. The only 0 W cycles were 1 per dropout run, which is the site stop.
Off battery: with B Off and a 6500 W house, B gives 568 W, which is the shortfall. At 4500 W it gives 0 W.
Re-pin of n2_maint at margin 0: refusals go from 214 to 0. Every other row difference traces back to the charge now running (guard projection, and 1751→1838 W banked, under the 2500 W cap). The extra event lines only show because the 40 ARBITER lines no longer push them out of the log. The test_arbiter/test_peakguard changes only add the new source to the counts.
N=1 identity: I ran the tool on a copy of 9d5b7d1 and all three N=1 digests match.
M12 is behaviourally equivalent: commands and reasons are identical to the head on 10 scenarios, with the claim priority left out of the hash. M10 survives, and it is equivalent because at N=1 the maintenance claim routes to site, which live_units() never lists.
Strings: nothing new. charge maps to the existing "battery maintenance".
Findings (test gaps; the code is right).
F1: the N>=2 suspension has no behavioural check. Mutant R3 skips shaping_suspended() at N>=2, so A keeps hi = 0 during a peak event and cannot give. Part 2 does not catch it: at 5000 W, B alone covers the 1550 W, and the check asserts only the site sum, not that A gives. The mutant dies only through the pinned digests and a side effect of the headroom non-vacuous check. On a 6500 W house the mutant banks 4014 W, over the 3500 W cap, and 'Load too high' fires. The head banks 3450 W, and A gives on all 336 engaged cycles. Fix: add a 6500 W case (A must give, banked on the aim, no alarm) with its own non-vacuous run.
F2: N=2 without P1 is not covered. When nothing measured the quarter, charge_headroom_w() returns None and the charge is not sized, which matches N=1. Mutant R6 reads None as 0 W. A site with meter_source: off then never charges at N=2 (0 of 120 cycles), which is the original stall again, and every suite stays green. Fix: add a check that at N=2 with no P1, A charges at 2000 W on every cycle.
I am pushing both checks to the branch.
Mutations re-run (fresh copies, cache purged): M1, M4, M5, M6, M8, M18 killed (M4 and M18 by parts 1, 2, 4 and 5, as reported). R1 (headroom ignores the banked peak) killed. R2 (headroom + margin) killed by the digest only: the projection feedback absorbs it and the quarter still banks 3451 W, so the bill is unaffected. R4 (shaping outside a cheap window at N=2), R5 (max) and R7 (ceiling = rate, not the pack's hi) killed. R3 and R6 are F1 and F2 above.
## Review round 1 (SAFETY-84)
Checked on 43a52e7 in `goodwe-addon-checks:1`. Base has not moved (9d5b7d1).
**Correct as designed.**
- The `charge` hi and the peak `lo` cannot meet in one cycle. The peak claim is filed only when `peak_guard.engaged`. The shaping, and so the ceiling, is filed only when `shaping_suspended()` is False, which needs `not engaged`. Both come from the same decision object in the same cycle. The only other site limit is the `drain` lo, and the maintenance slot interlock (`POWER_MOVING` includes DRAIN and CHARGE) keeps it out.
- Probe (`scratch/SAFETY-84/review/probe.py`): N=2 at margin 0 and 50, with and without a 4000 W banked peak, on random-walk houses (500–4800 W) and on steps exactly at quarter edges, plus 90–100 s P1 dropouts and a restart 420 s or 880 s into a quarter. Results: 0 cycles with both claims, 0 refusals, 0 opposite splits, 0 give-backs without the guard, no 'Load too high', and every banked quarter at or under its aim. The only 0 W cycles were 1 per dropout run, which is the site stop.
- Off battery: with B Off and a 6500 W house, B gives 568 W, which is the shortfall. At 4500 W it gives 0 W.
- Re-pin of `n2_maint` at margin 0: refusals go from 214 to 0. Every other row difference traces back to the charge now running (guard projection, and 1751→1838 W banked, under the 2500 W cap). The extra event lines only show because the 40 ARBITER lines no longer push them out of the log. The `test_arbiter`/`test_peakguard` changes only add the new source to the counts.
- N=1 identity: I ran the tool on a copy of 9d5b7d1 and all three N=1 digests match.
- M12 is behaviourally equivalent: commands and reasons are identical to the head on 10 scenarios, with the claim priority left out of the hash. M10 survives, and it is equivalent because at N=1 the maintenance claim routes to `site`, which `live_units()` never lists.
- Strings: nothing new. `charge` maps to the existing "battery maintenance".
**Findings (test gaps; the code is right).**
**F1: the N>=2 suspension has no behavioural check.** Mutant R3 skips `shaping_suspended()` at N>=2, so A keeps `hi = 0` during a peak event and cannot give. Part 2 does not catch it: at 5000 W, B alone covers the 1550 W, and the check asserts only the site sum, not that A gives. The mutant dies only through the pinned digests and a side effect of the headroom non-vacuous check. On a 6500 W house the mutant banks 4014 W, over the 3500 W cap, and 'Load too high' fires. The head banks 3450 W, and A gives on all 336 engaged cycles. Fix: add a 6500 W case (A must give, banked on the aim, no alarm) with its own non-vacuous run.
**F2: N=2 without P1 is not covered.** When nothing measured the quarter, `charge_headroom_w()` returns None and the charge is not sized, which matches N=1. Mutant R6 reads None as 0 W. A site with `meter_source: off` then never charges at N=2 (0 of 120 cycles), which is the original stall again, and every suite stays green. Fix: add a check that at N=2 with no P1, A charges at 2000 W on every cycle.
I am pushing both checks to the branch.
Mutations re-run (fresh copies, cache purged): M1, M4, M5, M6, M8, M18 killed (M4 and M18 by parts 1, 2, 4 and 5, as reported). R1 (headroom ignores the banked peak) killed. R2 (headroom + margin) killed by the digest only: the projection feedback absorbs it and the quarter still banks 3451 W, so the bill is unaffected. R4 (shaping outside a cheap window at N=2), R5 (`max`) and R7 (ceiling = rate, not the pack's `hi`) killed. R3 and R6 are F1 and F2 above.
A 6500 W house needs more than B alone: A must give (the N>=2 suspension),
with a non-vacuous run without it (banks over the cap, 'Load too high').
Without P1 the charge is not sized, as at N=1: A charges at 2000 W.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F1. On a 6500 W house (more than B's 2500 W alone), A gives on every engaged cycle (224 of 224), the quarter banks 3450 W, nothing is refused and no 'Load too high' fires. A non-vacuous run with shaping_suspended() stubbed to False shows A held at 0 W, the quarter over the cap and the alarm firing. Mutant R3 is now killed by this check directly (banked 4014 W, A gives on 0 of 360).
F2. At N=2 with meter_source: off, peak_guard is None and A charges at 2000 W on every cycle, with nothing refused. Mutant R6 (None read as 0 W) is now killed.
No code change. Nothing is open. I will merge once the required check is green on 5c9f64d.
N=1 headroom (owner question, not changed here): I think the same sizing fits at N=1. It would remove the give-back chatter and the 12 W over the aim, and it uses the same shared charge_headroom_w(). It should be its own ticket, with its own N=1 re-pin and a rig run.
## Review round 2: F1 and F2 fixed in 5c9f64d
`test_safety84.py` part 2 gets two new checks:
- **F1.** On a 6500 W house (more than B's 2500 W alone), A gives on every engaged cycle (224 of 224), the quarter banks 3450 W, nothing is refused and no 'Load too high' fires. A non-vacuous run with `shaping_suspended()` stubbed to False shows A held at 0 W, the quarter over the cap and the alarm firing. Mutant R3 is now killed by this check directly (banked 4014 W, A gives on 0 of 360).
- **F2.** At N=2 with `meter_source: off`, `peak_guard` is None and A charges at 2000 W on every cycle, with nothing refused. Mutant R6 (None read as 0 W) is now killed.
No code change. Nothing is open. I will merge once the required check is green on 5c9f64d.
N=1 headroom (owner question, not changed here): I think the same sizing fits at N=1. It would remove the give-back chatter and the 12 W over the aim, and it uses the same shared `charge_headroom_w()`. It should be its own ticket, with its own N=1 re-pin and a rig run.
admin
merged commit 4ad8298ead into release/1.02026-09-30 21:00:56 +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.
What was wrong
At N=2, a maintenance CHARGE in a cheap window stalled. The charging battery's own
hi = -charge(P_MAINT_SHAPE) makes it charge. The loop's site target sat above thathi, so the other battery had to give. The allocator refuses opposite directions, so the site went to 0 W. On9d5b7d1,tools/safety84_identity.pyn2_charge refuses 436 of 480 cycles;safety79_identityn2_maint refuses 214 of 540. N=1 was fine.The design
main.pyfiles a site limitchargeat P_MAINT_SHAPE. Itshiis what that battery'shiplaces, in whole W (allocator.hi_w(), rounded the way the allocator rounds). The site target never goes above it, so the other battery only adds charge in the same direction. This mirrors SAFETY-78'sdrainfloor.peakguard.charge_headroom_w()). The aim is the cap, or a banked month peak, minuspeak_margin_w. So the charge charges less instead of stopping, and the meter sits on the aim. When the house alone is over the aim, the guard engages and the shaping is suspended, as before (shaping_suspended()). Nochargeclaim is filed then. So the guard'sloand thishinever meet in one cycle: nolo > hi, no 0 W park.hiis a hard limit, so a charge under a 0 W stop needs the other battery to give. Before this, it was refused with an ARBITER line on most cycles of the stop.tools/safety84_identity.py, which hashes every claim. N=2 outside a cheap window is identical too.Why size to the headroom: without it the aim still held, because the suspension did it. But at a 3000 W house a battery gave power back on 285 of 540 cycles (charge, then the guard makes both give). With the sizing the count is 0, A charges on every cycle, B stays at 0 W, and every quarter lands on the aim.
Measured (real Controller, goodwe-addon-checks:1)
9d5b7d1)No false 'Load too high' (
PEAK_GUARD_SATURATED) in any run. The OUTPUT_CLAMP ("Battery is not keeping up") log line comes 2 times in 480 cycles at N=2, the same as at N=1.Tests
test_safety84.py, 29 checks:9d5b7d1, and N=2 is pinned.Re-pinned on purpose:
test_peak_margin.pyn2_maint at margin 0 (it was refused on 214 of 540 cycles, now 0). Updated source-set pins:test_arbiter.pyandtest_peakguard.py10f (the newchargesite source). The full suite passes locally in the CI image, 55 of 55.Mutation table
Each mutation was run on a fresh copy through
tools/mutation_run.sh(cache purged), in goodwe-addon-checks:1, grading test_safety84.py (M13 also test_arbiter.py and test_peakguard.py).chargeclaimb.hiinstead ofallocator.hi_w(b)(no rounding)hi_w()returns the unroundedb.hiu in livenot b.contradictionb.hi < 0.0_charge_ceilingsite, which is never inlive_units(), so nothing is filed anyway. The guard is kept so that N=1 does not rest on that.chargeat P_LOOPchargeat P_MAINTENANCElonever coexists with it.chargescope UNIT> P_LOOP(a trade stops the charge)_charge_headroomchargeStrings
There are no new user-facing strings. The Why line maps
chargeto the existing "battery maintenance", so N=2 reads as N=1 does: "held back by battery maintenance". The Why line is English-only today. DOCS gets one plain sentence next to the SAFETY-78 and SAFETY-83 lines.For the reviewer / owner
🤖 Generated with Claude Code
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
Review round 1 (SAFETY-84)
Checked on
43a52e7ingoodwe-addon-checks:1. Base has not moved (9d5b7d1).Correct as designed.
chargehi and the peaklocannot meet in one cycle. The peak claim is filed only whenpeak_guard.engaged. The shaping, and so the ceiling, is filed only whenshaping_suspended()is False, which needsnot engaged. Both come from the same decision object in the same cycle. The only other site limit is thedrainlo, and the maintenance slot interlock (POWER_MOVINGincludes DRAIN and CHARGE) keeps it out.scratch/SAFETY-84/review/probe.py): N=2 at margin 0 and 50, with and without a 4000 W banked peak, on random-walk houses (500–4800 W) and on steps exactly at quarter edges, plus 90–100 s P1 dropouts and a restart 420 s or 880 s into a quarter. Results: 0 cycles with both claims, 0 refusals, 0 opposite splits, 0 give-backs without the guard, no 'Load too high', and every banked quarter at or under its aim. The only 0 W cycles were 1 per dropout run, which is the site stop.n2_maintat margin 0: refusals go from 214 to 0. Every other row difference traces back to the charge now running (guard projection, and 1751→1838 W banked, under the 2500 W cap). The extra event lines only show because the 40 ARBITER lines no longer push them out of the log. Thetest_arbiter/test_peakguardchanges only add the new source to the counts.9d5b7d1and all three N=1 digests match.site, whichlive_units()never lists.chargemaps to the existing "battery maintenance".Findings (test gaps; the code is right).
F1: the N>=2 suspension has no behavioural check. Mutant R3 skips
shaping_suspended()at N>=2, so A keepshi = 0during a peak event and cannot give. Part 2 does not catch it: at 5000 W, B alone covers the 1550 W, and the check asserts only the site sum, not that A gives. The mutant dies only through the pinned digests and a side effect of the headroom non-vacuous check. On a 6500 W house the mutant banks 4014 W, over the 3500 W cap, and 'Load too high' fires. The head banks 3450 W, and A gives on all 336 engaged cycles. Fix: add a 6500 W case (A must give, banked on the aim, no alarm) with its own non-vacuous run.F2: N=2 without P1 is not covered. When nothing measured the quarter,
charge_headroom_w()returns None and the charge is not sized, which matches N=1. Mutant R6 reads None as 0 W. A site withmeter_source: offthen never charges at N=2 (0 of 120 cycles), which is the original stall again, and every suite stays green. Fix: add a check that at N=2 with no P1, A charges at 2000 W on every cycle.I am pushing both checks to the branch.
Mutations re-run (fresh copies, cache purged): M1, M4, M5, M6, M8, M18 killed (M4 and M18 by parts 1, 2, 4 and 5, as reported). R1 (headroom ignores the banked peak) killed. R2 (headroom + margin) killed by the digest only: the projection feedback absorbs it and the quarter still banks 3451 W, so the bill is unaffected. R4 (shaping outside a cheap window at N=2), R5 (
max) and R7 (ceiling = rate, not the pack'shi) killed. R3 and R6 are F1 and F2 above.Review round 2: F1 and F2 fixed in
5c9f64dtest_safety84.pypart 2 gets two new checks:shaping_suspended()stubbed to False shows A held at 0 W, the quarter over the cap and the alarm firing. Mutant R3 is now killed by this check directly (banked 4014 W, A gives on 0 of 360).meter_source: off,peak_guardis None and A charges at 2000 W on every cycle, with nothing refused. Mutant R6 (None read as 0 W) is now killed.No code change. Nothing is open. I will merge once the required check is green on
5c9f64d.N=1 headroom (owner question, not changed here): I think the same sizing fits at N=1. It would remove the give-back chatter and the 12 W over the aim, and it uses the same shared
charge_headroom_w(). It should be its own ticket, with its own N=1 re-pin and a rig run.