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:
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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):
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user