SAFETY-79: peak guard keeps a margin below the limit
checks / unit (pull_request) Failing after 11m34s
infra-pin / fresh (pull_request) Successful in 1s

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
This commit is contained in:
glenn schrooyen
2026-09-28 23:09:39 +02:00
co-authored by Claude Opus 5.5
parent ed739d4cdb
commit 7c4c8f5c66
18 changed files with 549 additions and 53 deletions
@@ -0,0 +1,39 @@
# SAFETY-79 — the peak guard keeps a margin below the peak limit
**Status: DECIDED 2026-09-28 (owner decision).**
## 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.
**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.
## 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 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.
+3
View File
@@ -204,6 +204,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Limits | Inverter rating (W) | `max_w` | Your own number: the most power the add-on may ever ask of the inverter. Start low and raise it after commissioning. |
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
| Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. |
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit. |
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
@@ -274,6 +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.
- **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.
@@ -1612,6 +1614,7 @@ help - see "Why the tuning is what it is".
|---|---|
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
| `peak_cap_w` | The site's capacity-tariff target |
| `peak_margin_w` (50) | Watts the peak guard aims under `peak_cap_w` (SAFETY-79). Reports and the bill use `peak_cap_w` itself |
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start. ⚠️ **At a declared `inverters:` roster this key moves INTO each entry and is REQUIRED there** (SAFETY-32): it describes a battery, and two packs of different models have different cut-outs, so one number for both protects the shallower pack with the deeper pack's BMS. Nothing is inherited — an entry that omits it is refused **by name** at startup, and the flat key below is then not read at all |
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
+12 -3
View File
@@ -893,6 +893,10 @@ class Controller:
# every option family this file builds: no numeric fallback in main.py
# that config.yaml has not shipped.
self.peak_guard = None
# SAFETY-79. The guard aims this far under `peak_cap_w`; the bill and
# every report keep the real cap (`self.peak.cap_w`).
self.peak_margin_w = float(opts.get(
"peak_margin_w", peakguard.PEAK_OPTION_DEFAULTS["peak_margin_w"]))
self._peak_engaged = False
self._peak_saturated = False
self._peak_floor_limited = False
@@ -1823,6 +1827,11 @@ class Controller:
"""
return float(sum(inv.rating_w() for inv in self.inverters))
def peak_aim_w(self) -> float:
"""SAFETY-79. `peak_cap_w` less `peak_margin_w`: what the forecast
checks aim at. Reports and the bill use the real cap."""
return float(self.o.get("peak_cap_w", 3500)) - self.peak_margin_w
def site_dev_bound(self, which: str):
"""The roster's summed device floor / ceiling, or None if unknown.
@@ -3390,6 +3399,7 @@ class Controller:
# fraction of the discharge already happening.
batt_w=self.site_batt_w,
cap_w=float(self.o.get("peak_cap_w", 3500)),
margin_w=self.peak_margin_w,
reading=self.peak.peak(now),
# ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each
# unit contributes `min(its max_w, |its own device bounds|)`, so
@@ -3650,8 +3660,7 @@ class Controller:
# charge together, this flag would have to become per-pack - and
# so would the reasoning above it.
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc,
float(self.o.get("peak_cap_w", 3500))):
self.peak_guard, self.peak_fc, self.peak_aim_w()):
# Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the 40-line
# action log of the very context that explains it.
@@ -3678,7 +3687,7 @@ class Controller:
# rate the pack in front of us is charging at.
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
self.peak_fc, self.peak_aim_w())
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason -
+1
View File
@@ -1152,6 +1152,7 @@ LABELS = {
"integrator_max_w": "Catch-up limit",
"maintenance_discharge_w": "Draw-down rate",
"peak_cap_w": "Peak limit",
"peak_margin_w": "Margin below the peak limit",
"peak_forecast_entity": "15-minute demand forecast entity",
"meter_entity": "Grid power entity",
"soc_entity": "Battery level entity",
+38 -13
View File
@@ -143,6 +143,10 @@ 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)`
(`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.
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
holding itself costs real charging the site wanted. Raising the ceiling to a peak
@@ -453,7 +457,13 @@ PEAK_OPTION_DEFAULTS = {
# and it is consistent with DoD 90 on a full pack. It exists so validate.py
# rule 8 can refuse a `peak_soc_floor` at or below it - see that rule.
"bms_soc_floor": 10,
# SAFETY-79. Watts the guard aims under `peak_cap_w`, so a quarter that
# lands a little high still stays under the cap. Owner decision 2026-09-28.
"peak_margin_w": 50,
}
# SAFETY-79. config.yaml's schema range for `peak_margin_w`, mirrored so
# validate.py can refuse it in words; test_peakguard.py pins it to the file.
PEAK_MARGIN_RANGE_W = (0, 500)
@dataclass(frozen=True)
@@ -474,6 +484,7 @@ class PeakDecision:
projected_w: float | None = None
excess_w: float = 0.0
wanted_w: float | None = None # the floor before `achievable_w` clamped it
target_w: float | None = None # SAFETY-79: what the guard aims at
saturated: bool = False
# SAFETY-24. True when a forced discharge WAS wanted and the EMS-side SoC
# floor refused to size it. Mutually exclusive with `saturated` by
@@ -524,6 +535,13 @@ def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
return cap, CEILING_CAP
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]
def excess_w(projection, ceiling_w) -> float:
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
@@ -713,8 +731,8 @@ def binding_soc_floor(packs, soc_floor):
return max(floors)
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
max_hold_s=None, frozen: bool = False) -> PeakDecision:
def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc,
soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision:
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
`batt_w` MEASURED battery power, positive = discharge. None when the
@@ -732,8 +750,13 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
the pack for money. REQUIRED, and deliberately without a
default: a default here would be CFG-03's fourth defaults
site and TEST-14's quiet fallback in one line.
`margin_w` SAFETY-79. Watts to aim under `cap_w` (`peak_margin_w`).
REQUIRED for the same reason. `ceiling_w` stays the real
limit for every report; only `excess_w` uses the target.
"""
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
target = quarter_target_w(cap_w, margin_w, reading)
aim = "" if target == ceiling else f", aiming at {target:.0f} W"
ceil_top = abs(float(achievable_w))
blind = blindness(projection, max_hold_s, frozen)
@@ -743,7 +766,7 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
# see", which is one-directional and cannot cost pack energy.
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
blind=blind, projected_w=None,
target_w=target, blind=blind, projected_w=None,
reason=(f"PEAK_GUARD_BLIND: {blind}. The battery may not charge "
f"while this 15-minute period cannot be measured, so it "
f"is asked for no charging, floor 0 W"))
@@ -755,7 +778,7 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
# measurement that is not there is a made-up number.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected,
ceiling_from=from_where, target_w=target, projected_w=projected,
reason="no battery measurement to size a floor from")
if QUARTER_S - float(projection.elapsed_s) <= 0.0:
# A block with no seconds left cannot be influenced. This is degenerate
@@ -763,10 +786,10 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
# the battery's own current output would be a claim that says nothing.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected,
ceiling_from=from_where, target_w=target, projected_w=projected,
reason="the quarter has no seconds left to influence")
ex = excess_w(projection, ceiling)
ex = excess_w(projection, target)
wanted = float(batt_w) + ex
if wanted <= 0.0:
# ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY.
@@ -787,7 +810,8 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
# the quantity being thresholded is simply the right one.
return PeakDecision(
engaged=False, floor_w=None, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected, excess_w=ex,
ceiling_from=from_where, target_w=target, projected_w=projected,
excess_w=ex,
wanted_w=wanted,
reason="the load is under the level this quarter can afford")
@@ -805,7 +829,8 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
# not the grid-following loop, which still has no SoC guard.
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected, excess_w=ex,
ceiling_from=from_where, target_w=target, projected_w=projected,
excess_w=ex,
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
soc_floor_reason=denied,
# UX-10: plain sentences, every figure kept (test_messages.py
@@ -813,8 +838,8 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
reason=(
f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for "
f"{projected:.0f} W against a {ceiling:.0f} W limit "
f"({from_where}), and {wanted:+.0f} W from the battery would "
f"hold it. But {denied}"
f"({from_where}){aim}, and {wanted:+.0f} W from the battery "
f"would hold it. But {denied}"
+ ("" if soc is None else f", at {float(soc):.0f} %")
+ ("" if soc is None or soc_floor is None
else f" against {float(soc_floor):.0f} %")
@@ -825,11 +850,11 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
saturated = floor < wanted
return PeakDecision(
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
saturated=saturated, soc=soc, soc_floor=soc_floor,
target_w=target, blind=None, projected_w=projected, excess_w=ex,
wanted_w=wanted, saturated=saturated, soc=soc, soc_floor=soc_floor,
reason=(
f"PEAK_GUARD: this 15-minute period heads for {projected:.0f} W "
f"against a {ceiling:.0f} W limit ({from_where}), with "
f"against a {ceiling:.0f} W limit ({from_where}){aim}, with "
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
f"come off the meter. The battery measured {float(batt_w):+.0f} W "
f"and is asked for {floor:+.0f} W"
+1 -1
View File
@@ -13,7 +13,7 @@
"inverters.modes": {"name": "Battery modes"},
"tariff.selling": {"name": "Charging and selling on price"},
"tariff.feed": {"name": "Price feed details"},
"safety.peak": {"name": "Peak limit"},
"safety.peak": {"name": "Peak limit", "description": "The add-on aims a little below your limit, to be safe."},
"safety.start": {"name": "Starting and stopping"},
"safety.stops": {"name": "Safety stops", "description": "Measured on real hardware, so leave them unchanged."},
"maintenance.support": {"name": "Support"}
+1 -1
View File
@@ -13,7 +13,7 @@
"inverters.modes": {"name": "Batterijmodi"},
"tariff.selling": {"name": "Laden en verkopen op prijs"},
"tariff.feed": {"name": "Details van de prijsfeed"},
"safety.peak": {"name": "Piekgrens"},
"safety.peak": {"name": "Piekgrens", "description": "De add-on mikt iets onder uw grens, voor de zekerheid."},
"safety.start": {"name": "Starten en stoppen"},
"safety.stops": {"name": "Veiligheidsstops", "description": "Gemeten op echte hardware, dus laat ze zoals ze zijn."},
"maintenance.support": {"name": "Support"}
+1
View File
@@ -181,6 +181,7 @@ GROUPS = (
Part("peak", (
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
simple=True, wizard=5),
Field("peak_margin_w", SLIDER, "W", step=10), # SAFETY-79
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
Field("peak_soc_floor", LOCKED, "%"),
)),
+24 -1
View File
@@ -243,6 +243,9 @@ NOT READY banner already names the problem, (b) does not apply.
note below on `gain`, which is the term that owns that and which SAFETY-39
measured and did not change.
12. `peak_margin_w` outside 0-500 W, not whole, or not below `peak_cap_w`.
SAFETY-79; the Supervisor checks the range too, but not for sim/inject.py.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -333,7 +336,7 @@ from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import (
SOURCE_HA, SOURCE_HA_SIGNED, is_enabled, validate_meter_options)
from .peakguard import PEAK_OPTION_DEFAULTS
from .peakguard import PEAK_MARGIN_RANGE_W, PEAK_OPTION_DEFAULTS
from .price import (
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
@@ -1194,6 +1197,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
f"reserve above {bms_floor:.0f} %, or correct the cut-out "
"level.")
# --- 12. SAFETY-79: the margin the peak guard keeps under the limit -----
problems += _margin_problems(opts)
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
@@ -2022,6 +2028,23 @@ def _price_check(notes, opts, running, prices_known) -> None:
"Prices are fetched after the restart, and Home shows them.")
def _margin_problems(opts: dict) -> list[str]:
"""Rule 12: `peak_margin_w` is a whole number in the schema's range, and
below the peak limit, which would leave nothing to aim at."""
raw = opts.get("peak_margin_w")
margin = _number(opts, "peak_margin_w",
PEAK_OPTION_DEFAULTS["peak_margin_w"])
lo, hi = PEAK_MARGIN_RANGE_W
if margin is None or margin != int(margin) or not lo <= margin <= hi:
return [f"{S('peak_margin_w')} must be a whole number from {lo} to "
f"{hi} W. It is {messages.shown(raw)}."]
cap = _number(opts, "peak_cap_w", None)
if cap is not None and margin >= cap:
return [f"{S('peak_margin_w')} is {margin:.0f} W, not below the "
f"{S('peak_cap_w')} of {cap:.0f} W. Lower the margin."]
return []
def _number(opts: dict, key, default):
raw = opts.get(key, default)
if raw is None or raw == "":
+3
View File
@@ -290,6 +290,8 @@ options:
# protects the shallower pack with the deeper pack's number.
bms_soc_floor: 10
peak_cap_w: 3500
# SAFETY-79: the peak guard aims this many watts under peak_cap_w.
peak_margin_w: 50
peak_forecast_entity: ""
# The peak guard can COMMAND a discharge to hold a billed quarter under
@@ -1019,6 +1021,7 @@ schema:
max_w: int(100,5000)
bms_soc_floor: int(0,50)
peak_cap_w: int(500,15000)
peak_margin_w: int(0,500)
peak_forecast_entity: str?
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
+2 -2
View File
@@ -1039,7 +1039,7 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
now = t0 + timedelta(seconds=t)
claims = []
pk = _pg.decide(PeakTracker.project(avg, now), batt_w=batt,
cap_w=cap_w, reading=None, achievable_w=MAX_W,
cap_w=cap_w, margin_w=0.0, reading=None, achievable_w=MAX_W,
soc=50.0, soc_floor=20.0, max_hold_s=900.0,
frozen=False)
pc = _pg.claim(pk)
@@ -1380,7 +1380,7 @@ def _s47_headroom(*, meter_w, batt_w, elapsed_s=450.0):
t += 5.0
pk = _pg.decide(
PeakTracker.project(avg, S45_T0 + timedelta(seconds=elapsed_s)),
batt_w=batt_w, cap_w=S45_CAP_W, reading=None, achievable_w=MAX_W,
batt_w=batt_w, cap_w=S45_CAP_W, margin_w=0.0, reading=None, achievable_w=MAX_W,
soc=50.0, soc_floor=20.0, max_hold_s=900.0, frozen=False)
return _pg.charge_headroom_w(pk)
+1 -1
View File
@@ -964,7 +964,7 @@ def _peak_texts():
proj = Projection(projected_w=4338.0, elapsed_w=4338.0, elapsed_s=799.0,
covered_frac=1.0, complete=False, block_start=_T0,
held_w=0.0)
kw = dict(cap_w=3500.0, reading=None, soc_floor=30.0, max_hold_s=30.0)
kw = dict(cap_w=3500.0, margin_w=0.0, reading=None, soc_floor=30.0, max_hold_s=30.0)
sat = _pg.decide(proj, batt_w=1500.0, achievable_w=5000.0, soc=80.0, **kw)
low = _pg.decide(proj, batt_w=1500.0, achievable_w=5000.0, soc=20.0, **kw)
unread = _pg.decide(proj, batt_w=1500.0, achievable_w=5000.0, soc=None, **kw)
+270
View File
@@ -0,0 +1,270 @@
"""Runnable check for SAFETY-79, the margin under the peak limit.
`python3 test_peak_margin.py`
The peak guard aims at `peak_cap_w - peak_margin_w` (default 50 W); the bill
and every report keep the real `peak_cap_w`. DECISIONS/SAFETY-79-peak-margin.md
has the record. Drives the REAL Controller through tools/safety79_identity.py.
1. `peak_margin_w: 0` is byte-identical to release/1.0 ed739d4.
2. The guard aims at cap - margin at N=1 and N=2, and with OPT-11 modes on an
Off battery covers the shortfall to cap - margin.
3. The bill and the reports still use the real cap.
4. Each call site in main.py passes the value its side needs.
5. The setting: default, range, refusals in plain words.
"""
import ast
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import safety79_identity as rig # noqa: E402
from named_absence import nth, present # noqa: E402
from style_rules import text_problems # noqa: E402
from app import messages, peakguard # noqa: E402
from app.control import maintenance_charge_floor # noqa: E402
from app.peakguard import (PEAK_MARGIN_RANGE_W, # noqa: E402
PEAK_OPTION_DEFAULTS, quarter_target_w)
from app.validate import config_notes, validate_startup_config # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
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.
ED739D4 = {
"n1": "36f2dc6cd82ed63b93125dc3f8ea5f475e494255c614a4d2f353dad06e44f884",
"n2": "1d388daf1bcfc3e16b1ef696377ae2a01831963374e2a6a4d2f998b5c6d997c2",
"n1_off": "76e41db45667fffebd3ce99a49873a31d0b86fcc0c520057bbf38cc4f27138ca",
"n2_off": "196653957e2fdf227daa42447ac170c45292e6c3f4184196dd99c1140359e415",
}
_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 "
"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]
for k in ED739D4))
# ============================================================================ #
print("\n2. the guard aims at cap - margin")
_runs = {}
for name, spec in rig.SCENARIOS.items():
for m in (None, 0, 120):
_runs[name, m] = rig.run(**spec, margin=m, dur=3600.0)
for name in rig.SCENARIOS:
g = _runs[name, None][1].peak_guard
print(f" {name}: margin {_runs[name, None][1].peak_margin_w:.0f} W, "
f"aims at {g.target_w:.0f} W, limit {g.ceiling_w:.0f} W")
check("test_target_is_cap_minus_margin: with the key absent every scenario "
"(N=1, N=2, modes on and off) aims at cap - 50 and reports the cap",
all(_runs[n, None][1].peak_margin_w == 50.0
and _runs[n, None][1].peak_guard.target_w == CAP - 50
and _runs[n, None][1].peak_guard.ceiling_w == CAP
for n in rig.SCENARIOS))
check("...and a margin of 120 moves the aim to cap - 120, a margin of 0 to "
"the cap", all(_runs[n, 120][1].peak_guard.target_w == CAP - 120
and _runs[n, 0][1].peak_guard.target_w == CAP
for n in rig.SCENARIOS))
def banked(ctl):
return {b.start.strftime("%H:%M"): round(b.offtake_avg_w, 2)
for b in ctl.p1.blocks}
_b = {m: banked(_runs["n1_off", m][1]) for m in (0, None, 120)}
print(f" N=1, the battery Off: quarters banked {_b}")
check("test_off_battery_n1: at N=1 with the battery Off, the guard makes it "
"give just enough to bank the 11:30 quarter at cap - margin (2500, "
"2450, 2380 W for margins 0, 50, 120)",
[_b[m]["11:30"] for m in (0, None, 120)]
== [CAP, CAP - 50, CAP - 120])
def meter_at(rows, t):
row = next((r for r in rows if r[0] == t), None)
if row is None:
return None, {}
return rig.load(t) - sum(w for _u, w in row[2]), dict(row[2])
_g0, _c0 = meter_at(_runs["n2_off", 0][0], 1800.0)
_g50, _c50 = meter_at(_runs["n2_off", None][0], 1800.0)
print(f" N=2, B Off, 6000 W house: margin 0 -> {_c0}, meter {_g0} W; "
f"margin 50 -> {_c50}, meter {_g50} W")
check("test_off_battery_n2: at N=2 with B Off and A at its rating, B covers "
"the shortfall to cap - margin, not to the cap",
_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)
# ============================================================================ #
print("\n3. the bill and the reports keep the real cap")
_c = _runs["n1_off", None][1]
_pk = _c.peak.peak().peak_w
print(f" month peak {_pk:.0f} W, tracker cap {_c.peak.cap_w:.0f} W, "
f"warning {_c.peak.warning_frac():.3f}, headroom "
f"{_c.peak.headroom_w():.0f} W")
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],)
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 "
"the aim beside it", _eng and all(
g[2] == CAP and "against a 2500 W limit (peak_cap_w), aiming at "
"2450 W" in g[4] for g in _eng))
check("...and the settings note compares the month peak with the real cap: "
"a 2480 W peak under a 2500 W cap is no warning, 2520 W is",
config_notes({"peak_cap_w": 2500, "peak_margin_w": 50},
month_peak_w=2480) == []
and [n.keys for n in config_notes({"peak_cap_w": 2500,
"peak_margin_w": 50},
month_peak_w=2520)]
== [("peak_cap_w",)])
_lim = _eng[0][2] if _eng else 0.0
_notice = messages.peak_saturated(_lim)
check("...and the user-facing peak notice built from the decision's limit "
"says the cap", messages.watts(CAP) in _notice.body
and messages.watts(CAP - 50) not in _notice.body)
# ============================================================================ #
print("\n4. each call site in main.py")
with open(os.path.join(HERE, "app", "main.py"), encoding="utf-8") as f:
_src = f.read()
_tree = ast.parse(_src)
def calls(name):
return [n for n in ast.walk(_tree) if isinstance(n, ast.Call)
and isinstance(n.func, (ast.Attribute, ast.Name))
and getattr(n.func, "attr", getattr(n.func, "id", "")) == name]
def kw(call, key):
if not present(call):
return "absent"
return next((ast.get_source_segment(_src, k.value) for k in call.keywords
if k.arg == key), "absent")
def arg(call, i):
if not present(call) or len(call.args) <= i:
return "absent"
return ast.get_source_segment(_src, call.args[i])
_dec = [c for c in calls("decide")
if getattr(c.func, "value", None) is not None
and getattr(c.func.value, "id", "") == "peakguard"]
_shape = calls("shaping_suspended")
_maint = calls("maintenance_charge_floor")
_tracker = calls("PeakTracker")
_d, _s, _m, _t = (nth(_dec, 0, "peakguard.decide()"),
nth(_shape, 0, "shaping_suspended()"),
nth(_maint, 0, "maintenance_charge_floor()"),
nth(_tracker, 0, "PeakTracker()"))
print(f" decide(cap_w={kw(_d, 'cap_w')}, margin_w={kw(_d, 'margin_w')})")
print(f" shaping_suspended(..., {arg(_s, 2)})")
print(f" maintenance_charge_floor(..., {arg(_m, 2)})")
print(f" PeakTracker(cap_w={kw(_t, 'cap_w')})")
check("test_call_sites: the guard gets the real cap AND the margin; the "
"forecast checks get cap - margin; the month-peak tracker gets the "
"real cap",
len(_dec) == 1 and "peak_cap_w" in kw(_d, "cap_w")
and kw(_d, "margin_w") == "self.peak_margin_w"
and len(_shape) == 1 and len(_maint) == 1
and arg(_s, 2) == "self.peak_aim_w()"
and arg(_m, 2) == "self.peak_aim_w()"
and len(_tracker) == 1
and kw(_t, "cap_w") == 'float(opts.get("peak_cap_w", 3500))')
check("test_aim_w: `peak_aim_w()` is cap - margin, and the forecast sizing "
"gives a maintenance charge only what is left under it",
_c.peak_aim_w() == CAP - 50
and maintenance_charge_floor(2000.0, 2400.0, _c.peak_aim_w()) == 50.0
and peakguard.shaping_suspended(None, 2470.0, _c.peak_aim_w())
and not peakguard.shaping_suspended(None, 2470.0, CAP))
# ============================================================================ #
print("\n5. the setting")
def _cfg_pair(key):
"""(default, schema) lines for `key` at two spaces in config.yaml."""
with open(os.path.join(HERE, "config.yaml"), encoding="utf-8") as f:
hits = re.findall(rf"^ {key}:\s*(\S+)", f.read(), re.M)
return hits
_hits = _cfg_pair("peak_margin_w")
print(f" config.yaml: {_hits}")
check("config.yaml ships 50, int(0,500), and the in-code mirror matches",
_hits == ["50", "int(0,500)"]
and PEAK_OPTION_DEFAULTS["peak_margin_w"] == 50
and PEAK_MARGIN_RANGE_W == (0, 500))
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"max_w": 2000, "peak_cap_w": 3500}
_base_problems = validate_startup_config(BASE)
def refused(value, **more):
opts = dict(BASE, peak_margin_w=value, **more)
return [p for p in validate_startup_config(opts)
if p not in _base_problems]
_ok = {v: refused(v) for v in (0, 50, 500)}
_bad = {v: refused(v) for v in (-1, 501, 12.5, "abc")}
for v, p in _bad.items():
print(f" {v!r}: {p}")
check("test_margin_range: 0, 50 and 500 are accepted, and so is the key "
"absent", not any(_ok.values()) and _base_problems == [])
check("...and below 0, above 500, not whole, or not a number is refused, "
"once, naming the setting and the range",
all(len(p) == 1 and "“Margin below the peak limit” (peak_margin_w)"
in p[0] and "from 0 to 500 W" in p[0] for p in _bad.values()))
_high = refused(500, peak_cap_w=500)
print(f" 500 under a 500 W limit: {_high}")
check("...and a margin that leaves nothing under the limit is refused",
len(_high) == 1 and "not below" in _high[0])
_style = [text_problems(p[0], keys=("peak_margin_w", "peak_cap_w"),
entities=False) for p in list(_bad.values()) + [_high]]
check(f"...in plain words (STYLE.md): {_style}", not any(_style))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+32 -30
View File
@@ -368,7 +368,7 @@ check("...and it grows monotonically through the quarter, so the guard is at "
BATT_NOW = 400.0
a_edge = averager_at(CEIL, 450.0)
p_edge = PeakTracker.project(a_edge, B + timedelta(seconds=450))
d_edge = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
d_edge = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
e_edge = excess_w(p_edge, CEIL)
print(f" held exactly AT the ceiling for 450 s: projected "
@@ -383,7 +383,7 @@ check("at the engagement boundary the excess is zero, so the floor is exactly "
# than on the excess. Re-derived here rather than described: a guard that
# released as soon as `excess <= 0` hands the actuator back at the exact instant
# it is working, and the load springs straight back.
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0
# ⚠️ `floor_w` IS FORMATTED DEFENSIVELY, AND MUTATION.md's OWN LEDGER IS WHY.
@@ -404,7 +404,7 @@ check("...and the claim is held there, not released. `excess` falls to zero as "
"arithmetic read as a level", would_release_on_excess and released.engaged)
d_gone = decide(PeakTracker.project(averager_at(500.0, 450.0),
B + timedelta(seconds=450)),
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
check("...and it DOES release once the load falls under what the quarter can "
"afford, so 'threshold the floor' is not 'never let go'",
@@ -413,7 +413,7 @@ check("...and it DOES release once the load falls under what the quarter can "
# A hair over, and the floor is the battery plus the excess. Not a constant.
a_over = averager_at(CEIL + 200.0, 450.0)
p_over = PeakTracker.project(a_over, B + timedelta(seconds=450))
d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
print(f" 200 W over for 450 s: {d_over.explain()}")
check("a quarter projected over the ceiling engages, and the floor is the "
@@ -422,7 +422,7 @@ check("a quarter projected over the ceiling engages, and the floor is the "
and near(d_over.floor_w, BATT_NOW + excess_w(p_over, CEIL), 1e-6))
check("...and the floor moves with the battery measurement, so it is a claim "
"about delivered power rather than a fixed setpoint",
not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, reading=None,
not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR).floor_w,
d_over.floor_w, 1.0))
@@ -433,7 +433,7 @@ intent = 0.0
delivered_floor = []
intent_floor = []
for _ in range(5):
d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, reading=None,
d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
delivered_floor.append(d_del.floor_w)
intent = intent + excess_w(p_over, CEIL) # what the intent form would do
@@ -516,7 +516,7 @@ check(f"a loop already discharging harder ({BIG:.0f} W) than the floor is not "
absurd = averager_at(LIVE_MAX_W * 4, 870.0)
p_absurd = PeakTracker.project(absurd, B + timedelta(seconds=870))
DEV_MAX = LIVE_MAX_W - 500.0 # a device rating tighter than max_w
d_absurd = decide(p_absurd, batt_w=1000.0, cap_w=CAP_W, reading=None,
d_absurd = decide(p_absurd, batt_w=1000.0, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=min(LIVE_MAX_W, DEV_MAX), max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
r6 = resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "rating"),
Claim.limit("supervised", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "max_w"),
@@ -599,7 +599,7 @@ print(f" a real PeakStore on a missing volume: state={rd_broken.state}, "
f"headroom_w()={tr_broken.headroom_w(B)}")
check("the real store answers NOT_OBSERVED rather than a peak of 0 W",
rd_broken.state == NOT_OBSERVED and rd_broken.peak_w is None)
d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=rd_broken,
d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=rd_broken,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
print(f" {d_broken.explain()}")
check("AND THE GUARD STILL ENGAGES ON IT. An unreadable peak must not disarm "
@@ -627,7 +627,7 @@ print("4. blindness: three ways a projection is not evidence")
# (a) NO OPEN BLOCK. A fresh averager projects 0 W - under every ceiling.
fresh = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
p_fresh = PeakTracker.project(fresh, B)
d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, reading=None,
d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
print(f" fresh averager: projected {p_fresh.projected_w:.0f} W, "
f"block_start={p_fresh.block_start}; {d_fresh.explain()}")
@@ -655,7 +655,7 @@ p_frozen = PeakTracker.project(frozen_av, B + timedelta(seconds=600))
RAILED = LIVE_MAX_W # the loop has wound to the rail
is_frozen = looks_frozen(FREEZE_S + 1.0, -RAILED, LIVE_MAX_W,
FreezeWatchdogConfig(hold_s=FREEZE_S))
d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR,
frozen=is_frozen)
print(f" meter stuck at -4000 W with the command at {-RAILED:+.0f} W: "
@@ -680,7 +680,7 @@ check("...and the same command NOT frozen is left alone, so the blind branch is
"not simply always on",
near(resolve(SAFE + [Claim.set("loop", P_LOOP, -RAILED, "grid"),
peakguard.claim(decide(
p_frozen, batt_w=-RAILED, cap_w=CAP_W,
p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0,
reading=None, achievable_w=LIVE_MAX_W,
max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR, frozen=False))
or Claim.limit("noop", P_PEAK)]).target_w,
@@ -692,7 +692,7 @@ gapped = averager_at(4200.0, 600.0, gap_at=200.0, gap_s=GAP)
p_gap = PeakTracker.project(gapped, B + timedelta(seconds=600))
clean = averager_at(4200.0, 600.0)
p_clean = PeakTracker.project(clean, B + timedelta(seconds=600))
d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, reading=None,
d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
print(f" a {GAP:.0f} s hole at 4200 W: uncovered {uncovered_s(p_gap):.0f} s, "
f"projected {p_gap.projected_w:.0f} W vs {p_clean.projected_w:.0f} W with "
@@ -756,7 +756,7 @@ check("a renamed held value now raises AttributeError on the first cycle. The "
OLD = (p_held.partial_ws if hasattr(p_held, "partial_ws")
else HELD * 300.0) # acc, which the old form would still have had
old_projected = (HELD * 300.0 + 0.0 * (QUARTER_S - 300.0)) / QUARTER_S
d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, reading=None,
d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
print(f" same fixture: shipped projection {p_held.projected_w:.0f} W "
f"(engaged={d_true.engaged}), the defaulted form's "
@@ -832,7 +832,7 @@ def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W, pack=None,
next_cycle = t + timedelta(seconds=CYCLE_S)
cycles += 1
d = decide(PeakTracker.project(ing.averager, t),
batt_w=batt, cap_w=cap_w, reading=tracker.peak(t),
batt_w=batt, cap_w=cap_w, margin_w=0.0, reading=tracker.peak(t),
achievable_w=max_w, max_hold_s=MAX_HOLD_S, soc=soc,
soc_floor=floor_pct, frozen=False)
if d.floor_w is not None and d.floor_w < 0.0:
@@ -1417,7 +1417,8 @@ check("B3: main.py sizes the floor from the MEASURED battery power, not from "
# `datetime.now()`. Every number below therefore comes from ONE instant, and the
# tolerances that used to absorb the wall clock are gone.
_proj3 = _seen["projection"]
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
_kw = dict(cap_w=CAP_W, margin_w=b3.peak_margin_w, reading=b3.peak.peak(),
achievable_w=LIVE_MAX_W,
max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK,
soc_floor=SOC_FLOOR)
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
@@ -1554,7 +1555,8 @@ check("SAFETY-24 adds exactly the two keys it names and no more: §14's kW/W "
"trap `capacity_peak_limit_kw` stays declined, and no speculative "
"`peak_guard`/`peak_shave` block appeared beside it",
_peak_keys == []
and sorted(PEAK_OPTION_DEFAULTS) == ["bms_soc_floor", "peak_soc_floor"])
and sorted(PEAK_OPTION_DEFAULTS) == ["bms_soc_floor", "peak_margin_w",
"peak_soc_floor"]) # + SAFETY-79
# Graded on the parsed call syntax, not on the text: the module docstring names
# `peak_cap_w` while arguing that it does not read it, so a grep would be
# satisfied by deleting the explanation and would fail on writing one.
@@ -1634,13 +1636,13 @@ check("...and the defaulting form that was ACTUALLY shipped once in this "
discharge_permitted(float(None or 100), SOC_FLOOR) is None)
# --- the decision -----------------------------------------------------------
d_ok = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
d_ok = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK,
soc_floor=SOC_FLOOR)
d_lim = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
d_lim = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_FLOOR, soc_floor=SOC_FLOOR)
d_unread = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
d_unread = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=None,
soc_floor=SOC_FLOOR)
print(f" with battery: {d_ok.explain()}")
@@ -1682,7 +1684,7 @@ check("floor-limited and saturated are MUTUALLY EXCLUSIVE, by construction: the
# notification that matters.
d_quiet = decide(PeakTracker.project(averager_at(500.0, 450.0),
B + timedelta(seconds=450)),
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR)
print(f" an EMPTY pack on a quiet quarter: {d_quiet.explain()}")
@@ -1694,7 +1696,7 @@ check("an empty pack on a quarter that does not need shaving is IDLE, not "
and d_quiet.floor_w is None)
# And the BLIND branch is untouched: it never forces a discharge, so there is
# nothing for an energy floor to bound there.
d_blind_low = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
d_blind_low = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR, frozen=True)
check("...and an empty pack does not disturb the BLIND branch either: SAFETY-"
@@ -1751,7 +1753,7 @@ for load in (0.0, CEIL - 500.0, CEIL, CEIL + 300.0, CEIL + 3000.0,
for bw in (-2000.0, 0.0, 400.0, 4000.0):
for sc in (None, 0.0, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0):
for fl in (SOC_FLOOR, BMS_FLOOR + 1.0, 55.0):
_d = decide(_pr, batt_w=bw, cap_w=CAP_W, reading=None,
_d = decide(_pr, batt_w=bw, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W,
max_hold_s=MAX_HOLD_S, soc=sc, soc_floor=fl)
SWEEP_N += 1
@@ -1798,8 +1800,8 @@ check("`soc` and `soc_floor` are REQUIRED keyword-only arguments of decide() "
"with no default. A default on either is TEST-14's exact shape and would "
"be the quiet fallback this card exists to remove - and a default on "
"`soc_floor` would additionally be CFG-03's fourth defaults site",
_required_kw == {"batt_w", "cap_w", "reading", "achievable_w", "soc",
"soc_floor"})
_required_kw == {"batt_w", "cap_w", "margin_w", "reading",
"achievable_w", "soc", "soc_floor"})
_req_fields = tuple(f.name for f in dataclasses.fields(PeakDecision)
if f.default is dataclasses.MISSING
and f.default_factory is dataclasses.MISSING)
@@ -1948,7 +1950,7 @@ check("...and it reaches the action log too", any("FLOOR_LIMITED" in e
for e in c24.events))
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
# at 0 W in that state, so the guard's own answer is what is graded here.
d_nosoc = decide(p_hungry, batt_w=0.0, cap_w=CAP_W, reading=None,
d_nosoc = decide(p_hungry, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=None, soc_floor=SOC_FLOOR)
check("decide() handed soc=None answers floor-limited rather than a full "
@@ -2226,7 +2228,7 @@ def replay_loop(window, *, start_soc, soc_floor, usable_wh=REF_PACK_WH):
next_cycle = t + timedelta(seconds=CYCLE_S)
cycles += 1
d = decide(PeakTracker.project(ing.averager, t), batt_w=batt,
cap_w=CAP_W, reading=tracker.peak(t),
cap_w=CAP_W, margin_w=0.0, reading=tracker.peak(t),
achievable_w=LOOP_MAX_W, max_hold_s=MAX_HOLD_S, soc=soc,
soc_floor=soc_floor, frozen=False)
dec = compute(prev_w, w - batt, batt, LOOP_TUNING, sat_count=sat,
@@ -5462,7 +5464,7 @@ MUT.append(("blindness() returns None - the degenerate 0 W projection is graded"
# 2. the frozen term dropped from the guard's inputs.
MUT.append(("frozen=False - SAFETY-18's term dropped",
not decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
not decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR,
frozen=False).engaged and d_frozen.engaged,
"part 4: 'THE BACKSTOP: SAFETY-18's runaway ... cut to 0 W'"))
@@ -5533,7 +5535,7 @@ _early = sum(1 for _t in (500.0, 1000.0, 2000.0)
if discharge_permitted(SOC_FLOOR - 1.0, SOC_FLOOR) is not None
and not decide(PeakTracker.project(
averager_at(_t, 450.0), B + timedelta(seconds=450)),
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR).engaged)
MUT.append((f"the SoC gate moved above the `wanted > 0` test ({_early} quiet "
@@ -5963,7 +5965,7 @@ print("13. SAFETY-38 round 4: the ORDERED PAIR inside PeakDecision.explain()")
_S38_PROJ = PeakTracker.project(averager_at(CAP_W - 1200.0, 450.0),
B + timedelta(seconds=450))
_S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=obs_hi,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_OK, soc_floor=SOC_FLOOR)
# UX-08 2d: the row is in plain words now, and the pair is still ordered.
@@ -6042,7 +6044,7 @@ def _proj_at(net_w, elapsed):
def _decide_on(projection, *, batt_w, soc=SOC_OK, cap_w=CAP_W,
achievable_w=LIVE_MAX_W, reading=None, frozen=False):
return decide(projection, batt_w=batt_w, cap_w=cap_w, reading=reading,
return decide(projection, batt_w=batt_w, cap_w=cap_w, margin_w=0.0, reading=reading,
achievable_w=achievable_w, soc=soc, soc_floor=SOC_FLOOR,
max_hold_s=MAX_HOLD_S, frozen=frozen)
+1 -1
View File
@@ -176,7 +176,7 @@ def replay(paths, *, guarded: bool, cap_w, max_w, max_hold_s, cycle_s,
next_cycle = t + timedelta(seconds=cycle_s)
cycles += 1
d = decide(PeakTracker.project(ing.averager, t), batt_w=batt,
cap_w=cap_w, reading=tracker.peak(t),
cap_w=cap_w, margin_w=0.0, reading=tracker.peak(t),
achievable_w=max_w, max_hold_s=max_hold_s, soc=soc,
soc_floor=soc_floor, frozen=False)
if d.engaged:
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""SAFETY-79: with `peak_margin_w: 0`, is every command byte-identical to before?
python3 tools/safety79_identity.py # prints one digest per scenario
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
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.
"""
import functools
import hashlib
import os
import sys
import tempfile
import time
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.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
CAP_W = 2500
def load(t):
"""A house under the limit, over it, a solar surplus, far over, over."""
if t < 300:
return 800.0
if t < 900:
return 3200.0
if t < 1500:
return -2600.0
if t < 2100:
return 6000.0
return 4200.0
def soc(u, t):
return 60.0 # above the peak reserve: the guard may discharge
def _feed(c, t):
"""One P1 telegram per cycle: the meter reads what the house and the
batteries' measured power leave."""
imp, exp = split_signed(c.hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=rig.BASE + timedelta(seconds=t),
ingest_mono=time.monotonic()))
def store_off(name):
"""A store where battery `name` is switched Off (OPT-11)."""
s = rig._Store()
s.data[unit_key(name, packmode.USE_KEY)] = False
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}
SCENARIOS = {
"n1": dict(n=1),
"n2": dict(n=2),
# OPT-11: the only battery Off, so only the peak guard makes it give.
"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"),
}
def run(n, *, margin=None, modes=False, off=None, dur=2700.0):
"""Rows for one scenario; `margin=None` leaves the key out."""
extra = dict(P1, **(MODES if modes 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))
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,
on_cycle=on_cycle,
store=store_off(off) if off else None)
finally:
rig.Controller = real
return [r + (g,) for r, g in zip(rows, guard)], c
def digest(rows) -> str:
return hashlib.sha256(repr(rows).encode()).hexdigest()
def digests(margin=None) -> dict:
return {k: digest(run(**v, margin=margin)[0]) for k, v in SCENARIOS.items()}
if __name__ == "__main__":
m = None if len(sys.argv) < 2 else int(sys.argv[1])
for k, v in digests(m).items():
print(f"{k} {v}")
+3
View File
@@ -145,6 +145,9 @@ configuration:
peak_cap_w:
name: "Limits · Peak limit (W)"
description: "Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract."
peak_margin_w:
name: "Limits · Margin below the peak limit (W)"
description: "The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it."
peak_forecast_entity:
name: "Limits · 15-minute demand forecast entity (W)"
description: "Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit."
+3
View File
@@ -126,6 +126,9 @@ configuration:
peak_cap_w:
name: "Grenzen · Piekgrens (W)"
description: "Uw eigen getal: het gemiddelde vermogen per kwartier waaronder de add-on de afname houdt. Neem het over uit uw netcontract."
peak_margin_w:
name: "Grenzen · Marge onder de piekgrens (W)"
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
peak_forecast_entity:
name: "Grenzen · Entiteit kwartierprognose (W)"
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier. Laat u dit leeg, dan is er geen piekgrens."