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.
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.
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
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>
- 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>
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.02026-09-29 21:45:21 +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.
SAFETY-81 (SAFETY-79 test report F1/F2). Alarm behaviour only; every control output is unchanged.
Root causes
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 andsaturatedtrips. 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).saturatedis graded against cap − margin, so at margin 50 noise needs 50 W less to trip it.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 thanALARM_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.
5a4cdc2test_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)
ALARM_MISS_W = 0load_too_highalways trueg.saturatedgateload_too_highentirelyRisks
🤖 Generated with Claude Code
https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
Review of SAFETY-81 (reviewer agent)
Checked on head
6eb67c0, base5a4cdc2. Fixes pushed asf967073.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,6eb67c0andf967073. Control digests were identical in every scenario.5a4cdc26eb67c0f967073Every case that
5a4cdc2alarmed on and6eb67c0did not ended under the cap, or within 10 W of it.Finding (fixed):
6eb67c0still 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".ALARM_HOLD_S = 10s, timed on the projection's clock.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
peak_margin_wis already the owner's knob, and a second watt knob is jargon.3. Once per quarter: the log line was NOT written (fixed)
On
6eb67c0the 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.twice7000checks 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
5a4cdc2worktree with the tool copied in. The new scenariossine4500_fast,twice7000andstep6500_gapcarry5a4cdc2digests as well.6. Mutations: 11 run, all caught (after two new checks)
>→>=: killedmax(rest, 0)dropped: survived, now killed by the export unit checkFull 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.