TEST-10: pin max_w beside the meter_freeze_s key TEST-06 already pinned
checks / unit (pull_request) Successful in 3m43s

max_w and the LIVE_MAX_W literal beside it were the two survivors left
after TEST-06 pinned meter_freeze_s: changing max_w 2000 -> 4500 in
config.yaml passed every check in test_freeze.py and test_tariff.py
GREEN, and LIVE_MAX_W = 5000.0 was a bare literal that silently
disagreed with the shipped max_w (2000) with nothing in the file
saying so. For contrast, deadband_w 15 -> 5 already went RED 2 + 1,
so the pinning technique was known to work - it just was not applied
to the key either side of it.

Three changes, each measured:

1. test_freeze.py gains a check comparing Tuning()'s code default
   (control.py, 2000.0) against SHIPPED_MAX_W, read from config.yaml
   by extending the file's existing _cfg_default() regex reader
   rather than reimplementing test_arbitrage.py's _shipped. Broken
   and confirmed red: max_w: 2000 -> 4500 in config.yaml now fails
   exactly "the code default and the shipped default agree on max_w
   too" and nothing else in either file; reverting returns both files
   to all-green. (AC 1, AC 4)

2. LIVE_MAX_W is renamed LIVE_INSTALL_MAX_W in both test_freeze.py
   and test_tariff.py, each with a comment stating plainly that 5000
   is the live install's own override, not config.yaml's shipped
   max_w (2000) - the defect was a literal that could be misread as
   the shipped default, not the number itself. (AC 2)

3. test_freeze.py states PINNED_KEYS = ("meter_freeze_s", "deadband_w",
   "max_w") in one place as the complete set of config keys this
   suite pins. Deliberately excludes maintenance_* - CFG-03 (merged
   into release/1.0 since this branch started, merged in here) gave
   those keys a separate single source of truth in app/maintenance.py
   and this ticket has no reason to duplicate that pin. Not every key
   in config.yaml is pinned here on purpose: meter_freeze_s,
   deadband_w and max_w are the three this file's mutual-exclusion
   arithmetic (section 2b) actually reasons about together. (AC 3)

