SAFETY-81: no false 'Load too high' alarms #155

Merged
admin merged 2 commits from safety-81-load-too-high into release/1.0 2026-09-29 21:45:21 +02:00
Owner

SAFETY-81 (SAFETY-79 test report F1/F2). Alarm behaviour only; every control output is unchanged.

Root causes

  • F1 and F2 are both a double count. decide() adds the held meter reading to the battery reading. If the meter sample is older than the battery's last increase, that increase is counted twice and saturated trips. This happens under noise (F2), and at a quarter start, where the battery has come back but the held sample is still the 1 s spike (F1).
  • F2's margin link is real. saturated is graded against cap − margin, so at margin 50 noise needs 50 W less to trip it.
  • F1, last seconds. The gain 900/remaining turns a few W over the aim into thousands of W "needed" while the battery is far from its rating.
  • The 1 s spike itself comes from the cycle after a quarter boundary and before the new quarter's first sample. It sees 0 s left and files no claim, so an Off battery drops to 0 W for one cycle. That is the same edge tick as OPT-15 F1, so it is left to OPT-15.

Fix

The alarm now also needs peakguard.load_too_high(). With the battery at full power from now on, the quarter must still end more than ALARM_MISS_W (10 W) over the real limit. This is the miss itself in W of the quarter average, so the gain does not apply. The battery reading used is the lower of this cycle's and the last one's. There is at most one alarm per quarter. No strings changed. DOCS has two sentences, and the record is in DECISIONS/SAFETY-81-load-too-high.md.

For review: the 10 W tolerance is my choice. At margin 0 the guard itself lands up to +5.7 W over, so without the tolerance the alarm fires in the last second of most guarded quarters. The cost is that a real miss of 10 W or less is not alarmed.

Tests (test_safety81.py, via tools/safety81_sim.py)

The sim runs the real Controller at a 1 s cycle on a rig-shaped plant: N=1 Off, 2000 W, cap 3500, and one meter sample in ten late. Alarm times are in seconds, and quarters start at 900 and 1800.

scenario 5a4cdc2 this PR
edge4600, margin 50 / 0 (spike at quarter start) 2, 902, 1802, 2699 none
const4500, margin 50 / 0; sine4500 2699 none
noise 5000±150, margin 50 / 0 292, 2551/2552, 2698 none
burst_end (6000 W in last 20 s, lands under cap) 881, 1781, 2681 none
step6500 (real sustained overload from 1200) 1201, 1801 1201, 1801 (same second)
miss5560 (60 W over cap + battery from 1200) 1201, 1801 1201, 1801
ramp6500 (slow, through aim→cap band) 1296 1306 (by design: real cap + 10 W)
step7500 under peak reserve none none

test_peakguard.py's saturated fixture (a quarter already over, which the battery cannot win back) still alarms after one cycle.

Output identity

In all 12 sim scenarios the per-cycle target and written command digests equal 5a4cdc2's (part 1). test_peak_margin.py and test_modes.py identity digests pass unchanged. decide() is untouched.

Mutations (purged copies, test_safety81.py)

mutation result
M1 drop the min battery filter killed (edge4600, edge4600_m0, noise_m0)
M2 ALARM_MISS_W = 0 killed (noise_m0, ramp, unit)
M3 grade against the aim, not the cap killed (burst_end, ramp)
M4 load_too_high always true killed (edge, const)
M5 drop the per-quarter latch killed (step6500, miss5560)
M6 drop the g.saturated gate killed (step7500 under reserve)
M7 drop load_too_high entirely killed (edge, const)
M8 never update the previous battery reading killed (edge, noise_m0)

Risks

  • The 10 W tolerance (above).
  • The one-quarter latch means that if an overload stops and starts again within the same quarter, it is not re-notified. The first notification stays up.
  • A load that ramps slowly alarms later than before (10 s in ramp6500), because the alarm waits until the real cap is at stake.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV

