SAFETY-126: no false peak reserve alarm during the night grid charge #221

Merged
admin merged 3 commits from SAFETY-126 into release/1.0 2026-10-31 12:26:05 +01:00
Owner

SAFETY-126: no false "Battery stopped limiting the peak" alarm during the night grid charge under the peak reserve.

Cause

A grid charge under the peak limit lands the 15-minute period on the aim. A charge that runs a little harder than asked lands it 0-7 W over. In the period's last second excess_w() (app/peakguard.py, QUARTER_S * (projected - target) / remaining) multiplies that by 900 / remaining: 3 000 at 0.3 s, 90 000 at 0.01 s. So decide() wants tens of kW (wanted > 0, its floor-limited branch). With every battery under the reserve that is floor-limited at 0 W, and check_peak_floor_limited() alarmed on it at once. SAFETY-87's _reserve_short() grades only the case where another battery can give. With one battery able, the guard asks it for its rating for that cycle and the "Why" says "The peak limit needs power from the batteries."
The add-on's own charge is not the cause. wanted = batt + excess already counts it as stoppable.

Fix

  • peakguard.misses_aim(): with the batteries at 0 W from now on, does the period end more than ALARM_MISS_W (10 W) over the aim? This is load_too_high() with nothing to give, so there is no gain.
  • Controller._peak_landing(): runs each cycle the guard wants power, on the lower of two battery readings, as SAFETY-81 does. It marks the decision within_aim until a real miss is seen, which then stands until the guard lets go.
  • A within_aim decision raises no reserve alarm (ERROR, notification, red row), no red "Load too high" row, no "Why" peak and no "Battery stopped limiting the peak" event hint. The floor, the claim and the commands are unchanged.
  • DOCS one sentence, CHANGELOG one line (no version change, stays 0.19.2), DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md.