Full 17-file suite run green under goodwe-addon-ci after the merge
with release/1.0 (f630ed9, CFG-03). CHANGELOG.md updated under
Unreleased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-01 06:48:05 +02:00
co-authored by Claude Opus 5
parent f630ed949b
commit d0b053b150
3 changed files with 127 additions and 72 deletions
+28 -22
View File
@@ -115,10 +115,16 @@ fails = []
HERE = Path(__file__).resolve().parent
TMP = Path(tempfile.mkdtemp(prefix="goodwe-tariff-"))
# The live install's numbers: NOTES.md and freeze.py both record max_w 5000
# there. `peak_cap_w` ships at 3500 in config.yaml and is the SOFT LIMIT AC 3
# grades against - read out of the shipped file in part 10 rather than typed.
LIVE_MAX_W = 5000.0
# ⚠️ NOT config.yaml's shipped max_w (2000). NOTES.md and freeze.py both record
# that the LIVE INSTALL runs max_w 5000, and that is the number this file uses
# as the device's power ceiling wherever it needs one - it is named
# LIVE_INSTALL_MAX_W, not the shorter LIVE_MAX_W, precisely so it cannot be
# misread as "the current shipped default" (TEST-10; test_freeze.py carries the identical
# constant, under the identical name, for the identical reason - grep it there
# for the pin that stops config.yaml's max_w from drifting under it unnoticed).
# `peak_cap_w` ships at 3500 in config.yaml and is the SOFT LIMIT AC 3 grades
# against - read out of the shipped file in part 10 rather than typed.
LIVE_INSTALL_MAX_W = 5000.0
def check(name, cond):
@@ -298,7 +304,7 @@ CTRL_W = ctrl_closed[0].offtake_avg_w
# (b) restarted at minute 10, checkpoint restored through a real SQLite file.
st = fresh_store("restart")
tr = PeakTracker(st, cap_w=LIVE_MAX_W)
tr = PeakTracker(st, cap_w=LIVE_INSTALL_MAX_W)
a1 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
feed(a1, 0, RESTART_AT_S)
check("the checkpoint round-trips through SQLite", st.save_partial(a1.snapshot()))
@@ -335,7 +341,7 @@ OUTAGE_S = 200
a4 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
st4 = fresh_store("outage")
st4.save_partial(a1.snapshot())
PeakTracker(st4, cap_w=LIVE_MAX_W).restore(a4, B + timedelta(seconds=RESTART_AT_S + OUTAGE_S))
PeakTracker(st4, cap_w=LIVE_INSTALL_MAX_W).restore(a4, B + timedelta(seconds=RESTART_AT_S + OUTAGE_S))
o_closed = feed(a4, RESTART_AT_S + OUTAGE_S, QUARTER_S)
OUTAGE_UNCOVERED_S = OUTAGE_S - MAX_HOLD_S
print(f" a {OUTAGE_S} s outage inside the quarter, of which "
@@ -415,7 +421,7 @@ check("...so a new month starts from its OWN quarters, not from the previous "
# The restart that straddles the boundary: a checkpoint whose open block is in
# August, restored when `now` is already September.
st7 = fresh_store("straddle")
tr7 = PeakTracker(st7, cap_w=LIVE_MAX_W)
tr7 = PeakTracker(st7, cap_w=LIVE_INSTALL_MAX_W)
straddler = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
for i in range(0, 400, STEP_S):
straddler.add(sample(AUG_LATE + timedelta(seconds=i), RUN_W))
@@ -443,17 +449,17 @@ print("5. AN UNREADABLE STORE IS NOT A ZERO PEAK (the acceptance argument)")
# Three stores, three states, and the three must not answer alike.
good = fresh_store("states")
good_tr = PeakTracker(good, cap_w=LIVE_MAX_W)
good_tr = PeakTracker(good, cap_w=LIVE_INSTALL_MAX_W)
empty_r = good_tr.peak(B)
gone = fresh_store("gone")
gone_tr = PeakTracker(gone, cap_w=LIVE_MAX_W)
gone_tr = PeakTracker(gone, cap_w=LIVE_INSTALL_MAX_W)
gone.record_block(_CB(B, 3000.0, None))
gone.close() # the database goes away under a live reader
gone_r = gone_tr.peak(B)
never = PeakStore(str(TMP / "no-such-dir" / "x.db"))
never_tr = PeakTracker(never, cap_w=LIVE_MAX_W)
never_tr = PeakTracker(never, cap_w=LIVE_INSTALL_MAX_W)
never_r = never_tr.peak(B)
# A store that is OPEN and whose query FAILS is a third code path - a closed
@@ -463,7 +469,7 @@ broken = fresh_store("broken-table")
broken.record_block(_CB(B, 3000.0, None))
broken._db.execute("DROP TABLE monthly_peak")
broken._db.commit()
broken_r = PeakTracker(broken, cap_w=LIVE_MAX_W).peak(B)
broken_r = PeakTracker(broken, cap_w=LIVE_INSTALL_MAX_W).peak(B)
for label, r in (("fresh store, no quarter yet", empty_r),
("store that broke under a live reader", gone_r),
@@ -513,26 +519,26 @@ check("PeakReading exposes no accessor that defaults a missing peak to a "
if a.startswith(("get", "value")) or a.endswith("_or")])
# The consequence, which is the reason any of this matters.
print(f" headroom under a {LIVE_MAX_W:.0f} W limit: "
print(f" headroom under a {LIVE_INSTALL_MAX_W:.0f} W limit: "
f"no quarter yet -> {good_tr.headroom_w(B)}, "
f"unreadable -> {gone_tr.headroom_w(B)}")
check("an unreadable store yields NO headroom figure - the disarming default "
"(full headroom, guard silently off) is unreachable",
gone_tr.headroom_w(B) is None and never_tr.headroom_w(B) is None)
check(f"...where a genuinely empty month yields the full {LIVE_MAX_W:.0f} W, "
check(f"...where a genuinely empty month yields the full {LIVE_INSTALL_MAX_W:.0f} W, "
f"which is correct and is a different answer",
near(good_tr.headroom_w(B), LIVE_MAX_W))
near(good_tr.headroom_w(B), LIVE_INSTALL_MAX_W))
check("the 80 % warning fraction is likewise None when nothing was observed",
gone_tr.warning_frac(B) is None and good_tr.warning_frac(B) == 0.0)
# And a real peak reduces headroom, so "always None" does not pass this part.
good.record_block(_CB(B, LIVE_MAX_W * 0.9, None))
print(f" after one {LIVE_MAX_W * 0.9:.0f} W quarter: "
good.record_block(_CB(B, LIVE_INSTALL_MAX_W * 0.9, None))
print(f" after one {LIVE_INSTALL_MAX_W * 0.9:.0f} W quarter: "
f"headroom {good_tr.headroom_w(B):.0f} W, "
f"warning {good_tr.warning_frac(B) * 100:.0f} % of the limit")
check("a real observed peak gives a real headroom, so this part is not passed "
"by a store that answers None to everything",
near(good_tr.headroom_w(B), LIVE_MAX_W * 0.1, 1e-6))
near(good_tr.headroom_w(B), LIVE_INSTALL_MAX_W * 0.1, 1e-6))
check(f"...and it is over §9.1's 80 % warning threshold",
good_tr.warning_frac(B) > 0.8)
@@ -541,7 +547,7 @@ check(f"...and it is over §9.1's 80 % warning threshold",
corrupt_path = TMP / "corrupt.db"
corrupt_path.write_bytes(b"this is not a database" * 64)
corrupt = PeakStore(str(corrupt_path))
c_r = PeakTracker(corrupt, cap_w=LIVE_MAX_W).peak(B)
c_r = PeakTracker(corrupt, cap_w=LIVE_INSTALL_MAX_W).peak(B)
print(f" a corrupt database file -> {c_r.state}")
check("a corrupt database reads NOT_OBSERVED and does not raise into the "
"control loop (§6 fail-open)", c_r.state == NOT_OBSERVED)
@@ -746,7 +752,7 @@ under = 0
for path in REPLAY:
st9 = fresh_store("replay-" + path.stem)
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
tr9 = PeakTracker(st9, cap_w=SHIPPED_CAP_W or LIVE_MAX_W)
tr9 = PeakTracker(st9, cap_w=SHIPPED_CAP_W or LIVE_INSTALL_MAX_W)
first = last = None
with gzip.open(path, "rt", encoding="utf-8") as fh:
for line in fh:
@@ -760,7 +766,7 @@ for path in REPLAY:
span_h = (last - first).total_seconds() / 3600.0
qs = [w for _, w in st9.blocks_in(sorted(months)[0])] if months else []
peak = max(qs) if qs else 0.0
ok_q = sum(1 for w in qs if w <= (SHIPPED_CAP_W or LIVE_MAX_W))
ok_q = sum(1 for w in qs if w <= (SHIPPED_CAP_W or LIVE_INSTALL_MAX_W))
total_q += len(qs)
under += ok_q
replay_days += span_h / 24.0
@@ -815,7 +821,7 @@ class _Pub:
class _Hass:
async def limits(self, entity):
return (-LIVE_MAX_W, LIVE_MAX_W, 1.0)
return (-LIVE_INSTALL_MAX_W, LIVE_INSTALL_MAX_W, 1.0)
async def number(self, entity, invert=False):
return None
@@ -831,7 +837,7 @@ def built(db_path, opts=None):
return 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,
"max_w": LIVE_INSTALL_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(), _Pub(), stop=asyncio.Event(),