SAFETY-81 (SAFETY-79 test report F1/F2). Alarm behaviour only; every control output is unchanged. ## Root causes - **F1 and F2 are both a double count.** `decide()` adds the held meter reading to the battery reading. If the meter sample is older than the battery's last increase, that increase is counted twice and `saturated` trips. This happens under noise (F2), and at a quarter start, where the battery has come back but the held sample is still the 1 s spike (F1). - **F2's margin link is real.** `saturated` is graded against cap − margin, so at margin 50 noise needs 50 W less to trip it. - **F1, last seconds.** The gain 900/remaining turns a few W over the aim into thousands of W "needed" while the battery is far from its rating. - **The 1 s spike itself** comes from the cycle after a quarter boundary and before the new quarter's first sample. It sees 0 s left and files no claim, so an Off battery drops to 0 W for one cycle. That is the same edge tick as **OPT-15 F1**, so it is left to OPT-15. ## Fix The alarm now also needs `peakguard.load_too_high()`. With the battery at full power from now on, the quarter must still end more than `ALARM_MISS_W` (10 W) over the **real** limit. This is the miss itself in W of the quarter average, so the gain does not apply. The battery reading used is the lower of this cycle's and the last one's. There is at most one alarm per quarter. No strings changed. DOCS has two sentences, and the record is in DECISIONS/SAFETY-81-load-too-high.md. **For review:** the 10 W tolerance is my choice. At margin 0 the guard itself lands up to +5.7 W over, so without the tolerance the alarm fires in the last second of most guarded quarters. The cost is that a real miss of 10 W or less is not alarmed. ## Tests (test_safety81.py, via tools/safety81_sim.py) The sim runs the real Controller at a 1 s cycle on a rig-shaped plant: N=1 Off, 2000 W, cap 3500, and one meter sample in ten late. Alarm times are in seconds, and quarters start at 900 and 1800. | scenario | 5a4cdc2 | this PR | |---|---|---| | edge4600, margin 50 / 0 (spike at quarter start) | 2, 902, 1802, 2699 | none | | const4500, margin 50 / 0; sine4500 | 2699 | none | | noise 5000±150, margin 50 / 0 | 292, 2551/2552, 2698 | none | | burst_end (6000 W in last 20 s, lands under cap) | 881, 1781, 2681 | none | | **step6500** (real sustained overload from 1200) | 1201, 1801 | **1201, 1801** (same second) | | **miss5560** (60 W over cap + battery from 1200) | 1201, 1801 | **1201, 1801** | | ramp6500 (slow, through aim→cap band) | 1296 | 1306 (by design: real cap + 10 W) | | step7500 under peak reserve | none | none | test_peakguard.py's saturated fixture (a quarter already over, which the battery cannot win back) still alarms after one cycle. ## Output identity In all 12 sim scenarios the per-cycle target and written command digests equal 5a4cdc2's (part 1). test_peak_margin.py and test_modes.py identity digests pass unchanged. `decide()` is untouched. ## Mutations (purged copies, test_safety81.py) | mutation | result | |---|---| | M1 drop the min battery filter | killed (edge4600, edge4600_m0, noise_m0) | | M2 `ALARM_MISS_W = 0` | killed (noise_m0, ramp, unit) | | M3 grade against the aim, not the cap | killed (burst_end, ramp) | | M4 `load_too_high` always true | killed (edge, const) | | M5 drop the per-quarter latch | killed (step6500, miss5560) | | M6 drop the `g.saturated` gate | killed (step7500 under reserve) | | M7 drop `load_too_high` entirely | killed (edge, const) | | M8 never update the previous battery reading | killed (edge, noise_m0) | ## Risks - The 10 W tolerance (above). - The one-quarter latch means that if an overload stops and starts again within the same quarter, it is not re-notified. The first notification stays up. - A load that ramps slowly alarms later than before (10 s in ramp6500), because the alarm waits until the real cap is at stake. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
admin added 1 commit 2026-09-29 19:56:02 +02:00
SAFETY-81: no false 'Load too high' alarms
checks / unit (pull_request) Successful in 16m11s
infra-pin / fresh (pull_request) Successful in 1s
6eb67c0f9c
The alarm now also needs peakguard.load_too_high(): with the battery at
full power, the quarter still ends more than 10 W over the real limit.
The battery reading is the lower of this cycle's and the last, so a meter
sample from before the battery's last increase is not counted twice.
At most one alarm per quarter. Control outputs unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
admin added 1 commit 2026-09-29 20:58:40 +02:00
SAFETY-81 review: 10 s hold, ERROR line per episode, more scenarios
checks / unit (pull_request) Successful in 20m31s
infra-pin / fresh (pull_request) Successful in 2s
f967073cfe
- The alarm must hold for peakguard.ALARM_HOLD_S (10 s) on the projection's
  clock. A load that tops cap + battery for ~5 s every 30 s alarmed at every
  quarter start while the quarter landed under the aim (sine4500_fast).
