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:
@@ -45,7 +45,15 @@ graded on its own floor, and an unread SoC or unknown cut-out counts as held.
|
|||||||
The min/max pairing still decides permission, over the able batteries. The
|
The min/max pairing still decides permission, over the able batteries. The
|
||||||
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
|
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
|
||||||
call-site and reduction checks still bite. What changed is only that one held
|
call-site and reduction checks still bite. What changed is only that one held
|
||||||
battery no longer stops the others.
|
battery no longer stops the others. One exception, with battery modes on: see
|
||||||
|
Open.
|
||||||
|
|
||||||
|
**Maintenance pins beside a held battery** (review). A draining battery that
|
||||||
|
may still give is raised from its drain rate towards its rating for the peak
|
||||||
|
floor, after the others (`_drain_over_reserve()`; peak > drain), else the
|
||||||
|
floor's rest landed on the held battery. In a HOLD, a held battery counts its
|
||||||
|
own pin (a drain) among the others, and a held battery pinned to charge stops
|
||||||
|
the raise (never split both ways).
|
||||||
|
|
||||||
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
|
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
|
||||||
|
|
||||||
@@ -66,7 +74,9 @@ battery no longer stops the others.
|
|||||||
says "cannot keep … alone", not "will go over", so it stays true.
|
says "cannot keep … alone", not "will go over", so it stays true.
|
||||||
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
|
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
|
||||||
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
|
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
|
||||||
before a held battery in the first case needs a third allocator tier.
|
before a held battery in the first case needs a third allocator tier. The
|
||||||
|
same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0.
|
||||||
|
Until then, with modes on, a held battery can give for the peak.
|
||||||
- The notification and row text live in `messages.py`, which has no NL layer
|
- The notification and row text live in `messages.py`, which has no NL layer
|
||||||
yet (UX-19). The Home page text, which has NL, is unchanged.
|
yet (UX-19). The Home page text, which has NL, is unchanged.
|
||||||
|
|
||||||
|
|||||||
@@ -2562,9 +2562,11 @@ class Controller:
|
|||||||
(P_PEAK), so the floor did not bind and the house imported what the
|
(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
|
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
|
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:
|
if len(self.inverters) != 1:
|
||||||
|
self._drain_over_reserve(claims, maint_results)
|
||||||
return
|
return
|
||||||
res = maint_results[0]
|
res = maint_results[0]
|
||||||
if res.phase != DRAIN or not res.owns_setpoint:
|
if res.phase != DRAIN or not res.owns_setpoint:
|
||||||
@@ -2574,6 +2576,59 @@ class Controller:
|
|||||||
if c.source == peakguard.SOURCE and c.lo > rate:
|
if c.source == peakguard.SOURCE and c.lo > rate:
|
||||||
claims[i] = dataclasses.replace(c, priority=P_MAINTENANCE)
|
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):
|
def _peak_over_hold(self, claims, maint_results):
|
||||||
"""SAFETY-86: a peak floor outranks a maintenance HOLD (0 W).
|
"""SAFETY-86: a peak floor outranks a maintenance HOLD (0 W).
|
||||||
|
|
||||||
@@ -2603,7 +2658,13 @@ class Controller:
|
|||||||
others = 0.0
|
others = 0.0
|
||||||
for inv in self.inverters:
|
for inv in self.inverters:
|
||||||
u = self.actuator_of("maintenance", inv)
|
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
|
continue
|
||||||
b = pre_resolve([c for c in claims if c.actuator == u])
|
b = pre_resolve([c for c in claims if c.actuator == u])
|
||||||
if b.contradiction:
|
if b.contradiction:
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ import opt11_identity as rig # noqa: E402
|
|||||||
import safety84_identity as s84 # noqa: E402
|
import safety84_identity as s84 # noqa: E402
|
||||||
import safety87_identity as s87 # noqa: E402
|
import safety87_identity as s87 # noqa: E402
|
||||||
from app import messages, packmode, peakguard # noqa: E402
|
from app import messages, packmode, peakguard # noqa: E402
|
||||||
from app.arbiter import (Claim, P_MODE, P_PEAK, P_SAFETY, # noqa: E402
|
from app.arbiter import (Claim, P_MAINTENANCE, P_MODE, P_PEAK, # noqa: E402
|
||||||
UnitBounds, resolve)
|
P_SAFETY, UnitBounds, resolve)
|
||||||
from app.main import Controller # noqa: E402
|
from app.main import Controller # noqa: E402
|
||||||
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402
|
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402
|
||||||
from app.store import unit_key # noqa: E402
|
from app.store import unit_key # noqa: E402
|
||||||
@@ -540,6 +540,122 @@ check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
|
|||||||
"charge and a HOLD, and under a banked peak",
|
"charge and a HOLD, and under a banked peak",
|
||||||
_now == PINNED)
|
_now == PINNED)
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n10. review: maintenance pins beside a held battery, N=3, the page")
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_store():
|
||||||
|
s = rig._Store()
|
||||||
|
s.data[unit_key(B, "phase")] = DRAIN
|
||||||
|
s.data[unit_key(B, "phase_started")] = rig.BASE.isoformat()
|
||||||
|
s.data[unit_key(A, "last_completed")] = rig.BASE.isoformat()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_run():
|
||||||
|
return run(8000.0, IDLE, {"a": 15.0, "b": 60.0}, store=_drain_store(),
|
||||||
|
dur=900.0)
|
||||||
|
|
||||||
|
|
||||||
|
DR = _drain_run()
|
||||||
|
_real_dor = Controller._drain_over_reserve
|
||||||
|
Controller._drain_over_reserve = lambda self, claims, results: None
|
||||||
|
try:
|
||||||
|
DR_OLD = _drain_run()
|
||||||
|
finally:
|
||||||
|
Controller._drain_over_reserve = _real_dor
|
||||||
|
|
||||||
|
|
||||||
|
def _a_for_peak(rows):
|
||||||
|
"""A's command on the cycles where the site target is the peak floor
|
||||||
|
and B alone (2500 W) could place it."""
|
||||||
|
return [cmd(r)["a"] for r in rows
|
||||||
|
if r[-1][1] and r[-1][1][1] and r[1] <= 2500.0
|
||||||
|
and abs(r[1] - r[-1][1][1]) < 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
print(f" B draining, A held: A for the peak {_a_for_peak(DR[0])[:4]}, "
|
||||||
|
f"without the raise {_a_for_peak(DR_OLD[0])[:4]}")
|
||||||
|
check("⭐ B draining (2000 W pin) while A is held: B is raised to its rating "
|
||||||
|
"for the peak floor, so A gives nothing for it (peak > drain)",
|
||||||
|
DR[1].maints[1].phase == DRAIN and _a_for_peak(DR[0])
|
||||||
|
and all(a <= 0.0 for a in _a_for_peak(DR[0])))
|
||||||
|
check("non-vacuous: without the raise A gave the floor's rest above B's pin",
|
||||||
|
any(a > 0.0 for a in _a_for_peak(DR_OLD[0])))
|
||||||
|
|
||||||
|
|
||||||
|
def _stub(names, reserve, live=None):
|
||||||
|
"""A Controller stand-in for the pin rules, on SIMULATED units."""
|
||||||
|
s = types.SimpleNamespace(
|
||||||
|
inverters=[types.SimpleNamespace(name=n) for n in names],
|
||||||
|
actuator_of=lambda src, inv=None: inv.name,
|
||||||
|
live_units=lambda: tuple(live or names),
|
||||||
|
_peak_reserve=lambda: frozenset(reserve))
|
||||||
|
s._held_pin = lambda cl, u: Controller._held_pin(s, cl, u)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _hold(floor, names, reserve, extra=(), results=None):
|
||||||
|
"""`_peak_over_hold()`: x HOLDs; returns (x's pin, resolved shares)."""
|
||||||
|
s = _stub(names, reserve)
|
||||||
|
cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=n)
|
||||||
|
for n in names]
|
||||||
|
cl += [Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator="x"),
|
||||||
|
Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"),
|
||||||
|
*extra]
|
||||||
|
res = results or ([MaintResult(HOLD, True, 0.0, False, [])]
|
||||||
|
+ [MaintResult(IDLE, False, None, False, [])]
|
||||||
|
* (len(names) - 1))
|
||||||
|
hi = Controller._peak_over_hold(s, cl, res)
|
||||||
|
if hi is not None:
|
||||||
|
cl.append(Claim.limit("hold", P_MAINTENANCE, hi=hi))
|
||||||
|
r = resolve(cl + [Claim.set("loop", 10, floor)], units=tuple(names),
|
||||||
|
live=set(names), reserve=reserve)
|
||||||
|
pin = next((c.value for c in cl
|
||||||
|
if c.source == "maintenance" and c.actuator == "x"), None)
|
||||||
|
return pin, {u: round(v) for u, v in r.shares.items()}
|
||||||
|
|
||||||
|
|
||||||
|
_h3 = _hold(3000.0, ("x", "y", "z"), {"z"})
|
||||||
|
_h3b = _hold(2000.0, ("x", "y", "z"), {"z"})
|
||||||
|
print(f" N=3 HOLD x, y able, z held: floor 3000 {_h3}, 2000 {_h3b}")
|
||||||
|
check("⭐ HOLD at N=3 with one held: the able battery gives first, the "
|
||||||
|
"holding one the rest (3000 -> y 2500, x 500), the held one nothing",
|
||||||
|
_h3 == (500.0, {"x": 500, "y": 2500, "z": 0})
|
||||||
|
and _h3b == (0.0, {"x": 0, "y": 2000, "z": 0}))
|
||||||
|
_hd = _hold(2000.0, ("x", "z"), {"z"},
|
||||||
|
(Claim.set("maintenance", P_MAINTENANCE, 1500.0, DRAIN,
|
||||||
|
actuator="z"),),
|
||||||
|
[MaintResult(HOLD, True, 0.0, False, []),
|
||||||
|
MaintResult(DRAIN, True, 1500.0, False, [])])
|
||||||
|
_hp = _hold(2000.0, ("x", "z"), {"z"},
|
||||||
|
(Claim.set("refused", P_SAFETY, -50.0, "probe", actuator="z"),))
|
||||||
|
print(f" held z draining 1500: {_hd}; held z charge probe: {_hp}")
|
||||||
|
check("⭐ a held battery's own pin counts: draining 1500 W, the hold gives "
|
||||||
|
"only the 500 W rest (no over-placement); a charge probe leaves the "
|
||||||
|
"hold at 0 W (never split both ways, no park)",
|
||||||
|
_hd == (500.0, {"x": 500, "z": 1500}) and _hp[0] == 0.0)
|
||||||
|
|
||||||
|
_unk = three((60.0, 60.0, 60.0), (10, None, 10))
|
||||||
|
_unr = three((60.0, None, 60.0), (10, 10, 10))
|
||||||
|
check("⭐ N=3: an unknown cut-out or an unread level holds that battery "
|
||||||
|
"only; the two others are asked for the need",
|
||||||
|
_unk.held == ("y",) and not _unk.floor_limited
|
||||||
|
and _unk.floor_w == 2000.0 and _unr.held == ("y",)
|
||||||
|
and not _unr.floor_limited and _unr.floor_w == 2000.0)
|
||||||
|
|
||||||
|
_stop_row = {"ok": False, "warn": False, "id": "meter", "text": "Meter down."}
|
||||||
|
_peak_rows = [r for r in _c65.checks() if r.get("id") == "peak_guard"]
|
||||||
|
_c65.checks = lambda: _peak_rows[:1] + [_stop_row]
|
||||||
|
_both = _c65.status_payload()
|
||||||
|
del _c65.checks
|
||||||
|
check("⭐ a row that stops control outranks the running peak alarm: the "
|
||||||
|
"banner says 'Not ready' for it and the Home page shows the hold",
|
||||||
|
len(_peak_rows) == 1 and _peak_rows[0].get("runs")
|
||||||
|
and _both["banner"].startswith(messages.NOT_READY)
|
||||||
|
and "Meter down" in _both["banner"]
|
||||||
|
and "until the alarm clears" in json.dumps(_both, ensure_ascii=False))
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||||
|
|||||||
Reference in New Issue
Block a user