Owner decision 2026-09-28: the peak guard keeps a margin below the peak limit. In the OPT-11 P1 test a quarter banked 3501.9 W against a 3500 W limit, and even 1 W over sets the billed peak.
What changes
New option peak_margin_w: default 50, int(0,500). An install without the key gets 50.
The guard aims at max(peak_cap_w − peak_margin_w, month peak already banked) (peakguard.quarter_target_w()). The decision's ceiling_w stays the real limit. The engaged sentence now adds ", aiming at 2450 W" (only when the margin is above 0).
Settings › Safety › Peak limit: a slider for "Margin below the peak limit" (0–500 W, step 10). The label and help text are in translations/*.yaml, where option labels live under UX-13. The group gets a one-line description in app/ui_text/{en,nl}.json. That is the only change to the UI.
validate.py rule 12 refuses values below 0, above 500, fractions, non-numbers, and a margin not below peak_cap_w, with a plain message.
DOCS: a table row, one paragraph and an options-table row. DECISIONS/SAFETY-79-peak-margin.md.
does not read the cap; its sentence names the real limit
PeakTracker (month peak, headroom_w, warning_frac, the bill)
real cap
PeakDecision.ceiling_w, explain(), the status rows and notices (peak_saturated, peak_reserve), homeview limit
real cap
validate.config_notes() (month peak vs limit)
real cap
tools/replay_ac3.py (AC 3 replay)
margin 0: this keeps its pinned figures
Tests (test_peak_margin.py, new)
Identity:peak_margin_w: 0 gives the same digests as ed739d4, byte for byte. I ran tools/safety79_identity.py on ed739d4 and on this branch in the CI image. It drives the real Controller over a P1 meter for 540 cycles, with a 2500 W limit.
n1 36f2dc6c…f884, n2 1d388daf…97c2
n1_off 76e41db4…38ca, n2_off 19665395…e415
The default of 50 changes all four digests. The OPT-11 digests in test_modes are unchanged.
Aim: the aim is cap − margin at N=1 and N=2, with modes on and off, for margins of 0, 50 and 120.
N=1 with the battery Off: the quarter banks at 2500, 2450 and 2380 W.
N=2 with B Off: B gives 1050 W instead of 1000 W, and the meter reads 2450 W.
A month peak already banked above cap − margin is aimed at as it stands.
Billing and reports: these use the real cap.
Call sites: checked with an AST pin.
Range: out-of-range values are refused, with a STYLE.md check on the message.
Existing decide() callers in tests pass margin_w=0.0. margin_w is required, with no default (TEST-14). test_peakguard's main.py-kwargs mirror now uses the Controller's margin, and its over-limit fixture sizes the load against cap − margin. Without that change the ask grows by 50·900/remaining, and near the end of a quarter it saturated (CI run 1004).
Mutations (fresh copy per mutation, __pycache__ purged, python3 -B). Every one turned test_peak_margin.py red:
M1: the aim drops the subtraction. Caught by test_target_is_cap_minus_margin.
M2: the claim is sized on the cap. Caught by test_target… and test_off_battery_n1.
M3: the margin is applied to the billing PeakTracker. Caught by test_billing_uses_cap and test_call_sites.
M4: the reported limit becomes cap − margin. Caught by test_reports_use_cap.
M5: peak_aim_w drops the subtraction. Caught by test_aim_w.
M6: main.py passes margin 0. Caught by test_target….
M7: the range is not checked. Caught by the range check.
M8: the default becomes 0. Caught by test_target… and test_off_battery_n1.
Supervisor 2026.08.0, offline (its own SCHEMA_APP_CONFIG + AppOptions from the release image, run against this config.yaml):
The manifest is accepted. The UI node is integer 0–500.
The shipped options are accepted. 0, 50 and 500 are accepted.
−1 is refused ("value must be at least 0.0"), 501 ("at most 500.0") and 'abc' ("expected int").
12.5 is coerced to 12.
A stored blob without the key, merged with the defaults, gives 50.
For the reviewer
Open question: the margin applies to the cap only. A banked month peak above the cap is still aimed at exactly, so a 1–2 W overshoot of an already-raised month peak is still possible. This is recorded in DECISIONS.
A margin of 50 W makes the guard engage a little earlier and discharge up to 50 W more per quarter. Arbitrage charge headroom and maintenance charge sizing each shrink by 50 W.
UX-13 P3 touches uilayout.py and ui_text. My change there is one Field line and one description string.
Owner decision 2026-09-28: the peak guard keeps a margin below the peak limit. In the OPT-11 P1 test a quarter banked 3501.9 W against a 3500 W limit, and even 1 W over sets the billed peak.
## What changes
- New option `peak_margin_w`: default 50, `int(0,500)`. An install without the key gets 50.
- The guard aims at `max(peak_cap_w − peak_margin_w, month peak already banked)` (`peakguard.quarter_target_w()`). The decision's `ceiling_w` stays the real limit. The engaged sentence now adds ", aiming at 2450 W" (only when the margin is above 0).
- Settings › Safety › Peak limit: a slider for "Margin below the peak limit" (0–500 W, step 10). The label and help text are in `translations/*.yaml`, where option labels live under UX-13. The group gets a one-line description in `app/ui_text/{en,nl}.json`. That is the only change to the UI.
- validate.py rule 12 refuses values below 0, above 500, fractions, non-numbers, and a margin not below `peak_cap_w`, with a plain message.
- DOCS: a table row, one paragraph and an options-table row. DECISIONS/SAFETY-79-peak-margin.md.
## Call sites
| Call site | Uses |
|---|---|
| `peakguard.decide()` → `excess_w()` → claim floor (`main.py` passes `cap_w` + `margin_w=self.peak_margin_w`) | **cap − margin** |
| `peakguard.charge_headroom_w()` (arbitrage sizing), OPT-11 mode give-way to P_PEAK | **cap − margin** (they read the decision's `wanted_w` / claim) |
| `peakguard.shaping_suspended()` → `control.peak_at_risk()` (forecast) | **cap − margin** (`Controller.peak_aim_w()`) |
| `control.maintenance_charge_floor()` (forecast sizing) | **cap − margin** (`Controller.peak_aim_w()`) |
| `peak_soc_floor` logic (`discharge_permitted`, floor-limited branch) | does not read the cap; its sentence names the real limit |
| `PeakTracker` (month peak, `headroom_w`, `warning_frac`, the bill) | **real cap** |
| `PeakDecision.ceiling_w`, `explain()`, the status rows and notices (`peak_saturated`, `peak_reserve`), homeview limit | **real cap** |
| `validate.config_notes()` (month peak vs limit) | **real cap** |
| `tools/replay_ac3.py` (AC 3 replay) | margin 0: this keeps its pinned figures |
## Tests (`test_peak_margin.py`, new)
- **Identity:** `peak_margin_w: 0` gives the same digests as ed739d4, byte for byte. I ran `tools/safety79_identity.py` on ed739d4 and on this branch in the CI image. It drives the real Controller over a P1 meter for 540 cycles, with a 2500 W limit.
- n1 `36f2dc6c…f884`, n2 `1d388daf…97c2`
- n1_off `76e41db4…38ca`, n2_off `19665395…e415`
- The default of 50 changes all four digests. The OPT-11 digests in test_modes are unchanged.
- **Aim:** the aim is cap − margin at N=1 and N=2, with modes on and off, for margins of 0, 50 and 120.
- N=1 with the battery Off: the quarter banks at 2500, 2450 and 2380 W.
- N=2 with B Off: B gives 1050 W instead of 1000 W, and the meter reads 2450 W.
- A month peak already banked above cap − margin is aimed at as it stands.
- **Billing and reports:** these use the real cap.
- **Call sites:** checked with an AST pin.
- **Range:** out-of-range values are refused, with a STYLE.md check on the message.
- Existing `decide()` callers in tests pass `margin_w=0.0`. `margin_w` is required, with no default (TEST-14). test_peakguard's main.py-kwargs mirror now uses the Controller's margin, and its over-limit fixture sizes the load against cap − margin. Without that change the ask grows by 50·900/remaining, and near the end of a quarter it saturated (CI run 1004).
**Mutations** (fresh copy per mutation, `__pycache__` purged, `python3 -B`). Every one turned test_peak_margin.py red:
- M1: the aim drops the subtraction. Caught by `test_target_is_cap_minus_margin`.
- M2: the claim is sized on the cap. Caught by `test_target…` and `test_off_battery_n1`.
- M3: the margin is applied to the billing `PeakTracker`. Caught by `test_billing_uses_cap` and `test_call_sites`.
- M4: the reported limit becomes cap − margin. Caught by `test_reports_use_cap`.
- M5: `peak_aim_w` drops the subtraction. Caught by `test_aim_w`.
- M6: main.py passes margin 0. Caught by `test_target…`.
- M7: the range is not checked. Caught by the range check.
- M8: the default becomes 0. Caught by `test_target…` and `test_off_battery_n1`.
**Supervisor 2026.08.0, offline** (its own `SCHEMA_APP_CONFIG` + `AppOptions` from the release image, run against this config.yaml):
- The manifest is accepted. The UI node is integer 0–500.
- The shipped options are accepted. 0, 50 and 500 are accepted.
- −1 is refused ("value must be at least 0.0"), 501 ("at most 500.0") and 'abc' ("expected int").
- 12.5 is coerced to 12.
- A stored blob without the key, merged with the defaults, gives 50.
## For the reviewer
- **Open question:** the margin applies to the cap only. A banked month peak above the cap is still aimed at exactly, so a 1–2 W overshoot of an already-raised month peak is still possible. This is recorded in DECISIONS.
- A margin of 50 W makes the guard engage a little earlier and discharge up to 50 W more per quarter. Arbitrage charge headroom and maintenance charge sizing each shrink by 50 W.
- UX-13 P3 touches `uilayout.py` and `ui_text`. My change there is one Field line and one description string.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
New option peak_margin_w (default 50 W, 0-500 W). The peak guard, the
forecast checks and the maintenance charge sizing aim at
peak_cap_w - peak_margin_w; the month peak, the bill, headroom, warnings
and every notice keep the real cap. Owner decision 2026-09-28.
peak_margin_w 0 is byte-identical to ed739d4 (tools/safety79_identity.py,
test_peak_margin.py part 1). validate.py rule 12 refuses a value outside
0-500, not whole, or not below the peak limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
- quarter_target_w() is now the margin under quarter_ceiling_w(): a peak
banked between cap - margin and the cap was aimed at exactly, so one
overshoot crossed the cap; above the cap the same overshoot raised the bill.
- safety79_identity.py hashes charge_headroom_w() and the shaping suspension,
and adds n1_maint/n2_maint (cheap window, forecast across cap - 50 .. cap).
Digests re-printed on ed739d4 and pinned; margin 0 still identical.
- test_margin_under_banked_peak; DECISIONS and DOCS updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of PR #151 (SAFETY-79): findings, and fixes pushed as ce0aea3
First I merged release/1.0 7337b79 (OPT-11 P2) into the branch the normal way (e6096d3). There were no conflicts. On the merged tree, all 49 test files pass in the CI image.
1. Completeness: OK, no aim site was missed. I grepped peak_cap_w, ceiling_w, cap_w, headroom, quarter_ceiling_w, excess_w, peak_at_risk, shaping_suspended and maintenance_charge_floor across app/, including OPT-11 P2 (mqtt.py, haentities.py, packmode.py) and UX-13 P3 (homeview.py). I classified each one myself.
Aim:decide() → excess_w(target) → the claim floor. charge_headroom_w() (arbitrage) and the OPT-11 give-way (arbiter._unit_modes/_site_mode) both read that decision, so neither has its own cap. shaping_suspended() and maintenance_charge_floor() get peak_aim_w().
OPT-11 P2 and UX-13 P3 add no reader of the cap that aims.
2. Banked month peak: fixed. The margin now applies under it too.quarter_target_w() was max(cap − m, banked). That has two problems:
(a) A clear defect. A peak banked between cap − m and the cap was aimed at exactly. At 3499 W against 3500 W, the measured +1.9 W overshoot banks 3500.9 W, which is over the cap. This band is common in the month an install upgrades, because the old guard held quarters at the cap.
(b) Above the cap. Aiming at 5013 W exactly can bank about 5015 W. That raises the bill, just as going over the cap does, and the guard then aims at the new peak, so it can ratchet up each time.
The target is now quarter_ceiling_w() − margin. This follows the owner's reason ("even 1 W over sets the billed peak"): the margin covers the controller's overshoot, and that overshoot happens at any aim. The cost is up to 50 W of a peak already paid for going unused: a little more discharge, and 2·50·900/remaining W less charge headroom in that quarter. There is no bill cost, because that peak is already billed. At margin 0 nothing changes. The new check is test_margin_under_banked_peak, with decide() checked at banked 2499 and 3000 W. DECISIONS records the change and says it is a one-line undo.
3. Identity at margin 0: equal, but the harness did not reach arbitrage headroom or maintenance. It had no forecast, no maintenance and no price, and its guard tuple left out charge_headroom_w(). I made three changes:
added charge_headroom_w(g) and _maint_shape_suspended to each row;
added n1_maint/n2_maint: a maintenance CHARGE phase, a cheap window, and a forecast that sweeps 2300-2600 W (across cap − 50 and the cap), so both shaping_suspended() and the cheap-window floor fire;
re-printed the digests on ed739d4 (harness copied in) and on ce0aea3's tree. All 6 are equal (n1 82316f7d…, n2 0dc24e16…, n1_off eed44f05…, n2_off 69796c10…, n1_maint bd4a88a1…, n2_maint 0656bc5a…) and are now pinned.
To show the harness catches changes: mutating the shaping call to peak_aim_w() − 1 moves the digests at margin 0 (R8 below).
4. OPT-11: OK. I re-ran the #149 cases (comments 1906/1907), with the floor taken from the real decide() at margin 0 and at 50 (house 4500 W half-way, cap 3500 W). The floor is 2000 W at margin 0 and 2100 W at margin 50.
Trade +3000 W, A 1500 W, B Off: A 1500 / B 500 at margin 0, and A 1500 / B 600 at margin 50. B carries only the shortfall to cap − margin.
N=1 Off under a trade: 2100 W.
A floor A can carry alone: B stays at 0 W.
All batteries Off: 1050/1050 W.
A grid charge sized by headroom (3900 W): B Off stays at 0 W.
Fuzz: 16 516 cycles with floors from decide() at margins 0-500, N=2/3, #149's invariants. 0 refused, 0 under the floor, 0 needless Off, 0 contradictions.
5. Validation: OK. A margin at or above the cap is refused (checked by mutation R6). The Supervisor turns 12.5 into 12. That is harmless: whole watts are the contract, the slider steps by 10, and the error is under 1 W on a 50 W margin. No note is needed. Existing installs get 50 from config.yaml and from PEAK_OPTION_DEFAULTS, as intended.
6. Mutations: 12 run, 12 caught, 0 survivors. Each ran on a fresh copy with __pycache__ purged and -B.
The dev's M1, M5 and M8.
Mine:
R1 margin applied twice
R2 ceiling = target (margin on ceiling_w)
R3 main.py default 0
R4 peak_aim_w cap + margin
R5 the old banked-peak rule
R6 margin > cap accepted
R7 validate default missing
R8 shaping aim −1 W (identity)
R9 maintenance floor on the cap
7. Text and records: OK. The option name has 5 words and the description 24. The refusals are 20-22 words and name the setting by label and key. EN and NL match. I updated DECISIONS (the owner's decision of 2026-09-28, plus the banked-peak change) and one DOCS sentence.
Every point is resolved. I will merge when CI is green on ce0aea3.
## Review of PR #151 (SAFETY-79): findings, and fixes pushed as ce0aea3
First I merged release/1.0 7337b79 (OPT-11 P2) into the branch the normal way (e6096d3). There were no conflicts. On the merged tree, all 49 test files pass in the CI image.
**1. Completeness: OK, no aim site was missed.** I grepped `peak_cap_w`, `ceiling_w`, `cap_w`, `headroom`, `quarter_ceiling_w`, `excess_w`, `peak_at_risk`, `shaping_suspended` and `maintenance_charge_floor` across `app/`, including OPT-11 P2 (`mqtt.py`, `haentities.py`, `packmode.py`) and UX-13 P3 (`homeview.py`). I classified each one myself.
- **Aim:** `decide()` → `excess_w(target)` → the claim floor. `charge_headroom_w()` (arbitrage) and the OPT-11 give-way (`arbiter._unit_modes`/`_site_mode`) both read that decision, so neither has its own cap. `shaping_suspended()` and `maintenance_charge_floor()` get `peak_aim_w()`.
- **Report:** `PeakTracker` (main.py:831), `ceiling_w` in `messages.peak_saturated`/`peak_reserve`/`row_peak_*` (main.py 4561, 4636, 5411-5457), `homeview.peak_run`/`month_peak` (`c.peak.cap_w`, `pg.ceiling_w`), `validate.config_notes()`.
- OPT-11 P2 and UX-13 P3 add no reader of the cap that aims.
**2. Banked month peak: fixed. The margin now applies under it too.** `quarter_target_w()` was `max(cap − m, banked)`. That has two problems:
- **(a) A clear defect.** A peak banked between cap − m and the cap was aimed at exactly. At 3499 W against 3500 W, the measured +1.9 W overshoot banks 3500.9 W, which is over the cap. This band is common in the month an install upgrades, because the old guard held quarters at the cap.
- **(b) Above the cap.** Aiming at 5013 W exactly can bank about 5015 W. That raises the bill, just as going over the cap does, and the guard then aims at the new peak, so it can ratchet up each time.
The target is now `quarter_ceiling_w() − margin`. This follows the owner's reason ("even 1 W over sets the billed peak"): the margin covers the controller's overshoot, and that overshoot happens at any aim. The cost is up to 50 W of a peak already paid for going unused: a little more discharge, and 2·50·900/remaining W less charge headroom in that quarter. There is no bill cost, because that peak is already billed. At margin 0 nothing changes. The new check is `test_margin_under_banked_peak`, with `decide()` checked at banked 2499 and 3000 W. DECISIONS records the change and says it is a one-line undo.
**3. Identity at margin 0: equal, but the harness did not reach arbitrage headroom or maintenance.** It had no forecast, no maintenance and no price, and its guard tuple left out `charge_headroom_w()`. I made three changes:
- added `charge_headroom_w(g)` and `_maint_shape_suspended` to each row;
- added `n1_maint`/`n2_maint`: a maintenance CHARGE phase, a cheap window, and a forecast that sweeps 2300-2600 W (across cap − 50 and the cap), so both `shaping_suspended()` and the cheap-window floor fire;
- re-printed the digests on ed739d4 (harness copied in) and on ce0aea3's tree. All 6 are equal (n1 82316f7d…, n2 0dc24e16…, n1_off eed44f05…, n2_off 69796c10…, n1_maint bd4a88a1…, n2_maint 0656bc5a…) and are now pinned.
To show the harness catches changes: mutating the shaping call to `peak_aim_w() − 1` moves the digests at margin 0 (R8 below).
**4. OPT-11: OK.** I re-ran the #149 cases (comments 1906/1907), with the floor taken from the real `decide()` at margin 0 and at 50 (house 4500 W half-way, cap 3500 W). The floor is 2000 W at margin 0 and 2100 W at margin 50.
- Trade +3000 W, A 1500 W, B Off: A 1500 / B 500 at margin 0, and A 1500 / B 600 at margin 50. B carries only the shortfall to cap − margin.
- N=1 Off under a trade: 2100 W.
- A floor A can carry alone: B stays at 0 W.
- All batteries Off: 1050/1050 W.
- A grid charge sized by headroom (3900 W): B Off stays at 0 W.
- Fuzz: 16 516 cycles with floors from `decide()` at margins 0-500, N=2/3, #149's invariants. 0 refused, 0 under the floor, 0 needless Off, 0 contradictions.
**5. Validation: OK.** A margin at or above the cap is refused (checked by mutation R6). The Supervisor turns 12.5 into 12. That is harmless: whole watts are the contract, the slider steps by 10, and the error is under 1 W on a 50 W margin. No note is needed. Existing installs get 50 from `config.yaml` and from `PEAK_OPTION_DEFAULTS`, as intended.
**6. Mutations: 12 run, 12 caught, 0 survivors.** Each ran on a fresh copy with `__pycache__` purged and `-B`.
- The dev's M1, M5 and M8.
- Mine:
- R1 margin applied twice
- R2 `ceiling = target` (margin on `ceiling_w`)
- R3 main.py default 0
- R4 `peak_aim_w` cap + margin
- R5 the old banked-peak rule
- R6 `margin > cap` accepted
- R7 validate default missing
- R8 shaping aim −1 W (identity)
- R9 maintenance floor on the cap
**7. Text and records: OK.** The option name has 5 words and the description 24. The refusals are 20-22 words and name the setting by label and key. EN and NL match. I updated DECISIONS (the owner's decision of 2026-09-28, plus the banked-peak change) and one DOCS sentence.
Every point is resolved. I will merge when CI is green on ce0aea3.
admin
merged commit 7a00f12c76 into release/1.02026-09-29 01:01:35 +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.
Owner decision 2026-09-28: the peak guard keeps a margin below the peak limit. In the OPT-11 P1 test a quarter banked 3501.9 W against a 3500 W limit, and even 1 W over sets the billed peak.
What changes
peak_margin_w: default 50,int(0,500). An install without the key gets 50.max(peak_cap_w − peak_margin_w, month peak already banked)(peakguard.quarter_target_w()). The decision'sceiling_wstays the real limit. The engaged sentence now adds ", aiming at 2450 W" (only when the margin is above 0).translations/*.yaml, where option labels live under UX-13. The group gets a one-line description inapp/ui_text/{en,nl}.json. That is the only change to the UI.peak_cap_w, with a plain message.Call sites
peakguard.decide()→excess_w()→ claim floor (main.pypassescap_w+margin_w=self.peak_margin_w)peakguard.charge_headroom_w()(arbitrage sizing), OPT-11 mode give-way to P_PEAKwanted_w/ claim)peakguard.shaping_suspended()→control.peak_at_risk()(forecast)Controller.peak_aim_w())control.maintenance_charge_floor()(forecast sizing)Controller.peak_aim_w())peak_soc_floorlogic (discharge_permitted, floor-limited branch)PeakTracker(month peak,headroom_w,warning_frac, the bill)PeakDecision.ceiling_w,explain(), the status rows and notices (peak_saturated,peak_reserve), homeview limitvalidate.config_notes()(month peak vs limit)tools/replay_ac3.py(AC 3 replay)Tests (
test_peak_margin.py, new)peak_margin_w: 0gives the same digests ased739d4, byte for byte. I rantools/safety79_identity.pyoned739d4and on this branch in the CI image. It drives the real Controller over a P1 meter for 540 cycles, with a 2500 W limit.36f2dc6c…f884, n21d388daf…97c276e41db4…38ca, n2_off19665395…e415decide()callers in tests passmargin_w=0.0.margin_wis required, with no default (TEST-14). test_peakguard's main.py-kwargs mirror now uses the Controller's margin, and its over-limit fixture sizes the load against cap − margin. Without that change the ask grows by 50·900/remaining, and near the end of a quarter it saturated (CI run 1004).Mutations (fresh copy per mutation,
__pycache__purged,python3 -B). Every one turned test_peak_margin.py red:test_target_is_cap_minus_margin.test_target…andtest_off_battery_n1.PeakTracker. Caught bytest_billing_uses_capandtest_call_sites.test_reports_use_cap.peak_aim_wdrops the subtraction. Caught bytest_aim_w.test_target….test_target…andtest_off_battery_n1.Supervisor 2026.08.0, offline (its own
SCHEMA_APP_CONFIG+AppOptionsfrom the release image, run against this config.yaml):For the reviewer
uilayout.pyandui_text. My change there is one Field line and one description string.🤖 Generated with Claude Code
https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
7337b79, OPT-11 P2) into safety-79-peak-margin e6096d3e17Review of PR #151 (SAFETY-79): findings, and fixes pushed as
ce0aea3First I merged release/1.0
7337b79(OPT-11 P2) into the branch the normal way (e6096d3). There were no conflicts. On the merged tree, all 49 test files pass in the CI image.1. Completeness: OK, no aim site was missed. I grepped
peak_cap_w,ceiling_w,cap_w,headroom,quarter_ceiling_w,excess_w,peak_at_risk,shaping_suspendedandmaintenance_charge_flooracrossapp/, including OPT-11 P2 (mqtt.py,haentities.py,packmode.py) and UX-13 P3 (homeview.py). I classified each one myself.decide()→excess_w(target)→ the claim floor.charge_headroom_w()(arbitrage) and the OPT-11 give-way (arbiter._unit_modes/_site_mode) both read that decision, so neither has its own cap.shaping_suspended()andmaintenance_charge_floor()getpeak_aim_w().PeakTracker(main.py:831),ceiling_winmessages.peak_saturated/peak_reserve/row_peak_*(main.py 4561, 4636, 5411-5457),homeview.peak_run/month_peak(c.peak.cap_w,pg.ceiling_w),validate.config_notes().2. Banked month peak: fixed. The margin now applies under it too.
quarter_target_w()wasmax(cap − m, banked). That has two problems:The target is now
quarter_ceiling_w() − margin. This follows the owner's reason ("even 1 W over sets the billed peak"): the margin covers the controller's overshoot, and that overshoot happens at any aim. The cost is up to 50 W of a peak already paid for going unused: a little more discharge, and 2·50·900/remaining W less charge headroom in that quarter. There is no bill cost, because that peak is already billed. At margin 0 nothing changes. The new check istest_margin_under_banked_peak, withdecide()checked at banked 2499 and 3000 W. DECISIONS records the change and says it is a one-line undo.3. Identity at margin 0: equal, but the harness did not reach arbitrage headroom or maintenance. It had no forecast, no maintenance and no price, and its guard tuple left out
charge_headroom_w(). I made three changes:charge_headroom_w(g)and_maint_shape_suspendedto each row;n1_maint/n2_maint: a maintenance CHARGE phase, a cheap window, and a forecast that sweeps 2300-2600 W (across cap − 50 and the cap), so bothshaping_suspended()and the cheap-window floor fire;ed739d4(harness copied in) and on ce0aea3's tree. All 6 are equal (n1 82316f7d…, n2 0dc24e16…, n1_off eed44f05…, n2_off 69796c10…, n1_maint bd4a88a1…, n2_maint 0656bc5a…) and are now pinned.To show the harness catches changes: mutating the shaping call to
peak_aim_w() − 1moves the digests at margin 0 (R8 below).4. OPT-11: OK. I re-ran the #149 cases (comments 1906/1907), with the floor taken from the real
decide()at margin 0 and at 50 (house 4500 W half-way, cap 3500 W). The floor is 2000 W at margin 0 and 2100 W at margin 50.decide()at margins 0-500, N=2/3, #149's invariants. 0 refused, 0 under the floor, 0 needless Off, 0 contradictions.5. Validation: OK. A margin at or above the cap is refused (checked by mutation R6). The Supervisor turns 12.5 into 12. That is harmless: whole watts are the contract, the slider steps by 10, and the error is under 1 W on a 50 W margin. No note is needed. Existing installs get 50 from
config.yamland fromPEAK_OPTION_DEFAULTS, as intended.6. Mutations: 12 run, 12 caught, 0 survivors. Each ran on a fresh copy with
__pycache__purged and-B.ceiling = target(margin onceiling_w)peak_aim_wcap + marginmargin > capaccepted7. Text and records: OK. The option name has 5 words and the description 24. The refusals are 20-22 words and name the setting by label and key. EN and NL match. I updated DECISIONS (the owner's decision of 2026-09-28, plus the banked-peak change) and one DOCS sentence.
Every point is resolved. I will merge when CI is green on
ce0aea3.