- A cycle with no battery reading leaves the hold alone.
- Notified once per quarter, but the ERROR line is written per episode.
- New sim scenarios (sine4500_fast, twice7000, step6500_gap) with 5a4cdc2
  digests; control still byte-identical. Unit checks for export and the
  hold across a missing reading. test_peakguard's saturated fixture moves
  the hold start back instead of waiting it out.
- DECISIONS and DOCS updated: costs of the hold, tolerance reasoning, the
  double count in control left to a follow-up.

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

Review of SAFETY-81 (reviewer agent)

Checked on head 6eb67c0, base 5a4cdc2. Fixes pushed as f967073.

1. The real alarm is not weakened: fixed, with one trade-off

I ran 16 extra scenarios (real Controller, 1 s cycle, late samples) on 5a4cdc2, 6eb67c0 and f967073. Control digests were identical in every scenario.

scenario 5a4cdc2 6eb67c0 f967073 billed quarter
60 s at 9 kW mid-quarter 1201, 2699 1201 1211 3454 (lands)
3 × 30 s bursts at 8 kW 1001, 1301, 1601, 2699 1001 1011 (+3 ERROR lines) 3454
9 kW in last 30 s 1771, 2699 1771 1781 3571
7 kW in last 30 s 1771, 2699 none none 3504 (in tolerance)
9 kW in last 10 s 1791, 2699 none none 3492 (lands)
battery refuses, 5 kW 1204, … 1239 1249 4778
battery refuses, 5.8 kW 1001, … 1001, 1801 1035, 2599 5489 / 5800
N=2, A Off, 8.5 kW step 1201, 1801 same 1213, 1813 3836
N=2, A Off, B refuses 1633, 2598 1660, 2625 1670, 2635 3824
banked 4000, 6.1 / 6.5 kW 1201, 1801 same 1213, 1813 over banked
banked 4000, 5.9 kW 2693, 2695, 2697 none none 3955 (under banked)
overload twice in one quarter 1001, 1502, 2699 1001 1011 (+ERROR 1512) 3766
4500 ± 1200 W, 30 s cycle 112 alarms 7, 904, 1805 none 3448 (lands)

Every case that 5a4cdc2 alarmed on and 6eb67c0 did not ended under the cap, or within 10 W of it.

Finding (fixed): 6eb67c0 still false-alarms on a cycling load. load_too_high() assumes the load right now lasts the rest of the quarter. A load that goes over cap + battery for about 5 s in every 30 s (a hob or heater) fired once at the start of every quarter, and every quarter landed under the aim. The card says "never on noise".

  • Fix: the test must hold for ALARM_HOLD_S = 10 s, timed on the projection's clock.
  • Cost: a real alarm now comes 10–12 s later, still in the same quarter. With a refusing battery it comes 27–60 s later, and those faults have their own alarms. An overload in only the last ≤10 s of a quarter cannot alarm, but nobody could act on it in that time anyway.
  • Reversible: setting the constant to 0 removes the hold. Owner decision flagged.

