SAFETY-79 review: margin under a banked month peak too; identity harness reaches headroom and maintenance
- 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:
co-authored by
Claude Opus 5.5
parent
e6096d3e17
commit
ce0aea30b6
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user