SAFETY-79: peak guard keeps a margin below the limit (#151)
infra-pin / fresh (push) Successful in 1s
infra-pin / fresh (push) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #151.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# SAFETY-79 — the peak guard keeps a margin below the peak limit
|
||||
|
||||
**Status: DECIDED 2026-09-28 (owner decision).**
|
||||
|
||||
## Decision
|
||||
|
||||
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
|
||||
|
||||
- **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()`.
|
||||
|
||||
## Checks
|
||||
|
||||
`peak_margin_w: 0` is byte-identical to ed739d4 (test_peak_margin.py part 1,
|
||||
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`.
|
||||
@@ -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. 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.
|
||||
@@ -1616,6 +1618,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 |
|
||||
|
||||
@@ -894,6 +894,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
|
||||
@@ -1827,6 +1831,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.
|
||||
|
||||
@@ -3453,6 +3462,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
|
||||
@@ -3713,8 +3723,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.
|
||||
@@ -3741,7 +3750,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 -
|
||||
|
||||
@@ -1157,6 +1157,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 `peak_margin_w` under that ceiling
|
||||
(`quarter_target_w()`), so a quarter that lands a little high still stays under
|
||||
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
|
||||
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,15 @@ 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: `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:
|
||||
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
|
||||
|
||||
@@ -713,8 +733,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 +752,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 +768,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 +780,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 +788,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 +812,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 +831,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 +840,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 +852,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"}
|
||||
|
||||
@@ -182,6 +182,7 @@ GROUPS = (
|
||||
Part("peak", (
|
||||
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
|
||||
simple=True, wizard=5, note="month_peak"),
|
||||
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 == "":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -965,7 +965,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)
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""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.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
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; re-printed 2026-09-29 (review) after adding the charge headroom, the
|
||||
# shaping suspension and the two maintenance scenarios. 540 cycles each.
|
||||
ED739D4 = {
|
||||
"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 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]
|
||||
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)
|
||||
|
||||
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")
|
||||
_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][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")
|
||||
|
||||
|
||||
with open(os.path.join(HERE, "config.yaml"), encoding="utf-8") as f:
|
||||
_hits = re.findall(r"^ peak_margin_w:\s*(\S+)", f.read(), re.M)
|
||||
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")
|
||||
@@ -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:
|
||||
@@ -1175,7 +1175,9 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
int(now.timestamp() // QUARTER_S) * QUARTER_S, timezone.utc)
|
||||
span = int((now - blk).total_seconds())
|
||||
remaining = max(1.0, QUARTER_S - (now - blk).total_seconds())
|
||||
net_w = CAP_W + want_floor_w * remaining / QUARTER_S
|
||||
# SAFETY-79: sized against what the guard aims at, cap - margin.
|
||||
net_w = (CAP_W - ctl.peak_margin_w
|
||||
+ want_floor_w * remaining / QUARTER_S)
|
||||
for i in range(0, span + 1, 5):
|
||||
ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w))
|
||||
# ⚠️ AND ONE TELEGRAM AT `now` ITSELF. The averager credits a sample's
|
||||
@@ -1417,7 +1419,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 +1557,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 +1638,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 +1686,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 +1698,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 +1755,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 +1802,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 +1952,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 +2230,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 +5466,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 +5537,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 +5967,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 +6046,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)
|
||||
|
||||
|
||||
@@ -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,158 @@
|
||||
#!/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, 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.
|
||||
"""
|
||||
|
||||
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, peakguard # 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
|
||||
|
||||
|
||||
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),
|
||||
"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"),
|
||||
# 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, maint=False, dur=2700.0):
|
||||
"""Rows for one scenario; `margin=None` leaves the key out."""
|
||||
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((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_maint if maint else _feed,
|
||||
on_cycle=on_cycle,
|
||||
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
|
||||
|
||||
|
||||
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}")
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user