SAFETY-86: peak guard outranks the maintenance hold
checks / unit (pull_request) Successful in 24m17s
infra-pin / fresh (pull_request) Successful in 1s

During a HOLD the holding battery gives what keeps the quarter at the peak
aim. N=1: the peak floor is refiled at P_MAINTENANCE, as for a drain
(SAFETY-83). N>=2: the hold's pin is raised to the floor minus what the
other live batteries may place, so they give first. A site stop still
decides alone. The hold ends on its clock even if the level dipped.
Below the aim nothing changes (rows identical).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-01 00:56:34 +02:00
co-authored by Claude Opus 5.5
parent 513183aba3
commit ba31e7c8b1
8 changed files with 473 additions and 13 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ P_PEAK = 50 # capacity-tariff peak shaving - costs real money
# The order in effect: safety limits and the SAFETY-73 refusal > the peak
# guard > maintenance > modes > trade > loop. A peak floor above a drain still
# binds: the `drain` site floor at N>=2 (SAFETY-78), refiled at P_MAINTENANCE
# at N=1 (SAFETY-83). Not a maintenance HOLD at N=1 (DECISIONS/SAFETY-83).
# at N=1 (SAFETY-83); over a HOLD, `_peak_over_hold()` (SAFETY-86).
# A cheap-window charge is suspended while the peak guard holds and, at N>=2,
# sized to the quarter's headroom (SAFETY-84).
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
+48 -2
View File
@@ -74,7 +74,7 @@ from .hass import HomeAssistant
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
DRAIN, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
arbitrate as maint_arbitrate,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
@@ -2533,6 +2533,50 @@ class Controller:
if c.source == peakguard.SOURCE and c.lo > rate:
claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE)
def _peak_over_hold(self, claims, maint_results) -> None:
"""SAFETY-86: a peak floor outranks a maintenance HOLD (0 W).
At N=1 the floor is refiled at P_MAINTENANCE, as for a drain. At N>=2
the holding pack gives only what the others cannot: its pin is raised
to the floor minus what they may place. A site stop still decides
alone. DECISIONS/SAFETY-86-peak-over-hold.md.
"""
hold = {self.actuator_of("maintenance", inv)
for inv, res in zip(self.inverters, maint_results)
if res.phase == HOLD and res.owns_setpoint}
peak = [i for i, c in enumerate(claims)
if c.source == peakguard.SOURCE and c.lo > 0.0]
if not hold or not peak:
return
if len(self.inverters) == 1:
for i in peak:
claims[i] = dataclasses.replace(claims[i],
priority=P_MAINTENANCE)
return
if any(c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_PEAK for c in claims):
return
live = set(self.live_units())
others = 0.0
for inv in self.inverters:
u = self.actuator_of("maintenance", inv)
if u in hold or u not in live:
continue
b = pre_resolve([c for c in claims if c.actuator == u])
if b.contradiction:
continue
p = allocator.pin_w(b)
if p is not None and p < 0.0:
return # a pack pinned to charge: never split both ways
others += allocator.hi_w(b) if p is None else p
give = max(claims[i].lo for i in peak) - others
if give <= 0.0:
return
for i, c in enumerate(claims):
if c.source == "maintenance" and c.kind == SET \
and c.actuator in hold:
claims[i] = dataclasses.replace(c, value=give)
def _charge_headroom(self, floor: float, claims) -> float:
"""SAFETY-84: at N>=2 a cheap-window charge pulls no more than the
quarter's headroom under the peak aim (`charge_headroom_w()`), so it
@@ -3905,7 +3949,8 @@ class Controller:
# business, not this block's: a maintenance DRAIN or HOLD owns the
# setpoint at P_MAINTENANCE, and a maintenance CHARGE is only ever a
# P_MAINT_SHAPE limit (40), bound by this floor. A floor above the drain
# rate still wins (SAFETY-78 at N>=2, `_peak_over_drain()` at N=1).
# rate still wins (SAFETY-78 at N>=2, `_peak_over_drain()` at N=1), and
# so does a floor over a HOLD (`_peak_over_hold()`, SAFETY-86).
#
# ⚠️ AND IT IS NOT RUN AT ALL WITHOUT P1. `meter_source: off` is the
# legacy single-entity path: there is no telegram stream, so
@@ -4307,6 +4352,7 @@ class Controller:
reason="maintenance drain",
actuator=self.actuator_of("drain")))
self._peak_over_drain(claims, maint_results)
self._peak_over_hold(claims, maint_results)
# --- SAFETY-84: a cheap-window charge is a ceiling over the site ----
charge = self._charge_ceiling(claims)
if charge is not None: