Merge release/1.0 (SAFETY-81, #155) into opt11-p4-solar-forecast
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -929,6 +929,11 @@ class Controller:
|
||||
"peak_margin_w", peakguard.PEAK_OPTION_DEFAULTS["peak_margin_w"]))
|
||||
self._peak_engaged = False
|
||||
self._peak_saturated = False
|
||||
# SAFETY-81: what the alarm is graded on, beside the guard's decision.
|
||||
self._peak_proj = None
|
||||
self._peak_batt_prev = None
|
||||
self._peak_alarm_block = None
|
||||
self._peak_high_since = None
|
||||
self._peak_floor_limited = False
|
||||
self._maint_shape_suspended = False
|
||||
# OPT-11. Battery modes, off by default so an install that has not
|
||||
@@ -3821,8 +3826,10 @@ class Controller:
|
||||
# is not measured on that path, and the honest report is NOT COVERED -
|
||||
# the same answer, for the same reason, that the status page already
|
||||
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
|
||||
self._peak_proj = (None if not self.p1_enabled
|
||||
else self.peak.project(self.p1.averager, now))
|
||||
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
|
||||
self.peak.project(self.p1.averager, now),
|
||||
self._peak_proj,
|
||||
# ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is
|
||||
# ALREADY removing from the meter, and the meter sees every
|
||||
# inverter. One unit's power here would size the floor from a
|
||||
@@ -4925,22 +4932,56 @@ class Controller:
|
||||
available to it. That is the one state where the money is going to be
|
||||
spent and a human is the only remaining actuator.
|
||||
|
||||
SAFETY-81: `g.saturated` alone is not enough. It is graded against the
|
||||
aim, and a 1 s meter spike, noise or the last seconds of a quarter set
|
||||
it while the quarter lands on target; `peakguard.load_too_high()` must
|
||||
agree too, for `ALARM_HOLD_S`. The battery reading it gets is the lower
|
||||
of this cycle's and the last one's - see there.
|
||||
|
||||
Edge-triggered, and de-asserting arms it again - the same shape as
|
||||
FreezeWatchdog's `evaluate()`, and for the same reason: one notification
|
||||
per episode, not one per cycle.
|
||||
per episode, not one per cycle. SAFETY-81: the notification and action
|
||||
log line at most once per quarter; the ERROR line once per episode.
|
||||
"""
|
||||
g = self.peak_guard
|
||||
saturated = bool(g is not None and g.saturated)
|
||||
batt, prev = self.site_batt_w, self._peak_batt_prev
|
||||
if batt is None:
|
||||
# Unknown, not "fine": the hold below neither grows nor restarts.
|
||||
return
|
||||
self._peak_batt_prev = batt
|
||||
if prev is not None:
|
||||
batt = min(float(batt), float(prev))
|
||||
p = self._peak_proj
|
||||
high = bool(
|
||||
g is not None and g.saturated and 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=self.site_rating_w))
|
||||
# SAFETY-81: and for ALARM_HOLD_S, on the projection's clock.
|
||||
saturated = False
|
||||
if high:
|
||||
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
|
||||
if self._peak_high_since is None:
|
||||
self._peak_high_since = t
|
||||
saturated = ((t - self._peak_high_since).total_seconds()
|
||||
>= peakguard.ALARM_HOLD_S)
|
||||
else:
|
||||
self._peak_high_since = None
|
||||
was, self._peak_saturated = self._peak_saturated, saturated
|
||||
if not saturated or was:
|
||||
return
|
||||
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
|
||||
hint=messages.hint("peak_saturated"))
|
||||
_LOG.error(
|
||||
"Switch off large loads, the battery alone cannot keep this "
|
||||
"15-minute period under the peak limit: %s.",
|
||||
messages.reason_code(g.reason))
|
||||
_LOG.debug("Evidence: %s.", g.reason)
|
||||
# SAFETY-81: notified once per quarter; a later episode is logged only.
|
||||
if p.block_start == self._peak_alarm_block:
|
||||
return
|
||||
self._peak_alarm_block = p.block_start
|
||||
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
|
||||
hint=messages.hint("peak_saturated"))
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||
# is not a frozen meter and is not an out-of-range setpoint.
|
||||
|
||||
@@ -866,6 +866,40 @@ def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc,
|
||||
if saturated else "")))
|
||||
|
||||
|
||||
# SAFETY-81. A quarter must end this many W over the real limit before
|
||||
# "Load too high for battery" fires. Above the guard's own landing error at
|
||||
# margin 0 (up to +5.7 W, SAFETY-79 test check 5), which margin 0 accepts.
|
||||
ALARM_MISS_W = 10.0
|
||||
# ...and for this long without a break, so a load that peaks for a few seconds
|
||||
# (a hob or heater cycling) is not read as the rest of the quarter.
|
||||
ALARM_HOLD_S = 10.0
|
||||
|
||||
|
||||
def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool:
|
||||
"""SAFETY-81. May "Load too high for battery" fire? Only if, with the
|
||||
battery at full power from now on, the quarter still ends more than
|
||||
`ALARM_MISS_W` over the real limit (`ceiling_w`, not the aim).
|
||||
|
||||
This is the miss itself, in W of the quarter average, so it does not have
|
||||
`decide()`'s gain (900 / remaining): near a quarter's end that gain turns
|
||||
a watt over the aim into thousands of W wanted.
|
||||
|
||||
The load is the held meter reading plus `batt_w`. The caller passes the
|
||||
lower of this cycle's and the last cycle's battery reading, so a meter
|
||||
sample taken before the battery's last increase is not counted twice
|
||||
(DECISIONS/SAFETY-81-load-too-high.md). A real overload shows on the meter
|
||||
before the battery moves, so that filter costs it no cycle.
|
||||
"""
|
||||
remaining = QUARTER_S - float(projection.elapsed_s)
|
||||
if batt_w is None or remaining <= 0.0:
|
||||
return False
|
||||
rest = float(projection.held_w) + float(batt_w) - float(achievable_w)
|
||||
done_ws = (QUARTER_S * float(projection.projected_w)
|
||||
- max(float(projection.held_w), 0.0) * remaining)
|
||||
ends_w = (done_ws + max(rest, 0.0) * remaining) / QUARTER_S
|
||||
return ends_w - float(ceiling_w) > ALARM_MISS_W
|
||||
|
||||
|
||||
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