A cycle with no battery reading (STALE_READING) now leaves the hold alone. Before, it would have restarted it.

2. The 10 W tolerance: keep it, as a constant

  • What it costs: it only matters when the battery is already at its rating. A missed miss of 10 W or less is 10 W on one month's peak, which is cents at a capacity tariff of about €50 per kW a year.
  • Landing error: measured at margin 0, +7 W on N=1 and +13 W on a 10 kW N=2 site. That site was not saturated, so it gave no alarm.
  • Relative tolerance: a tolerance relative to the cap does not track that error. The error comes from sample timing and rating, not from the cap.
  • Setting or constant: not a setting. peak_margin_w is already the owner's knob, and a second watt knob is jargon.
  • Recommendation: 10 W constant.

3. Once per quarter: the log line was NOT written (fixed)

On 6eb67c0 the latch returned before _LOG.error, so a second overload in the same quarter left no trace anywhere. Now the notification and the action-log line still come once per quarter, and the ERROR line is written for every episode. The hold keeps flicker from spamming it. twice7000 checks this.

4. The double count also moves control → follow-up

On a late sample the guard commands the full rating for one cycle. In edge4600 at 902 s it asks for 2000 W where 1150 W holds the aim. The same happens under noise (noise5000_m50 at 292 s). This PR does not fix it; it goes to a follow-up card.

5. Harness

The harness is realistic: it has lag, late samples, the edge tick and the real Controller. It is not vacuous: the base alarms reproduce, and the mutations are caught. I reproduced all 12 of 5a4cdc2's digests myself, from a 5a4cdc2 worktree with the tool copied in. The new scenarios sine4500_fast, twice7000 and step6500_gap carry 5a4cdc2 digests as well.

6. Mutations: 11 run, all caught (after two new checks)

  • Dev's mutations, sampled: M1 (min filter), M3 (aim), M5 (latch), M6 (saturated gate) and M8 (prev never updated) are all killed.
  • Mine:
    • hold = 0: killed
    • hold halved: killed
    • ERROR only on first episode: killed
    • > → >=: killed
    • None restarts the hold: survived, now killed by part 5
    • max(rest, 0) dropped: survived, now killed by the export unit check

Full suite: 51/51 pass in the CI image.

7. Records

DECISIONS now records the hold, its costs, the tolerance reasoning and the control follow-up. DOCS "Load too high" is updated in plain words. test_peakguard.py's saturated fixture now shows that the first cycle does not notify, and that it notifies once after the hold.

