SAFETY-52's hold applied per unit at N>1 (owner, 2026-09-27, option R). A unit that gives < 100 W for 12 cycles in one direction while asked > 200 W, or trips its stale-reading gate on a ~0 W reading, gets a P_SAFETY `refused` unit limit closing that direction, so the allocator hands its share to the others within their own limits. Probe: a +-200 W pin every 15 min, one unit at a time, only while the site asks 200 W that way. All units refusing: the site law holds 0 W. Exits reseed the site target from the wire. INFO plus one WARNING (REFUSES_ABOVE_CUT_OUT) above the configured cut-out; SAFETY-42/43 no longer report a refusing unit. N=1 unchanged. test_refusal_units.py: cases a/b/c on flat and jitter registers against the old behaviour, probes, recovery, exits; mutations U1-U13 all red. TEST-19 no-fault campaign: 0 engagements in 5280 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6285 lines
360 KiB
Python
6285 lines
360 KiB
Python
"""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, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||
e_edge = excess_w(p_edge, CEIL)
|
||
print(f" held exactly AT the ceiling for 450 s: projected "
|
||
f"{p_edge.projected_w:.2f} W, excess {e_edge:.3f} W, "
|
||
f"floor {'(none)' if d_edge.floor_w is None else f'{d_edge.floor_w:.1f} W'}, "
|
||
f"battery measured {BATT_NOW:.0f} W")
|
||
check("at the engagement boundary the excess is zero, so the floor is exactly "
|
||
"the battery's own measured power - i.e. 'carry on'. The law is CONTINUOUS "
|
||
"and there is no step for a hysteresis constant to damp",
|
||
near(e_edge, 0.0, 1e-6) and near(d_edge.floor_w, BATT_NOW, 1e-6))
|
||
# THE OSCILLATION, and it is the reason the threshold is on the floor rather
|
||
# than on the excess. Re-derived here rather than described: a guard that
|
||
# released as soon as `excess <= 0` hands the actuator back at the exact instant
|
||
# it is working, and the load springs straight back.
|
||
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||
would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0
|
||
# ⚠️ `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, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||
check("...and it DOES release once the load falls under what the quarter can "
|
||
"afford, so 'threshold the floor' is not 'never let go'",
|
||
not d_gone.engaged and d_gone.floor_w is None)
|
||
|
||
# A hair over, and the floor is the battery plus the excess. Not a constant.
|
||
a_over = averager_at(CEIL + 200.0, 450.0)
|
||
p_over = PeakTracker.project(a_over, B + timedelta(seconds=450))
|
||
d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||
print(f" 200 W over for 450 s: {d_over.explain()}")
|
||
check("a quarter projected over the ceiling engages, and the floor is the "
|
||
"MEASURED battery power plus the derived excess",
|
||
d_over.engaged
|
||
and near(d_over.floor_w, BATT_NOW + excess_w(p_over, CEIL), 1e-6))
|
||
check("...and the floor moves with the battery measurement, so it is a claim "
|
||
"about delivered power rather than a fixed setpoint",
|
||
not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR).floor_w,
|
||
d_over.floor_w, 1.0))
|
||
|
||
# THE WIND-UP ARGUMENT, made concrete. A floor built on INTENT re-adds its own
|
||
# unmet ask every cycle; one built on delivered power does not.
|
||
stuck_batt = 0.0 # the inverter is not following
|
||
intent = 0.0
|
||
delivered_floor = []
|
||
intent_floor = []
|
||
for _ in range(5):
|
||
d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||
delivered_floor.append(d_del.floor_w)
|
||
intent = intent + excess_w(p_over, CEIL) # what the intent form would do
|
||
intent_floor.append(intent)
|
||
print(f" five cycles with the inverter not following: delivered-power floor "
|
||
f"{[f'{v:.0f}' for v in delivered_floor]}, intent floor "
|
||
f"{[f'{v:.0f}' for v in intent_floor]}")
|
||
check("a floor sized from DELIVERED battery power is stationary while the "
|
||
"inverter fails to follow; one sized from the standing command winds up",
|
||
len(set(f"{v:.3f}" for v in delivered_floor)) == 1
|
||
and 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, reading=None,
|
||
achievable_w=min(LIVE_MAX_W, DEV_MAX), max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||
r6 = resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "rating"),
|
||
Claim.limit("supervised", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "max_w"),
|
||
Claim.set("loop", P_LOOP, -200.0, "grid"),
|
||
peakguard.claim(d_absurd)])
|
||
print(f" an absurd projection ({p_absurd.projected_w:.0f} W with 30 s left) "
|
||
f"wanted {d_absurd.wanted_w:.0f} W, floor clamped to "
|
||
f"{d_absurd.floor_w:.0f} W; {r6.explain()}")
|
||
check("the floor is clamped to what the arbiter could actually grant, so the "
|
||
"claim can NEVER produce lo > hi - a contradiction resolves to 0 W, i.e. "
|
||
"the battery parked in the one minute of the month it should be pulling "
|
||
"hardest", not r6.contradiction and near(r6.target_w, DEV_MAX, 1e-9))
|
||
check("...and the decision SAYS it saturated rather than reporting the clamped "
|
||
"number as if it were the answer",
|
||
d_absurd.saturated and d_absurd.wanted_w > d_absurd.floor_w)
|
||
check("...while the un-clamped case does not claim saturation",
|
||
not d_over.saturated)
|
||
|
||
|
||
# ==============================================================================
|
||
print()
|
||
print("3. the ceiling, and what an unreadable peak store does to it")
|
||
|
||
obs_hi = PeakReading(OBSERVED, "2026-08", peak_w=CAP_W + 1513.0,
|
||
quarter_start=B)
|
||
obs_lo = PeakReading(OBSERVED, "2026-08", peak_w=CAP_W - 800.0,
|
||
quarter_start=B)
|
||
norec = PeakReading(NO_RECORD, "2026-08")
|
||
unread = PeakReading(NOT_OBSERVED, "2026-08", detail="disk gone")
|
||
zero = PeakReading(OBSERVED, "2026-08", peak_w=0.0, quarter_start=B)
|
||
|
||
for name, rd in (("OBSERVED above the cap", obs_hi),
|
||
("OBSERVED below the cap", obs_lo),
|
||
("NO_RECORD", norec), ("NOT_OBSERVED", unread),
|
||
("OBSERVED at 0 W", zero)):
|
||
c_w, c_from = quarter_ceiling_w(CAP_W, rd)
|
||
print(f" {name:<24} -> ceiling {c_w:.0f} W ({c_from})")
|
||
|
||
check("a month whose peak ALREADY stands above the cap raises the ceiling to "
|
||
"it - the bill for that month is already that number, and holding every "
|
||
"later quarter to the cap buys nothing while costing real charging",
|
||
quarter_ceiling_w(CAP_W, obs_hi) == (CAP_W + 1513.0, CEILING_MONTH_PEAK))
|
||
check("a month peak UNDER the cap does not lower the ceiling below the cap",
|
||
quarter_ceiling_w(CAP_W, obs_lo) == (CAP_W, CEILING_CAP))
|
||
check("NO_RECORD leaves the ceiling at the cap",
|
||
quarter_ceiling_w(CAP_W, norec) == (CAP_W, CEILING_CAP))
|
||
check("...and so does NOT_OBSERVED: a store that did not answer makes this "
|
||
"guard STRICTER, never looser. That direction is the whole point - the "
|
||
"number a failed read would have become is a zero peak, and a zero peak "
|
||
"reads as 'quiet month, full freedom'",
|
||
quarter_ceiling_w(CAP_W, unread)[0] <= quarter_ceiling_w(CAP_W, norec)[0]
|
||
and quarter_ceiling_w(CAP_W, unread) == (CAP_W, CEILING_CAP))
|
||
# ⚠️ GRADED ON THE STATE, NOT ON `peak_w is not None`. Today a NOT_OBSERVED
|
||
# reading always carries `peak_w=None`, so a `peak_w is not None` test happens to
|
||
# behave - and dropping `is_observed` from quarter_ceiling_w survives every other
|
||
# check in this file. That coincidence is exactly the thing not to depend on: a
|
||
# future PeakReading that carried a LAST-KNOWN peak alongside NOT_OBSERVED - the
|
||
# obvious next feature - would silently raise the ceiling off a read that failed.
|
||
# So the fixture is that reading, built here, and the rule graded is the state.
|
||
stale = PeakReading(NOT_OBSERVED, "2026-08", peak_w=CAP_W + 1500.0,
|
||
quarter_start=B, detail="disk gone, last known peak kept")
|
||
print(f" NOT_OBSERVED CARRYING a {stale.peak_w:.0f} W last-known peak "
|
||
f"-> ceiling {quarter_ceiling_w(CAP_W, stale)[0]:.0f} W "
|
||
f"({quarter_ceiling_w(CAP_W, stale)[1]})")
|
||
check("a NOT_OBSERVED reading that CARRIES a peak still does not raise the "
|
||
"ceiling - the gate is the reading's STATE, not whether a number happens "
|
||
"to be attached to it. A failed read is not an observation",
|
||
quarter_ceiling_w(CAP_W, stale) == (CAP_W, CEILING_CAP))
|
||
check("...and the relaxation is granted ONLY by an observation: the three "
|
||
"non-observations agree, and only the OBSERVED-above-cap case differs",
|
||
len({quarter_ceiling_w(CAP_W, r)[0]
|
||
for r in (norec, unread, zero)}) == 1
|
||
and quarter_ceiling_w(CAP_W, obs_hi)[0] > CAP_W)
|
||
|
||
# THE ACCEPTANCE HALF, and it is graded on a REAL store that cannot open.
|
||
broken = PeakStore(str(TMP / "no-such-volume" / "tariff.db"))
|
||
rd_broken = broken.peak(B)
|
||
tr_broken = PeakTracker(broken, cap_w=CAP_W)
|
||
print(f" a real PeakStore on a missing volume: state={rd_broken.state}, "
|
||
f"headroom_w()={tr_broken.headroom_w(B)}")
|
||
check("the real store answers NOT_OBSERVED rather than a peak of 0 W",
|
||
rd_broken.state == NOT_OBSERVED and rd_broken.peak_w is None)
|
||
d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=rd_broken,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||
print(f" {d_broken.explain()}")
|
||
check("AND THE GUARD STILL ENGAGES ON IT. An unreadable peak must not disarm "
|
||
"the cap - 'no peak recorded' is precisely the reading that would, and it "
|
||
"is the one bug that would leave every other check in this file green",
|
||
d_broken.engaged and near(d_broken.floor_w, d_over.floor_w, 1e-9))
|
||
check("...and it does so at the CAP ceiling, not at some number derived from "
|
||
"the store that could not answer",
|
||
d_broken.ceiling_w == CAP_W and d_broken.ceiling_from == CEILING_CAP)
|
||
check("`headroom_w()` is None on that same store, which is why this module does "
|
||
"not consume it: a consumer that defaulted that None to the cap would "
|
||
"have full headroom and no guard", tr_broken.headroom_w(B) is None)
|
||
src = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8")
|
||
_calls = {n.func.attr for n in ast.walk(ast.parse(src))
|
||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)}
|
||
check("...and peakguard.py calls no headroom accessor at all - graded on the "
|
||
"parsed call syntax, so deleting the paragraph above would not satisfy it",
|
||
"headroom_w" not in _calls and "warning_frac" not in _calls)
|
||
|
||
|
||
# ==============================================================================
|
||
print()
|
||
print("4. blindness: three ways a projection is not evidence")
|
||
|
||
# (a) NO OPEN BLOCK. A fresh averager projects 0 W - under every ceiling.
|
||
fresh = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||
p_fresh = PeakTracker.project(fresh, B)
|
||
d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||
print(f" fresh averager: projected {p_fresh.projected_w:.0f} W, "
|
||
f"block_start={p_fresh.block_start}; {d_fresh.explain()}")
|
||
check("a fresh averager's projection is 0 W, i.e. comfortably under any "
|
||
"ceiling - the degenerate value, present and quiet",
|
||
near(p_fresh.projected_w, 0.0) and p_fresh.projected_w < CAP_W)
|
||
check("...and the guard grades block_start BEFORE the number, so it reports "
|
||
"BLIND rather than 'the quarter is fine'",
|
||
d_fresh.blind == BLIND_NO_BLOCK and d_fresh.projected_w is None)
|
||
check("...and the blind action is a floor of 0 W - no charging while the "
|
||
"billed quantity cannot be measured - which is one-directional: "
|
||
"forbidding import can only lower delivered offtake, never raise it",
|
||
d_fresh.engaged and near(d_fresh.floor_w, 0.0))
|
||
r_blind = resolve(SAFE + [Claim.set("loop", P_LOOP, -2500.0, "grid"),
|
||
peakguard.claim(d_fresh)])
|
||
print(f" a loop asking to charge 2500 W while blind: {r_blind.explain()}")
|
||
check("...and through the real arbiter that stops the charge dead at 0 W "
|
||
"without forcing any discharge", near(r_blind.target_w, 0.0)
|
||
and not r_blind.contradiction)
|
||
|
||
# (b) FROZEN - the case SAFETY-07 exists to backstop, and the worst of the three:
|
||
# coverage is PERFECT and the projection is confident and wrong.
|
||
frozen_av = averager_at(-4000.0, 600.0) # meter stuck reading 4 kW of EXPORT
|
||
p_frozen = PeakTracker.project(frozen_av, B + timedelta(seconds=600))
|
||
RAILED = LIVE_MAX_W # the loop has wound to the rail
|
||
is_frozen = looks_frozen(FREEZE_S + 1.0, -RAILED, LIVE_MAX_W,
|
||
FreezeWatchdogConfig(hold_s=FREEZE_S))
|
||
d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR,
|
||
frozen=is_frozen)
|
||
print(f" meter stuck at -4000 W with the command at {-RAILED:+.0f} W: "
|
||
f"projected {p_frozen.projected_w:.0f} W, coverage "
|
||
f"{p_frozen.covered_frac * 100:.0f} %, looks_frozen={is_frozen}")
|
||
print(f" {d_frozen.explain()}")
|
||
check("a frozen meter reading export projects a CONFIDENT ZERO at full "
|
||
"coverage - the projection cannot see this failure at all",
|
||
near(p_frozen.projected_w, 0.0) and p_frozen.covered_frac >= 1.0)
|
||
check("...so blindness is decided by freeze.py's SHIPPED predicate, not by the "
|
||
"projection, and not by a second copy of the conjunction here", is_frozen)
|
||
check("...and the guard goes blind and forbids the import that is railing the "
|
||
"meter", d_frozen.blind == BLIND_FROZEN and near(d_frozen.floor_w, 0.0))
|
||
r_fz = resolve(SAFE + [Claim.set("loop", P_LOOP, -RAILED, "grid"),
|
||
peakguard.claim(d_frozen)])
|
||
print(f" the railed loop, arbitrated: {r_fz.explain()}")
|
||
check(f"THE BACKSTOP: SAFETY-18's runaway - a {-RAILED:+.0f} W grid charge held "
|
||
f"for a quarter - is cut to 0 W. freeze.py's table puts the rail at 75 s; "
|
||
f"the alarm alone arrives at {FREEZE_S:.0f} s of a {QUARTER_S} s billing "
|
||
f"quarter and stops nothing", near(r_fz.target_w, 0.0))
|
||
check("...and the same command NOT frozen is left alone, so the blind branch is "
|
||
"not simply always on",
|
||
near(resolve(SAFE + [Claim.set("loop", P_LOOP, -RAILED, "grid"),
|
||
peakguard.claim(decide(
|
||
p_frozen, batt_w=-RAILED, cap_w=CAP_W,
|
||
reading=None, achievable_w=LIVE_MAX_W,
|
||
max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR, frozen=False))
|
||
or Claim.limit("noop", P_PEAK)]).target_w,
|
||
-RAILED, 1e-9))
|
||
|
||
# (c) COVERAGE. A real hole in the telegram stream, longer than max_hold_s.
|
||
GAP = MAX_HOLD_S * 3
|
||
gapped = averager_at(4200.0, 600.0, gap_at=200.0, gap_s=GAP)
|
||
p_gap = PeakTracker.project(gapped, B + timedelta(seconds=600))
|
||
clean = averager_at(4200.0, 600.0)
|
||
p_clean = PeakTracker.project(clean, B + timedelta(seconds=600))
|
||
d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||
print(f" a {GAP:.0f} s hole at 4200 W: uncovered {uncovered_s(p_gap):.0f} s, "
|
||
f"projected {p_gap.projected_w:.0f} W vs {p_clean.projected_w:.0f} W with "
|
||
f"no hole")
|
||
check("a gap makes the projection read LOW - the under-guarding direction, "
|
||
"which is why it may not simply be graded as a number",
|
||
p_gap.projected_w < p_clean.projected_w - 1.0)
|
||
check("...and `uncovered_s` is derived from what Projection already carries",
|
||
near(uncovered_s(p_gap), GAP - MAX_HOLD_S, MAX_HOLD_S))
|
||
check("...and the guard goes blind on it, at the threshold p1.py already uses "
|
||
f"to stop trusting a held sample ({MAX_HOLD_S:.0f} s) rather than at a "
|
||
f"new number", d_gap.blind is not None
|
||
and d_gap.blind.startswith(BLIND_COVERAGE))
|
||
small = averager_at(4200.0, 600.0, gap_at=200.0, gap_s=MAX_HOLD_S / 2)
|
||
check("...while a hole SHORTER than one hold window is not blindness - the "
|
||
"averager covered it, so there is nothing unmeasured",
|
||
blindness(PeakTracker.project(small, B + timedelta(seconds=600)),
|
||
MAX_HOLD_S, False) is None)
|
||
|
||
check("blindness is decided before the projection is compared to anything: the "
|
||
"frozen case would otherwise have read 0 W and idled, and the fresh case "
|
||
"would have read 0 W and idled",
|
||
d_fresh.engaged and d_frozen.engaged
|
||
and d_fresh.projected_w is None and d_frozen.projected_w is None)
|
||
|
||
|
||
# ==============================================================================
|
||
print()
|
||
print("5. project()'s defaulted read - TEST-13 item 4, fixed here")
|
||
|
||
HELD = 4800.0
|
||
held_av = averager_at(HELD, 300.0)
|
||
p_held = PeakTracker.project(held_av, B + timedelta(seconds=300))
|
||
check("QuarterAverager exposes the held value as a documented public property",
|
||
hasattr(held_av, "held_net_w") and near(held_av.held_net_w, HELD))
|
||
check("...and Projection carries it, so a consumer can see WHAT was "
|
||
"extrapolated and not only the extrapolation", near(p_held.held_w, HELD))
|
||
|
||
|
||
class _Renamed:
|
||
"""An averager whose held value has been renamed - the TEST-13 scenario."""
|
||
|
||
block_start = B
|
||
elapsed_s = 300.0
|
||
partial_ws = HELD * 300.0
|
||
max_hold_s = MAX_HOLD_S
|
||
|
||
|
||
raised = None
|
||
try:
|
||
PeakTracker.project(_Renamed(), B + timedelta(seconds=300))
|
||
except AttributeError as err:
|
||
raised = err
|
||
print(f" project() against a renamed averager: "
|
||
f"{'AttributeError: ' + str(raised) if raised else 'NO ERROR - silent'}")
|
||
check("a renamed held value now raises AttributeError on the first cycle. The "
|
||
"shipped read was `getattr(averager, '_last_net', 0.0)`: a private name "
|
||
"with a fallback, which fails QUIET", raised is not None)
|
||
|
||
# ...and the direction of the quiet failure is the reason it mattered here.
|
||
OLD = (p_held.partial_ws if hasattr(p_held, "partial_ws")
|
||
else HELD * 300.0) # acc, which the old form would still have had
|
||
old_projected = (HELD * 300.0 + 0.0 * (QUARTER_S - 300.0)) / QUARTER_S
|
||
d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||
print(f" same fixture: shipped projection {p_held.projected_w:.0f} W "
|
||
f"(engaged={d_true.engaged}), the defaulted form's "
|
||
f"{old_projected:.0f} W (would engage={old_projected > CAP_W})")
|
||
check("...and it fails in the DISARMING direction: crediting 0 W held reads "
|
||
f"{p_held.projected_w - old_projected:.0f} W LOW on this fixture, which "
|
||
f"is the difference between a guard that engages and one that does not",
|
||
p_held.projected_w > CAP_W >= old_projected and d_true.engaged)
|
||
|
||
|
||
# ==============================================================================
|
||
print()
|
||
print("6. the replay: what the guard does to the two days that exist")
|
||
|
||
REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
|
||
check("the committed replay fixtures are present", len(REPLAY) >= 2)
|
||
|
||
|
||
def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W, pack=None,
|
||
soc_floor=None):
|
||
"""Drive one capture through the real ingest, tracker and guard.
|
||
|
||
See the module docstring for the model and why its baseline is pessimistic.
|
||
|
||
⚠️ SAFETY-24's `pack` IS A SECOND MODEL, AND IT IS STATED HERE RATHER THAN
|
||
HIDDEN. The captures carry meter power only, so the pack is simulated:
|
||
|
||
pack = (usable_wh, start_soc_pct, bms_floor_pct)
|
||
|
||
* SoC falls by the DELIVERED discharge, integrated over the real telegram
|
||
timestamps - the same integral that already produces `wh`, so the two
|
||
cannot disagree;
|
||
* the BMS is modelled as a HARD CUT at `bms_floor_pct`: at or below it the
|
||
modelled battery delivers 0 W whatever the guard asks for. Without that,
|
||
the "before this card" run would be unphysical and its comparison
|
||
worthless - the BMS really does stop the pack, and the finding this card
|
||
answers is that it is then the ONLY thing that does;
|
||
* nothing recharges the pack. One day, one direction. That is pessimistic
|
||
in the same deliberate way the zero-baseline discharge is: it isolates
|
||
what the peak guard alone spends.
|
||
|
||
`pack=None` pins the SoC at SOC_OK and reproduces SAFETY-07's numbers
|
||
exactly, which is what parts 6's existing checks grade.
|
||
"""
|
||
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
|
||
store = fresh_store(f"{path.stem}-{'g' if guarded else 'u'}"
|
||
f"{'' if pack is None else f'-p{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, 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())
|
||
net_w = CAP_W + want_floor_w * remaining / QUARTER_S
|
||
for i in range(0, span + 1, 5):
|
||
ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w))
|
||
# ⚠️ 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, 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_soc_floor"])
|
||
# Graded on the parsed call syntax, not on the text: the module docstring names
|
||
# `peak_cap_w` while arguing that it does not read it, so a grep would be
|
||
# satisfied by deleting the explanation and would fail on writing one.
|
||
_decide = 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, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK,
|
||
soc_floor=SOC_FLOOR)
|
||
d_lim = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||
soc=SOC_FLOOR, soc_floor=SOC_FLOOR)
|
||
d_unread = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=None,
|
||
soc_floor=SOC_FLOOR)
|
||
print(f" with battery: {d_ok.explain()}")
|
||
print(f" at the floor: {d_lim.explain()}")
|
||
check("with battery to spare the guard sizes the forced discharge exactly as "
|
||
"SAFETY-07 always did - this card changes nothing above the floor",
|
||
d_ok.engaged and not d_ok.floor_limited and near(d_ok.floor_w, WANTED, 1e-6))
|
||
check("at the floor it STOPS, and the floor it files is 0 W - not the discharge "
|
||
"it wanted, and not a negative number",
|
||
d_lim.engaged and d_lim.floor_limited and near(d_lim.floor_w, 0.0))
|
||
check("...and it REPORTS being floor-limited rather than silently under-"
|
||
"shaving: the decision carries the flag, the reason it bound, the SoC it "
|
||
"read and the floor it read",
|
||
d_lim.floor_limited and d_lim.soc_floor_reason == FLOOR_AT_SOC_FLOOR
|
||
and near(d_lim.soc, SOC_FLOOR) and near(d_lim.soc_floor, SOC_FLOOR))
|
||
check("...and it still says what it WOULD have taken, so the under-shave is a "
|
||
"number rather than a silence",
|
||
near(d_lim.wanted_w, WANTED, 1e-6) and d_lim.wanted_w > d_lim.floor_w)
|
||
check("...and its one-line explanation tells the operator the consequence in "
|
||
"the words that matter - the quarter is going over the ceiling",
|
||
"FLOOR_LIMITED" in d_lim.explain()
|
||
and "will go over the 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, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||
soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR)
|
||
print(f" an EMPTY pack on a quiet quarter: {d_quiet.explain()}")
|
||
check("an empty pack on a quarter that does not need shaving is IDLE, not "
|
||
"floor-limited. The SoC is only graded where a forced discharge is "
|
||
"actually being asked for - grading it earlier would raise the alarm on "
|
||
"every quiet cycle of the day",
|
||
not d_quiet.engaged and not d_quiet.floor_limited
|
||
and d_quiet.floor_w is None)
|
||
# And the BLIND branch is untouched: it never forces a discharge, so there is
|
||
# nothing for an energy floor to bound there.
|
||
d_blind_low = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||
soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR, frozen=True)
|
||
check("...and an empty pack does not disturb the BLIND branch either: SAFETY-"
|
||
"18's cut is still a 0 W floor, still blocks charging, and is still not "
|
||
"reported as an energy problem",
|
||
d_blind_low.engaged and near(d_blind_low.floor_w, 0.0)
|
||
and d_blind_low.blind == BLIND_FROZEN and not d_blind_low.floor_limited)
|
||
|
||
# --- the claim, through the real arbiter ------------------------------------
|
||
lc = peakguard.claim(d_lim)
|
||
print(f" floor-limited claim: {lc.source} {lc.kind} lo={lc.lo:.0f} "
|
||
f"hi={lc.hi} reason={lc.reason!r}")
|
||
r_charge = resolve(SAFE + [Claim.set("loop", P_LOOP, -1500.0, "grid"), lc])
|
||
r_disch = resolve(SAFE + [Claim.set("loop", P_LOOP, 1800.0, "grid"), lc])
|
||
r_stop = resolve(SAFE + [Claim.set("safety", P_SAFETY, 0.0, "stopped"), lc])
|
||
print(f" loop wants to charge 1500 W: {r_charge.explain()}")
|
||
print(f" loop wants to discharge 1800 W: {r_disch.explain()}")
|
||
check("a floor-limited guard STILL blocks charging - 0 W is one-directional, "
|
||
"it can only ever lower delivered offtake, and adding import in the "
|
||
"quarter that sets the bill is the one thing it must not permit",
|
||
near(r_charge.target_w, 0.0) and not r_charge.contradiction)
|
||
check("...and it does NOT pull the loop off a discharge it is already doing. A "
|
||
"`lo` limit is structurally incapable of capping discharge, which is why "
|
||
"the action here is 0 W and never a negative floor",
|
||
near(r_disch.target_w, 1800.0, 1e-9))
|
||
check("...and every P_SAFETY stop still outranks it. This is an ENERGY bound, "
|
||
"not a trip: 11.2's table is unchanged by this card",
|
||
near(r_stop.target_w, 0.0) and not r_stop.contradiction)
|
||
check("...and its arbiter reason names the SoC floor rather than reusing "
|
||
"'blind' or 'quarter peak', so the one line an operator reads tells the "
|
||
"three 0 W states apart", lc.reason == "SoC floor"
|
||
and peakguard.claim(d_ok).reason == "quarter peak")
|
||
|
||
# --- THE PROPERTY THE WHOLE SHAPE RESTS ON: no negative floor, ever ----------
|
||
# A `lo` limit is incapable of capping discharge or export only while it is
|
||
# non-negative. If this card could produce `lo < 0` it would have broken 9.1 as
|
||
# a property of the object, and the guard would be capping the very thing it is
|
||
# forbidden to cap. Swept rather than argued.
|
||
SWEEP_NEG = SWEEP_BOTH = SWEEP_N = 0
|
||
SWEEP_HI = True
|
||
# ⚠️ ARB-03 ADDS ONE DIMENSION TO THIS PROOF RATHER THAN RE-RUNNING IT UNCHANGED.
|
||
# `Claim` gained an `actuator`, and the asymmetry that makes this guard safe is
|
||
# now TWO facts, not one: `hi` is +inf on every claim it files, AND every claim
|
||
# it files is on the SITE actuator. A `lo` that landed on one inverter's interval
|
||
# would still have `hi = +inf` and would still never cap a discharge - and would
|
||
# leave the SITE, the thing the capacity tariff actually bills, with no floor at
|
||
# all. So the same combinations are swept for both.
|
||
SWEEP_SITE = True
|
||
for load in (0.0, CEIL - 500.0, CEIL, CEIL + 300.0, CEIL + 3000.0,
|
||
LIVE_MAX_W * 3):
|
||
for elapsed in (30.0, 450.0, 870.0):
|
||
_pr = PeakTracker.project(averager_at(load, elapsed),
|
||
B + timedelta(seconds=elapsed))
|
||
for bw in (-2000.0, 0.0, 400.0, 4000.0):
|
||
for sc in (None, 0.0, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0):
|
||
for fl in (SOC_FLOOR, BMS_FLOOR + 1.0, 55.0):
|
||
_d = decide(_pr, batt_w=bw, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W,
|
||
max_hold_s=MAX_HOLD_S, soc=sc, soc_floor=fl)
|
||
SWEEP_N += 1
|
||
if _d.floor_w is not None and _d.floor_w < 0.0:
|
||
SWEEP_NEG += 1
|
||
if _d.floor_limited and _d.saturated:
|
||
SWEEP_BOTH += 1
|
||
_c = peakguard.claim(_d)
|
||
if _c is not None and not (isinf(_c.hi) and _c.hi > 0):
|
||
SWEEP_HI = False
|
||
if _c is not None and _c.actuator != ACTUATOR_SITE:
|
||
SWEEP_SITE = False
|
||
print(f" swept {SWEEP_N} (load x elapsed x battery x SoC x floor) "
|
||
f"combinations, SoC included: {SWEEP_NEG} negative floors, "
|
||
f"{SWEEP_BOTH} decisions both floor-limited and saturated")
|
||
check(f"NOT ONE of the {SWEEP_N} combinations produces a negative floor. That "
|
||
f"is 9.1's 'discharge and export are never capped by the peak guard' as a "
|
||
f"property of the object, and SAFETY-24 preserves it - the only two "
|
||
f"values floor_w takes are 0.0 and a positive min(wanted, achievable)",
|
||
SWEEP_NEG == 0 and SWEEP_N > 600)
|
||
check("...and `hi` stays +inf on every claim any of them files",
|
||
SWEEP_HI)
|
||
check("...and every one of them is filed on the SITE actuator, which is the "
|
||
"other half of the same asymmetry now that a claim has an actuator: the "
|
||
"billed quantity is the quarter's offtake AT THE GRID CONNECTION, so a "
|
||
"floor that bound one inverter's share would leave the site - the thing "
|
||
"the capacity tariff meters - with no floor at all",
|
||
SWEEP_SITE)
|
||
check("...and no combination is both floor-limited and saturated, so the two "
|
||
"notifications are exclusive on measured inputs and not merely by reading "
|
||
"the code", SWEEP_BOTH == 0)
|
||
|
||
# --- TEST-14: a required field must not quietly gain a default --------------
|
||
# ⚠️ PORTED AS A SCAN, NOT HAND-WRITTEN PER FIELD. TEST-14 is open because an
|
||
# outcome type's required field gained a default in three separate files and no
|
||
# check noticed. The pinned sets below go red if ANY field or argument moves
|
||
# between required and defaulted - including a future one nobody has thought of
|
||
# - which is the whole difference between a scan and a case.
|
||
_sig = inspect.signature(decide)
|
||
_required_kw = {n for n, prm in _sig.parameters.items()
|
||
if prm.kind is prm.KEYWORD_ONLY and prm.default is prm.empty}
|
||
print(f" decide() required keyword-only arguments: {sorted(_required_kw)}")
|
||
check("`soc` and `soc_floor` are REQUIRED keyword-only arguments of decide() "
|
||
"with no default. A default on either is TEST-14's exact shape and would "
|
||
"be the quiet fallback this card exists to remove - and a default on "
|
||
"`soc_floor` would additionally be CFG-03's fourth defaults site",
|
||
_required_kw == {"batt_w", "cap_w", "reading", "achievable_w", "soc",
|
||
"soc_floor"})
|
||
_req_fields = tuple(f.name for f in dataclasses.fields(PeakDecision)
|
||
if f.default is dataclasses.MISSING
|
||
and f.default_factory is dataclasses.MISSING)
|
||
print(f" PeakDecision fields with no default: {list(_req_fields)}")
|
||
check("...and PeakDecision's required fields are unchanged by this card: the "
|
||
"four SAFETY-07 pinned, and the SAFETY-24 additions are genuinely "
|
||
"optional reporting rather than required fields handed a default",
|
||
_req_fields == ("engaged", "floor_w", "ceiling_w", "ceiling_from"))
|
||
|
||
|
||
# ==============================================================================
|
||
print()
|
||
print("9. the exhausting replay (AC 4), and the startup refusal")
|
||
|
||
# ⚠️ THE PACK IS SIZED FROM THE MEASUREMENT, NOT PICKED. `WH_07` is what the
|
||
# guard actually asks for on 2026-08-20 - re-derived from the committed capture
|
||
# on every run, never quoted - and the modelled pack is then chosen so the energy
|
||
# between the start and the EMS floor is ONE THIRD of it. That makes "a quarter
|
||
# that would exhaust the pack" true by construction of the measurement rather
|
||
# than by a number typed into this file: the day wants three times what the pack
|
||
# has to give above the floor, so it must run out, and without an EMS floor it
|
||
# must run through the BMS one too.
|
||
BAD = [pth for pth in REPLAY if "2026-08-20" in pth.name][0]
|
||
WH_BAD = replay(BAD, guarded=True)["wh"]
|
||
START_SOC = SOC_FLOOR + (SOC_FLOOR - BMS_FLOOR)
|
||
USABLE_WH = 100.0 * (WH_BAD / 3.0) / (START_SOC - SOC_FLOOR)
|
||
PACK = (USABLE_WH, START_SOC, BMS_FLOOR)
|
||
# How far past the floor one cycle at the rail can carry the pack before the
|
||
# next decision sees it. DERIVED from the cycle and the modelled pack, because a
|
||
# tolerance typed as a constant is a tolerance that stops meaning anything.
|
||
OVERSHOOT_PP = 100.0 * (LIVE_MAX_W * CYCLE_S / 3600.0) / USABLE_WH
|
||
print(f" the guard's measured appetite on {BAD.name}: {WH_BAD:.0f} Wh")
|
||
print(f" modelled pack: {USABLE_WH:.0f} Wh usable, starting at "
|
||
f"{START_SOC:.0f} %, BMS cut at {BMS_FLOOR:.0f} %, EMS floor at "
|
||
f"{SOC_FLOOR:.0f} %")
|
||
print(f" -> {(START_SOC - SOC_FLOOR) / 100.0 * USABLE_WH:.0f} Wh above the "
|
||
f"EMS floor, {(START_SOC - BMS_FLOOR) / 100.0 * USABLE_WH:.0f} Wh above "
|
||
f"the BMS one - against {WH_BAD:.0f} Wh of appetite; one cycle at the "
|
||
f"rail is {OVERSHOOT_PP:.2f} pp of this pack")
|
||
check("the fixture is a pack this day genuinely exhausts, and that is derived "
|
||
"from the day's own measured appetite rather than asserted: the energy "
|
||
"above the BMS cut-out is less than the guard wants",
|
||
(START_SOC - BMS_FLOOR) / 100.0 * USABLE_WH < WH_BAD)
|
||
|
||
# TWO WORLDS, SAME PACK, SAME CAPTURE.
|
||
# BEFORE: the EMS floor set to the BMS cut-out - i.e. the BMS is the sole
|
||
# energy backstop, which is precisely what SAFETY-07 shipped.
|
||
# AFTER: the EMS floor as config.yaml ships it.
|
||
BEFORE = replay(BAD, guarded=True, pack=PACK, soc_floor=BMS_FLOOR)
|
||
AFTER = replay(BAD, guarded=True, pack=PACK, soc_floor=SOC_FLOOR)
|
||
print(f" BEFORE (BMS is the only backstop): ends at {BEFORE['end_soc']:.1f} %"
|
||
f", low-water {BEFORE['min_soc']:.1f} %, {BEFORE['wh']:.0f} Wh out, "
|
||
f"{BEFORE['at_bms']} guard cycles spent AT the BMS limit, "
|
||
f"{BEFORE['limited']} floor-limited")
|
||
print(f" AFTER (peak_soc_floor {SOC_FLOOR:.0f} %): ends at "
|
||
f"{AFTER['end_soc']:.1f} %, low-water {AFTER['min_soc']:.1f} %, "
|
||
f"{AFTER['wh']:.0f} Wh out, {AFTER['at_bms']} cycles at the BMS limit, "
|
||
f"{AFTER['limited']} floor-limited")
|
||
check("BEFORE: the day really does take the pack to the BMS cut-out and hold it "
|
||
"there. That is the finding this card answers - not a pack destroyed, a "
|
||
"pack repeatedly parked at its own protection with no EMS-side record of "
|
||
"why", BEFORE["min_soc"] <= BMS_FLOOR + 1e-9 and BEFORE["at_bms"] > 0)
|
||
check("AFTER: the forced discharge STOPS AT THE CONFIGURED FLOOR - to within "
|
||
"the one cycle of overshoot the 15 s tick allows, derived above and not "
|
||
"typed - and the pack never reaches the BMS limit at all. That is AC 1, "
|
||
"replayed rather than reasoned about",
|
||
AFTER["min_soc"] >= SOC_FLOOR - OVERSHOOT_PP and AFTER["at_bms"] == 0
|
||
and AFTER["min_soc"] > BMS_FLOOR + 1.0)
|
||
check("...and it does not merely stop, it SAYS SO: floor-limited cycles are "
|
||
"counted off the decisions themselves, so the under-shave is reported "
|
||
"rather than silent", AFTER["limited"] > 0)
|
||
# ⚠️ WHAT "BEFORE" IS AND IS NOT. It is the shipped code with the EMS floor set
|
||
# equal to the BMS cut-out, which reproduces the pre-card ENERGY trajectory
|
||
# exactly - the pack drains to the BMS and the BMS is the only thing that stops
|
||
# it. What it does not reproduce is the silence: pre-card there was no gate to
|
||
# report at all, whereas this run reports one at the BMS limit, by which point
|
||
# the pack is already there. The trajectory is the thing being compared.
|
||
check("...and the two worlds differ in the trajectory, not merely in a flag: "
|
||
"before, every floor-limited cycle happens with the pack ALREADY at the "
|
||
"BMS limit; after, none of them do",
|
||
BEFORE["at_bms"] > 0 and AFTER["at_bms"] == 0)
|
||
# THE DERIVED VERDICT: the energy the card withholds is exactly the energy
|
||
# between the two floors. Not "less", not "a bit" - the difference in Wh equals
|
||
# the difference in percentage points times the pack, to within one cycle.
|
||
GAP_WH = (SOC_FLOOR - BMS_FLOOR) / 100.0 * USABLE_WH
|
||
TOL = 2.0 * LIVE_MAX_W * CYCLE_S / 3600.0 # one cycle of overshoot per run
|
||
print(f" energy withheld: {BEFORE['wh'] - AFTER['wh']:.1f} Wh, against "
|
||
f"{GAP_WH:.1f} Wh between the two floors (tolerance {TOL:.1f} Wh = one "
|
||
f"cycle of overshoot at the rail in each of the two runs)")
|
||
check("the energy this card withholds IS the energy between the two floors, to "
|
||
"within one cycle at the rail - so the floor is doing the arithmetic it "
|
||
"claims and is not merely correlated with doing less",
|
||
abs((BEFORE["wh"] - AFTER["wh"]) - GAP_WH) <= TOL)
|
||
check("...and no cycle in either world files a negative floor, on real "
|
||
"captured data and not only on the synthetic sweep",
|
||
BEFORE["neg_floor"] == 0 and AFTER["neg_floor"] == 0)
|
||
# THE HONEST HALF. Stopping costs money, and the check says how much rather than
|
||
# looking away from it.
|
||
_ub = max(BEFORE["blocks"].values())
|
||
_ab = max(AFTER["blocks"].values())
|
||
print(f" the price of the floor: worst quarter {_ub:.0f} W before, "
|
||
f"{_ab:.0f} W after - a floor-limited guard shaves LESS, on purpose, and "
|
||
f"the operator is told so")
|
||
check("stopping at the floor costs peak shaving, and this file says so out "
|
||
"loud rather than reporting only the SoC it saved: the worst quarter is "
|
||
"no better with the floor in place", _ab >= _ub - 1e-6)
|
||
|
||
# --- the wiring: one real cycle on an empty pack -----------------------------
|
||
# ⚠️ `maintenance_soc_floor` IS SET, AND SET TO A VALUE THAT WOULD FLIP THE
|
||
# ANSWER. The pack sits one point under `peak_soc_floor` and well ABOVE the
|
||
# maintenance floor, so a controller that read the maintenance key would come
|
||
# out NOT limited here. Without this the two keys are indistinguishable on every
|
||
# fixture in this file - a mutation swapping them survived until it was added.
|
||
MAINT_LOW = 5
|
||
c24, hass24, NET24, _ = wired_over_limit(
|
||
TMP / "soc24.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0,
|
||
opts={"maintenance_soc_floor": MAINT_LOW})
|
||
check(f"the fixture separates the two floors: the pack at {SOC_FLOOR - 1:.0f} % "
|
||
f"is under peak_soc_floor {SOC_FLOOR:.0f} % and over "
|
||
f"maintenance_soc_floor {MAINT_LOW} %, so reading the wrong key gives the "
|
||
f"wrong answer here", MAINT_LOW < SOC_FLOOR - 1.0 < SOC_FLOOR)
|
||
print(f" a real cycle over a {NET24:.0f} W quarter with the pack at "
|
||
f"{SOC_FLOOR - 1.0:.0f} %: {c24.peak_guard.explain()}")
|
||
check("the REAL Controller reaches the floor-limited branch, so main.py hands "
|
||
"decide() a state of charge at all", c24.peak_guard.floor_limited)
|
||
check("...and the number written to the inverter is not a forced discharge - "
|
||
"the pack is not spent for money below the floor",
|
||
hass24.writes and hass24.writes[-1] >= -1e-6
|
||
and hass24.writes[-1] <= c24.peak_guard.wanted_w)
|
||
check("...and the operator is TOLD THE PEAK WILL BE EXCEEDED, exactly once, "
|
||
"with its own notification id - AC 2, and the reason failing to shave "
|
||
"must not be silent",
|
||
hass24.notifications.count("goodwe_peak_soc_floor") == 1
|
||
and "goodwe_peak_saturated" not in hass24.notifications)
|
||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c24.cycle())
|
||
check("...once per episode, not once per cycle - a quarter of an hour of "
|
||
"identical cards is how an operator learns to dismiss the one that "
|
||
"mattered", hass24.notifications.count("goodwe_peak_soc_floor") == 1)
|
||
rows24 = [r for r in c24.checks() if 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, 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, 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.
|
||
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||
"arbitrage", "maintenance", "loop", "circulation",
|
||
"sign_site", "pack", "refused"}
|
||
# 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 TWELVE. 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 and OPT-09's additions: the six 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"}
|
||
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
|
||
== {"device", "supervised", "failsafe", "maintenance", "pack",
|
||
"refused"})
|
||
|
||
# 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",
|
||
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
|
||
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
|
||
"circulation": ACTUATOR_SITE, "sign_site": 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 THIRTEEN 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) == 13
|
||
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. Eight of the thirteen appends are SCOPE_UNIT - `device`, "
|
||
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack` and "
|
||
"SAFETY-73's two `refused` - and all eight 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]) == 8)
|
||
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 fifteen appends account for exactly the twelve "
|
||
"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"})
|
||
# ⚠️ 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, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR,
|
||
frozen=False).engaged and d_frozen.engaged,
|
||
"part 4: 'THE BACKSTOP: SAFETY-18's runaway ... cut to 0 W'"))
|
||
|
||
# 3. `is_observed` dropped from quarter_ceiling_w, i.e. any reading's peak
|
||
# trusted. A NOT_OBSERVED reading carries peak_w=None, so the defeat is LOUD.
|
||
def _ceiling_without_the_gate(cap, rd):
|
||
return max(float(cap), float(rd.peak_w))
|
||
|
||
|
||
_loud = False
|
||
try:
|
||
_ceiling_without_the_gate(CAP_W, unread)
|
||
except TypeError:
|
||
_loud = True
|
||
MUT.append(("the `is_observed` gate dropped from quarter_ceiling_w",
|
||
_loud, "TypeError on PeakReading.peak_w=None - it fails loud"))
|
||
|
||
# 4. the 900/remaining gain flattened to a plain difference.
|
||
_p800 = PeakTracker.project(a800, B + timedelta(seconds=800))
|
||
MUT.append(("the 900/remaining gain replaced by a plain difference",
|
||
not near(excess_w(_p800, CEIL), _p800.projected_w - CEIL, 1.0),
|
||
"part 1: 'the law is the difference times 9.00x'"))
|
||
|
||
# 5. the achievable_w clamp removed, so the floor reaches the arbiter unbounded.
|
||
MUT.append(("the achievable_w clamp removed",
|
||
resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "r"),
|
||
Claim.set("loop", P_LOOP, -200.0, "grid"),
|
||
Claim.limit("peak", P_PEAK,
|
||
lo=d_absurd.wanted_w)]).contradiction,
|
||
"part 2: 'the claim can NEVER produce lo > hi'"))
|
||
|
||
# 6. the claim filed as a SET rather than a LIMIT - it would then outrank
|
||
# nothing it should, but it WOULD survive a safety stop losing the tie.
|
||
_as_set = resolve(SAFE + [Claim.set("safety", P_SAFETY, 0.0, "stopped"),
|
||
Claim.set("peak", P_PEAK, pc.lo, "x")])
|
||
MUT.append(("filed as a SET at P_PEAK instead of a LIMIT",
|
||
near(_as_set.target_w, 0.0) and pc.kind == "limit",
|
||
"part 2: 'it is a LIMIT at P_PEAK, not a SET'"))
|
||
|
||
# 7. THE ONE THAT DOES NOT BITE, and it is stated rather than hidden.
|
||
_zero_peak = PeakReading(OBSERVED, "2026-08", peak_w=0.0, quarter_start=B)
|
||
_same = quarter_ceiling_w(CAP_W, _zero_peak) == quarter_ceiling_w(CAP_W, unread)
|
||
MUT.append(("NOT_OBSERVED silently replaced by a peak of 0.0 W",
|
||
not _same,
|
||
"NOTHING - and by construction: the ceiling is max(cap, peak), so a "
|
||
"zero peak is the TIGHTEST possible input to it"))
|
||
|
||
# ---- SAFETY-24's own defeats ------------------------------------------------
|
||
# 8. the SoC gate removed altogether - i.e. the guard exactly as SAFETY-07
|
||
# shipped it. Re-derived on the same modelled pack, not described.
|
||
MUT.append(("the SoC gate removed - SAFETY-07's unbounded forced discharge",
|
||
BEFORE["min_soc"] <= BMS_FLOOR and AFTER["min_soc"] > BMS_FLOOR,
|
||
"part 9: 'the forced discharge STOPS AT THE CONFIGURED FLOOR'"))
|
||
|
||
# 9. an unreadable SoC read as a full battery. THE DISARMING DIRECTION, and the
|
||
# one this project has actually shipped once (NOTES.md 4.4b).
|
||
MUT.append(("`soc or 100` - an unreadable SoC read as a full battery",
|
||
discharge_permitted(float(None or 100), SOC_FLOOR) is None
|
||
and d_unread.floor_limited,
|
||
"part 8: 'an unreadable SoC is refused, and refused with its OWN "
|
||
"reason'"))
|
||
|
||
# 10. the gate moved ABOVE the `wanted > 0` test, i.e. graded where it is
|
||
# degenerate. Counted on the healthy day rather than argued: every quiet
|
||
# cycle would report the peak was about to be exceeded.
|
||
_early = sum(1 for _t in (500.0, 1000.0, 2000.0)
|
||
if discharge_permitted(SOC_FLOOR - 1.0, SOC_FLOOR) is not None
|
||
and not decide(PeakTracker.project(
|
||
averager_at(_t, 450.0), B + timedelta(seconds=450)),
|
||
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||
soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR).engaged)
|
||
MUT.append((f"the SoC gate moved above the `wanted > 0` test ({_early} quiet "
|
||
f"quarters would have alarmed)",
|
||
_early > 0 and not d_quiet.floor_limited,
|
||
"part 8: 'an empty pack on a quarter that does not need shaving is "
|
||
"IDLE, not floor-limited'"))
|
||
|
||
# 11. rule 8 written `<` instead of `<=`, so a floor EQUAL to the BMS cut-out
|
||
# is accepted and buys zero margin.
|
||
MUT.append(("validate rule 8 written `<` instead of `<=`",
|
||
len(rule8(validate_startup_config(
|
||
opts24(peak_soc_floor=BMS_FLOOR)))) == 1,
|
||
"part 9: 'so is a floor EXACTLY AT it, which is the interesting "
|
||
"case'"))
|
||
|
||
# 12. the floor-limited branch filing `wanted` anyway - a report with no action,
|
||
# which is the shape a "just log it" review comment produces.
|
||
MUT.append(("floor-limited reported but the floor still filed at `wanted`",
|
||
near(d_lim.floor_w, 0.0) and d_lim.wanted_w > 0.0,
|
||
"part 8: 'at the floor it STOPS, and the floor it files is 0 W'"))
|
||
|
||
# 13. THE SECOND ONE THAT DOES NOT BITE, and it is a real limitation rather than
|
||
# a defect. Stated, because the alternative is a reader believing this card
|
||
# bounds more than it does.
|
||
#
|
||
# ⚠️ SAFETY-25 CHANGED WHAT "NOT CAUGHT" MEANS HERE, AND ONLY THAT. Nothing
|
||
# PREVENTS it - the code is unchanged and a `lo` limit still cannot cap a
|
||
# discharge - but part 10 now MEASURES it on the committed capture and 10f
|
||
# PINS THE CLAIM-SOURCE SET around it, so the exposure can no longer change
|
||
# size under a green suite: file the reserve and 10f goes red on the line
|
||
# it is written. That is the whole difference between an uncaught mutation
|
||
# and an unrecorded one. ⚠️ THE PIN IS LOAD-BEARING FOR THIS ROW. Without
|
||
# it this rationale was FALSE - PR #31's reviewer built the memo's Option B
|
||
# and this row went on printing "NOTHING PREVENTS IT" under a green suite.
|
||
_loop_unbounded = near(r_disch.target_w, 1800.0, 1e-9)
|
||
MUT.append(("the grid-following loop discharging past this floor",
|
||
not _loop_unbounded,
|
||
"NOTHING PREVENTS IT, ON PURPOSE - this card bounds THE PEAK "
|
||
"GUARD's forced discharge, the thing SAFETY-07 introduced. A `lo` "
|
||
"limit is structurally incapable of closing SAFETY-18's finding "
|
||
"that the loop has no SoC guard. SAFETY-25 part 10 now MEASURES "
|
||
"it: one sustained-load evening, "
|
||
f"{EVE_START:.0f} % -> {EV['end_soc']:.1f} % on the reference pack, "
|
||
f"identical at every peak_soc_floor the schema accepts - and 10f "
|
||
f"pins the claim-source set, so building the floor takes this "
|
||
f"part RED instead of leaving this row quietly stale"))
|
||
|
||
# ---- 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, 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, 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")
|