SAFETY-79: peak guard keeps a margin below the limit
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:
co-authored by
Claude Opus 5.5
parent
ed739d4cdb
commit
7c4c8f5c66
@@ -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 -
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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, "%"),
|
||||
)),
|
||||
|
||||
@@ -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 == "":
|
||||
|
||||
Reference in New Issue
Block a user