SAFETY-95: a maintenance drain that ends hands back smoothly #177

Merged
admin merged 3 commits from safety-95-drain-end-reseed into release/1.0 2026-10-07 17:48:53 +02:00
Owner

Card: SAFETY-95 (found in the SAFETY-93 review, O1). Mirror of SAFETY-93 on the drain end.

What was wrong

At N=2, when A's maintenance drain ended (switched Off, done, timeout, refusal) with the house at 1200 W, A's 2000 W drain floor stayed in the loop's site target and landed on B: B went 0 -> +1600 W in one cycle and exported. Under a solar surplus B gave 1600 W into the export.

What changed (app/main.py, N>=2 only)

  • _charge_track() now also tracks a drain: it ends when the battery leaves DRAIN, is not live, its claims contradict, or it refuses (SAFETY-73's limit outranks the pin, so the refusal is read from the battery).
  • _reseed_drain_end() (runs after the drop and charge-end reseeds): start = what the others stood at + A's new command (its pin, else 0 W; 0 W if dropped). If the house needs more, raised to the house's own point (live batteries' readings + grid - target), never above last cycle's start. Then one slew step a cycle.
  • _drain_grid_w(): while A settles (SAFETY-96's window), the loop's error is the house's point minus its start, not the meter, so it does not chase A's tail (B down then up again).
  • _reseed_drop() skips a draining battery whose drain ended (it is this rule's).
  • Why not SAFETY-93's rule alone: measured, B went to -465 W (charging) and the grid imported 849 W while the loop chased A's tail. Details: DECISIONS/SAFETY-95-drain-end.md.
  • One DOCS line (plain, links the card). No new log or notification strings.

Tests (test_safety95.py, real Controller, 10 s lag, 2 s meter)

  • Every drain end (complete -> charge, Off, timeout, abort, stale link, refusal) at N=2 AND N=3, houses 1200 W / 4000 W / -1500 W, each against base (the fix taken out in-process). Non-vacuous: base steps B by > 1 slew step into an export for complete, Off, timeout, refusal.
  • Rig case: B 0 -> 1210 W (not 1600), grid within 50 W of aim (base +559 W).
  • Peak guard still binds across the end; a drain end on the cycle another battery drops / rejoins; a missed poll.
  • N=1: every one of the six drain ends, 1200 W and surplus: rows byte-identical with and without the change. N=2 healthy moving house: byte-identical.
  • Unit checks of the rule's boundaries; fires exactly once (not at the charge or HOLD end).
  • Full suite: 66/66 files PASS (goodwe-addon-checks:1, GOODWE_REQUIRE_HWSIM=1).

Mutations (purged copies via tools/mutation_run.sh; ran test_safety95/93/96/78; control green)

mutant killed by
M1 reseed not called test_safety95 (34), test_safety78
M2 start = strict SAFETY-93 rule (no house raise) test_safety95 (21)
M3 no cap at last cycle's start test_safety95 (1)
M4 no tail (loop chases the meter) test_safety95 (19)
M5 refusal does not end a drain test_safety95 (7)
M6 drop reseed does not skip an ended drain test_safety95 (3), test_safety78
M7 house point counts a dropped battery test_safety95 (1)
M8 a freed battery keeps its drain in the start test_safety95 (23)
M9 rule runs at N=1 test_safety95 (1)
M10 tail not bounded by the settle window test_safety95 (1)
M11 tail counts only the ending battery test_safety95 (23)
M12 a dropped battery keeps its pinned command in the start test_safety95 (1; this check added in 9c0314a, the mutant survived on bdec66e)

test_modes.py re-pin (on purpose)

tools/opt11_identity.py n2_maint: A's drain ends by a refusal at t=1655 s. Before: B jumps to 2500 W (about 1100 W export) and slews back to 1400 W. After: B goes straight to the house's 1420 W and holds. Rows differ only at 1655-2105 s; the other 45 identity digests are byte-identical. New pin SAFETY95_N2_MAINT with a comment.

Open points

  • Stale link at N=2, 1200 W: after the drop the loop overshoots B to 1730 W for a few cycles (lagging plant, the dropped battery's tail is on the meter). Identical to base (SAFETY-96's drop path); no one-cycle jump (site step 310 W).
  • CHANGELOG "Known issues" still lists SAFETY-95; left for the release (as SAFETY-96 did).

🤖 Generated with Claude Code

Card: SAFETY-95 (found in the SAFETY-93 review, O1). Mirror of SAFETY-93 on the drain end. ## What was wrong At N=2, when A's maintenance drain ended (switched Off, done, timeout, refusal) with the house at 1200 W, A's 2000 W drain floor stayed in the loop's site target and landed on B: B went 0 -> +1600 W in one cycle and exported. Under a solar surplus B gave 1600 W into the export. ## What changed (`app/main.py`, N>=2 only) - `_charge_track()` now also tracks a drain: it ends when the battery leaves DRAIN, is not live, its claims contradict, or it refuses (SAFETY-73's limit outranks the pin, so the refusal is read from the battery). - `_reseed_drain_end()` (runs after the drop and charge-end reseeds): start = what the others stood at + A's new command (its pin, else 0 W; 0 W if dropped). If the house needs more, raised to the house's own point (live batteries' readings + grid - target), never above last cycle's start. Then one slew step a cycle. - `_drain_grid_w()`: while A settles (SAFETY-96's window), the loop's error is the house's point minus its start, not the meter, so it does not chase A's tail (B down then up again). - `_reseed_drop()` skips a draining battery whose drain ended (it is this rule's). - Why not SAFETY-93's rule alone: measured, B went to -465 W (charging) and the grid imported 849 W while the loop chased A's tail. Details: `DECISIONS/SAFETY-95-drain-end.md`. - One DOCS line (plain, links the card). No new log or notification strings. ## Tests (`test_safety95.py`, real Controller, 10 s lag, 2 s meter) - Every drain end (complete -> charge, Off, timeout, abort, stale link, refusal) at N=2 AND N=3, houses 1200 W / 4000 W / -1500 W, each against base (the fix taken out in-process). Non-vacuous: base steps B by > 1 slew step into an export for complete, Off, timeout, refusal. - Rig case: B 0 -> 1210 W (not 1600), grid within 50 W of aim (base +559 W). - Peak guard still binds across the end; a drain end on the cycle another battery drops / rejoins; a missed poll. - N=1: every one of the six drain ends, 1200 W and surplus: rows byte-identical with and without the change. N=2 healthy moving house: byte-identical. - Unit checks of the rule's boundaries; fires exactly once (not at the charge or HOLD end). - Full suite: 66/66 files PASS (`goodwe-addon-checks:1`, `GOODWE_REQUIRE_HWSIM=1`). ## Mutations (purged copies via `tools/mutation_run.sh`; ran test_safety95/93/96/78; control green) | mutant | killed by | |---|---| | M1 reseed not called | test_safety95 (34), test_safety78 | | M2 start = strict SAFETY-93 rule (no house raise) | test_safety95 (21) | | M3 no cap at last cycle's start | test_safety95 (1) | | M4 no tail (loop chases the meter) | test_safety95 (19) | | M5 refusal does not end a drain | test_safety95 (7) | | M6 drop reseed does not skip an ended drain | test_safety95 (3), test_safety78 | | M7 house point counts a dropped battery | test_safety95 (1) | | M8 a freed battery keeps its drain in the start | test_safety95 (23) | | M9 rule runs at N=1 | test_safety95 (1) | | M10 tail not bounded by the settle window | test_safety95 (1) | | M11 tail counts only the ending battery | test_safety95 (23) | | M12 a dropped battery keeps its pinned command in the start | test_safety95 (1; this check added in 9c0314a, the mutant survived on bdec66e) | ## test_modes.py re-pin (on purpose) `tools/opt11_identity.py` n2_maint: A's drain ends by a refusal at t=1655 s. Before: B jumps to 2500 W (about 1100 W export) and slews back to 1400 W. After: B goes straight to the house's 1420 W and holds. Rows differ only at 1655-2105 s; the other 45 identity digests are byte-identical. New pin `SAFETY95_N2_MAINT` with a comment. ## Open points - Stale link at N=2, 1200 W: after the drop the loop overshoots B to 1730 W for a few cycles (lagging plant, the dropped battery's tail is on the meter). Identical to base (SAFETY-96's drop path); no one-cycle jump (site step 310 W). - CHANGELOG "Known issues" still lists SAFETY-95; left for the release (as SAFETY-96 did). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
admin added 2 commits 2026-10-07 14:37:00 +02:00
When a battery's drain ended (done, Off, timeout, abort, stale link,
refusal), its drain floor stayed in the site target and landed on the
others: B 0 -> +1600 W in one cycle at a 1200 W house, exporting; under a
solar surplus B gave into the export.

Now, on that cycle, the loop starts from what the others stood at plus the
battery's new command, raised to the house's own point when the house needs
more (never above last cycle's start). While the battery settles, the loop
counts the batteries at its start instead of chasing the drain's tail on
the meter. Then one slew step a cycle. N=1 is unchanged.

test_safety95.py; DECISIONS/SAFETY-95-drain-end.md; one DOCS line.
test_modes.py: opt11 n2_maint re-pinned on purpose (its drain ends by a
refusal at 1655 s; B now stays at the house's 1420 W instead of 2500 W).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SAFETY-95: N=1 identity for every drain end; plainer DOCS line
checks / unit (pull_request) Successful in 32m35s
infra-pin / fresh (pull_request) Successful in 2s
9c0314a826
test_safety95.py: the N=1 byte-identity now covers all six drain ends
(Off and stale link were missing); end_at()/measure() read through a
default, so test_named_absence.py passes (it failed on bdec66e); a check
that a dropped battery's start is 0 W even under a pinned charge (it
kills a mutation that survived).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

SAFETY-95 review (head 9c0314a): changes requested

The fix is sound. On my own runs: full suite 66/66 green; N=1 byte-identical against the real release/1.0 tree (not only the in-process without()) for all six drain ends at 1200 W, 4000 W and -1500 W; the n2_maint re-pin is a real gain (base: B 0 -> 2500 W at 1655 s, grid -1096 W; head: B holds 1420 W, grid -16 W; only 1655-2105 s differ); held battery at its reserve (N=2/N=3, 1200/4000 W, with and without the P1 peak guard) still gives last and nothing exports. All 12 listed mutants are killed (control green). Two of my own mutants survive the full suite, so two parts of the rule have no test.

F1: the stale-link leg of the rule has no test

  • Where: app/main.py:2458 (u in live as a drain end), :2510 (dropped branch), :2549 (drop reseed skips it). test_safety95.py:297 grades stale link only against base +100 W.
  • Mutant R2 (u in live or res.phase == DRAIN, so a dropped drain goes back to SAFETY-96's _reseed_drop()) passes all 66 files.
  • Failure scenario: someone reverts this leg and nothing goes red. It does make a difference that you measured: N=3, 4000 W, board watchdog 25 s: grid +676 W with it, +786 W without. N=2, 1200 W, board at 0 W before the drop: B 1080 -> 1240 W at once with it, slews 1120/1170/1210 W without.
  • Fix: one check that tells head from base for a stale link, e.g. N=3 4000 W imp <= old_imp - 50, or the board-at-0 case where B reaches the house's point on the drop cycle.

F2: the contradiction clause for a drain has no test

  • Where: app/main.py:2459 (.contradiction now also ends a DRAIN).
  • Mutant R3 (contradiction ends only a CHARGE) passes all 66 files.
  • Failure scenario: a drain pin crossed by a hard limit (e.g. SAFETY-91's drain floor against a pack limit at empty) is sent 0 W, no end fires, and the old target lands on B. The refusal clause may cover every reachable case. If so, say why.
  • Fix: a test where the drain ends only by a contradiction, or a short argument that it is unreachable apart from a refusal (a good argument closes this).

F3 (nit, not blocking): the refusal sign

  • Where: app/main.py:2460 inv.refused is a sign (+1 will not give, -1 will not take). _held_back() uses (inv.refused or 0) > 0.
  • Mutant R8 (inv.refused > 0) survives. That is expected, because it is the intended meaning. A charge refusal still latched when a drain starts keeps that drain out of tracking until the refusal clears.
  • Fix: inv.refused > 0, matching _held_back(). Optional.

Mutations (purged copies, tools/mutation_run.sh, test_safety95/93/96/78 + test_modes)

M1-M12 all killed. My own: R1 drain-end reseed before the drop reseed: killed. R4 i_w not reseeded: killed. R5 house point without the grid aim: killed. R6 no settle mark: killed. R7 refusal of the wrong sign: killed. R9 tail error against the old start: killed. R2 and R3 survive (full suite). R8 survives (equivalent; see F3).

DOCS line: fine, it matches its neighbours in that section.

Not merging. Back to dev for F1 and F2. F3 is optional.

## SAFETY-95 review (head 9c0314a): changes requested The fix is sound. On my own runs: full suite 66/66 green; N=1 byte-identical against the real release/1.0 tree (not only the in-process `without()`) for all six drain ends at 1200 W, 4000 W and -1500 W; the n2_maint re-pin is a real gain (base: B 0 -> 2500 W at 1655 s, grid -1096 W; head: B holds 1420 W, grid -16 W; only 1655-2105 s differ); held battery at its reserve (N=2/N=3, 1200/4000 W, with and without the P1 peak guard) still gives last and nothing exports. All 12 listed mutants are killed (control green). Two of my own mutants survive the **full** suite, so two parts of the rule have no test. ### F1: the stale-link leg of the rule has no test - **Where:** `app/main.py:2458` (`u in live` as a drain end), `:2510` (dropped branch), `:2549` (drop reseed skips it). `test_safety95.py:297` grades stale link only against base +100 W. - **Mutant R2** (`u in live or res.phase == DRAIN`, so a dropped drain goes back to SAFETY-96's `_reseed_drop()`) passes all 66 files. - **Failure scenario:** someone reverts this leg and nothing goes red. It does make a difference that you measured: N=3, 4000 W, board watchdog 25 s: grid +676 W with it, +786 W without. N=2, 1200 W, board at 0 W before the drop: B 1080 -> 1240 W at once with it, slews 1120/1170/1210 W without. - **Fix:** one check that tells head from base for a stale link, e.g. N=3 4000 W `imp <= old_imp - 50`, or the board-at-0 case where B reaches the house's point on the drop cycle. ### F2: the contradiction clause for a drain has no test - **Where:** `app/main.py:2459` (`.contradiction` now also ends a DRAIN). - **Mutant R3** (contradiction ends only a CHARGE) passes all 66 files. - **Failure scenario:** a drain pin crossed by a hard limit (e.g. SAFETY-91's drain floor against a pack limit at empty) is sent 0 W, no end fires, and the old target lands on B. The refusal clause may cover every reachable case. If so, say why. - **Fix:** a test where the drain ends only by a contradiction, or a short argument that it is unreachable apart from a refusal (a good argument closes this). ### F3 (nit, not blocking): the refusal sign - **Where:** `app/main.py:2460` `inv.refused` is a sign (+1 will not give, -1 will not take). `_held_back()` uses `(inv.refused or 0) > 0`. - **Mutant R8** (`inv.refused > 0`) survives. That is expected, because it is the intended meaning. A charge refusal still latched when a drain starts keeps that drain out of tracking until the refusal clears. - **Fix:** `inv.refused > 0`, matching `_held_back()`. Optional. ### Mutations (purged copies, `tools/mutation_run.sh`, test_safety95/93/96/78 + test_modes) M1-M12 all killed. My own: R1 drain-end reseed before the drop reseed: killed. R4 i_w not reseeded: killed. R5 house point without the grid aim: killed. R6 no settle mark: killed. R7 refusal of the wrong sign: killed. R9 tail error against the old start: killed. **R2 and R3 survive (full suite)**. R8 survives (equivalent; see F3). DOCS line: fine, it matches its neighbours in that section. Not merging. Back to dev for F1 and F2. F3 is optional.
admin added 1 commit 2026-10-07 16:41:18 +02:00
SAFETY-95 review: test the stale-link and contradiction legs; refusal +1 only
checks / unit (pull_request) Successful in 32m34s
infra-pin / fresh (pull_request) Successful in 1s
4707d37019
F1: test_safety95 checks that a dropped draining battery is this rule's
(stale link, N=3, 4000 W: grid import 676 W vs base 786 W); kills R2.
F2: _charge_track tested directly with a contradicting drain (SAFETY-91's
pin as a floor against a closed pack); kills R3. Today a drain contradicts
only through a refusal; the clause stays, as a contradiction sends 0 W.
F3: a drain ends on a refusal only when the battery will not give
(refused > 0, as _held_back()); one that will not take still drains.
Tested both signs; kills R8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

F1: fixed in 4707d37.
New check in test_safety95.py, section 1: "⭐ stale link, N=3, 4000 W: a dropped draining battery is this rule's, not the link-drop reseed's". It requires the grid import to be at least 50 W below base. Head: 676 W, base: 786 W.

R2 (u in live or res.phase == DRAIN) is killed by that check: under R2 the import is 786 W, the same as base. The other files stay green under R2.

**F1: fixed in 4707d37.** New check in `test_safety95.py`, section 1: "⭐ stale link, N=3, 4000 W: a dropped draining battery is this rule's, not the link-drop reseed's". It requires the grid import to be at least 50 W below base. Head: 676 W, base: 786 W. R2 (`u in live or res.phase == DRAIN`) is killed by that check: under R2 the import is 786 W, the same as base. The other files stay green under R2.
Author
Owner

F2: fixed with a test in 4707d37. The clause stays.

The test calls _charge_track() directly. A is draining, and its drain SET is crossed by SAFETY-91's pin-as-floor (maintenance LIMIT lo=2000 at P_MAINTENANCE) against a closed pack (pack LIMIT hi=0 at P_SAFETY). That gives a contradiction, and the drain must end. A control case (rating only) must not end. This kills R3.

Can it happen? Not today, except through a refusal:

  • Every unit limit at or above P_MAINTENANCE contains 0 W: device rating, supervised ±max_w, pack envelope, refused interval.
  • The one that does not is SAFETY-91's lo=p (_drain_over_reserve). Its p is pin_w() of the drain SET, already clamped into those limits.
  • The only limit that changes after that is SAFETY-73's probe void (refused), and that is a refusal, already an end.

I kept the clause rather than simplify it away. A contradiction sends A 0 W (main.py split: p = None if ... b.contradiction), which is a drain end whatever caused it. It matches SAFETY-93's charge rule, so a future limit cannot reopen the jump silently. The DECISIONS note says this.

**F2: fixed with a test in 4707d37. The clause stays.** The test calls `_charge_track()` directly. A is draining, and its drain SET is crossed by SAFETY-91's pin-as-floor (`maintenance` LIMIT `lo=2000` at P_MAINTENANCE) against a closed pack (`pack` LIMIT `hi=0` at P_SAFETY). That gives a contradiction, and the drain must end. A control case (rating only) must not end. This kills R3. **Can it happen?** Not today, except through a refusal: - Every unit limit at or above P_MAINTENANCE contains 0 W: device rating, supervised ±max_w, pack envelope, refused interval. - The one that does not is SAFETY-91's `lo=p` (`_drain_over_reserve`). Its `p` is `pin_w()` of the drain SET, already clamped into those limits. - The only limit that changes after that is SAFETY-73's probe void (`refused`), and that is a refusal, already an end. I kept the clause rather than simplify it away. A contradiction sends A 0 W (`main.py` split: `p = None if ... b.contradiction`), which is a drain end whatever caused it. It matches SAFETY-93's charge rule, so a future limit cannot reopen the jump silently. The DECISIONS note says this.
Author
Owner

F3: fixed in 4707d37.
_charge_track() now uses (inv.refused or 0) > 0, as _held_back() does. A battery that will not take still drains.

New check: "a refusal ends a drain only if A will not give (+1); one that will not take (-1) leaves the drain standing". It failed on 9c0314a and passes now, so R8's old form (inv.refused truthy) is killed. R7 (< 0) is still killed by 8 checks plus test_modes.

The n2_maint digest is unchanged. Full suite: 66/66.

**F3: fixed in 4707d37.** `_charge_track()` now uses `(inv.refused or 0) > 0`, as `_held_back()` does. A battery that will not take still drains. New check: "a refusal ends a drain only if A will not give (+1); one that will not take (-1) leaves the drain standing". It failed on 9c0314a and passes now, so R8's old form (`inv.refused` truthy) is killed. R7 (`< 0`) is still killed by 8 checks plus test_modes. The n2_maint digest is unchanged. Full suite: 66/66.
Author
Owner

SAFETY-95 re-review (head 4707d37): approved

I re-ran every mutant on purged copies of 4707d37 (test_safety95/93/96/78 + test_modes, control green). M1-M12 are all killed. R1-R9 are all killed, including R2, R3 and R8 in its old form (inv.refused truthy). Full suite: 66/66. test_modes is green, so the n2_maint digest is unchanged.

  • F1: closed. The new check is a real difference, not a tolerance: head imports 676 W, base 786 W, and the bar is base - 50 W (736 W). R2 now fails it at 786 W.
  • F2: closed. I accept the argument. A contradiction sends A 0 W (arbiter: "CONTRADICTION ... commanding 0 W"), so it is a drain end whatever caused it. The clause matches SAFETY-93's charge end. The DECISIONS note now says this, and the direct _charge_track() test kills R3.
  • F3: closed. (inv.refused or 0) > 0 matches _held_back(). The new check covers both signs (+1 ends the drain, -1 leaves it standing). It kills R8 in its old form, and R7 is still killed.

The diff 9c0314a..4707d37 holds nothing else: the one-line sign fix, the docstring, the DECISIONS paragraph, and 3 tests.

Merging with head pinned to 4707d37.

## SAFETY-95 re-review (head 4707d37): approved I re-ran every mutant on purged copies of 4707d37 (test_safety95/93/96/78 + test_modes, control green). M1-M12 are all killed. R1-R9 are all killed, including R2, R3 and R8 in its old form (`inv.refused` truthy). Full suite: 66/66. test_modes is green, so the n2_maint digest is unchanged. - **F1: closed.** The new check is a real difference, not a tolerance: head imports 676 W, base 786 W, and the bar is base - 50 W (736 W). R2 now fails it at 786 W. - **F2: closed. I accept the argument.** A contradiction sends A 0 W (arbiter: "CONTRADICTION ... commanding 0 W"), so it is a drain end whatever caused it. The clause matches SAFETY-93's charge end. The DECISIONS note now says this, and the direct `_charge_track()` test kills R3. - **F3: closed.** `(inv.refused or 0) > 0` matches `_held_back()`. The new check covers both signs (+1 ends the drain, -1 leaves it standing). It kills R8 in its old form, and R7 is still killed. The diff 9c0314a..4707d37 holds nothing else: the one-line sign fix, the docstring, the DECISIONS paragraph, and 3 tests. Merging with head pinned to 4707d37.
admin merged commit 340c7eef42 into release/1.0 2026-10-07 17:48:53 +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#177