SAFETY-79 review: margin under a banked month peak too; identity harness reaches headroom and maintenance
checks / unit (pull_request) Successful in 11m50s
infra-pin / fresh (pull_request) Successful in 1s

- 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>
This commit is contained in:
Review Agent
2026-09-29 00:49:16 +02:00
co-authored by Claude Opus 5.5
parent e6096d3e17
commit ce0aea30b6
5 changed files with 130 additions and 50 deletions
@@ -4,36 +4,38 @@
## Decision
The peak guard aims at `peak_cap_w - peak_margin_w`, not at `peak_cap_w`.
`peak_margin_w` is a setting: default 50 W, range 0-500 W, whole watts. An
install without the key gets 50.
The peak guard aims `peak_margin_w` under the limit it holds: under
`peak_cap_w`, or under a month peak already banked above it
(`quarter_target_w()`). `peak_margin_w` is a setting: default 50 W, range
0-500 W, whole watts. An install without the key gets 50.
**Why (owner):** the guard aimed exactly at the cap, and in the OPT-11 P1 test
a quarter banked 3501.9 W against a 3500 W limit (OPT-11-P1-test-report.md,
check 4). A peak even 1 W over still sets the billed peak. It is a setting and
not a constant because the add-on is sold to several sites.
**Banked peak (review, 2026-09-29):** the PR first aimed at a banked month
peak as it stood. That can bank a few W over it, which raises the bill the
same way; and a peak banked between cap - margin and the cap (common in the
month an install upgrades, since the old guard held quarters at the cap) was
aimed at exactly, so one overshoot went over the cap. The margin now applies
under it too. Cost: up to `peak_margin_w` of a peak already paid for goes
unused (more discharge, less charge headroom); no bill cost. Undo is one line
in `quarter_target_w()`.
## What uses which number
- **cap - margin (the aim):** `peakguard.decide()` (the claim's floor, and so
arbitrage's `charge_headroom_w()` and OPT-11's give-way, which read it),
`shaping_suspended()` and `maintenance_charge_floor()` (the forecast checks,
via `Controller.peak_aim_w()`).
- **the aim:** `peakguard.decide()` (the claim's floor, and so arbitrage's
`charge_headroom_w()` and OPT-11's give-way, which read it), and, as cap -
margin, `shaping_suspended()` and `maintenance_charge_floor()` (the forecast
checks, via `Controller.peak_aim_w()`).
- **the real cap (reporting and billing):** `PeakTracker` (month peak,
headroom, 80 % warning), the decision's `ceiling_w` and every notice and
status row built from it, Home's limit, and `validate.config_notes()`.
A month peak already banked above cap - margin is aimed at as it stands
(`quarter_target_w()` is `quarter_ceiling_w()` on the lowered cap), so the
margin never holds a quarter below a peak that is already paid for.
## Checks
`peak_margin_w: 0` is byte-identical to ed739d4 (test_peak_margin.py part 1,
digests from tools/safety79_identity.py). validate.py rule 12 refuses a value
outside 0-500, not whole, or not below `peak_cap_w`.
## Open
Whether the margin should also apply below a banked month peak above the cap
is not decided here; today it does not.
digests from tools/safety79_identity.py, which also runs a maintenance charge
with a forecast). validate.py rule 12 refuses a value outside 0-500, not
whole, or not below `peak_cap_w`.
+1 -1
View File
@@ -275,7 +275,7 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Inverter rating** (`max_w`): your own number, not a derived one. It is not read when the inverter list is filled in, since each entry carries its own and the site limit is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not.
- **Battery cut-out level** (`bms_soc_floor`, `inverters.bms_soc_floor`): measured, not chosen. Register 0x0560 reads 10 % on the reference GW5000S-BP. It exists so the add-on can refuse a “Peak reserve” at or below it, which could never take effect. Each inverter entry states its own, because one default for two different batteries protects the shallower one with the deeper one's number.
- **Peak limit** (`peak_cap_w`): your own number, from your grid contract. In Belgium it is the capacity-tariff peak your distribution charge is billed on.
- **Margin below the peak limit** (`peak_margin_w`): 50 W by default, 0 to 500 W (SAFETY-79). Peak limiting aims at the peak limit minus this margin, because a 15-minute period aimed exactly at the limit can end a watt or two over it (3501.9 W against 3500 W in a test), and even 1 W over sets the billed peak. The bill, this month's peak and the warnings still compare against the peak limit itself. 0 aims at the limit, as before.
- **Margin below the peak limit** (`peak_margin_w`): 50 W by default, 0 to 500 W (SAFETY-79). Peak limiting aims at the peak limit minus this margin, because a 15-minute period aimed exactly at the limit can end a watt or two over it (3501.9 W against 3500 W in a test), and even 1 W over sets the billed peak. Once this month's peak is already above the limit, it aims the same margin below that peak. The bill, this month's peak and the warnings still compare against the peak limit itself. 0 aims at the limit, as before.
- **Peak reserve** (`peak_soc_floor`): derived, not picked (SAFETY-24). The reference battery holds 13.8 kWh usable and the worst modelled peak day asks for 878 Wh, or 6.4 %. 20 % sits ten points above the cut-out level and leaves about 1.6 worst days of room. It bounds peak limiting only: ordinary use goes on down to the cut-out level. It must stay above “Battery cut-out level”.
- **Dynamic price source** (`price_provider`): selecting a provider is what starts the fetch, every half hour, whether or not trading is on. It may not be combined with the two older price entities. engie_dynamic, totalenergies_dynamic and eneco_eddy are withdrawn and refuse to start: their endpoints never existed. They stay in the list only so such a site gets that explanation.
- **Supplier markup** (`price_markup_eur_kwh`): under manual_csv, whose prices are all in, it is ignored and logged as ignored.
+8 -6
View File
@@ -143,9 +143,9 @@ because the money is spent on `max(month's peak so far, this quarter)`:
ceiling_w = max(peak_cap_w, the month's banked peak)
SAFETY-79: the guard AIMS at `max(peak_cap_w - peak_margin_w, banked peak)`
SAFETY-79: the guard AIMS `peak_margin_w` under that ceiling
(`quarter_target_w()`), so a quarter that lands a little high still stays under
the cap. `ceiling_w` stays the real limit for reports and the bill.
it. `ceiling_w` stays the real limit for reports and the bill.
Once a month's peak already stands at 5013 W, holding every later quarter to
3500 W buys nothing - the bill for that month is already 5013 W - while the
@@ -536,10 +536,12 @@ def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
def quarter_target_w(cap_w, margin_w, reading) -> float:
"""SAFETY-79. The average the guard AIMS at: `quarter_ceiling_w()` with the
cap lowered by `margin_w`. A month peak already paid for is aimed at as it
stands. Reports and the bill keep the real cap (`ceiling_w`)."""
return quarter_ceiling_w(float(cap_w) - float(margin_w), reading)[0]
"""SAFETY-79. The average the guard AIMS at: `margin_w` under
`quarter_ceiling_w()`, so under a month peak already banked as well as
under the cap. Aiming AT a banked peak can bank a few W over it, which
raises the bill as surely as going over the cap. Reports and the bill keep
the real limit (`ceiling_w`)."""
return quarter_ceiling_w(cap_w, reading)[0] - float(margin_w)
def excess_w(projection, ceiling_w) -> float:
+49 -17
View File
@@ -47,20 +47,25 @@ CAP = float(rig.CAP_W)
print("1. peak_margin_w 0: byte-identical to release/1.0 ed739d4")
# Printed by `python3 tools/safety79_identity.py 0` on the ed739d4 tree (the
# tool copied in, nothing else changed; that tree ignores the key), in the CI
# image, 2026-09-28. 540 cycles each: every command, the loop and arbiter
# sentences, the allocation, and the guard's floor, limit and sentence.
# image; re-printed 2026-09-29 (review) after adding the charge headroom, the
# shaping suspension and the two maintenance scenarios. 540 cycles each.
ED739D4 = {
"n1": "36f2dc6cd82ed63b93125dc3f8ea5f475e494255c614a4d2f353dad06e44f884",
"n2": "1d388daf1bcfc3e16b1ef696377ae2a01831963374e2a6a4d2f998b5c6d997c2",
"n1_off": "76e41db45667fffebd3ce99a49873a31d0b86fcc0c520057bbf38cc4f27138ca",
"n2_off": "196653957e2fdf227daa42447ac170c45292e6c3f4184196dd99c1140359e415",
"n1": "82316f7d4362fae4c035a1bed5e7907cf362faba994c634d0b1c356c501f53e5",
"n2": "0dc24e16a8390ec42edacf735eaf20273847b33b9c67d7cdc536df0ca739e421",
"n1_off": "eed44f058f3fcc6e7651c5c84c6fa6313e7520035ad7cac282012cf6909668a0",
"n2_off": "69796c107b9f78892e04d282b754f889590acf9fe1b96f09525f5ea2005e9c0a",
"n1_maint":
"bd4a88a10aa840a360ac781ac04e30b040a5aac5426fabba1fb259d71dfeba67",
"n2_maint":
"0656bc5a4a2fa3a7455822fdd7da5f68f57fd9593f374e869a0d30ba7e429275",
}
_zero = rig.digests(0)
_default = rig.digests()
for k in ED739D4:
print(f" {k:7} margin 0 {'same' if _zero[k] == ED739D4[k] else 'DIFFERS'}"
f" {_zero[k][:16]}, default {_default[k][:16]}")
check("⭐ at N=1 and N=2, with and without battery modes, margin 0 is "
check("⭐ at N=1 and N=2, with and without battery modes and with a "
"maintenance charge, margin 0 is "
"byte-identical to ed739d4", _zero == ED739D4)
check("...and the default (key absent) changes every scenario, so the "
"scenarios do reach the margin", all(_default[k] != ED739D4[k]
@@ -118,15 +123,42 @@ check("test_off_battery_n2: at N=2 with B Off and A at its rating, B covers "
_c0["b"] == 1000.0 and _g0 == CAP
and _c50["b"] == 1050.0 and _g50 == CAP - 50)
_obs = type("R", (), {"is_observed": True, "peak_w": 2480.0})()
check("test_month_peak_not_lowered: a month peak already paid for is aimed "
"at as it stands (2480 W with cap 2500, margin 50), and a higher one "
"too; no peak aims at cap - margin",
quarter_target_w(CAP, 50, _obs) == 2480.0
and quarter_target_w(CAP, 50,
type("R", (), {"is_observed": True,
"peak_w": 3000.0})()) == 3000.0
and quarter_target_w(CAP, 50, None) == CAP - 50)
def _peak(w, observed=True):
return type("R", (), {"is_observed": observed, "peak_w": w})()
# Review (SAFETY-79 point 2): the margin applies under a month peak already
# banked, too. Aiming AT it can bank a few W over it, which raises the bill;
# and a banked peak between cap - margin and the cap (a quarter the old guard
# held at the cap, say) was aimed at exactly, so one overshoot crossed the cap.
check("test_margin_under_banked_peak: the aim is the margin under the limit "
"that holds - the cap, or a month peak already banked above it",
quarter_target_w(CAP, 50, _peak(2499.0)) == CAP - 50
and quarter_target_w(CAP, 50, _peak(3000.0)) == 2950.0
and quarter_target_w(CAP, 50, _peak(3000.0, observed=False)) == CAP - 50
and quarter_target_w(CAP, 50, None) == CAP - 50
and quarter_target_w(CAP, 0, _peak(3000.0)) == 3000.0)
def _decide(banked_w, projected_w):
"""Half a quarter at `projected_w`, battery idle, a month peak banked."""
from datetime import datetime, timezone
from app.tariff import Projection
pr = Projection(projected_w, projected_w, 450.0, 1.0, False,
datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc),
projected_w)
return peakguard.decide(pr, batt_w=0.0, cap_w=CAP, margin_w=50,
reading=_peak(banked_w), achievable_w=5000.0,
soc=60.0, soc_floor=20.0)
_d1, _d2 = _decide(2499.0, 2600.0), _decide(3000.0, 3100.0)
print(f" banked 2499 W: floor {_d1.floor_w}, limit {_d1.ceiling_w}; "
f"banked 3000 W: floor {_d2.floor_w}, limit {_d2.ceiling_w}")
check("...and decide() sizes the floor to land the quarter 50 W under it "
"(2449 and 2950 W), while the reported limit stays the cap or the peak",
_d1.floor_w == 2 * (2600.0 - 2450.0) and _d1.ceiling_w == CAP
and _d2.floor_w == 2 * (3100.0 - 2950.0) and _d2.ceiling_w == 3000.0)
# ============================================================================ #
print("\n3. the bill and the reports keep the real cap")
@@ -139,7 +171,7 @@ check("test_billing_uses_cap: the month-peak tracker (bill, headroom, 80 % "
"warning) is built on the real cap, not cap - margin",
_c.peak.cap_w == CAP and _c.peak.warning_frac() == _pk / CAP
and _c.peak.headroom_w() == CAP - _pk)
_eng = [g for r in _runs["n1_off", None][0] for g in (r[-1],)
_eng = [g for r in _runs["n1_off", None][0] for g in (r[-1][1],)
if g and g[0] and g[1]]
print(f" {len(_eng)} engaged cycles; one: {_eng[0][4] if _eng else None}")
check("test_reports_use_cap: every engaged sentence names the real limit and "
+52 -8
View File
@@ -6,7 +6,11 @@
tools/opt11_identity.py's harness (the REAL Controller, read_inputs + cycle, in
simulated time) with a P1 meter, so the peak guard runs, and a peak limit the
house goes over. Each row hashes opt11_identity's fields plus the guard's floor,
limit and sentence. It uses only the API release/1.0 ed739d4 already had, so
limit and sentence, its charge headroom (what arbitrage is sized to) and the
maintenance shaping's suspension. The two `_maint` scenarios run a maintenance
charge in a cheap window with a demand forecast that crosses cap - 50 .. cap,
so `shaping_suspended()` and `maintenance_charge_floor()` are both reached.
It uses only the API release/1.0 ed739d4 already had, so
the same file runs on the old tree (which ignores `peak_margin_w`) and the new.
test_peak_margin.py part 1 holds the new tree to the digests printed on ed739d4.
"""
@@ -22,7 +26,7 @@ from datetime import timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
from app import packmode # noqa: E402
from app import packmode, peakguard # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
@@ -62,9 +66,43 @@ def store_off(name):
return s
def forecast(t):
"""A demand forecast that sweeps 2300-2600 W every 10 minutes: across
cap - 50 and the cap, so the margin moves the forecast checks."""
return 2300.0 + (t % 600) / 600 * 300
def _feed_maint(c, t):
"""`_feed`, plus the forecast and a cheap price (now under average)."""
if not getattr(c.hass, "_s79", False):
read = c.hass.number
fixed = {"sensor.pnow": 0.05, "sensor.pavg": 0.20}
async def number(e, invert=False):
if e == "sensor.fc":
return forecast(c.hass.t)
if e in fixed:
return fixed[e]
return await read(e, invert)
c.hass.number, c.hass._s79 = number, True
_feed(c, t)
def store_charging(n):
"""A store where the first battery is in its maintenance CHARGE phase."""
s = rig._Store()
for k in ("phase", "phase_started"):
s.data[k if n == 1 else unit_key("inverter-a", k)] = (
"charge" if k == "phase" else rig.BASE.isoformat())
return s
P1 = {"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
"meter_phases": 1, "peak_cap_w": CAP_W}
MODES = {"battery_modes_enabled": True}
MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11,
"peak_forecast_entity": "sensor.fc", "price_now_entity": "sensor.pnow",
"price_avg_entity": "sensor.pavg"}
SCENARIOS = {
"n1": dict(n=1),
@@ -73,28 +111,34 @@ SCENARIOS = {
"n1_off": dict(n=1, modes=True, off="inverter-a"),
# OPT-11: B Off, so B covers what A cannot.
"n2_off": dict(n=2, modes=True, off="inverter-b"),
# A maintenance charge in a cheap window, with a demand forecast.
"n1_maint": dict(n=1, maint=True),
"n2_maint": dict(n=2, maint=True),
}
def run(n, *, margin=None, modes=False, off=None, dur=2700.0):
def run(n, *, margin=None, modes=False, off=None, maint=False, dur=2700.0):
"""Rows for one scenario; `margin=None` leaves the key out."""
extra = dict(P1, **(MODES if modes else {}))
extra = dict(P1, **(MODES if modes else {}), **(MAINT if maint else {}))
if margin is not None:
extra["peak_margin_w"] = margin
guard = []
def on_cycle(c, t):
g = c.peak_guard
guard.append(None if g is None else (g.engaged, g.floor_w, g.ceiling_w,
g.ceiling_from, g.reason))
guard.append((c._maint_shape_suspended, None if g is None else (
g.engaged, g.floor_w, g.ceiling_w, g.ceiling_from, g.reason,
peakguard.charge_headroom_w(g))))
db = os.path.join(tempfile.mkdtemp(prefix="s79-"), "tariff.db")
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
try:
rows, c = rig.run(n, extra=extra, dur=dur, load=load, soc=soc,
cut=lambda u, t: False, before=_feed,
cut=lambda u, t: False,
before=_feed_maint if maint else _feed,
on_cycle=on_cycle,
store=store_off(off) if off else None)
store=(store_off(off) if off else
store_charging(n) if maint else None))
finally:
rig.Controller = real
return [r + (g,) for r, g in zip(rows, guard)], c