SAFETY-87: peak guard uses every battery that can still give
At N>=2 a battery at or under its own peak reserve is held: it gives nothing for the peak (weight 0 while the site gives) and the guard's need is placed on the live, non-refusing batteries above theirs. The guard is floor-limited, and alarms, only when those cannot hold the limit at full power for ALARM_HOLD_S. The peak alarms no longer read "Not ready" or hold the Home page at 0 W, because control keeps running. N=1 is byte-identical. DECISIONS/SAFETY-87-guard-uses-able-batteries.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
This commit is contained in:
+101
-19
@@ -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.
|
||||
|
||||
@@ -2558,10 +2599,11 @@ 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:
|
||||
if u in hold or u not in live or inv.name in reserve:
|
||||
continue
|
||||
b = pre_resolve([c for c in claims if c.actuator == u])
|
||||
if b.contradiction:
|
||||
@@ -4021,6 +4063,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 +4600,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 +5217,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 +5239,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 +5265,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 +5275,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 +6070,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 +6099,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 +6432,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 +6489,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 +6613,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 +6628,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):
|
||||
|
||||
Reference in New Issue
Block a user