#!/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}")