SAFETY-126: a landing error on the aim is not an overrun

During the night grid charge under the peak reserve the guard alarmed
(ERROR PEAK_GUARD_FLOOR_LIMITED, "Battery stopped limiting the peak") and the
Batteries "Why" said "The peak limit needs power" at quarter ends, while every
quarter landed on the aim. The charge lands the period 0-7 W over the aim and
excess_w()'s 900 / remaining turns that into tens of kW in the last second;
with nothing able to give, check_peak_floor_limited() alarmed on any
wanted > 0.

Each cycle the guard wants power, _peak_landing() asks misses_aim(): with the
batteries at 0 W, does the period end more than ALARM_MISS_W over the aim? If
not, the decision is within_aim: no reserve alarm, no red peak row, no "Why"
peak. The claim and the commands are unchanged. A real overrun under the
reserve alarms on the same cycle as before.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-31 01:26:02 +01:00
co-authored by Claude
parent cc9bbeb73e
commit 40595e42ae
8 changed files with 526 additions and 8 deletions
+4 -2
View File
@@ -818,9 +818,11 @@ def refusing(c, inv) -> int:
def peak_engaged(c) -> bool:
"""The peak guard asks the batteries for power this cycle. Not when it is
blind or floor-limited: those are engaged with a 0 W floor (no charging
only), so no battery gives power for it."""
only), so no battery gives power for it. Nor when the quarter ends on
the aim anyway (SAFETY-126, `within_aim`)."""
pg = getattr(c, "peak_guard", None)
return bool(pg is not None and pg.engaged and (pg.floor_w or 0.0) > 0.0)
return bool(pg is not None and pg.engaged and (pg.floor_w or 0.0) > 0.0
and not pg.within_aim)
def battery_rows(c, shadow, running, held=False):
+33 -3
View File
@@ -950,6 +950,9 @@ class Controller:
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
self._reserve_batt_prev = None
self._reserve_high_since = None
# SAFETY-126: `_peak_landing()`'s last battery reading and latch.
self._land_batt_prev = None
self._peak_needed = False
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
@@ -2989,6 +2992,27 @@ class Controller:
soc=min(float(p.soc) for p in able),
soc_floor=peakguard.binding_soc_floor(able, floor))
def _peak_landing(self, decision):
"""SAFETY-126: `decision` marked `within_aim` while the guard wants
power but the batteries at 0 W would end the quarter within
`ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). Once a real miss
is seen, the need stands until the guard lets go. The claim and the
commands are unchanged; only the alarms and the "Why" read it.
DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md."""
batt, prev = self.site_batt_w, self._land_batt_prev
self._land_batt_prev = batt
if not (decision.engaged and decision.blind is None
and (decision.wanted_w or 0.0) > 0.0):
self._peak_needed = False
return decision
if batt is not None and prev is not None:
batt = min(float(batt), float(prev))
self._peak_needed = self._peak_needed or (
self._peak_proj is not None and peakguard.misses_aim(
self._peak_proj, batt_w=batt, target_w=decision.target_w))
return (decision if self._peak_needed
else dataclasses.replace(decision, within_aim=True))
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."""
@@ -4845,6 +4869,7 @@ class Controller:
if self.peak_guard is not None:
self.peak_guard = self._peak_on_able(self.peak_guard,
maint_results)
self.peak_guard = self._peak_landing(self.peak_guard)
# 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
@@ -4869,6 +4894,7 @@ class Controller:
self.log_event(_pg.explain(), hint=messages.hint(
"peak_off" if not _pg.engaged else
"peak_blind" if _pg.blind else
"peak_on" if _pg.within_aim else
"peak_reserve" if _pg.floor_limited else
"peak_saturated" if _pg.saturated else "peak_on"))
@@ -6166,6 +6192,9 @@ class Controller:
limited = self._reserve_short(g)
else:
self._reserve_batt_prev = self._reserve_high_since = None
# SAFETY-126: nothing can give, so it alarms at once, but only
# when the quarter really misses the aim (`_peak_landing()`).
limited = limited and not g.within_aim
was, self._peak_floor_limited = self._peak_floor_limited, limited
if not limited or was:
return
@@ -7043,12 +7072,13 @@ class Controller:
if _pg is None:
out.append({"ok": True, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_waiting()})
elif _pg.saturated:
elif _pg.saturated and not _pg.within_aim:
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
"text": messages.row_peak_saturated(_pg.ceiling_w)})
elif _pg.floor_limited and (not (_pg.floor_w or 0.0) > 0.0
or self._peak_floor_limited):
elif _pg.floor_limited and (
not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim)
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
+17
View File
@@ -504,6 +504,11 @@ class PeakDecision:
# when they are not asked). See `place_on_able()`.
held: tuple = ()
able_w: float | None = None
# SAFETY-126: the guard wants power, but with the batteries at 0 W the
# quarter still ends within ALARM_MISS_W of the aim (`misses_aim()`), as
# for a landing error in its last second. Set by main.py. No alarm and no
# "Why" peak while it holds; the floor and the claim are unchanged.
within_aim: bool = False
def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
@@ -997,6 +1002,18 @@ def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool:
return ends_w - float(ceiling_w) > ALARM_MISS_W
def misses_aim(projection, *, batt_w, target_w) -> bool:
"""SAFETY-126. With the batteries at 0 W from now on, does the quarter
end more than `ALARM_MISS_W` over the aim (`target_w`)? `decide()`'s
`wanted > 0` asks the same question with no margin and with its gain, so a
1 W landing error in a quarter's last second reads as tens of kW. A grid
charge the add-on runs counts as stoppable: it is in `batt_w`. The caller
passes the lower of two cycles' battery readings, as for
`load_too_high()`. DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md."""
return load_too_high(projection, batt_w=batt_w, ceiling_w=target_w,
achievable_w=0.0)
def charge_headroom_w(decision) -> float | None:
"""SAFETY-45. The hardest this SITE may charge without putting the billed
quarter over the ceiling, in W, positive - or None when nothing measured it.