checks / unit (pull_request) Successful in 3m29s
SAFETY-07 can command a discharge to hold a billed quarter down, and nothing below it consulted the state of charge. On the 2026-08-20 replay it asks for 878 Wh in a day. Relying on the BMS is not absurd - it is a real floor and it works - but it is the pack's protection rather than the operator's, and a month of aggressive quarters leaves the battery repeatedly at that cut-out with no EMS-side record of why. The first symptom is a pack never available when wanted, which is indistinguishable from the SoC drift MAINT-01 exists to correct. decide() now takes soc and soc_floor, both required and neither defaulted, and discharge_permitted() is the one place the question is answered. Above the floor nothing changes. At or below it the guard files a 0 W floor instead of a sized one - one-directional, so charging stays blocked and no discharge is capped - and reports floor_limited with the reason it bound. It is not a trip: every stop in 11.2's table still outranks the claim, and the floor is never negative, so 9.1's asymmetry survives as a property of the object (0 negative floors over 1296 swept combinations, SoC included). The SoC is graded only after `wanted > 0` is established, so it is never evaluated where it governs nothing. An unreadable SoC is floor-limited, not a full battery - a full battery is exactly the value that would disarm the floor, and this project shipped that defaulting once already. Failing to shave is not silent: one notification per episode (goodwe_peak_soc_floor), distinct from the saturation alarm because the two ask for opposite operator actions, plus its own status row and an action-log line. Verified by replay. The 2026-08-20 capture is driven against a modelled pack sized from the day's own measured appetite, so it genuinely exhausts: without the floor it reaches the BMS cut-out and holds there for 1227 guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The energy withheld equals the energy between the two floors to within one cycle at the rail. The honest half is reported too - the worst quarter goes 4338 W -> 5030 W, because a floor-limited guard shaves less on purpose. Two new options, mirrored once in peakguard.PEAK_OPTION_DEFAULTS (TARIFF-02's pattern), imported by validate.py and main.py rather than typed as literals and pinned to config.yaml by a check that reads the file - so CFG-03 still tracks three places. validate.py rule 8 refuses peak_soc_floor <= bms_soc_floor at startup, in CFG-01/MAINT-04's manner. maintenance_soc_floor is deliberately not reused and not renamed: it is a drain target, not a spend limit, and the rename is recorded as a decision to revisit if a third floor appears. Eleven mutations of the shipped source were run against the checks; all eleven go red and there are no survivors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
1905 lines
104 KiB
Python
1905 lines
104 KiB
Python
"""Runnable check for SAFETY-07, the quarter-peak projection guard.
|
||
|
||
python3 test_peakguard.py
|
||
|
||
Ten 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. 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.
|
||
"""
|
||
|
||
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
|
||
from app.arbiter import (
|
||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
|
||
)
|
||
from app.control import peak_at_risk
|
||
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
|
||
from app.main import Controller
|
||
from app.p1 import (
|
||
PLAUSIBLE_MAX_W, P1Ingest, QUARTER_S, QuarterAverager, make_sample,
|
||
split_signed,
|
||
)
|
||
from app.peakguard import (
|
||
BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP,
|
||
CEILING_MONTH_PEAK, FLOOR_AT_SOC_FLOOR, FLOOR_SOC_UNREADABLE,
|
||
PEAK_OPTION_DEFAULTS, PeakDecision, blindness, decide, discharge_permitted,
|
||
excess_w, quarter_ceiling_w, uncovered_s,
|
||
)
|
||
from app.validate import validate_startup_config
|
||
from app.tariff import (
|
||
NOT_OBSERVED, NO_RECORD, OBSERVED, PeakReading, PeakStore, PeakTracker,
|
||
month_key,
|
||
)
|
||
|
||
fails = []
|
||
HERE = Path(__file__).resolve().parent
|
||
TMP = Path(tempfile.mkdtemp(prefix="goodwe-peakguard-"))
|
||
|
||
|
||
def check(name, cond):
|
||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||
if not cond:
|
||
fails.append(name)
|
||
|
||
|
||
def near(a, b, tol=1e-6):
|
||
return a is not None and b is not None and abs(a - b) <= tol
|
||
|
||
|
||
def sample(t: datetime, net_w: float, phases=1, phase_net=None):
|
||
"""One P1 telegram through the SHIPPED derivation - test_tariff.py's helper,
|
||
for its reason: the sign convention under test must be the one that ships."""
|
||
imp, exp = split_signed(net_w)
|
||
pi = pe = None
|
||
if phase_net is not None:
|
||
pairs = [split_signed(v) for v in phase_net]
|
||
pi = [a for a, _ in pairs]
|
||
pe = [b for _, b in pairs]
|
||
return make_sample("ha_signed", imp, exp, phases=phases,
|
||
phase_import_w=pi, phase_export_w=pe,
|
||
ingest_ts=t, ingest_mono=t.timestamp())
|
||
|
||
|
||
def fresh_store(name: str) -> PeakStore:
|
||
return PeakStore(str(TMP / f"{name}.db"))
|
||
|
||
|
||
def read_option(key: str):
|
||
"""The shipped default for `key`, out of config.yaml.
|
||
|
||
⚠️ `peak_cap_w:` appears TWICE - once under `options:`, once under `schema:`.
|
||
test_tariff.py's part 9 documents the trap; this takes the FIRST match, which
|
||
is the options block, i.e. the number an install actually lands on.
|
||
"""
|
||
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
|
||
if line.strip().startswith(f"{key}:"):
|
||
try:
|
||
return float(line.split(":", 1)[1].strip())
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
|
||
def read_schema_max(key: str):
|
||
"""The schema's upper bound for `key` - `int(lo,hi)` -> hi."""
|
||
body = (HERE / "config.yaml").read_text(encoding="utf-8")
|
||
marker = body.index("\nschema:")
|
||
for line in body[marker:].splitlines():
|
||
st = line.strip()
|
||
if st.startswith(f"{key}:") and "(" in st and ")" in st:
|
||
return float(st[st.index("(") + 1:st.rindex(")")].split(",")[1])
|
||
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. AC 3 is TARIFF-01's and is still not met - "
|
||
f"see test_tariff.py part 9. What this compares is the same quarters with "
|
||
f"and without the guard, which is what two days can support.")
|
||
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.notifications = []
|
||
|
||
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)
|
||
return True
|
||
|
||
async def call(self, domain, service, data):
|
||
self.notifications.append(data.get("notification_id"))
|
||
return True
|
||
|
||
|
||
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.
|
||
_call = next(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")
|
||
_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",
|
||
_batt_src == "self.batt")
|
||
# ⚠️ 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",
|
||
_soc_src == "self.soc")
|
||
check("...and main.py still has exactly ONE `.decide(` call site, so grading "
|
||
"the first one grades the shipped one",
|
||
sum(1 for n in ast.walk(ast.parse(_mainsrc))
|
||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
||
and n.func.attr == "decide") == 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
|
||
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
|
||
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 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("and a Controller whose SoC entity does not answer hands decide() None, "
|
||
"which is 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)
|
||
|
||
# --- 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 documents "
|
||
"(maintenance_charge_w 2000 there, 2500 in main.py), refused a second "
|
||
"outing here",
|
||
"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. 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.
|
||
_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, ON PURPOSE - this card bounds THE PEAK GUARD's forced "
|
||
"discharge, the thing SAFETY-07 introduced. SAFETY-18's finding "
|
||
"that the loop itself has no SoC guard is still open, and a `lo` "
|
||
"limit is structurally incapable of closing it"))
|
||
|
||
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", not MUT[12][1])
|
||
|
||
|
||
# ==============================================================================
|
||
print()
|
||
shutil.rmtree(TMP, ignore_errors=True)
|
||
if fails:
|
||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||
sys.exit(1)
|
||
print("all checks passed")
|