Tests (test_safety126.py, real Controller, OPT-15 lag, 1 s cycle 0.3 s or 0.01 s before the edge)

  • Part 1, night grid charge under the reserve: aim 3 450 (owner's shape) and banked 5 413. Result: 0 ERROR, 0 notifications, 0 red rows, 0 "Why" peak, quarters at or under aim + 10 W. Not vacuous: the guard was floor-limited at the edge. With the fix reverted the same runs alarm. Commands are identical with and without the fix.
  • On release/1.0 cc9bbeb, the file fails in part 1: 6 checks, then a traceback in part 3 because misses_aim does not exist there.
  • Part 2, real overrun under the reserve (night charge, then a 6 500 W house; modes off, 5 500 W): ERROR + notification naming both batteries. A step load alarms on the same cycle as before the fix. No new notifications.
  • Part 2, round 2 (review F3): after a real overrun ends, the latch is reset and the night's later landings are quiet again.

What the 10 W tolerance costs (review B1, B2; owner accepted 2026-10-31)

  • A steadily rising load alarms a little later, once the projected miss passes 10 W (review: 3 000 -> 4 000 W over 45 min, 56 s later). With the default 50 W margin the alarm still comes before the limit.
  • With peak_margin_w 0, a quarter can end up to 10 W over the limit without this alarm, the tolerance "Load too high" already accepts (SAFETY-81).
  • Parts 3-4: misses_aim() at its boundaries; the page and "Why" on a within_aim decision.

Mutations (purged copies, test_safety126.py)

mutant result
M1 alarm ignores within_aim red (3)
M2 "Why" ignores within_aim red (4)
M3 misses_aim always True red (11)
M4 misses_aim always False (no real alarm) red (8)
fix reverted (_peak_landing identity, in-file) red (part 1)
R2 latch never reset (round 2, F3) red, 2 checks

Digests (same inputs, head vs base, 21:50-23:30 local, cycle 0.3 s before the edge)

case cmds sha (both) base notes / errors / Why peak head
owner night, aim 3 450 1d5dce88… (5 004) 1 / 1 / 4 0 / 0 / 0
owner night, peak 5 413 9d800c9b… (914) 0 / 0 / 4 0 / 0 / 0
modes off, 5 500 W from 22:10 24f556fc… (2 244) 1 / 1 / 0 1 / 1 / 0
arbitrage on, modes off b3c3925c… (9 919) 2 / 2 / 3 0 / 0 / 0

Full suite locally: 92 files. The first run had 2 fails (named_absence reads, MUTATION.md pointer in the new file). Both are fixed in 8e2463b and re-run green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC

SAFETY-126: no false "Battery stopped limiting the peak" alarm during the night grid charge under the peak reserve. ## Cause A grid charge under the peak limit lands the 15-minute period on the aim. A charge that runs a little harder than asked lands it 0-7 W over. In the period's last second `excess_w()` (`app/peakguard.py`, `QUARTER_S * (projected - target) / remaining`) multiplies that by 900 / remaining: 3 000 at 0.3 s, 90 000 at 0.01 s. So `decide()` wants tens of kW (`wanted > 0`, its floor-limited branch). With every battery under the reserve that is floor-limited at 0 W, and `check_peak_floor_limited()` alarmed on it at once. SAFETY-87's `_reserve_short()` grades only the case where another battery can give. With one battery able, the guard asks it for its rating for that cycle and the "Why" says "The peak limit needs power from the batteries." The add-on's own charge is not the cause. `wanted = batt + excess` already counts it as stoppable. ## Fix - `peakguard.misses_aim()`: with the batteries at 0 W from now on, does the period end more than `ALARM_MISS_W` (10 W) over the aim? This is `load_too_high()` with nothing to give, so there is no gain. - `Controller._peak_landing()`: runs each cycle the guard wants power, on the lower of two battery readings, as SAFETY-81 does. It marks the decision `within_aim` until a real miss is seen, which then stands until the guard lets go. - A `within_aim` decision raises no reserve alarm (ERROR, notification, red row), no red "Load too high" row, no "Why" peak and no "Battery stopped limiting the peak" event hint. **The floor, the claim and the commands are unchanged.** - DOCS one sentence, CHANGELOG one line (no version change, stays 0.19.2), `DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md`. ## Tests (`test_safety126.py`, real Controller, OPT-15 lag, 1 s cycle 0.3 s or 0.01 s before the edge) - Part 1, night grid charge under the reserve: aim 3 450 (owner's shape) and banked 5 413. Result: 0 ERROR, 0 notifications, 0 red rows, 0 "Why" peak, quarters at or under aim + 10 W. Not vacuous: the guard was floor-limited at the edge. With the fix reverted the same runs alarm. Commands are identical with and without the fix. - On release/1.0 cc9bbeb, the file fails in part 1: 6 checks, then a traceback in part 3 because `misses_aim` does not exist there. - Part 2, real overrun under the reserve (night charge, then a 6 500 W house; modes off, 5 500 W): ERROR + notification naming both batteries. A step load alarms on the same cycle as before the fix. No new notifications. - Part 2, round 2 (review F3): after a real overrun ends, the latch is reset and the night's later landings are quiet again. ## What the 10 W tolerance costs (review B1, B2; owner accepted 2026-10-31) - A steadily rising load alarms a little later, once the projected miss passes 10 W (review: 3 000 -> 4 000 W over 45 min, 56 s later). With the default 50 W margin the alarm still comes before the limit. - With `peak_margin_w` 0, a quarter can end up to 10 W over the limit without this alarm, the tolerance "Load too high" already accepts (SAFETY-81). - Parts 3-4: `misses_aim()` at its boundaries; the page and "Why" on a `within_aim` decision. ## Mutations (purged copies, `test_safety126.py`) | mutant | result | |---|---| | M1 alarm ignores `within_aim` | red (3) | | M2 "Why" ignores `within_aim` | red (4) | | M3 `misses_aim` always True | red (11) | | M4 `misses_aim` always False (no real alarm) | red (8) | | fix reverted (`_peak_landing` identity, in-file) | red (part 1) | | R2 latch never reset (round 2, F3) | red, 2 checks | ## Digests (same inputs, head vs base, 21:50-23:30 local, cycle 0.3 s before the edge) | case | cmds sha (both) | base notes / errors / Why peak | head | |---|---|---|---| | owner night, aim 3 450 | 1d5dce88… (5 004) | 1 / 1 / 4 | 0 / 0 / 0 | | owner night, peak 5 413 | 9d800c9b… (914) | 0 / 0 / 4 | 0 / 0 / 0 | | modes off, 5 500 W from 22:10 | 24f556fc… (2 244) | 1 / 1 / 0 | 1 / 1 / 0 | | arbitrage on, modes off | b3c3925c… (9 919) | 2 / 2 / 3 | 0 / 0 / 0 | Full suite locally: 92 files. The first run had 2 fails (named_absence reads, MUTATION.md pointer in the new file). Both are fixed in 8e2463b and re-run green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
admin added 2 commits 2026-10-31 01:55:09 +01:00
During the night grid charge under the peak reserve the guard alarmed
(ERROR PEAK_GUARD_FLOOR_LIMITED, "Battery stopped limiting the peak") and the
Batteries "Why" said "The peak limit needs power" at quarter ends, while every
quarter landed on the aim. The charge lands the period 0-7 W over the aim and
excess_w()'s 900 / remaining turns that into tens of kW in the last second;
with nothing able to give, check_peak_floor_limited() alarmed on any
wanted > 0.

Each cycle the guard wants power, _peak_landing() asks misses_aim(): with the
batteries at 0 W, does the period end more than ALARM_MISS_W over the aim? If
not, the decision is within_aim: no reserve alarm, no red peak row, no "Why"
peak. The claim and the commands are unchanged. A real overrun under the
reserve alarms on the same cycle as before.

Co-Authored-By: Claude <noreply@anthropic.com>
SAFETY-126: test reads through named_absence; points at MUTATION.md
checks / unit (pull_request) Successful in 19m25s
infra-pin / fresh (pull_request) Successful in 1s
8e2463bc32
Co-Authored-By: Claude <noreply@anthropic.com>
Author
Owner

SAFETY-126 review (head 8e2463b) - not merged, owner decision needed

The cause is right and the fix removes the rig's false alarm with identical commands. But I found two cases that the brief counts as blockers, so I did not merge.

Checked fine:

  • The cause, reproduced on cc9bbeb: ERROR + notification at the quarter edge, quarters at +1.5 to +1.9 W.
  • Commands are byte-identical in all 41 break-it shapes and the 4 digests.
  • Full suite: 92/92 locally. CI 1464 and 1465 are green.

Findings

B1 - High (brief blocker): a load that rises steadily now alarms later than before. app/peakguard.py:1005 (misses_aim), app/main.py:6197.

  • Shape: modes off, both batteries at 10 %, house rising 3 000 -> 4 000 W over 45 min.
  • The quarter ended at 3 500.2 W, which is 50 W over the 3 450 W aim and 0.2 W over the cap.
  • First alarm: base 1 315 s (3 notifications in 5 s), head 1 371 s (1 notification), so 56 s later.
  • Why: the 10 W tolerance. The alarm now fires when the projected miss passes 10 W, not 0 W. Any tolerance does this to a rising load.
  • With step loads the alarm comes on the same cycle as base. I checked 6 500 / 4 100 W mid-quarter, in the last 1 s and 5 s, in the first 1 s, a 20 s spike, N=1, arbitrage on, a 5 s meter lag and late P1.
  • The DECISIONS note (line 29), DOCS and the PR say "fires on the same cycle as before". That is not true for a rising load.

B2 - Medium (brief blocker, not the default config): at peak_margin_w under 10, a quarter can end over the billed limit with no alarm. app/peakguard.py:1013.

  • Margin 0, house 3 505 W or 3 509 W, batteries under the reserve.
  • Every quarter ends 5-9 W over the 3 500 W cap. The first one raises the month peak.
  • Head raises no alarm. Base raises one at 1 s.
  • At 3 515 W both alarm.
  • At the default margin of 50 a 10 W miss over the aim never reaches the limit. This is SAFETY-81's tolerance, which already accepts 10 W over the real limit.

F3 - Low (test gap): mutant "_peak_needed latch never cleared" survives test_safety126.py. The reset is at app/main.py:3006.

  • With that mutant, the false alarm comes back after any real overrun.
  • Wanted: a test where a real overrun ends and the night charge's quarter landings that follow are quiet again.

Info:

  • The modes-off re-alarms that were dropped were false, so dropping them is right. Head 2 notifications, base 4; with a step to 5 500 W: head 2, base 5. In each case, with the batteries at 0 W, the quarter could no longer end over the aim, and it ended at 1 934-2 925 W.
  • No string was added. The engage hint "Peak guard started" is true to control.
  • The CHANGELOG ### Not in a release yet under 0.19.2 is acceptable: test_version_gate passes. The release owner should move it.
  • The run-tests.sh change is a sort order only.

Options for the owner

  1. Accept the tolerance as is. Fix the text: a rising load alarms once the projected miss passes 10 W, which at margin >= 10 is still before the limit. Add the latch test.
  2. Round 2, alarm path only:
    • Cap the tolerance at the billed limit, so a miss counts when the quarter would end over min(aim + 10 W, limit). This closes B2. At margin 0 a +2 W landing does raise the month peak, so alarming on it there is true.
    • Optionally, use the tolerance only in the quarter's last seconds, so a rising load alarms on base's cycle. This closes B1.
    • Add the latch test.

Details are in reports/SAFETY-126-review-report.md.

## SAFETY-126 review (head 8e2463b) - not merged, owner decision needed The cause is right and the fix removes the rig's false alarm with identical commands. But I found two cases that the brief counts as blockers, so I did not merge. **Checked fine:** - The cause, reproduced on cc9bbeb: ERROR + notification at the quarter edge, quarters at +1.5 to +1.9 W. - Commands are byte-identical in all 41 break-it shapes and the 4 digests. - Full suite: 92/92 locally. CI 1464 and 1465 are green. ### Findings **B1 - High (brief blocker): a load that rises steadily now alarms later than before.** `app/peakguard.py:1005` (`misses_aim`), `app/main.py:6197`. - Shape: modes off, both batteries at 10 %, house rising 3 000 -> 4 000 W over 45 min. - The quarter ended at 3 500.2 W, which is 50 W over the 3 450 W aim and 0.2 W over the cap. - First alarm: base 1 315 s (3 notifications in 5 s), head 1 371 s (1 notification), so **56 s later**. - Why: the 10 W tolerance. The alarm now fires when the projected miss passes 10 W, not 0 W. Any tolerance does this to a rising load. - With step loads the alarm comes on the same cycle as base. I checked 6 500 / 4 100 W mid-quarter, in the last 1 s and 5 s, in the first 1 s, a 20 s spike, N=1, arbitrage on, a 5 s meter lag and late P1. - The DECISIONS note (line 29), DOCS and the PR say "fires on the same cycle as before". That is not true for a rising load. **B2 - Medium (brief blocker, not the default config): at `peak_margin_w` under 10, a quarter can end over the billed limit with no alarm.** `app/peakguard.py:1013`. - Margin 0, house 3 505 W or 3 509 W, batteries under the reserve. - Every quarter ends 5-9 W over the 3 500 W cap. The first one raises the month peak. - Head raises no alarm. Base raises one at 1 s. - At 3 515 W both alarm. - At the default margin of 50 a 10 W miss over the aim never reaches the limit. This is SAFETY-81's tolerance, which already accepts 10 W over the real limit. **F3 - Low (test gap): mutant "`_peak_needed` latch never cleared" survives `test_safety126.py`.** The reset is at `app/main.py:3006`. - With that mutant, the false alarm comes back after any real overrun. - Wanted: a test where a real overrun ends and the night charge's quarter landings that follow are quiet again. **Info:** - The modes-off re-alarms that were dropped were false, so dropping them is right. Head 2 notifications, base 4; with a step to 5 500 W: head 2, base 5. In each case, with the batteries at 0 W, the quarter could no longer end over the aim, and it ended at 1 934-2 925 W. - No string was added. The engage hint "Peak guard started" is true to control. - The CHANGELOG `### Not in a release yet` under 0.19.2 is acceptable: `test_version_gate` passes. The release owner should move it. - The `run-tests.sh` change is a sort order only. ### Options for the owner 1. **Accept the tolerance as is.** Fix the text: a rising load alarms once the projected miss passes 10 W, which at margin >= 10 is still before the limit. Add the latch test. 2. **Round 2, alarm path only:** - Cap the tolerance at the billed limit, so a miss counts when the quarter would end over min(aim + 10 W, limit). This closes B2. At margin 0 a +2 W landing does raise the month peak, so alarming on it there is true. - Optionally, use the tolerance only in the quarter's last seconds, so a rising load alarms on base's cycle. This closes B1. - Add the latch test. Details are in `reports/SAFETY-126-review-report.md`.
admin added 1 commit 2026-10-31 11:52:31 +01:00
SAFETY-126 round 2: state what the 10 W tolerance costs; test the latch reset
checks / unit (pull_request) Successful in 18m11s
infra-pin / fresh (pull_request) Successful in 2s
a66a7f1a0f
Text only (DECISIONS, DOCS, CHANGELOG): a step load alarms on the same cycle
as before; a steadily rising load alarms a little later; with peak_margin_w 0
a period can end up to 10 W over the limit without this alarm, as for 'Load
too high' (SAFETY-81). Owner accepted 2026-10-31.

test_safety126.py part 2: after a real overrun ends, _peak_landing()'s latch
is reset and the night's later landings are quiet (review F3).

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

SAFETY-126 re-review (head a66a7f1): approved, merging

Owner decision 2026-10-31: the 10 W tolerance is accepted (B1, B2).

  • Diff 8e2463b..a66a7f1: only CHANGELOG, DECISIONS, DOCS and test_safety126.py change. app/ is byte-identical to the tree I reviewed.
  • Wording: true to control and plain in DECISIONS, DOCS, the CHANGELOG and the PR body:
    • "same cycle" now covers step loads only;
    • B1: a rising load alarms a little later, still before the limit at the default 50 W margin;
    • B2: at margin 0 a quarter can end up to 10 W over the limit, as with "Load too high";
    • DECISIONS records the owner's acceptance.
    • No UI string changed, so there is no NL text to check. DOCS and CHANGELOG are English-only.
  • F3 test: it drives the real control cycle. The "latch never reset" mutant (main.py:3006 removed) is red, 2 checks, and head passes.
    • Grading the post-overrun quarters against the new month peak is acceptable.
    • Those quarters land at 3 576.1 and 3 571.6 W against the new aim of about 3 565-3 570 W, roughly 40 W under the new peak.
    • That landing is the known charge-sizing overshoot of the 11 %-harder plant (leftover 3), not a weakness of the latch test: the test's key checks are no notification, no "Why" peak and no red row.
  • Local runs (--network none): test_safety126 passes, as do test_docs, test_messages, test_version_gate, test_ui_text, test_named_absence and test_bytecode.
  • CI on a66a7f1: run 1466 checks / unit (pull_request) success (92 files, 0 failed); run 1467 infra-pin success.
## SAFETY-126 re-review (head a66a7f1): approved, merging Owner decision 2026-10-31: the 10 W tolerance is accepted (B1, B2). - **Diff 8e2463b..a66a7f1:** only CHANGELOG, DECISIONS, DOCS and `test_safety126.py` change. `app/` is byte-identical to the tree I reviewed. - **Wording:** true to control and plain in DECISIONS, DOCS, the CHANGELOG and the PR body: - "same cycle" now covers step loads only; - B1: a rising load alarms a little later, still before the limit at the default 50 W margin; - B2: at margin 0 a quarter can end up to 10 W over the limit, as with "Load too high"; - DECISIONS records the owner's acceptance. - No UI string changed, so there is no NL text to check. DOCS and CHANGELOG are English-only. - **F3 test:** it drives the real control cycle. The "latch never reset" mutant (`main.py:3006` removed) is **red, 2 checks**, and head passes. - Grading the post-overrun quarters against the new month peak is acceptable. - Those quarters land at 3 576.1 and 3 571.6 W against the new aim of about 3 565-3 570 W, roughly 40 W under the new peak. - That landing is the known charge-sizing overshoot of the 11 %-harder plant (leftover 3), not a weakness of the latch test: the test's key checks are no notification, no "Why" peak and no red row. - **Local runs (`--network none`):** `test_safety126` passes, as do `test_docs`, `test_messages`, `test_version_gate`, `test_ui_text`, `test_named_absence` and `test_bytecode`. - **CI on a66a7f1:** run 1466 `checks / unit (pull_request)` success (92 files, 0 failed); run 1467 `infra-pin` success.
admin merged commit deee98d352 into release/1.0 2026-10-31 12:26:05 +01: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#221