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:
co-authored by
Claude
parent
cc9bbeb73e
commit
40595e42ae
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user