SAFETY-84: maintenance charge at N=2 no longer stalls #160

Merged
admin merged 2 commits from safety-84-n2-maint-charge into release/1.0 2026-09-30 21:00:56 +02:00
Owner

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.ai/code/session_019DBUfvndgHwaV339Fvd2gr

## 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
admin added 1 commit 2026-09-30 20:01:01 +02:00
SAFETY-84: maintenance charge at N=2 no longer stalls
checks / unit (pull_request) Successful in 23m57s
infra-pin / fresh (pull_request) Successful in 2s
43a52e7f60
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>
Author
Owner

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.

## 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.
admin added 1 commit 2026-09-30 20:36:48 +02:00
SAFETY-84 review: check the charging pack gives under the guard, and no-P1 at N=2
checks / unit (pull_request) Successful in 23m51s
infra-pin / fresh (pull_request) Successful in 1s
5c9f64d87a
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>
Author
Owner

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.

## 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.0 2026-09-30 21:00:56 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#160