SAFETY-87 review: maintenance pins beside a held battery
A draining battery that may still give is raised from its drain rate towards its rating for the peak floor while another battery is held (peak > drain); before, the floor's rest landed on the held battery. In a HOLD, a held battery's own pin counts among the others: a held drain no longer over-places the site, and a held charge probe no longer parks it. Tests: test_safety87.py part 10 (drain beside a held battery, N=3 HOLD, unknown cut-out and unread level at N=3, a stopping row outranks the running peak alarm). DECISIONS entry updated, incl. the modes-on exception. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
This commit is contained in:
@@ -2562,9 +2562,11 @@ class Controller:
|
||||
(P_PEAK), so the floor did not bind and the house imported what the
|
||||
drain left. Where the floor asks for more than the drain rate, it is
|
||||
refiled at P_MAINTENANCE and wins; nothing else changes. Safety claims
|
||||
(P_SAFETY) still outrank it. At N>=2 the `drain` floor does this.
|
||||
(P_SAFETY) still outrank it. At N>=2 the `drain` floor does this, and
|
||||
`_drain_over_reserve()` when a battery is held at its reserve.
|
||||
"""
|
||||
if len(self.inverters) != 1:
|
||||
self._drain_over_reserve(claims, maint_results)
|
||||
return
|
||||
res = maint_results[0]
|
||||
if res.phase != DRAIN or not res.owns_setpoint:
|
||||
@@ -2574,6 +2576,59 @@ class Controller:
|
||||
if c.source == peakguard.SOURCE and c.lo > rate:
|
||||
claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE)
|
||||
|
||||
def _held_pin(self, claims, u):
|
||||
"""SAFETY-87: what held unit `u` places anyway (its own pin), or None
|
||||
when that pin charges, so the site is never split both ways."""
|
||||
b = pre_resolve([c for c in claims if c.actuator == u])
|
||||
p = None if b.contradiction else allocator.pin_w(b)
|
||||
return None if p is not None and p < 0.0 else max(p or 0.0, 0.0)
|
||||
|
||||
def _drain_over_reserve(self, claims, maint_results) -> None:
|
||||
"""SAFETY-87, N>=2: while a battery is held at its peak reserve, a
|
||||
draining battery that may still give is raised from its drain rate
|
||||
towards its rating for the peak floor, after the others. Otherwise
|
||||
the floor's rest lands on the held battery (its weight 0 only orders
|
||||
the split; the drain pin binds). A site stop still decides alone."""
|
||||
reserve = self._peak_reserve()
|
||||
floor = max((c.lo for c in claims if c.source == peakguard.SOURCE),
|
||||
default=0.0)
|
||||
if not reserve or not floor > 0.0 or any(
|
||||
c.kind == SET and c.actuator == ACTUATOR_SITE
|
||||
and c.priority > P_PEAK for c in claims):
|
||||
return
|
||||
live = set(self.live_units())
|
||||
drains, others = [], 0.0
|
||||
for inv, res in zip(self.inverters, maint_results):
|
||||
u = self.actuator_of("maintenance", inv)
|
||||
if u not in live:
|
||||
continue
|
||||
if inv.name in reserve:
|
||||
p = self._held_pin(claims, u)
|
||||
if p is None:
|
||||
return
|
||||
others += p
|
||||
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
|
||||
if res.phase == DRAIN and b.pinned_by == "maintenance" \
|
||||
and p is not None and p > 0.0:
|
||||
drains.append((u, p, allocator.hi_w(b)))
|
||||
others += allocator.hi_w(b) if p is None else p
|
||||
give = floor - others
|
||||
for u, p, hi in drains:
|
||||
if give <= 0.0:
|
||||
break
|
||||
up = max(p, min(hi, p + give))
|
||||
give -= up - p
|
||||
for i, c in enumerate(claims):
|
||||
if c.source == "maintenance" and c.kind == SET \
|
||||
and c.actuator == u:
|
||||
claims[i] = dataclasses.replace(c, value=up)
|
||||
|
||||
def _peak_over_hold(self, claims, maint_results):
|
||||
"""SAFETY-86: a peak floor outranks a maintenance HOLD (0 W).
|
||||
|
||||
@@ -2603,7 +2658,13 @@ class Controller:
|
||||
others = 0.0
|
||||
for inv in self.inverters:
|
||||
u = self.actuator_of("maintenance", inv)
|
||||
if u in hold or u not in live or inv.name in reserve:
|
||||
if u in hold or u not in live:
|
||||
continue
|
||||
if inv.name in reserve: # only its own pin, never both ways
|
||||
p = self._held_pin(claims, u)
|
||||
if p is None:
|
||||
return
|
||||
others += p
|
||||
continue
|
||||
b = pre_resolve([c for c in claims if c.actuator == u])
|
||||
if b.contradiction:
|
||||
|
||||
Reference in New Issue
Block a user