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