At two inverters the drain pins its pack and the site target is split around the pin. Below the pin the other pack had to charge, the allocator refused the opposite directions, and the site sat at 0 W with an ARBITER line every cycle (house below or above the drain rate, or a grid charge). main.py now files a site limit `drain` (lo = what the pin places, at P_MAINTENANCE) at N>=2 while a pack drains, so the other pack only adds power in the same direction. A drain that cannot give power waits with one log line. With battery modes on, no grid charge while a pack drains, and the other battery's card says why. N=1 is byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
15 KiB
Python
344 lines
15 KiB
Python
"""Runnable check for SAFETY-78: a maintenance drain at N=2 no longer stalls.
|
|
`python3 test_safety78.py`
|
|
|
|
At two inverters the drain pins its battery at the drain rate and the site
|
|
target is split around the pin. Below the pin the other battery had to charge,
|
|
the allocator refused the opposite directions, the site sat at 0 W and the
|
|
drain never moved (OPT-11 P1 F1, UX-13 P3 check 5, OPT-11 P3 F4). Now a site
|
|
`lo` (`drain`, P_MAINTENANCE) keeps the site target at or above what the pin
|
|
places. Drives the REAL Controller (tools/opt11_identity.py's harness).
|
|
DECISIONS/SAFETY-78-drain-at-two.md has the record.
|
|
|
|
1. N=2, house below, at and above the drain rate, a solar surplus: the drain
|
|
runs at its rate, no opposite split, no refusal, no log spam, and it ends.
|
|
2. A drain that cannot give power waits with ONE log line.
|
|
3. Battery modes on, a Cheap hours battery in a cheap run during the drain:
|
|
no grid charge, and its card says why.
|
|
4. The peak guard binds during a drain, and the arbiter's order holds.
|
|
5. N=1 is byte-identical to release/1.0 5a4cdc2, and the harness bites.
|
|
"""
|
|
|
|
import functools
|
|
import logging
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
|
|
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 opt11p3_identity as p3 # noqa: E402
|
|
import safety78_identity as s78 # noqa: E402
|
|
import safety79_identity as s79 # noqa: E402
|
|
from app import arbitrage, homeview, packmode # noqa: E402
|
|
from app.arbiter import (Claim, P_ARBITRAGE, P_LOOP, P_MAINTENANCE, # noqa
|
|
P_PEAK, P_SAFETY, resolve)
|
|
from app.main import Controller # noqa: E402
|
|
from app.maintenance import CHARGE, DRAIN # noqa: E402
|
|
from app.store import unit_key # noqa: E402
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
M = {"maintenance_enabled": True, "maintenance_start_hour": 11}
|
|
A, B = "inverter-a", "inverter-b"
|
|
|
|
|
|
def cmd(r):
|
|
return dict(r[2])
|
|
|
|
|
|
def refused(rows):
|
|
return sum(1 for r in rows if r[7] and "REFUSED" in r[7])
|
|
|
|
|
|
def opposite(rows):
|
|
return sum(1 for r in rows if cmd(r)["a"] * cmd(r)["b"] < 0)
|
|
|
|
|
|
def drain_run(house, dur=600.0, soc=lambda u, t: 50.0, cut=None, **kw):
|
|
return rig.run(2, extra=dict(M, **kw.pop("extra", {})), dur=dur,
|
|
load=lambda t: house(t) if callable(house) else house,
|
|
soc=soc, cut=cut or (lambda u, t: False), **kw)
|
|
|
|
|
|
def with_peak_db(fn):
|
|
"""Run `fn()` with the Controller's peak store in a temporary file."""
|
|
db = os.path.join(tempfile.mkdtemp(prefix="s78t-"), "tariff.db")
|
|
real = rig.Controller
|
|
rig.Controller = functools.partial(real, peak_db_path=db)
|
|
try:
|
|
return fn()
|
|
finally:
|
|
rig.Controller = real
|
|
|
|
|
|
# ============================================================================ #
|
|
print("1. N=2: the drain runs whatever the house uses")
|
|
RUNS = {h: drain_run(h) for h in (1200.0, 2000.0, 3000.0, -1500.0)}
|
|
for h, (rows, c) in RUNS.items():
|
|
print(f" house {h:+.0f} W: last A {cmd(rows[-1])['a']:+.0f} W, B "
|
|
f"{cmd(rows[-1])['b']:+.0f} W, grid {h - sum(cmd(rows[-1]).values()):+.0f}"
|
|
f" W; {rows[-1][6]}; refusals {refused(rows)}")
|
|
check("⭐ below, at and above the drain rate and under a solar surplus, "
|
|
"battery A is commanded its 2000 W drain on EVERY cycle, so the drain "
|
|
"moves (it used to sit at 0 W from the first cycle)",
|
|
all(cmd(r)["a"] == 2000.0 for rows, _c in RUNS.values() for r in rows)
|
|
and all(c.maints[0].phase == DRAIN for _r, c in RUNS.values()))
|
|
check("⭐ no cycle splits the site both ways and the allocator refuses "
|
|
"nothing: battery B never charges while A drains",
|
|
all(opposite(rows) == 0 and refused(rows) == 0
|
|
and all(cmd(r)["b"] >= 0.0 for r in rows)
|
|
for rows, _c in RUNS.values()))
|
|
check("...and nothing is logged per cycle: no ARBITER line in the action log",
|
|
not any("ARBITER" in e for _r, c in RUNS.values() for e in c.events))
|
|
_r3 = RUNS[3000.0][0]
|
|
check("above the drain rate, B covers the rest of the house in the SAME "
|
|
"direction and the grid settles at the loop's aim",
|
|
990.0 <= cmd(_r3[-1])["b"] <= 1030.0
|
|
and abs(3000.0 - sum(cmd(_r3[-1]).values()) + 10.0) <= 30.0)
|
|
check("at or below the drain rate, B is held at 0 W and A exports the rest, "
|
|
"as the drain does at one inverter ('exports the surplus')",
|
|
all(cmd(RUNS[h][0][-1])["b"] == 0.0 for h in (1200.0, 2000.0, -1500.0))
|
|
and all(RUNS[h][0][-1][6].startswith("loop -> 2000 W")
|
|
for h in (1200.0, 2000.0, -1500.0)))
|
|
check("...and where the loop alone would ask less, the Why names the drain",
|
|
"limited by drain" in RUNS[1200.0][0][-1][6])
|
|
|
|
# The drain ends: A's level falls to the floor, and the cycle moves on.
|
|
_end_rows, _end = drain_run(1200.0, dur=500.0,
|
|
soc=lambda u, t: max(5.0, 50.0 - t / 10.0)
|
|
if u == "a" else 50.0)
|
|
check("⭐ and the drain ENDS: A reaches the 11 % floor and the cycle moves "
|
|
"to its charge phase, with no refusal on the way",
|
|
_end.maints[0].phase == CHARGE and refused(_end_rows) == 0)
|
|
|
|
# The check bites: without the floor, the same site stalls as it did.
|
|
_real = Controller._drain_floor
|
|
Controller._drain_floor = lambda self, claims, results: None
|
|
try:
|
|
_bite, _bc = drain_run(1200.0, dur=120.0)
|
|
finally:
|
|
Controller._drain_floor = _real
|
|
print(f" without the drain floor: refusals {refused(_bite)}/{len(_bite)}, "
|
|
f"last A {cmd(_bite[-1])['a']:+.0f} W")
|
|
check("non-vacuous: with the floor taken out, the same fixture refuses every "
|
|
"cycle and parks A at 0 W (the reported defect)",
|
|
refused(_bite) == len(_bite) and cmd(_bite[-1])["a"] == 0.0
|
|
and any("ARBITER" in e for e in _bc.events))
|
|
|
|
# ============================================================================ #
|
|
print("\n2. a drain that cannot give power waits, with one log line")
|
|
_w_rows, _w = drain_run(1500.0, cut=lambda u, t: u == "a")
|
|
_waits = [e for e in _w.events if "cannot give power now" in e]
|
|
print(f" A refuses to give: {_waits}; last {_w_rows[-1][2]}")
|
|
check("⭐ A refuses to give power (SAFETY-73): the cycle waits, the line is "
|
|
"logged ONCE over 120 cycles, and nothing is refused",
|
|
len(_waits) == 1 and _w.inverters[0].refused == 1
|
|
and refused(_w_rows) == 0 and _w.maints[0].phase == DRAIN)
|
|
check("...and B covers the house on its own meanwhile",
|
|
1400.0 <= cmd(_w_rows[-1])["b"] <= 1600.0)
|
|
|
|
|
|
def reject_a(c, t):
|
|
"""Every write to A is rejected, so A's link goes stale (not live)."""
|
|
if not getattr(c.hass, "_rej", False):
|
|
real = c.hass.set_number
|
|
|
|
async def set_number(e, v):
|
|
return False if e == "number.sp_a" else await real(e, v)
|
|
c.hass.set_number, c.hass._rej = set_number, True
|
|
|
|
|
|
_l_rows, _l = drain_run(1500.0, dur=400.0, before=reject_a)
|
|
print(f" A's writes rejected: {_l_rows[-1][2]}, {_l_rows[-1][7][-40:]}")
|
|
check("⭐ a draining battery that is dropped (stale link) adds no floor: B "
|
|
"covers the 1500 W house, not the 2000 W drain rate, and the wait is "
|
|
"logged once",
|
|
"dropped inverter-a" in _l_rows[-1][7]
|
|
and cmd(_l_rows[-1])["b"] == 1500.0
|
|
and sum("cannot give power now" in e for e in _l.events) == 1)
|
|
|
|
# ============================================================================ #
|
|
print("\n3. battery modes on: no grid charge during the drain")
|
|
|
|
|
|
def cheap_b():
|
|
s = rig._Store()
|
|
s.data[unit_key(A, packmode.MODE_KEY)] = packmode.SOLAR_FIRST
|
|
s.data[unit_key(B, packmode.MODE_KEY)] = packmode.CHEAP_HOURS
|
|
return s
|
|
|
|
|
|
def modes_run(maint):
|
|
extra = dict(s79.P1, **p3.ARB, battery_modes_enabled=True,
|
|
**(M if maint else {}))
|
|
ticks = []
|
|
|
|
def on_cycle(c, t):
|
|
ticks.append(c.mode_tick)
|
|
rows, c = with_peak_db(lambda: rig.run(
|
|
2, extra=extra, dur=1800.0, load=lambda t: 800.0,
|
|
soc=lambda u, t: 50.0, cut=lambda u, t: False, before=p3._feed,
|
|
on_cycle=on_cycle, store=cheap_b()))
|
|
cheap = [(r, k) for r, k in zip(rows, ticks) if 900.0 <= r[0] < 1800.0]
|
|
return rows, c, cheap
|
|
|
|
|
|
_base_rows, _base_c, _base_cheap = modes_run(False)
|
|
_m_rows, _m_c, _m_cheap = modes_run(True)
|
|
check("baseline: with no drain, B (Cheap hours only) charges from the grid "
|
|
"in the cheap run, so the fixture reaches the charge",
|
|
any(cmd(r)["b"] < 0.0 for r, _k in _base_cheap)
|
|
and all(k.roles[B] == "charge" for _r, k in _base_cheap))
|
|
check("⭐ with A draining, B never charges in that run: its role is not "
|
|
"'charge', no trade is filed, and the modes say why ('cycle')",
|
|
all(cmd(r)["b"] >= 0.0 and k.roles[B] != "charge" and k.action is None
|
|
and k.why == "cycle" for r, k in _m_cheap)
|
|
and _m_c.maints[0].phase == DRAIN)
|
|
check("...and the drain runs through the run: A at 2000 W, nothing refused",
|
|
all(cmd(r)["a"] == 2000.0 for r in _m_rows) and refused(_m_rows) == 0)
|
|
|
|
# Battery modes off: the site trade (`arbitrage.decide()`) buys in the cheap
|
|
# period. It is a site SET at P_ARBITRAGE, below the drain floor.
|
|
_t_trades = []
|
|
_t_rows, _t_c = with_peak_db(lambda: rig.run(
|
|
2, extra=dict(s79.P1, **p3.ARB, **M), dur=1800.0, load=lambda t: 800.0,
|
|
soc=lambda u, t: 50.0, cut=lambda u, t: False, before=p3._feed,
|
|
on_cycle=lambda c, t: _t_trades.append(c.arb and c.arb.action)))
|
|
print(f" modes off, trades seen: {sorted({str(a) for a in _t_trades})}")
|
|
check("⭐ battery modes off: the site trade still asks to buy in the cheap "
|
|
"period, and the drain floor keeps it from charging B against the drain: "
|
|
"A drains at 2000 W, B never charges, nothing is refused",
|
|
arbitrage.CHARGE in _t_trades
|
|
and all(cmd(r)["a"] == 2000.0 and cmd(r)["b"] >= 0.0 for r in _t_rows)
|
|
and refused(_t_rows) == 0)
|
|
_cards = homeview.battery_rows(_m_c, False, True)
|
|
print(f" cards: {[(b['name'], b['now'], b['why']) for b in _cards]}")
|
|
check("⭐ B's card tells the truth: it names the draining battery and says "
|
|
"it does not charge (it said 'charges from the grid' while resting)",
|
|
_cards[1]["why_key"] == "other_cycle" and A in _cards[1]["why"]
|
|
and _cards[0]["why_key"] == "cycle_mode")
|
|
|
|
# ============================================================================ #
|
|
print("\n4. the peak guard during a drain, and the order of the claims")
|
|
|
|
|
|
def off_b():
|
|
s = rig._Store()
|
|
s.data[unit_key(B, packmode.USE_KEY)] = False
|
|
return s
|
|
|
|
|
|
_p_rows, _p = with_peak_db(lambda: rig.run(
|
|
2, extra=dict(s79.P1, battery_modes_enabled=True, **M), dur=600.0,
|
|
load=lambda t: 6000.0, soc=lambda u, t: 60.0, cut=lambda u, t: False,
|
|
before=s79._feed, store=off_b()))
|
|
print(f" house 6000 W, limit {s79.CAP_W} W, B Off: {_p_rows[-1][2]}, "
|
|
f"{_p_rows[-1][6]}")
|
|
check("⭐ the peak guard binds during the drain: the site goes above the "
|
|
"drain to the guard's floor, A keeps draining at 2000 W and B (Off) "
|
|
"gives only the shortfall, in the same direction",
|
|
_p.peak_guard.engaged and _p_rows[-1][1] >= _p.peak_guard.floor_w > 2000.0
|
|
and all(cmd(r)["a"] == 2000.0 and cmd(r)["b"] >= 0.0 for r in _p_rows)
|
|
and "limited by peak" in _p_rows[-1][6] and refused(_p_rows) == 0)
|
|
|
|
|
|
|
|
def blind_at(t0):
|
|
"""The site meter stops answering at `t0`: a safety SET at P_SAFETY."""
|
|
def before(c, t):
|
|
if t >= t0 and not getattr(c.hass, "_blind", False):
|
|
real = c.hass.number
|
|
|
|
async def number(e, invert=False):
|
|
return None if e == "sensor.grid" else await real(e, invert)
|
|
c.hass.number, c.hass._blind = number, True
|
|
return before
|
|
|
|
|
|
_s_rows, _s = drain_run(3000.0, dur=300.0, before=blind_at(150.0))
|
|
print(f" meter lost at 150 s: {_s_rows[-1][2]}, {_s_rows[-1][6]}")
|
|
check("⭐ a safety stop outranks the drain at N=2: with the meter gone the "
|
|
"safety SET parks both batteries at 0 W, and the drain ran before",
|
|
cmd(_s_rows[20])["a"] == 2000.0
|
|
and all(cmd(r) == {"a": 0.0, "b": 0.0} for r in _s_rows if r[0] >= 175.0)
|
|
and _s_rows[-1][6].startswith("safety -> 0 W"))
|
|
|
|
UNITS = (A, B)
|
|
_drain = [Claim.set("maintenance", P_MAINTENANCE, 2000.0, "drain", actuator=A),
|
|
Claim.limit("drain", P_MAINTENANCE, lo=2000.0, reason="drain")]
|
|
_order = {
|
|
"loop under the drain": ([Claim.set("loop", P_LOOP, 500.0)], 2000.0),
|
|
"a grid charge (trade)": ([Claim.set("arbitrage", P_ARBITRAGE, -2500.0)],
|
|
2000.0),
|
|
"a sale bigger than the drain": ([Claim.set("arbitrage", P_ARBITRAGE,
|
|
3000.0)], 3000.0),
|
|
"a peak floor above the drain": ([Claim.set("loop", P_LOOP, 500.0),
|
|
Claim.limit("peak", P_PEAK, lo=3500.0)],
|
|
3500.0),
|
|
"a safety stop": ([Claim.set("loop", P_LOOP, 500.0),
|
|
Claim.set("safety", P_SAFETY, 0.0, "stopped")], 0.0),
|
|
}
|
|
_got = {}
|
|
for k, (extra, want) in _order.items():
|
|
r = resolve(_drain + extra, units=UNITS)
|
|
_got[k] = (r.target_w, dict(r.shares), r.allocated)
|
|
print(f" {k}: target {r.target_w:+.0f} W, shares {dict(r.shares)}")
|
|
check("⭐ the order holds: a safety stop parks everything; the peak floor "
|
|
"above the drain wins; a sale above it runs; a grid charge and the "
|
|
"loop below it are lifted to it; never an opposite split",
|
|
all(_got[k][0] == want and _got[k][2]
|
|
and not (min(_got[k][1].values()) < 0 < max(_got[k][1].values()))
|
|
for k, (_e, want) in _order.items())
|
|
and _got["a safety stop"][1] == {A: 0, B: 0}
|
|
and _got["a grid charge (trade)"][1] == {A: 2000, B: 0})
|
|
|
|
# ============================================================================ #
|
|
print("\n5. N=1: byte-identical to release/1.0 5a4cdc2")
|
|
# Printed by `python3 tools/safety78_identity.py` on release/1.0 5a4cdc2 (the
|
|
# same file copied in, nothing else changed), goodwe-addon-checks:1,
|
|
# 2026-09-29. 480 cycles each, draining throughout, every claim and the
|
|
# newest action-log line hashed.
|
|
AT_5A4CDC2 = {
|
|
"n1_drain":
|
|
"55fcfc4a0b9251746f315cfdea607a33640d3a81faf6604c4718a04bbc7dd776",
|
|
"n1_drain_modes":
|
|
"93867eb4ee4132b47c030668bd7f07587a353bced2461524e09421125c3c4edf",
|
|
}
|
|
_now = s78.digests()
|
|
for k in AT_5A4CDC2:
|
|
print(f" {k} {'same' if _now[k] == AT_5A4CDC2[k] else 'DIFFERS'} "
|
|
f"{_now[k][:16]}")
|
|
check("⭐ at one inverter a drain under a house below, at and above its "
|
|
"rate, a surplus and a load over the peak limit, with battery modes "
|
|
"off and on, is byte-identical to 5a4cdc2: commands, reasons, roles, "
|
|
"trades and every claim",
|
|
_now == AT_5A4CDC2)
|
|
Controller._drain_floor = lambda self, claims, results: (
|
|
2000.0 if any(r.phase == DRAIN for r in results) else None)
|
|
try:
|
|
_leak = s78.digest(s78.run(**s78.SCENARIOS["n1_drain"], dur=60.0)[0])
|
|
_same = s78.digest(s78.run(**s78.SCENARIOS["n1_drain"], dur=60.0)[0])
|
|
finally:
|
|
Controller._drain_floor = _real
|
|
_ref = s78.digest(s78.run(**s78.SCENARIOS["n1_drain"], dur=60.0)[0])
|
|
check("non-vacuous: filing the drain floor at N=1 (which moves no watt "
|
|
"there) moves the digest, because the harness hashes the claims",
|
|
_leak != _ref and _leak == _same)
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|