SAFETY-87 review: maintenance pins beside a held battery
checks / unit (pull_request) Successful in 26m5s
infra-pin / fresh (pull_request) Successful in 1s

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:
Claude
2026-10-01 10:50:01 +02:00
parent d89c093318
commit aa501ffec7
3 changed files with 193 additions and 6 deletions
+63 -2
View File
@@ -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: