"""Runnable check for SAFETY-07, the quarter-peak projection guard. python3 test_peakguard.py Eleven parts. What each is for, and - where it matters - what it is deliberately NOT allowed to be tested at: 1. THE CONTROL LAW, `excess_w`, graded AWAY from the two points where it is degenerate. At the start of a quarter the 900/remaining gain is exactly 1.0 and `excess` collapses onto the plain difference `projected - ceiling`; a check placed only there cannot tell the shipped law from that difference. So part 1 first shows them coinciding (documenting the trap), then separates them, then shows the two properties the whole design rests on: the law is ZERO at the engagement boundary (so the claim is continuous and needs no hysteresis constant) and its gain GROWS through the quarter (so the guard is gentlest where its evidence is weakest). 2. THE CLAIM, through the REAL `arbiter.resolve()`. That it binds the loop and the maintenance charge shaping, that it does NOT bind a maintenance phase that owns the actuator or any safety stop, that it can never cap discharge or export, and - the load-bearing one - that it can never produce the `lo > hi` contradiction the arbiter resolves to 0 W. 3. THE CEILING, AND A FAILED READ. Only an `OBSERVED` reading may raise it. `NO_RECORD` and `NOT_OBSERVED` both leave it at the cap, so an unreadable store makes the guard STRICTER. Graded against a REAL `PeakStore` that genuinely cannot open, not a stub - and the acceptance half is that the guard still ENGAGES with that store, because "fail open on an unreadable peak" is the one bug that would leave every other check in this file green. 4. BLINDNESS, all three kinds, each one evaluated exactly where the number it replaces is degenerate. A fresh averager projects 0 W, which is under every ceiling there is; a frozen meter projects confidently; a meter gap projects LOW. None of the three may be graded as a number. 5. `project()`'s DEFAULTED READ - TEST-13 item 4, fixed here because this card is the consumer that would have inherited it. The old form failed quiet AND in the disarming direction; part 5 computes what it would have concluded on the same fixture the shipped form engages on. 6. THE REPLAY. 2026-08-20's 5029.8 W billed quarter, closed-loop, and the healthy day's baseline. Every figure re-derived from the committed capture. 7. THE WIRING, through the real `Controller.cycle()` and its real status page. 8. SAFETY-24: THE ENERGY FLOOR under the forced discharge, graded only where a forced discharge is actually being asked for - the SoC is a degenerate stimulus on every cycle where `wanted <= 0`, and grading it there is how six bugs hid in this repository this week. Includes the sweep that shows the floor is never negative, which is the property the whole `lo`-limit shape rests on. 9. THE EXHAUSTING REPLAY (AC 4) AND THE STARTUP REFUSAL. The same 2026-08-20 capture, against a MODELLED PACK small enough that the guard's own 878 Wh day would take it to the BMS cut-out - so the two worlds, before and after this card, are run against each other rather than reasoned about. 10. SAFETY-25: THE LOOP, WHICH THIS FLOOR DOES NOT BOUND. A TRIPWIRE, not a guarantee - it records a defect and goes red when the defect is fixed. Part 11's mutation 13 has said in words since SAFETY-24 that ordinary self-consumption can take the pack past `peak_soc_floor`; this part replays a sustained-load evening through the SHIPPED control law, the SHIPPED guard and the REAL arbiter and measures how far past. It also sweeps `peak_soc_floor` over the whole schema range to show the answer does not move by one part in ten thousand, and pins the four operator-facing surfaces that now say so. ⚠️ THE REPLAY ALONE IS NOT THE TRIPWIRE, and believing it was is the defect PR #31's reviewer found: `replay_loop()` hand-builds main.py's claim list, so it is blind to any claim main.py ADDS - which is where a loop-side floor would be built. 10f is what closes that. It pins the set of claim sources `Controller.cycle()` can file, statically over its AST and dynamically through real cycles swept over the whole SoC range, so a ninth source takes this part red. A floor built inside `control.compute()` or the guard instead moves 10a-10c's numbers directly, because the replay drives both. 11. MUTATION. Eleven one-line defeats of these two guards, each run against the checks above to show they are not vacuous. ------------------------------------------------------------------------------ WHAT THE REPLAY IS AND IS NOT The captures carry ONE series: the signed net power at the meter (`{"t": iso8601, "w": signed_watts}`). They do not carry battery power, so the replay cannot reconstruct how much of 2026-08-20's offtake was the house and how much was an inverter. The model is therefore stated rather than hidden: * the captured `w` is treated as the load presented to the meter, and * the guard's battery is modelled as an ADDITIONAL discharge subtracted from it, delivered on the next telegram (a one-sample actuation delay), and * when the guard is idle the modelled battery delivers 0 W. That last line is deliberately PESSIMISTIC and it is what makes the number mean something. The real grid-following loop would itself have been discharging on 2026-08-20 - it was not, because that is the day the vendor controller failed - so crediting the guard with zero baseline discharge isolates SAFETY-07's own contribution instead of flattering it with the loop's. Two days is also not thirty. AC 3's 95 %-over-30-days criterion is TARIFF-01's and is still not met; `test_tariff.py`'s part 9 says so at length and this file does not re-litigate it. What part 6 reports is the comparison that two days CAN support: the same quarters, with and without the guard. ⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run `tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving edit reverted inside one second is invisible to CPython's staleness test. That is TEST-15, and it has already turned one live mutation into a false survivor. Read MUTATION.md before you report a survivor. """ import asyncio import ast import dataclasses import gzip import inspect import json import re import shutil import sys import tempfile from datetime import datetime, timedelta, timezone from math import inf, isinf from pathlib import Path from app import peakguard # ⚠️ THE DROP AND REFUSAL REASONS ARE IMPORTED, NOT TRANSCRIBED. A check that # matched the English would stay green against an allocator that renamed the # case out from under it, which is this repository's own named failure mode. from app.allocator import ( DROP_STALE as ALLOC_DROP_STALE, REFUSE_EMPTY as ALLOC_REFUSE_EMPTY, ) from app.arbiter import ( ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, SCOPE_SITE, SCOPE_UNIT, Claim, resolve, scope_violations, ) from app.control import Tuning, compute, peak_at_risk from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w from app import main as app_main from app.inverter import Inverter from app.main import Controller from app.p1 import ( PLAUSIBLE_MAX_W, P1Ingest, QUARTER_S, QuarterAverager, make_sample, split_signed, ) from app.peakguard import ( BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP, CEILING_MONTH_PEAK, FLOOR_AT_SOC_FLOOR, FLOOR_SOC_UNREADABLE, PEAK_OPTION_DEFAULTS, PeakDecision, blindness, decide, discharge_permitted, excess_w, quarter_ceiling_w, uncovered_s, ) from app.validate import validate_startup_config from app.tariff import ( NOT_OBSERVED, NO_RECORD, OBSERVED, PeakReading, PeakStore, PeakTracker, month_key, ) fails = [] HERE = Path(__file__).resolve().parent TMP = Path(tempfile.mkdtemp(prefix="goodwe-peakguard-")) def check(name, cond): print(f" {'ok ' if cond else 'FAIL'} {name}") if not cond: fails.append(name) def near(a, b, tol=1e-6): return a is not None and b is not None and abs(a - b) <= tol def sample(t: datetime, net_w: float, phases=1, phase_net=None): """One P1 telegram through the SHIPPED derivation - test_tariff.py's helper, for its reason: the sign convention under test must be the one that ships.""" imp, exp = split_signed(net_w) pi = pe = None if phase_net is not None: pairs = [split_signed(v) for v in phase_net] pi = [a for a, _ in pairs] pe = [b for _, b in pairs] return make_sample("ha_signed", imp, exp, phases=phases, phase_import_w=pi, phase_export_w=pe, ingest_ts=t, ingest_mono=t.timestamp()) def fresh_store(name: str) -> PeakStore: return PeakStore(str(TMP / f"{name}.db")) def read_option(key: str): """The shipped default for `key`, out of config.yaml. ⚠️ `peak_cap_w:` appears TWICE - once under `options:`, once under `schema:`. test_tariff.py's part 9 documents the trap; this takes the FIRST match, which is the options block, i.e. the number an install actually lands on. """ for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines(): if line.strip().startswith(f"{key}:"): try: return float(line.split(":", 1)[1].strip()) except ValueError: continue return None def read_schema_max(key: str): """The schema's upper bound for `key` - `int(lo,hi)` -> hi.""" return _schema_bound(key, 1) def read_schema_min(key: str): """The schema's lower bound for `key` - `int(lo,hi)` -> lo. SAFETY-25's sweep needs BOTH ends: the argument there is that no value the schema accepts changes the answer, and a sweep that only reached upwards would leave "well, set it lower then" unanswered. """ return _schema_bound(key, 0) def _schema_bound(key: str, which: int): body = (HERE / "config.yaml").read_text(encoding="utf-8") marker = body.index("\nschema:") for line in body[marker:].splitlines(): st = line.strip() if st.startswith(f"{key}:") and "(" in st and ")" in st: return float(st[st.index("(") + 1:st.rindex(")")].split(",")[which]) return None # Everything numeric below is read out of the shipped file, not typed here. CAP_W = read_option("peak_cap_w") MAX_HOLD_S = read_option("meter_max_age_s") FREEZE_S = read_option("meter_freeze_s") CYCLE_S = read_option("cycle_floor_s") # The live install runs max_w 5000 (NOTES.md, freeze.py). That is also the schema # ceiling and the GW5000S-BP's rating, which is why the two agree - so the # device figure is taken from the schema rather than typed. LIVE_MAX_W = read_schema_max("max_w") # SAFETY-24's two options, out of the shipped file for the same reason. SOC_FLOOR = read_option("peak_soc_floor") BMS_FLOOR = read_option("bms_soc_floor") MAINT_FLOOR = read_option("maintenance_soc_floor") # A state of charge that comfortably permits a forced discharge, DERIVED from # the floor rather than typed: every part of this file that is not about # SAFETY-24 has to grade SAFETY-07's behaviour at a SoC where the new floor is # not the thing deciding the answer, or it would be measuring the wrong guard. SOC_OK = SOC_FLOOR + 30.0 print("SAFETY-07 - the quarter-peak projection guard") print("SAFETY-24 - and the energy floor under its forced discharge") print(f" read from config.yaml: peak_cap_w {CAP_W:.0f} W, " f"meter_max_age_s {MAX_HOLD_S:.0f} s, meter_freeze_s {FREEZE_S:.0f} s, " f"cycle_floor_s {CYCLE_S:.0f} s, max_w schema ceiling {LIVE_MAX_W:.0f} W") print(f" ...and peak_soc_floor {SOC_FLOOR:.0f} %, bms_soc_floor " f"{BMS_FLOOR:.0f} %, maintenance_soc_floor {MAINT_FLOOR:.0f} %") check("every threshold this check grades against came out of config.yaml", None not in (CAP_W, MAX_HOLD_S, FREEZE_S, CYCLE_S, LIVE_MAX_W, SOC_FLOOR, BMS_FLOOR, MAINT_FLOOR)) check("...and the SoC these checks use as 'plenty of battery' is comfortably " "clear of the floor, so nothing outside part 9 is silently grading " "SAFETY-24 instead of SAFETY-07", SOC_OK > SOC_FLOOR + 10.0) B = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc) # a quarter boundary def averager_at(net_w, seconds, *, start=B, step=5.0, gap_at=None, gap_s=0.0): """A real `QuarterAverager` driven `seconds` into the block at `net_w`. Optionally with a real hole in the telegram stream, which is how a coverage shortfall is produced here: manufactured by NOT sending telegrams, exactly as a dead meter produces it, rather than by writing to a private field. """ a = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) t = 0.0 while t <= seconds: if gap_at is not None and gap_at <= t < gap_at + gap_s: t += step continue a.add(sample(start + timedelta(seconds=t), net_w)) t += step return a # ============================================================================== print() print("1. the control law, and the point where it is indistinguishable") # THE DEGENERACY, NAMED FIRST. At elapsed 0 the gain 900/remaining is exactly # 1.0, so `excess_w` and the naive difference `projected - ceiling` are the same # number. Anything graded only here separates neither. CEIL = CAP_W top = averager_at(5000.0, 0.0) p_top = PeakTracker.project(top, B) e_top = excess_w(p_top, CEIL) naive_top = p_top.projected_w - CEIL print(f" at the top of the quarter: projected {p_top.projected_w:.0f} W, " f"excess {e_top:.1f} W, naive difference {naive_top:.1f} W") check("at elapsed 0 the shipped law and the plain difference coincide - so a " "check placed only there proves nothing about either", near(e_top, naive_top, 1e-6)) # Away from it they must separate, and the direction is the design: the later # the guard engages, the harder it has to pull to recover the same average. for el in (300.0, 600.0, 800.0): a = averager_at(5000.0, el) p = PeakTracker.project(a, B + timedelta(seconds=el)) e = excess_w(p, CEIL) gain = QUARTER_S / (QUARTER_S - p.elapsed_s) print(f" at {el:.0f} s elapsed: projected {p.projected_w:.0f} W, " f"excess {e:.0f} W, gain {gain:.2f}x") check(f"at {el:.0f} s the law is the difference times {gain:.2f} - the " f"900/remaining gain, not a constant offset", near(e, (p.projected_w - CEIL) * gain, 1e-6) and e > (p.projected_w - CEIL)) a300 = averager_at(5000.0, 300.0) a800 = averager_at(5000.0, 800.0) e300 = excess_w(PeakTracker.project(a300, B + timedelta(seconds=300)), CEIL) e800 = excess_w(PeakTracker.project(a800, B + timedelta(seconds=800)), CEIL) check("...and it grows monotonically through the quarter, so the guard is at " "its GENTLEST when its projection rests on the least evidence", e800 > e300 > 0.0) # CONTINUITY AT THE BOUNDARY. This is what stands in for §10.2's hysteresis. BATT_NOW = 400.0 a_edge = averager_at(CEIL, 450.0) p_edge = PeakTracker.project(a_edge, B + timedelta(seconds=450)) d_edge = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) e_edge = excess_w(p_edge, CEIL) print(f" held exactly AT the ceiling for 450 s: projected " f"{p_edge.projected_w:.2f} W, excess {e_edge:.3f} W, " f"floor {'(none)' if d_edge.floor_w is None else f'{d_edge.floor_w:.1f} W'}, " f"battery measured {BATT_NOW:.0f} W") check("at the engagement boundary the excess is zero, so the floor is exactly " "the battery's own measured power - i.e. 'carry on'. The law is CONTINUOUS " "and there is no step for a hysteresis constant to damp", near(e_edge, 0.0, 1e-6) and near(d_edge.floor_w, BATT_NOW, 1e-6)) # THE OSCILLATION, and it is the reason the threshold is on the floor rather # than on the excess. Re-derived here rather than described: a guard that # released as soon as `excess <= 0` hands the actuator back at the exact instant # it is working, and the load springs straight back. released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0 print(f" at the boundary: excess {e_edge:.3f} W (an `excess > 0` test would " f"RELEASE: {would_release_on_excess}), floor {released.floor_w:.0f} W " f"(the shipped `floor > 0` test holds: {released.engaged})") check("...and the claim is held there, not released. `excess` falls to zero as " "soon as the battery is already taking the watts, so thresholding IT is " "the bang-bang §10.2 forbids; thresholding the FLOOR is the same " "arithmetic read as a level", would_release_on_excess and released.engaged) d_gone = decide(PeakTracker.project(averager_at(500.0, 450.0), B + timedelta(seconds=450)), batt_w=BATT_NOW, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) check("...and it DOES release once the load falls under what the quarter can " "afford, so 'threshold the floor' is not 'never let go'", not d_gone.engaged and d_gone.floor_w is None) # A hair over, and the floor is the battery plus the excess. Not a constant. a_over = averager_at(CEIL + 200.0, 450.0) p_over = PeakTracker.project(a_over, B + timedelta(seconds=450)) d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) print(f" 200 W over for 450 s: {d_over.explain()}") check("a quarter projected over the ceiling engages, and the floor is the " "MEASURED battery power plus the derived excess", d_over.engaged and near(d_over.floor_w, BATT_NOW + excess_w(p_over, CEIL), 1e-6)) check("...and the floor moves with the battery measurement, so it is a claim " "about delivered power rather than a fixed setpoint", not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR).floor_w, d_over.floor_w, 1.0)) # THE WIND-UP ARGUMENT, made concrete. A floor built on INTENT re-adds its own # unmet ask every cycle; one built on delivered power does not. stuck_batt = 0.0 # the inverter is not following intent = 0.0 delivered_floor = [] intent_floor = [] for _ in range(5): d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) delivered_floor.append(d_del.floor_w) intent = intent + excess_w(p_over, CEIL) # what the intent form would do intent_floor.append(intent) print(f" five cycles with the inverter not following: delivered-power floor " f"{[f'{v:.0f}' for v in delivered_floor]}, intent floor " f"{[f'{v:.0f}' for v in intent_floor]}") check("a floor sized from DELIVERED battery power is stationary while the " "inverter fails to follow; one sized from the standing command winds up", len(set(f"{v:.3f}" for v in delivered_floor)) == 1 and intent_floor[-1] > intent_floor[0] * 4) # ============================================================================== print() print("2. the claim, through the real arbiter") SAFE = [Claim.limit("device", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "rating"), Claim.limit("supervised", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "max_w")] pc = peakguard.claim(d_over) print(f" claim: {pc.source} {pc.kind} lo={pc.lo:.0f} hi={pc.hi} " f"priority={pc.priority}") check("it is a LIMIT at P_PEAK, not a SET - so no safety stop can be dragged " "off zero by it", pc.kind == "limit" and pc.priority == P_PEAK) check("...with only a LOWER bound. `hi` is +inf, which is §9.1's 'discharge and " "export are never capped by the peak guard' as a property of the object " "rather than a rule to remember", isinf(pc.hi) and pc.hi > 0) # It binds the loop. LOOP_ASK = -1500.0 # the loop wants to CHARGE at 1500 W r = resolve(SAFE + [Claim.set("loop", P_LOOP, LOOP_ASK, "grid"), pc]) print(f" loop asks {LOOP_ASK:+.0f} W (charging): {r.explain()}") check("the floor binds the grid-following loop and turns a charge into the " "commanded discharge", near(r.target_w, pc.lo, 1e-9) and r.target_w > 0) check("...and the arbiter names the peak claim as what bound it, so an operator " "reading one line knows why the battery is discharging", r.bound_by is not None and "peak" in r.bound_by) # TWO LIMITS THAT CROSS. This is the defect this check was written to assume # away and instead found: a LIMIT does not out-vote another LIMIT. Clause 2 takes # the MOST RESTRICTIVE of every limit at or above the winner's priority, with no # priority between them - which is what makes the safety bounds unconditional. r2 = resolve(SAFE + [Claim.set("loop", P_LOOP, LOOP_ASK, "grid"), Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only"), pc]) print(f" peak floor AND maintenance charge-only shaping both filed: " f"{r2.explain()}") check("filing both is a CONTRADICTION, not a P_PEAK win - `lo > hi` resolves to " "0 W, i.e. the battery parked in the one quarter it should be shaving. " "P_PEAK 50 > P_MAINT_SHAPE 40 does NOT save it", r2.contradiction and near(r2.target_w, 0.0)) check("...so the rule for two limits that can cross is SUPPRESSION, and " "removing the shaping gives the floor, which is what main.py now does", near(resolve(SAFE + [Claim.set("loop", P_LOOP, LOOP_ASK, "grid"), pc] ).target_w, pc.lo, 1e-9)) # It does NOT bind a maintenance phase that owns the actuator, nor any stop. r3 = resolve(SAFE + [Claim.set("maintenance", P_MAINTENANCE, 0.0, "hold"), pc]) r4 = resolve(SAFE + [Claim.set("safety", P_SAFETY, 0.0, "stopped"), pc]) print(f" maintenance HOLD owns the setpoint: {r3.explain()} | " f"control stopped: {r4.explain()}") check("a maintenance phase that OWNS the actuator (P_MAINTENANCE 60) is not " "dragged off its own setpoint by the peak floor", near(r3.target_w, 0.0)) check("...and neither is any P_SAFETY stop - a controller pushing power the " "wrong way is wrong however much money it is saving", near(r4.target_w, 0.0)) check("...and neither resolution is a contradiction, which would ALSO have " "produced 0.0 and would have meant something entirely different", not r3.contradiction and not r4.contradiction) # Discharge is never capped: the loop asking for MORE discharge than the floor # must be left alone. BIG = pc.lo + 900.0 r5 = resolve(SAFE + [Claim.set("loop", P_LOOP, BIG, "grid"), pc]) check(f"a loop already discharging harder ({BIG:.0f} W) than the floor is not " f"pulled back to it - a floor cannot cap discharge", near(r5.target_w, BIG, 1e-9)) # THE CONTRADICTION, which is the one that would park the battery. absurd = averager_at(LIVE_MAX_W * 4, 870.0) p_absurd = PeakTracker.project(absurd, B + timedelta(seconds=870)) DEV_MAX = LIVE_MAX_W - 500.0 # a device rating tighter than max_w d_absurd = decide(p_absurd, batt_w=1000.0, cap_w=CAP_W, reading=None, achievable_w=min(LIVE_MAX_W, DEV_MAX), max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) r6 = resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "rating"), Claim.limit("supervised", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "max_w"), Claim.set("loop", P_LOOP, -200.0, "grid"), peakguard.claim(d_absurd)]) print(f" an absurd projection ({p_absurd.projected_w:.0f} W with 30 s left) " f"wanted {d_absurd.wanted_w:.0f} W, floor clamped to " f"{d_absurd.floor_w:.0f} W; {r6.explain()}") check("the floor is clamped to what the arbiter could actually grant, so the " "claim can NEVER produce lo > hi - a contradiction resolves to 0 W, i.e. " "the battery parked in the one minute of the month it should be pulling " "hardest", not r6.contradiction and near(r6.target_w, DEV_MAX, 1e-9)) check("...and the decision SAYS it saturated rather than reporting the clamped " "number as if it were the answer", d_absurd.saturated and d_absurd.wanted_w > d_absurd.floor_w) check("...while the un-clamped case does not claim saturation", not d_over.saturated) # ============================================================================== print() print("3. the ceiling, and what an unreadable peak store does to it") obs_hi = PeakReading(OBSERVED, "2026-08", peak_w=CAP_W + 1513.0, quarter_start=B) obs_lo = PeakReading(OBSERVED, "2026-08", peak_w=CAP_W - 800.0, quarter_start=B) norec = PeakReading(NO_RECORD, "2026-08") unread = PeakReading(NOT_OBSERVED, "2026-08", detail="disk gone") zero = PeakReading(OBSERVED, "2026-08", peak_w=0.0, quarter_start=B) for name, rd in (("OBSERVED above the cap", obs_hi), ("OBSERVED below the cap", obs_lo), ("NO_RECORD", norec), ("NOT_OBSERVED", unread), ("OBSERVED at 0 W", zero)): c_w, c_from = quarter_ceiling_w(CAP_W, rd) print(f" {name:<24} -> ceiling {c_w:.0f} W ({c_from})") check("a month whose peak ALREADY stands above the cap raises the ceiling to " "it - the bill for that month is already that number, and holding every " "later quarter to the cap buys nothing while costing real charging", quarter_ceiling_w(CAP_W, obs_hi) == (CAP_W + 1513.0, CEILING_MONTH_PEAK)) check("a month peak UNDER the cap does not lower the ceiling below the cap", quarter_ceiling_w(CAP_W, obs_lo) == (CAP_W, CEILING_CAP)) check("NO_RECORD leaves the ceiling at the cap", quarter_ceiling_w(CAP_W, norec) == (CAP_W, CEILING_CAP)) check("...and so does NOT_OBSERVED: a store that did not answer makes this " "guard STRICTER, never looser. That direction is the whole point - the " "number a failed read would have become is a zero peak, and a zero peak " "reads as 'quiet month, full freedom'", quarter_ceiling_w(CAP_W, unread)[0] <= quarter_ceiling_w(CAP_W, norec)[0] and quarter_ceiling_w(CAP_W, unread) == (CAP_W, CEILING_CAP)) # ⚠️ GRADED ON THE STATE, NOT ON `peak_w is not None`. Today a NOT_OBSERVED # reading always carries `peak_w=None`, so a `peak_w is not None` test happens to # behave - and dropping `is_observed` from quarter_ceiling_w survives every other # check in this file. That coincidence is exactly the thing not to depend on: a # future PeakReading that carried a LAST-KNOWN peak alongside NOT_OBSERVED - the # obvious next feature - would silently raise the ceiling off a read that failed. # So the fixture is that reading, built here, and the rule graded is the state. stale = PeakReading(NOT_OBSERVED, "2026-08", peak_w=CAP_W + 1500.0, quarter_start=B, detail="disk gone, last known peak kept") print(f" NOT_OBSERVED CARRYING a {stale.peak_w:.0f} W last-known peak " f"-> ceiling {quarter_ceiling_w(CAP_W, stale)[0]:.0f} W " f"({quarter_ceiling_w(CAP_W, stale)[1]})") check("a NOT_OBSERVED reading that CARRIES a peak still does not raise the " "ceiling - the gate is the reading's STATE, not whether a number happens " "to be attached to it. A failed read is not an observation", quarter_ceiling_w(CAP_W, stale) == (CAP_W, CEILING_CAP)) check("...and the relaxation is granted ONLY by an observation: the three " "non-observations agree, and only the OBSERVED-above-cap case differs", len({quarter_ceiling_w(CAP_W, r)[0] for r in (norec, unread, zero)}) == 1 and quarter_ceiling_w(CAP_W, obs_hi)[0] > CAP_W) # THE ACCEPTANCE HALF, and it is graded on a REAL store that cannot open. broken = PeakStore(str(TMP / "no-such-volume" / "tariff.db")) rd_broken = broken.peak(B) tr_broken = PeakTracker(broken, cap_w=CAP_W) print(f" a real PeakStore on a missing volume: state={rd_broken.state}, " f"headroom_w()={tr_broken.headroom_w(B)}") check("the real store answers NOT_OBSERVED rather than a peak of 0 W", rd_broken.state == NOT_OBSERVED and rd_broken.peak_w is None) d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=rd_broken, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) print(f" {d_broken.explain()}") check("AND THE GUARD STILL ENGAGES ON IT. An unreadable peak must not disarm " "the cap - 'no peak recorded' is precisely the reading that would, and it " "is the one bug that would leave every other check in this file green", d_broken.engaged and near(d_broken.floor_w, d_over.floor_w, 1e-9)) check("...and it does so at the CAP ceiling, not at some number derived from " "the store that could not answer", d_broken.ceiling_w == CAP_W and d_broken.ceiling_from == CEILING_CAP) check("`headroom_w()` is None on that same store, which is why this module does " "not consume it: a consumer that defaulted that None to the cap would " "have full headroom and no guard", tr_broken.headroom_w(B) is None) src = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8") _calls = {n.func.attr for n in ast.walk(ast.parse(src)) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)} check("...and peakguard.py calls no headroom accessor at all - graded on the " "parsed call syntax, so deleting the paragraph above would not satisfy it", "headroom_w" not in _calls and "warning_frac" not in _calls) # ============================================================================== print() print("4. blindness: three ways a projection is not evidence") # (a) NO OPEN BLOCK. A fresh averager projects 0 W - under every ceiling. fresh = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) p_fresh = PeakTracker.project(fresh, B) d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) print(f" fresh averager: projected {p_fresh.projected_w:.0f} W, " f"block_start={p_fresh.block_start}; {d_fresh.explain()}") check("a fresh averager's projection is 0 W, i.e. comfortably under any " "ceiling - the degenerate value, present and quiet", near(p_fresh.projected_w, 0.0) and p_fresh.projected_w < CAP_W) check("...and the guard grades block_start BEFORE the number, so it reports " "BLIND rather than 'the quarter is fine'", d_fresh.blind == BLIND_NO_BLOCK and d_fresh.projected_w is None) check("...and the blind action is a floor of 0 W - no charging while the " "billed quantity cannot be measured - which is one-directional: " "forbidding import can only lower delivered offtake, never raise it", d_fresh.engaged and near(d_fresh.floor_w, 0.0)) r_blind = resolve(SAFE + [Claim.set("loop", P_LOOP, -2500.0, "grid"), peakguard.claim(d_fresh)]) print(f" a loop asking to charge 2500 W while blind: {r_blind.explain()}") check("...and through the real arbiter that stops the charge dead at 0 W " "without forcing any discharge", near(r_blind.target_w, 0.0) and not r_blind.contradiction) # (b) FROZEN - the case SAFETY-07 exists to backstop, and the worst of the three: # coverage is PERFECT and the projection is confident and wrong. frozen_av = averager_at(-4000.0, 600.0) # meter stuck reading 4 kW of EXPORT p_frozen = PeakTracker.project(frozen_av, B + timedelta(seconds=600)) RAILED = LIVE_MAX_W # the loop has wound to the rail is_frozen = looks_frozen(FREEZE_S + 1.0, -RAILED, LIVE_MAX_W, FreezeWatchdogConfig(hold_s=FREEZE_S)) d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR, frozen=is_frozen) print(f" meter stuck at -4000 W with the command at {-RAILED:+.0f} W: " f"projected {p_frozen.projected_w:.0f} W, coverage " f"{p_frozen.covered_frac * 100:.0f} %, looks_frozen={is_frozen}") print(f" {d_frozen.explain()}") check("a frozen meter reading export projects a CONFIDENT ZERO at full " "coverage - the projection cannot see this failure at all", near(p_frozen.projected_w, 0.0) and p_frozen.covered_frac >= 1.0) check("...so blindness is decided by freeze.py's SHIPPED predicate, not by the " "projection, and not by a second copy of the conjunction here", is_frozen) check("...and the guard goes blind and forbids the import that is railing the " "meter", d_frozen.blind == BLIND_FROZEN and near(d_frozen.floor_w, 0.0)) r_fz = resolve(SAFE + [Claim.set("loop", P_LOOP, -RAILED, "grid"), peakguard.claim(d_frozen)]) print(f" the railed loop, arbitrated: {r_fz.explain()}") check(f"THE BACKSTOP: SAFETY-18's runaway - a {-RAILED:+.0f} W grid charge held " f"for a quarter - is cut to 0 W. freeze.py's table puts the rail at 75 s; " f"the alarm alone arrives at {FREEZE_S:.0f} s of a {QUARTER_S} s billing " f"quarter and stops nothing", near(r_fz.target_w, 0.0)) check("...and the same command NOT frozen is left alone, so the blind branch is " "not simply always on", near(resolve(SAFE + [Claim.set("loop", P_LOOP, -RAILED, "grid"), peakguard.claim(decide( p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR, frozen=False)) or Claim.limit("noop", P_PEAK)]).target_w, -RAILED, 1e-9)) # (c) COVERAGE. A real hole in the telegram stream, longer than max_hold_s. GAP = MAX_HOLD_S * 3 gapped = averager_at(4200.0, 600.0, gap_at=200.0, gap_s=GAP) p_gap = PeakTracker.project(gapped, B + timedelta(seconds=600)) clean = averager_at(4200.0, 600.0) p_clean = PeakTracker.project(clean, B + timedelta(seconds=600)) d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) print(f" a {GAP:.0f} s hole at 4200 W: uncovered {uncovered_s(p_gap):.0f} s, " f"projected {p_gap.projected_w:.0f} W vs {p_clean.projected_w:.0f} W with " f"no hole") check("a gap makes the projection read LOW - the under-guarding direction, " "which is why it may not simply be graded as a number", p_gap.projected_w < p_clean.projected_w - 1.0) check("...and `uncovered_s` is derived from what Projection already carries", near(uncovered_s(p_gap), GAP - MAX_HOLD_S, MAX_HOLD_S)) check("...and the guard goes blind on it, at the threshold p1.py already uses " f"to stop trusting a held sample ({MAX_HOLD_S:.0f} s) rather than at a " f"new number", d_gap.blind is not None and d_gap.blind.startswith(BLIND_COVERAGE)) small = averager_at(4200.0, 600.0, gap_at=200.0, gap_s=MAX_HOLD_S / 2) check("...while a hole SHORTER than one hold window is not blindness - the " "averager covered it, so there is nothing unmeasured", blindness(PeakTracker.project(small, B + timedelta(seconds=600)), MAX_HOLD_S, False) is None) check("blindness is decided before the projection is compared to anything: the " "frozen case would otherwise have read 0 W and idled, and the fresh case " "would have read 0 W and idled", d_fresh.engaged and d_frozen.engaged and d_fresh.projected_w is None and d_frozen.projected_w is None) # ============================================================================== print() print("5. project()'s defaulted read - TEST-13 item 4, fixed here") HELD = 4800.0 held_av = averager_at(HELD, 300.0) p_held = PeakTracker.project(held_av, B + timedelta(seconds=300)) check("QuarterAverager exposes the held value as a documented public property", hasattr(held_av, "held_net_w") and near(held_av.held_net_w, HELD)) check("...and Projection carries it, so a consumer can see WHAT was " "extrapolated and not only the extrapolation", near(p_held.held_w, HELD)) class _Renamed: """An averager whose held value has been renamed - the TEST-13 scenario.""" block_start = B elapsed_s = 300.0 partial_ws = HELD * 300.0 max_hold_s = MAX_HOLD_S raised = None try: PeakTracker.project(_Renamed(), B + timedelta(seconds=300)) except AttributeError as err: raised = err print(f" project() against a renamed averager: " f"{'AttributeError: ' + str(raised) if raised else 'NO ERROR - silent'}") check("a renamed held value now raises AttributeError on the first cycle. The " "shipped read was `getattr(averager, '_last_net', 0.0)`: a private name " "with a fallback, which fails QUIET", raised is not None) # ...and the direction of the quiet failure is the reason it mattered here. OLD = (p_held.partial_ws if hasattr(p_held, "partial_ws") else HELD * 300.0) # acc, which the old form would still have had old_projected = (HELD * 300.0 + 0.0 * (QUARTER_S - 300.0)) / QUARTER_S d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) print(f" same fixture: shipped projection {p_held.projected_w:.0f} W " f"(engaged={d_true.engaged}), the defaulted form's " f"{old_projected:.0f} W (would engage={old_projected > CAP_W})") check("...and it fails in the DISARMING direction: crediting 0 W held reads " f"{p_held.projected_w - old_projected:.0f} W LOW on this fixture, which " f"is the difference between a guard that engages and one that does not", p_held.projected_w > CAP_W >= old_projected and d_true.engaged) # ============================================================================== print() print("6. the replay: what the guard does to the two days that exist") REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz")) check("the committed replay fixtures are present", len(REPLAY) >= 2) def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W, pack=None, soc_floor=None): """Drive one capture through the real ingest, tracker and guard. See the module docstring for the model and why its baseline is pessimistic. ⚠️ SAFETY-24's `pack` IS A SECOND MODEL, AND IT IS STATED HERE RATHER THAN HIDDEN. The captures carry meter power only, so the pack is simulated: pack = (usable_wh, start_soc_pct, bms_floor_pct) * SoC falls by the DELIVERED discharge, integrated over the real telegram timestamps - the same integral that already produces `wh`, so the two cannot disagree; * the BMS is modelled as a HARD CUT at `bms_floor_pct`: at or below it the modelled battery delivers 0 W whatever the guard asks for. Without that, the "before this card" run would be unphysical and its comparison worthless - the BMS really does stop the pack, and the finding this card answers is that it is then the ONLY thing that does; * nothing recharges the pack. One day, one direction. That is pessimistic in the same deliberate way the zero-baseline discharge is: it isolates what the peak guard alone spends. `pack=None` pins the SoC at SOC_OK and reproduces SAFETY-07's numbers exactly, which is what parts 6's existing checks grade. """ ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S) store = fresh_store(f"{path.stem}-{'g' if guarded else 'u'}" f"{'' if pack is None else f'-p{soc_floor:.0f}'}") tracker = PeakTracker(store, cap_w=cap_w) batt = 0.0 # delivered discharge, W (positive) next_cycle = None graded_engaged = blind_engaged = saturated = cycles = 0 limited = at_bms = neg_floor = 0 wh = 0.0 prev_t = None usable_wh, soc, bms = (None, SOC_OK, None) if pack is None else ( float(pack[0]), float(pack[1]), float(pack[2])) floor_pct = SOC_FLOOR if soc_floor is None else float(soc_floor) min_soc = soc for line in gzip.open(path, "rt", encoding="utf-8"): row = json.loads(line) t = datetime.fromisoformat(row["t"]) if prev_t is not None: dwh = batt * (t - prev_t).total_seconds() / 3600.0 wh += dwh if usable_wh: soc = max(0.0, soc - 100.0 * dwh / usable_wh) min_soc = min(min_soc, soc) prev_t = t ing.submit(sample(t, float(row["w"]) - batt)) tracker.sync(ing) if next_cycle is not None and t < next_cycle: continue next_cycle = t + timedelta(seconds=CYCLE_S) cycles += 1 d = decide(PeakTracker.project(ing.averager, t), batt_w=batt, cap_w=cap_w, reading=tracker.peak(t), achievable_w=max_w, max_hold_s=MAX_HOLD_S, soc=soc, soc_floor=floor_pct, frozen=False) if d.floor_w is not None and d.floor_w < 0.0: neg_floor += 1 if d.engaged: (globals(), None) if d.blind: blind_engaged += 1 else: graded_engaged += 1 saturated += 1 if d.saturated else 0 limited += 1 if d.floor_limited else 0 if usable_wh and soc <= bms: at_bms += 1 if not guarded: continue batt = max(0.0, d.floor_w) if (d.engaged and d.floor_w is not None) else 0.0 # THE BMS, MODELLED. It is a real floor and it works - that is the whole # point of stating the risk fairly - so the "before" world is not a pack # discharged to nothing, it is a pack pinned at the BMS limit. if usable_wh and soc <= bms: batt = 0.0 month = month_key(datetime.fromisoformat(json.loads( gzip.open(path, "rt", encoding="utf-8").readline())["t"])) return { "blocks": dict(store.blocks_in(month)), "cycles": cycles, "graded": graded_engaged, "blind": blind_engaged, "saturated": saturated, "wh": wh, "month": month, "limited": limited, "at_bms": at_bms, "neg_floor": neg_floor, "end_soc": soc, "min_soc": min_soc, } totals = {"u_q": 0, "u_under": 0, "g_q": 0, "g_under": 0} worst_start = None for path in REPLAY: u = replay(path, guarded=False) g = replay(path, guarded=True) uq, gq = u["blocks"], g["blocks"] u_peak_start = max(uq, key=lambda k: uq[k]) u_under = sum(1 for v in uq.values() if v <= CAP_W) g_under = sum(1 for v in gq.values() if v <= CAP_W) totals["u_q"] += len(uq); totals["u_under"] += u_under totals["g_q"] += len(gq); totals["g_under"] += g_under print(f" {path.name}: {len(uq)} quarters, " f"uncontrolled peak {max(uq.values()):.1f} W -> " f"guarded peak {max(gq.values()):.1f} W; " f"under {CAP_W:.0f} W: {u_under}/{len(uq)} -> {g_under}/{len(gq)}") print(f" guard cycles {g['cycles']}, engaged on the measurement " f"{g['graded']}, blind {g['blind']}, saturated {g['saturated']}; " f"modelled discharge {g['wh']:.0f} Wh") check(f"{path.name}: the guard never RAISES a quarter - it caps charging " f"and forces discharge, and neither can add offtake", all(gq.get(k, 0.0) <= v + 1e-6 for k, v in uq.items())) check(f"{path.name}: no quarter that was under the limit is pushed over it", g_under >= u_under) if max(uq.values()) > CAP_W: worst_start = u_peak_start WORST_U = uq[u_peak_start] WORST_G = gq[u_peak_start] WORST_DAY = path.name G_SAT = g["saturated"] check("one of the two days contains a quarter over the soft limit, or there is " "nothing here to grade", worst_start is not None) print() print(f" THE BILLED QUARTER. {WORST_DAY}, block starting " f"{worst_start.isoformat()}:") print(f" uncontrolled {WORST_U:.1f} W -> guarded {WORST_G:.1f} W " f"({WORST_U - WORST_G:.1f} W removed, " f"{100.0 * (WORST_U - WORST_G) / WORST_U:.1f} %)") check(f"the guard brings 2026-08-20's billed quarter under the " f"{CAP_W:.0f} W soft limit", WORST_G <= CAP_W) check("...and it does it without ever asking for more than the inverter has - " "no saturation notification would have fired on this day", G_SAT == 0) check(f"...and that quarter really was the one specs.md:583's argument is " f"about - a billed quarter over the live install's own " f"{LIVE_MAX_W:.0f} W rating", WORST_U > LIVE_MAX_W) U_PCT = 100.0 * totals["u_under"] / totals["u_q"] G_PCT = 100.0 * totals["g_under"] / totals["g_q"] print() print(f" BASELINE {totals['u_under']}/{totals['u_q']} = {U_PCT:.1f} % of " f"quarters under {CAP_W:.0f} W, uncontrolled") print(f" GUARDED {totals['g_under']}/{totals['g_q']} = {G_PCT:.1f} %") print(f" Two days, not thirty - by choice: the thirty exist now " f"(scenarios/p1-net-30d-2026-08/, DATA-01) and live one directory down so " f"this glob does not pick them up and put ~50 s of replay on every PR. " f"tools/replay_ac3.py runs the month and must reproduce the totals above " f"to be believed: `python3 tools/replay_ac3.py scenarios --expect " f"{totals['u_under']}/{totals['u_q']}`. Over the thirty days the guard " f"takes August's peak from 5029.8 W to 4107.3 W - still over the " f"{CAP_W:.0f} W soft limit - and moves the interval count from 98.89 % to " f"98.99 %, because once a month peak is set the ceiling it defends is " f"that peak and not the cap. See that directory's README.") check("the guard improves on the uncontrolled baseline rather than merely " "matching it", G_PCT > U_PCT) check("...and it does not degrade a single quarter anywhere in the two days", totals["g_under"] >= totals["u_under"]) healthy = [p for p in REPLAY if "2026-08-23" in p.name] if healthy: h = replay(healthy[0], guarded=True) print(f" HEALTHY DAY FALSE-ENGAGEMENT RATE: {h['graded']} engagements on " f"the measurement and {h['blind']} blind, over {h['cycles']} guard " f"cycles ({h['wh']:.1f} Wh of modelled discharge)") check("zero engagements on the healthy reference day - measured, not " "asserted from principle. This is the number that decides trip vs " "notify: SAFETY-18 shipped notification-first because a false trip " "parks the battery, and this guard's false-trip rate on the only " "healthy day that exists is 0", h["graded"] == 0 and h["blind"] == 0 and h["wh"] == 0.0) # ============================================================================== print() print("7. the wiring: the real Controller, the real cycle, the real status page") class _Store: # ⚠️ PER INSTANCE. `data` was a CLASS attribute, which is fine while # nothing writes it and a trap the moment part 7 forces a maintenance phase # on one controller: every controller built afterwards would inherit it. def __init__(self, data=None): self.data = dict(data or {}) def set(self, key, value): self.data[key] = value def get_time(self, *a): return None def set_time(self, *a): pass class _Pub: enabled = False def publish(self, *a, **k): pass def close(self): pass class _Hass: def __init__(self): self.writes = [] self.per_entity = [] self.notifications = [] # SAFETY-25. The whole payload, not only the id. AC 3 is about what the # operator READS, so a fixture that records only that a notification # fired cannot grade it - TEST-06's finding on SAFETY-18's NOT COVERED # row, one file across. self.calls = [] async def limits(self, entity): return (-LIVE_MAX_W, LIVE_MAX_W, 1.0) async def number(self, entity, invert=False): return None async def set_number(self, entity, value): self.writes.append(value) # DEV-02. WHICH inverter each write went to, which is the only way to # tell "both units were commanded their own share" from "one unit was # commanded twice". `writes` keeps its old shape - a list of floats - # because a dozen checks above read it that way. self.per_entity.append((entity, value)) return True async def call(self, domain, service, data): self.notifications.append(data.get("notification_id")) self.calls.append(dict(data)) return True def last_message(self, notification_id: str) -> str: """The body of the most recent notification with that id, or "".""" bodies = [d.get("message", "") for d in self.calls if d.get("notification_id") == notification_id] return bodies[-1] if bodies else "" def built(db_path, opts=None, store=None): hass = _Hass() c = Controller(dict( {"setpoint_entity": "number.sp", "meter_source": "ha_signed", "p1_net_entity": "sensor.net", "meter_phases": 1, "max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15, "target_grid_w": 0, "step_w": 10, "heartbeat_s": 10, "stale_input_s": 15}, **(opts or {})), hass, store or _Store(), _Pub(), stop=asyncio.Event(), peak_db_path=str(db_path)) c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W return c, hass c, hass = built(TMP / "wired.db") check(f"a Controller built with peak_cap_w ABSENT takes the shipped " f"{CAP_W:.0f} W - config.yaml and main.py's fallback cannot drift apart", c.peak.cap_w == CAP_W) check("...and before the first cycle the guard has NO decision, which the " "status page must not spell as 'idle'", c.peak_guard is None) pre = [r for r in c.checks() if "peak guard" in r["text"]] print(" status row before the first cycle: " + (pre[0]["text"] if pre else "(none)")) check("the status page says so in its own row", len(pre) == 1 and "no decision yet" in pre[0]["text"]) # ⚠️ THE LEGACY PATH, AND IT IS A REGRESSION THIS CARD ACTUALLY CAUSED. # `meter_source: off` is the single-entity path an install starts on. There is no # telegram stream, so `p1.averager` never opens a block, so a guard that ran # there would be BLIND on every cycle forever - and blind means NO CHARGING. The # first cut of this card did exactly that, silently, on every install that has # not opted in to P1; test_clamp.py's export case is what caught it. c_off, hass_off = built(TMP / "legacy.db", {"meter_source": "off", "meter_entity": "sensor.grid"}) c_off.auto, c_off.soc, c_off.batt, c_off.grid = True, 55.0, 0.0, -6000.0 asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c_off.cycle()) off_rows = [r for r in c_off.checks() if "peak guard" in r["text"]] print(f" meter_source off: guard={c_off.peak_guard}, wrote " f"{hass_off.writes[-1]:+.0f} W") print(" status row: " + (off_rows[0]["text"] if off_rows else "(none)")) check("with no P1 the guard does not run at all - it files nothing, and the " "legacy path can still charge", c_off.peak_guard is None and hass_off.writes and hass_off.writes[-1] < 0) check("...and the status page says NOT COVERED rather than 'idle' or a green " "row, which is the same answer SAFETY-18 already gives on a transport " "that cannot see it", len(off_rows) == 1 and "NOT COVERED" in off_rows[0]["text"] and off_rows[0]["warn"]) # ⚠️ THE FIXTURE MUST LAND IN THE CURRENT QUARTER, AND THAT IS NOT PEDANTRY. # `cycle()` reads the real clock, and `project()` measures the open block against # it. Telegrams stamped at a fixed date make `elapsed_wall` the AGE OF THE FIXTURE # - tens of thousands of uncovered seconds - and the guard correctly answers # "meter gap". Which is a true answer to a question this part is not asking: the # first version of part 7 graded its own fixture and not the wiring. # # So the telegrams are laid from the START OF THE QUARTER THAT IS OPEN NOW up to # now. There is one narrow race left - the quarter rolling between the last # telegram and `cycle()`'s own `datetime.now()`, which leaves the averager on a # block with no seconds remaining - so the whole setup is retried on a fresh # Controller rather than left to flake. It is bounded at three attempts: a fourth # would mean something is wrong that is not a boundary. def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0, standing_w=None, soc=55.0): """A real Controller driven one real cycle over a real over-limit quarter. ⚠️ THE LOAD IS DERIVED FROM WHERE IN THE QUARTER THE WALL CLOCK LANDS, and it has to be. `excess = 900 × (projected − ceiling) ÷ remaining`, so a fixed 5200 W fixture asks for 1700 W with ten minutes left and 300 kW with five seconds left - the second of which saturates, and a check that saturated or not depending on the minute it ran would be a coin toss wearing a verdict. So the caller names the FLOOR it wants and the load that produces it is computed backwards from `remaining` at the moment of the run. """ for attempt in range(4): ctl, hs = built(db if attempt == 0 else Path(f"{db}.{attempt}"), opts, store=_Store(store) if store is not None else None) ctl.auto, ctl.soc, ctl.batt = True, soc, batt_w ctl.dev_min, ctl.dev_max = -LIVE_MAX_W, LIVE_MAX_W if standing_w is not None: # The command STANDING ON THE WIRE, which is what B3 below needs to # differ from the measured battery power. ctl.target = ctl.last_write = standing_w now = datetime.now(timezone.utc) blk = datetime.fromtimestamp( int(now.timestamp() // QUARTER_S) * QUARTER_S, timezone.utc) span = int((now - blk).total_seconds()) remaining = max(1.0, QUARTER_S - (now - blk).total_seconds()) net_w = CAP_W + want_floor_w * remaining / QUARTER_S for i in range(0, span + 1, 5): ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w)) # ⚠️ ASSIGNED, not read back from `p1.net_w`. This helper stamps # `ingest_mono` with an epoch second so the averager (which keys on # `ingest_ts`) replays correctly, while `net_w` keys on a MONOTONIC age # that the same stamp makes meaningless - so `net_w` is None here and the # cycle would take the "inputs missing" early return before it ever # reached the guard. ctl.grid = net_w asyncio.get_event_loop_policy().new_event_loop().run_until_complete( ctl.cycle()) g = ctl.peak_guard if g is not None and g.engaged and not g.blind: # ⚠️ A FLOOR-LIMITED DECISION IS A LANDING, NOT A MISS. It is # `engaged` and not `blind`, which is what this loop is waiting for; # SAFETY-24's own fixtures below arrive here on purpose. return ctl, hs, net_w, remaining # Only a quarter boundary crossed between the last telegram and # `cycle()`'s own clock gets here. Bounded at four: a fifth would mean # something is wrong that is not a boundary. return ctl, hs, net_w, remaining # 30 % of what the inverter can give: comfortably engaged, comfortably short of # saturation, at any point in any quarter. c, hass, NET_W, REMAIN = wired_over_limit(TMP / "wired2.db", LIVE_MAX_W * 0.3) print(f" after one real cycle over a {NET_W:.0f} W quarter " f"({REMAIN:.0f} s left in it): {c.reason}") print(f" {c.peak_guard.explain()}") check("the real cycle files the peak claim and the real arbiter binds the real " "loop with it", c.peak_guard is not None and c.peak_guard.engaged and not c.peak_guard.saturated and "peak" in (c.reason or "")) check("...and the number written to the inverter is the guard's floor", hass.writes and near(hass.writes[-1], c.peak_guard.floor_w, 1.5)) rows = [r for r in c.checks() if "peak guard" in r["text"].lower()] print(" status row: " + (rows[0]["text"] if rows else "(none)")) check("the status page carries exactly one peak-guard row, flagged", len(rows) == 1 and rows[0]["warn"]) check("an engagement that is not saturated raises NO notification - §10.3's " "'clamp AND alarm' is SAFETY-03's rule about a BUG SIGNAL, and a house " "load bigger than the inverter is not a bug", "goodwe_peak_saturated" not in hass.notifications) check("...but it does reach the action log, which is where a control action " "that moves energy belongs", any("PEAK GUARD" in e for e in c.events)) n_events = sum(1 for e in c.events if "PEAK GUARD" in e) asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c.cycle()) check("...and it is edge-logged, not level-logged: a second engaged cycle adds " "no second line, so a quarter of engagement cannot push the cause of the " "event off a 40-line log", sum(1 for e in c.events if "PEAK GUARD" in e) == n_events) # The saturated case, through the same real cycle. # A floor of 1.5x what the inverter can give: it saturates wherever in the # quarter the clock happens to be, and the load it implies stays inside # p1.PLAUSIBLE_MAX_W - a fixture the ingest itself rejects would grade nothing. c2, hass2, NET2, _ = wired_over_limit(TMP / "wired3.db", LIVE_MAX_W * 1.5) check(f"the saturating fixture ({NET2:.0f} W) is a load the real ingest " f"accepts, not one it rejects as implausible", NET2 < PLAUSIBLE_MAX_W) print(f" saturated case: {c2.peak_guard.explain()}") check("a load larger than the inverter saturates the guard", c2.peak_guard.saturated) check("...and THAT raises exactly one notification, with its own id - the one " "state where the money will be spent and a human is the only remaining " "actuator", hass2.notifications.count("goodwe_peak_saturated") == 1) asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c2.cycle()) check("...once per episode, not once per cycle", hass2.notifications.count("goodwe_peak_saturated") == 1) check("...and the command is still bounded by the rating, so a saturated guard " "never asks the hardware for more than it has", abs(hass2.writes[-1]) <= LIVE_MAX_W + 1e-6) # ============================================================================== # THE WIRING'S OWN MUTATIONS. Everything above this line grades `peakguard.py`, # and a reviewer showed that is not the same thing as grading the guard: THREE # one-line changes to main.py's CALL SITE left all twelve suites green while a # default install parked at 0 W in the quarter that sets the month's bill. # # So these three are executed, not described. Each mutation is put in place on # the SHIPPED module and the REAL `Controller.cycle()` is driven with it - which # also proves main.py routes through the thing being mutated rather than # inlining it, because a monkeypatch of an inlined expression would do nothing # and these checks would pass vacuously. print() print(" the wiring's own mutations - a default install, maintenance charging") # A DEFAULT INSTALL: `peak_forecast_entity` is absent, so `peak_fc` is None and # `peak_at_risk()` answers False forever. That is the whole point - it is the # configuration on which the old suspension could not fire. MAINT_CHARGING = {"phase": "charge"} _mainsrc = (HERE / "app" / "main.py").read_text(encoding="utf-8") def maint_cycle(tag, want_floor_w=LIVE_MAX_W * 0.3, **kw): """One real cycle with maintenance in CHARGE and no forecast entity.""" return wired_over_limit(TMP / tag, want_floor_w, store=MAINT_CHARGING, **kw) base, base_hass, BASE_NET, BASE_REM = maint_cycle("mut-base.db") res0 = base.maint.tick(datetime.now(timezone.utc), base.soc) print(f" maintenance phase {res0.phase!r}: owns_setpoint={res0.owns_setpoint}, " f"charge_only={res0.charge_only}; peak_forecast_entity absent so " f"peak_fc={base.peak_fc}") check("the fixture really is the dangerous one: maintenance in its CHARGE phase " "(a P_MAINT_SHAPE `hi <= 0` limit) on an install with no " "peak_forecast_entity, and the guard genuinely engaged on the measurement", res0.charge_only and not res0.owns_setpoint and base.peak_fc is None and base.peak_guard is not None and base.peak_guard.engaged and base.peak_guard.blind is None) print(f" SHIPPED: suspended=" f"{peakguard.shaping_suspended(base.peak_guard, base.peak_fc, CAP_W)}, " f"{base.reason}, target {base.target:+.1f} W") check("SHIPPED: the shaping stands down, the arbiter does not contradict, and " "the battery discharges into the peak", peakguard.shaping_suspended(base.peak_guard, base.peak_fc, CAP_W) and "CONTRADICTION" not in base.reason and near(base.target, base.peak_guard.floor_w, 1.5) and base.target > 0.0) # ---- B1: the suppression removed altogether -------------------------------- _shipped_pred = peakguard.shaping_suspended try: peakguard.shaping_suspended = lambda d, fc, cap: False b1, b1_hass, _, _ = maint_cycle("mut-b1.db") finally: peakguard.shaping_suspended = _shipped_pred print(f" B1 (suppression removed): {b1.reason}, target {b1.target:+.1f} W") check("B1: with the suppression gone the two LIMITS cross - `lo >= 0` from the " "peak guard against `hi <= 0` from the shaping - and the arbiter answers " "CONTRADICTION and 0 W. The battery is PARKED in the billed quarter, and " "P_PEAK 50 > P_MAINT_SHAPE 40 does not save it", "CONTRADICTION" in b1.reason and near(b1.target, 0.0) and b1.peak_guard.engaged) # ---- B2: re-gated on peak_forecast_entity, i.e. the historical bug ---------- # WARNING - THIS IS THE ONE THAT MUST NOT SHIP UNDEFENDED. It is verbatim the # defect this card's own comment describes, and until this check existed the # comment explained a fix that nothing could regress. That is the same shape as # a corrected comment nobody tests, which this project has already been caught # by once. try: peakguard.shaping_suspended = lambda d, fc, cap: peak_at_risk(fc, float(cap)) b2, b2_hass, _, _ = maint_cycle("mut-b2.db") finally: peakguard.shaping_suspended = _shipped_pred print(f" B2 (re-gated on peak_forecast_entity): " f"peak_at_risk({b2.peak_fc}, {CAP_W:.0f}) = " f"{peak_at_risk(b2.peak_fc, CAP_W)}, {b2.reason}, " f"target {b2.target:+.1f} W") check("B2: gating the suspension on peak_forecast_entity ALONE parks a default " "install at 0 W. That entity is optional and empty there, so peak_at_risk " "is False forever and the shaping is filed against the guard's floor - " "silently, in the quarter that sets the month's bill", not peak_at_risk(b2.peak_fc, CAP_W) and "CONTRADICTION" in b2.reason and near(b2.target, 0.0)) check("...and the shipped predicate differs from that mutation on exactly this " "input, which is what makes the measured term load-bearing rather than " "decorative", _shipped_pred(base.peak_guard, None, CAP_W) is True and peak_at_risk(None, CAP_W) is False) check("...while the forecast term is not redundant either: on a site that DOES " "set peak_forecast_entity, a forecast over the cap suspends the shaping " "even before the measured guard engages", _shipped_pred(None, CAP_W + 1000.0, CAP_W) is True and _shipped_pred(None, None, CAP_W) is False) # ---- B3: the floor sized from intent rather than from delivered power ------- # `decide()` is graded above with a hand-passed `batt_w`; nothing graded WHICH # value main.py hands it. Recorded off the real call, with the measured battery # and the standing command deliberately far apart. STANDING = 3000.0 _seen = {} _shipped_decide = peakguard.decide def _recording_decide(projection, **kw): _seen.update(kw) return _shipped_decide(projection, **kw) try: peakguard.decide = _recording_decide b3, b3_hass, _, _ = maint_cycle("mut-b3.db", batt_w=0.0, standing_w=STANDING) finally: peakguard.decide = _shipped_decide print(f" B3: measured battery {b3.batt:+.0f} W, command standing on the wire " f"{STANDING:+.0f} W; main.py handed decide() batt_w={_seen.get('batt_w')}") check("B3: main.py sizes the floor from the MEASURED battery power, not from " "the command. The utility bills what FLOWED, and `grid = load - batt` is " "true of delivered power and false of intent", near(_seen.get("batt_w"), b3.batt) and not near(_seen.get("batt_w"), STANDING)) # THE CONSEQUENCE, ITERATED. One cycle of the intent form is merely inflated by # the standing command; the failure is what happens on the NEXT cycle, when its # own unmet ask has become the standing command. So both forms are run forward # against the same frozen projection - the inverter never follows, so the meter # never moves - and the two are allowed to separate over time rather than being # judged at a single point where they need not differ. _proj3 = PeakTracker.project(b3.p1.averager, datetime.now(timezone.utc)) _kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W, max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK, soc_floor=SOC_FLOOR) _intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw) check("one cycle of the intent form is exactly the delivered form plus the " "command standing on the wire - which is the whole defect in one line: " "watts that were asked for and never arrived, counted as if they had", near(_intent.floor_w - b3.peak_guard.floor_w, STANDING, 1.5)) _i_cmd, _d_batt = STANDING, 0.0 _i_track, _d_track, _i_sat_at = [], [], None for _k in range(1, 9): _di = _shipped_decide(_proj3, batt_w=_i_cmd, **_kw) _dd = _shipped_decide(_proj3, batt_w=_d_batt, **_kw) _i_track.append(_di.floor_w) _d_track.append(_dd.floor_w) if _di.saturated and _i_sat_at is None: _i_sat_at = _k _i_cmd = _di.floor_w # the intent form's ask becomes what stands # ...while the inverter delivers nothing, so the delivered form's input # does not move at all. print(f" ...iterated against a frozen meter and an inverter that never " f"follows:") print(f" intent-sized floor {[f'{v:.0f}' for v in _i_track]}" f" -> SATURATED at cycle {_i_sat_at}") print(f" delivered-power floor {[f'{v:.0f}' for v in _d_track]} -> stationary") check("...and the consequence is the wind-up part 1 demonstrates, now on the " "REAL controller's own projection: the intent-sized floor re-adds its own " "unmet ask every cycle and reaches the rail SATURATED, while the " "delivered-power floor does not move at all", _i_sat_at is not None and _i_track[-1] > _i_track[0] and len({f"{v:.3f}" for v in _d_track}) == 1 and not b3.peak_guard.saturated) # Graded on the parsed call syntax too, so a rename cannot quietly reintroduce # it: the keyword main.py passes must be the measured attribute. # ⚠️ SELECTED BY QUALIFIED NAME, NOT BY BEING FIRST. This took the first # `.decide(` in main.py and asserted there was only one - which was true when # SAFETY-24 was written and STOPPED BEING TRUE when OPT-04 added # `arbitrage.decide(...)`. Grading "whichever .decide() the AST walk reaches # first" is exactly the fragility a reviewer flagged on SAFETY-07: it grades a # position, not the call. `peakguard.decide` is what this file is about, so name # it, and require exactly one of THAT. _peak_calls = [n for n in ast.walk(ast.parse(_mainsrc)) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == "decide" and isinstance(n.func.value, ast.Name) and n.func.value.id == "peakguard"] _call = _peak_calls[0] _batt_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "batt_w")) _soc_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "soc")) print(f" main.py's decide(batt_w=...) is `{_batt_src}`, decide(soc=...) is " f"`{_soc_src}`") check("...and the call site says so in the source: the argument is the measured " "battery attribute, not the standing command and not the resolved target. " "⚠️ DEV-02 MOVED WHICH ATTRIBUTE AND NOT WHICH KIND OF THING IT IS: " "`self.batt` was one inverter's power used as a site measurement, and " "`site_batt_w` is the SUM over the roster - still measured, still not " "`standing` and not `self.target`. The reduction itself is graded in " "test_inverter.py; what is pinned here is that a MEASUREMENT is what " "sizes the floor", _batt_src == "self.site_batt_w") # ⚠️ SAFETY-24, AND ON THE SAME SINGLE CALL SITE. This grader takes the FIRST # `.decide(` in main.py and reads a NAME rather than a value, so a second call # site - or a decoy - would defeat it; this card deliberately adds neither, and # extends the existing grader instead of writing a second one. check("...and `soc` is handed over as the MEASURED reading, bare. Not " "`self.soc or 100`, not a `float(..., 100)`, not the maintenance floor: " "a full battery is exactly the value that disarms SAFETY-24's floor, and " "NOTES.md 4.4b records this project shipping that exact defaulting once. " "⚠️ DEV-02 MADE IT `pack_floor_soc` - THE EMPTIEST PACK - and the " "defaulting hazard is unchanged and still the point: that property is a " "bare `min()` over measured readings that answers None if ANY unit did " "not read, because `decide()` treats None as floor-limited, which costs a " "shave rather than a pack. An `or 100` anywhere on that path is the same " "defect with two inverters", _soc_src == "self.pack_floor_soc") check(f"...and main.py has exactly ONE `peakguard.decide(` call site (it has " f"{len(_peak_calls)}), so the call graded above is unambiguously the " f"shipped one. Other modules may call their own decide() - OPT-04's " f"arbitrage.decide() does - and that must not silently move which call " f"this grader reads", len(_peak_calls) == 1) # ⚠️ AND WHICH KEY THE FLOOR COMES FROM, GRADED ON THE SOURCE. A mutation that # swapped `peak_soc_floor` for `maintenance_soc_floor` SURVIVED every # behavioural check in this file, because the two defaults are close enough that # no fixture separated them - which is exactly the coupling the card forbids and # exactly the kind of survivor a value-based check cannot see. This grades the # NAME, and part 9 below now also separates them behaviourally. _floor_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "soc_floor")).replace("'", '"') print(f" main.py's decide(soc_floor=...) is `{_floor_src}`") check("...and the floor comes from `peak_soc_floor`, defaulted from the ONE " "mirror - not from `maintenance_soc_floor` (a drain target, not a spend " "limit) and not from a literal, which would be CFG-03's fourth site", '"peak_soc_floor"' in _floor_src and "maintenance_soc_floor" not in _floor_src and 'PEAK_OPTION_DEFAULTS["peak_soc_floor"]' in _floor_src) # SAFETY-24 must be pointed at from the module that needs it, not only from a # review thread - a ticket nobody can find from the code is a ticket nobody # reads before shipping this to a client install. check("peakguard.py names SAFETY-24, the missing SoC backstop on the forced " "discharge this guard can command", "SAFETY-24" in src) # ============================================================================== # CFG-03: no fourth defaults site. cfg = (HERE / "config.yaml").read_text(encoding="utf-8") val = (HERE / "app" / "validate.py").read_text(encoding="utf-8") # ⚠️ REWRITTEN BY SAFETY-24, BECAUSE IT IS NO LONGER TRUE AS WRITTEN. SAFETY-07 # added no option and this check said so. This card adds TWO and says which - # leaving the old sentence in place while the file it reads has grown two keys # would be a green line asserting the opposite of the diff. What is still # refused is a THIRD: §14's kW/W trap stays declined, and no key beyond the two # named ones exists. _peak_keys = sorted(k for k in ("peak_guard", "peak_shave", "capacity_peak_limit_kw") if k in cfg) check("SAFETY-24 adds exactly the two keys it names and no more: §14's kW/W " "trap `capacity_peak_limit_kw` stays declined, and no speculative " "`peak_guard`/`peak_shave` block appeared beside it", _peak_keys == [] and sorted(PEAK_OPTION_DEFAULTS) == ["bms_soc_floor", "peak_soc_floor"]) # Graded on the parsed call syntax, not on the text: the module docstring names # `peak_cap_w` while arguing that it does not read it, so a grep would be # satisfied by deleting the explanation and would fail on writing one. _decide = next(n for n in ast.walk(ast.parse(src)) if isinstance(n, ast.FunctionDef) and n.name == "decide") _DECIDE_ARGS = [a.arg for a in _decide.args.args + _decide.args.kwonlyargs] _getters = [n for n in ast.walk(ast.parse(src)) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == "get"] print(f" peakguard.py makes {len(_getters)} options-style `.get()` calls") check("peakguard.py still READS no option: it makes no `.get()` call at all " "and takes `cap_w`, `soc` and `soc_floor` as arguments. CFG-03's three " "defaults sites are config.yaml's `options:`, config.yaml's `schema:` " "and main.py's fallbacks; PEAK_OPTION_DEFAULTS is a MIRROR of the first, " "TARIFF-02-style, not a fourth site - it is what main.py and validate.py " "read INSTEAD of typing literals", not _getters and "cap_w" in _DECIDE_ARGS and "soc" in _DECIDE_ARGS and "soc_floor" in _DECIDE_ARGS) # And the fallback in main.py must equal the shipped default, for the reason # test_validate.py's CFG-03 block spells out at length: the two diverging is a # live defect in this repo for `maintenance_charge_w`, and it is not to be # repeated on the key this guard's ceiling comes from. _fallbacks = {float(m) for m in re.findall(r'peak_cap_w"\s*,\s*([0-9]+(?:\.[0-9]+)?)', _mainsrc)} print(f" peak_cap_w: config.yaml ships {CAP_W:.0f}, main.py falls back to " f"{sorted(_fallbacks)}") check("...and every literal fallback for peak_cap_w in main.py equals the " "shipped default, so the guard's ceiling cannot differ between a " "Supervisor install and a test harness", _fallbacks and all(v == CAP_W for v in _fallbacks)) # ============================================================================== print() print("8. SAFETY-24: the energy floor under the forced discharge") # ⚠️ THE STIMULUS FIRST, AND IT IS NOT DEGENERATE. Everything below is graded on # a quarter that GENUINELY WANTS A FORCED DISCHARGE - `wanted > 0` - because the # state of charge governs nothing on a cycle where the guard was never going to # discharge. A check placed on a quiet quarter would pass with the gate removed, # with the gate inverted, and with the gate reading the wrong sensor. a_hungry = averager_at(CEIL + 900.0, 600.0) p_hungry = PeakTracker.project(a_hungry, B + timedelta(seconds=600)) WANTED = BATT_NOW + excess_w(p_hungry, CEIL) print(f" the stimulus: a quarter projecting {p_hungry.projected_w:.0f} W " f"against a {CEIL:.0f} W ceiling with " f"{QUARTER_S - p_hungry.elapsed_s:.0f} s left; a forced discharge of " f"{WANTED:+.0f} W is what would hold it") check("the stimulus really does want a forced discharge, so the SoC is a " "quantity that decides something here", WANTED > 0.0) # --- the predicate, which is the ONE place the question is answered ---------- PERMITS = discharge_permitted(SOC_OK, SOC_FLOOR) AT = discharge_permitted(SOC_FLOOR, SOC_FLOOR) UNDER = discharge_permitted(SOC_FLOOR - 1.0, SOC_FLOOR) UNREAD = discharge_permitted(None, SOC_FLOOR) NOFLOOR = discharge_permitted(SOC_OK, None) print(f" discharge_permitted: {SOC_OK:.0f} % -> {PERMITS!r}") print(f" {SOC_FLOOR:.0f} % (exactly at) -> {AT!r}") print(f" None (unreadable) -> {UNREAD!r}") check("a pack above the floor may be discharged", PERMITS is None) check("...a pack AT the floor may not - `<=`, not `<`. Permitting one more " "cycle at the floor would make the floor mean 'one cycle below the " "floor', which is the whole quantity this option names", AT == FLOOR_AT_SOC_FLOOR and UNDER == FLOOR_AT_SOC_FLOOR) check("...an UNREADABLE SoC is refused, and refused with its OWN reason. A " "failed read is not an observation, and a full battery is exactly the " "value that would disarm this floor", UNREAD == FLOOR_SOC_UNREADABLE and UNREAD != AT) check("...and no configured floor is not permission either", NOFLOOR is not None) # THE DISARMING ALTERNATIVE, COMPUTED RATHER THAN DESCRIBED. `float(soc or 100)` # is the shape NOTES.md 4.4b records the stage-5 loop actually shipping. check("...and the defaulting form that was ACTUALLY shipped once in this " "project - reading a missing SoC as a full battery - would have permitted " "it, so this is a live direction and not a hypothetical", discharge_permitted(float(None or 100), SOC_FLOOR) is None) # --- the decision ----------------------------------------------------------- d_ok = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR) d_lim = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_FLOOR, soc_floor=SOC_FLOOR) d_unread = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=None, soc_floor=SOC_FLOOR) print(f" with battery: {d_ok.explain()}") print(f" at the floor: {d_lim.explain()}") check("with battery to spare the guard sizes the forced discharge exactly as " "SAFETY-07 always did - this card changes nothing above the floor", d_ok.engaged and not d_ok.floor_limited and near(d_ok.floor_w, WANTED, 1e-6)) check("at the floor it STOPS, and the floor it files is 0 W - not the discharge " "it wanted, and not a negative number", d_lim.engaged and d_lim.floor_limited and near(d_lim.floor_w, 0.0)) check("...and it REPORTS being floor-limited rather than silently under-" "shaving: the decision carries the flag, the reason it bound, the SoC it " "read and the floor it read", d_lim.floor_limited and d_lim.soc_floor_reason == FLOOR_AT_SOC_FLOOR and near(d_lim.soc, SOC_FLOOR) and near(d_lim.soc_floor, SOC_FLOOR)) check("...and it still says what it WOULD have taken, so the under-shave is a " "number rather than a silence", near(d_lim.wanted_w, WANTED, 1e-6) and d_lim.wanted_w > d_lim.floor_w) check("...and its one-line explanation tells the operator the consequence in " "the words that matter - the quarter is going over the ceiling", "FLOOR-LIMITED" in d_lim.explain() and "WILL GO OVER THE CEILING" in d_lim.explain() and f"{CEIL:.0f} W" in d_lim.explain()) check("an unreadable SoC lands in the same branch, with the OTHER reason - so " "an operator reading one line can tell a flat battery from a dead sensor", d_unread.floor_limited and near(d_unread.floor_w, 0.0) and d_unread.soc_floor_reason == FLOOR_SOC_UNREADABLE and d_unread.soc is None) check("floor-limited and saturated are MUTUALLY EXCLUSIVE, by construction: the " "branch returns before the achievable_w clamp that sets `saturated`, so " "the two notifications can never both fire on one cycle", not d_lim.saturated and not d_unread.saturated) # --- and NOT graded where it is degenerate ---------------------------------- # The same empty pack on a quarter that is UNDER the ceiling. A gate placed one # line earlier would report FLOOR-LIMITED here - on a quiet afternoon, over a # guard that was never going to discharge - and an operator who is told the peak # will be exceeded on a quarter that is nowhere near it stops reading the # notification that matters. d_quiet = decide(PeakTracker.project(averager_at(500.0, 450.0), B + timedelta(seconds=450)), batt_w=BATT_NOW, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR) print(f" an EMPTY pack on a quiet quarter: {d_quiet.explain()}") check("an empty pack on a quarter that does not need shaving is IDLE, not " "floor-limited. The SoC is only graded where a forced discharge is " "actually being asked for - grading it earlier would raise the alarm on " "every quiet cycle of the day", not d_quiet.engaged and not d_quiet.floor_limited and d_quiet.floor_w is None) # And the BLIND branch is untouched: it never forces a discharge, so there is # nothing for an energy floor to bound there. d_blind_low = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR, frozen=True) check("...and an empty pack does not disturb the BLIND branch either: SAFETY-" "18's cut is still a 0 W floor, still blocks charging, and is still not " "reported as an energy problem", d_blind_low.engaged and near(d_blind_low.floor_w, 0.0) and d_blind_low.blind == BLIND_FROZEN and not d_blind_low.floor_limited) # --- the claim, through the real arbiter ------------------------------------ lc = peakguard.claim(d_lim) print(f" floor-limited claim: {lc.source} {lc.kind} lo={lc.lo:.0f} " f"hi={lc.hi} reason={lc.reason!r}") r_charge = resolve(SAFE + [Claim.set("loop", P_LOOP, -1500.0, "grid"), lc]) r_disch = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"), lc]) r_stop = resolve(SAFE + [Claim.set("safety", P_SAFETY, 0.0, "stopped"), lc]) print(f" loop wants to charge 1500 W: {r_charge.explain()}") print(f" loop wants to discharge 1800 W: {r_disch.explain()}") check("a floor-limited guard STILL blocks charging - 0 W is one-directional, " "it can only ever lower delivered offtake, and adding import in the " "quarter that sets the bill is the one thing it must not permit", near(r_charge.target_w, 0.0) and not r_charge.contradiction) check("...and it does NOT pull the loop off a discharge it is already doing. A " "`lo` limit is structurally incapable of capping discharge, which is why " "the action here is 0 W and never a negative floor", near(r_disch.target_w, 1800.0, 1e-9)) check("...and every P_SAFETY stop still outranks it. This is an ENERGY bound, " "not a trip: 11.2's table is unchanged by this card", near(r_stop.target_w, 0.0) and not r_stop.contradiction) check("...and its arbiter reason names the SoC floor rather than reusing " "'blind' or 'quarter peak', so the one line an operator reads tells the " "three 0 W states apart", lc.reason == "SoC floor" and peakguard.claim(d_ok).reason == "quarter peak") # --- THE PROPERTY THE WHOLE SHAPE RESTS ON: no negative floor, ever ---------- # A `lo` limit is incapable of capping discharge or export only while it is # non-negative. If this card could produce `lo < 0` it would have broken 9.1 as # a property of the object, and the guard would be capping the very thing it is # forbidden to cap. Swept rather than argued. SWEEP_NEG = SWEEP_BOTH = SWEEP_N = 0 SWEEP_HI = True # ⚠️ ARB-03 ADDS ONE DIMENSION TO THIS PROOF RATHER THAN RE-RUNNING IT UNCHANGED. # `Claim` gained an `actuator`, and the asymmetry that makes this guard safe is # now TWO facts, not one: `hi` is +inf on every claim it files, AND every claim # it files is on the SITE actuator. A `lo` that landed on one inverter's interval # would still have `hi = +inf` and would still never cap a discharge - and would # leave the SITE, the thing the capacity tariff actually bills, with no floor at # all. So the same combinations are swept for both. SWEEP_SITE = True for load in (0.0, CEIL - 500.0, CEIL, CEIL + 300.0, CEIL + 3000.0, LIVE_MAX_W * 3): for elapsed in (30.0, 450.0, 870.0): _pr = PeakTracker.project(averager_at(load, elapsed), B + timedelta(seconds=elapsed)) for bw in (-2000.0, 0.0, 400.0, 4000.0): for sc in (None, 0.0, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0): for fl in (SOC_FLOOR, BMS_FLOOR + 1.0, 55.0): _d = decide(_pr, batt_w=bw, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=sc, soc_floor=fl) SWEEP_N += 1 if _d.floor_w is not None and _d.floor_w < 0.0: SWEEP_NEG += 1 if _d.floor_limited and _d.saturated: SWEEP_BOTH += 1 _c = peakguard.claim(_d) if _c is not None and not (isinf(_c.hi) and _c.hi > 0): SWEEP_HI = False if _c is not None and _c.actuator != ACTUATOR_SITE: SWEEP_SITE = False print(f" swept {SWEEP_N} (load x elapsed x battery x SoC x floor) " f"combinations, SoC included: {SWEEP_NEG} negative floors, " f"{SWEEP_BOTH} decisions both floor-limited and saturated") check(f"NOT ONE of the {SWEEP_N} combinations produces a negative floor. That " f"is 9.1's 'discharge and export are never capped by the peak guard' as a " f"property of the object, and SAFETY-24 preserves it - the only two " f"values floor_w takes are 0.0 and a positive min(wanted, achievable)", SWEEP_NEG == 0 and SWEEP_N > 600) check("...and `hi` stays +inf on every claim any of them files", SWEEP_HI) check("...and every one of them is filed on the SITE actuator, which is the " "other half of the same asymmetry now that a claim has an actuator: the " "billed quantity is the quarter's offtake AT THE GRID CONNECTION, so a " "floor that bound one inverter's share would leave the site - the thing " "the capacity tariff meters - with no floor at all", SWEEP_SITE) check("...and no combination is both floor-limited and saturated, so the two " "notifications are exclusive on measured inputs and not merely by reading " "the code", SWEEP_BOTH == 0) # --- TEST-14: a required field must not quietly gain a default -------------- # ⚠️ PORTED AS A SCAN, NOT HAND-WRITTEN PER FIELD. TEST-14 is open because an # outcome type's required field gained a default in three separate files and no # check noticed. The pinned sets below go red if ANY field or argument moves # between required and defaulted - including a future one nobody has thought of # - which is the whole difference between a scan and a case. _sig = inspect.signature(decide) _required_kw = {n for n, prm in _sig.parameters.items() if prm.kind is prm.KEYWORD_ONLY and prm.default is prm.empty} print(f" decide() required keyword-only arguments: {sorted(_required_kw)}") check("`soc` and `soc_floor` are REQUIRED keyword-only arguments of decide() " "with no default. A default on either is TEST-14's exact shape and would " "be the quiet fallback this card exists to remove - and a default on " "`soc_floor` would additionally be CFG-03's fourth defaults site", _required_kw == {"batt_w", "cap_w", "reading", "achievable_w", "soc", "soc_floor"}) _req_fields = tuple(f.name for f in dataclasses.fields(PeakDecision) if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING) print(f" PeakDecision fields with no default: {list(_req_fields)}") check("...and PeakDecision's required fields are unchanged by this card: the " "four SAFETY-07 pinned, and the SAFETY-24 additions are genuinely " "optional reporting rather than required fields handed a default", _req_fields == ("engaged", "floor_w", "ceiling_w", "ceiling_from")) # ============================================================================== print() print("9. the exhausting replay (AC 4), and the startup refusal") # ⚠️ THE PACK IS SIZED FROM THE MEASUREMENT, NOT PICKED. `WH_07` is what the # guard actually asks for on 2026-08-20 - re-derived from the committed capture # on every run, never quoted - and the modelled pack is then chosen so the energy # between the start and the EMS floor is ONE THIRD of it. That makes "a quarter # that would exhaust the pack" true by construction of the measurement rather # than by a number typed into this file: the day wants three times what the pack # has to give above the floor, so it must run out, and without an EMS floor it # must run through the BMS one too. BAD = [pth for pth in REPLAY if "2026-08-20" in pth.name][0] WH_BAD = replay(BAD, guarded=True)["wh"] START_SOC = SOC_FLOOR + (SOC_FLOOR - BMS_FLOOR) USABLE_WH = 100.0 * (WH_BAD / 3.0) / (START_SOC - SOC_FLOOR) PACK = (USABLE_WH, START_SOC, BMS_FLOOR) # How far past the floor one cycle at the rail can carry the pack before the # next decision sees it. DERIVED from the cycle and the modelled pack, because a # tolerance typed as a constant is a tolerance that stops meaning anything. OVERSHOOT_PP = 100.0 * (LIVE_MAX_W * CYCLE_S / 3600.0) / USABLE_WH print(f" the guard's measured appetite on {BAD.name}: {WH_BAD:.0f} Wh") print(f" modelled pack: {USABLE_WH:.0f} Wh usable, starting at " f"{START_SOC:.0f} %, BMS cut at {BMS_FLOOR:.0f} %, EMS floor at " f"{SOC_FLOOR:.0f} %") print(f" -> {(START_SOC - SOC_FLOOR) / 100.0 * USABLE_WH:.0f} Wh above the " f"EMS floor, {(START_SOC - BMS_FLOOR) / 100.0 * USABLE_WH:.0f} Wh above " f"the BMS one - against {WH_BAD:.0f} Wh of appetite; one cycle at the " f"rail is {OVERSHOOT_PP:.2f} pp of this pack") check("the fixture is a pack this day genuinely exhausts, and that is derived " "from the day's own measured appetite rather than asserted: the energy " "above the BMS cut-out is less than the guard wants", (START_SOC - BMS_FLOOR) / 100.0 * USABLE_WH < WH_BAD) # TWO WORLDS, SAME PACK, SAME CAPTURE. # BEFORE: the EMS floor set to the BMS cut-out - i.e. the BMS is the sole # energy backstop, which is precisely what SAFETY-07 shipped. # AFTER: the EMS floor as config.yaml ships it. BEFORE = replay(BAD, guarded=True, pack=PACK, soc_floor=BMS_FLOOR) AFTER = replay(BAD, guarded=True, pack=PACK, soc_floor=SOC_FLOOR) print(f" BEFORE (BMS is the only backstop): ends at {BEFORE['end_soc']:.1f} %" f", low-water {BEFORE['min_soc']:.1f} %, {BEFORE['wh']:.0f} Wh out, " f"{BEFORE['at_bms']} guard cycles spent AT the BMS limit, " f"{BEFORE['limited']} floor-limited") print(f" AFTER (peak_soc_floor {SOC_FLOOR:.0f} %): ends at " f"{AFTER['end_soc']:.1f} %, low-water {AFTER['min_soc']:.1f} %, " f"{AFTER['wh']:.0f} Wh out, {AFTER['at_bms']} cycles at the BMS limit, " f"{AFTER['limited']} floor-limited") check("BEFORE: the day really does take the pack to the BMS cut-out and hold it " "there. That is the finding this card answers - not a pack destroyed, a " "pack repeatedly parked at its own protection with no EMS-side record of " "why", BEFORE["min_soc"] <= BMS_FLOOR + 1e-9 and BEFORE["at_bms"] > 0) check("AFTER: the forced discharge STOPS AT THE CONFIGURED FLOOR - to within " "the one cycle of overshoot the 15 s tick allows, derived above and not " "typed - and the pack never reaches the BMS limit at all. That is AC 1, " "replayed rather than reasoned about", AFTER["min_soc"] >= SOC_FLOOR - OVERSHOOT_PP and AFTER["at_bms"] == 0 and AFTER["min_soc"] > BMS_FLOOR + 1.0) check("...and it does not merely stop, it SAYS SO: floor-limited cycles are " "counted off the decisions themselves, so the under-shave is reported " "rather than silent", AFTER["limited"] > 0) # ⚠️ WHAT "BEFORE" IS AND IS NOT. It is the shipped code with the EMS floor set # equal to the BMS cut-out, which reproduces the pre-card ENERGY trajectory # exactly - the pack drains to the BMS and the BMS is the only thing that stops # it. What it does not reproduce is the silence: pre-card there was no gate to # report at all, whereas this run reports one at the BMS limit, by which point # the pack is already there. The trajectory is the thing being compared. check("...and the two worlds differ in the trajectory, not merely in a flag: " "before, every floor-limited cycle happens with the pack ALREADY at the " "BMS limit; after, none of them do", BEFORE["at_bms"] > 0 and AFTER["at_bms"] == 0) # THE DERIVED VERDICT: the energy the card withholds is exactly the energy # between the two floors. Not "less", not "a bit" - the difference in Wh equals # the difference in percentage points times the pack, to within one cycle. GAP_WH = (SOC_FLOOR - BMS_FLOOR) / 100.0 * USABLE_WH TOL = 2.0 * LIVE_MAX_W * CYCLE_S / 3600.0 # one cycle of overshoot per run print(f" energy withheld: {BEFORE['wh'] - AFTER['wh']:.1f} Wh, against " f"{GAP_WH:.1f} Wh between the two floors (tolerance {TOL:.1f} Wh = one " f"cycle of overshoot at the rail in each of the two runs)") check("the energy this card withholds IS the energy between the two floors, to " "within one cycle at the rail - so the floor is doing the arithmetic it " "claims and is not merely correlated with doing less", abs((BEFORE["wh"] - AFTER["wh"]) - GAP_WH) <= TOL) check("...and no cycle in either world files a negative floor, on real " "captured data and not only on the synthetic sweep", BEFORE["neg_floor"] == 0 and AFTER["neg_floor"] == 0) # THE HONEST HALF. Stopping costs money, and the check says how much rather than # looking away from it. _ub = max(BEFORE["blocks"].values()) _ab = max(AFTER["blocks"].values()) print(f" the price of the floor: worst quarter {_ub:.0f} W before, " f"{_ab:.0f} W after - a floor-limited guard shaves LESS, on purpose, and " f"the operator is told so") check("stopping at the floor costs peak shaving, and this file says so out " "loud rather than reporting only the SoC it saved: the worst quarter is " "no better with the floor in place", _ab >= _ub - 1e-6) # --- the wiring: one real cycle on an empty pack ----------------------------- # ⚠️ `maintenance_soc_floor` IS SET, AND SET TO A VALUE THAT WOULD FLIP THE # ANSWER. The pack sits one point under `peak_soc_floor` and well ABOVE the # maintenance floor, so a controller that read the maintenance key would come # out NOT limited here. Without this the two keys are indistinguishable on every # fixture in this file - a mutation swapping them survived until it was added. MAINT_LOW = 5 c24, hass24, NET24, _ = wired_over_limit( TMP / "soc24.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0, opts={"maintenance_soc_floor": MAINT_LOW}) check(f"the fixture separates the two floors: the pack at {SOC_FLOOR - 1:.0f} % " f"is under peak_soc_floor {SOC_FLOOR:.0f} % and over " f"maintenance_soc_floor {MAINT_LOW} %, so reading the wrong key gives the " f"wrong answer here", MAINT_LOW < SOC_FLOOR - 1.0 < SOC_FLOOR) print(f" a real cycle over a {NET24:.0f} W quarter with the pack at " f"{SOC_FLOOR - 1.0:.0f} %: {c24.peak_guard.explain()}") check("the REAL Controller reaches the floor-limited branch, so main.py hands " "decide() a state of charge at all", c24.peak_guard.floor_limited) check("...and the number written to the inverter is not a forced discharge - " "the pack is not spent for money below the floor", hass24.writes and hass24.writes[-1] >= -1e-6 and hass24.writes[-1] <= c24.peak_guard.wanted_w) check("...and the operator is TOLD THE PEAK WILL BE EXCEEDED, exactly once, " "with its own notification id - AC 2, and the reason failing to shave " "must not be silent", hass24.notifications.count("goodwe_peak_soc_floor") == 1 and "goodwe_peak_saturated" not in hass24.notifications) asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c24.cycle()) check("...once per episode, not once per cycle - a quarter of an hour of " "identical cards is how an operator learns to dismiss the one that " "mattered", hass24.notifications.count("goodwe_peak_soc_floor") == 1) rows24 = [r for r in c24.checks() if "peak guard" in r["text"].lower()] print(" status row: " + (rows24[0]["text"] if rows24 else "(none)")) check("the status page carries exactly one peak-guard row and it is NOT ok - " "the money is going to be spent and a human is the only actuator left", len(rows24) == 1 and not rows24[0]["ok"] and "FLOOR-LIMITED" in rows24[0]["text"]) check("...and it reaches the action log too", any("FLOOR-LIMITED" in e for e in c24.events)) # A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop # at 0 W in that state, so the guard's own answer is what is graded here. d_nosoc = decide(p_hungry, batt_w=0.0, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=None, soc_floor=SOC_FLOOR) check("decide() handed soc=None answers floor-limited rather than a full " "battery - the whole 'a failed read is not an observation' rule, on the " "one input where the disarming value is the plausible one", d_nosoc.floor_limited and not d_nosoc.saturated) # ⚠️ REVIEW-FOUND, AND THE CLAIM ABOVE USED TO BE WIDER THAN ITS CHECK. It read # "a CONTROLLER whose SoC entity does not answer hands decide() None" while # calling decide() directly and driving no Controller - so a reviewer could # insert `self.soc = 100.0 if self.soc is None else self.soc` immediately above # main.py's decide() call, which is the exact `float(soc or 100)` defaulting # discharge_permitted()'s own docstring forbids, and every check here stayed # green. The wiring property is STRONGER than the one that was claimed, and this # is it: a Controller with no SoC never reaches the guard at all, because # `inputs_ok` is False and cycle() returns at the safety stop first. Graded on # the SERVICE CALL - decide() is spied - rather than on its arguments, so a # defaulted value cannot satisfy it. _nosoc_ctl, _nosoc_hass = built(TMP / "nosoc.db") _nosoc_ctl.auto, _nosoc_ctl.soc, _nosoc_ctl.batt = True, None, 0.0 _nosoc_ctl.dev_min, _nosoc_ctl.dev_max = -LIVE_MAX_W, LIVE_MAX_W _nosoc_now = datetime.now(timezone.utc) _nosoc_blk = datetime.fromtimestamp( int(_nosoc_now.timestamp() // QUARTER_S) * QUARTER_S, timezone.utc) for _i in range(0, int((_nosoc_now - _nosoc_blk).total_seconds()) + 1, 5): _nosoc_ctl.p1.submit(sample(_nosoc_blk + timedelta(seconds=_i), CAP_W * 2)) _nosoc_ctl.grid = CAP_W * 2 _nosoc_seen = [] _nosoc_real = peakguard.decide try: peakguard.decide = lambda *a, **k: (_nosoc_seen.append(k.get("soc")), _nosoc_real(*a, **k))[1] asyncio.get_event_loop_policy().new_event_loop().run_until_complete( _nosoc_ctl.cycle()) finally: peakguard.decide = _nosoc_real print(f" a Controller with no SoC: inputs_ok={_nosoc_ctl.inputs_ok}, " f"decide() called {len(_nosoc_seen)} time(s), target " f"{_nosoc_ctl.target:+.0f} W") check("and a Controller whose SoC entity does not answer never reaches the " "guard AT ALL - `inputs_ok` is False, cycle() stops at the safety claim, " "and decide() is not called. A defaulting line above the call site " "therefore cannot disarm this floor, which is a stronger guarantee than " "handing decide() a None would be", not _nosoc_ctl.inputs_ok and not _nosoc_seen and abs(_nosoc_ctl.target) < 1e-9 and _nosoc_ctl.peak_guard is None) # --- the startup refusal, in CFG-01 / MAINT-04's manner ---------------------- def opts24(**kw): """Otherwise-startable options, so the ONLY thing under test is rule 8.""" base = {"setpoint_entity": "number.sp", "meter_source": "off", "meter_entity": "sensor.grid", "max_w": 2000, "meter_phases": 1, "meter_max_age_s": 30, "sign_disagree_w": 300, "maintenance_charge_w": 2000, "peak_soc_floor": PEAK_OPTION_DEFAULTS["peak_soc_floor"], "bms_soc_floor": PEAK_OPTION_DEFAULTS["bms_soc_floor"]} base.update(kw) return base BASE_OK = validate_startup_config(opts24()) check("the SHIPPED pair starts - a validator that refuses config.yaml's own " "defaults is a validator nobody keeps", BASE_OK == []) def rule8(problems): return [x for x in problems if "bms_soc_floor" in x] below = rule8(validate_startup_config(opts24(peak_soc_floor=BMS_FLOOR - 5))) equal = rule8(validate_startup_config(opts24(peak_soc_floor=BMS_FLOOR))) above = rule8(validate_startup_config(opts24(peak_soc_floor=BMS_FLOOR + 1))) junk = validate_startup_config(opts24(peak_soc_floor="soon")) print(f" refusal at {BMS_FLOOR - 5:.0f} %: {below[0][:96] if below else '(none)'}...") check("a floor BELOW the pack's own cut-out is refused at startup, in CFG-01 / " "MAINT-04's manner - the operator states a bound, the BMS enforces a " "different one, and nothing would say which won", len(below) == 1) check("...and so is a floor EXACTLY AT it, which is the interesting case: equal " "buys zero margin, so it can never be the thing that stops the discharge " "and the status page would report a backstop that cannot bind. Rule 6 " "permits equality because equality there leaves one working point; here " "it leaves none", len(equal) == 1) check("...while one point above is accepted, so this refuses the disarmed case " "only and not 'close to the limit'", above == []) check("...and the refusal NAMES BOTH NUMBERS and the shipped default, so the " "operator can act on it without reading the source", f"{BMS_FLOOR:.0f} %" in below[0] and f"{BMS_FLOOR - 5:.0f} %" in below[0] and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in below[0]) check("...and a non-numeric floor is refused rather than defaulted, for the " "reason every read in this file is: a value that cannot be parsed is not " "an observation of anything", any("must both be numbers" in x for x in junk)) # ⚠️ AND IT IS NOT SCOPED TO ANYTHING. Rule 6's comment gives the argument: a # contradictory pair is contradictory whether or not the feature is switched on, # and scoping it leaves the trap armed for the one operator action that cannot # be expected to change what a floor means. check("...and the rule is not scoped to meter_source or to any enable flag - " "the pair is wrong on a P1 install too, and 'turn P1 on' is not an action " "that should change what a SoC floor means", len(rule8(validate_startup_config(opts24( peak_soc_floor=BMS_FLOOR - 5, meter_source="ha_signed", p1_net_entity="sensor.net")))) == 1) # --- CFG-03: the mirror, pinned to config.yaml ------------------------------- # TARIFF-02's pattern exactly - and pinned by READING the shipped file, not by # transcribing it, which is the only version of this check that can catch drift. _found24 = {k: read_option(k) for k in PEAK_OPTION_DEFAULTS} print(f" config.yaml ships {_found24}, peakguard mirrors " f"{dict(PEAK_OPTION_DEFAULTS)}") check("every SAFETY-24 option is shipped in config.yaml's options: block", all(v is not None for v in _found24.values())) check("...and each shipped default equals PEAK_OPTION_DEFAULTS, the ONE in-code " "mirror - TARIFF-02's move, so this card adds no fourth defaults site", all(float(_found24[k]) == float(v) for k, v in PEAK_OPTION_DEFAULTS.items())) _schema24 = [k for k in PEAK_OPTION_DEFAULTS if read_schema_max(k) is not None] check("...and both are in the schema: block too, or the Supervisor drops the " "option and the add-on silently runs on the mirror instead of the " "operator's value", sorted(_schema24) == sorted(PEAK_OPTION_DEFAULTS)) _val_src = (HERE / "app" / "validate.py").read_text(encoding="utf-8") check("...and validate.py takes its fallbacks from that same mirror rather than " "typing literals - which is the divergence its own rule 6 used to " "document (maintenance_charge_w 2000 in config.yaml, 2500 in main.py), " "refused a second outing here and since closed by CFG-03", "PEAK_OPTION_DEFAULTS" in _val_src and not re.search(r'"peak_soc_floor"\s*,\s*[0-9]', _val_src) and not re.search(r'"peak_soc_floor"\s*,\s*[0-9]', _mainsrc)) check("...and 20 % is not a number typed into config.yaml on a hunch: it is 10 " "points of margin over the pack's own cut-out, which on the 13.8 kWh " "reference pack is 1.57x the guard's worst measured day", PEAK_OPTION_DEFAULTS["peak_soc_floor"] - PEAK_OPTION_DEFAULTS["bms_soc_floor"] == 10 and 0.10 * 13800.0 / WH_BAD > 1.5) check("...and it is a SEPARATE KEY from maintenance_soc_floor, whose SHIPPED " f"VALUE ({MAINT_FLOOR:.0f} %) is not this one ({SOC_FLOOR:.0f} %) - which " "is the argument for not sharing them, measured off config.yaml rather " "than asserted: they are a drain TARGET and a spend LIMIT, and the site " "that wants one does not thereby want the other", MAINT_FLOOR != SOC_FLOOR and "maintenance_soc_floor" not in PEAK_OPTION_DEFAULTS) # ============================================================================== print() print("10. SAFETY-25: the loop discharges past this floor, and how far past") # ⚠️ WHAT THIS PART IS. It is a TRIPWIRE, in MAINT-04's manner, and it records a # DEFECT rather than a guarantee. SAFETY-24's mutation 13 said in words that the # grid-following loop can take the pack past `peak_soc_floor`; that sentence has # now been written down four times across four tickets and never once measured. # This part measures it on the committed capture and pins the number, so that: # # * the honesty half of SAFETY-25 - the config comment, DOCS.md, the operator # notification and the status row - is anchored to a replayed figure rather # than to a claim, and # * THE DAY SOMEBODY BUILDS A LOOP-SIDE FLOOR, THIS GOES RED. That is the # point. The exposure must not be able to change under a green suite; it has # to be closed deliberately, and closing it means answering AC 2 and # deleting or inverting these checks along with # DECISIONS/SAFETY-25-loop-soc-floor.md's status - OPEN when this was # written, DECIDED (Option A, deferred B) since 2026-09-02. # # ⚠️ AND IT IS 10f THAT MAKES THAT SENTENCE TRUE, NOT 10a-10c. The replay # below hand-builds main.py's claim list, so on its own it is blind to a # floor filed as a NEW CLAIM - the memo's own Option B, and PR #31's # reviewer proved the blindness by building it: the whole suite stayed # green. 10f pins the claim-source set so that shape goes red too. The two # halves cover the two places a loop-side floor can be built: 10f for an # arbiter claim in Controller.cycle(), 10a-10c for a clamp inside # control.compute() or peakguard, which the replay drives directly. # # ⚠️ AND IT DRIVES THE SHIPPED CODE, not a transcription of it: control.compute() # at the shipped tuning, peakguard.decide(), peakguard.claim() and # arbiter.resolve(), in main.py's order. Break any of them and this part moves - # which is what makes it a check rather than a paragraph. See the memo section 7 # for what is modelled and what is measured. # The shipped tuning, READ from config.yaml rather than typed - the same rule as # every other number in this file. A tuning transcribed here would grade a # control law nobody ships. LOOP_TUNING = Tuning( gain=read_option("gain"), max_w=read_option("max_w"), slew_w=read_option("slew_w"), deadband_w=read_option("deadband_w"), step_w=int(read_option("step_w")), saturation_w=read_option("saturation_w"), saturation_cycles=int(read_option("saturation_cycles")), target_grid_w=read_option("target_grid_w")) LOOP_MAX_W = float(LOOP_TUNING.max_w) print(f" the shipped loop tuning, out of config.yaml: {LOOP_TUNING}") # ⚠️ THE REFERENCE PACK, DERIVED FROM THE THREE HARDWARE NUMBERS THIS REPOSITORY # ALREADY STATES, not from a rounded kWh figure typed here. peakguard.py's # PEAK_OPTION_DEFAULTS comment says "300 Ah at 51.2 V with DoD 90 - 13.8 kWh # usable"; that sentence is asserted below, so if the reference pack ever # changes this fixture goes red instead of quietly modelling the old one. REF_PACK_WH = 300.0 * 51.2 * 0.90 _pg_src = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8") check("the modelled pack is the repository's own reference pack, and the source " "still says so - 300 Ah at 51.2 V with DoD 90. A fixture whose hardware " "assumption drifts away from the module it grades is a fixture measuring " "a machine nobody owns", "300 Ah at 51.2 V with DoD 90" in _pg_src and abs(REF_PACK_WH - 13800.0) < 100.0) def evening(h0: int, h1: int): """The rows of 2026-08-20's capture inside [h0, h1) UTC, as they were. `h1` may be 24, meaning "to the end of the capture" - the file runs to 21:59:56 UTC, which is local midnight. """ lo = datetime(2026, 8, 20, h0, tzinfo=timezone.utc) hi = (datetime(2026, 8, 21, tzinfo=timezone.utc) if h1 >= 24 else datetime(2026, 8, 20, h1, tzinfo=timezone.utc)) out = [] for line in gzip.open(BAD, "rt", encoding="utf-8"): row = json.loads(line) t = datetime.fromisoformat(row["t"]) if lo <= t < hi: out.append((t, float(row["w"]))) return out def replay_loop(window, *, start_soc, soc_floor, usable_wh=REF_PACK_WH): """The GRID-FOLLOWING LOOP over a window, closed-loop, with a modelled pack. part 6's `replay()` models the PEAK GUARD's discharge and pins the SoC above the floor on purpose - it is grading SAFETY-07. This one models the thing SAFETY-07 never claimed to bound: ordinary self-consumption. The cycle is main.py's, in main.py's order and with main.py's claims: peakguard.decide(...) the guard sees this cycle's SoC control.compute(...) the loop sees the meter minus the battery arbiter.resolve([supervised, loop@P_LOOP, peakguard.claim(...)]) ⚠️ THE ARBITER IS IN THE PATH AND THAT IS THE WHOLE POINT. Cutting it out and reading `Decision.target_w` directly would measure the control law and prove nothing about the floor: the question is whether the guard's `lo` claim, filed at P_PEAK against a loop winning at P_LOOP, can cap the discharge. It cannot, and it is `resolve()` that has to say so. The pack model is part 9's, one line for one line - SoC falls by the DELIVERED discharge integrated over the real telegram stamps, the BMS is a hard cut at `bms_soc_floor`, and nothing recharges inside the window. `start_soc` is the one number the captures cannot supply (they carry meter power only), and it is derived from the shipped option rather than picked. """ ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S) store = fresh_store(f"s25-{window[0][0]:%H%M}-{soc_floor:.0f}-{start_soc:.0f}") tracker = PeakTracker(store, cap_w=CAP_W) batt = 0.0 # delivered discharge, W (positive) prev_w = i_w = 0.0 sat = 0 soc = float(start_soc) min_soc = soc wh = 0.0 prev_t = next_cycle = None cycles = below = below_discharging = limited = limited_discharging = 0 at_bms = guard_floors = 0 max_granted = 0.0 for t, w in window: if prev_t is not None: dwh = batt * (t - prev_t).total_seconds() / 3600.0 wh += dwh soc = max(0.0, soc - 100.0 * dwh / usable_wh) min_soc = min(min_soc, soc) prev_t = t ing.submit(sample(t, w - batt)) tracker.sync(ing) if next_cycle is not None and t < next_cycle: continue next_cycle = t + timedelta(seconds=CYCLE_S) cycles += 1 d = decide(PeakTracker.project(ing.averager, t), batt_w=batt, cap_w=CAP_W, reading=tracker.peak(t), achievable_w=LOOP_MAX_W, max_hold_s=MAX_HOLD_S, soc=soc, soc_floor=soc_floor, frozen=False) dec = compute(prev_w, w - batt, batt, LOOP_TUNING, sat_count=sat, i_w=i_w) sat, i_w = dec.sat_count, dec.i_w claims = [Claim.limit("supervised", P_SAFETY, -LOOP_MAX_W, LOOP_MAX_W, "max_w"), Claim.set("loop", P_LOOP, dec.target_w, dec.reason)] pc = peakguard.claim(d) if pc is not None: claims.append(pc) if pc.lo > 0.0: guard_floors += 1 r = resolve(claims) prev_w = r.target_w # THE BMS, MODELLED - part 9's line, for part 9's reason. The pack's own # cut-out is a real floor and it works; the finding is that it is the # only one. if soc <= BMS_FLOOR: at_bms += 1 batt = 0.0 else: batt = max(0.0, r.target_w) max_granted = max(max_granted, batt) if soc < soc_floor: below += 1 below_discharging += 1 if batt > 0.0 else 0 if d.floor_limited: limited += 1 limited_discharging += 1 if batt > 0.0 else 0 return {"cycles": cycles, "wh": wh, "min_soc": min_soc, "end_soc": soc, "below": below, "below_discharging": below_discharging, "limited": limited, "limited_discharging": limited_discharging, "at_bms": at_bms, "guard_floors": guard_floors, "max_granted": max_granted} # --- 10a. the sustained-load evening ---------------------------------------- # 19:00-22:00 UTC is 21:00-24:00 local: the sun is down, the PV is off, and the # house is simply drawing. Chosen for that property and CHECKED for it below - # a window with export in it would let the loop recharge and the replay would be # measuring something else. EVE = evening(19, 24) EVE_W = [w for _, w in EVE] print(f" the evening of 2026-08-20, {EVE[0][0]:%H:%M}-{EVE[-1][0]:%H:%M} UTC: " f"{len(EVE)} telegrams, mean {sum(EVE_W) / len(EVE_W):.0f} W, " f"min {min(EVE_W):.0f} W, max {max(EVE_W):.0f} W") check("the window really is a SUSTAINED-LOAD evening and not a quiet one - " "every telegram in it is an IMPORT, so the pack has nowhere to recharge " "from and the loop is discharging for the whole three hours", min(EVE_W) > 0.0 and len(EVE) > 1500) # ⚠️ ONE POINT ABOVE THE FLOOR, DERIVED FROM THE SHIPPED OPTION. This is the # state an operator who has read `peak_soc_floor: 20` believes is protected: the # pack is above the number they set. It is the only fixture parameter the # captures cannot supply, and it is taken from config.yaml rather than chosen. EVE_START = SOC_FLOOR + 1.0 EV = replay_loop(EVE, start_soc=EVE_START, soc_floor=SOC_FLOOR) print(f" the reference pack ({REF_PACK_WH:.0f} Wh usable) starting at " f"{EVE_START:.0f} %, one point above peak_soc_floor {SOC_FLOOR:.0f} %:") print(f" ends at {EV['end_soc']:.1f} %, low-water {EV['min_soc']:.1f} %, " f"{EV['wh']:.0f} Wh delivered by the LOOP over {EV['cycles']} cycles") print(f" {EV['below']} cycles with the pack under peak_soc_floor, " f"{EV['below_discharging']} of them still discharging; " f"{EV['at_bms']} at the BMS cut-out; the guard filed a floor on " f"{EV['guard_floors']}") check(f"THE FINDING, REPLAYED. One sustained-load evening takes the pack from " f"above peak_soc_floor to the pack's own BMS cut-out - " f"{EVE_START:.0f} % -> {EV['end_soc']:.1f} % against a " f"{BMS_FLOOR:.0f} % cut-out - and the EMS floor an operator set is not " f"what stopped it", EV["min_soc"] <= BMS_FLOOR + 0.05 and EV["at_bms"] > 0) check("...and it is not one cycle of overshoot past the floor either: the loop " "goes on delivering discharge for hundreds of cycles with the pack " "already below the number the operator configured", EV["below_discharging"] > 100 and EV["below"] > EV["below_discharging"]) check("...and the peak guard is not involved at all. It never files a floor on " "this evening, because 830 W of house load never projects a quarter over " "the 3500 W ceiling - so `peak_soc_floor` is not merely too weak here, it " "is never consulted", EV["guard_floors"] == 0 and EV["limited"] == 0) # --- 10b. and it is a BEHAVIOUR, not a config value ------------------------- # ⚠️ THE SWEEP IS THE ARGUMENT. "Raise peak_soc_floor" is the first thing anyone # reading the option name would try, so the check is not that it is insufficient # - it is that it does NOTHING, at any value the schema will accept, to four # decimal places. Both ends of the schema range are swept rather than a # comfortable middle. SOC_SCHEMA_LO = float(read_schema_min("peak_soc_floor")) SOC_SCHEMA_HI = float(read_schema_max("peak_soc_floor")) SWEEP_FLOORS = [SOC_SCHEMA_LO, BMS_FLOOR + 1.0, SOC_FLOOR, SOC_SCHEMA_HI] SWEPT = [(f, replay_loop(EVE, start_soc=EVE_START, soc_floor=f)) for f in SWEEP_FLOORS] for f, res in SWEPT: print(f" peak_soc_floor {f:5.0f} % -> ends at {res['end_soc']:.4f} %," f" {res['wh']:.2f} Wh out, guard floors {res['guard_floors']}") check(f"the exposure is a BEHAVIOUR, NOT A CONFIG VALUE - the same evening ends " f"at the same state of charge at every peak_soc_floor the schema accepts " f"({SOC_SCHEMA_LO:.0f}..{SOC_SCHEMA_HI:.0f} %), to four decimal places. " f"This is AC 4's sentence, measured: the option an operator would reach " f"for cannot move this number by any amount at all", len({round(res["end_soc"], 4) for _, res in SWEPT}) == 1 and len({round(res["wh"], 4) for _, res in SWEPT}) == 1) # ⚠️ THE SWEEP DELIBERATELY OVERRUNS WHAT VALIDATE WOULD ACCEPT. Its low end is # the SCHEMA's, and rule 8 refuses anything at or below `bms_soc_floor`, so a # start at 5 % is not a configuration an operator could boot. Including it is the # point: it closes "you swept only the legal band" without leaving the reader to # wonder what happens outside it. The band that IS startable is bounded below by # rule 8, and BMS_FLOOR + 1 is its first member, which is in the sweep too. check("...and the sweep spans the whole schema range, rule 8's refused low end " "included, so 'a legal value would have worked' is closed off rather " "than left open. The first STARTABLE floor - one point above " "bms_soc_floor - is swept as well, and lands on the same number", SOC_SCHEMA_HI >= 60.0 and SOC_SCHEMA_LO <= BMS_FLOOR and (BMS_FLOOR + 1.0) in SWEEP_FLOORS and len([f for f in SWEEP_FLOORS if f > BMS_FLOOR]) >= 3) # --- 10c. the two states in the SAME cycle ---------------------------------- # The evening never engages the guard, so it cannot show the sharpest form of # the finding: the status page saying FLOOR-LIMITED while the pack empties. The # same day's high-load block can, and it is the same capture and the same code. HOT = evening(15, 17) HOT_R = replay_loop(HOT, start_soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR) print(f" the same day's high-load block " f"{HOT[0][0]:%H:%M}-{HOT[-1][0]:%H:%M} UTC, pack started one point BELOW " f"the floor:") print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, " f"{HOT_R['limited_discharging']} of them with the loop simultaneously " f"granted discharge (max {HOT_R['max_granted']:.0f} W)") check("THE ASYMMETRY, ON REAL DATA. On every cycle where the guard reports " "FLOOR-LIMITED - 'commanding no forced discharge' on the status page and " "in the operator's notification - the arbiter is at the same instant " "granting the LOOP a discharge. A `lo` limit cannot cap a discharge, so " "the guard's floor and the pack emptying are not alternatives; they " "happen together", HOT_R["limited"] > 10 and HOT_R["limited_discharging"] == HOT_R["limited"] and HOT_R["max_granted"] > 0.0) check("...which is why SAFETY-24's floor could not be widened into this one, " "and why the fix is not a bigger number. Recorded in " "DECISIONS/SAFETY-25-loop-soc-floor.md, where AC 2 was DECIDED on " "2026-09-02: Option A, the BMS stays the only backstop, and the " "loop-side reserve is DEFERRED to ALLOC-01 - so this replay is not a " "pending exposure but the RECORDED behaviour, and part 10 stays as its " "tripwire", HOT_R["min_soc"] <= BMS_FLOOR + 0.05) # --- 10d. the trap in the obvious fix, checked rather than asserted --------- # ⚠️ THE MEMO'S CENTRAL ENGINEERING CLAIM, RUN THROUGH THE REAL ARBITER. The # first shape anyone reaches for is a `hi` limit on the loop - and filed while # the peak guard is asking for a positive floor it is `lo > hi`, which # arbiter.py resolves to 0 W: THE BATTERY PARKED, in the one quarter of the # month that sets the bill. That is the same crossing-limits hazard # shaping_suspended() already exists to avoid, and it is why the memo's Option B # carries an ORDERING RULE rather than a number. _RESERVE_HI = Claim.limit("reserve", P_LOOP, hi=0.0, reason="loop SoC floor") _naive = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"), peakguard.claim(d_ok), _RESERVE_HI]) _ordered = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"), lc, _RESERVE_HI]) print(f" a naive loop reserve while the guard wants a floor: " f"{_naive.explain()}") print(f" the same reserve while the guard is floor-limited: " f"{_ordered.explain()}") check("a loop-side `hi` reserve filed while the peak guard is asking for a " "POSITIVE floor is a CONTRADICTION and parks the battery at 0 W - so the " "obvious shape of AC 2 has a trap in it, and this is it, run rather than " "described", _naive.contradiction and near(_naive.target_w, 0.0)) check("...and the ordering the memo proposes removes it: with " "loop_soc_floor <= peak_soc_floor the guard is ALREADY floor-limited " "wherever the reserve binds, so it files lo = 0, and lo == hi == 0 " "resolves to a clean 0 W with no contradiction", not _ordered.contradiction and near(_ordered.target_w, 0.0) and near(lc.lo, 0.0) and peakguard.claim(d_ok).lo > 0.0) # --- 10e. the honesty half, pinned to the shipped text ---------------------- # ⚠️ ASSERT THE STRING, NOT THE INTENT - TEST-06's lesson on SAFETY-18's NOT # COVERED row, which was untested for exactly this reason: the one branch that # exists to tell an operator what a guard does NOT cover is the one nothing # grades. These four surfaces are the whole of AC 3, and each is checked where # it actually renders. _cfg_src = (HERE / "config.yaml").read_text(encoding="utf-8") _docs_src = (HERE / "DOCS.md").read_text(encoding="utf-8") _memo = HERE / "DECISIONS" / "SAFETY-25-loop-soc-floor.md" check("config.yaml says next to the option itself that peak_soc_floor bounds " "the peak guard only and is not a pack-wide reserve - the operator edits " "this file, so it is the first place the scope has to be true", "BOUNDS THE PEAK GUARD ONLY" in _cfg_src and "NOT A PACK-WIDE RESERVE" in _cfg_src.upper() and "bms_soc_floor) stops it" in _cfg_src) check("...and DOCS.md says it in the options table AND in the section, with the " "replayed evening rather than an assurance", "not a pack-wide reserve" in _docs_src and "IT BOUNDS THE PEAK GUARD ONLY" in _docs_src and "SAFETY-25" in _docs_src) # ⚠️ THE DECISION LANDED (2026-09-02, Option A) AND THIS CHECK MOVED WITH IT. # It used to pin "Status: OPEN. This is a memo, not a decision." - the memo being # HONEST that AC 2 was unanswered. AC 2 is answered now, so the same check pins # the same property in its new form: the memo states WHICH option, WHEN, and its # REVISIT TRIGGER, and it still carries the options and the recommendation # UNCHANGED. A memo that recorded the verdict and deleted the reasoning would be # the failure this is guarding against, which is why "Recommendation" and both # rejected options are still asserted here rather than only the status line. _memo_txt = _memo.read_text(encoding="utf-8") check("...and the decision memo AC 1 asks for is COMMITTED, records AC 2 as " "DECIDED with a date, an option and a revisit trigger, and STILL carries " "the options analysis and the recommendation that were put to the owner", _memo.exists() and "Status: DECIDED 2026-09-02 — Option A." in _memo_txt and "ALLOC-01" in _memo_txt and "Recommendation" in _memo_txt and "### Option A" in _memo_txt and "### Option B" in _memo_txt) check("...and it says plainly that part 10f - the pin that made SAFETY-25 " "mergeable - is a SCALAR SoC sweep against a single-series capture and " "so cannot express two packs. The tripwire's own limits belong in the " "memo that leans on it, or the next reader merges a two-pack reserve on " "a green check that cannot see the failure", "10f" in _memo_txt and "SAFETY-27" in _memo_txt and "cannot express two packs" in _memo_txt.lower()) check("...and DOCS.md sends the reader to it rather than leaving the open " "question implicit", "DECISIONS/SAFETY-25-loop-soc-floor.md" in _docs_src) # The two OPERATOR-FACING surfaces, rendered by the real Controller rather than # grepped: the status row and the notification body are what a human reads at # the moment the guard stops, and a scope sentence that lives only in a source # comment is a scope sentence the operator never sees. c25, hass25, NET25, _ = wired_over_limit( TMP / "soc25.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0) rows25 = [r for r in c25.checks() if "peak guard" in r["text"].lower()] print(" status row: " + (rows25[0]["text"] if rows25 else "(none)")) check("the STATUS PAGE row for a floor-limited guard says the floor bounds the " "guard only and that self-consumption may keep discharging below it. " "Without that sentence the page implies a pack-wide reserve at the exact " "moment the replay above says the loop is still emptying the pack", len(rows25) == 1 and "PEAK GUARD FLOOR-LIMITED" in rows25[0]["text"] and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper() and "not a pack-wide reserve" in rows25[0]["text"] and "BMS cut-out" in rows25[0]["text"]) # ⚠️ THE ROW KEEPS THE WHOLE SENTENCE; THE BANNER DOES NOT. status_payload() # renders the first not-ok/not-warn row as the page headline, so before this the # loudest element on the page was the same 550-character paragraph the row three # lines below repeats verbatim. The scope wording is not weakened anywhere it # matters - it is the ROW that an operator reads to learn what the floor bounds, # and the row is asserted in full above. _pay25 = c25.status_payload() print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}") # The guard's own line, which the banner has always carried and which is not # this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter # that is going over the ceiling. _expl25 = rows25[0]["text"].split(". NOTE:")[0] check("the NOT READY banner is the guard's own line plus a SHORT scope tag - " "under 90 characters of it - and NOT the row's whole paragraph repeated " "at the top of the page. A headline that is a paragraph is a headline " "nobody reads, and this one is followed three lines later by the same " "text verbatim", _pay25["level"] == "bad" and _pay25["banner"].startswith("NOT READY") and _expl25 in _pay25["banner"] and "BOUNDS THE PEAK GUARD ONLY" in _pay25["banner"].upper() and "not a pack-wide reserve" not in _pay25["banner"] and len(_pay25["banner"]) - len(_expl25) < 90) check("...and shortening the banner did NOT shorten the row: the full AC 3 " "sentence is still what the checks list carries, which is the surface the " "operator reads to find out what the floor does not bound", len(rows25[0]["text"]) > 300 and "not a pack-wide reserve" in rows25[0]["text"] and "BMS cut-out" in rows25[0]["text"]) _body25 = hass25.last_message("goodwe_peak_soc_floor") print(f" notification body tail: ...{_body25[-180:]}") check("...and so does the persistent notification, which outlives the page and " "is the thing an operator reads on a phone at 19:00. It names what has " "stopped - the guard buying a lower bill - and what has not", "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body25 and "not a" in _body25 and "pack-wide reserve" in _body25 and "BMS cut-out" in _body25) # --- 10f. THE CLAIM-SOURCE SET, PINNED -------------------------------------- # ⚠️ THIS IS WHAT MAKES 10a-10c A TRIPWIRE RATHER THAN A SNAPSHOT, and it is # here because the first cut of this part did NOT have it and the tripwire # sentence above was therefore FALSE. `replay_loop()` hand-builds main.py's # claim list as a literal `[supervised, loop, peakguard.claim(...)]`, so it is # structurally BLIND to any claim main.py ADDS - which is exactly where AC 2 # would be built. PR #31's reviewer proved it by building the fix: they # implemented the memo's own Option B in main.py - a # `Claim.limit("reserve", P_LOOP, hi=0.0)` filed under a `loop_soc_floor` - and # the whole suite stayed GREEN, this part included, while mutation 13 went on # printing "NOTHING PREVENTS IT, ON PURPOSE" and the same 21 % -> 10.0 % on a # tree where something DID prevent it. Pushed to an always-binding 95 % reserve # it was still green here. That is this repository's named failure mode - a # check that observed something other than what it claimed to observe - and it # had been committed as a guarantee in four places. # # So the MODEL is now pinned to the MODELLED THING, two ways, because the two # fail differently: # # * STATICALLY, over `Controller.cycle()`'s own AST. Every `claims.append(...)` # in it must be one this scan can read, and the set of sources it can file # must be exactly the eight below. A ninth - a `reserve`, whatever it ends # up called - takes this red on the line it is written, in every # configuration, whether or not any fixture here reaches the state that # files it. # * DYNAMICALLY, through REAL `Controller.cycle()`s with main.py's OWN # `resolve` spied, swept over the WHOLE state-of-charge range in the two # states 10a-10c replay. The static scan pins the NAMES; this pins that the # names actually reach the arbiter and that nothing else does. A claim that # appears only at a low SoC - precisely the shape a loop-side floor has - # cannot hide from a sweep that visits 0 %. # # The third way a loop-side floor could be built is INSIDE `control.compute()` # or the guard (the memo's Option D), and that needs nothing new here: the # replay above drives both, so it moves the numbers in 10a-10c directly. # The whole inventory of claim sources main.py can file, in any configuration. # ⚠️ ADDING TO THIS SET IS A DELIBERATE ACT. If you are here because you added a # claim to `Controller.cycle()` and this went red, that is the tripwire working: # read DECISIONS/SAFETY-25-loop-soc-floor.md and update part 10 and mutation 13 # rather than this line alone. ⚠️ AC 2 IS DECIDED (2026-09-02, Option A) AND # THAT DOES NOT RETIRE THIS SET - Option B is DEFERRED, not rejected, and # "deferred" is exactly the state in which a ninth source gets built by # accident. Its revisit trigger is ALLOC-01, under which the reserve becomes a # PER-INVERTER interval at the allocator rather than a ninth claim here. CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak", "arbitrage", "maintenance", "loop"} # The two appends that file a claim BUILT ELSEWHERE in the method. Named here so # that a THIRD such indirection reads as an unrecognised append rather than as a # silent gap in the scan. CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"} _main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8")) _cycle_def = next( f for cls in ast.walk(_main_tree) if isinstance(cls, ast.ClassDef) and cls.name == "Controller" for f in cls.body if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle") def claim_source(node): """The source string an appended expression files, or None if unreadable.""" if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "Claim" and node.func.attr in ("set", "limit") and node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str)): return node.args[0].value if isinstance(node, ast.Name): return CLAIM_BUILDERS.get(node.id) return None _appends = [n for n in ast.walk(_cycle_def) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr == "append" and isinstance(n.func.value, ast.Name) and n.func.value.id == "claims" and len(n.args) == 1] _filed = [claim_source(n.args[0]) for n in _appends] print(f" main.py Controller.cycle() files {len(_appends)} claims, from " f"{len(CLAIM_SOURCES)} sources: " f"{', '.join(sorted({s for s in _filed if s}))}") check("every `claims.append(...)` in main.py's cycle is one this scan can READ " "- a claim filed through a shape it cannot parse has to fail here rather " "than be counted as nothing, which is how a scan like this goes quietly " "vacuous", len(_appends) >= 10 and None not in _filed) check("...and the sources that cycle can file are EXACTLY these eight. THIS is " "what makes part 10 a tripwire: replay_loop() hand-builds the claim list, " "so it cannot see a claim main.py adds - and a loop-side SoC reserve is a " "NINTH source. Filing one takes this line red on the line it is written", set(_filed) == CLAIM_SOURCES) def cycle_claims(run): """The claim list main.py's OWN `resolve` was handed, from a real cycle. ⚠️ SPIED AT `app.main.resolve`, not reconstructed. The point of the whole sub-part is that this file must stop believing its own transcription of main.py's claim list, so the observation has to be taken from main.py's call site and nowhere else. """ seen = [] real = app_main.resolve def _spy(claims, **kw): # ⚠️ `**kw` PASSES THE ROSTER THROUGH UNREAD, AND THAT IS THE WHOLE # ADAPTATION ARB-03 MADE HERE. main.py now calls # `resolve(claims, units=self.units)`; a spy with a fixed signature would # raise a TypeError on every real cycle, which is loud, but a spy that # DROPPED the roster would be silently grading a different arbitration # from the one main.py performs. What is observed - `claims`, the list # itself - is unchanged, so the two halves below still see exactly what # they saw before, and a ninth source still goes red. seen.append(list(claims)) return real(claims, **kw) app_main.resolve = _spy try: out = run() finally: app_main.resolve = real return out, (seen[-1] if seen else []) def run_cycle(ctl): asyncio.get_event_loop_policy().new_event_loop().run_until_complete( ctl.cycle()) return ctl # The IDLE state, which is what 10a and 10b replay: a sustained house load well # under the ceiling, so the guard files nothing and the loop is alone with the # safety limits. One controller, cycled once per state of charge - the load is # the evening's own mean, so this is the evening's cycle at every SoC it passes # through and at every SoC it does not. EVE_MEAN_W = sum(EVE_W) / len(EVE_W) c_src, _ = built(TMP / "sources.db") c_src.auto, c_src.batt, c_src.soc = True, 0.0, 100.0 _blk_src = datetime.fromtimestamp( int(datetime.now(timezone.utc).timestamp() // QUARTER_S) * QUARTER_S, timezone.utc) for _i in range(0, int((datetime.now(timezone.utc) - _blk_src).total_seconds()) + 1, 5): c_src.p1.submit(sample(_blk_src + timedelta(seconds=_i), EVE_MEAN_W)) c_src.grid = EVE_MEAN_W # ⚠️ THE WHOLE RANGE, INCLUDING 0 %. A reserve conditioned on a low pack is # invisible to a sweep that starts at a comfortable one, and "conditioned on a # low pack" is the definition of the claim this is watching for. SRC_SWEEP = sorted({float(s) for s in range(0, 101, 5)} | {BMS_FLOOR, SOC_FLOOR, EVE_START, EV["end_soc"]}) IDLE_SRC = {} for _soc in SRC_SWEEP: c_src.soc = _soc _, _cl = cycle_claims(lambda: run_cycle(c_src)) IDLE_SRC[_soc] = frozenset(c.source for c in _cl) IDLE_SETS = set(IDLE_SRC.values()) print(f" a real idle cycle at {len(SRC_SWEEP)} states of charge " f"({min(SRC_SWEEP):.0f}..{max(SRC_SWEEP):.0f} %) files: " + " | ".join(sorted(", ".join(sorted(s)) for s in IDLE_SETS))) check("a REAL Controller.cycle() in the state 10a and 10b replay - auto, P1 " "healthy, the guard idle under an 830 W load - hands the arbiter exactly " "{device, supervised, loop}, at EVERY state of charge from 0 to 100 %. " "Not one source appears at a low pack that does not appear at a full one, " "which is the claim a loop-side reserve would break", IDLE_SETS == {frozenset({"device", "supervised", "loop"})} and len(SRC_SWEEP) >= 21 and min(SRC_SWEEP) == 0.0) # The ENGAGED state, which is what 10c replays: the guard over its ceiling and # floor-limited on an empty pack. Two states of charge, both under the floor, # because that is the band a loop-side reserve would be conditioned on. HOT_SRC = set() for _soc in (SOC_FLOOR - 1.0, 0.0): (c_h, _h, _n, _r), _cl = cycle_claims( lambda: wired_over_limit(TMP / f"sources-hot-{_soc:.0f}.db", LIVE_MAX_W * 0.3, soc=_soc)) HOT_SRC.add(frozenset(c.source for c in _cl)) _hot_limited = c_h.peak_guard is not None and c_h.peak_guard.floor_limited print(" a real FLOOR-LIMITED cycle files: " + " | ".join(sorted(", ".join(sorted(s)) for s in HOT_SRC))) check("...and with the guard actually FLOOR-LIMITED - 10c's state, on an empty " "pack - the set gains the guard's own claim and NOTHING ELSE. So the " "three sources replay_loop() hand-builds are the whole cycle in both of " "the states it replays, rather than a subset of it that happened to " "match", _hot_limited and HOT_SRC == {frozenset({"device", "supervised", "loop", "peak"})}) # THE FOLD, stated rather than assumed. replay_loop() files ONE `supervised` # limit where main.py files two - `device`, from the inverter's own reported # range, and `supervised`, from `max_w`. Both are symmetric P_SAFETY limits, so # the tighter one binds and modelling them as one is exact, not an # approximation. Checked on the claims the real cycle actually filed. _safety_lims = [c for c in _cl if c.source in ("device", "supervised")] check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into " "its one `supervised` claim is exact rather than convenient: both are " "symmetric LIMIT claims at P_SAFETY, so the tighter of the pair binds and " "one claim at the tighter bound is the same constraint", len(_safety_lims) == 2 and all(c.kind == "limit" and c.priority == P_SAFETY and near(c.lo, -c.hi) and c.hi > 0.0 for c in _safety_lims) and near(LOOP_MAX_W, read_option("max_w"))) # --- 10g. ARB-03: AND THE ACTUATOR EACH OF THEM IS FILED ON ----------------- # ⚠️ THE SAME PIN, ONE DIMENSION WIDER, AND FOR THE SAME REASON. 10f pins WHICH # sources main.py can file. ARB-03 gave every claim an `actuator`, and a claim # routed to the wrong stage is a defect 10f cannot see: the source set is # unchanged when the peak floor is misfiled onto one inverter, and the site is # then left with no floor at all in the quarter that sets the month's bill. # # It is also what keeps SAFETY-25 §0c honest from the other side. That table # says the deferred loop reserve becomes a PER-INVERTER `hi` at stage A while # the peak floor stays a SITE `lo` at stage B, and calls the crossing # structurally impossible. The structure is `CLAIM_SCOPE`, so `CLAIM_SCOPE` is # pinned to the AST-scanned set rather than trusted to agree with it. check("every source main.py can file is CLASSIFIED site or per-inverter, and " "nothing else is. A ninth source now goes red twice - once here for " "having no classification, once above for being a ninth - and it takes an " "argument about what the claim is ABOUT to make either line green", set(CLAIM_SCOPE) == CLAIM_SOURCES) print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE)) + " | per-inverter: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT))) check("...and the split is the one ARB-03 argued: the four about the GRID " "CONNECTION are site claims and the four about a pack, a device or one " "write are per-inverter", {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == {"loop", "peak", "arbitrage", "safety"} and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == {"device", "supervised", "failsafe", "maintenance"}) # The shipped roster is ONE inverter, so §6.2's identity holds and every claim a # real cycle files is a site claim. Observed on the claims main.py's own resolve # was handed, not assumed from the routing table. _real_acts = {(c.source, c.actuator) for c in _cl} print(f" the real FLOOR-LIMITED cycle files: " + ", ".join(f"{s}@{a}" for s, a in sorted(_real_acts))) check("at the SHIPPED one-inverter roster every claim a real cycle files is on " "the SITE actuator, per-inverter sources included. That is §6.2's " "identity - one inverter means the fabricated grid reading and the " "battery setpoint are the same number, so there is one actuator and it is " "both - and it is why ARB-03 is a pure refactor here", all(a == ACTUATOR_SITE for _, a in _real_acts)) check("...and the arbiter agrees the routing is legal, on the list main.py " "actually handed it", scope_violations(_cl) == []) # And the day the roster grows, the SAME cycle must partition. Graded through # main.py's own `actuator_of()` on a real Controller - the method every # `claims.append` above routes through - rather than through a copy of it. # # ⚠️ DEV-02: THE ROSTER IS GROWN BY ADDING A REAL `Inverter`, NOT BY SETTING # `c.units`. It used to be a tuple somebody could assign beside a roster of one, # i.e. a site claiming two actuators with one writer - which is a half-driven # site with nothing to say so - and `Controller.units` is now DERIVED from # `Controller.inverters` and has no setter. Building the object is also what # makes these checks mean more than they did: the second unit has its own # setpoint entity, its own rating and its own device range, so the claims below # are filed from ITS numbers rather than from a copy of A's. # # ⚠️ INVERTER B DOES NOT EXIST YET. Its rating here is a HYPOTHESIS: nothing in # this file is a measured parameter for a second device (SPIKE-04 measures one). # What is graded is the ARCHITECTURE - which actuator each claim lands on, and # whose number each per-unit bound is built from. # # ⚠️ AND IT IS 3000 W RATHER THAN A's 5000 W, WHICH IS NOT COSMETIC. THE RULE, # STATED AT THE LINE THAT NEEDED IT: ANY CHECK ASSERTING "X DIFFERS FROM Y" MUST # BE RUN ONCE WITH X AND Y ACTUALLY MADE DIFFERENT. This helper used to default # B's rating and device range to A's `LIVE_MAX_W`, and the AC 4 check below # compared inverter B's `supervised` bound against `_b2.max_w` - which was # LITERALLY THE SAME NUMBER as A's. It read as evidence and observed nothing: # filing both per-unit claims from `self.inverters[0]`'s numbers instead of # `inv`'s left all 19 files green. That is the third check this session whose # DATA could not reach the failure it named (ARB-03's call-site scan saw 3 of 8 # sites; ALLOC-01's replay generator saw 0 escapes where a widened one saw # 39,991), so the number is different HERE, once, for every fixture in the file. B_RATING_HYPOTHESIS = LIVE_MAX_W - 2000.0 def second_inverter(ctl, *, name="inverter-b", setpoint="number.sp_b", max_w=None, dev=None): """Append a real second `Inverter` to `ctl`, and return a restore().""" rating = B_RATING_HYPOTHESIS if max_w is None else max_w inv = Inverter(name, setpoint_entity=setpoint, soc_entity="sensor.soc_b", batt_entity="sensor.batt_b", max_w=rating, store=ctl.store) inv.dev_min, inv.dev_max = dev or (-rating, rating) # The readings a real cycle needs from it. `inputs_ok` requires EVERY unit # to have answered, which is the point - a pack nothing can grade stops the # site rather than being quietly dropped from the split. inv.soc, inv.batt = ctl.inverters[0].soc, 0.0 original = list(ctl.inverters) ctl.inverters.append(inv) def restore(): ctl.inverters[:] = original return inv, restore _b, _restore_b = second_inverter(c_src) _routed = {s: c_src.actuator_of(s) for s in CLAIM_SOURCES} _restore_b() print(" with a second inverter on the roster, main.py routes: " + ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items()))) check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second " "inverter and its OWN routing method splits the eight: `device`, " "`supervised`, `failsafe` and `maintenance` bind the unit this controller " "drives, `loop`, `peak`, `arbitrage` and `safety` stay on the grid " "connection. Swap any one of them - route `peak` to the unit and the site " "keeps no floor; route `maintenance` to the site and it can cross that " "floor into `lo > hi` - and this line goes red", _routed == {"device": "inverter-a", "supervised": "inverter-a", "failsafe": "inverter-a", "maintenance": "inverter-a", "loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE, "arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE}) check("...and the roster it was restored to is the shipped one, so nothing " "below inherits a two-inverter Controller", c_src.units == (app_main.UNIT_A,) and len(c_src.inverters) == 1) # ⚠️ AND THE ROUTING TABLE BEING RIGHT IS NOT THE SAME AS THE CALL SITES USING # IT RIGHT. `actuator_of("maintenance")` written at the `device` append routes a # rating limit to whatever `maintenance` routes to, and at the shipped # one-inverter roster BOTH answers are the site, so nothing above can see it. # ⚠️ AND THE DYNAMIC CYCLE BELOW SEES ONLY THREE OF THE EIGHT DIRECT APPENDS. # That was THIS PART committing this repository's own named failure - a check # observing something other than what it claimed - inside the check written to # prevent it. The first cut of 10g called the two-inverter cycle "the line a # mis-typed `actuator_of(...)` at a call site goes red on", full stop. IT IS # NOT. That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign # watchdog, the guard idle and arbitrage off, so it reaches `device`, # `supervised` and `loop` AND NOTHING ELSE. PR #40's reviewer mutated the other # five appends one at a time and every suite in this repository stayed GREEN, # this part included. At the maintenance-shaping append, # `actuator_of("maintenance")` -> `actuator_of("peak")` then produced, at a # roster of two, a site loop of -1500 W against the guard's site `lo` of 1500 W # and the now-misrouted charge-only `hi = 0`: `lo > hi`, arbiter clause 3, 0 W # IN THE BILLED QUARTER - SAFETY-25 §0a's fatal case, built by a one-word typo # under a green suite. The same mutation at the `failsafe` append survived too. # # So THE CALL SITES ARE PINNED STATICALLY, over 10f's own AST scan of # `Controller.cycle()` and for 10f's reason: a static pin sees every append on # the line it is written, in every configuration, whether or not any fixture # here reaches the state that files it. All eight direct appends expose their # actuator as a LITERAL - `actuator=self.actuator_of('')` - so the # literal is simply required to EQUAL the source the claim is filed under. That # is what makes "a mis-typed `actuator_of(...)` goes red" a true sentence about # all eight rather than about the three a fixture happens to reach. def append_actuator(node): """The actuator expression a DIRECT append routes its claim through. ⚠️ RETURNED AS SOURCE TEXT, NOT EVALUATED, and that is the whole point. `self.actuator_of('device')` and `self.actuator_of('maintenance')` are the SAME VALUE at the shipped roster and two different routings; only the text written at the call site tells them apart, which is exactly why every dynamic check in this file is blind to the swap at N=1. """ kw = [k for k in node.keywords if k.arg == "actuator"] if len(kw) != 1: return None return ast.unparse(kw[0].value) # The appends that BUILD their claim in place - the eight whose routing is # chosen by hand, and the only ones where a typo is possible - against the two # that append a claim built elsewhere in the method and carry no `actuator=` # here at all. _direct = [n.args[0] for n in _appends if isinstance(n.args[0], ast.Call)] _indirect = [n.args[0].id for n in _appends if isinstance(n.args[0], ast.Name)] _routing = sorted(((c.args[0].value, append_actuator(c), c.lineno) for c in _direct), key=lambda r: r[2]) # ⚠️ DEV-02 GAVE `actuator_of()` A SECOND ARGUMENT - the roster entry the claim # is ABOUT - so a per-inverter append inside the `for inv in self.inverters:` # loop reads `self.actuator_of('device', inv)`. The pin is unchanged in what it # grades: the FIRST argument, the string literal, must still be the source the # claim is filed under. What the second argument may be is deliberately NOT # constrained here - it is a roster entry, and which one is a question the # dynamic two-inverter cycle below answers on real claims - but its PRESENCE is, # because a per-inverter claim built in that loop and routed with no `inv` files # inverter B's rating on inverter A. That is graded at the line: a source # classified SCOPE_UNIT must pass one. def _routing_literal(a): """The first argument of `self.actuator_of(...)`, or None if it is not one.""" if not a.startswith("self.actuator_of("): return None return a[len("self.actuator_of("):-1].split(",")[0].strip() _misrouted = [r for r in _routing if _routing_literal(r[1]) != repr(r[0])] _unit_sources_unrouted = [ r for r in _routing if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]] print(f" {len(_direct)} direct appends, each read at its own line: " + ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing)) check("all eight of `Controller.cycle()`'s DIRECT appends route through " "`self.actuator_of()`, so this scan can read the routing " "of EVERY one of them. An append that computed its actuator, or omitted " "the argument, or hand-wrote a string, fails HERE rather than being " "silently skipped - which is how a scan like this goes quietly vacuous, " "and 10f is in this file because that already happened once", len(_direct) == 8 and all(a is not None and a.startswith("self.actuator_of('") for _s, a, _ln in _routing)) check("⚠️ AND AT EVERY ONE OF THE EIGHT THE LITERAL IT ROUTES THROUGH IS THE " "SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed " "`actuator_of(...)` at a call site goes red on - all eight of them, on " "the line the typo is written, at every roster, INCLUDING the five " "appends no fixture in this repository reaches. " "`actuator_of(\"peak\")` at the maintenance append is a different string " "here even though it is the same VALUE at the shipped roster of one", _misrouted == []) check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. " "`actuator_of()` takes the roster entry as its second argument and " "defaults to the FIRST one when it is left out - which is right for the " "site-scoped sources and is the whole defect for a per-inverter one: a " "`device` limit built inside the roster loop and routed without `inv` " "files inverter B's rating on inverter A, where it binds the wrong " "share. Five of the eight appends are SCOPE_UNIT - `device`, " "`supervised`, `failsafe` and maintenance's two - and all five pass one", _unit_sources_unrouted == [] and len([r for r in _routing if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 5) check("...and the only appends this pin does NOT reach are the two that file a " "claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no " "`actuator=` at the call site to mis-type. Their routing is pinned where " "it is CHOSEN instead: peakguard.claim() by the SWEEP_SITE sweep above, " "over every load x elapsed x battery x SoC x floor combination, and " "arbitrage.claim() by test_arbitrage.py", sorted(_indirect) == sorted(CLAIM_BUILDERS) and len(_direct) + len(_indirect) == len(_appends)) check("...and between them the ten appends account for exactly the eight " "sources 10f pins, so the routing pin and the source set are one scan " "and neither can drift from the other", {s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values()) == CLAIM_SOURCES) # THE DYNAMIC HALF, kept, and now stated for what it actually observes: a real # cycle proving the routing reaches the arbiter THROUGH main.py rather than only # through this file's reading of main.py. It grades three of the eight appends. # The static pin above is what covers the other five. _b2, _restore_b2 = second_inverter(c_src) _n2_writes_before = len(c_src.hass.per_entity) try: _, _cl2 = cycle_claims(lambda: run_cycle(c_src)) _n2_target, _n2_reason = c_src.target, c_src.reason _n2_events = list(c_src.events) _n2_writes = c_src.hass.per_entity[_n2_writes_before:] _n2_shares = {i.name: i.target for i in c_src.inverters} _n2_last = {i.name: i.last_write for i in c_src.inverters} _n2_alloc = c_src.allocation finally: _restore_b2() print(" a REAL two-inverter cycle files: " + ", ".join(f"{c.source}@{c.actuator}" for c in _cl2)) check("a real cycle at a roster of TWO partitions its own claims correctly - " "every claim main.py filed is on an actuator its source's scope permits, " "observed on the list main.py's own `resolve` was handed. ⚠️ AND THIS " "SEES THREE OF THE EIGHT APPENDS, NOT THE CLASS: this state files " "`device`, `supervised` and `loop`, so it is the static pin above and not " "this line that covers `failsafe`, `safety` and the two `maintenance` " "appends", len(_cl2) >= 3 and scope_violations(_cl2) == []) check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN " "ACTUATOR - which is DEV-02 in one line. Before this card there was ONE " "`device` pair, read from ONE `setpoint_entity`, filed once: a second " "inverter's rating was never read and never bound anything, so its share " "was clamped against the first one's numbers. Now the loop over the " "roster files a pair per unit and the loop claim stays on the grid " "connection", sorted((c.source, c.actuator) for c in _cl2) == [("device", "inverter-a"), ("device", "inverter-b"), ("loop", ACTUATOR_SITE), ("supervised", "inverter-a"), ("supervised", "inverter-b")]) check("...and naming those three is not an accident of this fixture but the " "reason the static pin exists - five of the eight sources need a state " "no check in this file reaches", {c.source for c in _cl2} == CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak", "arbitrage"}) # ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three # checks above grade WHICH ACTUATOR each claim lands on. They say nothing about # WHOSE NUMBERS the bound is built from - and those are two different defects # with two different fixes. Filing the per-unit `device` and `supervised` claims # from `self.inverters[0]`'s numbers while leaving `actuator=self.actuator_of( # ..., inv)` alone routes every claim correctly and bounds inverter B by # inverter A's rating: at A=5000/B=3000 the planned envelope for inverter-b # becomes (-5000, 5000) instead of (-3000, 3000), which is a 2000 W # over-command the arbiter believes is supervised. # # ⚠️ THE RULE THIS LINE EXISTS TO OBEY: ANY CHECK ASSERTING "X DIFFERS FROM Y" # MUST BE RUN ONCE WITH X AND Y ACTUALLY MADE DIFFERENT. The version this # replaces compared inverter B's bound against `_b2.max_w` while # `second_inverter()` defaulted `max_w` to A's `LIVE_MAX_W` - so it compared # 5000 against 5000, and the mutation above left all 19 files GREEN. The # PRECONDITION below is what stops that happening again: it fails if the two # ratings are ever made equal, so the check cannot go quietly vacuous. _sup2 = {c.actuator: (c.lo, c.hi) for c in _cl2 if c.source == "supervised"} _dev2 = {c.actuator: (c.lo, c.hi) for c in _cl2 if c.source == "device"} print(f" supervised bounds by actuator: {_sup2}; device bounds: {_dev2}") check("PRECONDITION, AND IT IS THE WHOLE POINT: A AND B HAVE DIFFERENT " "RATINGS. A check that 'B's bound is B's own number' cannot observe " "anything while B's number IS A's number, and this file shipped that " "check for a whole review round", _b2.max_w != c_src.inverters[0].max_w and (_b2.dev_min, _b2.dev_max) != (c_src.inverters[0].dev_min, c_src.inverters[0].dev_max)) check("⚠️ AND EACH UNIT'S `supervised` BOUND IS ITS OWN `max_w`, NOT A COPY OF " "THE FIRST UNIT'S. That is the sentence AC 4 is, and it is now stated " "against LITERAL, DIFFERENT numbers rather than against an expression " "that could be reading the same object twice: B is bounded at " f"+/-{B_RATING_HYPOTHESIS:.0f} W and A at +/-{LIVE_MAX_W:.0f} W", _sup2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W), "inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)}) check("...and so is each unit's `device` bound, which is the other number the " "same loop files and the one that was NEVER READ before this card - one " "`dev_min`/`dev_max` pair came off one `setpoint_entity`, so a second " "inverter's reported range bound nothing at all", _dev2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W), "inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)}) check("...and the bounds the ARBITER RESOLVED for inverter B - not just the " "claims filed at it - are B's own, because a claim filed correctly and " "then resolved against the wrong envelope would be the same defect one " "layer down", _n2_alloc is not None and next((p for p in _n2_alloc.plans if p.unit == "inverter-b"), None) is not None and (lambda p: p.lo == -B_RATING_HYPOTHESIS and p.hi == B_RATING_HYPOTHESIS)( next(p for p in _n2_alloc.plans if p.unit == "inverter-b"))) # --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER --- # ⚠️ THIS PART WAS WRITTEN AGAINST `allocated=False` AND HAS BEEN RE-AIMED, NOT # DELETED. ARB-03's `allocate()` declined at every roster of two or more, # because it had no allocator and the obvious placeholder - hand the site target # to each inverter - IS the ARCH-01 fault. ALLOC-01 filled the seam, so that # trigger is gone. The branch is NOT, and this part is why it had better not be: # PR #40's reviewer replaced main.py's `if not resolution.allocated:` with # `if False:` and the whole 17-file suite stayed GREEN, so an unreachable-but- # right instinct was one refactor away from being deleted as dead code. # Removing the trigger without giving the branch a new one would rebuild that # hole exactly. # # THERE ARE NOW TWO REFUSALS AND THEY ARE DIFFERENT REFUSALS, so all three # states below are driven as REAL CYCLES through `Controller.cycle()`: # # 10h-1 the allocator SUCCEEDS and this controller cannot deliver the split. # 10h-2 one inverter's master is stale: the survivor carries the whole site # target, WHICH IS DELIVERABLE, and the drop is announced (AC 6). # 10h-3 every master is stale: no envelope set, so the allocator itself # refuses and `allocated=False` is reached for real. # # ⚠️ AND 10h-1 IS THE HALF DEV-02 CHANGED, SO IT HAS BEEN RE-AIMED AGAIN AND # THE PREVIOUS AIM IS RECORDED HERE RATHER THAN DELETED. ALLOC-01 had to ADD # this guard: there was one `setpoint_entity` in this add-on, so a controller # handed a valid 450/450 split and a roster of two would have written # `self.target` - 900, the SITE number - to the single inverter it could reach # while a second sat uncommanded. Not the broadcast fault, but the same shape of # over-command. # # DEV-02 gives it a writer per inverter, so that trigger is gone and the # deliverable case is now the ORDINARY one: both units are commanded THEIR OWN # SHARE, which is what 10h-1 grades below. The refusal survives with a trigger # it can still reach - a roster entry with NO setpoint entity of its own - and # 10h-1b drives it. print(f" at N=2 the allocator produced {_n2_alloc.explain()}") print(f" and main.py resolved to {_n2_target:+.0f} W, reason " f"{_n2_reason!r}, wrote {_n2_writes}") print(" and logged: " + next((e for e in _n2_events if "ARBITER" in e), "(nothing)")) check("⚠️ THE ALLOCATOR NOW SUCCEEDS AT A ROSTER OF TWO, and the shares SUM to " "the site target exactly rather than repeating it. That sum is the whole " "safety property: it makes the gain at the meter a convex combination of " "the per-unit gains, where a broadcast makes it N times the per-unit " "gain - 4000 W peak-to-peak forever at gain 1.0 on admin/goodwe's " "two-master rig, with not one guard firing", _n2_alloc.ok and sorted(_n2_alloc.shares) == ["inverter-a", "inverter-b"] and sum(_n2_alloc.shares.values()) == _n2_alloc.sum_w and _n2_alloc.unmet_w == 0) check("...and it did NOT broadcast: neither share is the site target, and " "THAT is the line a placeholder `{u: target for u in units}` goes red on", all(v != _n2_alloc.sum_w for v in _n2_alloc.shares.values()) or _n2_alloc.sum_w == 0) check("⚠️ AND MAIN.PY NOW DELIVERS THE SPLIT. THIS IS DEV-02'S DELIVERABLE, " "and it is the line that used to read 'refusing to command a partial " "site'. Each inverter is written ITS OWN SHARE, to ITS OWN setpoint " "entity, in one cycle - two entities, two numbers, and the two numbers " "are the allocator's, not the site target repeated", sorted(_n2_writes) == sorted([("number.sp", float(_n2_alloc.shares["inverter-a"])), ("number.sp_b", float(_n2_alloc.shares["inverter-b"]))])) check("...and neither entity was handed the SITE target, which is the " "broadcast fault stated as a check on the wire rather than on the " "allocator. Each unit supplying the site number IN FULL is 4000 W " "peak-to-peak forever at gain 1.0 on admin/goodwe's two-master rig, with " "not one guard firing", _n2_target == 0.0 or all(v != _n2_target for _e, v in _n2_writes)) check("...and the resolution is NOT the 0 W failsafe any more: the site target " "is a real number and it reached the hardware. That is the whole " "difference this card makes to this part", _n2_target != 0.0 and "failsafe" not in _n2_reason) check("...and each inverter's own `last_write` records what IT was told, so " "the heartbeat re-emits a SHARE rather than the site number and " "SAFETY-02 grades each unit against the command IT is standing on", _n2_last == {u: float(v) for u, v in _n2_alloc.shares.items()}) check("...and the action log no longer carries the 'no writer' refusal, " "because there is a writer. This is the line that goes red if that " "branch is left standing with its old trigger", not any("no writer" in e for e in _n2_events)) # --- 10h-1b. THE RE-AIMED REFUSAL: A ROSTER ENTRY WITH NO WRITER ------------ # ⚠️ THE INSTINCT SURVIVES ITS TRIGGER, WHICH IS WHAT ALLOC-01 ASKED FOR WHEN IT # SAID RE-AIM RATHER THAN DELETE - and PR #40's reviewer is why: replacing # main.py's sibling branch with `if False:` left the whole suite green, so an # unreachable-but-right guard was one refactor from being deleted as dead code. # A configured inverter with no `setpoint_entity` of its own is still a unit # this controller cannot put a number on, and commanding the ones it CAN reach # would over-command them by exactly the missing unit's share. _nw, _restore_nw = second_inverter(c_src, name="inverter-c", setpoint="") _nw_writes_before = len(c_src.hass.per_entity) try: _, _cl_nw = cycle_claims(lambda: run_cycle(c_src)) _nw_target, _nw_reason = c_src.target, c_src.reason _nw_events = list(c_src.events) _nw_writes = c_src.hass.per_entity[_nw_writes_before:] finally: _restore_nw() print(f" a roster entry with no setpoint entity: main.py resolved to " f"{_nw_target:+.0f} W, reason {_nw_reason!r}, wrote {_nw_writes}") print(" and logged: " + next((e for e in _nw_events if "ARBITER" in e), "(nothing)")) check("⚠️ A UNIT WITH NO WRITER TAKES THE SITE TO THE 0 W FAILSAFE. It is a " "CONFIGURATION that cannot be delivered - not a link that went away, " "which is the dropped case below and is announced and survived - and a " "site running at part strength on a roster somebody wrote down wrong " "reads as healthy on every dashboard", _nw_target == 0.0 and "failsafe" in _nw_reason) check("...and it says WHY, naming the inverter it has no setpoint entity for", any("no setpoint entity for inverter-c" in e for e in _nw_events)) check("...and then it WRITES that 0 W to the unit it CAN reach, on the same " "line as every other cycle. A substitution, not an abstention: the " "inverter holds its last command forever, so failing toward inaction " "means commanding zero, not falling silent", _nw_writes == [("number.sp", 0.0)]) check("...and the 0 W is NOT what the site target happened to be - the same " "claims at a roster whose units all have writers resolve to a real " "number, which is what says the branch does work rather than agreeing " "with the resolution by luck", resolve(_cl_nw, units=("inverter-a",)).allocated is True and resolve(_cl_nw, units=("inverter-a",)).target_w != 0.0) # --- 10h-2. A DEAD LINK, AND THE SURVIVOR NOT ABSORBING IT SILENTLY --------- # ⚠️ STIMULATED THROUGH main.py's OWN SEAM, NOT THROUGH A PATCHED ARBITER. # `Controller.live_units()` is the production method the allocator's `live` # argument comes from. # # ⚠️ AND SINCE DEV-02 IT IS DRIVEN BY THE REAL STIMULUS RATHER THAN OVERRIDDEN. # This used to replace the method with a lambda, because at a roster of one # "its master is stale" had no expression separate from "this controller is # blind". `Inverter.is_live()` gives it one: a link whose every write has been # REJECTED for a whole `LINK_STALE_S` window is not commandable, which is layer # 1's own veto period - the point at which the firmware takes that inverter to # zero anyway. So inverter B is put into exactly that state and the shipped # method is asked, which is what makes this a check on `live_units()` instead of # a check on a lambda this file wrote. _bs, _restore_bs = second_inverter(c_src) _bs.rejected_since = datetime.now(timezone.utc) - timedelta( seconds=app_main.LINK_STALE_S + 5) _bs.last_write_ok = False _stale_writes_before = len(c_src.hass.per_entity) try: check("PRECONDITION: the SHIPPED `live_units()` - not a lambda - drops the " "inverter whose writes have been rejected for a whole layer-1 window, " "and keeps the healthy one", c_src.live_units() == ("inverter-a",)) _, _cl_stale = cycle_claims(lambda: run_cycle(c_src)) _stale_target = c_src.target _stale_alloc = c_src.allocation _stale_events = list(c_src.events) _stale_writes = c_src.hass.per_entity[_stale_writes_before:] finally: _restore_bs() print(f" inverter-b's master stale: {_stale_alloc.explain()}") print(" and logged: " + next((e for e in _stale_events if "ALLOCATOR" in e), "(nothing)")) check("a unit whose master is stale is REMOVED FROM THE ENVELOPE SET rather " "than allocated into. An allocator that kept commanding a link it cannot " "reach is writing into a void and calling the site target met", _stale_alloc.dropped == (("inverter-b", ALLOC_DROP_STALE),) and "inverter-b" not in _stale_alloc.shares) check("...and the SURVIVOR CARRIES THE WHOLE SITE TARGET, which is right - " "there is one integrator on P1 and its output still has to be delivered - " "and it is now DELIVERABLE, so main.py writes it instead of failsafing. " "The two guards are distinguished by this line: a dead sibling is not the " "same event as a sibling with no writer", _stale_alloc.shares == {"inverter-a": _stale_alloc.sum_w} and _stale_target == float(_stale_alloc.sum_w) and ("number.sp", _stale_target) in _stale_writes) check("⚠️ AND THE DROPPED UNIT IS COMMANDED 0 W, NOT LEFT STANDING. The " "inverter holds its last command FOREVER - it has no meter timeout of its " "own - so 'not in the split' has to mean zero on the wire. Leaving it " "silent would leave a dead-link inverter pushing its last share while the " "survivor carried the whole site target on top of it", ("number.sp_b", 0.0) in _stale_writes) check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY, which is the whole of AC 6 and of " "SAFETY-30. The action log names the inverter, the reason it left, and " "the consequence - that what remains now carries its share. A site " "running at half its inverters and reporting nothing reads as a healthy " "site to the operator and to every dashboard", any("inverter-b" in e and "stale link" in e and "carry" in e for e in _stale_events)) # --- 10h-3. EVERY MASTER STALE: THE ALLOCATOR'S OWN REFUSAL, REACHED -------- # Same stimulus, both units. ⚠️ AND IT REACHES A ROSTER OF ONE TOO: this is the # state `live_units()`'s own docstring warns about - the sole inverter dropping # takes the SITE to 0 W - so it is driven here rather than left as a sentence. _bd, _restore_bd = second_inverter(c_src) _gone = datetime.now(timezone.utc) - timedelta( seconds=app_main.LINK_STALE_S + 5) _bd.rejected_since, _bd.last_write_ok = _gone, False c_src.inverters[0].rejected_since = _gone c_src.inverters[0].last_write_ok = False _dead_writes_before = len(c_src.hass.per_entity) try: _, _cl_dead = cycle_claims(lambda: run_cycle(c_src)) _dead_target, _dead_reason = c_src.target, c_src.reason _dead_alloc = c_src.allocation _dead_events = list(c_src.events) _dead_writes = [v for _e, v in c_src.hass.per_entity[_dead_writes_before:]] finally: _restore_bd() c_src.inverters[0].rejected_since = None c_src.inverters[0].last_write_ok = True print(f" every master stale: {_dead_alloc.explain()}") print(" and logged: " + next((e for e in _dead_events if "ARBITER" in e), "(nothing)")) check("⚠️ AND HERE `allocated=False` IS REACHED FOR REAL, ON A REAL CYCLE. " "With no inverter in the envelope set there is nobody to allocate to, " "and inventing a share for a link nobody can reach is the failure this " "branch exists for. The refusal path ARB-03 left behind is therefore " "still live code with a live trigger, which is what the ticket asked for " "when it said re-aim rather than delete", _dead_alloc.ok is False and _dead_alloc.shares == {} and ALLOC_REFUSE_EMPTY in _dead_alloc.refused) check("...and main.py substitutes the 0 W failsafe on it, writes that 0 W, and " "puts the allocator's OWN sentence in the action log rather than a " "paraphrase - so the operator reads which of the refusals fired", _dead_target == 0.0 and "failsafe" in _dead_reason and set(_dead_writes) == {0.0} and len(_dead_writes) == 2 and any("ARBITER: REFUSED" in e and ALLOC_REFUSE_EMPTY in e for e in _dead_events)) check("...and the roster it was restored to is the shipped one, with a healthy " "link, so nothing below inherits a two-inverter Controller or a dead one", c_src.units == (app_main.UNIT_A,) and c_src.live_units() == (app_main.UNIT_A,)) # ============================================================================== print() print("11. mutation: what defeats these guards, and which check bites") # Each entry re-implements ONE defeat and reports whether a check above would # have gone red. The last row is the honest one: it does NOT bite, and saying so # is worth more than a green line that is not true. MUT = [] # 1. blindness() disabled - the fresh averager's 0 W projection graded as a # number. Part 4 asserts d_fresh.engaged, so the defeat is visible there. _ex_fresh = excess_w(p_fresh, CEIL) MUT.append(("blindness() returns None - the degenerate 0 W projection is graded", (0.0 + _ex_fresh) <= 0.0 and d_fresh.engaged, "part 4: 'a fresh averager ... reports BLIND'")) # 2. the frozen term dropped from the guard's inputs. MUT.append(("frozen=False - SAFETY-18's term dropped", not decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR, frozen=False).engaged and d_frozen.engaged, "part 4: 'THE BACKSTOP: SAFETY-18's runaway ... cut to 0 W'")) # 3. `is_observed` dropped from quarter_ceiling_w, i.e. any reading's peak # trusted. A NOT_OBSERVED reading carries peak_w=None, so the defeat is LOUD. def _ceiling_without_the_gate(cap, rd): return max(float(cap), float(rd.peak_w)) _loud = False try: _ceiling_without_the_gate(CAP_W, unread) except TypeError: _loud = True MUT.append(("the `is_observed` gate dropped from quarter_ceiling_w", _loud, "TypeError on PeakReading.peak_w=None - it fails loud")) # 4. the 900/remaining gain flattened to a plain difference. _p800 = PeakTracker.project(a800, B + timedelta(seconds=800)) MUT.append(("the 900/remaining gain replaced by a plain difference", not near(excess_w(_p800, CEIL), _p800.projected_w - CEIL, 1.0), "part 1: 'the law is the difference times 9.00x'")) # 5. the achievable_w clamp removed, so the floor reaches the arbiter unbounded. MUT.append(("the achievable_w clamp removed", resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "r"), Claim.set("loop", P_LOOP, -200.0, "grid"), Claim.limit("peak", P_PEAK, lo=d_absurd.wanted_w)]).contradiction, "part 2: 'the claim can NEVER produce lo > hi'")) # 6. the claim filed as a SET rather than a LIMIT - it would then outrank # nothing it should, but it WOULD survive a safety stop losing the tie. _as_set = resolve(SAFE + [Claim.set("safety", P_SAFETY, 0.0, "stopped"), Claim.set("peak", P_PEAK, pc.lo, "x")]) MUT.append(("filed as a SET at P_PEAK instead of a LIMIT", near(_as_set.target_w, 0.0) and pc.kind == "limit", "part 2: 'it is a LIMIT at P_PEAK, not a SET'")) # 7. THE ONE THAT DOES NOT BITE, and it is stated rather than hidden. _zero_peak = PeakReading(OBSERVED, "2026-08", peak_w=0.0, quarter_start=B) _same = quarter_ceiling_w(CAP_W, _zero_peak) == quarter_ceiling_w(CAP_W, unread) MUT.append(("NOT_OBSERVED silently replaced by a peak of 0.0 W", not _same, "NOTHING - and by construction: the ceiling is max(cap, peak), so a " "zero peak is the TIGHTEST possible input to it")) # ---- SAFETY-24's own defeats ------------------------------------------------ # 8. the SoC gate removed altogether - i.e. the guard exactly as SAFETY-07 # shipped it. Re-derived on the same modelled pack, not described. MUT.append(("the SoC gate removed - SAFETY-07's unbounded forced discharge", BEFORE["min_soc"] <= BMS_FLOOR and AFTER["min_soc"] > BMS_FLOOR, "part 9: 'the forced discharge STOPS AT THE CONFIGURED FLOOR'")) # 9. an unreadable SoC read as a full battery. THE DISARMING DIRECTION, and the # one this project has actually shipped once (NOTES.md 4.4b). MUT.append(("`soc or 100` - an unreadable SoC read as a full battery", discharge_permitted(float(None or 100), SOC_FLOOR) is None and d_unread.floor_limited, "part 8: 'an unreadable SoC is refused, and refused with its OWN " "reason'")) # 10. the gate moved ABOVE the `wanted > 0` test, i.e. graded where it is # degenerate. Counted on the healthy day rather than argued: every quiet # cycle would report the peak was about to be exceeded. _early = sum(1 for _t in (500.0, 1000.0, 2000.0) if discharge_permitted(SOC_FLOOR - 1.0, SOC_FLOOR) is not None and not decide(PeakTracker.project( averager_at(_t, 450.0), B + timedelta(seconds=450)), batt_w=BATT_NOW, cap_w=CAP_W, reading=None, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR).engaged) MUT.append((f"the SoC gate moved above the `wanted > 0` test ({_early} quiet " f"quarters would have alarmed)", _early > 0 and not d_quiet.floor_limited, "part 8: 'an empty pack on a quarter that does not need shaving is " "IDLE, not floor-limited'")) # 11. rule 8 written `<` instead of `<=`, so a floor EQUAL to the BMS cut-out # is accepted and buys zero margin. MUT.append(("validate rule 8 written `<` instead of `<=`", len(rule8(validate_startup_config( opts24(peak_soc_floor=BMS_FLOOR)))) == 1, "part 9: 'so is a floor EXACTLY AT it, which is the interesting " "case'")) # 12. the floor-limited branch filing `wanted` anyway - a report with no action, # which is the shape a "just log it" review comment produces. MUT.append(("floor-limited reported but the floor still filed at `wanted`", near(d_lim.floor_w, 0.0) and d_lim.wanted_w > 0.0, "part 8: 'at the floor it STOPS, and the floor it files is 0 W'")) # 13. THE SECOND ONE THAT DOES NOT BITE, and it is a real limitation rather than # a defect. Stated, because the alternative is a reader believing this card # bounds more than it does. # # ⚠️ SAFETY-25 CHANGED WHAT "NOT CAUGHT" MEANS HERE, AND ONLY THAT. Nothing # PREVENTS it - the code is unchanged and a `lo` limit still cannot cap a # discharge - but part 10 now MEASURES it on the committed capture and 10f # PINS THE CLAIM-SOURCE SET around it, so the exposure can no longer change # size under a green suite: file the reserve and 10f goes red on the line # it is written. That is the whole difference between an uncaught mutation # and an unrecorded one. ⚠️ THE PIN IS LOAD-BEARING FOR THIS ROW. Without # it this rationale was FALSE - PR #31's reviewer built the memo's Option B # and this row went on printing "NOTHING PREVENTS IT" under a green suite. _loop_unbounded = near(r_disch.target_w, 1800.0, 1e-9) MUT.append(("the grid-following loop discharging past this floor", not _loop_unbounded, "NOTHING PREVENTS IT, ON PURPOSE - this card bounds THE PEAK " "GUARD's forced discharge, the thing SAFETY-07 introduced. A `lo` " "limit is structurally incapable of closing SAFETY-18's finding " "that the loop has no SoC guard. SAFETY-25 part 10 now MEASURES " "it: one sustained-load evening, " f"{EVE_START:.0f} % -> {EV['end_soc']:.1f} % on the reference pack, " f"identical at every peak_soc_floor the schema accepts - and 10f " f"pins the claim-source set, so building the floor takes this " f"part RED instead of leaving this row quietly stale")) for label, caught, where in MUT: print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}") print(f" {where}") check("the eleven mutations that CAN disarm these guards are each caught by a " "check above - a guard whose checks survive its own defeat is not a " "guard", all(m[1] for m in MUT[:6]) and all(m[1] for m in MUT[7:12])) check("...and the seventh is honestly reported as not caught, because it cannot " "disarm anything: `max(cap, peak)` makes a zero peak the strictest " "ceiling there is, so the disarming direction is closed by the shape of " "the expression rather than by a check. THAT is why the ceiling is a max " "and not a subtraction - `headroom = cap - peak` would have turned the " "same zero into FULL freedom, which part 3 shows is why headroom_w() is " "not consumed here", not MUT[6][1] and quarter_ceiling_w(CAP_W, _zero_peak)[0] == CAP_W) check("...and so is the last one, which is not a defeat at all but the EDGE OF " "THIS CARD: the grid-following loop can still take the pack past this " "floor, because the floor bounds the peak guard's own forced discharge " "and a `lo` limit cannot cap a discharge. Saying so here is worth more " "than a green line that would be untrue - and part 10 now says it with a " "number instead of a sentence, which is worth more again", not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05) # ============================================================================== print() shutil.rmtree(TMP, ignore_errors=True) if fails: print(f"{len(fails)} FAILED: {', '.join(fails)}") sys.exit(1) print("all checks passed")