Merge pull request 'SAFETY-87: peak guard uses every battery that can still give' (#162) from safety-87-guard-uses-able-batteries into release/1.0
infra-pin / fresh (push) Successful in 2s

SAFETY-87: peak guard uses every battery that can still give (#162)
This commit was merged in pull request #162.
This commit is contained in:
2026-10-01 12:06:41 +02:00
12 changed files with 1170 additions and 72 deletions
@@ -69,6 +69,8 @@ which does not run the startup gate) the absence costs a shave and never a pack.
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].*
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
**is unchanged**, exactly as the card instructed. What changed is the floor it is
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
@@ -0,0 +1,83 @@
# SAFETY-87 - the peak guard uses every battery that can still give
**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not
changed" list. Tests: `test_safety87.py`; identity:
`tools/safety87_identity.py`.
**The defect.** At N>=2 the guard graded the emptiest battery against the
highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the
whole site floor-limited: the claim asked for 0 W and the reserve alarm fired,
even with the other battery full. Measured: a maintenance charge (A at 11 %)
raised the alarm 10 times while both batteries gave 2000 W and the quarter
landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at
15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86
review; rig case 7a: 4563.8 W).
**The fix.**
- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()`
then grades each battery on its own floor (`peakguard.reserve_split()`).
One at or under it is *held*. The others that are live and not refusing
(SAFETY-73) are *able*.
- A floor-limited decision is re-taken on the able batteries
(`peakguard.place_on_able()`): their own permission (min SoC against max
floor, among them), and a floor of `min(need, able_w)`. `able_w` is their
rating plus the drain a held battery gives anyway (its maintenance pin).
- The held batteries weigh 0 in the split while the site gives
(`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a
HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`),
so the holding battery covers the need.
- Floor-limited now means: some battery is held and the able ones cannot hold
the aim. The claim still asks them for `able_w`. The alarm fires only if
they miss the real limit at full power for `ALARM_HOLD_S`, the same test as
'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end
is not an alarm. With nothing able (all held, or the rest refusing or
dropped) it is the old state: 0 W, immediate alarm.
- The alarm and the row name `held`, each battery graded on its own floor. The
old code named batteries against the site's highest floor.
- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its
title without "Not ready", and the Home page is not shown as held at 0 W.
Rows that do stop control still outrank it.
**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against
max(floor) so the guard never sizes a forced discharge a battery's own BMS
stops. Now no battery at or under its own floor is asked for the peak: it is
graded on its own floor, and an unread SoC or unknown cut-out counts as held.
The min/max pairing still decides permission, over the able batteries. The
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
call-site and reduction checks still bite. What changed is only that one held
battery no longer stops the others. One exception, with battery modes on: see
Open.
**Maintenance pins beside a held battery** (review). A draining battery that
may still give is raised from its drain rate towards its rating for the peak
floor, after the others (`_drain_over_reserve()`; peak > drain), else the
floor's rest landed on the held battery. In a HOLD, a held battery counts its
own pin (a drain) among the others, and a held battery pinned to charge stops
the raise (never split both ways).
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
| case | before | after |
|---|---|---|
| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 |
| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 |
| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) |
| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms |
| both under the reserve | alarm | alarm (unchanged) |
| N=1, every scenario | | byte-identical |
**Open, for the owner.**
- The loop still draws a held battery (SAFETY-25 option A: the reserve binds
the peak guard only). It gives last, after the able ones. So with house 6500
in the charge case, B alone cannot hold the aim, the alarm fires as
specified, and the loop then lands the quarter on the aim from A. The text
says "cannot keep … alone", not "will go over", so it stays true.
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
before a held battery in the first case needs a third allocator tier. The
same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0.
Until then, with modes on, a held battery can give for the peak.
- The notification and row text live in `messages.py`, which has no NL layer
yet (UX-19). The Home page text, which has NL, is unchanged.
**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90.
+9 -3
View File
@@ -1639,7 +1639,7 @@ help - see "Why the tuning is what it is".
| `maintenance_enabled` | false | Enable the monthly cycle |
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
| `maintenance_soc_target` | 99 | Charge target |
@@ -3157,6 +3157,10 @@ and what to do:
- **A battery cut-out level is not known.** Fill in “Battery cut-out level”
for each inverter in the settings.
With several inverters, a battery at its peak reserve does not give for the
peak and the others do. This alarm then comes only when the others cannot
keep the period under the limit alone. Control keeps running meanwhile.
The peak reserve only applies to keeping the peak down. Normal home use can
still draw the battery below it, down to the battery cut-out level, where the
battery switches itself off.
@@ -3252,7 +3256,9 @@ state of charge is unread, or when an inverter's `bms_soc_floor` (label
“Battery cut-out level”) is missing, because the reserve is graded against it.
Charging from the grid stays blocked meanwhile. With several inverters the
notification names the battery at its limit (SAFETY-32), each graded against
its own cut-out level. ⚠️ **The reserve binds the peak guard only
its own cut-out level. That battery is held (weight 0 while the site gives)
and the guard's need goes to the others, up to their rating; the alarm fires
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). ⚠️ **The reserve binds the peak guard only
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
does not protect*.
@@ -3390,7 +3396,7 @@ for a new install*.
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
+8 -1
View File
@@ -635,7 +635,7 @@ def site_resolve(claims: list) -> Resolution:
def resolve(claims: list, *, units=None, weights=None, live=None,
tick=None) -> Resolution:
tick=None, reserve=()) -> Resolution:
"""The three stages, in order, over one cycle's claims.
`units` is the site's roster of inverters. Left None it is derived from the
@@ -737,6 +737,13 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
gate, held = _unit_modes(out, site_claims, bounds, live)
if callable(weights):
weights = weights(out.target_w)
# SAFETY-87: units under their peak reserve weigh 0 while the site gives,
# so the others carry the peak floor. An Off battery gives the peak's
# shortfall, so it goes before them when no other unit is left.
reserve = set(reserve) if out.target_w > 0.0 else set()
if held and reserve and not set(bounds) - held - reserve:
held = set()
held = held | reserve
if held:
weights = {u: (0.0 if u in held else
(1.0 if weights is None else weights.get(u, 1.0)))
+6 -5
View File
@@ -923,24 +923,25 @@ def state_of(c, level, shadow):
def build(c, *, level: str, checks: list, now: datetime | None = None,
lang: str = "en") -> dict:
lang: str = "en", held: bool | None = None) -> dict:
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
the controller."""
_LANG[0] = uitext.lang_of(lang)
try:
return _build(c, level=level, checks=checks, now=now)
return _build(c, level=level, checks=checks, now=now, held=held)
finally:
_LANG[0] = "en"
def _build(c, *, level, checks, now):
def _build(c, *, level, checks, now, held=None):
now = now or datetime.now(timezone.utc)
shadow = bool(c.shadow)
kind = state_of(c, level, shadow)
# A red banner means every inverter is at 0 W (the status cascade), so
# nothing is planned until it clears. Shadow mode still plans: it is
# what the add-on would do.
held = level == "bad" and not shadow
# what the add-on would do. `held` False: an alarm while control runs
# (SAFETY-87, the peak alarms).
held = level == "bad" and not shadow and held is not False
running = (bool(c.auto) or shadow) and not held
settled = all(at_zero(inv) for inv in c.inverters)
ctl = next((r for r in checks if r.get("id") == "controller"), None)
+162 -19
View File
@@ -936,6 +936,9 @@ class Controller:
self._peak_alarm_block = None
self._peak_high_since = None
self._peak_floor_limited = False
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
self._reserve_batt_prev = None
self._reserve_high_since = None
self._maint_shape_suspended = False
# OPT-11. Battery modes, off by default so an install that has not
# turned them on behaves exactly as before (SAFETY-10 section 1). Each
@@ -2514,6 +2517,44 @@ class Controller:
self._drain_waits = waits
return floor if floor > 0.0 else None
def _peak_on_able(self, decision, maint_results):
"""SAFETY-87: at N>=2, `decision` placed on the batteries that can
still give: live, not refusing, above their own reserve. `able_w` is
their rating plus the drain a held battery gives anyway (its
maintenance pin). Unchanged when no battery is held back.
DECISIONS/SAFETY-87-guard-uses-able-batteries.md."""
if len(self.inverters) < 2:
return decision
floor = float(self.o.get(
"peak_soc_floor", peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))
ok, held = peakguard.reserve_split(self.packs, floor)
if not held:
return decision
live = set(self.live_units())
able = [p for p, inv in zip(self.packs, self.inverters)
if p in ok and inv.name in live and not (inv.refused or 0) > 0]
if not able:
return dataclasses.replace(decision, held=held)
drain = sum(min(float(r.setpoint_w), i.rating_w())
for i, r in zip(self.inverters, maint_results)
if i.name in held and r.phase == DRAIN
and r.owns_setpoint and (r.setpoint_w or 0.0) > 0.0)
names = {p.name for p in able}
return peakguard.place_on_able(
decision, held,
able_w=drain + sum(i.rating_w() for i in self.inverters
if i.name in names),
soc=min(float(p.soc) for p in able),
soc_floor=peakguard.binding_soc_floor(able, floor))
def _peak_reserve(self) -> frozenset:
"""SAFETY-87: the batteries that give nothing for the peak this
cycle: under their reserve while the others carry a peak floor."""
g = self.peak_guard
if g is None or g.able_w is None or not (g.floor_w or 0.0) > 0.0:
return frozenset()
return frozenset(g.held)
def _peak_over_drain(self, claims, maint_results) -> None:
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it.
@@ -2521,9 +2562,11 @@ class Controller:
(P_PEAK), so the floor did not bind and the house imported what the
drain left. Where the floor asks for more than the drain rate, it is
refiled at P_MAINTENANCE and wins; nothing else changes. Safety claims
(P_SAFETY) still outrank it. At N>=2 the `drain` floor does this.
(P_SAFETY) still outrank it. At N>=2 the `drain` floor does this, and
`_drain_over_reserve()` when a battery is held at its reserve.
"""
if len(self.inverters) != 1:
self._drain_over_reserve(claims, maint_results)
return
res = maint_results[0]
if res.phase != DRAIN or not res.owns_setpoint:
@@ -2533,6 +2576,59 @@ class Controller:
if c.source == peakguard.SOURCE and c.lo > rate:
claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE)
def _held_pin(self, claims, u):
"""SAFETY-87: what held unit `u` places anyway (its own pin), or None
when that pin charges, so the site is never split both ways."""
b = pre_resolve([c for c in claims if c.actuator == u])
p = None if b.contradiction else allocator.pin_w(b)
return None if p is not None and p < 0.0 else max(p or 0.0, 0.0)
def _drain_over_reserve(self, claims, maint_results) -> None:
"""SAFETY-87, N>=2: while a battery is held at its peak reserve, a
draining battery that may still give is raised from its drain rate
towards its rating for the peak floor, after the others. Otherwise
the floor's rest lands on the held battery (its weight 0 only orders
the split; the drain pin binds). A site stop still decides alone."""
reserve = self._peak_reserve()
floor = max((c.lo for c in claims if c.source == peakguard.SOURCE),
default=0.0)
if not reserve or not floor > 0.0 or any(
c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_PEAK for c in claims):
return
live = set(self.live_units())
drains, others = [], 0.0
for inv, res in zip(self.inverters, maint_results):
u = self.actuator_of("maintenance", inv)
if u not in live:
continue
if inv.name in reserve:
p = self._held_pin(claims, u)
if p is None:
return
others += p
continue
b = pre_resolve([c for c in claims if c.actuator == u])
if b.contradiction:
continue
p = allocator.pin_w(b)
if p is not None and p < 0.0:
return # a pack pinned to charge: never split both ways
if res.phase == DRAIN and b.pinned_by == "maintenance" \
and p is not None and p > 0.0:
drains.append((u, p, allocator.hi_w(b)))
others += allocator.hi_w(b) if p is None else p
give = floor - others
for u, p, hi in drains:
if give <= 0.0:
break
up = max(p, min(hi, p + give))
give -= up - p
for i, c in enumerate(claims):
if c.source == "maintenance" and c.kind == SET \
and c.actuator == u:
claims[i] = dataclasses.replace(c, value=up)
def _peak_over_hold(self, claims, maint_results):
"""SAFETY-86: a peak floor outranks a maintenance HOLD (0 W).
@@ -2558,11 +2654,18 @@ class Controller:
and c.priority > P_PEAK for c in claims):
return
live = set(self.live_units())
reserve = self._peak_reserve() # SAFETY-87: they give nothing
others = 0.0
for inv in self.inverters:
u = self.actuator_of("maintenance", inv)
if u in hold or u not in live:
continue
if inv.name in reserve: # only its own pin, never both ways
p = self._held_pin(claims, u)
if p is None:
return
others += p
continue
b = pre_resolve([c for c in claims if c.actuator == u])
if b.contradiction:
continue
@@ -4021,6 +4124,11 @@ class Controller:
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max")),
)
# SAFETY-87: at N>=2 a battery at or under its own reserve gives
# nothing for the peak, and the others carry the guard's need.
if self.peak_guard is not None:
self.peak_guard = self._peak_on_able(self.peak_guard,
maint_results)
# SITE, and peakguard.claim() says so on the object it builds. The
# quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION,
# which is what the capacity tariff bills; there is one quarter-hour
@@ -4553,7 +4661,8 @@ class Controller:
_live = self.live_units()
while True:
resolution = resolve(claims, units=self.units, live=_live,
weights=self.pack_weights, tick=self.alloc_tick)
weights=self.pack_weights, tick=self.alloc_tick,
reserve=self._peak_reserve())
# SAFETY-73: a probe pin stands only while the resolved site target
# asks at least PROBE_W its way. Below that a sibling would carry
# the opposite direction (circulation), so the pin goes back to the
@@ -5169,6 +5278,8 @@ class Controller:
floor-limited the guard asked for NOTHING, because the pack is at or
under the EMS floor, or its SoC could not be read.
Nothing is being discharged for this quarter at all.
(SAFETY-87, N>=2: or the batteries above their
reserve give all they can and it is not enough.)
They cannot both be true: peakguard.decide()'s floor-limited branch
returns before the `achievable_w` clamp that sets `saturated`.
@@ -5189,6 +5300,10 @@ class Controller:
"""
g = self.peak_guard
limited = bool(g is not None and g.floor_limited)
if limited and (g.floor_w or 0.0) > 0.0:
limited = self._reserve_short(g)
else:
self._reserve_batt_prev = self._reserve_high_since = None
was, self._peak_floor_limited = self._peak_floor_limited, limited
if not limited or was:
return
@@ -5211,13 +5326,8 @@ class Controller:
# think the pack is AT it. `decide()` guards its own reason string the
# same way; a f-string that formats a None here would turn the one
# notification that matters into a traceback.
# ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor
# the decision was actually taken with, so this re-grades nothing and
# cannot disagree with the alarm it is printed inside. At a roster of one
# no pack is named - there is exactly one.
_blocked = [name for name, why
in peakguard.pack_permissions(self.packs, g.soc_floor)
if why is not None]
# SAFETY-32/87: which battery is at its reserve, each graded on its own
# floor (`g.held`). At a roster of one no battery is named.
# ⚠️ SAFETY-25's sentence (the reserve binds the peak guard only) is
# part of messages.peak_reserve() on every branch, the unreadable-SoC
# one included.
@@ -5226,8 +5336,31 @@ class Controller:
g.ceiling_w, soc_unread=g.soc is None,
floor_unknown=(g.soc is not None
and g.soc_floor is None),
packs=(_blocked if len(self.inverters) > 1
else ())).data())
packs=(g.held if len(self.inverters) > 1
else ()),
others=(g.floor_w or 0.0) > 0.0).data())
def _reserve_short(self, g) -> bool:
"""SAFETY-87: the batteries that can still give, at full power, miss
the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as
SAFETY-81 grades 'Load too high'."""
p, batt = self._peak_proj, self.site_batt_w
if batt is None:
return self._peak_floor_limited
prev, self._reserve_batt_prev = self._reserve_batt_prev, batt
if prev is not None:
batt = min(float(batt), float(prev))
if not (p is not None and p.block_start is not None
and peakguard.load_too_high(p, batt_w=batt,
ceiling_w=g.ceiling_w,
achievable_w=g.able_w)):
self._reserve_high_since = None
return False
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
if self._reserve_high_since is None:
self._reserve_high_since = t
return ((t - self._reserve_high_since).total_seconds()
>= peakguard.ALARM_HOLD_S)
async def check_circulating(self) -> None:
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
@@ -5998,8 +6131,10 @@ class Controller:
"text": messages.row_peak_guard_waiting()})
elif _pg.saturated:
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"text": messages.row_peak_saturated(_pg.ceiling_w)})
elif _pg.floor_limited:
elif _pg.floor_limited and (not (_pg.floor_w or 0.0) > 0.0
or self._peak_floor_limited):
# SAFETY-24. NOT ok, like saturation: in both states the money
# is going to be spent and a human is the only actuator left. A
# separate row from saturation because the two ask for opposite
@@ -6025,18 +6160,17 @@ class Controller:
# UX-08: the same three branches, and the same pack naming, as
# check_peak_floor_limited()'s notification. The figures stay in
# `_pg.reason`, on the log and the event list.
_blocked = [name for name, why
in peakguard.pack_permissions(self.packs,
_pg.soc_floor)
if why is not None]
# SAFETY-87: `runs`, control goes on (status_payload()).
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"banner": messages.banner_peak_reserve(),
"text": messages.row_peak_reserve(
_pg.ceiling_w, soc_unread=_pg.soc is None,
floor_unknown=(_pg.soc is not None
and _pg.soc_floor is None),
packs=(_blocked if len(self.inverters) > 1
else ()))})
packs=(_pg.held if len(self.inverters) > 1
else ()),
others=(_pg.floor_w or 0.0) > 0.0)})
elif _pg.blind:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_blind(
@@ -6359,6 +6493,11 @@ class Controller:
def status_payload(self, lang: str = "en") -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
# SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while
# control goes on, so it neither reads "Not ready" nor holds the page.
runs = [c for c in bad if c.get("runs")]
bad = [c for c in bad if not c.get("runs")]
running_alarm = False
# --- SAFETY-10, AND IT IS FIRST IN THE CASCADE -----------------------
# "The battery is NOT being controlled" is the one sentence on this
# page that may never be displaced by another condition. A banner that
@@ -6411,6 +6550,9 @@ class Controller:
level, banner = "bad", messages.banner_not_ready(bad[0])
elif not self.auto:
level, banner = "warn", messages.banner_stopped()
elif runs:
level, running_alarm = "bad", True
banner = runs[0].get("banner") or messages.headline(runs[0]["text"])
elif any(i.delivery_wd.tripped for i in self.inverters):
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
@@ -6532,6 +6674,7 @@ class Controller:
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.why_words)),
]
held = level == "bad" and not running_alarm
out = {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.hint,
@@ -6546,7 +6689,7 @@ class Controller:
# fields are served alone (the page then shows the installer details).
try:
out.update(homeview.build(self, level=level, checks=checks,
lang=lang))
lang=lang, held=held))
except Exception: # noqa: BLE001
# Once per process at ERROR: the page polls every 3 s.
if not getattr(self, "_home_failed", False):
+21 -4
View File
@@ -217,7 +217,7 @@ def peak_saturated(ceiling_w, *, shadow: bool = False) -> Notice:
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> Notice:
packs=(), others: bool = False) -> Notice:
"""SAFETY-24/25/32: the peak guard may not draw the battery any lower.
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
@@ -227,7 +227,8 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
roster means some inverter's cut-out level is missing (the peak reserve
itself always has a value) - so the action names the cut-out level.
Both are named by their setting labels, "Peak reserve (%)" and "Battery
cut-out level (%)".
cut-out level (%)". `others`: the other batteries give all they can and
it is not enough (SAFETY-87), so the limit MAY be passed.
"""
if soc_unread:
why = "The battery level cannot be read"
@@ -241,6 +242,12 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
else "The battery is at its peak reserve")
act = "Switch off large loads, or lower the peak reserve."
if others:
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, and the other batteries cannot keep this "
f"15-minute period under your {watts(ceiling_w)} peak "
f"limit alone. {act} Home use can still draw a battery "
f"below its reserve, down to its cut-out level.")
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, so this 15-minute period will go over your "
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
@@ -470,8 +477,9 @@ def row_peak_saturated(ceiling_w) -> str:
def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> str:
"""SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay."""
packs=(), others: bool = False) -> str:
"""SAFETY-24/25/32. The last sentence is SAFETY-25 and must stay.
`others`: as in `peak_reserve()` (SAFETY-87)."""
if soc_unread:
why = "Battery level cannot be read"
elif floor_unknown:
@@ -479,6 +487,10 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
else:
why = (f"{names(packs)} battery at its peak reserve" if packs
else "Battery at its peak reserve")
if others:
return (f"{why}. The others cannot hold the {watts(ceiling_w)} peak "
f"limit alone. Home use still draws it lower, to the cut-out "
f"level.")
return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
f"use still draws the battery below it, down to the cut-out level.")
@@ -1014,6 +1026,9 @@ def status_samples() -> list:
("row_peak_guard_off", AMBER, row_peak_guard_off()),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"],
others=True)),
("row_peak_reserve", RED, row_peak_reserve(
5000, soc_unread=True, floor_unknown=False)),
("row_peak_reserve", RED, row_peak_reserve(
@@ -1116,6 +1131,8 @@ def samples() -> list:
peak_reserve(5000, soc_unread=False, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"]),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"], others=True),
peak_reserve(5000, soc_unread=True, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=True),
circulating(["inverter-a"], ["inverter-b"]),
+46 -1
View File
@@ -397,7 +397,7 @@ function, so whichever way the owner answers, this file does not change and does
not need re-testing. Nothing here indexes a phase.
"""
from dataclasses import dataclass
from dataclasses import dataclass, replace
from . import messages
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
@@ -490,11 +490,18 @@ class PeakDecision:
# floor refused to size it. Mutually exclusive with `saturated` by
# construction: this branch asks for nothing, so it cannot have run out of
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
# SAFETY-87: at N>=2 it may ask the batteries above their reserve for
# `able_w`, all they have, when that is not enough (`place_on_able()`).
floor_limited: bool = False
soc: float | None = None
soc_floor: float | None = None
soc_floor_reason: str | None = None
reason: str = ""
# SAFETY-87, N>=2 only: the batteries at or under their own reserve,
# which give nothing for the peak, and what the others can give (None
# when they are not asked). See `place_on_able()`.
held: tuple = ()
able_w: float | None = None
def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
@@ -733,6 +740,43 @@ def binding_soc_floor(packs, soc_floor):
return max(floors)
def reserve_split(packs, soc_floor) -> tuple[list, tuple]:
"""SAFETY-87: `(packs the guard may spend, names of those it may not)`,
each pack graded on its own floor by `pack_permissions()`."""
perms = pack_permissions(packs, soc_floor)
return ([p for p, (_n, why) in zip(packs, perms) if why is None],
tuple(n for n, why in perms if why is not None))
def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision:
"""SAFETY-87: `decision` with the batteries in `held` (at or under their
own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor`
describe the others, as `decide()`'s arguments do for the roster. A
floor-limited decision is re-taken on them: they are asked for the need
up to `able_w`, and it stays floor-limited only if that is not enough.
`soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it.
DECISIONS/SAFETY-87-guard-uses-able-batteries.md."""
held = tuple(held)
if not decision.floor_limited or decision.wanted_w is None \
or discharge_permitted(soc, soc_floor) is not None:
return replace(decision, held=held)
wanted = float(decision.wanted_w)
floor = min(wanted, float(able_w))
head = (f"this 15-minute period heads for {decision.projected_w:.0f} W "
f"against a {decision.ceiling_w:.0f} W limit "
f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. "
f"At the peak reserve, so giving nothing for the peak: "
f"{messages.names(held)}. The others are asked for {floor:+.0f} W")
if floor >= wanted:
return replace(decision, floor_limited=False, floor_w=floor,
soc_floor_reason=None, held=held, able_w=float(able_w),
reason=f"PEAK_GUARD: {head}")
return replace(decision, floor_w=floor, held=held, able_w=float(able_w),
reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can "
f"give, but {wanted:+.0f} W was needed, so this "
f"period may go over the limit"))
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.
@@ -1097,5 +1141,6 @@ def claim(decision: PeakDecision) -> Claim | None:
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
reason=("blind" if decision.blind
else "SoC floor" if decision.floor_limited
and not decision.floor_w
else "quarter peak"),
actuator=ACTUATOR_SITE)
+56 -38
View File
@@ -109,6 +109,7 @@ import re
import shutil
import sys
import tempfile
import types
import time
from datetime import datetime, timedelta, timezone
from math import inf, isinf
@@ -2537,12 +2538,18 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
# that is going over the ceiling.
# UX-08: the banner is the notification's own title, and the row below keeps
# the SAFETY-25 sentence.
check("the Not ready banner is a short headline - the notification's title - "
"and NOT the row repeated at the top of the page. A headline that is a "
"paragraph is a headline nobody reads",
# SAFETY-87: control goes on while the guard is floor-limited, so the banner
# does not say "Not ready". This fixture also has a row that does stop control
# (no SoC entity), which outranks it; the peak row alone is graded below.
c25.checks = lambda: rows25
_pay25 = c25.status_payload()
del c25.checks
print(f" banner, peak row alone: {_pay25['banner']}")
check("the banner is a short headline - the notification's title, without "
"'Not ready' (control goes on) - and NOT the row repeated at the top of "
"the page. A headline that is a paragraph is a headline nobody reads",
_pay25["level"] == "bad"
and _pay25["banner"] == (f"{messages.NOT_READY} — "
f"{messages.T_PEAK_RESERVE}")
and _pay25["banner"] == messages.T_PEAK_RESERVE
and not _row_scope(_pay25["banner"]))
check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
"sentence is still what the checks list carries, which is the surface the "
@@ -4139,18 +4146,22 @@ check("⚠️ ...AND THE SAME PAIR SWAPPED GIVES THE SAME SITE FLOOR, so no "
and _g_deepa.soc_floor != _g_shallow.soc_floor
and [pk.bms_floor for pk in _g_ctl_deepa.packs]
== [B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_SIM])
check("...and it is not just a number in a report - THE SITE'S VERDICT FLIPS. "
"With both packs at 25 % the shallow site is permitted and the guard "
"sizes a positive forced discharge; BOTH deep sites are FLOOR-LIMITED, "
"because 25 % is at or below the 35 % cut-out wherever on the roster it "
"sits. Grading the roster against one position would spend a pack its "
"BMS stops - through `sim/inject.py`, which does not run rule 8 - and "
"this line is what goes red when it does",
# SAFETY-87 changed what the verdict is at N>=2: the pack at or under its own
# floor is HELD (named, gives nothing for the peak) and the others still shave.
check("...and it is not just a number in a report - THE HELD PACK FOLLOWS IT. "
"With both packs at 25 % the shallow site holds nobody back; on BOTH deep "
"sites the pack whose 35 % cut-out is at or above 25 % is held, wherever "
"it sits, gives nothing for the peak (weight 0), and the other pack "
"carries a positive floor (SAFETY-87). Holding a position instead of "
"the pack would spend a pack its BMS stops",
_g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0
and _g_deep.floor_limited is True and _g_deep.floor_w == 0.0
and _g_deep.soc_floor_reason == FLOOR_AT_SOC_FLOOR
and _g_deepa.floor_limited is True and _g_deepa.floor_w == 0.0
and _g_deepa.soc_floor_reason == FLOOR_AT_SOC_FLOOR)
and _g_shallow.held == ()
and _g_deep.floor_limited is False and _g_deep.floor_w > 0.0
and _g_deep.held == ("inverter-b",)
and _g_ctl_deep._peak_reserve() == {"inverter-b"}
and _g_deepa.floor_limited is False and _g_deepa.floor_w > 0.0
and _g_deepa.held == ("inverter-a",)
and _g_ctl_deepa._peak_reserve() == {"inverter-a"})
check("...and the precondition that makes the three runs comparable (habit 1): "
"the two deep runs carry THE SAME PAIR of cut-outs in opposite orders, "
"those two numbers really are different, and all six packs are at the "
@@ -4221,14 +4232,14 @@ check("⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster
for r in _g3_runs)
and [[pk.bms_floor for pk in r[2].packs] for r in _g3_runs]
== [r[1] for r in _g3_runs])
check("...and the VERDICT flips with it, on all three: 25 % is at or below the "
"35 % cut-out wherever it sits, so every one of these sites is "
"FLOOR-LIMITED and sizes no forced discharge at all - where the "
"all-shallow two-pack baseline above was permitted and sized a positive "
"one. A slice that dropped the deep position would spend, through "
"`sim/inject.py`, a pack its BMS stops",
all(r[3].floor_limited is True and r[3].floor_w == 0.0
and r[3].soc_floor_reason == FLOOR_AT_SOC_FLOOR for r in _g3_runs)
check("...and the HELD pack follows it, on all three (SAFETY-87): 25 % is at "
"or below the 35 % cut-out wherever it sits, so that pack and only that "
"pack is held and gives nothing for the peak, while the two others carry "
"a positive floor. A slice that dropped the deep position would spend, "
"through `sim/inject.py`, a pack its BMS stops",
all(r[3].floor_limited is False and r[3].floor_w > 0.0
and r[3].held == (r[2].inverters[r[1].index(_G3_DEEP_SIM)].name,)
and r[2]._peak_reserve() == set(r[3].held) for r in _g3_runs)
and _g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0)
check("...and the precondition that makes the three comparable (habit 1): every "
"pack in all three rosters is at the SAME state of charge, so neither the "
@@ -4386,12 +4397,18 @@ async def _notify32(packs_socs, floors):
inv_b, _ = second_inverter(ctl)
for inv, so, fl in zip(ctl.inverters, packs_socs, floors):
inv.soc, inv.bms_soc_floor = so, fl
ctl.peak_guard = PeakDecision(
# SAFETY-87: the full pack refuses to give (SAFETY-73), so no battery
# can carry the peak and the alarm still fires; it names the held one.
inv.refused = 1 if so > SOC_FLOOR else 0
_idle = [types.SimpleNamespace(phase="idle", owns_setpoint=False,
setpoint_w=None)] * 2
ctl.peak_guard = ctl._peak_on_able(PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP,
projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0,
floor_limited=True, soc=min(packs_socs),
soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR),
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x")
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x"),
_idle)
ctl._peak_floor_limited = False
await ctl.check_peak_floor_limited()
return hs.last_message("goodwe_peak_soc_floor")
@@ -4581,18 +4598,19 @@ check("...and the TWO-pack mirror, which is what production reaches the moment "
[r[3].soc for r in _s2_runs] == [_S_EMPTY_SIM] * 2
and [[inv.soc for inv in r[2].inverters] for r in _s2_runs]
== [[_S_EMPTY_SIM, _S_FULL_SIM], [_S_FULL_SIM, _S_EMPTY_SIM]])
check("⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT, which is what makes this a "
"defect rather than a reporting difference. With the emptiest pack at "
"11 % - below the 20 % EMS floor - every one of those five sites is "
"FLOOR-LIMITED and sizes NO forced discharge at all, where the baseline "
"with its emptiest pack at 25 % is permitted and sizes a positive one. A "
"reduction that dropped the empty pack's position would grade this site "
"for PERMISSION against a pack 39 points fuller and spend, through "
"`sim/inject.py`, a pack its own BMS stops",
all(r[3].floor_limited is True and r[3].floor_w == 0.0
# SAFETY-87: at N>=2 the emptiest pack is HELD and the others still shave.
check("⚠️ ...AND THE HELD PACK FOLLOWS IT, which is what makes this a defect "
"rather than a reporting difference. With the emptiest pack at 11 % - "
"below the 20 % EMS floor - that pack and only that pack is held on "
"every one of those five sites and gives nothing for the peak, while "
"the fuller ones carry a positive floor (SAFETY-87); the baseline holds "
"nobody back. A reduction that dropped the empty pack's position would "
"spend, through `sim/inject.py`, a pack its own BMS stops",
all(r[3].floor_limited is False and r[3].floor_w > 0.0
and r[3].held == (r[2].inverters[r[1].index(_S_EMPTY_SIM)].name,)
for r in _s3_runs + _s2_runs)
and _s_ok.floor_limited is False and _s_ok.floor_w > 0.0
and _s_ok.soc == SOC_BETWEEN)
and _s_ok.held == () and _s_ok.soc == SOC_BETWEEN)
check("...and the precondition that makes the six runs comparable (habit 1): "
"every pack in every roster carries the SAME cut-out, so "
"`binding_soc_floor()` answers the SAME site floor on all of them and the "
@@ -5727,8 +5745,8 @@ MUT.append(("main.py grading the site against ONE roster position - "
_g_deep.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
and _g_deepa.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
and _g_shallow.soc_floor == float(SOC_FLOOR)
and _g_deep.floor_limited and _g_deepa.floor_limited
and not _g_shallow.floor_limited,
and _g_deep.held == ("inverter-b",)
and _g_deepa.held == ("inverter-a",) and _g_shallow.held == (),
"part 12g, REWRITTEN as behaviour: real two-pack cycles, the SAME "
"pair of cut-outs carried in both orders, and both the floor "
"reaching decide() and the site's verdict follow the ROSTER rather "
+2 -1
View File
@@ -337,7 +337,8 @@ HOLDING = [MaintResult(HOLD, True, 0.0, False, []),
def pin_after(floor, *extra, live=(A, B), results=HOLDING, c=_c):
fake = types.SimpleNamespace(inverters=c.inverters,
actuator_of=c.actuator_of,
live_units=lambda: tuple(live))
live_units=lambda: tuple(live),
_peak_reserve=frozenset)
cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=A),
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=B),
Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator=A),
+709
View File
@@ -0,0 +1,709 @@
"""Runnable check for SAFETY-87: the peak guard uses every battery that can
still give. `python3 test_safety87.py`
At N>=2 the guard graded the EMPTIEST battery (SAFETY-32), so one battery under
the peak reserve stopped the guard for all of them: it asked for nothing and
raised the reserve alarm while the other battery was full. Now a battery at or
under its own reserve is HELD (it gives nothing for the peak, weight 0) and
the guard's need is placed on the others; the reserve alarm fires only when
they cannot hold the limit. Drives the REAL Controller (tools/
safety84_identity.py's harness). DECISIONS/SAFETY-87-guard-uses-able-batteries.md.
1. Maintenance charge at N=2 (A under the reserve, B able), house over the
aim: no reserve alarm, the quarter on the aim, A gives nothing for the
peak while B can carry it. When B cannot, the alarm fires and names A.
2. HOLD at N=2 (A full holding, B under the reserve): A gives, B gives
nothing, the quarter lands on the aim; at a banked month peak too.
3. Both under the reserve, or the other one refusing: the alarm still fires
and names the right battery.
4. The alarm at the quarter's edge: a one-cycle miss does not fire it.
5. place_on_able() at its boundaries, and the claim it files.
6. The allocator: a held battery gives last, after an Off one.
7. A held battery draining for maintenance counts for its drain.
8. The page: a peak alarm does not say "Not ready" or "0 W until the alarm
clears" while control goes on.
9. N=1 is byte-identical to release/1.0 b8ddfd7 (tools/safety87_identity.py).
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import asyncio
import dataclasses
import json
import logging
import os
import sys
import types
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
import safety87_identity as s87 # noqa: E402
from app import messages, packmode, peakguard # noqa: E402
from app.arbiter import (Claim, P_MAINTENANCE, P_MODE, P_PEAK, # noqa: E402
P_SAFETY, UnitBounds, resolve)
from app.main import Controller # noqa: E402
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
A, B = "inverter-a", "inverter-b"
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
TOL = 10.0 # W over the aim a banked quarter may show (SAFETY-81)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def store(phase, *, b_off=False):
"""Battery A in `phase` from the first cycle (B idle, cycle done)."""
s = rig._Store()
s.data[unit_key(A, "phase")] = phase
s.data[unit_key(A, "phase_started")] = rig.BASE.isoformat()
s.data[unit_key(B, "last_completed")] = rig.BASE.isoformat()
if phase == IDLE:
s.data[unit_key(A, "last_completed")] = rig.BASE.isoformat()
if b_off:
s.data[unit_key(B, packmode.USE_KEY)] = False
return s
def run(house, phase, socs, *, cheap=False, n=2, **kw):
"""Rows, controller, the notifications raised and one record per cycle:
(floor_limited, held, floor_w, reserve set, alarm latched)."""
notes, seen = [], []
real_check = Controller.check_peak_floor_limited
async def spy(self):
await real_check(self)
g = self.peak_guard
seen.append(None if g is None else (
g.floor_limited, g.held, g.floor_w, self._peak_reserve(),
self._peak_floor_limited))
def before(c, t):
if not getattr(c.hass, "_s87", False):
real = c.hass.call
async def call(*a):
if a[:2] == ("persistent_notification", "create"):
notes.append(a[2])
return await real(*a)
c.hass.call, c.hass._s87 = call, True
return s84.feed(cheap)(c, t)
kw.setdefault("dur", 2700.0)
extra = dict({"maintenance_hold_min": 600}, **kw.pop("extra", {}))
Controller.check_peak_floor_limited = spy
try:
rows, c = s84.run(
n, load=house if callable(house) else lambda t: house,
cheap=cheap, store=kw.pop("store", None) or store(phase),
soc=lambda u, t: socs[u], before=before, extra=extra, **kw)
finally:
Controller.check_peak_floor_limited = real_check
return rows, c, notes, seen
def without_fix(fn):
"""The shipped b8ddfd7 behaviour: no battery is ever held back."""
real = Controller._peak_on_able
Controller._peak_on_able = lambda self, decision, results: decision
try:
return fn()
finally:
Controller._peak_on_able = real
def cmd(r):
return dict(r[2])
def banked(c):
return [b.offtake_avg_w for b in c.p1.blocks]
def on_aim(c, aim=AIM):
b = banked(c)
return len(b) >= 2 and all(aim - TOL <= v <= aim + TOL for v in b)
def reserve_notes(notes):
return [x for x in notes
if x["notification_id"] == messages.ID_PEAK_RESERVE]
def limited_events(c):
return sum(1 for e in c.events if "PEAK_GUARD_FLOOR_LIMITED" in e)
def show(tag, rows, c, notes, seen):
a = [cmd(r)["a"] for r in rows]
b = [cmd(r)["b"] for r in rows]
print(f" {tag}: banked {[round(v, 1) for v in banked(c)]} W, mean A "
f"{sum(a) / len(a):.0f} W, B {sum(b) / len(b):.0f} W, reserve "
f"alarms {len(reserve_notes(notes))}, held "
f"{sorted({h for s in seen if s for h in s[1]})}")
# ============================================================================ #
print("1. maintenance charge at N=2: A under the reserve, B able")
LOW_A = {"a": 11.0, "b": 60.0}
CH = run(4500.0, "charge", LOW_A, cheap=True)
CH_OLD = without_fix(lambda: run(4500.0, "charge", LOW_A, cheap=True))
show("house 4500", *CH)
show("house 4500, before the fix", *CH_OLD)
check("⭐ house 4500 W, B can carry the guard alone: no reserve alarm (no "
"notification, no PEAK_GUARD_FLOOR_LIMITED line) and every quarter on "
"the aim",
not reserve_notes(CH[2]) and limited_events(CH[1]) == 0
and on_aim(CH[1]))
check("...A is held (it gives nothing for the peak) on every cycle the guard "
"asks for power, and the guard asks for a positive floor",
any(s and s[2] and s[2] > 0.0 for s in CH[3])
and all(s[1] == (A,) and s[3] == {A} for s in CH[3]
if s and s[2] and s[2] > 0.0))
check("non-vacuous: before the fix the same run raised the reserve alarm "
"(the SAFETY-84 rig test's check 2/3 false alarm)",
len(reserve_notes(CH_OLD[2])) >= 1 and limited_events(CH_OLD[1]) >= 1)
_b_carry = [r for r, s in zip(CH[0], CH[3])
if s and s[3] == {A} and r[1] <= 2500.0 and r[1] > 0.0]
check("...and while B can carry the site target (at most its 2500 W) A is "
"not asked to give at all (weight 0 behind B)",
len(_b_carry) >= 3 and all(cmd(r)["a"] <= 0.0 for r in _b_carry))
CH65 = run(6500.0, "charge", LOW_A, cheap=True)
show("house 6500", *CH65)
_n65 = reserve_notes(CH65[2])
check("⭐ house 6500 W, B (2500 W) cannot hold the 3450 W aim alone: the "
"reserve alarm fires and names A, the battery under its reserve, and "
"says the others cannot hold the limit alone",
len(_n65) >= 1
and all("inverter-a is at its peak reserve" in x["message"]
and "inverter-b" not in x["message"]
and "cannot keep this 15-minute period under your 3 500 W peak "
"limit alone" in x["message"] for x in _n65))
check("...and while it is limited the guard still asks B for everything B "
"has (floor 2500 W), so B is never left idle",
any(s and s[0] and s[2] == 2500.0 for s in CH65[3])
and all(s[2] == 2500.0 for s in CH65[3] if s and s[0]))
# ============================================================================ #
print("\n2. HOLD at N=2: A full and holding, B under the reserve")
LOW_B = {"a": 99.0, "b": 15.0}
HD = run(5500.0, HOLD, LOW_B)
HD_OLD = without_fix(lambda: run(5500.0, HOLD, LOW_B))
show("house 5500", *HD)
show("house 5500, before the fix", *HD_OLD)
check("⭐ house 5500 W: A gives, B gives nothing, every quarter lands on the "
"aim, no reserve alarm, and A stays in its HOLD",
on_aim(HD[1]) and not reserve_notes(HD[2])
and limited_events(HD[1]) == 0
and all(cmd(r)["b"] == 0.0 for r in HD[0])
and max(cmd(r)["a"] for r in HD[0]) > 2000.0
and HD[1].maints[0].phase == HOLD)
check("non-vacuous: before the fix A sat at 0 W, B (at 15 %) gave through "
"the loop and the reserve alarm fired (SAFETY-86 rig case 7a)",
all(cmd(r)["a"] == 0.0 for r in HD_OLD[0])
and max(cmd(r)["b"] for r in HD_OLD[0]) > 2000.0
and len(reserve_notes(HD_OLD[2])) >= 1)
HD65 = run(6500.0, HOLD, LOW_B)
HD65_OLD = without_fix(lambda: run(6500.0, HOLD, LOW_B))
show("house 6500", *HD65)
show("house 6500, before the fix", *HD65_OLD)
_n = reserve_notes(HD65[2])
check("⭐ house 6500 W, A alone (2500 W) cannot hold the aim: A gives all it "
"has, B still gives nothing, and the reserve alarm fires ONCE naming B",
all(cmd(r)["b"] == 0.0 for r in HD65[0])
and max(cmd(r)["a"] for r in HD65[0]) == 2500.0
and len(_n) == 1 and "inverter-b is at its peak reserve" in
_n[0]["message"] and "inverter-a" not in _n[0]["message"])
check("...and the quarter is no worse than before the fix (4051 W in the "
"SAFETY-86 review), with B kept at its reserve",
max(banked(HD65[1])) <= max(banked(HD65_OLD[1])) + 1.0)
BK = run(6000.0, HOLD, LOW_B, month_peak=4000.0)
show("house 6000, month peak 4000", *BK)
check("⭐ under a banked month peak of 4000 W the aim is 3950 W (peak - "
"margin): A gives, B nothing, every quarter on 3950 W, no alarm",
on_aim(BK[1], 3950.0) and not reserve_notes(BK[2])
and all(cmd(r)["b"] == 0.0 for r in BK[0]))
# ============================================================================ #
print("\n3. a real loss of peak limiting still alarms")
BOTH = run(5500.0, IDLE, {"a": 15.0, "b": 12.0})
show("both under", *BOTH)
_nb = reserve_notes(BOTH[2])
check("⭐ both batteries under the reserve: the reserve alarm fires and "
"names both, the guard asks for nothing (claim lo 0 W, 'SoC floor')",
len(_nb) >= 1
and all("inverter-a and inverter-b" in x["message"] for x in _nb)
and all(not s[2] for s in BOTH[3] if s and s[0])
and any(c[0] == "peak" and c[4] == 0.0 and c[6] == "SoC floor"
for r in BOTH[0] for c in r[-1][0]))
_ctl2 = BOTH[1]
for _inv, _s, _r in zip(_ctl2.inverters, (15.0, 60.0), (0, 1)):
_inv.soc, _inv.refused = _s, _r # SAFETY-73: B refuses to give
_ref = _ctl2._peak_on_able(_lim_all := peakguard.PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=3500.0,
ceiling_from=peakguard.CEILING_CAP, projected_w=4000.0, excess_w=550.0,
wanted_w=2000.0, target_w=3450.0, floor_limited=True, soc=15.0,
soc_floor=20.0, soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR,
reason="PEAK_GUARD_FLOOR_LIMITED: x"),
[MaintResult(IDLE, False, None, False, [])] * 2)
check("⭐ A under the reserve and B refusing to give (SAFETY-73): nothing can "
"give for the peak, so it stays limited at 0 W (the alarm fires) and "
"only A is named (test_peakguard.py part 12h drives the notification)",
_ref.floor_limited and _ref.floor_w == 0.0 and _ref.held == (A,)
and _ref.able_w is None and _ctl2._peak_reserve() == frozenset())
_ctl2.inverters[1].refused = 0
_ctl2.live_units = lambda: (A,) # B dropped from the split
_drop = _ctl2._peak_on_able(_lim_all, [MaintResult(IDLE, False, None, False,
[])] * 2)
del _ctl2.live_units
check("...and the same with B dropped (not live): nothing can give, the "
"alarm state stands at 0 W, only A named",
_drop.floor_limited and _drop.floor_w == 0.0 and _drop.held == (A,)
and _drop.able_w is None)
async def _notify(ctl, socs, cutouts, refused):
"""The shipped check_peak_floor_limited() on `ctl`, its notification."""
sent = []
async def call(*a):
sent.append(a)
return True
for inv, so, cut, ref in zip(ctl.inverters, socs, cutouts, refused):
inv.soc, inv.bms_soc_floor, inv.refused = so, cut, ref
real, ctl.hass.call = ctl.hass.call, call
ctl.peak_guard = ctl._peak_on_able(dataclasses.replace(
_lim_all, soc=min(socs), soc_floor=peakguard.binding_soc_floor(
ctl.packs, 20.0)), [MaintResult(IDLE, False, None, False, [])] * 2)
ctl._peak_floor_limited = False
try:
await ctl.check_peak_floor_limited()
finally:
ctl.hass.call = real
for inv in ctl.inverters:
inv.bms_soc_floor, inv.refused = 10, 0
return [a[2]["message"] for a in sent
if a[2].get("notification_id") == messages.ID_PEAK_RESERVE]
# Cut-outs A 10 % / B 35 % (simulated; rule 8 refuses it, sim/inject.py does
# not run rule 8), A at 25 % refusing, B at 30 %. Only B is at its own floor;
# A is above its own but under the site's highest (35 %).
_msg = asyncio.run(_notify(_ctl2, (25.0, 30.0), (10, 35), (1, 0)))
print(f" differing cut-outs: {_msg}")
check("⭐ the alarm names the battery at ITS OWN floor (B, 30 % <= 35 %), not "
"every battery under the site's highest floor (A at 25 % is above its "
"own 20 %)",
len(_msg) == 1 and "inverter-b is at its peak reserve" in _msg[0]
and "inverter-a" not in _msg[0])
# ============================================================================ #
print("\n4. the reserve alarm at the quarter's edge")
def spiky(t):
"""A 5000 W house (B alone can hold the aim) with a 5 s spike to
11000 W every 150 s, which B alone cannot."""
return 11000.0 if t % 150 < 5 else 5000.0
LOW_A_IDLE = {"a": 15.0, "b": 60.0}
SP = run(spiky, IDLE, LOW_A_IDLE)
_flips = sum(1 for s in SP[3] if s and s[0])
show("spiky house", *SP)
print(f" {_flips} cycles floor-limited (B alone short during a spike)")
check("⭐ a 5 s spike that B alone cannot cover makes the guard limited for a "
"cycle, and that does not fire the alarm: the miss must hold for "
"ALARM_HOLD_S, as for 'Load too high'. No quarter goes over the aim",
_flips >= 5 and not reserve_notes(SP[2])
and not any(s[4] for s in SP[3] if s)
and len(banked(SP[1])) >= 2 and max(banked(SP[1])) <= AIM)
def ungated(fn):
real = Controller._reserve_short
Controller._reserve_short = lambda self, g: True
try:
return fn()
finally:
Controller._reserve_short = real
_ug = ungated(lambda: run(spiky, IDLE, LOW_A_IDLE))
check("non-vacuous: without the hold the same run raises the alarm",
len(reserve_notes(_ug[2])) >= 1)
# ============================================================================ #
print("\n5. place_on_able() at its boundaries")
_lim = peakguard.PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=3500.0,
ceiling_from=peakguard.CEILING_CAP, projected_w=4000.0, excess_w=550.0,
wanted_w=2000.0, target_w=3450.0, floor_limited=True, soc=15.0,
soc_floor=20.0, soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR,
reason="PEAK_GUARD_FLOOR_LIMITED: x")
def place(d=_lim, held=("b",), able_w=2000.0, soc=60.0, floor=20.0):
return peakguard.place_on_able(d, held, able_w=able_w, soc=soc,
soc_floor=floor)
_eq, _short = place(), place(able_w=1999.0)
check("⭐ the others can give exactly the need (2000 W): not limited, floor "
"2000 W, the claim reads 'quarter peak' (the page says 'the peak "
"limit')",
not _eq.floor_limited and _eq.engaged and _eq.floor_w == 2000.0
and _eq.held == ("b",) and _eq.able_w == 2000.0
and peakguard.claim(_eq).lo == 2000.0
and peakguard.claim(_eq).reason == "quarter peak"
and _eq.reason.startswith("PEAK_GUARD: "))
check("...1 W short: limited, but the claim still asks them for all they "
"have (1999 W), not 0 W",
_short.floor_limited and _short.floor_w == 1999.0
and peakguard.claim(_short).lo == 1999.0
and peakguard.claim(_short).reason == "quarter peak"
and _short.reason.startswith("PEAK_GUARD_FLOOR_LIMITED: "))
check("...the others' own permission is graded: at or under their floor, or "
"unread, nothing is asked (0 W, 'SoC floor')",
all(place(soc=s).floor_w == 0.0 and place(soc=s).floor_limited
and peakguard.claim(place(soc=s)).reason == "SoC floor"
for s in (20.0, 19.0, None)))
check("...the roster's verdict is kept as decide() echoed it (soc 15 %, "
"floor 20 %), and a decision that was not limited is only labelled",
_eq.soc == 15.0 and _eq.soc_floor == 20.0
and all(place(d=d) == dataclasses.replace(d, held=("b",))
for d in (dataclasses.replace(
_lim, floor_limited=False, engaged=False,
floor_w=None),
dataclasses.replace(_lim, wanted_w=None))))
check("...and at N=1 the floor-limited claim is unchanged: 0 W, 'SoC floor'",
peakguard.claim(_lim).lo == 0.0
and peakguard.claim(_lim).reason == "SoC floor")
def three(socs, cuts):
"""`_peak_on_able()` on a stub roster of three (SIMULATED packs)."""
names = ("x", "y", "z")
invs = [types.SimpleNamespace(name=n, refused=0, rating_w=lambda: 2500.0)
for n in names]
stub = types.SimpleNamespace(
inverters=invs, o={"peak_soc_floor": 20},
packs=[peakguard.Pack(n, so, c) for n, so, c in zip(names, socs, cuts)],
live_units=lambda: names)
return Controller._peak_on_able(stub, _lim, [MaintResult(
IDLE, False, None, False, [])] * 3)
# x 25 % (cut-out 10 -> floor 20), y 40 % (cut-out 30), z 15 % (held). x and
# y each clear their own floor, but 25 % is not above y's 30 %: the pair is
# graded min SoC against max floor (SAFETY-32), so nothing is asked.
_t3 = three((25.0, 40.0, 15.0), (10, 30, 10))
_t3ok = three((35.0, 40.0, 15.0), (10, 30, 10))
check("⭐ three batteries, z held: the two others are graded as a pair, the "
"EMPTIEST of them (25 %) against the HIGHEST of their floors (30 %), "
"so nothing is asked; at 35 % they are asked for the need",
_t3.held == ("z",) and _t3.floor_limited and _t3.floor_w == 0.0
and _t3ok.held == ("z",) and not _t3ok.floor_limited
and _t3ok.floor_w == 2000.0)
# ============================================================================ #
print("\n6. the allocator: a held battery gives last")
def split(target, reserve, modes=(), floor=1500.0):
cl = [Claim.set("loop", 10, target),
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=A),
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=B),
Claim.limit("peak", P_PEAK, lo=min(target, floor))]
cl += [Claim.limit("mode", P_MODE, 0.0, 0.0, "off", actuator=u)
for u in modes]
r = resolve(cl, units=(A, B), live=(A, B), weights={A: 1.0, B: 3.0},
reserve=reserve)
return {u: round(v) for u, v in r.shares.items()}
check("⭐ B held: A carries the site target first (B's weight 3.0 ignored) "
"and B only takes what A cannot: 1500 -> A 1500 / B 0, 4000 -> A 2500 "
"/ B 1500",
split(1500.0, {B}) == {A: 1500, B: 0}
and split(4000.0, {B}) == {A: 2500, B: 1500})
check("...no one held: the weights decide, as before",
split(1500.0, set()) == {A: 375, B: 1125})
check("...charging is not touched: a held battery may still charge first",
split(-2000.0, {B}) == split(-2000.0, set()))
check("⭐ A Off and B under the reserve, a 3000 W floor B cannot place: the "
"Off battery gives first (2500 W) and B only the last 500 W - not an "
"equal 1500/1500 split",
split(3000.0, {B}, modes=(A,), floor=3000.0) == {A: 2500, B: 500})
check("...OPEN, recorded: when B alone CAN place the floor, OPT-11 keeps the "
"Off battery resting and B gives it (1500 W). See the DECISIONS entry",
split(1500.0, {B}, modes=(A,)) == {A: 0, B: 1500})
check("...and with B Off and nobody held, Off still gives way to the other "
"battery, as before (OPT-11)",
split(1500.0, set(), modes=(B,)) == {A: 1500, B: 0})
# ============================================================================ #
print("\n7. a held battery draining for maintenance")
_ctl = HD[1]
def able_w(results, socs=(15.0, 60.0)):
for inv, s in zip(_ctl.inverters, socs):
inv.soc, inv.refused = s, 0
d = _ctl._peak_on_able(dataclasses.replace(
_lim, wanted_w=6000.0), results)
return d.able_w, d.held, d.floor_w
_idle = [MaintResult(IDLE, False, None, False, [])] * 2
_drain = [MaintResult(DRAIN, True, 2000.0, False, []),
MaintResult(IDLE, False, None, False, [])]
print(f" A at 15 % idle: {able_w(_idle)}; draining 2000 W: "
f"{able_w(_drain)}")
check("⭐ A under the reserve but draining 2000 W for its monthly cycle: "
"that drain still counts (able 4500 W = B 2500 + 2000), so B is not "
"asked to carry it; idle, only B's 2500 W",
able_w(_idle) == (2500.0, (A,), 2500.0)
and able_w(_drain) == (4500.0, (A,), 4500.0))
# ============================================================================ #
print("\n8. the page while a peak alarm stands and control goes on")
_rows65, _c65 = CH65[0], CH65[1]
_c65._peak_floor_limited = True
_c65.peak_guard = dataclasses.replace(
_lim, floor_w=2500.0, held=(A,), able_w=2500.0)
_pay = _c65.status_payload()
_txt = json.dumps(_pay, ensure_ascii=False)
_row = [r for r in _pay["checks"] if r.get("id") == "peak_guard"]
print(f" banner: {_pay['banner']}; row: {_row[0]['text'] if _row else '-'}")
check("⭐ the banner is the alarm's title without 'Not ready', and nothing "
"on the page says the inverters go to 0 W or that an alarm stopped "
"control",
_pay["banner"] == messages.T_PEAK_RESERVE and _pay["level"] == "bad"
and "Not ready" not in _txt and "until the alarm clears" not in _txt
and "An alarm stopped control" not in _txt)
check("...the row names A and says the others cannot hold the limit alone, "
"in three short sentences",
len(_row) == 1 and _row[0]["text"].startswith(
"inverter-a battery at its peak reserve. The others cannot hold "
"the 3 500 W peak limit alone."))
_c65._peak_floor_limited = False
_blip = [r for r in _c65.checks() if r.get("id") == "peak_guard"]
_c65._peak_floor_limited = True
check("...and while the alarm is not latched (a one-cycle miss) the row is "
"the ordinary amber guard row, not the red reserve row",
len(_blip) == 1 and _blip[0]["warn"] and not _blip[0].get("runs")
and "peak reserve" not in _blip[0]["text"])
_c65.auto = False
_stopped = _c65.status_payload()
_c65.auto = True
check("...a stopped controller still says so: the peak alarm does not take "
"the banner from it", _stopped["banner"] != messages.T_PEAK_RESERVE
and _stopped["level"] != "bad")
# ============================================================================ #
print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
PINNED = { # tools/safety87_identity.py on b8ddfd7
"n1_under":
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
"n1_crossing":
"97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198",
"n1_above":
"45726a285aa84f40542d56482652397c3c1e212ba4fe2009f6cc52f537ccad52",
"n1_under_banked":
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
"n1_charge_low":
"91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32",
"n1_hold":
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
}
_now = s87.digests()
for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
"log) equals b8ddfd7's, under, crossing and above the reserve, in a "
"charge and a HOLD, and under a banked peak",
_now == PINNED)
# ============================================================================ #
print("\n10. review: maintenance pins beside a held battery, N=3, the page")
def _drain_store():
s = rig._Store()
s.data[unit_key(B, "phase")] = DRAIN
s.data[unit_key(B, "phase_started")] = rig.BASE.isoformat()
s.data[unit_key(A, "last_completed")] = rig.BASE.isoformat()
return s
def _drain_run():
return run(8000.0, IDLE, {"a": 15.0, "b": 60.0}, store=_drain_store(),
dur=900.0)
DR = _drain_run()
_real_dor = Controller._drain_over_reserve
Controller._drain_over_reserve = lambda self, claims, results: None
try:
DR_OLD = _drain_run()
finally:
Controller._drain_over_reserve = _real_dor
def _a_for_peak(rows):
"""A's command on the cycles where the site target is the peak floor
and B alone (2500 W) could place it."""
return [cmd(r)["a"] for r in rows
if r[-1][1] and r[-1][1][1] and r[1] <= 2500.0
and abs(r[1] - r[-1][1][1]) < 1.0]
print(f" B draining, A held: A for the peak {_a_for_peak(DR[0])[:4]}, "
f"without the raise {_a_for_peak(DR_OLD[0])[:4]}")
check("⭐ B draining (2000 W pin) while A is held: B is raised to its rating "
"for the peak floor, so A gives nothing for it (peak > drain)",
DR[1].maints[1].phase == DRAIN and _a_for_peak(DR[0])
and all(a <= 0.0 for a in _a_for_peak(DR[0])))
check("non-vacuous: without the raise A gave the floor's rest above B's pin",
any(a > 0.0 for a in _a_for_peak(DR_OLD[0])))
def _stub(names, reserve, live=None):
"""A Controller stand-in for the pin rules, on SIMULATED units."""
s = types.SimpleNamespace(
inverters=[types.SimpleNamespace(name=n) for n in names],
actuator_of=lambda src, inv=None: inv.name,
live_units=lambda: tuple(live or names),
_peak_reserve=lambda: frozenset(reserve))
s._held_pin = lambda cl, u: Controller._held_pin(s, cl, u)
return s
def _hold(floor, names, reserve, extra=(), results=None):
"""`_peak_over_hold()`: x HOLDs; returns (x's pin, resolved shares)."""
s = _stub(names, reserve)
cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=n)
for n in names]
cl += [Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator="x"),
Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"),
*extra]
res = results or ([MaintResult(HOLD, True, 0.0, False, [])]
+ [MaintResult(IDLE, False, None, False, [])]
* (len(names) - 1))
hi = Controller._peak_over_hold(s, cl, res)
if hi is not None:
cl.append(Claim.limit("hold", P_MAINTENANCE, hi=hi))
r = resolve(cl + [Claim.set("loop", 10, floor)], units=tuple(names),
live=set(names), reserve=reserve)
pin = next((c.value for c in cl
if c.source == "maintenance" and c.actuator == "x"), None)
return pin, {u: round(v) for u, v in r.shares.items()}
_h3 = _hold(3000.0, ("x", "y", "z"), {"z"})
_h3b = _hold(2000.0, ("x", "y", "z"), {"z"})
print(f" N=3 HOLD x, y able, z held: floor 3000 {_h3}, 2000 {_h3b}")
check("⭐ HOLD at N=3 with one held: the able battery gives first, the "
"holding one the rest (3000 -> y 2500, x 500), the held one nothing",
_h3 == (500.0, {"x": 500, "y": 2500, "z": 0})
and _h3b == (0.0, {"x": 0, "y": 2000, "z": 0}))
_hd = _hold(2000.0, ("x", "z"), {"z"},
(Claim.set("maintenance", P_MAINTENANCE, 1500.0, DRAIN,
actuator="z"),),
[MaintResult(HOLD, True, 0.0, False, []),
MaintResult(DRAIN, True, 1500.0, False, [])])
_hp = _hold(2000.0, ("x", "z"), {"z"},
(Claim.set("refused", P_SAFETY, -50.0, "probe", actuator="z"),))
print(f" held z draining 1500: {_hd}; held z charge probe: {_hp}")
check("⭐ a held battery's own pin counts: draining 1500 W, the hold gives "
"only the 500 W rest (no over-placement); a charge probe leaves the "
"hold at 0 W (never split both ways, no park)",
_hd == (500.0, {"x": 500, "z": 1500}) and _hp[0] == 0.0)
_unk = three((60.0, 60.0, 60.0), (10, None, 10))
_unr = three((60.0, None, 60.0), (10, 10, 10))
check("⭐ N=3: an unknown cut-out or an unread level holds that battery "
"only; the two others are asked for the need",
_unk.held == ("y",) and not _unk.floor_limited
and _unk.floor_w == 2000.0 and _unr.held == ("y",)
and not _unr.floor_limited and _unr.floor_w == 2000.0)
_stop_row = {"ok": False, "warn": False, "id": "meter", "text": "Meter down."}
_peak_rows = [r for r in _c65.checks() if r.get("id") == "peak_guard"]
_c65.checks = lambda: _peak_rows[:1] + [_stop_row]
_both = _c65.status_payload()
del _c65.checks
check("⭐ a row that stops control outranks the running peak alarm: the "
"banner says 'Not ready' for it and the Home page shows the hold",
len(_peak_rows) == 1 and _peak_rows[0].get("runs")
and _both["banner"].startswith(messages.NOT_READY)
and "Meter down" in _both["banner"]
and "until the alarm clears" in json.dumps(_both, ensure_ascii=False))
for _inv, _s, _r in zip(_ctl2.inverters, (15.0, 60.0), (0, -1)):
_inv.soc, _inv.refused = _s, _r # B refuses only to CHARGE
_chg = _ctl2._peak_on_able(_lim_all, [MaintResult(IDLE, False, None, False,
[])] * 2)
_ctl2.inverters[1].refused = 0
check("⭐ a battery that refuses only to charge can still give: it stays "
"able and carries the floor (2000 W of 2500 W)",
not _chg.floor_limited and _chg.floor_w == 2000.0
and _chg.able_w == 2500.0 and _chg.held == (A,))
def _short_for(held_w, secs=30.0):
"""`_reserve_short()` over `secs` of a steady quarter: the meter at
`held_w` with the able batteries already at their full 2500 W."""
s = types.SimpleNamespace(_reserve_batt_prev=None,
_reserve_high_since=None,
_peak_floor_limited=False, site_batt_w=2500.0)
g = dataclasses.replace(_lim, floor_w=2500.0, able_w=2500.0)
out = []
for k in range(int(secs / 5.0) + 1):
s._peak_proj = types.SimpleNamespace(
block_start=rig.BASE, elapsed_s=450.0 + 5.0 * k,
projected_w=held_w, held_w=held_w)
out.append(Controller._reserve_short(s, g))
return out
_aim_miss, _limit_miss = _short_for(3480.0), _short_for(3600.0)
check("⭐ the reserve alarm is graded on the real limit, not the aim: a "
"quarter ending at 3480 W (over the 3450 W aim, under 3500 W) never "
"alarms; one ending at 3600 W alarms once it holds ALARM_HOLD_S",
not any(_aim_miss) and not any(_limit_miss[:2])
and all(_limit_miss[2:]))
_runs_src = {}
for _f in ("main.py", "homeview.py"):
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
_src = _fh.read()
for _part in _src.split('out.append({')[1:]:
if '"runs": True' in _part.split("})", 1)[0]:
_runs_src.setdefault(_f, []).append(
_part.split('"id": "', 1)[-1].split('"', 1)[0])
check("⭐ 'control still runs' is set on the two peak rows only (saturated "
"and reserve); no row that stops control carries it",
_runs_src == {"main.py": ["peak_guard", "peak_guard"]})
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-87 checks passed")
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""SAFETY-87: at one inverter, is the peak guard byte-identical to before?
python3 tools/safety87_identity.py # prints one digest per scenario
safety84_identity's harness (the REAL Controller on a P1 meter, every claim
and the guard's decision hashed) with the battery under, crossing and above
the peak reserve, the house over the aim, in a maintenance charge and HOLD,
and under a month peak banked at 4000 W. Uses only the API release/1.0
b8ddfd7 has, so the same file runs on both trees; test_safety87.py holds this
tree to the digests printed there.
"""
import os
import sys
from datetime import timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
DUR = 1800.0
def crossing(u, t):
"""30 % falling 1 % a minute to 12 %: crosses the 20 % reserve."""
return max(12.0, 30.0 - t / 60.0)
def store(phase=None):
"""In `phase` from the first cycle, or idle with a cycle done yesterday."""
s = rig._Store()
if phase is None:
s.data["last_completed"] = (rig.BASE - timedelta(days=1)).isoformat()
else:
s.data["phase"], s.data["phase_started"] = phase, rig.BASE.isoformat()
return s
SCENARIOS = {
"n1_under": dict(soc=lambda u, t: 15.0, load=lambda t: 5500.0),
"n1_crossing": dict(soc=crossing, load=lambda t: 5000.0),
"n1_above": dict(soc=lambda u, t: 60.0, load=lambda t: 5500.0),
"n1_under_banked": dict(soc=lambda u, t: 15.0, load=lambda t: 6000.0,
month_peak=4000.0),
"n1_charge_low": dict(soc=lambda u, t: 11.0, load=lambda t: 5000.0,
store="charge"),
"n1_hold": dict(soc=lambda u, t: 99.0, load=lambda t: 5500.0,
store="hold", cheap=False,
extra={"maintenance_hold_min": 600}),
}
def digests() -> dict:
out = {}
for k, v in SCENARIOS.items():
v = dict(v)
st = store(v.pop("store", None))
out[k] = s84.digest(s84.run(1, dur=DUR, store=st, **v)[0])
return out
if __name__ == "__main__":
for k, v in digests().items():
print(f"{k} {v}")