Files
goodwe-addon/goodwe_controller/test_safety91.py
T
adminandClaude Opus 5.5 6872353e2e
checks / unit (pull_request) Successful in 26m19s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-91: a held battery is drawn only after the others are full
At N>=2 a battery at or under its own peak reserve now weighs 0 whenever
the site gives (not only for the peak), a draining battery's pin becomes
a floor at its drain rate so it rises before the held one gives, and a
quarter's edge is graded on the next quarter when its first telegram is
late. SAFETY-25 option A stays: the held battery is still drawn once the
others are full. N=1 unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:03:54 +02:00

384 lines
17 KiB
Python

"""Runnable check for SAFETY-91: a held battery is drawn only after the others
are full. `python3 test_safety91.py`
At N>=2 a battery at or under its peak reserve was held back only while the
peak guard asked for power. The self-consumption loop drew it at its full
rating while a draining battery stayed at its drain rate (SAFETY-87 rig test
F1), and at a quarter's edge in a HOLD the held battery gave 2500 W for one
cycle (F2). Now a held battery weighs 0 whenever the site gives, a draining
battery rises above its drain rate before it, and the edge is graded on the
next quarter. SAFETY-25 option A stands: once the others are full the loop
may still draw it. Drives the REAL Controller on a lagging battery (10 s, as
on the rig). DECISIONS/SAFETY-91-held-battery-last.md.
1. B draining, A held, house over B's rating: B rises to full before A gives.
2. A held reaching its cut-out: nothing is placed on it, B gives its rating.
3. HOLD across a quarter edge: the held battery gives nothing at the edge.
4. Idle, A held: B carries the house first; both held or B refusing: as before.
5. The rules at their boundaries (claim lists, N=3, a site stop).
6. next_quarter() at its boundaries.
7. N=1 is untouched.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import math
import os
import sys
import time
import types
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
from app import peakguard # noqa: E402
from app.arbiter import (Claim, P_MAINTENANCE, P_SAFETY, # noqa: E402
resolve)
from app.main import Controller # noqa: E402
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import QUARTER_S, Projection, next_quarter # noqa: E402
fails = []
A, B = "inverter-a", "inverter-b"
FULL = 2500.0
CUT = 10.0 # bms_soc_floor of both test batteries
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def lagging(fn, lag=10.0, tick=1.0):
"""`fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag), a
1 s meter tick, and a BMS that stops discharge at its cut-out."""
real_p, real_t = rig._Hass.power, rig.TICK
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for _ in range(int(round((self.t - tl) / 0.1))):
d = self.cmd[u] - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a))
rig._Hass.power, rig.TICK = power, tick
try:
return fn()
finally:
rig._Hass.power, rig.TICK = real_p, real_t
def store(phase_of, n=2):
"""`phase_of`: {name: phase}; the others have just done their cycle."""
s = rig._Store()
for u in (A, B)[:n]:
key = (lambda k: k) if n == 1 else (lambda k: unit_key(u, k))
if u in phase_of:
s.data[key("phase")] = phase_of[u]
s.data[key("phase_started")] = rig.BASE.isoformat()
else:
s.data[key("last_completed")] = rig.BASE.isoformat()
return s
def run(house, phase_of, socs, dur, late=0.0, kwh=15.36, n=2):
"""Rows and controller. Levels follow each battery's measured power; the
P1 telegram read at each cycle is `late` s old."""
soc, last, levels = dict(socs), [0.0], {}
def before(c, t):
for u in soc:
soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0])
/ 3600.0 / (kwh * 10.0))
last[0], levels[t] = t, dict(soc)
imp, exp = split_signed(c.hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=rig.BASE + timedelta(seconds=t - late),
ingest_mono=time.monotonic()))
rows, c = lagging(lambda: s84.run(
n, load=house if callable(house) else (lambda t: house), cheap=False,
store=store(phase_of, n), soc=lambda u, t: soc[u], before=before,
dur=dur, extra={"maintenance_discharge_w": 1000,
"maintenance_hold_min": 600}))
c.levels = levels
return rows, c
def without_fix(fn):
"""release/1.0 8c438ab: held only for the peak, a drain stays pinned, the
quarter's edge is not rolled over."""
real = (Controller._held_back, Controller._drain_rise)
import app.main as m
real_nq = m.next_quarter
Controller._held_back = lambda self: self._peak_reserve()
Controller._drain_rise = lambda self, claims, results: None
m.next_quarter = lambda p, max_hold_s: p
try:
return fn()
finally:
Controller._held_back, Controller._drain_rise = real
m.next_quarter = real_nq
def cmd(r):
return dict(r[2])
def seg(rows, lo, hi):
"""Mean A and B commands over [lo, hi) s."""
rs = [r for r in rows if lo <= r[0] < hi]
return (round(sum(cmd(r)["a"] for r in rs) / len(rs)),
round(sum(cmd(r)["b"] for r in rs) / len(rs)))
def a_before_b(rows, t0=0.0):
"""Cycles where held A is asked to give while B is not at full power."""
return sum(1 for r in rows if r[0] >= t0
and cmd(r)["a"] > 0.0 and cmd(r)["b"] < FULL)
def unmet(rows, lo, hi):
return sum(1 for r in rows if lo <= r[0] < hi and r[7] and "UNMET" in r[7])
# ============================================================================ #
print("1. B draining at 1000 W, A held at 15 %, house over B's rating")
STEPS = (800.0, 4000.0, 5500.0)
def steps(t):
return STEPS[min(int(t // 300), 2)]
DR = run(steps, {B: DRAIN}, {"a": 15.0, "b": 60.0}, 900.0)
DR_OLD = without_fix(lambda: run(steps, {B: DRAIN}, {"a": 15.0, "b": 60.0},
900.0))
for tag, (rows, c) in (("after", DR), ("before", DR_OLD)):
print(f" {tag}: house 800 A/B {seg(rows, 60, 300)}, 4000 "
f"{seg(rows, 360, 600)}, 5500 {seg(rows, 660, 900)}; A asked while "
f"B not full: {a_before_b(rows)} cycles; UNMET "
f"{unmet(rows, 0, 900)}")
check("⭐ A (held) is never asked to give while B has room: every cycle that "
"puts power on A has B at its full 2500 W",
a_before_b(DR[0]) == 0 and DR[1].maints[1].phase == DRAIN)
check("...house 4000: B rises from its 1000 W drain rate to 2500 W and A "
"gives only the 1500 W rest (before: A 2500, B 1000)",
seg(DR[0], 360, 600)[1] >= 2480 and seg(DR[0], 360, 600)[0] <= 1520
and seg(DR_OLD[0], 360, 600)[0] >= 2480
and seg(DR_OLD[0], 360, 600)[1] <= 1020)
check("...house 800: B still drains at its 1000 W rate (the rest is "
"exported, as before) and A gives nothing",
all(cmd(r)["b"] >= 1000.0 and cmd(r)["a"] == 0.0
for r in DR[0] if 60 <= r[0] < 300))
check("...house 5500 (over both ratings): option A stands, A is drawn as a "
"last resort once B is full, and nothing is left UNMET",
seg(DR[0], 660, 900) == (2500, 2500) and unmet(DR[0], 0, 900) == 0)
check("non-vacuous: before the fix A was asked while B had room",
a_before_b(DR_OLD[0]) >= 100)
# ============================================================================ #
print("\n2. the held battery reaches its cut-out (house 5500)")
CO = run(5500.0, {B: DRAIN}, {"a": 11.0, "b": 60.0}, 1200.0)
CO_OLD = without_fix(lambda: run(5500.0, {B: DRAIN}, {"a": 11.0, "b": 60.0},
1200.0))
def after_cut(rows, c):
"""Rows once A reads its cut-out (its level follows its power)."""
return [r for r in rows if c.levels[r[0]]["a"] <= CUT]
_ac, _ac_old = after_cut(*CO), after_cut(*CO_OLD)
print(f" after A's cut-out: B {seg(_ac, 0, 9e9)[1]} W over {len(_ac)} "
f"cycles (before: {seg(_ac_old, 0, 9e9)[1]} W); A soc "
f"{CO[1].inverters[0].soc:.2f} %")
check("⭐ at its cut-out nothing is placed on A (0 W on every cycle), B "
"gives its full 2500 W and nothing is left UNMET on the empty A",
CO[1].inverters[0].soc <= CUT and len(_ac) >= 100
and all(cmd(r)["a"] == 0.0 and cmd(r)["b"] == FULL for r in _ac)
and unmet(CO[0], 600, 1200) == 0)
check("non-vacuous: before the fix B stayed pinned under its rating on most "
"of those cycles (the rig saw ~2422 W)",
len(_ac_old) >= 100
and sum(1 for r in _ac_old if cmd(r)["b"] < FULL) > len(_ac_old) / 2)
# ============================================================================ #
print("\n3. HOLD across a quarter edge (A full holding, B held at 15 %)")
EDGE = run(5500.0, {A: HOLD}, {"a": 99.0, "b": 15.0}, 1000.0, late=0.5)
EDGE_OLD = without_fix(lambda: run(5500.0, {A: HOLD}, {"a": 99.0, "b": 15.0},
1000.0, late=0.5))
def at_edge(rows):
return [(r[0], cmd(r)["a"], cmd(r)["b"]) for r in rows
if QUARTER_S - 1 <= r[0] <= QUARTER_S + 1]
print(f" edge after {at_edge(EDGE[0])}, before {at_edge(EDGE_OLD[0])}")
check("⭐ the telegram at the edge comes late (0.5 s): B gives nothing on any "
"cycle (the first cycle, before any quarter is measured, aside), A "
"keeps giving and stays in its HOLD",
all(cmd(r)["b"] == 0.0 for r in EDGE[0] if r[0] > 0)
and all(a > 2000.0 for _t, a, _b in at_edge(EDGE[0]))
and EDGE[1].maints[0].phase == HOLD)
check("non-vacuous: before the fix B gave over 2000 W at the edge (F2)",
any(b > 2000.0 for _t, _a, b in at_edge(EDGE_OLD[0])))
# ============================================================================ #
print("\n4. idle, house 2000 W")
ID = run(2000.0, {}, {"a": 15.0, "b": 60.0}, 300.0)
ID_OLD = without_fix(lambda: run(2000.0, {}, {"a": 15.0, "b": 60.0}, 300.0))
print(f" A held: after {seg(ID[0], 120, 300)}, before "
f"{seg(ID_OLD[0], 120, 300)}")
check("⭐ A held, B able: B carries the house alone; A is asked only on the "
"cycles where the lagging B is already at its full command",
a_before_b(ID[0]) == 0 and seg(ID[0], 120, 300)[0] == 0
and seg(ID[0], 120, 300)[1] >= 1950)
check("non-vacuous: before the fix the weights gave A a share",
seg(ID_OLD[0], 120, 300)[0] > 50)
BOTH = run(2000.0, {}, {"a": 15.0, "b": 14.0}, 300.0)
BOTH_OLD = without_fix(lambda: run(2000.0, {}, {"a": 15.0, "b": 14.0}, 300.0))
check("...both held: nothing to give first, the split is as before",
[r[:3] for r in BOTH[0]] == [r[:3] for r in BOTH_OLD[0]])
def held_back(socs, refused=(0, 0), live=(A, B)):
c = ID[1]
for inv, s, r in zip(c.inverters, socs, refused):
inv.soc, inv.refused = s, r
c.live_units = lambda: live
try:
return c._held_back()
finally:
del c.live_units
for inv in c.inverters:
inv.refused = 0
check("...held back only while another can give: A held beside B able; "
"nothing with B refusing to give, B dropped, or both under",
held_back((15.0, 60.0)) == {A} and held_back((15.0, 60.0), (0, 1))
== frozenset() and held_back((15.0, 60.0), live=(A,)) == frozenset()
and held_back((15.0, 14.0)) == frozenset()
and held_back((15.0, 60.0), (0, -1)) == {A})
# ============================================================================ #
print("\n5. the rules at their boundaries")
def stub(names, held, live=None):
"""A Controller stand-in for `_drain_rise()`, 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),
_held_back=lambda: frozenset(held))
return s
def rise(names, held, target, drains, extra=(), live=None):
"""`_drain_rise()` then the split: (claims kinds, shares)."""
cl = [Claim.limit("supervised", P_SAFETY, -FULL, FULL, actuator=n)
for n in names]
cl += [Claim.set("maintenance", P_MAINTENANCE, w, DRAIN, actuator=n)
for n, w in drains.items()]
cl += list(extra)
res = [MaintResult(DRAIN, True, drains[n], False, []) if n in drains
else MaintResult(IDLE, False, None, False, []) for n in names]
Controller._drain_rise(stub(names, held, live), cl, res)
r = resolve(cl + [Claim.set("loop", 10, target)], units=tuple(names),
live=set(live or names), reserve=held)
kinds = {c.actuator: c.kind for c in cl if c.source == "maintenance"}
return kinds, {u: round(v) for u, v in r.shares.items()}
_n3 = rise(("x", "y", "z"), {"z"}, 1500.0, {"x": 1000.0})
print(f" N=3, x draining 1000, y able, z held, 1500 W: {_n3[1]}")
check("⭐ N=3: the draining battery keeps its drain rate as a floor (x >= "
"1000 W) while sharing with the able one; the held one gives nothing",
_n3[0] == {"x": "limit"} and _n3[1]["x"] >= 1000 and _n3[1]["z"] == 0
and sum(_n3[1].values()) == 1500)
_stop = rise((A, B), {A}, 3000.0, {B: 1000.0},
(Claim.set("safety", P_SAFETY, 0.0, "stopped"),))
check("⭐ a site stop decides alone: the drain stays a pin, nothing rises",
_stop[0] == {B: "set"} and _stop[1] == {A: 0, B: 0})
_hd = rise((A, B), {A}, 5000.0, {A: 1000.0})
check("...a held battery's own drain stays a pin (it is not raised for the "
"loop): A 1000 W, B 2500 W",
_hd[0] == {A: "set"} and _hd[1] == {A: 1000, B: 2500})
_nh = rise((A, B), set(), 3000.0, {B: 1000.0})
check("...with nothing held the drain stays a pin, as before (SAFETY-78)",
_nh[0] == {B: "set"} and _nh[1] == {A: 2000, B: 1000})
_dead = rise((A, B), {A}, 3000.0, {B: 1000.0}, live=(A,))
check("...a draining battery that is not live is not touched",
_dead[0] == {B: "set"})
# ============================================================================ #
print("\n6. next_quarter()")
T0 = rig.BASE
def proj(elapsed, covered=1.0, held=3000.0, start=T0):
return Projection(projected_w=2000.0, elapsed_w=1900.0, elapsed_s=elapsed,
covered_frac=covered, complete=covered >= 1.0,
block_start=start, held_w=held)
_in = proj(899.0)
_past = next_quarter(proj(900.4, covered=899.9 / 900.4), 30.0)
check("⭐ inside its block a projection is unchanged (the same object)",
next_quarter(_in, 30.0) is _in)
check("⭐ past its end with a fresh held reading: the next quarter, "
"projected from that reading",
_past.block_start == T0 + timedelta(seconds=QUARTER_S)
and abs(_past.elapsed_s - 0.4) < 1e-9 and _past.projected_w == 3000.0
and _past.covered_frac == 1.0
and peakguard.blindness(_past, 30.0, False) is None)
_old = proj(960.0, covered=880.0 / 960.0)
check("...a held reading too old to be credited, no block, or an export: "
"unchanged, or projected at 0 W offtake",
next_quarter(_old, 30.0) is _old
and next_quarter(proj(950.0, start=None), 30.0).block_start is None
and next_quarter(proj(900.5, held=-800.0), 30.0).projected_w == 0.0)
# ============================================================================ #
print("\n7. N=1")
for _name, _args in (("HOLD, late telegram at the edge",
(5500.0, {A: HOLD}, {"a": 99.0}, 1000.0, 0.5)),
("drain under the reserve, house 4000",
(4000.0, {A: DRAIN}, {"a": 15.0}, 600.0, 0.5))):
_new = run(*_args, n=1)
_old = without_fix(lambda: run(*_args, n=1))
print(f" {_name}: A at the edge "
f"{[cmd(r)['a'] for r in _new[0] if 899 <= r[0] <= 901]}")
check(f"⭐ N=1, {_name}: every row (claims, commands, the guard) is "
"identical with and without the fix",
_new[1].maints[0].phase == _args[1][A] and len(_new[0]) > 500
and _new[0] == _old[0])
_one = types.SimpleNamespace(inverters=[object()])
_cl = [Claim.set("maintenance", P_MAINTENANCE, 1000.0, DRAIN)]
check("⭐ at one inverter nothing is held back and no pin moves (the N=1 "
"identity digests in test_safety78/79/83/84/86/87 stay pinned)",
Controller._held_back(_one) == frozenset()
and Controller._drain_rise(types.SimpleNamespace(
inverters=[object()], _held_back=lambda: frozenset()), _cl,
[MaintResult(DRAIN, True, 1000.0, False, [])]) is None
and _cl[0].kind == "set")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-91 checks passed")