SAFETY-86: peak guard outranks the maintenance hold
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:
co-authored by
Claude Opus 5.5
parent
513183aba3
commit
ba31e7c8b1
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user