At N=1 the drain is the site SET at P_MAINTENANCE (60), so the peak floor
(P_PEAK, 50) was not applied and the house imported what the drain left.
Where the floor asks for more than the drain rate, main.py now refiles the
peak claim at P_MAINTENANCE, so it wins; safety SETs still park everything.
Nothing else changes: safety78_identity --diff against 35cdffe changes only
cycles where the guard wanted more than the drain; N=2 is byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
7.4 KiB
Python
187 lines
7.4 KiB
Python
#!/usr/bin/env python3
|
|
"""SAFETY-78: at one inverter, is a maintenance drain byte-identical to before?
|
|
|
|
python3 tools/safety78_identity.py # prints one digest per scenario
|
|
|
|
tools/safety79_identity.py's harness (the REAL Controller on a P1 meter, so
|
|
the peak guard runs) with the battery draining for its monthly cycle from the
|
|
first cycle, under a house below, at and above the drain rate, a solar
|
|
surplus and a load over the peak limit. Each row hashes safety79's fields,
|
|
the modes' roles and the trade, the action log's newest line, and every claim
|
|
main.py hands `resolve()`, so a claim or a log line added at N=1 that changes
|
|
no watt still moves the digest. The `_modes` scenarios run battery modes on,
|
|
Cheap hours only, over opt11p3_identity's prices. Uses only the API release/1.0 5a4cdc2 has, so the same file runs on
|
|
both trees; test_safety78.py part 5 holds this tree to the digests printed
|
|
there.
|
|
|
|
SAFETY-83 adds the owner's settings (limit 3500, drain 2500, margin 50) with a
|
|
house that goes over drain + aim and back, and N=2 runs of both. At N=1 the
|
|
peak floor now outranks the drain where it asks for more, so the N=1 digests
|
|
moved; `--dump FILE` on the base tree and `--diff FILE` here list every
|
|
changed cycle and whether the peak guard wanted more than the drain on it.
|
|
"""
|
|
|
|
import functools
|
|
import hashlib
|
|
import os
|
|
import pickle
|
|
import sys
|
|
import tempfile
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
import opt11_identity as rig # noqa: E402
|
|
import opt11p3_identity as p3 # noqa: E402
|
|
import safety79_identity as s79 # noqa: E402
|
|
import app.main as m # noqa: E402
|
|
from app import packmode # noqa: E402
|
|
from app.store import unit_key # noqa: E402
|
|
|
|
DUR = 2400.0
|
|
DRAIN = {"maintenance_enabled": True, "maintenance_start_hour": 11}
|
|
|
|
|
|
def load(t):
|
|
"""Below the 2000 W drain, at it, above it, a surplus, over the limit."""
|
|
k = int(t // 480)
|
|
return (1200.0, 2000.0, 3000.0, -1500.0, 6000.0)[min(k, 4)]
|
|
|
|
|
|
def store_cheap(n):
|
|
s = rig._Store()
|
|
for u in ("inverter-a", "inverter-b")[:n]:
|
|
s.data[unit_key(u, packmode.MODE_KEY)] = packmode.CHEAP_HOURS
|
|
return s
|
|
|
|
|
|
def load_owner(t):
|
|
"""Below the 2500 W drain, above it, over drain + aim (5950 W), back."""
|
|
return (1200.0, 3000.0, 6500.0, 3000.0, -1500.0)[min(int(t // 480), 4)]
|
|
|
|
|
|
OWNER = {"peak_cap_w": 3500, "peak_margin_w": 50,
|
|
"maintenance_discharge_w": 2500}
|
|
|
|
SCENARIOS = {
|
|
"n1_drain": dict(n=1),
|
|
"n1_drain_modes": dict(n=1, modes=True),
|
|
"n1_drain_owner": dict(n=1, load=load_owner, extra=OWNER),
|
|
"n2_drain": dict(n=2),
|
|
"n2_drain_owner": dict(n=2, load=load_owner, extra=OWNER),
|
|
}
|
|
|
|
|
|
def run(n, *, modes=False, dur=DUR, load=load, extra=None):
|
|
extra = dict(s79.P1, **DRAIN, **(extra or {}))
|
|
if modes:
|
|
extra.update(p3.ARB, battery_modes_enabled=True)
|
|
seen, extra_rows = [], []
|
|
real_resolve = m.resolve
|
|
|
|
def spy(claims, **kw):
|
|
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
|
|
c.reason, c.actuator) for c in claims))
|
|
return real_resolve(claims, **kw)
|
|
|
|
def on_cycle(c, t):
|
|
g, tick, a = c.peak_guard, c.mode_tick, c.arb
|
|
extra_rows.append((
|
|
seen[-1] if seen else None,
|
|
None if g is None else (g.engaged, g.floor_w, g.reason),
|
|
None if tick is None else (tuple(sorted(tick.roles.items())),
|
|
tick.action, tick.why),
|
|
None if a is None else (a.action, a.power_w, a.reason),
|
|
[mm.phase for mm in c.maints],
|
|
len(c.events), c.events[0] if c.events else None))
|
|
db = os.path.join(tempfile.mkdtemp(prefix="s78-"), "tariff.db")
|
|
real = rig.Controller
|
|
rig.Controller = functools.partial(real, peak_db_path=db)
|
|
m.resolve = spy
|
|
try:
|
|
rows, c = rig.run(n, extra=extra, dur=dur, load=load, soc=s79.soc,
|
|
cut=lambda u, t: False,
|
|
before=p3._feed if modes else s79._feed,
|
|
on_cycle=on_cycle,
|
|
store=store_cheap(n) if modes else None)
|
|
finally:
|
|
rig.Controller = real
|
|
m.resolve = real_resolve
|
|
return [r + (x,) for r, x in zip(rows, extra_rows)], c
|
|
|
|
|
|
def digest(rows) -> str:
|
|
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
|
|
|
|
|
def digests() -> dict:
|
|
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
|
|
|
|
|
FIELDS = ("t", "target", "commands", "loop reason", "integrator",
|
|
"saturation", "why", "allocation")
|
|
XFIELDS = ("claims", "guard", "roles", "trade", "phase", "log length",
|
|
"newest log line")
|
|
|
|
|
|
def wanted_more(row) -> bool:
|
|
"""Did the peak guard ask for more than the drain rate on this cycle?"""
|
|
claims, guard = row[-1][0] or (), row[-1][1]
|
|
rate = [c[3] for c in claims if c[0] == "maintenance" and c[2] == "set"]
|
|
return (bool(rate) and guard is not None and bool(guard[0])
|
|
and guard[1] is not None and guard[1] > max(rate))
|
|
|
|
|
|
def diff(base: dict, now: dict) -> dict:
|
|
"""Per scenario: (cycles, changed, changed where the guard wanted more on
|
|
either tree, changed cycles whose commands differ without that)."""
|
|
out = {}
|
|
for k, rows in now.items():
|
|
old = base.get(k)
|
|
if old is None:
|
|
continue
|
|
ch = [i for i, (a, b) in enumerate(zip(old, rows)) if a != b]
|
|
ch += list(range(min(len(old), len(rows)), max(len(old), len(rows))))
|
|
more = [i for i in ch if i < min(len(old), len(rows))
|
|
and (wanted_more(old[i]) or wanted_more(rows[i]))]
|
|
cmd = [i for i in ch if i not in more
|
|
and (i >= min(len(old), len(rows))
|
|
or old[i][1:3] != rows[i][1:3])]
|
|
fields = set()
|
|
for i in set(ch) - set(more) - set(cmd):
|
|
fields |= {FIELDS[j] for j in range(8) if old[i][j] != rows[i][j]}
|
|
fields |= {XFIELDS[j] for j in range(7)
|
|
if old[i][8][j] != rows[i][8][j]}
|
|
out[k] = (len(rows), ch, more, cmd, sorted(fields))
|
|
return out
|
|
|
|
|
|
def all_rows() -> dict:
|
|
return {k: run(**v)[0] for k, v in SCENARIOS.items()}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if "--dump" in sys.argv:
|
|
with open(sys.argv[sys.argv.index("--dump") + 1], "wb") as f:
|
|
pickle.dump(all_rows(), f)
|
|
sys.exit(0)
|
|
if "--diff" in sys.argv:
|
|
with open(sys.argv[sys.argv.index("--diff") + 1], "rb") as f:
|
|
base = pickle.load(f)
|
|
for k, (n, ch, more, cmd, fields) in diff(base, all_rows()).items():
|
|
rest = sorted(set(ch) - set(more))
|
|
print(f"{k}: {n} cycles, {len(ch)} changed, {len(more)} with the "
|
|
f"guard above the drain, {len(rest)} others "
|
|
f"({len(cmd)} with a different target or command"
|
|
+ (f"; the rest differ only in {', '.join(fields)}"
|
|
if fields else "") + ")"
|
|
+ (f", first changed t={ch[0] * rig.TICK:.0f} s" if ch else ""))
|
|
sys.exit(0)
|
|
if "--summary" in sys.argv:
|
|
for k, v in SCENARIOS.items():
|
|
rows = run(**v)[0]
|
|
print(f"{k}: {len(rows)} cycles, phases "
|
|
f"{sorted({str(r[-1][-1]) for r in rows})}, targets "
|
|
f"{sorted({r[1] for r in rows})[:6]}")
|
|
for k, v in digests().items():
|
|
print(f"{k} {v}")
|