## Review of SAFETY-81 (reviewer agent) Checked on head 6eb67c0, base 5a4cdc2. Fixes pushed as **f967073**. ### 1. The real alarm is not weakened: fixed, with one trade-off I ran 16 extra scenarios (real Controller, 1 s cycle, late samples) on 5a4cdc2, 6eb67c0 and f967073. Control digests were identical in every scenario. | scenario | 5a4cdc2 | 6eb67c0 | f967073 | billed quarter | |---|---|---|---|---| | 60 s at 9 kW mid-quarter | 1201, 2699 | 1201 | 1211 | 3454 (lands) | | 3 × 30 s bursts at 8 kW | 1001, 1301, 1601, 2699 | 1001 | 1011 (+3 ERROR lines) | 3454 | | 9 kW in last 30 s | 1771, 2699 | 1771 | 1781 | **3571** | | 7 kW in last 30 s | 1771, 2699 | none | none | 3504 (in tolerance) | | 9 kW in last 10 s | 1791, 2699 | none | none | 3492 (lands) | | battery refuses, 5 kW | 1204, … | 1239 | 1249 | 4778 | | battery refuses, 5.8 kW | 1001, … | 1001, 1801 | 1035, 2599 | 5489 / 5800 | | N=2, A Off, 8.5 kW step | 1201, 1801 | same | 1213, 1813 | 3836 | | N=2, A Off, B refuses | 1633, 2598 | 1660, 2625 | 1670, 2635 | 3824 | | banked 4000, 6.1 / 6.5 kW | 1201, 1801 | same | 1213, 1813 | over banked | | banked 4000, 5.9 kW | 2693, 2695, 2697 | none | none | 3955 (under banked) | | overload twice in one quarter | 1001, 1502, 2699 | 1001 | 1011 (+ERROR 1512) | 3766 | | **4500 ± 1200 W, 30 s cycle** | 112 alarms | **7, 904, 1805** | none | 3448 (lands) | Every case that 5a4cdc2 alarmed on and 6eb67c0 did not ended under the cap, or within 10 W of it. **Finding (fixed): 6eb67c0 still false-alarms on a cycling load.** `load_too_high()` assumes the load right now lasts the rest of the quarter. A load that goes over cap + battery for about 5 s in every 30 s (a hob or heater) fired once at the start of every quarter, and every quarter landed under the aim. The card says "never on noise". - **Fix:** the test must hold for `ALARM_HOLD_S = 10` s, timed on the projection's clock. - **Cost:** a real alarm now comes 10–12 s later, still in the same quarter. With a refusing battery it comes 27–60 s later, and those faults have their own alarms. An overload in only the last ≤10 s of a quarter cannot alarm, but nobody could act on it in that time anyway. - **Reversible:** setting the constant to 0 removes the hold. **Owner decision flagged.** A cycle with no battery reading (STALE_READING) now leaves the hold alone. Before, it would have restarted it. ### 2. The 10 W tolerance: keep it, as a constant - **What it costs:** it only matters when the battery is already at its rating. A missed miss of 10 W or less is 10 W on one month's peak, which is cents at a capacity tariff of about €50 per kW a year. - **Landing error:** measured at margin 0, +7 W on N=1 and +13 W on a 10 kW N=2 site. That site was not saturated, so it gave no alarm. - **Relative tolerance:** a tolerance relative to the cap does not track that error. The error comes from sample timing and rating, not from the cap. - **Setting or constant:** not a setting. `peak_margin_w` is already the owner's knob, and a second watt knob is jargon. - **Recommendation: 10 W constant.** ### 3. Once per quarter: the log line was NOT written (fixed) On 6eb67c0 the latch returned before `_LOG.error`, so a second overload in the same quarter left no trace anywhere. Now the notification and the action-log line still come once per quarter, and the ERROR line is written for every episode. The hold keeps flicker from spamming it. `twice7000` checks this. ### 4. The double count also moves control → follow-up On a late sample the guard commands the full rating for one cycle. In edge4600 at 902 s it asks for 2000 W where 1150 W holds the aim. The same happens under noise (noise5000_m50 at 292 s). This PR does not fix it; it goes to a follow-up card. ### 5. Harness The harness is realistic: it has lag, late samples, the edge tick and the real Controller. It is not vacuous: the base alarms reproduce, and the mutations are caught. I reproduced all 12 of 5a4cdc2's digests myself, from a 5a4cdc2 worktree with the tool copied in. The new scenarios `sine4500_fast`, `twice7000` and `step6500_gap` carry 5a4cdc2 digests as well. ### 6. Mutations: 11 run, all caught (after two new checks) - **Dev's mutations, sampled:** M1 (min filter), M3 (aim), M5 (latch), M6 (saturated gate) and M8 (prev never updated) are all killed. - **Mine:** - hold = 0: killed - hold halved: killed - ERROR only on first episode: killed - `>` → `>=`: killed - None restarts the hold: **survived**, now killed by part 5 - `max(rest, 0)` dropped: **survived**, now killed by the export unit check Full suite: 51/51 pass in the CI image. ### 7. Records DECISIONS now records the hold, its costs, the tolerance reasoning and the control follow-up. DOCS "Load too high" is updated in plain words. `test_peakguard.py`'s saturated fixture now shows that the first cycle does not notify, and that it notifies once after the hold.
admin merged commit 4f25b24aa6 into release/1.0 2026-09-29 21:45:21 +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#155