Files
goodwe-addon/goodwe_controller/test_peakguard.py
T
Claude 5d27f8496f
checks / unit (pull_request) Failing after 17m13s
infra-pin / fresh (pull_request) Successful in 2s
SAFETY-78: maintenance drain at N=2 no longer stalls
At two inverters the drain pins its pack and the site target is split
around the pin. Below the pin the other pack had to charge, the allocator
refused the opposite directions, and the site sat at 0 W with an ARBITER
line every cycle (house below or above the drain rate, or a grid charge).

main.py now files a site limit `drain` (lo = what the pin places, at
P_MAINTENANCE) at N>=2 while a pack drains, so the other pack only adds
power in the same direction. A drain that cannot give power waits with
one log line. With battery modes on, no grid charge while a pack drains,
and the other battery's card says why. N=1 is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:52:51 +02:00

6316 lines
362 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
import time
from datetime import datetime, timedelta, timezone
from math import inf, isinf
from pathlib import Path
from app import messages
from app import peakguard
from named_absence import first, given, nth, present
def _src(node):
"""`ast.unparse(node)`, or what was there instead - never a raise."""
return ast.unparse(node) if isinstance(node, ast.AST) else repr(node)
# ⚠️ 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, build_roster
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, Pack, PeakDecision, binding_soc_floor, blindness,
charge_headroom_w, decide, discharge_permitted, excess_w, pack_floor_pct,
pack_permissions, 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"))
# ⚠️⚠️ THESE THREE READERS WERE CHANGED BY CFG-04, AND THE OLD ONES WERE THE
# PERMISSIVE KIND. Read this before touching them.
#
# WHAT THEY USED TO DO. `read_option()` walked the whole file and took the FIRST
# line whose STRIPPED text started with `<key>:`; `_schema_bound()` did the same
# from `schema:` onwards. The docstring justified first-match by naming the one
# collision that existed - `peak_cap_w:` appears once under `options:` and once
# under `schema:` - and it was right about that one.
#
# WHY THAT STOPPED BEING SAFE. CFG-04's `inverters:` is a LIST OF OBJECTS, and
# its entries carry a `max_w:` of their own, indented six spaces under
# `schema:`. `line.strip()` throws the indentation away, so `read_schema_max
# ("max_w")` began answering with the PER-INVERTER bound instead of the site
# one - silently, and in this file's case correctly, because the two bounds
# happen to be the same `int(100,5000)` today. A reader that returns inverter
# #1's number and keeps passing is the failure mode this repository is named
# after: green, and not watching. config.yaml declares `inverters:` BEFORE
# `max_w:` deliberately so that the trap stays baited rather than sorted out by
# an ordering nobody would maintain.
#
# WHAT THEY DO NOW. A top-level option and a top-level schema key are at EXACTLY
# two spaces; anything nested is at four or more. So the anchor is the
# indentation, the file's two halves disambiguate `peak_cap_w` (the options half
# for a default, the schema half for a bound) and first-match is not relied on
# at all. And where a key really is ambiguous - the same key twice at top level
# in the same half - these RAISE instead of picking one, because a reader that
# guesses is how this got here.
#
# test_inverter.py part 12 runs all of them against a synthetic TWO-INVERTER
# config.yaml whose nested numbers differ from its flat ones, which is the only
# way to tell a reader that is anchored from one that is lucky.
def _cfg_halves():
body = (HERE / "config.yaml").read_text(encoding="utf-8")
marker = body.index("\nschema:")
return body[:marker], body[marker:]
def _top_level(half: str, key: str) -> list:
"""Every ` key: ...` line at EXACTLY two spaces. Raises if there are two."""
hits = [ln for ln in half.splitlines()
if ln.startswith(f" {key}:") and not ln[2:3].isspace()]
if len(hits) > 1:
raise AssertionError(
f"config.yaml declares {key!r} {len(hits)} times at the top level "
f"of one block: {hits!r}. This reader will not pick one for you - "
"an option with two defaults is a question for whoever wrote the "
"second, not a tie for a check to break.")
return hits
def read_option(key: str):
"""The shipped default for `key`, out of config.yaml's `options:` block."""
hits = _top_level(_cfg_halves()[0], key)
if not hits:
return None
try:
return float(hits[0].split(":", 1)[1].strip())
except ValueError:
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):
for line in _top_level(_cfg_halves()[1], key):
st = line.strip()
if "(" 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 = given(read_option("peak_cap_w"), "peak_cap_w not in config.yaml")
MAX_HOLD_S = given(read_option("meter_max_age_s"), "meter_max_age_s not in config.yaml")
FREEZE_S = given(read_option("meter_freeze_s"), "meter_freeze_s not in config.yaml")
CYCLE_S = given(read_option("cycle_floor_s"), "cycle_floor_s not in config.yaml")
# 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 = given(read_schema_max("max_w"), "max_w not in config.yaml")
# SAFETY-24's two options, out of the shipped file for the same reason.
SOC_FLOOR = given(read_option("peak_soc_floor"), "peak_soc_floor not in config.yaml")
BMS_FLOOR = given(read_option("bms_soc_floor"), "bms_soc_floor not in config.yaml")
MAINT_FLOOR = given(read_option("maintenance_soc_floor"), "maintenance_soc_floor not in config.yaml")
# 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",
present(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, margin_w=0.0, 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, margin_w=0.0, 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
# ⚠️ `floor_w` IS FORMATTED DEFENSIVELY, AND MUTATION.md's OWN LEDGER IS WHY.
# The mutation this check exists for - reverting the `floor > 0` rung to
# `excess > 0` - makes `floor_w` None HERE, so a bare `:.0f` raises TypeError
# and ABORTS THE WHOLE FILE on the line after the check that just went red.
# Parts 2-13 are then ungraded and the recorded coverage of that mutation is
# smaller than the truth: exactly what MAINT-05's `delinquent(...)['b']`
# subscript did to test_delivery.py, and habit 5 ("a check whose failure mode
# is a hang - or a crash - needs a deadline, or a name") in the other costume.
print(f" at the boundary: excess {e_edge:.3f} W (an `excess > 0` test would "
f"RELEASE: {would_release_on_excess}), floor "
f"{'(none)' if released.floor_w is None else f'{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, margin_w=0.0, 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, margin_w=0.0, 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, margin_w=0.0, 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, margin_w=0.0, 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 nth(intent_floor, -1) > nth(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, margin_w=0.0, 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, margin_w=0.0, 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, margin_w=0.0, 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, margin_w=0.0, 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, margin_w=0.0,
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, margin_w=0.0, 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, margin_w=0.0, 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{given(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, margin_w=0.0, 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": 0, "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 r.get("id") == "peak_guard"]
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 r.get("id") == "peak_guard"]
print(f" meter_source off: guard={c_off.peak_guard}, wrote "
f"{nth(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 the guard is off, and why, rather than "
"'idle' or a green row - the same answer SAFETY-18 already gives on a "
"transport that cannot see it", len(off_rows) == 1
and ": off" in off_rows[0]["text"]
and "Where the meter reading comes from" in off_rows[0]["text"]
and "idle" not in off_rows[0]["text"]
and not off_rows[0]["ok"] 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.
#
# ⚠️⚠️ CI-06. AND THE QUARTER MUST HAVE ROOM LEFT TO SIZE A FIXTURE IN, WHICH IS
# A SECOND AND MUCH WIDER RACE THAN THE BOUNDARY ONE ABOVE. It is where three of
# this file's four known-flaky checks came from, and it is not a boundary
# effect - it is the control law's own gain applied to this helper's arithmetic.
#
# Every second of the open block the telegrams do NOT cover is credited at the
# HELD value instead of the measured one, so the projection loses `net_w x
# uncovered / 900` W - and `excess = 900 x (projected - ceiling) / remaining`
# multiplies that back up by `900 / remaining`. The floor this helper delivers
# is therefore
#
# floor ~ want x (1 - uncovered/900) - net_w x uncovered / remaining
#
# and the second term is the whole story: at 600 s left one uncovered second
# costs 6 W of a 1500 W floor, at 12 s left it costs 292 W, and at 3 s left it
# takes the projection under the ceiling and THE GUARD DOES NOT ENGAGE AT ALL -
# which fails "the real cycle files the peak claim", "...and the number written
# to the inverter is the guard's floor" and "the STATUS PAGE row for a
# floor-limited guard" together, for no reason except the minute the suite ran.
# Measured on this tree as shipped: at 12 s left the fixture asked for 1500 W
# and delivered 613 W, a -59 % fixture that stayed green and graded a different
# site than the one its text names.
#
# TWO uncovered stretches produced it and both are closed here:
#
# 1. THE TAIL. `range(0, span + 1, 5)` stops at the last 5 s mark at or before
# `now`, so 0-5 s of the block were never covered by construction. One
# final telegram stamped at `now` closes it - which is also what a real
# meter does, and what this helper's own docstring already claims it does.
# 2. THE LAG between that last telegram and `cycle()`'s own `datetime.now()`.
# That one is real work and cannot be removed, only bounded: measured at
# 12-96 ms over 23 fixtures on this tree under contention.
#
# So the ROOM the helper needs is derived from what it promises rather than
# picked. Requiring the delivered floor to be within 1 % of the floor asked for,
# at the shipped ceiling and this file's smallest fixture (`net_w` ~ 3500 W,
# `want` 1500 W), and taking the lag at 0.1 s - past every one of the 23
# measurements:
#
# net_w x lag / (remaining x want) <= 0.01 - lag/900
# remaining >= 3500 x 0.1 / (1500 x 0.00989) = 23.6 s -> FIXTURE_ROOM_S
#
# Below that the helper WAITS OUT THE REST OF THE QUARTER rather than grading a
# fixture the arithmetic cannot deliver. The wait is at most FIXTURE_ROOM_S and
# is taken on about 2.6 % of runs; a suite that silently graded a -59 % fixture
# instead was the alternative. ⚠️ This is not a widened tolerance - the checks
# downstream are unchanged and the 1 % is a requirement on the FIXTURE, not on
# any assertion.
FIXTURE_ROOM_S = 24.0
def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
standing_w=None, soc=55.0, prepare=None):
"""A real Controller driven one real cycle over a real over-limit quarter.
`prepare(ctl)` runs after the Controller is built and before the telegrams
are fed, which is the only window in which a SECOND inverter can be put on
the roster and given its own readings. It is inside the retry loop on
purpose: each attempt builds a fresh Controller, so a hook that mutated only
the first one would leave attempts 2-4 grading a different site.
⚠️ 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):
# ⚠️ ROOM FIRST, AND BEFORE THE CONTROLLER IS BUILT. See FIXTURE_ROOM_S.
# Retrying does not help here - four attempts take ~40 ms each and all
# four would land in the same dying quarter - so the tail of the block
# is waited out once, which puts the next attempt at ~900 s of room.
_room = QUARTER_S - (datetime.now(timezone.utc).timestamp() % QUARTER_S)
if _room < FIXTURE_ROOM_S:
time.sleep(_room + 0.05)
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 prepare is not None:
prepare(ctl)
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())
# SAFETY-79: sized against what the guard aims at, cap - margin.
net_w = (CAP_W - ctl.peak_margin_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))
# ⚠️ AND ONE TELEGRAM AT `now` ITSELF. The averager credits a sample's
# value over the interval to the NEXT sample, so without this the block
# is covered only to the last 5 s mark and 0-5 s of it are extrapolated
# from the held value. See the header: at 12 s of remaining that alone
# turned a 1500 W fixture into a 613 W one. A real meter does not stop
# sending at the last multiple of five either.
ctl.p1.submit(sample(now, 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 r.get("id") == "peak_guard"]
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"])
# UX-08 2d: the engaged row names what the battery is asked for FIRST and the
# limit SECOND. Both are watts, so only the ordered pair tells them apart.
from app import messages as _msg_on # noqa: E402
_on = (f"asks the battery for {_msg_on.watts(c.peak_guard.floor_w)} to stay "
f"under {_msg_on.watts(c.peak_guard.ceiling_w)}")
_on_swapped = (f"asks the battery for {_msg_on.watts(c.peak_guard.ceiling_w)} "
f"to stay under {_msg_on.watts(c.peak_guard.floor_w)}")
check("...and it names the battery's share first and the limit second, and "
"the two render differently so the swap is visible",
len(rows) == 1 and _on in rows[0]["text"]
and _on_swapped not in rows[0]["text"]
and _msg_on.watts(c.peak_guard.floor_w)
!= _msg_on.watts(c.peak_guard.ceiling_w))
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",
bool(hass2.writes) and 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)
# ⚠️ CI-06. THE PROJECTION OBJECT ITSELF IS KEPT, AND THAT IS WHAT MAKES THE
# COMPARISON BELOW A COMPARISON RATHER THAN A RACE. The check under
# "THE CONSEQUENCE, ITERATED" says both forms are run "against the same
# frozen projection", and until this line existed that sentence was FALSE:
# the intent form was decided against a SECOND `PeakTracker.project(
# b3.p1.averager, datetime.now())` taken after the cycle had returned, so
# the two numbers came from two different instants of the wall clock. The
# control law is `excess = 900 x (projected - ceiling) / remaining`, whose
# derivative in time is `excess / remaining`, so at a 1500 W floor the two
# instants separated at 1500 / remaining W per second and the check's 1.5 W
# tolerance bought exactly `remaining / 1000` SECONDS - 900 ms at the top of
# a quarter and 6 ms with ten seconds left of it. Measured on this tree, the
# gap between the two statements is 13-40 ms idle and larger under load, so
# the check was answered by WHERE IN THE QUARTER IT RAN about `gap_ms / 900`
# of the time. Keeping the object removes the clock from the comparison
# altogether: the tolerance below is EXACT, not widened.
_seen["projection"] = projection
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.
#
# ⚠️ CI-06. "THE SAME FROZEN PROJECTION" IS NOW THE SAME OBJECT. `_proj3` is the
# `Projection` the real cycle actually graded, captured by `_recording_decide`
# above, and not a second projection of the same averager taken at a later
# `datetime.now()`. Every number below therefore comes from ONE instant, and the
# tolerances that used to absorb the wall clock are gone.
_proj3 = _seen["projection"]
_kw = dict(cap_w=CAP_W, margin_w=b3.peak_margin_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)
# The delivered form, re-decided from the SAME projection. It has to reproduce
# the real controller's own floor EXACTLY or `_kw` is not the call main.py
# makes - which is a second thing this now grades and did not before, and the
# one that keeps the identity below honest rather than self-referential.
_delivered = _shipped_decide(_proj3, batt_w=0.0, **_kw)
check("the delivered form re-decided from the cycle's OWN projection is the "
"number the real Controller wrote, to the bit. The kwargs below are "
"main.py's call and not an approximation of it, so the identity that "
"follows is a statement about `decide()` and not about this fixture",
near(_delivered.floor_w, b3.peak_guard.floor_w))
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. "
"⚠️ EXACT, not within 1.5 W: both forms are decided from one Projection "
"object, so a tolerance here would only be hiding a clock. CI-06 - the "
"1.5 W it used to carry bought `remaining / 1000` seconds and made this "
"line answer by where in the quarter the suite happened to run",
near(_intent.floor_w - _delivered.floor_w, STANDING)
and not _intent.saturated and not _delivered.saturated)
_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 len(_i_track) > 1 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 = nth(_peak_calls, 0, "peakguard.decide() call in main.py")
_batt_src = _src(first(k.value for k in _call.keywords if k.arg == "batt_w"))
_soc_src = _src(first(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 = _src(first(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_margin_w",
"peak_soc_floor"]) # + SAFETY-79
# 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 = first((n for n in ast.walk(ast.parse(src))
if isinstance(n, ast.FunctionDef) and n.name == "decide"),
"def decide in peakguard.py")
_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, margin_w=0.0, 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, margin_w=0.0, 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, margin_w=0.0, 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 limit" 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, margin_w=0.0, 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, margin_w=0.0, 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, margin_w=0.0, 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", "margin_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 r.get("id") == "peak_guard"]
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 not rows24[0]["warn"]
and "peak limit will be passed" 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, margin_w=0.0, 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 == [])
# UX-08 2c: the shipped default left the sentence (it is on the option's
# description). The two numbers the operator acts on stay.
check("...and the refusal NAMES BOTH NUMBERS, so the operator can act on it "
"without reading the source",
len(below) == 1
and f"{BMS_FLOOR:.0f} %" in below[0]
and f"{BMS_FLOOR - 5:.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, margin_w=0.0, 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, {nth(EVE, 0)[0]:%H:%M}-{nth(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"{nth(HOT, 0)[0]:%H:%M}-{nth(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)")
print(f" {HOT_R['below']} cycles below the {SOC_FLOOR:.0f} % EMS floor, "
f"{HOT_R['below_discharging']} of them discharging; low-water "
f"{HOT_R['min_soc']:.2f} % against a {BMS_FLOOR:.0f} % BMS cut-out")
# ⚠️ THESE TWO CHECKS WERE RE-WRITTEN AT SAFETY-39 AND THE OLD NUMBERS ARE
# PRINTED HERE RATHER THAN DELETED, BECAUSE "AN ASSERTION WAS LOOSENED TO GET A
# BRANCH GREEN" IS THE FIRST THING A REVIEWER SHOULD SUSPECT AND IT HAS TO BE
# ANSWERABLE. They read `limited_discharging == limited` and `min_soc <=
# bms_soc_floor + 0.05`, and both held at `slew_w: 1000`. SAFETY-39 re-derived
# `slew_w` as a ratio to the rail (222 W at this 2000 W cap), and on this same
# two-hour replay:
#
# slew_w 1000 slew_w 0 (the rail)
# limited 49 49
# limited_discharging 49 48
# low-water SoC 9.98 % 10.57 %
# cycles at the BMS cut 190 0
# energy out 1247 Wh 1165 Wh
# below / discharging 406 / 138 406 / 162
#
# So the loop drains this pack ~82 Wh more slowly and no longer reaches the
# cut-out INSIDE THIS WINDOW. The FINDING is untouched and is in fact sharper on
# the measure that carries it - 162 cycles of granted discharge below the floor,
# up from 138. What moved is two numbers that happened to be exact at one
# tuning: one cycle of loop spin-up, and whether a two-hour window is long
# enough to touch a cut-out the loop is now approaching more slowly.
#
# The checks below therefore assert the SUBSTANCE and not the coincidence: that
# the two states occur together on all but at most one cycle, and that the pack
# still ends up past the EMS floor and within a point of its own cut-out with no
# EMS-side bound anywhere in the path. If a future change makes either of these
# fail, that IS a finding - do not widen them again.
check("THE ASYMMETRY, ON REAL DATA. On the cycles 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. ⚠️ AT MOST ONE CYCLE MAY DIFFER, and that one is the "
"loop's cold start from 0 W - a tolerance of one, not a tolerance",
HOT_R["limited"] > 10
and HOT_R["limited_discharging"] >= HOT_R["limited"] - 1
and HOT_R["max_granted"] > 0.0)
check("...and the loop is granted a discharge on hundreds of cycles with the "
"pack ALREADY below the EMS floor, which is the same asymmetry counted "
"over the whole window rather than over the floor-limited subset - the "
"measure the finding actually rests on, and it does not depend on one "
"cycle of spin-up",
HOT_R["below"] > 100 and HOT_R["below_discharging"] > 100)
check("...which is why SAFETY-24's floor could not be widened into this one, "
"and why the fix is not a bigger number: the pack still ends the window "
"PAST the EMS floor and within one point of its own BMS cut-out, with no "
"EMS-side bound anywhere in the path. 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"] < SOC_FLOOR
and HOT_R["min_soc"] <= BMS_FLOOR + 1.0)
# --- 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 r.get("id") == "peak_guard"]
def _row_scope(text):
"""SAFETY-25 in messages.row_peak_reserve()'s words."""
return ("Home use still draws the battery below it" in text
and "down to the cut-out level" in text)
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 "at its peak reserve" in rows25[0]["text"]
and _row_scope(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.
# UX-08: the banner is the notification's own title, and the row below keeps
# the SAFETY-25 sentence.
check("the Not ready banner is a short headline - the notification's title - "
"and NOT the row repeated at the top of the page. A headline that is a "
"paragraph is a headline nobody reads",
_pay25["level"] == "bad"
and _pay25["banner"] == (f"{messages.NOT_READY} — "
f"{messages.T_PEAK_RESERVE}")
and not _row_scope(_pay25["banner"]))
check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
"sentence is still what the checks list carries, which is the surface the "
"operator reads to find out what the reserve does not bound",
len(rows25) == 1 and _row_scope(rows25[0]["text"]))
_body25 = hass25.last_message("goodwe_peak_soc_floor")
def _says_scope(body):
"""SAFETY-25 in messages.peak_reserve()'s words: the reserve binds the peak
guard only, and home use goes on discharging below it to the cut-out."""
return ("The reserve only applies to keeping the peak down" in body
and "normal home use can still draw the battery below it" in body
and "down to the battery cut-out level" in body)
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",
_says_scope(_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.
# ⚠️ SAFETY-31 ADDED THE NINTH, DELIBERATELY, AND THIS IS THE PARAGRAPH THAT
# SENTENCE ASKS FOR. `circulation` is a SET at P_SAFETY on the SITE actuator,
# filed only while `circ_wd.tripped`. It is not the deferred loop-side reserve
# this tripwire was built to catch: it is not conditioned on a state of charge,
# it does not shape the loop, and it appears in NO cycle of the sweeps below -
# the dynamic halves of this part still observe exactly {device, supervised,
# loop} at every SoC from 0 to 100 %, because nothing in those fixtures
# circulates. What it does is stop the site when two inverters are measured
# pushing against each other. See DECISIONS/SAFETY-31-circulating-power.md.
# ⚠️ SAFETY-30 ADDED THE TENTH, AND IT NEEDS ITS OWN PARAGRAPH FOR THE REASON
# `circulation` needed one. `sign_site` is a SET at P_SAFETY on the SITE
# actuator, filed only while `site_sign_wd.tripped` AND the roster holds more
# than one inverter. It is not a second copy of `failsafe`: that one grades ONE
# unit's command against THAT unit's own measured power and parks that unit,
# while this grades the SUM of the commands against the meter and stops the
# site. It appears in NO cycle of the sweeps below - the dynamic halves still
# observe exactly {device, supervised, loop} at every SoC, because the shipped
# roster is one inverter and this claim cannot be filed there at all. See
# DECISIONS/SAFETY-30-per-unit-sign.md.
# ⚠️ OPT-09 ADDED THE ELEVENTH, AND IT IS THE ONE THIS TRIPWIRE WAS BUILT FOR
# THE SHAPE OF, SO IT NEEDS THE LONGEST PARAGRAPH. `pack` is a
# `Claim.limit(... P_SAFETY ...)` on ONE INVERTER'S actuator, filed only where it
# BINDS and only at a roster of TWO OR MORE, carrying that pack's own state of
# charge against that pack's own `bms_soc_floor`: `hi = 0` for a battery at or
# under its cut-out, `lo = 0` for one at 100 %.
#
# IT IS CONDITIONED ON A STATE OF CHARGE, WHICH IS EXACTLY WHAT THIS SET WATCHES
# FOR, so the argument has to be made rather than asserted. SAFETY-25's deferred
# Option B was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
# `loop_soc_floor` option: an EMS PREFERENCE, chosen by an operator, stopping the
# grid-following loop at a depth somebody picked, ON THE SITE. Four things
# separate this from it, and all four are graded elsewhere in this repository:
#
# 1. THE NUMBER IS NOT AN OPTION. It is `bms_soc_floor` - measured off
# register 0x0560 and required of every roster entry since SAFETY-32. No
# new option is added and `peak_soc_floor` is deliberately NOT consulted
# (test_packshare.py part 2 grades that a pack at 15 % with a 10 % cut-out
# is NOT closed, though a 20 % EMS floor would have closed it).
# 2. IT IS A UNIT CLAIM, SO IT CANNOT BOUND THE SITE TARGET. It changes WHICH
# INVERTER carries the target, never how big the target is.
# 3. IT IS NOT FILED AT ALL AT A ROSTER OF ONE, which is why the sweeps below
# still observe exactly {device, supervised, loop} at every state of charge
# from 0 to 100 % on the shipped install. Option A stands untouched there:
# ordinary self-consumption still discharges to the BMS cut-out.
# 4. AND THE MEMO ITSELF NAMES THIS AS THE REVISIT: "under ALLOC-01 the
# reserve becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a
# ninth claim here". Stage 1 is how an interval reaches the allocator, and
# stage 1's input is claims - so a unit-scoped claim is the shape that
# sentence asks for.
#
# If you are here because you added a TWELFTH, that argument is what this line
# wants from you, not an edit.
# ⚠️ SAFETY-73 ADDED `refused` (a P_SAFETY unit limit on a measured refusal).
# ⚠️ OPT-11 ADDED `mode`, ON PURPOSE, AND IT IS NOT THE DEFERRED RESERVE EITHER.
# `mode` is `Claim.limit("mode", P_MODE, lo, hi)` on ONE inverter's actuator:
# the owner's per-battery choice (on/off, Solar first, Leftover solar), filed
# only with `battery_modes_enabled` on (default off). It is not conditioned on
# a state of charge, and it cannot bound the site against the peak floor:
# 1. stage 1 keeps it APART from the hard limits (UnitBounds.mode_*), so it
# never enters a `lo > hi` test, not even the unit's own;
# 2. stage 3 applies it only while the site target was decided at or below
# P_MODE (35), so the guard's floor (P_PEAK, 50) RELEASES it, and at N=1
# `resolve()` applies it to the site by the same rule, after stage 2;
# 3. its range always holds 0 W, so it can forbid a direction and never
# force one (a range without 0 is dropped as a software fault).
# test_arbiter.py part 9 grades the no-contradiction claim at N=2, over 625
# cycles with the floor, and part 10 at N=1. The sweeps below never reach it:
# their fixtures leave battery modes off, so they still see {device,
# supervised, loop}. DECISIONS/OPT-11-battery-modes.md.
# ⚠️ SAFETY-78 ADDED `drain`, AND IT IS NOT THE DEFERRED RESERVE. It is a
# site `lo` at P_MAINTENANCE, filed only at N>=2 while a pack's maintenance
# drain is pinned, equal to what that pin places. It is not conditioned on a
# state of charge, it never closes discharge (a `lo`, not a `hi`), and it is
# never filed at N=1, so the sweeps below still see {device, supervised,
# loop}. DECISIONS/SAFETY-78-drain-at-two.md.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation",
"sign_site", "pack", "refused", "mode", "drain"}
# 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 = first((
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"),
"Controller.cycle in main.py")
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 FOURTEEN. 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 new source. Filing one takes this line red on the line it "
"is written, and OPT-09's `pack` had to argue its way past it above",
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 TWELFTH source now goes red twice - once here for "
"having no classification, once above for being a twelfth - 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, with SAFETY-31's and "
"SAFETY-30's, OPT-09's and SAFETY-78's additions: the seven about the GRID CONNECTION "
"are site claims and the five about a pack, a device or one write are "
"per-inverter. `circulation` is a site claim because its quantity is "
"defined over the roster and is identically zero for any one unit; "
"`sign_site` is one because the number that tripped it is the SITE "
"target, which no single inverter owns - while `failsafe` beside it is "
"per-inverter because since SAFETY-30 it grades one unit's command "
"against that unit's OWN measured power",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site",
"drain"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance", "pack",
"refused", "mode"})
# 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.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
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 eleven: `device`, "
"`supervised`, `failsafe`, `maintenance` and OPT-09's `pack` bind the "
"unit this controller "
"drives, `loop`, `peak`, `arbitrage`, `safety`, SAFETY-31's `circulation` "
"and SAFETY-30's `sign_site` 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`; "
"route `circulation` to one unit and a site-wide loop parks the inverter "
"that may well be the one still listening; route `sign_site` to one unit "
"and a SITE total pointing the wrong way parks one inverter while the "
"rest of the roster goes on pushing the same way - and this line goes red",
_routed == {"device": "inverter-a", "supervised": "inverter-a",
"failsafe": "inverter-a", "maintenance": "inverter-a",
"pack": "inverter-a", "refused": "inverter-a",
"mode": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE,
"drain": 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 NINE 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('<source>')` - 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 ten 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"]
return ast.unparse(kw[0].value) if len(kw) == 1 else None
# The appends that BUILD their claim in place - the nine 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 FIFTEEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, 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) == 15
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 ELEVEN 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 nine of them, on "
"the line the typo is written, at every roster, INCLUDING the six "
"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. Nine of the fifteen appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack`, "
"SAFETY-73's two `refused` and OPT-11's `mode` - and all nine pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
"the site, beside `circulation`, because the number that tripped it is "
"the site target",
_unit_sources_unrouted == []
and len([r for r in _routing
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 9)
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 seventeen appends account for exactly the "
"fourteen "
"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),
# ⚠️ OPT-09, AND IT IS OBSERVED HERE RATHER THAN ONLY PINNED
# STATICALLY. This fixture leaves both packs at 100 % - the last value
# of the SoC sweep above - so each one's `pack` claim closes its `lo`
# at 0 W: a full battery is not asked to charge. It is on ITS OWN
# actuator, which is the half a `pack` limit filed on the site would
# fail, and it appears ONLY here: the one-inverter sweep above files
# no `pack` claim at any state of charge, which is AC 4.
("pack", "inverter-a"), ("pack", "inverter-b"),
("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 - EIGHT of the twelve sources need a state "
"no check in this file reaches. SAFETY-31's `circulation` is one of the "
"seven: it is filed only while the site is MEASURED to be circulating, "
"which no fixture here is. SAFETY-30's `sign_site` is another, and it "
"needs TWO states at once - a roster of two AND the site's total command "
"standing opposed to the meter for its whole window",
{c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage", "circulation", "sign_site",
"refused", "mode", "drain"})
# ⚠️ 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)})
_n2_plans = {p.unit: (p.lo, p.hi) for p in (_n2_alloc.plans if _n2_alloc else ())}
print(f" the envelopes the ARBITER RESOLVED: {_n2_plans}")
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. ⚠️ THE `hi` IS B'S OWN RATING AND THE `lo` IS 0 W, and the "
"second half is OPT-09 rather than a defect: both packs in this fixture "
"sit at 100 % - the last value of the SoC sweep above - so each one's "
"`pack` claim closes its charge end. The two units' envelopes are still "
"DIFFERENT NUMBERS, which is what this check exists to observe: a bound "
"copied from the first unit would read +5000 on B",
_n2_alloc is not None
and _n2_plans.get("inverter-b") == (0.0, B_RATING_HYPOTHESIS)
and _n2_plans.get("inverter-a") == (0.0, LIVE_MAX_W)
and _n2_plans["inverter-a"] != _n2_plans["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("12. SAFETY-32 - one bms_soc_floor for two packs grades one against the")
print(" other's BMS. Per-entry cut-out, per-pack permission, rule 8 per unit")
# ⚠️ THE TWO FLOORS DIFFER, AND THAT IS THE FIRST CHECK RATHER THAN AN
# ASSUMPTION. This card's acceptance criteria say it in words because the repo
# has now shipped SIX checks whose fixture could not reach the failure it named,
# two of them - DEV-02's `_b2.max_w` and CFG-04's blank `soc_entity` - being
# EXACTLY this shape: a per-unit claim compared against a number the fixture
# happened to have twice. MUTATION.md habit 1: write the precondition as its own
# check, so a later edit that makes them equal goes RED instead of making every
# claim below quietly vacuous.
#
# ⚠️ AND ONLY ONE OF THESE NUMBERS IS MEASURED. `A_PACK_FLOOR` is config.yaml's
# shipped `bms_soc_floor` - register 0x0560 on the reference GW5000S-BP. The two
# B values are SIMULATED: there is no second pack on this site (~8 days out) and
# nothing here is a measurement of one. They are chosen for what they have to
# straddle, and that is stated on each.
A_PACK_FLOOR = BMS_FLOOR
# BELOW peak_soc_floor, so a roster carrying it is one rule 8 ACCEPTS.
B_PACK_FLOOR_SIM = SOC_FLOOR - 2.0
# ABOVE peak_soc_floor: the shallower pack, whose own BMS would win. This is the
# configuration the flat key used to accept and this card refuses.
B_PACK_FLOOR_DEEP_SIM = SOC_FLOOR + 15.0
# A state of charge ABOVE the site floor and BELOW the shallow pack's cut-out -
# the ONE band in which the two packs disagree. Outside it they answer the same
# and the fixture would observe nothing.
SOC_BETWEEN = SOC_FLOOR + 5.0
print(f" floors: A {A_PACK_FLOOR:.0f} % (measured), B {B_PACK_FLOOR_SIM:.0f} % "
f"/ {B_PACK_FLOOR_DEEP_SIM:.0f} % (SIMULATED - inverter B does not exist), "
f"peak_soc_floor {SOC_FLOOR:.0f} %, probe SoC {SOC_BETWEEN:.0f} %")
check("PRECONDITION (MUTATION.md habit 1): the two per-pack floors are "
"DIFFERENT numbers and they straddle peak_soc_floor, and the probe SoC "
"sits strictly between them. Two equal floors cannot show a pack graded "
"against the wrong one, which is the defect this card is about and the "
"defect six checks in this repository have already shipped",
A_PACK_FLOOR != B_PACK_FLOOR_DEEP_SIM
and A_PACK_FLOOR < SOC_FLOOR < B_PACK_FLOOR_DEEP_SIM
and A_PACK_FLOOR < SOC_BETWEEN < B_PACK_FLOOR_DEEP_SIM
and B_PACK_FLOOR_SIM != A_PACK_FLOOR and B_PACK_FLOOR_SIM < SOC_FLOOR)
# --- 12a. AC 1: the permission is answered PER PACK --------------------------
# ⚠️ BOTH PACKS ARE AT THE SAME STATE OF CHARGE. That is the whole design of
# this fixture: SoC is held EQUAL so it cannot be what separates the two
# answers, and the only thing left that can is each pack's own floor. The
# shipped `min()` over SoC - which this card does NOT change - reduces these two
# readings to one number before anything is graded, so after it the two packs
# are literally indistinguishable.
_pk_a = Pack("inverter-a", SOC_BETWEEN, A_PACK_FLOOR)
_pk_b = Pack("inverter-b", SOC_BETWEEN, B_PACK_FLOOR_DEEP_SIM)
_perm = pack_permissions([_pk_a, _pk_b], SOC_FLOOR)
print(f" per-pack at the SAME {SOC_BETWEEN:.0f} %: "
+ "; ".join(f"{n}: {'PERMITTED' if w is None else w}" for n, w in _perm))
check("⚠️ AC 1. Two packs AT THE SAME STATE OF CHARGE, graded against their OWN "
"cut-outs, get OPPOSITE answers: the deeper pack is still permitted and "
"the shallower one is WITHHELD. The SoC is equal on purpose - it is the "
"floors that differ, so this line can only be green if each pack was "
"graded against its own number",
_perm == [("inverter-a", None),
("inverter-b", FLOOR_AT_SOC_FLOOR)])
check("...and the refusal is asserted by its CONTENT, not by the fact that one "
"happened (MUTATION.md habit 6): `not None` is satisfied by an unreadable "
"SoC, by an unconfigured floor and by the at-floor case alike, and those "
"are three different operator actions",
_perm[1][1] == FLOOR_AT_SOC_FLOOR
and _perm[1][1] != FLOOR_SOC_UNREADABLE)
# ⚠️ THE COUNTERFACTUAL, AND IT VARIES EXACTLY ONE THING (habit 7). Same two
# packs, same SoC, same site floor - only B's cut-out moves to A's. Both are
# then permitted. Without this line, 12a would also pass for an implementation
# that refused the second entry always, or the named one, or every roster of two.
_perm_same = pack_permissions(
[_pk_a, Pack("inverter-b", SOC_BETWEEN, A_PACK_FLOOR)], SOC_FLOOR)
print(f" ...and with B's cut-out set to A's: "
+ "; ".join(f"{n}: {'PERMITTED' if w is None else 'withheld'}"
for n, w in _perm_same))
check("...and the counterfactual: give B the SAME cut-out as A and BOTH are "
"permitted. One thing varied, from an accepted baseline - so 12a's "
"difference came from the floor and not from the position, the name, or "
"the roster being two long",
[w for _, w in _perm_same] == [None, None])
# ⚠️ AND THE OTHER AXIS, because a per-pack grader could still read pack[0]'s
# SoC for every pack. Same floors, DIFFERENT SoC: the emptier pack is withheld.
_perm_soc = pack_permissions(
[Pack("inverter-a", SOC_FLOOR + 10.0, A_PACK_FLOOR),
Pack("inverter-b", SOC_FLOOR - 1.0, A_PACK_FLOOR)], SOC_FLOOR)
check("...and with the floors EQUAL and the two SoC readings different, it is "
"the emptier pack that is withheld - so the grader reads each pack's own "
"SoC as well as its own floor. Two mutations, two axes, and a fixture "
"that is degenerate on one of them cannot see the other",
[w for _, w in _perm_soc] == [None, FLOOR_AT_SOC_FLOOR])
# --- 12b. an unstated cut-out is WITHHELD, not defaulted ---------------------
# ⚠️ AT 100 % STATE OF CHARGE, which is the strongest form of the claim: a full
# battery is the one value that disarms every other rung in this file, so if the
# missing floor is what refuses THIS pack, nothing else can be doing it.
_perm_none = pack_permissions([Pack("inverter-b", 100.0, None)], SOC_FLOOR)
print(f" a pack with NO stated cut-out, at 100 %: {_perm_none[0][1]}")
check("a roster entry that states no `bms_soc_floor` is WITHHELD even at a full "
"battery - not defaulted to the flat key, not defaulted to 0 %, and not "
"quietly permitted. An invented cut-out IS this card's defect, and 0 % is "
"the most permissive value the key has",
_perm_none[0][1] is not None
and pack_floor_pct(Pack("b", 100.0, None), SOC_FLOOR) is None
and binding_soc_floor([_pk_a, Pack("b", 100.0, None)], SOC_FLOOR) is None)
# --- 12c. AC 5: at a roster of ONE, the floor is the option, unchanged -------
# ⚠️ SWEPT OVER THE WHOLE SCHEMA, NOT ASSERTED ON THE SHIPPED PAIR. "N=1 is
# byte-identical" is a claim about every configuration the schema accepts, and
# the shipped pair is one point in it. Every rule-8-VALID (peak, bms) pair is
# tried; the reduction must answer the option itself in all of them, or some
# install's floor moved under it.
_SW_LO, _SW_HI = int(read_schema_min("peak_soc_floor")), int(read_schema_max("peak_soc_floor"))
_BMS_HI = int(read_schema_max("bms_soc_floor"))
_n1_pairs = [(pk, bm) for pk in range(_SW_LO, _SW_HI + 1)
for bm in range(0, _BMS_HI + 1) if bm < pk]
_n1_bad = [(pk, bm) for pk, bm in _n1_pairs
if binding_soc_floor([Pack("inverter-a", 50.0, float(bm))],
float(pk)) != float(pk)]
print(f" N=1 sweep: {len(_n1_pairs)} rule-8-valid (peak_soc_floor, "
f"bms_soc_floor) pairs over the shipped schema, {len(_n1_bad)} disagree")
check("⚠️ AC 5, SWEPT. At a roster of ONE the floor the guard grades against IS "
"`peak_soc_floor`, for every pair of values the schema accepts that rule "
"8 would also accept. The reduction is new code on the path of every "
"existing install, so 'nothing changes at N=1' is measured over the whole "
"option space rather than at the shipped point",
len(_n1_pairs) > 500 and _n1_bad == [])
# ...and the case rule 8 refuses is the case the reduction is FOR, so it must
# NOT answer the option there - otherwise the max() above is dead in both
# directions and the mutation that deletes it can never bite.
_n1_deep = binding_soc_floor(
[Pack("inverter-a", 50.0, B_PACK_FLOOR_DEEP_SIM)], SOC_FLOOR)
check("...and on the pair rule 8 REFUSES - a cut-out above the EMS floor - the "
"reduction answers the CUT-OUT and not the option, which is the only "
"direction in which it does anything. Without this line the sweep above "
"would be satisfied by `soc_floor` typed straight through",
_n1_deep == B_PACK_FLOOR_DEEP_SIM and _n1_deep != SOC_FLOOR)
# --- 12c2. THE REDUCTION ITSELF, ON A ROSTER WHOSE FLOORS ALL DIFFER ---------
# ⚠️ THIS IS THE CHECK PR #48 DID NOT HAVE, AND ITS ABSENCE IS THIS REPOSITORY'S
# RECURRING DEFECT LANDING ON THIS CARD. Every other fixture above grades
# `binding_soc_floor()` on a roster of ONE (12c) or on a config rule 8 accepts,
# where `pack_floor_pct()` answers `peak_soc_floor` for EVERY pack - so `floors`
# is a list of IDENTICAL numbers and `max`, `min`, `floors[0]` and `floors[-1]`
# are four spellings of one answer. A reviewer changed `max(floors)` to
# `min(floors)` and all 19 files stayed GREEN. The data could not reach the
# failure the check names.
#
# ⚠️ AND THE DEGENERACY IS NOT A DEFENCE, BY THIS CARD'S OWN ARGUMENT.
# `pack_floor_pct()`'s docstring, main.py's call-site comment and DECISIONS §4
# all say "unreachable is a claim about today's rule ordering, not a property of
# this function, and `validate_startup_config()` is also `sim/inject.py`'s entry
# point". That argument justifies WRITING the reduction; it equally OBLIGES
# grading it. Via `sim/inject.py`, which does not run rule 8 at all, `min` here
# lets the guard command a forced discharge down to 20 % on a pack whose BMS
# cuts out at 35 %. So the reduction is graded on a roster rule 8 would refuse -
# deliberately, because that is the only roster on which it does anything.
#
# ⚠️ THE FOUR NUMBERS, AND WHY THESE FOUR. Every one is ABOVE `peak_soc_floor`,
# so `pack_floor_pct()` selects the CUT-OUT for each and `floors` IS this tuple
# in this order. Read as a list, the four candidate reductions are four
# DIFFERENT numbers:
#
# floors = (30, 25, 40, 35)
# max(floors) = 40 <- the shipped answer, the deepest cut-out
# min(floors) = 25
# floors[0] = 30 <- "grade the site against pack A", survivor 2's
# defect expressed inside the function
# floors[-1] = 35
# mean = 32.5
#
# so no two of them can be confused, and the extremes sit at INTERIOR roster
# positions (index 2 is the max, index 1 the min) - which is what a two-pack
# fixture structurally cannot do, since there `max` and `last` coincide and so
# do `min` and `first`. Three packs cannot do it either. Four is the smallest
# roster that separates all four.
#
# ⚠️ ALL FOUR ARE SIMULATED. There is ONE pack on this site; inverter B is ~8
# days out and does not physically exist, and a roster of four certainly does
# not. Nothing here is a measurement of anything. `binding_soc_floor()` is a
# pure function over a list and these are the inputs that make its answer
# observable - the only measured cut-out in this file is inverter A's 10 %.
_MANY_FLOORS_SIM = (30.0, 25.0, 40.0, 35.0)
_many_packs = [Pack(f"sim-{i}", 50.0, f) for i, f in enumerate(_MANY_FLOORS_SIM)]
_many_derived = [pack_floor_pct(pk, SOC_FLOOR) for pk in _many_packs]
_many_answers = {"max": max(_MANY_FLOORS_SIM), "min": min(_MANY_FLOORS_SIM),
"first": _MANY_FLOORS_SIM[0], "last": _MANY_FLOORS_SIM[-1],
"mean": sum(_MANY_FLOORS_SIM) / len(_MANY_FLOORS_SIM)}
print(f" a SIMULATED roster of {len(_many_packs)} with cut-outs "
f"{[f'{f:.0f}' for f in _MANY_FLOORS_SIM]} % over peak_soc_floor "
f"{SOC_FLOOR:.0f} % -> per-pack floors {[f'{f:.0f}' for f in _many_derived]} %")
print(" candidate reductions: "
+ ", ".join(f"{k}={v:g}" for k, v in _many_answers.items())
+ f"; binding_soc_floor() -> {binding_soc_floor(_many_packs, SOC_FLOOR):g}")
check("PRECONDITION (MUTATION.md habit 1): on this roster max, min, first, last "
"and the mean are FIVE DIFFERENT numbers, and every cut-out is strictly "
"above peak_soc_floor so the per-pack floors ARE the cut-outs. A later "
"edit that makes any two of them equal takes this line red rather than "
"leaving the claim below quietly vacuous - which is exactly what a "
"rule-8-valid roster does to it",
len(set(_many_answers.values())) == 5
and all(f > SOC_FLOOR for f in _MANY_FLOORS_SIM)
and _many_derived == list(_MANY_FLOORS_SIM))
check("⚠️ `binding_soc_floor()` answers the MAXIMUM of the packs' own floors - "
"the DEEPEST cut-out on the roster - and on this roster that is not the "
"minimum, not the first pack's, not the last pack's and not the mean. "
"This is the conservative pairing with the MINIMUM in `pack_floor_soc`: "
"`min(soc) <= max(floor)` is implied by 'some pack is at or below its own "
"floor', so the site verdict is never more permissive than grading each "
"pack separately. `min(floors)` here would let a forced discharge be "
"sized down to 25 % on the pack whose BMS cuts out at 40 %",
binding_soc_floor(_many_packs, SOC_FLOOR) == _many_answers["max"]
and binding_soc_floor(_many_packs, SOC_FLOOR) != _many_answers["min"]
and binding_soc_floor(_many_packs, SOC_FLOOR) != _many_answers["first"]
and binding_soc_floor(_many_packs, SOC_FLOOR) != _many_answers["last"]
and binding_soc_floor(_many_packs, SOC_FLOOR) != _many_answers["mean"])
# ⚠️ THE COUNTERFACTUAL, ONE THING VARIED FROM AN ACCEPTED BASELINE (habit 7).
# The pack that is raised is the LAST one, which is neither the max nor the min
# above - so this line can only move if the reduction really looked at it. It is
# raised past the old maximum, and the answer follows it.
_deeper = list(_many_packs)
_deeper[-1] = Pack("sim-3", 50.0, 45.0)
print(f" ...raise ONLY the last pack 35 % -> 45 %: "
f"{binding_soc_floor(_many_packs, SOC_FLOOR):g} -> "
f"{binding_soc_floor(_deeper, SOC_FLOOR):g}")
check("...and the counterfactual: raise ONE pack - the LAST, which was neither "
"the deepest nor the shallowest - past the old deepest, and the answer "
"follows it to 45 %. So the reduction reads the LAST entry, and the 40 % "
"above was not a coincidence of that one position. ⚠️ THE LAST ENTRY IS "
"ALL THIS REACHES: every window ending at the roster's end contains it, "
"so `max(floors[-2:])` follows this line exactly. It claimed 'every "
"entry' and could not see that; the table below is the data that can",
binding_soc_floor(_deeper, SOC_FLOOR) == 45.0
and binding_soc_floor(_many_packs, SOC_FLOOR) == 40.0)
# ⚠️ AND THAT COUNTERFACTUAL IS NOT ENOUGH EITHER, WHICH IS ROUND 3's BLOCKER 1.
# It varies only the LAST pack, and EVERY window that contains the last two
# entries follows it - `max(floors[-2:])` answers 40 on the baseline and 45 on
# `_deeper`, exactly as the shipped reduction does. It SURVIVED all 19 files.
# The sentence above used to claim "the reduction reads every entry"; the DATA
# reaches only "it reads the last entry", and the claim has been cut back to
# that. This repository's recurring defect is a check whose text is broader than
# its fixture, and it had landed inside the check written to close it.
#
# ⚠️ THE GENERAL LAW, and the thing to carry to the next card: A FIXTURE OF N
# PACKS CANNOT SEPARATE TWO REDUCTIONS THAT AGREE ON N PACKS. Two packs cannot
# separate max from min from first from last. THREE cannot separate max from
# max-of-the-last-two. A single roster cannot separate a reduction from any
# window that happens to contain its answer. So the fixture is not one roster
# but a SET, and the property is not "this number is right" but "each member of
# an enumerated family of rewrites is separated from `max` somewhere in the set".
#
# THE SET: the same four cut-outs, with the deepest one moved to EACH position
# in turn. A slice that omits position i answers below the deepest on the roster
# whose deepest sits at i, so no proper subset of {0,1,2,3} survives all four;
# min, mean and median are separated by the baseline. The table is COMPUTED and
# PRINTED, so a member no roster kills shows up as a survivor and takes the
# check red naming it, rather than being absent from a list nobody re-derived.
#
# ⚠️ WHAT IT DOES NOT CLAIM. It grades the LISTED members. A rewrite outside the
# family - one that sorts first, filters on pack NAME, or rounds - is not
# covered, and the printed table is the exact extent of the claim.
_DEEPEST_SIM = 50.0
_reduction_rosters = [("baseline ", tuple(_MANY_FLOORS_SIM))]
for _i in range(len(_MANY_FLOORS_SIM)):
_fl = list(_MANY_FLOORS_SIM)
_fl[_i] = _DEEPEST_SIM
_reduction_rosters.append((f"deepest at index {_i} ", tuple(_fl)))
def _sim_packs(floors):
"""The same simulated roster shape 12c2 uses, at whatever floors are given."""
return [Pack(f"sim-{i}", 50.0, f) for i, f in enumerate(floors)]
_REDUCTION_FAMILY = {
"min(floors)": lambda fl: min(fl),
"floors[0] (== max(floors[:1]))": lambda fl: fl[0],
"floors[-1] (== max(floors[-1:]))": lambda fl: fl[-1],
"mean(floors)": lambda fl: sum(fl) / len(fl),
"median(floors), upper": lambda fl: sorted(fl)[len(fl) // 2],
"max(floors[:2])": lambda fl: max(fl[:2]),
"max(floors[-2:])": lambda fl: max(fl[-2:]),
"max(floors[:3])": lambda fl: max(fl[:3]),
"max(floors[-3:])": lambda fl: max(fl[-3:]),
"max(floors[1:])": lambda fl: max(fl[1:]),
"max(floors[:-1])": lambda fl: max(fl[:-1]),
"max(floors[1:-1])": lambda fl: max(fl[1:-1]),
"max(floors[::2])": lambda fl: max(fl[::2]),
"min(floors[-2:])": lambda fl: min(fl[-2:]),
}
_reduction_kills = {
_nm: [_rn.strip() for _rn, _fl in _reduction_rosters if _fn(_fl) != max(_fl)]
for _nm, _fn in _REDUCTION_FAMILY.items()}
print(" THE ROSTER SET (all SIMULATED), and binding_soc_floor() on each:")
for _rn, _fl in _reduction_rosters:
print(f" {_rn} floors {[f'{f:.0f}' for f in _fl]} -> "
f"{binding_soc_floor(_sim_packs(_fl), SOC_FLOOR):g} "
f"(max() {max(_fl):g})")
print(" THE REDUCTION FAMILY, and which roster separates each from max():")
for _nm, _k in _reduction_kills.items():
print(f" {_nm:<34} killed by: "
+ ("; ".join(_k) if _k else "*** NOTHING IN THIS SET - SURVIVOR ***"))
check("⚠️ THE FAMILY TABLE. Every listed rewrite of `max(floors)` answers "
"something OTHER than `max` on at least one roster in this set - "
"`max(floors[-2:])`, round 3's blocker, only on the roster whose deepest "
"cut-out sits at index 0 or 1, which is precisely the data the single "
"last-pack counterfactual above could not supply. A member with an empty "
"kill list is a live survivor and takes THIS line red naming it, so "
"adding a family member without a fixture that reaches it is not "
"possible quietly. ⚠️ THE LISTED MEMBERS ONLY - the printed table is the "
"extent of the claim",
all(_reduction_kills.values()))
check("...and `binding_soc_floor()` answers `max()` on EVERY roster in the set, "
"which is what turns each separation above into a real mutation kill: "
"rewrite the reduction as any listed member and it disagrees with this "
"line on the roster named beside it. In particular the deepest cut-out is "
"the answer at index 0, 1, 2 AND 3, so no positional window of the roster "
"is what is being read",
all(binding_soc_floor(_sim_packs(_fl), SOC_FLOOR) == max(_fl)
for _rn, _fl in _reduction_rosters)
and [binding_soc_floor(_sim_packs(_fl), SOC_FLOOR)
for _rn, _fl in _reduction_rosters[1:]] == [_DEEPEST_SIM] * 4)
check("PRECONDITION (habit 1): the set is five FOUR-pack rosters; the four "
"derived ones each carry the SAME multiset of cut-outs with the deepest "
"at a different position; every cut-out in every roster is strictly above "
"`peak_soc_floor`, so `pack_floor_pct()` selects the cut-out and `floors` "
"IS the tuple; and the deepest is UNIQUE in each, without which a "
"positional slice could agree with max by accident",
len(_reduction_rosters) == 5
and all(len(_fl) == 4 for _rn, _fl in _reduction_rosters)
and all(f > SOC_FLOOR for _rn, _fl in _reduction_rosters for f in _fl)
and all(sorted(_fl) == sorted((_DEEPEST_SIM,) + _MANY_FLOORS_SIM[1:])
or sorted(_fl) == sorted(_MANY_FLOORS_SIM[:1] + (_DEEPEST_SIM,)
+ _MANY_FLOORS_SIM[2:])
or sorted(_fl) == sorted(_MANY_FLOORS_SIM[:2] + (_DEEPEST_SIM,)
+ _MANY_FLOORS_SIM[3:])
or sorted(_fl) == sorted(_MANY_FLOORS_SIM[:3] + (_DEEPEST_SIM,))
for _rn, _fl in _reduction_rosters[1:])
and all(_fl.count(max(_fl)) == 1 for _rn, _fl in _reduction_rosters))
# ⚠️ AND THE FOUR-PACK SET HAS THE SAME CEILING AS EVERY OTHER FIXTURE: it
# cannot separate `max(floors)` from `max(floors[:4])`, because at N=4 that
# slice IS the list. This round's battery confirmed the identical shape one pack
# along at the call site (`self.packs[:3]` survives a three-pack run), so it is
# graded here rather than assumed away. `binding_soc_floor()` is a PURE function
# over a list, which is the one place the sweep is cheap: rosters of length 1
# through 12, the deepest cut-out at EVERY position of each, and the answer must
# be the deepest every time. That kills every fixed-width head or tail window
# narrower than the longest roster, at once, rather than one mutation at a time.
_SWEEP_N = 12
_SWEEP_SHALLOW, _SWEEP_DEEP = SOC_FLOOR + 5.0, SOC_FLOOR + 25.0
_sweep_rows = []
for _n in range(1, _SWEEP_N + 1):
for _pos in range(_n):
_fl = [_SWEEP_SHALLOW] * _n
_fl[_pos] = _SWEEP_DEEP
_sweep_rows.append((_n, _pos, tuple(_fl)))
_sweep_bad = [(n, pos, fl) for n, pos, fl in _sweep_rows
if binding_soc_floor(_sim_packs(fl), SOC_FLOOR) != max(fl)]
_head_kill = {w: any(max(fl[:w]) != max(fl) for _n, _p, fl in _sweep_rows)
for w in range(1, _SWEEP_N + 1)}
_tail_kill = {w: any(max(fl[-w:]) != max(fl) for _n, _p, fl in _sweep_rows)
for w in range(1, _SWEEP_N + 1)}
print(f" LENGTH SWEEP (all SIMULATED): {len(_sweep_rows)} rosters, lengths 1"
f"..{_SWEEP_N}, the {_SWEEP_DEEP:.0f} % cut-out at EVERY position of each "
f"among {_SWEEP_SHALLOW:.0f} % siblings -> "
f"{len(_sweep_rows) - len(_sweep_bad)} answer the deepest, "
f"{len(_sweep_bad)} do not")
print(" fixed-width windows killed by the sweep: head widths "
+ ",".join(str(w) for w in sorted(_head_kill) if _head_kill[w])
+ "; tail widths "
+ ",".join(str(w) for w in sorted(_tail_kill) if _tail_kill[w])
+ f"; NOT reached: head/tail width {_SWEEP_N} and wider")
check("⚠️ THE LENGTH SWEEP. Over rosters of 1 to 12 packs with the deepest "
"cut-out at every position of each, `binding_soc_floor()` answers the "
"DEEPEST every time - so every head window `max(floors[:w])` and every "
"tail window `max(floors[-w:])` of width 1 to 11 disagrees with it on at "
"least one roster here, and that is the whole positional family below "
"width 12 closed in one property rather than one mutation at a time",
not _sweep_bad
and len(_sweep_rows) == _SWEEP_N * (_SWEEP_N + 1) // 2
and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N)))
check("⚠️ ...AND WIDTH 12 AND WIDER IS HONESTLY NOT REACHED, recorded rather "
"than papered over. No roster in this file is longer than 12, so "
"`max(floors[:12])` IS `max(floors)` on every one of them and no check "
"here can tell them apart. A 13-pack roster would close width 12 and hand "
"width 13 the same free pass: THE REGRESS DOES NOT TERMINATE IN A "
"FIXTURE, which is this round's lesson and is why 12g's call site carries "
"an EXACT STRUCTURAL pin alongside its runs. This line asserts the "
"unreachedness itself, so a later sweep that lengthens the rosters takes "
"it red and forces the sentence to be rewritten",
not _head_kill[_SWEEP_N] and not _tail_kill[_SWEEP_N]
and max(len(fl) for _n, _p, fl in _sweep_rows) == _SWEEP_N)
# ...and the EMS floor is still the other half of the conjunction: lift it above
# every cut-out on the roster and it wins for all of them.
_over = SOC_FLOOR + 40.0
check("...and with `peak_soc_floor` lifted above every cut-out on this roster "
"the answer is the OPTION, for all four - `pack_floor_pct()` is a "
"conjunction and this is its other side, so a reduction that dropped the "
"EMS floor entirely goes red here as well as in the N=1 sweep",
_over > max(_MANY_FLOORS_SIM)
and binding_soc_floor(_many_packs, _over) == float(_over))
# ...and the EMPTY roster, the SECOND thing this fix round's own battery found
# unguarded: `return None` in place of `float(soc_floor)` there (-43 bytes) was
# GREEN across all 19 files. It is a defensive branch - `Controller.inverters` is never
# empty on a started controller, and `inputs_ok` refuses the cycle before the
# guard is reached if it were - so nothing in the suite went near it. Pinned
# rather than argued, because a branch with no check is a branch that can be
# rewritten under a green suite, and BOTH directions of it are asserted: the
# option when there is one, and None when there is not.
print(f" an EMPTY roster: binding_soc_floor([], {SOC_FLOOR:.0f}) -> "
f"{binding_soc_floor([], SOC_FLOOR)}, binding_soc_floor([], None) -> "
f"{binding_soc_floor([], None)}")
check("an EMPTY roster answers the OPTION - there is no pack to conjoin with, "
"so there is nothing to raise the floor above it - and answers None when "
"the option itself is None. ⚠️ AND IT CANNOT PERMIT ANYTHING EITHER WAY: "
"`Controller.pack_floor_soc` is None at an empty roster, and "
"`discharge_permitted()` refuses on a None SoC whatever the floor is",
binding_soc_floor([], SOC_FLOOR) == float(SOC_FLOOR)
and binding_soc_floor([], None) is None
and discharge_permitted(None, binding_soc_floor([], SOC_FLOOR))
== FLOOR_SOC_UNREADABLE)
# --- 12d. AC 4: pack_floor_soc's None-on-missing is untouched ---------------
_c32, _h32 = built(TMP / "s32.db")
_b32, _restore32 = second_inverter(_c32)
_c32.inverters[0].soc, _c32.inverters[1].soc = SOC_BETWEEN, SOC_FLOOR + 30.0
check("AC 4: `pack_floor_soc` is still the MINIMUM measured SoC across the "
"roster - this card changed the FLOOR, not the reduction, and DEV-02's "
"comment is the record of why the emptiest pack is right",
_c32.pack_floor_soc == SOC_BETWEEN)
_c32.inverters[1].soc = None
check("...and it is still None - never a number - when ANY pack did not answer, "
"so a silent pack is floor-limited rather than defaulted to full. The "
"per-pack grading added beside it must not have turned an absence into a "
"reading",
_c32.pack_floor_soc is None
and pack_permissions(_c32.packs, SOC_FLOOR)[1][1] == FLOOR_SOC_UNREADABLE)
_c32.inverters[1].soc = SOC_FLOOR + 30.0
# ⚠️ ASSERTED AGAINST LITERALS THAT DIFFER ON ALL THREE AXES (habit 2). Compared
# against `[(inv.name, inv.soc, inv.bms_soc_floor) for inv in ...]` this check
# could not tell "each unit's own three numbers" from "the first unit's three,
# twice"; the tuples below are written out, and no two of them share a value.
_c32.inverters[0].bms_soc_floor = A_PACK_FLOOR
_c32.inverters[1].bms_soc_floor = B_PACK_FLOOR_SIM
print(f" Controller.packs -> {[(p.name, p.soc, p.bms_floor) for p in _c32.packs]}")
check("...and `Controller.packs` carries EACH unit's OWN name, SoC and cut-out "
"- not the first unit's repeated, which is DEV-02's `self.inverters[0]` "
"defect and the one that left all 19 files green",
[(pk.name, pk.soc, pk.bms_floor) for pk in _c32.packs]
== [("inverter-a", SOC_BETWEEN, A_PACK_FLOOR),
("inverter-b", SOC_FLOOR + 30.0, B_PACK_FLOOR_SIM)]
and A_PACK_FLOOR != B_PACK_FLOOR_SIM and SOC_BETWEEN != SOC_FLOOR + 30.0)
_restore32()
# --- 12e. rule 8 grades EVERY unit and NAMES it ------------------------------
def _roster32(a_floor, b_floor, **kw):
"""A two-entry roster, otherwise-startable, differing only in the floors."""
entries = []
for nm, sp, fl in (("inverter-a", "number.sp_a", a_floor),
("inverter-b", "number.sp_b", b_floor)):
e = {"name": nm, "setpoint_entity": sp, "max_w": 2000}
if fl is not None:
e["bms_soc_floor"] = fl
entries.append(e)
return opts24(setpoint_entity="", inverters=entries, **kw)
check("the two-entry fixture with BOTH cut-outs under the EMS floor is "
"ACCEPTED, so every refusal below has nowhere else to come from "
"(MUTATION.md habit 7)",
validate_startup_config(_roster32(A_PACK_FLOOR, B_PACK_FLOOR_SIM)) == [])
# ⚠️ THE OFFENDER IS THE SECOND ENTRY. CFG-04 has just fixed a rule 5/6 bug of
# exactly this shape - a caps-list index fed to a function that indexes the
# ROSTER, so the refusal named the WRONG INVERTER - and an index hardwired to 0,
# or a loop that grades only the first entry, passes a fixture whose offender is
# first. So it is second here, and first in the mirror below.
_r_second = rule8(validate_startup_config(
_roster32(A_PACK_FLOOR, B_PACK_FLOOR_DEEP_SIM)))
# ⚠️ INDEXED THROUGH `_msg()`, NOT `[0]`. The mutation this pair exists to catch
# - rule 8 grading only the first unit - makes this list EMPTY, and a bare `[0]`
# turns the red into an IndexError traceback that kills the file before the
# remaining checks report. A mutation battery wants NAMED reds, not a stack.
def _msg(problems, i=0):
return problems[i] if len(problems) > i else ""
print(f" offender SECOND -> {len(_r_second)} refusal(s): "
f"{_msg(_r_second)[:150] or '(none)'}...")
check("⚠️ AC 3. Rule 8 grades EVERY unit: a roster whose SECOND pack has a "
"cut-out above `peak_soc_floor` is refused, ONCE, and the refusal NAMES "
"inverter-b. Under the flat key this config STARTED, and the guard would "
"then have commanded a forced discharge that pack B's BMS - not the "
"operator's floor - is the thing that stops",
len(_r_second) == 1 and " for inverter-b" in _msg(_r_second)
and " for inverter-a" not in _msg(_r_second))
check("...and it names BOTH numbers, so the operator can act on it without "
"reading the source",
f"{B_PACK_FLOOR_DEEP_SIM:.0f} %" in _msg(_r_second)
and f"{SOC_FLOOR:.0f} %" in _msg(_r_second))
# The mirror. A grader hardwired to index 1 - or one that reports the LAST
# offender - passes the check above and fails this one.
_r_first = rule8(validate_startup_config(
_roster32(B_PACK_FLOOR_DEEP_SIM, A_PACK_FLOOR)))
check("...and with the SAME two numbers in the OTHER ORDER it names "
"inverter-a instead. The pair of checks is what makes this an index "
"check: one of them goes red for any hardwired position",
len(_r_first) == 1 and " for inverter-a" in _msg(_r_first)
and " for inverter-b" not in _msg(_r_first))
# And both at once, because a loop that `break`s on the first offender reports
# one refusal for a config with two.
_r_both = rule8(validate_startup_config(
_roster32(B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_DEEP_SIM + 1.0)))
check("...and a roster where BOTH packs offend is refused TWICE, once per unit "
"and each naming its own - so an operator with two wrong packs is not "
"told about one of them and left to discover the other on the next boot",
len(_r_both) == 2 and " for inverter-a" in _msg(_r_both, 0)
and " for inverter-b" in _msg(_r_both, 1))
# A missing per-entry cut-out is refused BY NAME rather than defaulted.
_r_missing = rule8(validate_startup_config(_roster32(A_PACK_FLOOR, None)))
print(f" entry with no bms_soc_floor -> {_msg(_r_missing)[:120] or '(none)'}...")
check("...and an entry that states NO `bms_soc_floor` is refused by name rather "
"than defaulted. THE DECISION THIS CARD RECORDS: a per-entry default is a "
"cut-out this add-on invented for a battery it has not been told about, "
"which is the same wrong number the flat key produced. See "
"DECISIONS/SAFETY-32-per-pack-bms-floor.md",
len(_r_missing) == 1 and " for inverter-b" in _msg(_r_missing)
and "must both be numbers" in _msg(_r_missing))
# --- 12f. nothing is inherited, with the flat key SET AND DIFFERENT ----------
# ⚠️ THE FLAT KEY IS A REAL, DIFFERENT NUMBER HERE, AND THAT IS THE CHECK.
# CFG-04 wrote this exact claim against a fixture whose flat `soc_entity` was
# BLANK, so inherited and not-inherited were the same empty string and the
# mutation that restores the fallback SURVIVED. 30 % is nothing else in this
# file, so an entry that came back holding it can only have borrowed it.
_FLAT_ODD = 30.0
_opts_inh = {"bms_soc_floor": _FLAT_ODD, "setpoint_entity": "",
"inverters": [{"name": "inverter-a", "setpoint_entity": "number.sp_a",
"max_w": 2000, "bms_soc_floor": A_PACK_FLOOR},
{"name": "inverter-b", "setpoint_entity": "number.sp_b",
"max_w": 2000}]}
_r_inh = build_roster(_opts_inh, _Store())
print(f" flat bms_soc_floor {_opts_inh['bms_soc_floor']:.0f} %, roster carries "
f"{[i.bms_soc_floor for i in _r_inh]}")
check("an entry that omits `bms_soc_floor` has NO cut-out and does NOT borrow "
"the flat one - which is set to a real, DIFFERENT number right here, so "
"inherited and not-inherited are visibly different answers. A pack graded "
"against a floor that belongs to another battery is this whole card",
_r_inh[1].bms_soc_floor is None
and _r_inh[0].bms_soc_floor == A_PACK_FLOOR
and _FLAT_ODD not in [i.bms_soc_floor for i in _r_inh])
# ...and at the FLAT shape the same key IS this one pack's cut-out.
_r_flat = build_roster({"setpoint_entity": "number.sp",
"bms_soc_floor": _FLAT_ODD}, _Store())
check("...while at the single-inverter shape the flat key IS that one pack's "
"cut-out, because there the flat keys describe the one inverter. The two "
"readings of the same key are the two shapes, not a fallback",
len(_r_flat) == 1 and _r_flat[0].bms_soc_floor == _FLAT_ODD)
_r_dflt = build_roster({"setpoint_entity": "number.sp"}, _Store())
check("...and with the key absent entirely the one pack takes the SHIPPED "
"default out of the one mirror - so an install that never set it reads "
"the same number the validator grades it against, and CFG-03 gains no "
"fourth defaults site",
_r_dflt[0].bms_soc_floor == BMS_FLOOR
and BMS_FLOOR == PEAK_OPTION_DEFAULTS["bms_soc_floor"])
# --- 12g. the call site: main.py hands decide() the per-pack reduction -------
# ⚠️ REWRITTEN. THIS CHECK USED TO BE A SUBSTRING TEST OVER main.py's SOURCE -
# `"self.packs" in _floor_call` - AND IT WAS NOT A PIN. The mutation that grades
# the whole site against pack A's floor is `self.packs[:1]`, which still
# CONTAINS that substring: +4 bytes, all 19 files green, including this one.
# MUTATION.md habit 4, in the same costume CFG-04's `if entity:` wore: the check
# asserted against the TEXT of the thing under test instead of its behaviour.
# The AST is still unparsed and PRINTED (habit 3) because a reader wants to see
# the expression, but nothing below asserts on its spelling. Which OPTION KEY
# the floor comes from is a claim genuinely about the code's shape and is pinned
# on the source in part 4, where it already lives; what is graded here is the
# VALUE that reaches `decide()`.
_floor_call = _src(first(k.value for k in _call.keywords
if k.arg == "soc_floor"))
print(f" main.py's decide(soc_floor=...) is `{_floor_call}`")
def _floor_reaching_decide(tag, *floors, peak_floor=None):
"""Drive ONE REAL `Controller.cycle()` on an N-pack roster and report the
floor `peakguard.decide()` was actually handed, read back off the decision
main.py itself filed (`PeakDecision.soc_floor` is the argument, echoed).
⚠️ NOT A SPY AND NOT A GREP. The shipped `cycle()` runs, the shipped
`binding_soc_floor()` runs, and the observable is a field of the decision
the controller stored - so the whole path from `Controller.inverters` to the
guard's argument is under test. Every pack is held at the SAME SoC and the
same everything else; the only things that vary between calls are the packs'
own cut-outs.
⚠️ N IS A PARAMETER, AND THAT IS THIS ROUND'S BLOCKER 2. It used to be fixed
at TWO, and a roster of two cannot distinguish a call site that reduces the
ROSTER from one that reduces a WINDOW of two positions in it - `self.packs`
and `self.packs[:2]` are the same list there, by exactly the argument 12c2
makes about two packs being unable to separate max from min from first from
last. The extra units are appended through `second_inverter()`, the same
hook the two-pack runs use, one per floor past the first.
"""
opts = {} if peak_floor is None else {"peak_soc_floor": peak_floor}
def _prep(ctl):
for _i in range(len(floors) - 1):
_sfx = chr(ord("b") + _i)
second_inverter(ctl, name=f"inverter-{_sfx}",
setpoint=f"number.sp_{_sfx}")
for inv, fl in zip(ctl.inverters, floors):
inv.soc, inv.batt, inv.bms_soc_floor = SOC_BETWEEN, 0.0, fl
ctl, hs, _net, _rem = wired_over_limit(
TMP / f"s32g_{tag}.db", LIVE_MAX_W * 0.3, opts=opts, prepare=_prep)
return ctl, ctl.peak_guard
# BASELINE: neither cut-out is above the EMS floor, so the conjunction selects
# the option for both packs and the site floor is `peak_soc_floor`. At
# SOC_BETWEEN (5 points above it) the guard is permitted and sizes a real
# discharge. Both runs below vary one thing from THIS baseline (habit 7).
_g_ctl_shallow, _g_shallow = _floor_reaching_decide(
"shallow", A_PACK_FLOOR, B_PACK_FLOOR_SIM)
# The SECOND pack's cut-out moves above the EMS floor. Nothing else changes -
# not pack A, not either SoC, not the option.
_g_ctl_deep, _g_deep = _floor_reaching_decide(
"deepb", A_PACK_FLOOR, B_PACK_FLOOR_DEEP_SIM)
# ⚠️ AND THE SAME TWO CUT-OUTS SWAPPED BETWEEN THE PACKS, which is the mirror
# 12h already runs on the SoC axis and which THIS part needed and did not have.
# Without it, `self.packs[-1:]` - grade the site against the LAST pack - answers
# the same two numbers as the full roster on the two runs above, because pack A
# is never the deeper one there. It SURVIVED the first cut of this fix round,
# +5 bytes, all 19 files green. With the pair swapped, `[:1]` and `[-1:]`
# disagree with the roster in opposite runs, and no single-position slice is
# left that can pass all three.
_g_ctl_deepa, _g_deepa = _floor_reaching_decide(
"deepa", B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_SIM)
print(f" REAL two-pack cycles, both packs at {SOC_BETWEEN:.0f} %, "
f"peak_soc_floor {SOC_FLOOR:.0f} % (every cut-out but inverter-a's own "
f"{A_PACK_FLOOR:.0f} % is SIMULATED):")
print(f" cut-outs A {A_PACK_FLOOR:.0f} / B {B_PACK_FLOOR_SIM:.0f} % -> "
f"decide() got soc_floor={_g_shallow.soc_floor:g} %, floor_limited="
f"{_g_shallow.floor_limited}")
print(f" cut-outs A {A_PACK_FLOOR:.0f} / B {B_PACK_FLOOR_DEEP_SIM:.0f} % -> "
f"decide() got soc_floor={_g_deep.soc_floor:g} %, floor_limited="
f"{_g_deep.floor_limited}")
print(f" ...the SAME PAIR SWAPPED, A {B_PACK_FLOOR_DEEP_SIM:.0f} / B "
f"{B_PACK_FLOOR_SIM:.0f} % -> decide() got "
f"soc_floor={_g_deepa.soc_floor:g} %, floor_limited="
f"{_g_deepa.floor_limited}")
check("⚠️ THE CALL-SITE PIN, AS BEHAVIOUR. Change the cut-out of a NON-FIRST "
"pack and the floor that reaches `decide()` MOVES with it: 20 % (the "
"option, because B's cut-out is under it) becomes 35 % (B's own cut-out, "
"because it is over it). A call site that graded the site against "
"`self.packs[:1]` - pack A alone - answers 20 % both times and cannot "
"produce this difference, and neither can `min(floors)`; that is the "
"+4-byte mutation that walked through the substring test this check "
"replaces",
_g_shallow.soc_floor == float(SOC_FLOOR)
and _g_deep.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
and _g_deep.soc_floor != _g_shallow.soc_floor)
check("⚠️ ...AND THE SAME PAIR SWAPPED GIVES THE SAME SITE FLOOR, so no "
"SINGLE-POSITION slice of the roster can produce these three answers. "
"`self.packs[-1:]` - the LAST pack alone - agrees with the roster on both "
"runs above and answers 20 % here, where the deeper cut-out sits on the "
"FIRST pack. That mutation SURVIVED the first cut of this fix round and "
"this is the line that closes it. ⚠️ AND THAT IS ALL THIS LINE SHOWS - "
"NOT that the whole roster is read. At N=2 a two-position WINDOW is the "
"roster: `self.packs[:2]` answers identically on all three runs above and "
"walks straight through this check, +4 bytes, 19 files green. It is "
"round 3's blocker 2, and the THREE-pack runs below are what reach it",
_g_deepa.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
and _g_deepa.soc_floor == _g_deep.soc_floor
and _g_deepa.soc_floor != _g_shallow.soc_floor
and [pk.bms_floor for pk in _g_ctl_deepa.packs]
== [B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_SIM])
check("...and it is not just a number in a report - THE SITE'S VERDICT FLIPS. "
"With both packs at 25 % the shallow site is permitted and the guard "
"sizes a positive forced discharge; BOTH deep sites are FLOOR-LIMITED, "
"because 25 % is at or below the 35 % cut-out wherever on the roster it "
"sits. Grading the roster against one position would spend a pack its "
"BMS stops - through `sim/inject.py`, which does not run rule 8 - and "
"this line is what goes red when it does",
_g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0
and _g_deep.floor_limited is True and _g_deep.floor_w == 0.0
and _g_deep.soc_floor_reason == FLOOR_AT_SOC_FLOOR
and _g_deepa.floor_limited is True and _g_deepa.floor_w == 0.0
and _g_deepa.soc_floor_reason == FLOOR_AT_SOC_FLOOR)
check("...and the precondition that makes the three runs comparable (habit 1): "
"the two deep runs carry THE SAME PAIR of cut-outs in opposite orders, "
"those two numbers really are different, and all six packs are at the "
"SAME state of charge - so neither the SoC reduction nor a roster "
"position can be what separated the answers",
B_PACK_FLOOR_SIM != B_PACK_FLOOR_DEEP_SIM
and A_PACK_FLOOR != B_PACK_FLOOR_DEEP_SIM
and sorted(pk.bms_floor for pk in _g_ctl_deepa.packs)
== sorted([B_PACK_FLOOR_SIM, B_PACK_FLOOR_DEEP_SIM])
and [pk.soc for pk in _g_ctl_deep.packs] == [SOC_BETWEEN, SOC_BETWEEN]
and [pk.soc for pk in _g_ctl_deepa.packs] == [SOC_BETWEEN, SOC_BETWEEN]
and [pk.bms_floor for pk in _g_ctl_deep.packs]
== [A_PACK_FLOOR, B_PACK_FLOOR_DEEP_SIM]
and [pk.bms_floor for pk in _g_ctl_shallow.packs]
== [A_PACK_FLOOR, B_PACK_FLOOR_SIM]
and _g_ctl_deep.pack_floor_soc == _g_ctl_shallow.pack_floor_soc
== _g_ctl_deepa.pack_floor_soc)
# ⚠️ AND THREE RUNS ON A TWO-PACK ROSTER ARE STILL NOT ENOUGH, WHICH IS THIS
# ROUND'S BLOCKER 2. The paragraph above states the TRUE claim - no
# SINGLE-POSITION slice passes all three - and the check below it stated a
# BROADER one, "what is reduced is the ROSTER and not a POSITION in it". The
# broader one is false on this fixture: `self.packs[:2]` IS `self.packs` at a
# roster of two, so it answers identically on every run above and SURVIVED all
# 19 files (+4 bytes). The prose has been narrowed to what those three runs
# reach; what follows is the data that reaches the rest.
#
# THE FIXTURE, and why it is shaped this way. Three REAL cycles on a THREE-pack
# roster, the deep cut-out sitting at a DIFFERENT position each time - index 0,
# index 1 (the interior one a two-pack roster has no room for) and index 2. Any
# positional slice of `self.packs` is a proper subset of {0, 1, 2}, so it omits
# at least one position, and on the run whose deep pack sits at that position it
# sees only shallow cut-outs and answers the OPTION where the roster answers the
# cut-out. That is the whole argument, and it is enumerated rather than asserted
# from memory in the table below.
#
# ⚠️ SIMULATED. Two of the three units do not exist; inverter B is ~8 days out
# and there is no inverter C at all. The shallow slots carry inverter A's
# MEASURED 10 % cut-out and the deep one is the same SIMULATED 35 % the runs
# above use. Nothing here is a measurement of a second or third pack.
_G3_DEEP_SIM = B_PACK_FLOOR_DEEP_SIM
_g3_runs = []
for _pos in range(3):
_fl3 = [A_PACK_FLOOR] * 3
_fl3[_pos] = _G3_DEEP_SIM
_ctl3, _dec3 = _floor_reaching_decide(f"n3p{_pos}", *_fl3)
_g3_runs.append((f"3-pack, deep at index {_pos}", _fl3, _ctl3, _dec3))
print(f" REAL THREE-pack cycles, all packs at {SOC_BETWEEN:.0f} %, "
f"peak_soc_floor {SOC_FLOOR:.0f} %, the {_G3_DEEP_SIM:.0f} % cut-out "
f"moved along the roster (units b and c are SIMULATED):")
for _rn, _fl3, _ctl3, _dec3 in _g3_runs:
print(f" {_rn}: cut-outs {[f'{f:.0f}' for f in _fl3]} % -> decide() "
f"got soc_floor={_dec3.soc_floor:g} %, "
f"floor_limited={_dec3.floor_limited}")
check("⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster of "
"THREE the floor that reaches `decide()` is the deep cut-out wherever on "
"the roster that cut-out sits - index 0, index 1 and index 2 all answer "
"35 %, and all three rosters carry the SAME multiset of cut-outs. A call "
"site that handed `binding_soc_floor()` any PROPER SUBSET of the "
"positions would answer the option, 20 %, on the run whose deep pack sits "
"at the position it dropped. `self.packs[:2]` - the +4-byte mutation that "
"walked through the two-pack runs above, because at N=2 it is the whole "
"roster - goes red on the index-2 run",
[r[3].soc_floor for r in _g3_runs]
== [float(_G3_DEEP_SIM)] * 3
and all(len(r[2].packs) == 3 for r in _g3_runs)
and all(sorted(pk.bms_floor for pk in r[2].packs)
== sorted([A_PACK_FLOOR, A_PACK_FLOOR, _G3_DEEP_SIM])
for r in _g3_runs)
and [[pk.bms_floor for pk in r[2].packs] for r in _g3_runs]
== [r[1] for r in _g3_runs])
check("...and the VERDICT flips with it, on all three: 25 % is at or below the "
"35 % cut-out wherever it sits, so every one of these sites is "
"FLOOR-LIMITED and sizes no forced discharge at all - where the "
"all-shallow two-pack baseline above was permitted and sized a positive "
"one. A slice that dropped the deep position would spend, through "
"`sim/inject.py`, a pack its BMS stops",
all(r[3].floor_limited is True and r[3].floor_w == 0.0
and r[3].soc_floor_reason == FLOOR_AT_SOC_FLOOR for r in _g3_runs)
and _g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0)
check("...and the precondition that makes the three comparable (habit 1): every "
"pack in all three rosters is at the SAME state of charge, so neither the "
"`pack_floor_soc` MINIMUM nor a roster position can be what separated the "
"answers, and the deep cut-out really is above the EMS floor while the "
"shallow one really is below it - two cut-outs on the same side of it "
"would make every line above vacuous",
all([pk.soc for pk in r[2].packs] == [SOC_BETWEEN] * 3 for r in _g3_runs)
and len({r[2].pack_floor_soc for r in _g3_runs}) == 1
and A_PACK_FLOOR < SOC_FLOOR < _G3_DEEP_SIM)
# ⚠️ AND THE ENUMERATION ITSELF, COMPUTED RATHER THAN CLAIMED. The lesson this
# round is here to learn is that a fixture of N packs cannot separate reductions
# that agree on N packs, so "no slice survives" is a claim about a FAMILY and a
# SET OF RUNS and has to be graded as one. Below: every positional slice of the
# roster, applied to the REAL `Controller.packs` each cycle above actually ran,
# compared against the floor that REALLY reached `decide()` on that run. A slice
# with an empty kill list is a survivor and this line goes red naming it.
#
# ⚠️ WHAT THIS DOES NOT CLAIM. It grades the members listed and nothing wider -
# a rewrite that is not a positional slice (a different reduction, a filter on
# pack NAME, a sort before the slice) is not covered here, and `min`/`mean`/the
# rest of the reduction family are graded on the pure function in 12c2 instead.
# The table is PRINTED so a reader can see exactly how far the claim reaches.
_G_RUNS = ([("2-pack, no cut-out over the floor", _g_ctl_shallow, _g_shallow),
("2-pack, deep at index 1", _g_ctl_deep, _g_deep),
("2-pack, deep at index 0", _g_ctl_deepa, _g_deepa)]
+ [(rn, ctl, dec) for rn, _fl, ctl, dec in _g3_runs])
_SLICE_FAMILY = {
"self.packs[:1]": lambda ps: ps[:1],
"self.packs[-1:]": lambda ps: ps[-1:],
"self.packs[:2]": lambda ps: ps[:2],
"self.packs[-2:]": lambda ps: ps[-2:],
"self.packs[1:]": lambda ps: ps[1:],
"self.packs[:-1]": lambda ps: ps[:-1],
"self.packs[1:-1]": lambda ps: ps[1:-1],
"self.packs[::2]": lambda ps: ps[::2],
}
_slice_kills = {
_nm: [rn for rn, ctl, dec in _G_RUNS
if binding_soc_floor(_fn(ctl.packs), SOC_FLOOR) != dec.soc_floor]
for _nm, _fn in _SLICE_FAMILY.items()}
print(" THE POSITIONAL-SLICE FAMILY at this call site, and which run "
"separates each from the whole roster:")
for _nm, _k in _slice_kills.items():
print(f" {_nm:<18} killed by: "
+ ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***"))
check("⚠️ EVERY positional slice in the enumerated family answers something "
"OTHER than the floor that really reached `decide()` on at least one of "
"the six runs above - and the two three-pack runs at index 2 and index 0 "
"are what kill `[:2]` and `[-2:]`, which the two-pack runs structurally "
"could not. The kill list is computed against the REAL `Controller.packs` "
"each cycle ran and the REAL `PeakDecision.soc_floor` it produced, so a "
"member with an empty list is a live survivor and takes this line red "
"naming it. This grades the LISTED members only; the printed table is its "
"extent",
all(_slice_kills.values()))
check("...and the baseline the table is measured against is the shipped call "
"site's OWN answer, not a re-derivation: on every run, the floor that "
"reached `decide()` equals `binding_soc_floor()` over the WHOLE roster "
"that cycle carried. If it did not, every kill list above would be "
"measuring the wrong thing",
all(dec.soc_floor == binding_soc_floor(ctl.packs, SOC_FLOOR)
for _rn, ctl, dec in _G_RUNS)
and len(_G_RUNS) == 6
and sorted(len(ctl.packs) for _rn, ctl, dec in _G_RUNS) == [2, 2, 2, 3, 3, 3])
# ⚠️ AND HERE IS THE HONEST LIMIT OF EVERY FIXTURE ABOVE, FOUND BY THIS ROUND'S
# OWN BATTERY AND NOT ARGUED FROM: `self.packs[:3]` and `self.packs[-3:]` SURVIVE
# all six behavioural runs, +4 and +5 bytes, and they survive for the same reason
# `[:2]` survived the two-pack runs - THE LONGEST ROSTER HERE IS THREE, and a
# slice that keeps three consecutive positions IS the roster at N=3. Adding a
# four-pack run would close those two and hand `[:4]` and `[-4:]` the same free
# pass. THE REGRESS DOES NOT TERMINATE IN A FIXTURE. A bounded roster cannot
# grade an unbounded family of slices, and pretending otherwise by adding one
# more pack is precisely the move that has been rejected twice on this card.
#
# So the two halves are separated and each says what it reaches:
#
# the RUNS above grade the VALUE - that the floor reaching `decide()` really
# is the roster's deepest cut-out, wherever on the roster it sits, and that
# the site's verdict follows it. They reach every positional slice up to N=3
# and, by construction, nothing beyond.
#
# the PIN below grades the ARGUMENT'S IDENTITY - a claim genuinely about the
# code's shape, so it is graded on the code's shape, exactly, at ANY N. It is
# NOT the substring test this part replaced: `"self.packs" in _floor_call` is
# true of every slice, whereas `ast.unparse(arg) == "self.packs"` is false of
# all of them. `self.packs[:3]` unparses to `self.packs[:3]`.
#
# ⚠️ AND THE PIN IS BLIND TO TWO THINGS THE RUNS ARE NOT, which is why both are
# here. It cannot see a change to what `Controller.packs` RETURNS - a property
# rewritten as `self.inverters[:2]` leaves this expression identical, and it is
# the three-pack runs' `len(ctl.packs) == 3` that goes red on it. And it cannot
# see the reduction INSIDE `binding_soc_floor()` at all; that is 12c2's.
_floor_node = first(k.value for k in _call.keywords if k.arg == "soc_floor")
_floor_roster_arg = (_floor_node.args[0]
if isinstance(_floor_node, ast.Call) and _floor_node.args
else None)
print(f" the ROSTER argument main.py reduces is "
f"`{_src(_floor_roster_arg)}`, an AST "
f"{type(_floor_roster_arg).__name__}")
check("⚠️ THE STRUCTURAL HALF, AND IT IS EXACT RATHER THAN A SUBSTRING. The "
"roster handed to `binding_soc_floor()` is the WHOLE property "
"`self.packs` - an attribute of self, not a Subscript, not a call, not a "
"comprehension - so EVERY slice of it is red here at every roster length, "
"including `self.packs[:3]` and `self.packs[-3:]`, which survive all six "
"runs above because the longest roster they drive is three packs long. "
"This grades the code's SHAPE and nothing about the values; what the "
"expression MEANS is graded by the runs, and which option key it reads is "
"pinned in part 4",
isinstance(_floor_node, ast.Call)
and ast.unparse(_floor_node.func) == "peakguard.binding_soc_floor"
and len(_floor_node.args) == 2 and not _floor_node.keywords
and isinstance(_floor_roster_arg, ast.Attribute)
and isinstance(_floor_roster_arg.value, ast.Name)
and _floor_roster_arg.value.id == "self"
and _floor_roster_arg.attr == "packs"
and ast.unparse(_floor_roster_arg) == "self.packs")
check("...and the counterfactual that shows this pin is not vacuous (habit 7): "
"the very slices it exists for really do unparse to something else, so "
"the equality above is doing work rather than being trivially true of "
"anything shaped like an argument",
all(ast.unparse(ast.parse(f"self.packs{sl}", mode="eval").body)
!= "self.packs"
for sl in ("[:1]", "[-1:]", "[:2]", "[-2:]", "[:3]", "[-3:]",
"[1:]", "[:-1]", "[1:-1]", "[::2]"))
and ast.unparse(ast.parse("list(reversed(self.packs))",
mode="eval").body) != "self.packs")
# ...and the OTHER half of the conjunction still reaches the same argument, so
# the reduction did not replace the source: move `peak_soc_floor` itself, with
# both cut-outs held under it, and the floor follows the OPTION.
_G_OPT_ODD = SOC_FLOOR + 22.0
_, _g_opt = _floor_reaching_decide("opt", A_PACK_FLOOR, B_PACK_FLOOR_SIM,
peak_floor=_G_OPT_ODD)
print(f" peak_soc_floor moved to {_G_OPT_ODD:.0f} % with both cut-outs "
f"under it -> decide() got soc_floor={_g_opt.soc_floor:g} %")
check("...and with both cut-outs held below it, moving `peak_soc_floor` moves "
"the floor that reaches `decide()` too - so this card moved the "
"REDUCTION and not the SOURCE. WHICH key that is, is a claim about the "
"code's shape and stays pinned on the source in part 4; that the option "
"still governs is a claim about behaviour and is graded here",
_g_opt.soc_floor == float(_G_OPT_ODD)
and _G_OPT_ODD > B_PACK_FLOOR_SIM and _G_OPT_ODD > A_PACK_FLOOR
and _g_opt.soc_floor != _g_shallow.soc_floor)
# --- 12h. the operator-facing half, driven through the shipped method -------
# ⚠️ RUN, NOT GREPPED. CFG-04's review found a substring test over main.py that
# the defeating mutation walked straight through, so this drives the shipped
# `check_peak_floor_limited()` on a real two-pack Controller and reads what the
# operator's notification actually says.
async def _notify32(packs_socs, floors):
ctl, hs = built(TMP / f"s32n{len(packs_socs)}{floors[-1]:.0f}.db")
inv_b, _ = second_inverter(ctl)
for inv, so, fl in zip(ctl.inverters, packs_socs, floors):
inv.soc, inv.bms_soc_floor = so, fl
ctl.peak_guard = PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP,
projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0,
floor_limited=True, soc=min(packs_socs),
soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR),
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x")
ctl._peak_floor_limited = False
await ctl.check_peak_floor_limited()
return hs.last_message("goodwe_peak_soc_floor")
_body32 = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_notify32([SOC_FLOOR + 30.0, SOC_FLOOR - 1.0], [A_PACK_FLOOR, A_PACK_FLOOR]))
print(f" two-pack notification tail: ...{_body32[-200:]}")
check("the notification an operator reads at 19:00 NAMES THE PACK that stopped "
"the site, and names only that one. 'the battery is at its floor' on a "
"site with two batteries is an instruction to go and look at both, and "
"the wrong one is the one that is fine",
"The battery of inverter-b is at its peak reserve" in _body32
and "inverter-a" not in _body32)
# The mirror again, on the SoC axis: swap which pack is empty and the name
# swaps. A hardwired `self.inverters[1].name` passes the line above.
_body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_notify32([SOC_FLOOR - 1.0, SOC_FLOOR + 30.0], [A_PACK_FLOOR, A_PACK_FLOOR]))
check("...and with the SAME two readings swapped between the packs it names "
"inverter-a instead - so the sentence follows the measurement rather "
"than a position in the roster",
"The battery of inverter-a is at its peak reserve" in _body32b
and "inverter-b" not in _body32b)
# ⚠️ AC 5, THE OTHER HALF: THE SAME METHOD ON A ROSTER OF ONE MUST PRINT WHAT IT
# ALWAYS PRINTED. This card adds a sentence to an alarm every existing install
# already receives, so "byte-identical at N=1" is asserted against the shipped
# text rather than assumed - and against the text of the very body the two-pack
# run above produced, minus its added sentence.
async def _notify32_one():
ctl, hs = built(TMP / "s32n1.db")
ctl.inverters[0].soc = SOC_FLOOR - 1.0
ctl.peak_guard = PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP,
projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0,
floor_limited=True, soc=SOC_FLOOR - 1.0,
soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR),
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x")
ctl._peak_floor_limited = False
await ctl.check_peak_floor_limited()
return hs.last_message("goodwe_peak_soc_floor")
_body32one = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_notify32_one())
check("⚠️ AC 5. At a roster of ONE the same notification names NO pack - "
"byte for byte the two-pack message minus the name (UX-08). There is "
"one battery, naming it is noise, and this alarm reaches every existing "
"install",
"inverter-" not in _body32one
and _body32one == _body32.replace("The battery of inverter-b is",
"The battery is")
and _says_scope(_body32one))
# --- 12i. THE SIBLING ARGUMENT: `pack_floor_soc`, THE OTHER HALF OF THE PAIR --
# ⚠️ THIS IS ROUND 4's BLOCKER, AND IT IS THE SAME FAMILY ONE ARGUMENT OVER.
# Everything from 12c2 to 12h grades reductions on the FLOOR axis: the max
# inside `binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
# `pack_floor_pct()`'s conjunction. `Controller.pack_floor_soc` is the MINIMUM
# over the same roster, handed to the SAME `peakguard.decide()` call as `soc=`,
# and until this block nothing graded its reduction past N=2.
#
# ⚠️ THE PAIRING IS DOCUMENTED, WHICH IS WHY HARDENING ONE HALF IS NOT ENOUGH.
# main.py's call site says "MAXIMUM here pairs with the MINIMUM in
# `pack_floor_soc`: both reductions move the verdict towards refusing a shave
# and away from spending a pack". A conjunction whose two halves have different
# amounts of evidence behind them is exactly as strong as its weaker half.
#
# THE SURVIVORS THIS BLOCK EXISTS FOR, all confirmed GREEN across all 19 files
# at 52380c0 and again on the rebase onto 3934ad7:
#
# min(float(v) for v in vals) -> ... for v in vals[:2] +4 bytes
# -> ... for v in vals[-2:] +5 bytes
# -> ... for v in vals[:3] +4 bytes
#
# while `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)` and the
# mean were all killed. The suite's separating power on the SoC axis stopped at
# exactly N=2 - which is the state the FLOOR axis was in before round 3, and
# which round 3 named as its own blocker 2. The evidence is a fixture of two
# packs, and A FIXTURE OF N PACKS CANNOT SEPARATE REDUCTIONS THAT AGREE ON N.
#
# IT CHANGES A REAL ANSWER, which is why it is a defect and not a tidiness
# point: a three-pack roster at 50 / 60 / 11 % with the emptiest pack THIRD.
# The shipped property answers 11 %; `vals[:2]` answers 50 %. The site is then
# graded for discharge PERMISSION against a pack 39 points fuller than the
# emptiest one, and spends a pack its own BMS stops - through `sim/inject.py`,
# which runs no validator rule at all.
#
# ⚠️ AND PRODUCTION IS DEGENERATE ON THE FLOOR AXIS, WHICH MAKES THIS AXIS THE
# LIVE ONE. SPIKE-04 reads pack B as 4 x Dyness DL5.0C at DoD 90 [FROM THE
# DATASHEET AND THE SERIAL - inverter B does not exist and nothing about it has
# been measured], so its cut-out is 10 %, the same as pack A's MEASURED 10 %.
# Two packs that floor at the same value make `binding_soc_floor()` answer the
# option whatever it reduces. The SoC axis has no such degeneracy: two batteries
# at the same state of charge is a coincidence, not a configuration.
#
# THE TREATMENT IS ROUND 3's, TRANSFERRED: a roster SET with the extreme at
# every position, a LENGTH SWEEP, and an EXACT STRUCTURAL PIN on the reduction's
# argument. Nothing below is a measurement of a second or third pack.
_S_EMPTY_SIM, _S_MID_SIM, _S_FULL_SIM = 11.0, 50.0, 60.0
_S_TRIO_SIM = (_S_FULL_SIM, _S_MID_SIM, _S_EMPTY_SIM)
def _soc_reaching_decide(tag, *socs):
"""Drive ONE REAL `Controller.cycle()` on an N-pack roster and report the
SoC `peakguard.decide()` was actually handed, read back off the decision
main.py itself filed (`PeakDecision.soc` is the argument, echoed).
⚠️ THE EXACT MIRROR OF `_floor_reaching_decide` ABOVE, on the other axis.
Not a spy and not a grep: the shipped `cycle()` runs, the shipped
`pack_floor_soc` runs, and the observable is a field of the decision the
controller stored - so the whole path from `Controller.inverters` to the
guard's argument is under test. Every pack carries the SAME cut-out
(inverter A's measured 10 %, which is below `peak_soc_floor`, so
`binding_soc_floor()` answers the option for every roster here); the only
thing that varies between calls is the packs' states of charge. That is
habit 1: with the FLOOR axis held flat, nothing but the SoC reduction can
move these answers.
"""
def _prep(ctl):
for _i in range(len(socs) - 1):
_sfx = chr(ord("b") + _i)
second_inverter(ctl, name=f"inverter-{_sfx}",
setpoint=f"number.sp_{_sfx}")
for inv, so in zip(ctl.inverters, socs):
inv.soc, inv.batt, inv.bms_soc_floor = so, 0.0, A_PACK_FLOOR
ctl, _hs, _net, _rem = wired_over_limit(
TMP / f"s32s_{tag}.db", LIVE_MAX_W * 0.3, prepare=_prep)
return ctl, ctl.peak_guard
# BASELINE: three packs, all of them ABOVE `peak_soc_floor`, so the guard is
# permitted and sizes a real forced discharge. Every run below varies one thing
# from this baseline (habit 7) - which pack is the empty one, and nothing else.
_s_ctl_ok, _s_ok = _soc_reaching_decide("ok3", _S_FULL_SIM, _S_MID_SIM,
SOC_BETWEEN)
# THE SET: the SAME THREE readings, the emptiest at EACH position in turn. A
# reduction that reads a proper subset of the positions misses the empty pack on
# the run where it sits at the position that subset drops, and answers a fuller
# pack instead.
_s3_runs = []
for _pos in range(3):
_sc3 = [_S_FULL_SIM, _S_MID_SIM, _S_EMPTY_SIM]
_sc3.remove(_S_EMPTY_SIM)
_sc3.insert(_pos, _S_EMPTY_SIM)
_c3s, _d3s = _soc_reaching_decide(f"s3p{_pos}", *_sc3)
_s3_runs.append((f"3-pack, emptiest at index {_pos}", _sc3, _c3s, _d3s))
# ...and the two-pack mirror, which is the shape production reaches first.
_s2_runs = []
for _pos in range(2):
_sc2 = [_S_FULL_SIM, _S_EMPTY_SIM] if _pos else [_S_EMPTY_SIM, _S_FULL_SIM]
_c2s, _d2s = _soc_reaching_decide(f"s2p{_pos}", *_sc2)
_s2_runs.append((f"2-pack, emptiest at index {_pos}", _sc2, _c2s, _d2s))
print(f" REAL cycles, every cut-out held at inverter-a's measured "
f"{A_PACK_FLOOR:.0f} %, peak_soc_floor {SOC_FLOOR:.0f} % (every pack past "
f"the first is SIMULATED):")
print(f" 3-pack baseline, none empty: SoCs "
f"{[f'{s:g}' for s in (_S_FULL_SIM, _S_MID_SIM, SOC_BETWEEN)]} % -> "
f"decide() got soc={_s_ok.soc:g} %, floor_limited={_s_ok.floor_limited}")
for _rn, _sc, _ctl_s, _dec_s in _s3_runs + _s2_runs:
print(f" {_rn}: SoCs {[f'{s:g}' for s in _sc]} % -> decide() got "
f"soc={_dec_s.soc:g} %, floor_limited={_dec_s.floor_limited}")
check("⚠️ THE POSITIONAL PIN ON THE SoC AXIS, AND IT NEEDS THREE PACKS TO "
"EXIST. On a roster of THREE the SoC that reaches `decide()` is the "
"EMPTIEST pack wherever on the roster that pack sits - index 0, index 1 "
"and index 2 all answer 11 %, and all three rosters carry the SAME "
"multiset of readings. A property that reduced any PROPER SUBSET of the "
"positions would answer a fuller pack on the run whose empty one sits at "
"the position it dropped. `vals[:2]` and `vals[-2:]` - the +4- and "
"+5-byte mutations that walked through every fixture in this file up to "
"round 3, because at N=2 they ARE the roster - go red here",
[r[3].soc for r in _s3_runs] == [_S_EMPTY_SIM] * 3
and all(len(r[2].inverters) == 3 for r in _s3_runs)
and all(sorted(inv.soc for inv in r[2].inverters)
== sorted(_S_TRIO_SIM) for r in _s3_runs)
and [[inv.soc for inv in r[2].inverters] for r in _s3_runs]
== [r[1] for r in _s3_runs])
check("...and the TWO-pack mirror, which is what production reaches the moment "
"inverter B is installed: swap the same two readings between the packs "
"and the answer is the emptier one both times. `vals[:1]` and `vals[-1:]` "
"- grade the site against pack A alone, or against the last pack alone - "
"each agree with the roster on one of these two runs and disagree on the "
"other, so neither survives the pair",
[r[3].soc for r in _s2_runs] == [_S_EMPTY_SIM] * 2
and [[inv.soc for inv in r[2].inverters] for r in _s2_runs]
== [[_S_EMPTY_SIM, _S_FULL_SIM], [_S_FULL_SIM, _S_EMPTY_SIM]])
check("⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT, which is what makes this a "
"defect rather than a reporting difference. With the emptiest pack at "
"11 % - below the 20 % EMS floor - every one of those five sites is "
"FLOOR-LIMITED and sizes NO forced discharge at all, where the baseline "
"with its emptiest pack at 25 % is permitted and sizes a positive one. A "
"reduction that dropped the empty pack's position would grade this site "
"for PERMISSION against a pack 39 points fuller and spend, through "
"`sim/inject.py`, a pack its own BMS stops",
all(r[3].floor_limited is True and r[3].floor_w == 0.0
for r in _s3_runs + _s2_runs)
and _s_ok.floor_limited is False and _s_ok.floor_w > 0.0
and _s_ok.soc == SOC_BETWEEN)
check("...and the precondition that makes the six runs comparable (habit 1): "
"every pack in every roster carries the SAME cut-out, so "
"`binding_soc_floor()` answers the SAME site floor on all of them and the "
"FLOOR axis cannot be what separated the answers; the three readings "
"really are three different numbers; and the empty one is really below "
"the EMS floor while the other two are really above it - two readings on "
"the same side of it would make every line here vacuous",
len({r[3].soc_floor for r in _s3_runs + _s2_runs}) == 1
and _s_ok.soc_floor == _s3_runs[0][3].soc_floor == float(SOC_FLOOR)
and all(inv.bms_soc_floor == A_PACK_FLOOR
for r in _s3_runs + _s2_runs for inv in r[2].inverters)
and len({_S_EMPTY_SIM, _S_MID_SIM, _S_FULL_SIM}) == 3
and _S_EMPTY_SIM < SOC_FLOOR < SOC_BETWEEN < _S_MID_SIM < _S_FULL_SIM)
# ⚠️ AND THE ENUMERATION ITSELF, COMPUTED RATHER THAN CLAIMED - 12g's table on
# the other axis. Every positional slice of `vals`, applied to the REAL
# `Controller.inverters` each cycle above actually carried, compared against the
# SoC that REALLY reached `decide()` on that run. A slice with an empty kill
# list is a live survivor and this line goes red naming it.
#
# ⚠️ WHAT THIS DOES NOT CLAIM. It grades the members listed and nothing wider -
# a rewrite that is not a positional slice (a different reduction, a filter on
# pack NAME, a sort before the slice) is not covered here. `max` and the mean
# are graded on the same runs immediately below. The table is PRINTED so a
# reader can see exactly how far the claim reaches.
_S_RUNS = ([("3-pack baseline, none empty", _s_ctl_ok, _s_ok)]
+ [(rn, ctl, dec) for rn, _sc, ctl, dec in _s3_runs + _s2_runs])
def _soc_reduced(vals, fn):
"""`pack_floor_soc`'s body with ONE thing changed: what `min()` iterates.
The None guard still runs over the WHOLE list, because these mutations are
inside the `min()` generator and not in the guard - the guard is graded
separately below. An empty slice makes the real `min()` raise, which is a
crashing cycle and therefore a kill; it is reported as a distinct answer
rather than swallowed.
"""
if not vals or any(v is None for v in vals):
return None
sl = fn(vals)
return float(min(float(v) for v in sl)) if sl else "min() of an empty slice"
_SOC_SLICE_FAMILY = {
"vals[:1]": lambda vs: vs[:1],
"vals[-1:]": lambda vs: vs[-1:],
"vals[:2]": lambda vs: vs[:2],
"vals[-2:]": lambda vs: vs[-2:],
"vals[1:]": lambda vs: vs[1:],
"vals[:-1]": lambda vs: vs[:-1],
"vals[1:-1]": lambda vs: vs[1:-1],
"vals[::2]": lambda vs: vs[::2],
}
_SOC_REDUCTION_FAMILY = {
"max(vals)": lambda vs: max(vs),
"mean(vals)": lambda vs: sum(vs) / len(vs),
"median(vals), upper": lambda vs: sorted(vs)[len(vs) // 2],
"sorted(vals)[1] (second emptiest)": lambda vs: sorted(vs)[min(1, len(vs) - 1)],
}
_soc_slice_kills = {
_nm: [rn for rn, ctl, dec in _S_RUNS
if _soc_reduced([inv.soc for inv in ctl.inverters], _fn) != dec.soc]
for _nm, _fn in _SOC_SLICE_FAMILY.items()}
_soc_red_kills = {
_nm: [rn for rn, ctl, dec in _S_RUNS
if _fn([inv.soc for inv in ctl.inverters]) != dec.soc]
for _nm, _fn in _SOC_REDUCTION_FAMILY.items()}
print(" THE POSITIONAL-SLICE FAMILY inside `pack_floor_soc`, and which run "
"separates each from the whole roster:")
for _nm, _k in _soc_slice_kills.items():
print(f" {_nm:<12} killed by: "
+ ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***"))
print(" ...and the NON-slice rewrites of the reduction itself:")
for _nm, _k in _soc_red_kills.items():
print(f" {_nm:<34} killed by: "
+ ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***"))
check("⚠️ EVERY positional slice in the enumerated family answers something "
"OTHER than the SoC that really reached `decide()` on at least one of "
"the six runs above - and it is the THREE-pack runs that kill `[:2]` and "
"`[-2:]`, which the two-pack runs structurally could not, by exactly the "
"argument 12c2 makes about N packs and reductions that agree on N. The "
"kill list is computed against the REAL `Controller.inverters` each cycle "
"carried and the REAL `PeakDecision.soc` it produced, so a member with an "
"empty list is a live survivor and takes this line red naming it. This "
"grades the LISTED members only; the printed table is its extent",
all(_soc_slice_kills.values()))
check("...and the same for the rewrites that are not slices at all: `max` - "
"the FULLEST pack, which is `binding_soc_floor()`'s reduction pasted onto "
"the wrong axis and the single most likely wrong edit here - the mean, "
"the median and the second-emptiest each disagree with the shipped answer "
"on at least one run. `max(vals)` on the index-2 run answers 60 % where "
"the site is graded at 11 %",
all(_soc_red_kills.values()))
check("...and the baseline the tables are measured against is the shipped "
"property's OWN answer, not a re-derivation: on every run, the SoC that "
"reached `decide()` equals `Controller.pack_floor_soc` on the roster that "
"cycle carried. If it did not, every kill list above would be measuring "
"the wrong thing",
all(dec.soc == ctl.pack_floor_soc for _rn, ctl, dec in _S_RUNS)
and len(_S_RUNS) == 6
and sorted(len(ctl.inverters) for _rn, ctl, dec in _S_RUNS)
== [2, 2, 3, 3, 3, 3])
# ⚠️ AND THE HONEST LIMIT OF EVERY RUN ABOVE, FOUND BY THIS ROUND'S OWN BATTERY
# AND NOT ARGUED FROM: `vals[:3]` and `vals[-3:]` SURVIVE all six behavioural
# runs, for the same reason `[:2]` survived the two-pack ones - THE LONGEST
# ROSTER HERE IS THREE, and a slice that keeps three consecutive positions IS
# the roster at N=3. Adding a four-pack cycle would close those two and hand
# `[:4]` and `[-4:]` the same free pass. THE REGRESS DOES NOT TERMINATE IN A
# FIXTURE - round 3's lesson, arriving unchanged on this axis.
#
# So the same two halves, each saying what it reaches. First the SWEEP, which is
# cheap here because `pack_floor_soc` is a PROPERTY and needs no cycle to
# evaluate: rosters of 1 to 12 real `Inverter`s, the empty pack at EVERY
# position of each, and the property must answer the emptiest every time. That
# kills every fixed-width head or tail window narrower than the longest roster
# at once, rather than one mutation at a time.
_S_SWEEP_N = 12
def _roster_of(socs):
"""A real Controller carrying one real `Inverter` per reading given."""
ctl, _hs = built(TMP / f"s32sw{len(socs)}.db")
for _i in range(len(socs) - 1):
second_inverter(ctl, name=f"sweep-{_i}", setpoint=f"number.sw_{_i}")
for inv, so in zip(ctl.inverters, socs):
inv.soc, inv.batt, inv.bms_soc_floor = so, 0.0, A_PACK_FLOOR
return ctl
_s_sweep_rows = []
for _n in range(1, _S_SWEEP_N + 1):
for _pos in range(_n):
_sc = [_S_FULL_SIM] * _n
_sc[_pos] = _S_EMPTY_SIM
_s_sweep_rows.append((_n, _pos, tuple(_sc),
_roster_of(_sc).pack_floor_soc))
_s_sweep_bad = [(n, p, sc, got) for n, p, sc, got in _s_sweep_rows
if got != min(sc)]
_s_head_kill = {w: any(min(sc[:w]) != min(sc) for _n, _p, sc, _g in _s_sweep_rows)
for w in range(1, _S_SWEEP_N + 1)}
_s_tail_kill = {w: any(min(sc[-w:]) != min(sc) for _n, _p, sc, _g in _s_sweep_rows)
for w in range(1, _S_SWEEP_N + 1)}
print(f" LENGTH SWEEP on the real property (all SIMULATED past pack A): "
f"{len(_s_sweep_rows)} rosters, lengths 1..{_S_SWEEP_N}, the "
f"{_S_EMPTY_SIM:g} % pack at EVERY position of each among "
f"{_S_FULL_SIM:g} % siblings -> "
f"{len(_s_sweep_rows) - len(_s_sweep_bad)} answer the emptiest, "
f"{len(_s_sweep_bad)} do not")
print(" fixed-width windows killed by the sweep: head widths "
+ ",".join(str(w) for w in sorted(_s_head_kill) if _s_head_kill[w])
+ "; tail widths "
+ ",".join(str(w) for w in sorted(_s_tail_kill) if _s_tail_kill[w])
+ f"; NOT reached: head/tail width {_S_SWEEP_N} and wider")
check("⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`. Over rosters "
"of 1 to 12 real inverters with the empty pack at every position of each, "
"the property answers the EMPTIEST every time - so every head window "
"`min(vals[:w])` and every tail window `min(vals[-w:])` of width 1 to 11 "
"disagrees with it on at least one roster here, and that is the whole "
"positional family below width 12 closed in one property rather than one "
"mutation at a time. ⚠️ THIS IS THE REAL PROPERTY ON A REAL ROSTER, not a "
"re-implementation: `_roster_of()` appends real `Inverter` objects and "
"reads `ctl.pack_floor_soc`, so it also sees a rewrite of the "
"comprehension that builds `vals`",
not _s_sweep_bad
and len(_s_sweep_rows) == _S_SWEEP_N * (_S_SWEEP_N + 1) // 2
and all(_s_head_kill[w] and _s_tail_kill[w]
for w in range(1, _S_SWEEP_N)))
check("⚠️ ...AND WIDTH 12 AND WIDER IS HONESTLY NOT REACHED, recorded rather "
"than papered over - the same ceiling 12c2's sweep records on the floor "
"axis. No roster in this file is longer than 12, so `min(vals[:12])` IS "
"`min(vals)` on every one of them and no check here can tell them apart. "
"A 13-pack roster would close width 12 and hand width 13 the same free "
"pass. That is why the STRUCTURAL PIN below exists alongside these runs, "
"and this line asserts the unreachedness itself so a later sweep that "
"lengthens the rosters takes it red and forces the sentence rewritten",
not _s_head_kill[_S_SWEEP_N] and not _s_tail_kill[_S_SWEEP_N]
and max(len(sc) for _n, _p, sc, _g in _s_sweep_rows) == _S_SWEEP_N)
# ...and the NONE GUARD is a reduction over roster length TOO, and it had
# exactly the same fixture problem: 12d sets the SECOND of two packs to None, so
# `any(v is None for v in vals[:1])` - grade only pack A for silence - answers
# "all present" there and hands a list containing None to `min()`. The sweep
# below moves the silent pack along the roster at every length, which is the
# data that reaches it. THE DIRECTION MATTERS: None must WIN, because a silent
# pack is a pack nothing can grade and `decide()` treats None as floor-limited.
def _pfs_answer(ctl):
"""`pack_floor_soc`, with a raise reported as an ANSWER rather than
ending the file. A guard narrowed to a slice does not return a wrong
number - it lets a None reach `min(float(v) ...)` and raises TypeError -
and a check that dies is a check that names nothing."""
try:
return ctl.pack_floor_soc
except Exception as exc: # noqa: BLE001 - reported
return f"raised {type(exc).__name__}"
_s_none_rows = []
for _n in range(1, _S_SWEEP_N + 1):
for _pos in range(_n):
_ctl_n = _roster_of([_S_FULL_SIM] * _n)
_ctl_n.inverters[_pos].soc = None
_s_none_rows.append((_n, _pos, _pfs_answer(_ctl_n)))
print(f" NONE-GUARD SWEEP: {len(_s_none_rows)} rosters, ONE silent pack at "
f"every position of every length 1..{_S_SWEEP_N} -> "
f"{sum(1 for _n, _p, g in _s_none_rows if g is None)} answer None")
check("⚠️ THE NONE GUARD IS THE SAME FAMILY. One silent pack at ANY position of "
"ANY roster length makes `pack_floor_soc` None - never a number, and "
"never the minimum of the packs that did answer. A guard narrowed to a "
"positional slice would let the site spend the packs that answered while "
"one sat unmeasured; 12d's two-pack fixture, with the SECOND pack silent, "
"cannot see a guard narrowed to the first",
all(g is None for _n, _p, g in _s_none_rows)
and len(_s_none_rows) == _S_SWEEP_N * (_S_SWEEP_N + 1) // 2)
# ⚠️ THE STRUCTURAL HALF: the reduction's ARGUMENT, pinned EXACTLY, at any N.
# This is 12g's treatment applied inside a property rather than at a call site,
# and it is NOT a substring test - `"vals" in src` is true of every slice of
# `vals`, whereas `ast.unparse(iter) == "vals"` is false of all of them.
# `vals[:3]` unparses to `vals[:3]`.
#
# THREE expressions are pinned, because there are three reductions over roster
# length in this one property and the runs above reach them unevenly:
# the LIST `[inv.soc for inv in self.inverters]` - the roster itself
# the GUARD `any(v is None for v in vals)`
# the REDUCTION `min(float(v) for v in vals)`
#
# ⚠️ AND THE PIN IS BLIND TO WHAT THE RUNS SEE, which is why both halves are
# here. It cannot see `min` become `max` (identical shape, different name -
# killed by the roster set), it cannot see `inv.soc` become `inv.bms_soc_floor`
# (killed by the runs' held-flat cut-outs), and it cannot see a change inside
# `Inverter.soc` at all.
_pfs_fn = first((n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
and n.name == "pack_floor_soc"), "def pack_floor_soc in main.py")
_pfs_vals = first((n.value for n in _pfs_fn.body
if isinstance(n, ast.Assign)
and [ast.unparse(t) for t in n.targets] == ["vals"]),
"`vals = ...` in pack_floor_soc")
def _gen_iter_of(fn_node, name):
"""The iterable of the comprehension inside the single `name(...)` call."""
call = first((n for n in ast.walk(fn_node)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == name), f"{name}() call")
comp = nth(call.args, 0)
gens = getattr(comp, "generators", [])
return gens[0].iter if len(gens) == 1 else None
_pfs_min_iter = _gen_iter_of(_pfs_fn, "min")
_pfs_vals_iter = nth(getattr(_pfs_vals, "generators", []), 0).iter
_pfs_any_iter = _gen_iter_of(_pfs_fn, "any")
print(f" pack_floor_soc reduces: list over `{_src(_pfs_vals_iter)}`"
f", any() over `{_src(_pfs_any_iter)}`, min() over "
f"`{_src(_pfs_min_iter)}`")
check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduces "
"the WHOLE list `vals` - a bare Name, not a Subscript, not a call, not a "
"second comprehension - and `vals` is built by ONE comprehension over the "
"WHOLE property `self.inverters`, an attribute of self and not a slice of "
"one. So EVERY slice of either is red here at EVERY roster length, "
"including `vals[:3]` and `vals[-3:]`, which survive all six runs above "
"because the longest roster they drive is three packs long, and including "
"`self.inverters[:2]` in the comprehension, which the sweep sees only up "
"to length 12. This grades the code's SHAPE and nothing about the values; "
"what the expression MEANS is graded by the runs",
isinstance(_pfs_min_iter, ast.Name) and _pfs_min_iter.id == "vals"
and _src(_pfs_min_iter) == "vals"
and isinstance(_pfs_vals, ast.ListComp)
and len(_pfs_vals.generators) == 1
and not _pfs_vals.generators[0].ifs
and isinstance(_pfs_vals.generators[0].iter, ast.Attribute)
and isinstance(_pfs_vals.generators[0].iter.value, ast.Name)
and _pfs_vals.generators[0].iter.value.id == "self"
and _pfs_vals.generators[0].iter.attr == "inverters"
and _src(_pfs_vals_iter) == "self.inverters")
check("...and the NONE GUARD reduces that same whole list, pinned the same way. "
"`any(v is None for v in vals[:1])` is +4 bytes and leaves every other "
"line in this file identical; the sweep above reaches it up to length 12 "
"and this reaches it at every length",
isinstance(_pfs_any_iter, ast.Name) and _pfs_any_iter.id == "vals"
and _src(_pfs_any_iter) == "vals")
check("...and the counterfactual that shows these pins are not vacuous (habit "
"7): the very slices they exist for really do unparse to something else, "
"so the equalities above are doing work rather than being trivially true "
"of anything shaped like an argument",
all(ast.unparse(ast.parse(f"vals{sl}", mode="eval").body) != "vals"
and ast.unparse(ast.parse(f"self.inverters{sl}",
mode="eval").body) != "self.inverters"
for sl in ("[:1]", "[-1:]", "[:2]", "[-2:]", "[:3]", "[-3:]",
"[:12]", "[-12:]", "[1:]", "[:-1]", "[1:-1]", "[::2]"))
and ast.unparse(ast.parse("list(reversed(vals))",
mode="eval").body) != "vals")
# ...and BOTH consumers of this property are pinned to it by name, so a reduction
# hardened here cannot be bypassed by a call site that stops using it. The peak
# guard's `soc=` is already pinned in part 4; the OTHER one is arbitrage's, and
# it is a safety consumer too - `soc_floor_pct` is what stops a sell.
_arb_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 == "arbitrage"]
_arb_soc = _src(first(k.value for k in nth(_arb_calls, 0).keywords
if k.arg == "soc"))
print(f" main.py's arbitrage.decide(soc=...) is `{_arb_soc}` "
f"({len(_arb_calls)} call site)")
check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()` "
"is handed the SAME `self.pack_floor_soc` and grades it against "
"`soc_floor_pct` to decide whether to SELL - so the mutation this block "
"exists for spends a pack on the arbitrage path as well as the peak-guard "
"path, and hardening the property covers both. A call site that reverted "
"to a per-unit reading, or defaulted it, would take this line red",
len(_arb_calls) == 1 and _arb_soc == "self.pack_floor_soc")
# --- 12j. THE ENUMERATION ITSELF, AS A RUNNING CHECK, ACROSS THE PACKAGE ------
# ⚠️ THIS CARD HAS NOW BEEN REJECTED FOUR TIMES FOR THE SAME SHAPE ONE STEP
# OVER: a reduction over roster length whose fixture could not separate it from
# a slice of the roster. Round 2 hardened `binding_soc_floor()`; round 3 found
# the call site's argument; round 4 found `pack_floor_soc`, the SIBLING ARGUMENT
# of the same `decide()` call, and wrote the first version of this block - which
# enumerated the family inside `Controller` AND STOPPED AT THE CLASS BOUNDARY.
# Round 5's blocker was four survivors in validate.py, one module over, where
# rules 5 and 6 reduce over `unit_caps_indexed()`. Closing them one at a time
# has cost five rounds. This block does not do that any more.
#
# THE INSTRUMENT IS NOW PACKAGE-WIDE AND THE MODULE LIST IS COMPUTED, NOT
# WRITTEN. Every `.py` under `app/` is read off disk and parsed; nothing here
# names a module, so a module added tomorrow is graded on the day it is added.
# ⚠️ A HARDCODED MODULE LIST WOULD ITSELF BE "A CHECK WHOSE DATA CANNOT REACH
# THE FAILURE IT NAMES" (MUTATION.md), which is the defect this file exists to
# complain about: the list would go on passing while the reduction it was
# supposed to cover lived in the module nobody added to it.
#
# WHAT COUNTS AS A ROSTER, DERIVED RATHER THAN DECLARED. The seed is ONE
# expression - reading the `inverters` option - and everything else is transitive
# closure over the AST:
# * a function all of whose returns are roster-carrying is roster-producing
# (`roster_entries` -> `build_roster` -> `unit_caps_indexed` -> `unit_caps`);
# * a name assigned a roster-carrying value carries it, and so does a list
# ACCUMULATED inside a loop over one (`caps`, `roster`, `problems`);
# * an attribute or property that answers one carries it (`self.inverters`,
# `Controller.packs`, `Controller.units`, `Controller.live_units()`);
# * an argument at a call site that is handed one taints THAT PARAMETER in the
# callee, which is how the pin crosses into `peakguard.binding_soc_floor`,
# `arbiter.resolve`, `allocator.plan`, `circulation.circulating_w`,
# `maintenance.arbitrate` and `store.migrate_maint_keys` without being told
# they exist;
# * and A SLICE OF A ROSTER IS STILL A ROSTER, so a mutation cannot cut the
# taint and hide the reductions downstream of itself.
# An INDEX of a roster is NOT a roster: `self.inverters[0]` is one unit, and
# treating it as a roster would spread this analysis over every unit attribute
# in the package.
#
# THE CLAIM, in one line: at every place in `app/` where a roster-carrying value
# is REDUCED or ITERATED, the whole of it arrives. A `[:1]`, a `[-1:]`, a `[:2]`
# or a `[0]` between the roster and the reduction is red on the line it is
# written, at every roster length, in every configuration, and with no fixture
# required.
_APP_SRC = {p.stem: p.read_text(encoding="utf-8")
for p in sorted((HERE / "app").glob("*.py"))}
_ROSTER_OPTION = "inverters" # the ONE seed. Everything else is closure.
_WRAPPERS = {"list", "tuple", "sorted", "enumerate", "zip", "reversed",
"filter", "iter", "set", "dict"}
_REDUCERS = {"min", "max", "sum", "any", "all", "len", "sorted", "next", "list",
"tuple", "set", "reversed", "enumerate", "zip", "map", "filter"}
def _roster_sites(sources):
"""Every roster-carrying expression in reduction/iteration position.
Returns `(rows, meta)`; each row is
`(module, lineno, col, end_lineno, end_col, qualname, kind, source, sliced)`.
"""
trees = {m: ast.parse(s) for m, s in sources.items()}
funcs, importmap = {}, {}
for m, tree in trees.items():
def walk(node, qual):
for ch in ast.iter_child_nodes(node):
if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef)):
funcs[(m, qual + ch.name)] = ch
walk(ch, qual + ch.name + ".")
elif isinstance(ch, ast.ClassDef):
walk(ch, qual + ch.name + ".")
else:
walk(ch, qual)
walk(tree, "")
for n in ast.walk(tree):
if isinstance(n, ast.ImportFrom):
if n.module:
base = n.module.split(".")[-1]
if base in trees:
for a in n.names:
importmap[(m, a.asname or a.name)] = (base, a.name)
else:
# `from . import peakguard` - a MODULE, not a name inside
# one. This branch is the only reason the pin can see
# peakguard.py, and it was missing from the first draft.
for a in n.names:
if a.name in trees:
importmap[(m, a.asname or a.name)] = (a.name, None)
elif isinstance(n, ast.Import):
for a in n.names:
base = a.name.split(".")[-1]
if base in trees:
importmap[(m, a.asname or base)] = (base, None)
t_funcs, t_params, t_attrs, t_locals = set(), set(), set(), {}
bodies = {}
def body_nodes(fn):
if id(fn) not in bodies:
out = []
def rec(n):
for ch in ast.iter_child_nodes(n):
if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef,
ast.Lambda)):
continue
out.append(ch)
rec(ch)
rec(fn)
bodies[id(fn)] = out
return bodies[id(fn)]
def callee(m, qual, node):
f = node.func
if isinstance(f, ast.Name):
if (m, f.id) in importmap:
om, oname = importmap[(m, f.id)]
if oname and (om, oname) in funcs:
return (om, oname)
if (m, f.id) in funcs:
return (m, f.id)
elif isinstance(f, ast.Attribute):
if isinstance(f.value, ast.Name):
if f.value.id == "self" and "." in qual:
cls = qual.rsplit(".", 1)[0]
if (m, cls + "." + f.attr) in funcs:
return (m, cls + "." + f.attr)
if (m, f.value.id) in importmap:
om, _ = importmap[(m, f.value.id)]
if (om, f.attr) in funcs:
return (om, f.attr)
return None
def derived(node, m, qual):
if node is None:
return False
if isinstance(node, ast.Subscript):
# A SLICE of a roster is still a roster - a defective one - so the
# taint survives the mutation and every reduction downstream of it
# reports too. An INDEX is one unit and is not a roster.
return (isinstance(node.slice, ast.Slice)
and derived(node.value, m, qual))
if isinstance(node, ast.Call):
f = node.func
if (isinstance(f, ast.Attribute) and f.attr in ("get", "pop")
and node.args and isinstance(node.args[0], ast.Constant)
and node.args[0].value == _ROSTER_OPTION):
return True # THE SEED
if isinstance(f, ast.Name) and f.id in _WRAPPERS:
return any(derived(a, m, qual) for a in node.args)
tgt = callee(m, qual, node)
return bool(tgt and tgt in t_funcs)
if isinstance(node, ast.Name):
return (node.id in t_locals.get((m, qual), ())
or (m, qual, node.id) in t_params)
if isinstance(node, ast.Attribute):
if isinstance(node.value, ast.Name) and node.value.id == "self":
if node.attr in t_attrs:
return True
cls = qual.rsplit(".", 1)[0] if "." in qual else None
return bool(cls and (m, cls + "." + node.attr) in t_funcs)
return False
if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp,
ast.GeneratorExp)):
return any(derived(g.iter, m, qual) for g in node.generators)
if isinstance(node, ast.IfExp):
return (derived(node.body, m, qual)
or derived(node.orelse, m, qual))
if isinstance(node, ast.BoolOp):
return any(derived(v, m, qual) for v in node.values)
if isinstance(node, (ast.List, ast.Tuple)):
return any(isinstance(e, ast.Starred) and derived(e.value, m, qual)
for e in node.elts)
return False
for _ in range(40): # transitive closure, to fixpoint
before = (len(t_funcs), len(t_params), len(t_attrs),
sum(len(v) for v in t_locals.values()))
for (m, qual), fn in funcs.items():
loc = t_locals.setdefault((m, qual), set())
for n in body_nodes(fn):
if (isinstance(n, (ast.Assign, ast.AnnAssign))
and getattr(n, "value", None) is not None
and derived(n.value, m, qual)):
for t in (n.targets if isinstance(n, ast.Assign)
else [n.target]):
if isinstance(t, ast.Name):
loc.add(t.id)
elif (isinstance(t, ast.Attribute)
and isinstance(t.value, ast.Name)
and t.value.id == "self"):
t_attrs.add(t.attr)
if (isinstance(n, (ast.For, ast.AsyncFor))
and derived(n.iter, m, qual)):
# A list ACCUMULATED one-per-unit is a roster too: `caps`,
# `roster`, `problems` are all built this way, and round 5's
# blocker was two reductions over one of them.
for c in ast.walk(n):
if (isinstance(c, ast.Call)
and isinstance(c.func, ast.Attribute)
and c.func.attr in ("append", "extend")
and isinstance(c.func.value, ast.Name)):
loc.add(c.func.value.id)
if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr == "extend"
and isinstance(n.func.value, ast.Name)
and n.args and derived(n.args[0], m, qual)):
loc.add(n.func.value.id)
if isinstance(n, ast.Return) and derived(n.value, m, qual):
t_funcs.add((m, qual))
if isinstance(n, ast.Call):
tgt = callee(m, qual, n)
if tgt:
cfn = funcs[tgt]
pos = [a.arg for a in
cfn.args.posonlyargs + cfn.args.args]
off = 1 if (pos and pos[0] == "self") else 0
for j, a in enumerate(n.args):
if j + off < len(pos) and derived(a, m, qual):
t_params.add((tgt[0], tgt[1], pos[j + off]))
for kw in n.keywords:
if kw.arg and derived(kw.value, m, qual):
t_params.add((tgt[0], tgt[1], kw.arg))
after = (len(t_funcs), len(t_params), len(t_attrs),
sum(len(v) for v in t_locals.values()))
if after == before:
break
def shortens(sub):
"""Does this subscript change HOW MANY members come out?
A SLICE does, and so does a CONSTANT index (N in, one out). A subscript
by a NAME does not: `weights[u]`, `cap[u]` and `add[u]` in
`allocator._fill` are keyed lookups into a roster-derived MAPPING, which
is what iterating it looks like. Flagging those would make this check cry
wolf on every dict the roster builds - it did, on four sites, before this
predicate was written.
"""
sl = sub.slice
if isinstance(sl, ast.Slice):
return True
if isinstance(sl, ast.Constant) and isinstance(sl.value, int):
return True
return (isinstance(sl, ast.UnaryOp) and isinstance(sl.op, ast.USub)
and isinstance(sl.operand, ast.Constant))
def sliced(node, m, qual):
return any(isinstance(n, ast.Subscript) and shortens(n)
and derived(n.value, m, qual) for n in ast.walk(node))
def leaves(node, m, qual):
"""The roster REFERENCES inside a reduced expression.
The place a slice goes is the roster reference, not the whole argument:
`sum(i.max_w for i in self.inverters)` is defeated by slicing
`self.inverters`, and slicing the generator would be a TypeError rather
than a wrong answer - a mutation that crashes proves nothing.
"""
out = []
def rec(n):
if (isinstance(n, (ast.Name, ast.Attribute, ast.Call,
ast.Subscript)) and derived(n, m, qual)):
out.append(n)
if isinstance(n, ast.Call):
for a in n.args:
rec(a)
return
for c in ast.iter_child_nodes(n):
rec(c)
rec(node)
return out
seen = {}
for (m, qual), fn in funcs.items():
def note(kind, expr):
bad = sliced(expr, m, qual)
for lf in leaves(expr, m, qual):
key = (m, lf.lineno, lf.col_offset, lf.end_lineno,
lf.end_col_offset)
seen.setdefault(key, (qual, kind, ast.unparse(lf), bad))
for n in body_nodes(fn):
if isinstance(n, (ast.ListComp, ast.SetComp, ast.DictComp,
ast.GeneratorExp)):
for g in n.generators:
if derived(g.iter, m, qual) or sliced(g.iter, m, qual):
note("for..in", g.iter)
elif isinstance(n, (ast.For, ast.AsyncFor)):
if derived(n.iter, m, qual) or sliced(n.iter, m, qual):
note("for stmt", n.iter)
elif (isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id in _REDUCERS):
for a in n.args:
if derived(a, m, qual) or sliced(a, m, qual):
note(n.func.id + "()", a)
def probe(m, qual, expr_src):
"""Would this expression, IN THAT SCOPE, be reported as sliced?
The per-site counterfactual runs through here: it is the SAME predicate
the enumeration above is graded with, asked about a mutated spelling of
the site's own expression, so a predicate that stopped answering would
take both to the same place.
"""
return sliced(ast.parse(expr_src, mode="eval").body, m, qual)
rows = sorted((k + v) for k, v in seen.items())
return rows, (sorted(t_funcs), sorted(t_params), sorted(t_attrs), probe)
_SITES, (_TFUNCS, _TPARAMS, _TATTRS, _PROBE) = _roster_sites(_APP_SRC)
_SLICED = [r for r in _SITES if r[8]]
_BY_MOD = {}
for _r in _SITES:
_BY_MOD.setdefault(_r[0], []).append(_r)
def _slice_at(row, cut="[:1]"):
"""The package source with ONE roster reference sliced, for a counterfactual."""
m, _ln, _c, eln, ec = row[:5]
src = dict(_APP_SRC)
lines = src[m].split("\n")
lines[eln - 1] = lines[eln - 1][:ec] + cut + lines[eln - 1][ec:]
src[m] = "\n".join(lines)
return src
print(f" {len(_APP_SRC)} modules parsed from app/ (NOT a written list); "
f"{len(_BY_MOD)} of them reduce over a roster or a roster-derived list, "
f"and every such reduction is enumerated below "
f"({len(_SITES)} of them):")
for _m in sorted(_BY_MOD):
print(f" {_m}.py: {len(_BY_MOD[_m])}")
for _m in sorted(_BY_MOD):
for _row in _BY_MOD[_m]:
print(f" {'SLICED ' if _row[8] else ' '}{_m}.py:{_row[1]:<5}"
f" {_row[5]:<38} {_row[6]:<10} {_row[7][:56]}")
print(f" modules with NO roster reduction at all: "
f"{sorted(set(_APP_SRC) - set(_BY_MOD))}")
print(f" ...and what made each of them a roster, computed from ONE seed "
f"(`opts[{_ROSTER_OPTION!r}]`): {len(_TFUNCS)} roster-PRODUCING functions, "
f"{len(_TPARAMS)} roster-carrying PARAMETERS across module boundaries, "
f"and the attributes {_TATTRS}:")
print(f" returns a roster: "
+ ", ".join(f"{m}.{q}" for m, q in _TFUNCS))
print(f" is HANDED a roster: "
+ ", ".join(f"{m}.{q}({a})" for m, q, a in _TPARAMS))
# ⚠️ THE PR #48 ROUND-5 BLOCKERS, REDISCOVERED BY THE INSTRUMENT RATHER THAN
# COPIED FROM THE REVIEW. If this pin did not independently name all of them,
# the pin would be too narrow and THAT would be the finding.
#
# ⚠️ MATCHED BY FUNCTION AND EXPRESSION, NOT BY LINE NUMBER. The blockers were
# reported as validate.py:778 `caps_ix`, :779 `caps`, :783 and :893 `caps_ix`;
# editing three lines into validate.py moves all four, and a check pinned to
# 778 would then be green because it found NOTHING - a check whose data cannot
# reach the failure it names, in the block that exists to complain about them.
# The lines the pin actually reports are PRINTED so a reviewer can match them
# against the review by eye.
_BLOCK_FN = "validate_startup_config"
_blockers = [r for r in _SITES
if r[0] == "validate" and r[5] == _BLOCK_FN
and r[7] in ("caps", "caps_ix")]
print(f" PR #48's round-5 blockers, as the pin names them "
f"(validate.{_BLOCK_FN}, reductions over the caps list): "
+ ", ".join(f"validate.py:{r[1]} {r[6]} {r[7]}" for r in _blockers))
check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY, AND "
"NOW ACROSS THE WHOLE PACKAGE INSTEAD OF ONE CLASS. Every place in "
"`app/` where a roster or a roster-derived list is REDUCED or ITERATED "
"takes the whole of it: no `[:1]`, no `[-1:]`, no `[:2]`, no `[0]` "
"between the roster and the reduction. This is the check that would have "
"caught rounds 2, 3, 4 AND 5's blockers on the round they were written "
"instead of one review each - round 4's version stopped at `Controller` "
"and round 5's four survivors were one module over, in validate.py. A "
"new reduction added over a slice of a roster, in ANY module under "
"`app/`, is red on the line it is written, at every roster length and "
"with no fixture required",
len(_SITES) > 0 and not _SLICED)
check("...and it INDEPENDENTLY REDISCOVERS EVERY ONE OF PR #48's ROUND-5 "
"BLOCKERS - the `for ... in caps_ix` the caps list is built from, the "
"`min(caps)` rule 5 reduces it with, and the `min(caps_ix, key=...)` "
"rule 5's message and rule 6's comparison each take - and it found a "
"FURTHER ONE beside them that no review round named. A pin that had to "
"be told where to look would be a transcription of the review rather "
"than an instrument, so it is told ONE option key and nothing else",
len([r for r in _blockers if r[7] == "caps_ix"]) >= 3
and len([r for r in _blockers if r[7] == "caps"]) >= 1)
check("...and it reaches the sites of EVERY PREVIOUS ROUND'S BLOCKER TOO: "
"round 2's `peakguard.binding_soc_floor`, round 4's "
"`cycle()`/`publish()` `any(... for i in self.inverters)`, and "
"SAFETY-30's `_grade_store_migration`. The whole point of a family is "
"that its members are found by one rule",
any(r[0] == "peakguard" and r[5] == "binding_soc_floor" for r in _SITES)
and any(r[0] == "main" and r[5] == "Controller.cycle"
and r[6] == "any()" for r in _SITES)
and any(r[0] == "main" and r[5] == "Controller.publish"
and r[6] == "any()" for r in _SITES)
and any(r[0] == "main" and r[5] == "Controller._grade_store_migration"
for r in _SITES))
# --- non-vacuity, habit 7: vary ONE thing from an ACCEPTED baseline ----------
# ⚠️ EVERY ONE of the enumerated sites, mutated. Not a sample: the counterfactual
# asks the SAME predicate the enumeration is graded with whether a `[:1]` on
# THAT site's own expression, in THAT site's own scope, would be reported. A
# predicate that had quietly stopped answering would take the line above green
# and this one red.
_probe_fail = []
for _r in _SITES:
for _cut in ("[:1]", "[-1:]", "[:2]", "[0]"):
if not _PROBE(_r[0], _r[5], _r[7] + _cut):
_probe_fail.append((_r[0], _r[1], _r[7] + _cut))
print(f" per-site counterfactual: {len(_SITES)} sites x 4 cuts "
f"([:1], [-1:], [:2], [0]) = {len(_SITES) * 4} mutated spellings, "
f"{len(_probe_fail)} of them NOT reported")
check("...and the predicate is not trivially true: sliced at EVERY ONE of the "
"enumerated sites, in that site's own scope, with each of the four cuts "
"this card has actually been defeated by, it reports the slice. It also "
"ACCEPTS the legitimate wrapped forms the package really uses "
"(`enumerate(...)`, `zip(...)`, `sorted(...)`, "
"`pack_permissions(packs, floor)`) and REJECTS a keyed lookup being "
"treated as a slice (`weights[u]` in allocator._fill, which is what four "
"false SLICED reports cost before `shortens()` existed)",
not _probe_fail
and not _PROBE("main", "Controller.cycle", "self.inverters")
and not _PROBE("main", "Controller.cycle", "enumerate(self.inverters)")
and not _PROBE("main", "Controller.cycle", "zip(self.inverters, self.maints)")
and not _PROBE("main", "Controller.check_peak_floor_limited",
"peakguard.pack_permissions(self.packs, 10.0)")
and not _PROBE("allocator", "_fill", "[u for u in order if weights[u] > 0.0]")
and _PROBE("allocator", "_fill", "[u for u in order[:1] if weights[u] > 0.0]"))
# --- and the END-TO-END counterfactual, on the REAL FILES --------------------
# ⚠️ The probe above grades the predicate. This grades THE WHOLE INSTRUMENT: the
# text of a shipped module is mutated at a real byte offset, the entire analysis
# is re-run from scratch on it, and the site has to come back SLICED at exactly
# the line the mutation was written on. That is the difference between "the
# rule is right" and "the rule is wired to the files".
_e2e = []
for _r in _blockers:
_mut, _ = _roster_sites(_slice_at(_r))
_hit = [x for x in _mut if x[8] and x[0] == _r[0] and x[1] == _r[1]]
_e2e.append((_r[0], _r[1], _r[7], len(_hit), len([x for x in _mut if x[8]])))
for _m, _ln, _s, _hit, _tot in _e2e:
print(f" {_m}.py:{_ln} `{_s}` -> `{_s}[:1]`: reported at that line "
f"{'YES' if _hit else 'NO'} ({_tot} sliced site(s) in the package)")
check("...and the INSTRUMENT is wired to the files, not only to itself: "
"writing `[:1]` into app/validate.py at each of the four round-5 blocker "
"positions - every reduction over the caps list - and re-running the WHOLE "
"analysis over the mutated source "
"reports a sliced site at that exact line every time, where the clean "
"package reports none. ⚠️ THIS IS THE ONE THAT WOULD HAVE CAUGHT PR #48: "
"the four mutations the reviewer ran by hand against a 19/19 green suite "
"are red HERE without any fixture reaching them",
len(_e2e) >= 4 and all(h for _m, _l, _s, h, _t in _e2e))
# ⚠️ WHAT THIS INSTRUMENT CANNOT SEE, SAID PLAINLY, BECAUSE AN ENUMERATED LIST
# WITH AN HONEST GAP IS WORTH MORE THAN A CLAIM OF COMPLETENESS A REVIEWER CAN
# FALSIFY IN TEN MINUTES. Every item below is an OPEN ROW, not a closed one.
#
# IT GRADES AN IDENTITY, NOT A MEANING. The claim it makes is "the whole roster
# arrives". It says nothing about WHICH attribute is read off each unit, WHICH
# reduction is applied, or whether that reduction is the right one:
# `min(caps)` -> `max(caps)` is invisible here, and always will be, because a
# shape pin on a semantic claim would refuse honest refactors (MUTATION.md
# habit 10). The behavioural halves - 12a-12i above, test_validate.py's rule 5
# and rule 6 roster SETS, test_watchdog.py part 8 - are what say what these
# reductions MEAN, and they are the only thing that can.
#
# AND SPECIFICALLY, THE FOUR THINGS IT WILL NOT REPORT:
# 1. DIRECT INDEX SITES THAT ARE NOT REDUCTIONS. `self.inverters[0]` in
# `_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in
# `maint_unit`, `entries[index]` in `_unit_label()`. These are single-unit
# lookups, they are meant to be, and treating an index as a roster would
# spread this analysis over every unit attribute in the package. They are
# graded where they live.
# 2. A SUBSCRIPT BY A NAME. `weights[u]`, `cap[u]`, `add[u]` in
# `allocator._fill` are keyed lookups into a roster-derived MAPPING and are
# accepted deliberately - `shortens()` above. So a mutation that wrote
# `caps[i]` with `i` a variable would pass. It is not in the family this
# card has been defeated by four times (`[:1]`, `[-1:]`, `[:2]`, `[0]`),
# and buying it would cost four false SLICED reports in allocator.py.
# 3. A SLICE TAKEN THROUGH AN INTERMEDIATE REDUCTION. `sorted(roster)[:1]` is
# a subscript on a CALL RESULT rather than on a roster reference, and the
# pin does not flag it. Nothing in `app/` is written that way today; if
# something is, this line is the record that it would not be seen.
# 4. ANYTHING OUTSIDE `app/`. `tools/`, `sim/` and the checks themselves are
# not parsed. `sim/inject.py` reaches `validate_startup_config()` and is
# graded by nothing here.
#
# AND THE DISCOVERY ITSELF HAS A SEAM: the taint crosses a call boundary only
# where the callee is a plain function this package defines and the argument is
# passed positionally or by keyword. A roster handed through `*args`, stored in a
# dict and read back out, or passed to a method on an object the analysis cannot
# resolve, is lost at that hop - and everything downstream of the hop goes
# UNENUMERATED rather than reported as unreachable. That is the shape of the
# remaining exposure: not zero, and not measurable from inside this check.
# ⚠️ AND WHAT THE ENUMERATION ACTUALLY FOUND WHEN EVERY SITE WAS MUTATED - THE
# FINDING, RECORDED, BECAUSE THE LESSON IS THE MEASUREMENT AND NOT THE FIX.
#
# All 183 sites above were mutated to `[:1]` one at a time, +4 bytes each, and
# each mutant was run against the full 19-file suite in `goodwe-addon-ci`
# (mounted `:ro`, `python3 -B`, a fresh rsync per mutant, 0 `.pyc` in the tree
# before and after - a read-only mount cannot write one, which is TEST-15 closed
# by construction rather than by discipline). **183 of 183 RED.**
#
# ⚠️ THAT NUMBER IS TAUTOLOGICAL ON ITS OWN AND MUST NOT BE QUOTED WITHOUT THE
# SECOND RUN. With the check above in the tree, every slice is red BY THIS
# CHECK. So all 51 mutants whose first red was `test_peakguard.py` were re-run
# with this pin NEUTRALISED (`_SLICED` blanked), leaving only the behavioural
# checks. The answer:
#
# 145 of 183 a BEHAVIOURAL fixture in this repository separates the site
# from a slice of itself - it goes red with the pin turned off
# 38 of 183 NOTHING but this pin sees it. Structurally closed as of this
# card; behaviourally UNGRADED, and listed below rather than
# counted as covered
#
# THE 38, BY MODULE: main.py 27, allocator.py 6, validate.py 2, arbiter.py 1,
# circulation.py 1, maintenance.py 1. Grouped by what a `[:1]` there would cost:
#
# THE OPERATOR'S PAGE - 15 of them, and the largest group by far.
# `Controller.checks` (3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370,
# 3400, 3458) and `status_payload` (3556, 3566, 3578) build the rows and
# the counts an operator reads. A slice reports ONE unit's row where two
# exist, or a "1 of 2 live" that says 1 of 1. Nothing commands differently;
# what is lost is the evidence a human uses to notice that it should.
# THE WRITE PATH's OWN COUNTS - `write_setpoint` 1267/1272/1280, three
# `len(targets)` guards around the one place this add-on writes. The
# write itself iterates `targets` at 1287 and IS graded (test_inverter);
# these three are the arithmetic in its log line and its refusal count.
# THE ALLOCATOR'S INTERNALS - `_fill`'s zero-weight last-resort pool (365),
# `plan`'s `dropped` (526, 653), `free` (584) and `frac_order` (591).
# ALLOC-01's fixtures drive two and three units through the WEIGHTED pass;
# the last-resort pass and the largest-remainder tie-break are reached but
# not with enough units for a slice to change the answer.
# STARTUP AND FSM PLUMBING - `__init__` 235/351, `_only` 831, `_maint_button`
# 805, `cycle`'s maintenance zip at 1505, `send_heartbeats` 3015,
# `check_output_clamp` 2445/2452, `check_circulating` 2701,
# `maintenance.arbitrate` 547, `arbiter.site_resolve` 517,
# `circulation.opposing` 242.
# TWO REFUSAL-TEXT SITES - `validate._roster_problems`'s `len(entries)` (554)
# and `refuse()`'s numbering loop (1263). Both shape the WORDS of a
# refusal that still happens.
# AND `_grade_store_migration` (698), which round 4 already recorded as row 21
# of the DECISIONS table and argued to be an equivalent mutant at that call
# site. The argument is unchanged and is still an argument, not a proof.
#
# ⚠️ NONE OF THESE 38 IS CLOSED BEHAVIOURALLY BY THIS CARD, AND SAYING SO IS THE
# POINT. Closing them one at a time is exactly what cost this card four review
# rounds; the pin closes the FAMILY - a slice at any of them is red on the line
# it is written - and the list above is what a shape pin can never promise. The
# honest summary is: 145 sites have a fixture that can see them, 38 have only a
# structural claim, and every one of the 38 is named here with what it would
# cost rather than left out of a count.
# ==============================================================================
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, margin_w=0.0, 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, margin_w=0.0, 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"))
# ---- SAFETY-32's own defeats -------------------------------------------------
# ⚠️ RE-IMPLEMENTED IN PROCESS, exactly as SAFETY-07's B1/B2/B3 are: no source
# file is edited, so no `.pyc` is consulted and MUTATION.md's stale-bytecode
# defect cannot reach these rows. The SOURCE-EDIT battery is on the PR, run
# through `tools/mutation_run.sh`.
# 14. `build_roster()` letting an entry inherit the flat `bms_soc_floor` - this
# card's own defect, rebuilt in three lines, and CFG-04's exact mutation.
MUT.append(("`build_roster()` inheriting the flat `bms_soc_floor` for an entry "
"that omits it",
_r_inh[1].bms_soc_floor is None and _FLAT_ODD != A_PACK_FLOOR,
"part 12f: 'an entry that omits bms_soc_floor has NO cut-out'. ⚠️ "
"The flat key is set to 30 %, a number nothing else in this file "
"has, precisely so inherited and not-inherited are different "
"answers - CFG-04 shipped this claim against a BLANK flat key and "
"watched the mutation survive"))
# 15. the permission graded against the site's ONE floor, i.e. the shipped
# behaviour this card removes. Re-derived on 12a's own two packs.
_mut15 = [discharge_permitted(p.soc, SOC_FLOOR) for p in (_pk_a, _pk_b)]
MUT.append(("the permission graded against the ONE `peak_soc_floor` instead of "
"each pack's own cut-out - the shipped behaviour",
_mut15 != [w for _, w in _perm],
"part 12a: 'two packs AT THE SAME STATE OF CHARGE get OPPOSITE "
"answers'"))
# 16. every pack graded against packs[0]'s floor - DEV-02's `self.inverters[0]`
# defect and CFG-04's index borrow, in this card's costume.
_mut16 = [discharge_permitted(p.soc, pack_floor_pct(_pk_a, SOC_FLOOR))
for p in (_pk_a, _pk_b)]
MUT.append(("every pack graded against the FIRST pack's floor",
_mut16 != [w for _, w in _perm],
"part 12a, and it is the fixture that makes it visible: the two "
"floors differ, so borrowing pack A's answers a different question"))
# 17. `binding_soc_floor()` returning the bare option - the `max()` dropped.
MUT.append(("`binding_soc_floor()` returning `soc_floor` unreduced",
_n1_deep != SOC_FLOOR and _n1_deep == B_PACK_FLOOR_DEEP_SIM,
"part 12c: 'on the pair rule 8 REFUSES the reduction answers the "
"CUT-OUT and not the option'. ⚠️ THE N=1 SWEEP ALONE CANNOT SEE "
"THIS - it is 1775 pairs on which the two agree, which is the "
"point of pairing it with a case on which they must not"))
# 18. an unstated per-entry cut-out defaulted to 0 % rather than withheld - the
# most permissive value the key has, invented from an absence.
MUT.append(("an unstated per-entry cut-out defaulted to 0 %",
pack_floor_pct(Pack("b", 100.0, None), SOC_FLOOR) is None
and discharge_permitted(100.0, 0.0) is None,
"part 12b: 'a roster entry that states no bms_soc_floor is "
"WITHHELD even at a full battery'"))
# 19. rule 8 grading only the first unit, i.e. the flat rule left in place.
MUT.append(("rule 8 grading only the FIRST unit",
_r_second != [] and " for inverter-b" in _msg(_r_second),
"part 12e: 'a roster whose SECOND pack has a cut-out above "
"peak_soc_floor is refused'. ⚠️ The offender is the SECOND entry "
"for exactly this row"))
# 20. rule 8 naming a hardwired unit - CFG-04's rule 5/6 bug, which is why
# `unit_bms_floors_indexed()` carries the roster index.
MUT.append(("rule 8's refusal naming a hardwired inverter",
" for inverter-b" in _msg(_r_second) and " for inverter-a" in _msg(_r_first),
"part 12e: the two orders. Either hardwired index passes one of "
"them and fails the other"))
# 21. the notification naming a fixed roster position rather than the pack that
# is actually at its floor.
MUT.append(("the per-pack notification naming a fixed roster position",
"inverter-a" in _body32b and "inverter-b" not in _body32b,
"part 12h: the same two readings swapped between the packs"))
# ---- the two PR #48 SURVIVORS, and why they survived -------------------------
# ⚠️ BOTH OF THESE WERE APPLIED TO app/ AND ALL 19 FILES STAYED GREEN. They are
# this repository's recurring defect - a check whose DATA cannot reach the
# failure it names - landing on this card's own reduction, and they are recorded
# here as rows so that a later edit which re-degenerates the fixtures goes red
# on the row rather than on nothing.
# 22. `binding_soc_floor()` reduced with `min()` instead of `max()`. ZERO byte
# delta, and the reviewer proved by disassembly that it really ran. It
# survived because every roster in the suite was rule-8-valid or one pack
# long, so `floors` held IDENTICAL numbers and max/min/first/last were four
# spellings of one answer. 12c2's four simulated cut-outs are the data that
# reaches it.
_mut22 = [min(_MANY_FLOORS_SIM), _MANY_FLOORS_SIM[0], _MANY_FLOORS_SIM[-1],
sum(_MANY_FLOORS_SIM) / len(_MANY_FLOORS_SIM)]
MUT.append(("`binding_soc_floor()` reduced with `min()`, `first` or `last` "
"instead of `max()` - PR #48 SURVIVOR 1, zero byte delta",
binding_soc_floor(_many_packs, SOC_FLOOR) == max(_MANY_FLOORS_SIM)
and all(binding_soc_floor(_many_packs, SOC_FLOOR) != m
for m in _mut22),
"part 12c2: 'max, min, first, last and the mean are FIVE DIFFERENT "
"numbers'. ⚠️ The four cut-outs sit on a roster rule 8 would REFUSE, "
"deliberately: on one it accepts all four reductions are equal, "
"which is precisely why this mutation survived"))
# 22b. `binding_soc_floor()`'s EMPTY-roster branch rewritten to `return None`.
# -43 bytes, green across all 19 files until 12c2's last block pinned it.
# A defensive branch nothing reaches is still a branch.
MUT.append(("`binding_soc_floor([])` answering None instead of the option",
binding_soc_floor([], SOC_FLOOR) == float(SOC_FLOOR)
and binding_soc_floor([], None) is None,
"part 12c2's last block: 'an EMPTY roster answers the OPTION ... "
"and answers None when the option itself is None'"))
# 23. the CALL SITE grading the whole site against ONE ROSTER POSITION -
# `self.packs[:1]` (+4 bytes, PR #48's survivor) or `self.packs[-1:]`
# (+5 bytes, which survived the FIRST CUT OF THIS FIX ROUND until the
# swapped pair was added). 12g's old substring assertion
# (`"self.packs" in _floor_call`) passes on both, because a slice still
# contains that substring. Three runs close both directions at once: the
# deep cut-out is on the SECOND pack in one and on the FIRST in another, and
# the site floor is the same 35 % in both.
MUT.append(("main.py grading the site against ONE roster position - "
"`self.packs[:1]` (PR #48 SURVIVOR 2) or `self.packs[-1:]`",
_g_deep.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
and _g_deepa.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
and _g_shallow.soc_floor == float(SOC_FLOOR)
and _g_deep.floor_limited and _g_deepa.floor_limited
and not _g_shallow.floor_limited,
"part 12g, REWRITTEN as behaviour: real two-pack cycles, the SAME "
"pair of cut-outs carried in both orders, and both the floor "
"reaching decide() and the site's verdict follow the ROSTER rather "
"than a position. ⚠️ A substring test over main.py cannot see a "
"slice; MUTATION.md habit 4"))
# ---- and the TWO ROUND-3 SURVIVORS, the same defect one pack along -----------
# ⚠️ BOTH SURVIVED THE ROUND-2 FIX, and both are the family law above: a fixture
# of N packs cannot separate reductions that agree on N packs. Round 2 closed
# `min(floors)` and the single-position slices and then stated, in the check
# text, a BROADER claim than its data could reach - "the reduction reads every
# entry", "what is reduced is the ROSTER and not a POSITION". The two rewrites
# below satisfy every fixture round 2 had while making both of those sentences
# false. The prose has been cut back to what the old data reached and new data
# added for the rest; that is the whole of round 3.
# 24. `max(floors)` -> `max(floors[-2:])`, +5 bytes, green across all 19 files
# on 8501e05. Entries 0 and 1 are never read, so a roster of >= 3 can be
# graded against a floor BELOW its deepest cut-out - the same safety
# inversion as `min(floors)`, one pack along. It survived because 12c2's
# only counterfactual varied the LAST pack, and every window ending at the
# roster's end follows that. 12c2's roster SET reaches it.
MUT.append(("`binding_soc_floor()` reduced over a WINDOW - `max(floors[-2:])` - "
"ROUND 3 SURVIVOR 1, +5 bytes",
binding_soc_floor(_sim_packs(_reduction_rosters[1][1]), SOC_FLOOR)
== _DEEPEST_SIM
and max(_reduction_rosters[1][1][-2:]) != _DEEPEST_SIM
and bool(_reduction_kills["max(floors[-2:])"])
and all(_reduction_kills.values()),
"part 12c2's FAMILY TABLE: the same four cut-outs with the deepest "
"moved to each of the four positions in turn, and every listed "
"rewrite separated from max() by at least one of them. ⚠️ A SINGLE "
"counterfactual cannot do this - it can only show that ONE position "
"is read"))
# 25. `binding_soc_floor(self.packs, ...)` -> `self.packs[:2]`, +4 bytes, green
# across all 19 files on 8501e05. Every `_floor_reaching_decide` run in 12g
# drove a TWO-pack roster, where `[:2]` IS the roster, so no run could see
# it - and 12g's own check said so about two packs and max/min/first/last
# while claiming otherwise about itself. Three-pack runs with the deep
# cut-out at index 0, 1 and 2 reach it.
MUT.append(("main.py grading the site against a WINDOW of roster positions - "
"`self.packs[:2]` - ROUND 3 SURVIVOR 2, +4 bytes",
nth(_g3_runs, 2)[3].soc_floor == float(_G3_DEEP_SIM)
and binding_soc_floor(nth(_g3_runs, 2)[2].packs[:2], SOC_FLOOR)
== float(SOC_FLOOR)
and all(r[3].soc_floor == float(_G3_DEEP_SIM) for r in _g3_runs)
and all(_slice_kills.values()),
"part 12g's THREE-pack runs and their POSITIONAL-SLICE TABLE: the "
"deep cut-out at index 0, 1 and 2 of a three-pack roster, so every "
"proper subset of the positions drops the deep one on some run. "
"⚠️ TWO packs are structurally blind to this; `[:2]` is the whole "
"roster there"))
# 26. ...AND THE TWO THE BATTERY FOUND WHILE CLOSING THOSE. `self.packs[:3]`
# (+4) and `self.packs[-3:]` (+5) survive ALL SIX behavioural runs in 12g,
# because the longest roster those runs drive is three packs long and a
# three-wide window IS that roster. This is the fourth turn of the same
# screw: `[:1]` needed two packs, `[:2]` needed three, `[:3]` needs four,
# and adding a fourth pack would hand `[:4]` the free pass. They are closed
# STRUCTURALLY instead - the roster argument must unparse to exactly
# `self.packs` - and the row asserts BOTH the pin and the reason it was
# needed, so a later edit that deletes the pin cannot leave this green.
MUT.append(("main.py grading the site against a THREE-wide window - "
"`self.packs[:3]` / `self.packs[-3:]`, found by THIS round's "
"battery, invisible to every behavioural run in this file",
_src(_floor_roster_arg) == "self.packs"
and isinstance(_floor_roster_arg, ast.Attribute)
and all(binding_soc_floor(ctl.packs[:3], SOC_FLOOR) == dec.soc_floor
and binding_soc_floor(ctl.packs[-3:], SOC_FLOOR)
== dec.soc_floor for _rn, ctl, dec in _G_RUNS),
"part 12g's STRUCTURAL PIN, and ONLY that - the runs cannot see it "
"and this row asserts that they cannot. ⚠️ A bounded roster cannot "
"grade an unbounded family of slices; one more pack moves the hole "
"rather than closing it"))
# 27. AND THE ONE THAT IS NOT CLOSED. `max(floors[:12])` and every wider window
# inside `binding_soc_floor()` are INVISIBLE to this file: the length sweep
# runs to twelve packs, so a twelve-wide window is the whole list on every
# roster it drives. Unlike the call site there is no identity claim to pin
# here - the claim is semantic, "the maximum", and a shape pin would refuse
# honest refactors of the expression. Recorded as open, with its size: it
# needs a roster of THIRTEEN packs to become observable, on a site whose
# roster is currently ONE and whose second unit is ~8 days out.
MUT.append(("`binding_soc_floor()` reduced over a window 12 packs wide or "
"wider - NOT CAUGHT, and it takes a 13-pack roster to reach",
_head_kill.get(_SWEEP_N, False) or _tail_kill.get(_SWEEP_N, False),
"NOTHING CATCHES IT, on purpose. The sweep closes widths 1-11 and "
"says so; width 12 is where this file's data stops. ⚠️ Two further "
"rewrites are EQUIVALENT rather than uncaught - `max(set(floors))` "
"and `list(reversed(self.packs))` cannot change any answer, because "
"max is order- and duplicate-invariant; the second is nonetheless "
"red on 12g's structural pin"))
# --- ROUND 4: THE SIBLING ARGUMENT, AND THE ENUMERATION THAT FOUND THE REST --
# 28. `pack_floor_soc` reduced over a WINDOW of roster positions. THIS ROUND'S
# BLOCKER, and the same family as rows 25-26 one ARGUMENT over: the MINIMUM
# handed to the same `decide()` call as `soc=`. Green across all 19 files at
# 52380c0 and again on the rebase onto 3934ad7.
MUT.append(("`pack_floor_soc` reduced over a WINDOW - `vals[:2]` (+4 bytes) or "
"`vals[-2:]` (+5) - ROUND 4's BLOCKER, the SoC axis of the pair",
all(_soc_slice_kills[k] for k in ("vals[:2]", "vals[-2:]",
"vals[:1]", "vals[-1:]")),
"part 12i's THREE-pack runs and their POSITIONAL-SLICE TABLE: the "
"same three readings with the emptiest at index 0, 1 and 2, so "
"every proper subset of the positions drops the empty pack on some "
"run. ⚠️ At N=2 `[:2]` IS the roster - the two-pack mirror cannot "
"see it, which is why the suite's separating power on this axis "
"stopped at exactly N=2 for four rounds"))
# 29. ...and the three-wide window, invisible to every behavioural run here.
MUT.append(("`pack_floor_soc` reduced over a THREE-wide window - `vals[:3]` / "
"`vals[-3:]` - invisible to every cycle in this file",
_s_head_kill[3] and _s_tail_kill[3]
and _src(_pfs_min_iter) == "vals",
"part 12i's LENGTH SWEEP (widths 1-11, on the REAL property over "
"rosters of 1 to 12 real inverters) and its STRUCTURAL PIN. The "
"six real cycles cannot see it - three packs is the longest roster "
"they drive - and the sweep and the pin are what do"))
# 30. the reduction itself, on the wrong axis. Zero byte delta.
MUT.append(("`pack_floor_soc` reduced with `max()` - the FULLEST pack, which is "
"`binding_soc_floor()`'s own reduction pasted onto the SoC axis - "
"or with the mean, the median or the second-emptiest",
all(_soc_red_kills.values()),
"part 12i's NON-SLICE table, on the same six real cycles: the "
"emptiest pack is 11 % where the fullest is 60 %, so every one of "
"the four disagrees on every run. ⚠️ Zero byte delta, and the most "
"likely wrong edit here precisely because the sibling reduction one "
"argument over IS a max"))
# 31. the None guard, which is a reduction over roster length too.
MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - "
"`any(v is None for v in vals[:1])`, +4 bytes",
all(g is None for _n, _p, g in _s_none_rows)
and _src(_pfs_any_iter) == "vals",
"part 12i's NONE-GUARD SWEEP - one silent pack at every position of "
"every length 1..12 - and the same structural pin. ⚠️ 12d's "
"two-pack fixture silences the SECOND pack only, so a guard "
"narrowed to the FIRST answers 'all present' there and hands a None "
"to `min()`"))
# 32. and the roster the list itself is built from.
MUT.append(("`pack_floor_soc` building `vals` from a SLICE of the roster - "
"`[inv.soc for inv in self.inverters[:2]]`, +4 bytes",
_src(_pfs_vals_iter) == "self.inverters"
and _s_head_kill[2] and not _SLICED,
"part 12i's sweep reads the REAL property on real rosters so it "
"sees this too, its pin is on the comprehension's own iterable, "
"and 12j's ENUMERATION covers every other roster reduction in the "
"PACKAGE at once"))
# 33. THE ENUMERATION'S OWN FINDING, and the reason this row is worth more than
# the four above: it is not this card's code.
MUT.append(("a reduction over a roster added ANYWHERE ELSE IN THE PACKAGE over "
"a slice - which is how rounds 2, 3, 4 AND 5 each began",
bool(_SITES) and not _SLICED,
"part 12j: every roster reduction in EVERY module under `app/`, "
"enumerated from the AST off one seed and required to take the "
"roster whole. ⚠️ Round 4's version stopped at the `Controller` "
"class and round 5's four blockers were one module over, in "
"validate.py; this one enumerates "
f"{len(_SITES)} sites across {len(_BY_MOD)} modules and "
"rediscovers all four without being told where to look. THIS row "
"is the one that closes the family rather than the member"))
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", present(nth(MUT, 6)) and not MUT[6][1]
and quarter_ceiling_w(CAP_W, _zero_peak)[0] == CAP_W)
check("...and SAFETY-32's own eight defeats are each caught too - the flat "
"cut-out inherited, the site floor graded in place of the pack's, the "
"first pack's floor borrowed for all, the reduction dropped, an absent "
"cut-out defaulted to 0 %, rule 8 grading one unit, rule 8 naming a "
"hardwired one, and the alarm naming a position instead of a pack",
all(m[1] for m in MUT[13:21]))
check("...⚠️ AND SO ARE THE SEVEN DEFEATS THAT SURVIVED A GREEN 19-FILE SUITE, "
"which is why this card is on its third fix round: `min(floors)` in place "
"of `max(floors)` (zero byte delta) and `self.packs[:1]` at the call site "
"(+4 bytes), both green on d9388c8; `self.packs[-1:]` (+5 bytes) and the "
"empty-roster branch rewritten (-43 bytes), green on the FIRST CUT OF THE "
"FIX; `max(floors[-2:])` (+5) and `self.packs[:2]` (+4), both green on "
"8501e05 - THE ROUND-2 FIX ITSELF; and `self.packs[:3]` / "
"`self.packs[-3:]`, green on the first cut of ROUND 3 and found by its "
"own battery. None of the seven is a new guard; no shipped behaviour "
"changed. Every one is a check whose DATA could not reach the failure its "
"TEXT named - the defect this repository has now shipped in six "
"consecutive cards and, twice running, inside the fix written to close "
"it. The last five were found by a battery rather than by argument, "
"which is the only thing that has ever found any of them",
all(m[1] for m in MUT[21:27]) and len(MUT) == 34)
check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row "
"this round could write for it: a reduction over a window TWELVE packs "
"wide is indistinguishable from `max(floors)` on every roster in this "
"file, because the length sweep stops at twelve. Thirteen packs would "
"close it and open thirteen. The call site's half of the same regress is "
"closed by an exact structural pin because the claim there is an IDENTITY "
"('this roster'); the claim here is SEMANTIC ('the maximum') and a shape "
"pin would refuse honest refactors, so the hole is reported with its size "
"instead of hidden. THIS is the row that a fifth review should argue "
"with, and it exists to be argued with",
present(nth(MUT, 27)) and not MUT[27][1]
and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N)))
check("...⚠️ AND ROUND 4's SIX, WHICH ARE THE SAME FAMILY AT THE SIBLING "
"ARGUMENT OF THE SAME CALL. `pack_floor_soc` is the MINIMUM over the "
"roster handed to `decide(soc=...)`; `binding_soc_floor()` is the MAXIMUM "
"handed to `decide(soc_floor=...)` beside it, and peakguard.py's own "
"docstring names them as a pair. Rounds 2 and 3 hardened one half; "
"`vals[:2]`, `vals[-2:]` and `vals[:3]` were still green on 52380c0 with "
"the other, and a three-pack roster at 50 / 60 / 11 % answers 11 % clean "
"and 50 % mutated - a discharge PERMISSION graded against a pack 39 "
"points fuller than the emptiest. The last row is the one that matters "
"most: closing the MEMBER four times has cost four review rounds, so the "
"FAMILY is now enumerated from the source, and that enumeration found "
"four more survivors in code this card never touched",
all(m[1] for m in MUT[28:34]) and len(MUT) == 34
and _S_EMPTY_SIM < SOC_FLOOR)
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",
present(nth(MUT, 12)) and not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
# ==============================================================================
print()
print("13. SAFETY-38 round 4: the ORDERED PAIR inside PeakDecision.explain()")
# ⚠️ THE STRING "peak guard idle" APPEARED IN NO test_*.py UNTIL THIS PART.
# `peakguard.py:493` formats its OWN ATTRIBUTES and RETURNS the sentence:
#
# f"peak guard idle - quarter projects {self.projected_w:.0f} W under a "
# f"{self.ceiling_w:.0f} W ceiling ({self.ceiling_from})"
#
# Transpose the two and this fixture's row reads "projects 5013 W under a
# 2300 W ceiling" - a quarter projected 2713 W OVER the ceiling, printed on a
# row whose own words say the guard is IDLE and whose own flag says ok.
# MEASURED on this tree: zero-byte edit (50 032 bytes
# before, 50 032 after, sha256 d95df61a51716c26 -> 2d2ee3d0bcd51251), the
# emitted sentence changes, and the full 21-file suite stayed GREEN.
#
# The sentence was invisible to SAFETY-38's scanner for two rounds because both
# censuses scored one syntactic sink - APPEND, ASSIGN, LOG ARG - and this one is
# RETURNED. `tools/symmetric_message_scan.py` census C now scans the return sink
# and resolves it through the method's call sites; it rediscovered this pair and
# clamp.py's on its own, which is the only reason to trust the rest of its list.
#
# ⚠️ THE OPERANDS COME OFF THE PROJECTION AND OFF `quarter_ceiling_w()`, NOT OFF
# THE DECISION. `d.projected_w` and `d.ceiling_w` are the very fields the swap
# exchanges, so grading the sentence against them would compare the swap against
# itself and pass under it.
_S38_PROJ = PeakTracker.project(averager_at(CAP_W - 1200.0, 450.0),
B + timedelta(seconds=450))
_S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=obs_hi,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_OK, soc_floor=SOC_FLOOR)
# UX-08 2d: the row is in plain words now, and the pair is still ordered.
from app import messages as _msg38 # noqa: E402
_S38_LITERAL = (f"heading for {_msg38.watts(_S38_PROJ.projected_w)}, under "
f"this month's {_msg38.watts(_S38_CEIL_W)} peak")
_S38_SWAPPED = (f"heading for {_msg38.watts(_S38_CEIL_W)}, under "
f"this month's {_msg38.watts(_S38_PROJ.projected_w)} peak")
print(f" projection {_S38_PROJ.projected_w:.0f} W against a "
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
print(f" {_S38_IDLE.explain()!r}")
check("PRECONDITION: the guard is IDLE with a projection to print, the ceiling "
"came from the MONTH PEAK rather than the cap, and the two operands are "
"DIFFERENT NUMBERS that RENDER differently. Equal operands would make the "
"transposed sentence identical and every check below decoration",
not _S38_IDLE.engaged and _S38_IDLE.projected_w is not None
and _S38_CEIL_FROM == CEILING_MONTH_PEAK
and _S38_PROJ.projected_w != _S38_CEIL_W
and f"{_S38_PROJ.projected_w:.0f}" != f"{_S38_CEIL_W:.0f}"
and _S38_PROJ.projected_w < _S38_CEIL_W)
check("⚠️ THE ORDERED PAIR ON THE CONTIGUOUS LITERAL. The PROJECTION is named "
"first and the CEILING second; a substring test for either number passes "
"under the transposition because both numbers are still in the sentence, "
"so only the ordered pair can tell the two slots apart (MUTATION.md "
"habit 14)", _S38_LITERAL in _S38_IDLE.explain())
check("...and the TRANSPOSED sentence is NOT what it says, so the check above "
"discriminates rather than decorates",
_S38_SWAPPED not in _S38_IDLE.explain())
check("...and the row still names WHERE the ceiling came from, which is the "
"difference between a cap an operator set and a peak the month already "
"banked", "under this month's" in _S38_IDLE.explain()
and "peak limit" not in _S38_IDLE.explain())
# THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints
# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status
# row at main.py:3313 is what a browser shows, and the action-log line at
# main.py:1951 is what the release edge writes.
_c38, _h38 = built(TMP / "s38pin.db")
_c38.peak_guard = _S38_IDLE
_rows38 = [r for r in _c38.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read "
"off the shipped `checks()` rather than off `explain()` a second time. "
"This is the surface the owner actually looks at",
len(_rows38) == 1 and _S38_LITERAL in _rows38[0]["text"]
and _S38_SWAPPED not in _rows38[0]["text"])
check("...and the row is green and un-warned, which is what makes the "
"transposed version dangerous rather than merely untidy: an OK row "
"carrying a projection 2713 W OVER its own ceiling is the one an "
"operator scrolls past", len(_rows38) == 1
and _rows38[0]["ok"] and not _rows38[0]["warn"]
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
# ==============================================================================
print()
print("13. SAFETY-45 - charge_headroom_w(): the number decide() used to throw "
"away")
# ---------------------------------------------------------------------------
# `decide()` computes `wanted = batt_w + excess` - the DISCHARGE that lands the
# quarter exactly on the ceiling - and returns `engaged=False, floor_w=None`
# the moment that number is not positive. A negative `wanted` is not "nothing
# to say": it is a permitted CHARGE of `-wanted` W, and discarding it is what
# let an arbitrage charge sit at max_w against a quarter it was blowing.
#
# ⚠️ NOTHING IN decide() CHANGES IN THIS CARD, AND THAT IS THE POINT. The
# `wanted <= 0` branch is 2026-08-20's anti-bang-bang rung and part 6's replay
# is what proves it still holds; this part grades a PURE READ of the number it
# already carried. 13g asserts the branch is still there rather than trusting
# the diff.
# ---------------------------------------------------------------------------
def _proj_at(net_w, elapsed):
return PeakTracker.project(averager_at(net_w, elapsed),
B + timedelta(seconds=elapsed))
def _decide_on(projection, *, batt_w, soc=SOC_OK, cap_w=CAP_W,
achievable_w=LIVE_MAX_W, reading=None, frozen=False):
return decide(projection, batt_w=batt_w, cap_w=cap_w, margin_w=0.0, reading=reading,
achievable_w=achievable_w, soc=soc, soc_floor=SOC_FLOOR,
max_hold_s=MAX_HOLD_S, frozen=frozen)
def _decide_at(net_w, elapsed, **kw):
return _decide_on(_proj_at(net_w, elapsed), **kw)
def _avg_held(base_w, elapsed, held_w, *, start=B, step=5.0):
"""`elapsed` seconds BANKED at `base_w`, then `held_w` standing at the edge.
⚠️ THE HISTORY AND THE HELD VALUE ARE SEPARATE FIXTURE AXES, and 13c is why
this helper exists rather than `averager_at(load - batt, ...)`. The averager
credits a sample from its own timestamp to the NEXT one, so the last sample
contributes ZERO accumulated seconds and only becomes `held`. That is the
real shape of a battery whose command changed a moment ago - the quarter's
banked seconds do not retroactively change - and a fixture that moved the
whole history with it would be grading a different quarter each time.
"""
a = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
t = 0.0
while t < elapsed:
a.add(sample(start + timedelta(seconds=t), base_w))
t += step
a.add(sample(start + timedelta(seconds=elapsed), held_w))
return a
# --- 13a. the number, and it is DERIVED rather than read back ---------------
# The house is flat at 2600 W under a 3500 W cap, the battery is idle, and 300 s
# of the block are banked. 600 s remain, so the block can still absorb
# 900 W x 900/600 = 1350 W of extra offtake and finish exactly on the ceiling.
_H45_GAP = 900.0
_H45_LOAD = CAP_W - _H45_GAP
_H45_EL, _H45_REM = 300.0, QUARTER_S - 300.0
_h45_idle = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0)
_h45_head = charge_headroom_w(_h45_idle)
# The CONSEQUENCE, computed from the fixture's own literals and not from the
# module's arithmetic: hold that charge for the rest of the block and the
# BILLED AVERAGE is the ceiling. This is the meaning of the number, which is a
# different statement from the expression that produces it (habit 2).
_H45_FINAL = (_H45_LOAD * _H45_EL
+ (_H45_LOAD + _h45_head) * _H45_REM) / QUARTER_S
print(f" house {_H45_LOAD:.0f} W under a {CAP_W:.0f} W cap, battery 0 W, "
f"{_H45_EL:.0f} s banked: engaged={_h45_idle.engaged}, "
f"wanted={_h45_idle.wanted_w:+.0f} W, headroom={_h45_head:.0f} W")
print(f" holding that charge for the remaining {_H45_REM:.0f} s bills the "
f"block at {_H45_FINAL:.1f} W against a {CAP_W:.0f} W ceiling")
check("the guard files NOTHING here - this is the shipped `wanted <= 0` branch, "
"untouched, and the state SAFETY-45 is about",
not _h45_idle.engaged and _h45_idle.floor_w is None
and _h45_idle.wanted_w is not None and _h45_idle.wanted_w < 0.0)
check(f"...and yet the quarter can afford {_h45_head:.0f} W of charging, which "
f"is the number that was being discarded. Graded by its CONSEQUENCE - "
f"the block lands on the ceiling to the watt - rather than by "
f"re-deriving -wanted_w with the module's own formula",
near(_H45_FINAL, CAP_W, 0.5))
check(f"...and it is none of the other numbers this fixture has: not the cap "
f"({CAP_W:.0f}), not max_w ({LIVE_MAX_W:.0f}), not the house "
f"({_H45_LOAD:.0f}) and not the raw gap under the ceiling "
f"({_H45_GAP:.0f}) - the 900/remaining gain is 1.5 here and a check that "
f"could not tell those apart would be decoration",
not any(near(_h45_head, v, 1.0)
for v in (CAP_W, LIVE_MAX_W, _H45_LOAD, _H45_GAP)))
# --- 13a2. AND THE IDENTITY HOLDS ACROSS THE WHOLE BLOCK --------------------
# ⚠️ THE AMPLIFICATION IS REAL AND IT IS REPORTED WITH ITS SIZE (habit 13).
# 900/remaining reaches 180 in the last five seconds, so the headroom there is
# enormous - and it is bounded in EFFECT, not in magnitude: at every elapsed,
# holding it bills the block at exactly the ceiling.
H45_ID = []
for _el in (0.0, 60.0, 300.0, 600.0, 870.0, 895.0):
_rem = QUARTER_S - _el
_hd = charge_headroom_w(_decide_at(_H45_LOAD, _el, batt_w=0.0))
H45_ID.append((_el, _hd,
(_H45_LOAD * _el + (_H45_LOAD + _hd) * _rem) / QUARTER_S))
print(" headroom across the block (elapsed, headroom W, billed W): "
+ ", ".join(f"({a:.0f}, {b:.0f}, {c:.1f})" for a, b, c in H45_ID))
check("at EVERY point in the block, holding the headroom for the rest of it "
"bills the block at exactly the ceiling - so the 162 kW answer five "
"seconds from the boundary and the 900 W answer at the top of it are the "
"same statement about the bill",
all(near(c, CAP_W, 0.5) for _, _, c in H45_ID))
check("...and the fixture SPANS the amplification rather than sitting in one "
f"corner of it: the headroom moves by a factor of "
f"{max(b for _, b, _ in H45_ID) / min(b for _, b, _ in H45_ID):.0f} "
f"across those six points",
max(b for _, b, _ in H45_ID) > 100.0 * min(b for _, b, _ in H45_ID))
# --- 13b. HOLDING implies ZERO OR UNKNOWN, swept - the family, not one case -
# ⚠️ THE PROPERTY THAT MAKES THIS SAFE TO HAND A STRATEGY. Every state that
# files a claim has `wanted > 0`, `wanted is None` or `blind`, so this function
# can never hand a caller permission the guard's own `lo` is at that instant
# refusing. Swept over the same grid part 5's no-negative-floor proof uses,
# widened with the two ways a cycle can fail to measure at all.
H45_N = H45_BAD = H45_POS = H45_NONE = H45_ZERO = 0
for _load in (0.0, CAP_W - 500.0, CAP_W, CAP_W + 300.0, CAP_W + 3000.0,
LIVE_MAX_W * 3):
for _el in (30.0, 450.0, 870.0):
for _bw in (-LIVE_MAX_W, -2000.0, 0.0, 400.0, 4000.0, None):
for _sc in (None, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0):
for _fz in (False, True):
_d45 = _decide_at(_load, _el, batt_w=_bw, soc=_sc,
frozen=_fz)
_hr = charge_headroom_w(_d45)
H45_N += 1
if _hr is None:
H45_NONE += 1
elif _hr > 0.0:
H45_POS += 1
else:
H45_ZERO += 1
if _d45.engaged and _hr not in (None, 0.0):
H45_BAD += 1
print(f" swept {H45_N} (load x elapsed x battery x SoC x frozen) "
f"combinations: {H45_BAD} ENGAGED decisions answering a positive "
f"headroom; {H45_POS} permit charging, {H45_ZERO} answer 0 W, "
f"{H45_NONE} unmeasured")
check(f"NOT ONE of the {H45_N} combinations answers a positive headroom while "
f"the guard is ENGAGED. A decision whose `lo` is at that instant "
f"forbidding charging can never be read as permission to charge",
H45_BAD == 0 and H45_N > 800)
check("...and the sweep is vacuous in no direction: it reaches decisions that "
f"permit charging ({H45_POS}), decisions that permit none ({H45_ZERO}) "
f"and decisions nothing measured ({H45_NONE}), so all three answers are "
f"exercised", H45_POS > 0 and H45_ZERO > 0 and H45_NONE > 0)
# --- 13c. IT IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE TO IT
# CANNOT OSCILLATE -------------------------------------------------
# Raise the charge by dW and the METER rises by dW, so `excess` rises by exactly
# dW while `batt_w` falls by dW: `wanted` does not move. The headroom is a
# FEED-FORWARD number - a function of the house, the ceiling and the seconds
# already banked - so consuming it adds no feedback path around the plant.
# Without this property the whole fix would be one loop replacing another.
H45_INV, H45_EX = [], []
for _bw in (-LIVE_MAX_W, -2000.0, -500.0, 0.0, 500.0, 2000.0):
# ONE banked history, and the battery's change visible only in the HELD
# value - which is what a command given a moment ago actually looks like.
_d = _decide_on(PeakTracker.project(
_avg_held(_H45_LOAD, _H45_EL, _H45_LOAD - _bw),
B + timedelta(seconds=_H45_EL)), batt_w=_bw)
H45_INV.append(charge_headroom_w(_d))
H45_EX.append(round(_d.excess_w, 1))
print(f" same house, same banked quarter, battery {-LIVE_MAX_W:+.0f}.."
f"{2000.0:+.0f} W: headroom {[round(v, 3) for v in H45_INV]}")
print(f" ...while the EXCESS those runs see is {H45_EX}")
check("the headroom does NOT move when the battery moves across the whole "
"range of what it can do - it is a property of the HOUSE and the "
"CEILING, not of what the battery happens to be doing, which is what "
"makes a trade sized from it feed-forward rather than a second loop "
"around the plant",
max(H45_INV) - min(H45_INV) < 1.0)
check(f"...and the fixture SEPARATES it from the two quantities it is built "
f"out of: the battery moved {LIVE_MAX_W + 2000.0:.0f} W and the excess "
f"with it, over {len(set(H45_EX))} distinct values, so an implementation "
f"answering `-batt_w` or `abs(excess)` would give six different answers "
f"here (habit 4)",
len(set(H45_EX)) == len(H45_EX)
and len(set(round(v, 3) for v in H45_INV)) == 1)
# --- 13d. AND A BOUNDARY HANDS IT BACK AT THE AFFORDABLE RATE, NOT AT max_w --
# ⚠️ SAFETY-45's SECOND HALF, AND THE CLAIM IS NOT "IT DOES NOT CHANGE".
# `project()` resets to the held level at `elapsed = 0`, and the headroom
# collapses with it - from 162 kW to 900 W here. What the card is about is WHAT
# IT COLLAPSES TO: the house and the cap, and nothing else. The behaviour being
# removed re-armed at `max_w` regardless of either.
_H45_END = charge_headroom_w(_decide_at(_H45_LOAD, 895.0, batt_w=0.0))
_H45_TOP = charge_headroom_w(_decide_at(_H45_LOAD, 0.0, batt_w=0.0))
print(f" across a boundary, same house: {_H45_END:.0f} W with 5 s of the "
f"old quarter left -> {_H45_TOP:.0f} W with the new one 0 s old, against "
f"a max_w of {LIVE_MAX_W:.0f} W")
check(f"at the top of the new quarter the headroom is exactly the ceiling less "
f"the house ({CAP_W:.0f} - {_H45_LOAD:.0f} = {_H45_GAP:.0f} W). A trade "
f"refused at 14:59 resumes at 15:00 at the rate the new quarter can "
f"afford - the projection resets, this does not reset TO max_w",
near(_H45_TOP, _H45_GAP, 1.0) and _H45_TOP < LIVE_MAX_W)
check("...and the check is not passing because nothing moved: the headroom on "
"the other side of that boundary is two orders of magnitude larger, and "
"the PROJECTION really did reset",
_H45_END > 100.0 * _H45_TOP and _proj_at(_H45_LOAD, 0.0).elapsed_s < 1.0)
# --- 13e. the unmeasured answers, and None is NOT unlimited -----------------
_h45_nobatt = _decide_at(_H45_LOAD, _H45_EL, batt_w=None)
_h45_blind = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0, frozen=True)
print(f" no battery reading: {charge_headroom_w(_h45_nobatt)}; "
f"BLIND: {charge_headroom_w(_h45_blind)}; no decision at all: "
f"{charge_headroom_w(None)}")
check("a guard that never ran answers None, not a number - `meter_source: off` "
"reaches this with no decision at all and a caller that read it as a "
"large headroom would be guard_state()'s None-is-not-False collapse one "
"layer down", charge_headroom_w(None) is None)
check("...and a cycle with NO BATTERY MEASUREMENT answers None too: there is "
"nothing to size a floor from, so there is nothing to size a trade from "
"either", charge_headroom_w(_h45_nobatt) is None
and _h45_nobatt.wanted_w is None)
check("...and BLIND answers 0.0 - a NUMBER, and the number is no charging. "
"The claim it files already says exactly that at `floor 0 W`, and this "
"agrees with it rather than restating it",
charge_headroom_w(_h45_blind) == 0.0 and _h45_blind.blind
and _h45_blind.floor_w == 0.0)
check("...and the three are TOLD APART: blind is 0.0 and the two unmeasured "
"states are None, so a caller can refuse for the reason that is true "
"rather than for whichever fired first",
charge_headroom_w(_h45_blind) is not None
and charge_headroom_w(_h45_nobatt) is None)
# --- 13f. the SoC floor does not become charging permission -----------------
# ⚠️ THE ONE ENGAGED STATE WHERE THE GUARD IS DELIBERATELY NOT DISCHARGING.
# SAFETY-24's floor-limited decision files `lo = 0.0` - "charging stays
# blocked" - on a quarter that IS over the ceiling. If that read as headroom,
# an empty pack in a blown quarter would be told to buy.
_h45_lim = _decide_at(CAP_W + 2000.0, _H45_EL, batt_w=0.0, soc=SOC_FLOOR - 1.0)
print(f" floor-limited over a blown quarter: engaged={_h45_lim.engaged}, "
f"floor_limited={_h45_lim.floor_limited}, wanted="
f"{_h45_lim.wanted_w:+.0f} W, headroom={charge_headroom_w(_h45_lim)}")
check("a FLOOR-LIMITED decision - the guard wanted a discharge and SoC refused "
"to size it - answers 0.0 headroom. The quarter is over the ceiling; the "
"pack being empty is not a reason to fill it from the grid in it",
_h45_lim.floor_limited and _h45_lim.engaged
and charge_headroom_w(_h45_lim) == 0.0)
# --- 13g. decide() ITSELF IS UNCHANGED, PINNED -----------------------------
# ⚠️ MUTATION.md habit 4, in the direction a diff cannot cover. The whole safety
# argument of this card is "the 2026-08-20 rung is still there and part 6 still
# grades it". That is a claim about the SOURCE, so it is pinned on the source -
# and on the BEHAVIOUR, because a shape pin alone would pass a rung that had
# been inverted.
_H45_SRC = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8")
_H45_TREE = ast.parse(_H45_SRC)
_h45_fn = first((n for n in ast.walk(_H45_TREE)
if isinstance(n, ast.FunctionDef) and n.name == "decide"),
"def decide in peakguard.py")
_H45_RUNG = [n for n in ast.walk(_h45_fn) if isinstance(n, ast.If)
and ast.unparse(n.test) == "wanted <= 0.0"]
check("the `wanted <= 0.0` rung is still in decide(), spelled exactly that "
"way, on the AST rather than as a substring - it is the rung whose "
"removal put 2026-08-20's quarter 49 W over the limit after seventeen "
"engage/release cycles in 900 s", len(_H45_RUNG) == 1)
check("...and it still DISENGAGES rather than merely reporting: the fixture "
"13a is built on reaches it and files no claim, so the pin above is not "
"grading a rung that has been inverted under it",
peakguard.claim(_h45_idle) is None)
_h45_head_fn = first((n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef)
and n.name == "charge_headroom_w"),
"def charge_headroom_w in peakguard.py")
check("...and charge_headroom_w() is a PURE READ of the decision - one "
"positional parameter, no projection, no clock and no options - so it "
"cannot become a second answer to the question decide() already answers",
[a.arg for a in _h45_head_fn.args.args] == ["decision"]
and not _h45_head_fn.args.kwonlyargs)
# ==============================================================================
print()
shutil.rmtree(TMP, ignore_errors=True)
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")