From 770988c9026739d7c2c8e5abfa4ba5e74d924096 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 25 Sep 2026 00:06:58 +0200 Subject: [PATCH] TEST-26: a missing value in a check is a named FAIL, not a traceback Checks and prints that indexed an empty list, exhausted next(), or formatted None killed the rest of their file (FCST-06). Read them through the new named_absence.py (nth/first/field/given/present) and conjoin a presence term. - every site in the brief, plus the family the new scan and a sweep found - test_named_absence.py: scans every test_*.py for unguarded filtered-list indexing, next() without a default and numeric formats on maybe-None names; proven to bite; prints what it cannot see, counted - test_price.py 16a: the `_T17_CAPS is _T17_CAPS` tautology now grades the two assemble() calls' arguments - test_control.py: coverage table re-measured on runs graded to the end Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/named_absence.py | 99 ++++++ goodwe_controller/test_arbitrage.py | 24 +- goodwe_controller/test_clamp.py | 17 +- goodwe_controller/test_control.py | 107 ++---- goodwe_controller/test_delivery.py | 10 +- goodwe_controller/test_freeze.py | 6 +- goodwe_controller/test_freshness.py | 81 +++-- goodwe_controller/test_inverter.py | 9 +- goodwe_controller/test_named_absence.py | 426 +++++++++++++++++++++++ goodwe_controller/test_p1.py | 4 +- goodwe_controller/test_packshare.py | 11 +- goodwe_controller/test_peakguard.py | 155 +++++---- goodwe_controller/test_price.py | 51 ++- goodwe_controller/test_price_forecast.py | 9 +- goodwe_controller/test_price_history.py | 19 +- goodwe_controller/test_pv.py | 38 +- goodwe_controller/test_static_tariff.py | 7 +- goodwe_controller/test_tariff.py | 44 +-- goodwe_controller/test_watchdog.py | 22 +- 19 files changed, 853 insertions(+), 286 deletions(-) create mode 100644 goodwe_controller/named_absence.py create mode 100644 goodwe_controller/test_named_absence.py diff --git a/goodwe_controller/named_absence.py b/goodwe_controller/named_absence.py new file mode 100644 index 0000000..eb78d36 --- /dev/null +++ b/goodwe_controller/named_absence.py @@ -0,0 +1,99 @@ +"""Read a value a check prints or grades without raising (TEST-26). + +Each test file is one script, so an IndexError in a print or a check name +kills every check after it (MUTATION.md, FCST-06). Read through these instead, +and conjoin `present(x)` in the check so the absence grades as a named FAIL. + +`ABSENT` values never raise: they format as `` under any spec, +index and attribute to themselves, compare unequal AND not-unequal, and are +falsy. `not x`, `"a" not in x` and `all(... for _ in x)` are therefore TRUE of +one, which is why the check needs the `present()` term. `int()`, `range()` and +use as a list index still raise: guard those with `present()`. +""" + +_NAN = float("nan") + + +class Absent: + __slots__ = ("what",) + + def __init__(self, what): + object.__setattr__(self, "what", what) + + def __repr__(self): + return f"" + + __str__ = __repr__ + + def __format__(self, spec): + return repr(self) + + def __bool__(self): + return False + + def __eq__(self, other): + return False + + __ne__ = __lt__ = __le__ = __gt__ = __ge__ = __eq__ + + def __hash__(self): + return id(self) + + def __getitem__(self, _): + return self + + def __getattr__(self, _): + return self + + def __call__(self, *_a, **_k): + return self + + def __len__(self): + return 0 + + def __iter__(self): + return iter(()) + + def __contains__(self, _): + return False + + def __float__(self): + return _NAN + + def _same(self, *_): + return self + + __add__ = __radd__ = __sub__ = __rsub__ = __mul__ = __rmul__ = _same + __truediv__ = __rtruediv__ = __floordiv__ = __rfloordiv__ = _same + __mod__ = __rmod__ = __neg__ = __pos__ = __abs__ = __round__ = _same + + +def present(*xs): + """True when none of `xs` is an ABSENT stand-in.""" + return not any(isinstance(x, Absent) for x in xs) + + +def nth(seq, i=0, what=None): + """`seq[i]`, or an ABSENT saying how many items there were.""" + n = 0 if seq is None else len(seq) + if -n <= i < n: + return seq[i] + return Absent(what or f"[{i}] of {n} item(s)") + + +def first(it, what="no match"): + """`next(it)`, or ABSENT when it is exhausted.""" + return next(iter(it), Absent(what)) + + +def field(mapping, key, what=None): + """`mapping[key]`, or ABSENT when the key (or the mapping) is missing.""" + try: + return mapping[key] + except (KeyError, IndexError, TypeError): + return Absent(what or f"key {key!r}") + + +def given(x, what="None"): + """`x`, or ABSENT when it is None - so `:.2f` and arithmetic cannot raise.""" + return Absent(what) if x is None else x diff --git a/goodwe_controller/test_arbitrage.py b/goodwe_controller/test_arbitrage.py index 737ef3d..89dd3f3 100644 --- a/goodwe_controller/test_arbitrage.py +++ b/goodwe_controller/test_arbitrage.py @@ -79,6 +79,7 @@ import sys from datetime import datetime, timedelta, timezone from app import arbitrage as _arb +from named_absence import first from app import main as _main from app import peakguard as _pg from app.arbiter import ( @@ -933,12 +934,12 @@ check("main.py passes the guard's OWN measured headroom at its one " # once in `__init__` and never refreshed would satisfy the pin above and would # size every trade from the guard decision of whatever cycle happened to run # last - which is the defect the hoist's own comment warns about, one level up. -_s45_cycle = next(n for n in ast.walk(_S45_MAIN_TREE) - if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) - and n.name == "cycle" - and any(isinstance(c, ast.Call) - and ast.unparse(c.func).endswith("arbitrage.decide") - for c in ast.walk(n))) +_s45_cycle = first((n for n in ast.walk(_S45_MAIN_TREE) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) + and n.name == "cycle" + and any(isinstance(c, ast.Call) + and ast.unparse(c.func).endswith("arbitrage.decide") + for c in ast.walk(n))), "no cycle() calling arbitrage.decide") check("...and the assignment is INSIDE the same `cycle()` that makes the call, " "so the headroom the trade is sized from is this cycle's guard decision " "and not one carried from an arbitrary earlier one", @@ -1069,22 +1070,23 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W, res = resolve(claims) target = res.target_w head_w = _pg.charge_headroom_w(pk) if sized else HEADROOM_CLEAR - rec.append(dict( + row = dict( t_s=t, action=arb.action, power_w=arb.power_w, reason=arb.reason, price=arb.price_now_eur_kwh, spread=arb.spread_eur_kwh, winner=res.winner, target_w=float(res.target_w), headroom_w=head_w, engaged=bool(pk.engaged), sized_from_meter=_arb.sized_from_meter( - arb, head_w, winner=res.winner))) + arb, head_w, winner=res.winner)) + rec.append(row) if shadow: # --- TRANSCRIBED 1/3: `_shadow_settle()`'s record, taken # BEFORE the re-seed destroys its evidence. - rec[-1]["would_w"] = float(res.target_w) - rec[-1]["row"] = _shadow.words( + row["would_w"] = float(res.target_w) + row["row"] = _shadow.words( {"shadow_mode": "on"}, control_started=False, would_w=float(res.target_w), - sized_from_meter=rec[-1]["sized_from_meter"], + sized_from_meter=row["sized_from_meter"], reason=res.explain()) # --- TRANSCRIBED 2/3: `_write_unit()`'s substitution - the # plant below is driven from WIRE_W, not from the decision. diff --git a/goodwe_controller/test_clamp.py b/goodwe_controller/test_clamp.py index 8659611..dc033a2 100644 --- a/goodwe_controller/test_clamp.py +++ b/goodwe_controller/test_clamp.py @@ -78,6 +78,8 @@ import re import sys from datetime import datetime, timedelta, timezone +from named_absence import given, nth, present + from app.clamp import ( CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog, clamp_to_rating, @@ -322,15 +324,15 @@ _esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j) T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))] _esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None print(f" budget spent by a benign episode, then a continuous runaway from " - f"t={_RUNAWAY_AT:.0f}s: first alarm at t={_esc_fires[0]:.0f}s " - f"(delay {_esc_delay:.0f}s)") + f"t={_RUNAWAY_AT:.0f}s: first alarm at t={nth(_esc_fires, 0):.0f}s " + f"(delay {given(_esc_delay):.0f}s)") check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode " - f"spent the budget - it is not held for REASSERT_S ({_esc_delay:.0f}s " + f"spent the budget - it is not held for REASSERT_S ({given(_esc_delay):.0f}s " f"delay, escalation is {SUSTAINED_CYCLES} cycles)", _esc_delay is not None and _esc_delay < REASSERT_S / 2) check("...and it speaks at the escalation boundary rather than immediately, " "which is what keeps the scattered flood suppressed", - _esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1)) + _esc_delay is not None and _esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1)) # ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING # PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed @@ -523,7 +525,7 @@ check(f"a long winding episode does NOT flood the 40-row event log " f"({len(rows_l)} rows for {ctl_l.clamp_wd.engagements} engagements)", ctl_l.clamp_wd.engagements > 40 and len(rows_l) <= ctl_l.clamp_wd.episodes) check("...and the count in the row is the counter's, so nothing is lost", - f"{ctl_l.clamp_wd.engagements} engagement(s)" in rows_l[-1]) + f"{ctl_l.clamp_wd.engagements} engagement(s)" in nth(rows_l, -1)) # The RED row exists only while engaged, and the episode above is short, so it # is captured mid-run rather than asserted after the hysteresis has cleared it. check("the status page goes red WHILE it is engaged", @@ -1093,7 +1095,8 @@ check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two " not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to " f"{_asked_x:+.0f} W" in e for e in _rows_x) and f"the computed setpoint {_sent_x:+.0f} W was clamped to " - f"{_asked_x:+.0f} W" not in notes_x[0]["message"]) + f"{_asked_x:+.0f} W" not in nth(notes_x, 0)["message"] + and present(nth(notes_x, 0))) # The DEVICE bound, off part 6c's controller: same sentence, other stage. The # unit of coverage is the call site, and `bound` is the only thing that differs # between the two rows an operator can receive. @@ -1105,7 +1108,7 @@ check("...and the same ordered pair holds on the DEVICE-bound row, where the " and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to " f"{_sent_w:+.0f} W" in e for e in ctl_w.events) and f"the computed setpoint {_asked_w:+.0f} W was clamped to " - f"{_sent_w:+.0f} W" in notes_w[0]["message"]) + f"{_sent_w:+.0f} W" in nth(notes_w, 0)["message"]) # ============================================================================== diff --git a/goodwe_controller/test_control.py b/goodwe_controller/test_control.py index 08c79ac..46a3085 100644 --- a/goodwe_controller/test_control.py +++ b/goodwe_controller/test_control.py @@ -17,6 +17,7 @@ Read MUTATION.md before you report a survivor. import sys +from named_absence import nth from app.control import (LOOP_TICK_S, PLANT_SETTLE_DOWN_S, PLANT_SETTLE_S, SETTLE_CYCLES, SLEW_RAIL_FRAC, Tuning, compute, slew_limit_w, unasked_power, @@ -59,68 +60,34 @@ def check(name, cond): # stopped testing anything is precisely the failure this ticket exists to fix. # A comment cannot go stale-green, because it never claims to be running. # -# Measured at 389d9ec. Numbers are the lead's independent reproduction. -# ⚠️ THE SAFETY-13 ROWS WERE RE-MEASURED IN ROUND 2 AND THREE OF THEM WERE -# WRONG AS FIRST WRITTEN. Every figure below marked (S13) was taken by deleting -# the mechanism, purging __pycache__, running this file in the CI image and -# counting `FAIL` lines, on THIS tree; the release/1.0 comparison figures were -# taken the same way on a pristine c8dcb38 worktree. +# Measured at TEST-26 on a purged tree in the CI image, each run graded to the +# end of the file (98/99 where the `if _bad:` check is skipped). Before TEST-26 +# the rows marked * killed the file with an IndexError or ZeroDivisionError in +# a check, so their earlier figures were short; those checks now fail by name. # -# - `output freeze` was written as 11. The mutation form its own note -# describes - the whole `target = min(...)/max(...)` line removed, `reason` -# left alone - gives 6 on this tree AND 6 on the first SAFETY-13 commit, -# against 2 on release/1.0. 11 is not reproducible by that form, or by -# removing the line and its `reason` (4), or by swapping min/max (12). The -# row is 6. -# - `saturation detector, saturated_now = False` was left at 11 while the -# SAFETY-13 section added checks that grade it. It is 15. -# - the footnote's `frozen = False` figure was left at 10 for the same -# reason. It is 13. -# -# The rows below `output freeze` are this ticket's own mechanisms: removing the -# EXIT restores release/1.0's unbounded freeze; making `unasked_power()` return -# True gives an exit to the limit case as well, which is the half of the -# decision that has to stay unbounded; deriving SETTLE_CYCLES from -# PLANT_SETTLE_S (18 s, the UP ramp) instead of PLANT_SETTLE_DOWN_S (23 s) is -# the round-1 defect itself; and pinning `_SAT_COUNT_CAP` back at release/1.0's -# literal 10 caps the counter below the bound and deletes the exit silently. -# -# ⚠️ ONLY THE ROWS THIS BRANCH MOVES WERE RE-MEASURED. Everything not marked -# (S13) still carries 389d9ec's figures and was not re-run here. -# -# ⚠️ THE SLEW ROWS WERE RE-MEASURED AT SAFETY-39 AND ONE OF THEM MOVED: the -# slew limit was 4 and is now 5, because the SAFETY-39 section grades it too. -# The new row is the RAIL DEFAULT specifically - `slew_limit_w()` reduced to -# `return abs(float(tuning.slew_w))`, which leaves the limit intact and only -# removes the 0-means-the-rail resolution. Three checks notice, which is over -# the invariant's floor of two. Both figures were reproduced by deleting the -# mechanism, running this file and counting, on a tree with __pycache__ purged. -# -# mechanism in compute() checks that fail when deleted -# ------------------------------------------ ----------------------------- -# integrator freeze (AC 3) 2 -# integrator clamp (AC 1) 6 -# integrator bound follows max_w 4 -# output clamp 3 -# slew limit 5 -# slew follows the rail (SAFETY-39), `slew_w` read raw 3 -# output freeze (S13) 6 -# the output freeze's EXIT (S13), freeze made unconditional again 4 -# `unasked_power()` discriminates, replaced by `return True` (S13) 6 -# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9 -# `_SAT_COUNT_CAP` pinned back at release/1.0's 10 (S13) 6 -# deadband 5 -# quantisation 2 -# saturation detector, `saturated_now = False` (S13) 15 -# saturation duration (AC 2), fires instantly 2 -# sat counter reset on a good cycle 6 -# target_grid_w bias 3 -# i_w=None seeding from prev_w 6 -# -# The detector figure is for the `saturated_now = False` form specifically; -# disabling it further down as `frozen = False` is a weaker mutation and gives -# 13 on this tree (10 on release/1.0). Reproduce the same form or the number -# will not match. +# mechanism in compute(), and the exact form deleted checks that fail +# --------------------------------------------------------- ---------------- +# integrator freeze: `if not frozen or i_w == 0.0` -> `if True` 6 +# integrator clamp: `bounded = i_w` 6 +# integrator bound follows max_w: default `limit = 5000.0` 3 +# output clamp: `target = want` 3 +# slew limit: `slewed = target` 17 +# slew follows the rail (SAFETY-39): `slew_limit_w()` returns +# `abs(float(tuning.slew_w))` * 9 +# output freeze: the `target = min(...)/max(...)` line removed 7 +# output freeze: `if frozen:` -> `if False:` * 14 +# the output freeze's EXIT (S13): its `if` -> `if False` * 11 +# `unasked_power()` returns True (S13) 6 +# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9 +# `_SAT_COUNT_CAP = 10`, release/1.0's literal (S13) * 11 +# deadband: its `if` -> `if False` 7 +# quantisation: `target = target` 4 +# saturation detector: `saturated_now = False` * 27 +# saturation detector: `frozen = False` * 25 +# saturation duration fires instantly: `sat_count >= 1` 5 +# sat counter not reset on a good cycle: `else sat_count` 5 +# target_grid_w bias: `error = grid_w` 5 +# i_w=None seeding from prev_w: `i_w = 0.0` 11 # # ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in # for the mechanism under test. Deleting the integrator freeze once failed @@ -142,10 +109,9 @@ def check(name, cond): # mutation run always has one, because the previous run wrote it. -B is still # worth passing, for the NEXT edit rather than this one. # -# The error is one-directional - stale bytecode can only under-report - so -# every figure above is a lower bound at worst, and the two zeros ever recorded -# were both confirmed by fixing them and watching the count rise, which a -# caching artefact cannot do. +# Two things under-report a figure here, both in the same direction: stale +# bytecode, and a check that raises and stops the file before the rest are +# graded (MUTATION.md, FCST-06). The second is what the * rows above had. # --------------------------------------------------------------------------- print("control law") @@ -520,7 +486,7 @@ for _ in range(20): rows.append((prev, d.target_w, d.i_w, d.reason)) prev, i_w, sat = d.target_w, d.i_w, d.sat_count pinned = [r for r in rows if r[3] == "saturated-freeze"] -check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...", +check(f"non-zero prev_w, another writer: pinned at {nth(pinned, 0)[1]:.0f} W...", pinned and all(r[1] == -800.0 for r in pinned)) # ⚠️ SAFETY-48 MOVED THIS NUMBER FROM -2390 W TO -1200 W, DELIBERATELY, AND # THE PROPERTY IT IS HERE FOR IS UNCHANGED. The release lets the command walk @@ -550,9 +516,9 @@ for c in range(24): cmds.append(d.target_w) prev, i_w, sat = d.target_w, d.i_w, d.sat_count check("the card's case: the block does hold at first, at 0 W", - cmds[6] == 0.0 and cmds[7] == 0.0) + nth(cmds, 6) == 0.0 and nth(cmds, 7) == 0.0) started = [i for i, c in enumerate(cmds) if c != 0.0] -check(f"the card's case: the charge starts at cycle {started[0]}" +check(f"the card's case: the charge starts at cycle {nth(started, 0)}" f" instead of never", bool(started)) # ⚠️ AND IT STOPS AT THE FROZEN INTEGRATOR, NOT AT THE RAIL. This is AC 4: the # integrator moved while the output was blocked, and the release must not turn @@ -991,7 +957,8 @@ check("the shipped Tuning default is 0, i.e. the rail, so the law carries no " # serial, per goodwe/NOTES.md §1.1b. It is here because the ARITHMETIC is what # is being graded, and the arithmetic does not care. _rails = [100.0, 500.0, 1500.0, 2000.0, 5000.0, 9600.0, 10000.0] -_ticks = {r: r / slew_limit_w(Tuning(max_w=r)) for r in _rails} +# A zero limit grades as nan ticks (a named FAIL), not a ZeroDivisionError. +_ticks = {r: r / (slew_limit_w(Tuning(max_w=r)) or float("nan")) for r in _rails} print(" ticks to cross the rail at the default, by rail: " + ", ".join(f"{r:.0f}W={t:.1f}" for r, t in _ticks.items())) check("⭐ AT EVERY RAIL THE DEFAULT TAKES THE SAME NUMBER OF TICKS TO CROSS IT - " @@ -1010,7 +977,7 @@ check("...and that is the same as saying the crossing takes the plant's own " # refuses on, and it is asserted HERE too so that the law and the gate cannot # drift apart - the rule can only ever refuse what this ratio says is too fast. _broken = Tuning(max_w=5000.0, slew_w=1000.0) -_broken_s = 5000.0 / slew_limit_w(_broken) * LOOP_TICK_S +_broken_s = 5000.0 / (slew_limit_w(_broken) or float("nan")) * LOOP_TICK_S print(f" the 2026-09-05 configuration (max_w 5000, slew_w 1000) crosses its " f"rail in {_broken_s:.0f} s against a plant measured at {PLANT_SETTLE_S:.0f} s") check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by " diff --git a/goodwe_controller/test_delivery.py b/goodwe_controller/test_delivery.py index f7cf11e..29d3068 100644 --- a/goodwe_controller/test_delivery.py +++ b/goodwe_controller/test_delivery.py @@ -31,6 +31,8 @@ import re import sys from datetime import datetime, timedelta, timezone +from named_absence import given, present + from app.control import Tuning from app.delivery import ( DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE, @@ -738,8 +740,8 @@ async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC): _c_ship, _r_ship = asyncio.run(_at_saturation_w(_SAT_SHIPPED)) _c_up, _r_up = asyncio.run(_at_saturation_w(_SAT_RAISED)) _b_ship, _b_up = _c_ship.inverters[1], _c_up.inverters[1] -_gap_ship = short_by(_b_ship.standing_w(), _b_ship.batt) -_gap_up = short_by(_b_up.standing_w(), _b_up.batt) +_gap_ship = given(short_by(_b_ship.standing_w(), _b_ship.batt), "B not short") +_gap_up = given(short_by(_b_up.standing_w(), _b_up.batt), "B not short") check(f"the raised threshold is a config an operator can actually enter: " f"{_SAT_RAISED} W is inside the shipped schema " @@ -762,7 +764,7 @@ check("the raised arm REACHED the comparison: B was asked for a discharge " "share, is short of it, is live, is not parked, and NOTHING accounts for " "it - the only thing standing between this unit and an alarm is the " "configured watts", - float(_b_up.standing_w()) > 0 and _gap_up is not None + float(_b_up.standing_w()) > 0 and present(_gap_up) and not _b_up.sign_wd.tripped and account(live=True, parked=False, soc=_b_up.soc, bms_floor=_b_up.bms_soc_floor) is None) @@ -780,7 +782,7 @@ check("...and SAFETY-43's RELATIVE arm is silent on both arms of this fixture, " check("...and its shortfall lands STRICTLY BETWEEN the two configured " "thresholds, which is the one place the two answers can differ. Part 4's " "fixtures are all above both, which is exactly why they cannot see this", - _gap_up is not None and float(_SAT_SHIPPED) < _gap_up < float(_SAT_RAISED)) + present(_gap_up) and float(_SAT_SHIPPED) < _gap_up < float(_SAT_RAISED)) check("AT THE SHIPPED THRESHOLD THE UNIT IS NAMED", _r_ship[-1]["named"] == ("inverter-b",)) diff --git a/goodwe_controller/test_freeze.py b/goodwe_controller/test_freeze.py index cdd6cd7..63f30e3 100644 --- a/goodwe_controller/test_freeze.py +++ b/goodwe_controller/test_freeze.py @@ -821,9 +821,9 @@ all_notes = all_notes_of(hass_f) # the whole surface # install has. Flipped here rather than plumbing a whole P1 transport in - the # row's own content is what is under test. ctl_f.p1_enabled = True -rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]] +frozen_rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]] print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, " - f"{len(notes)} notification(s), {len(rows)} red status row(s)") + f"{len(notes)} notification(s), {len(frozen_rows)} red status row(s)") # SURFACE-WIDE, deliberately, and the second guard has now landed. SAFETY-03 # ships `goodwe_output_clamp`, and this scenario - a runaway loop winding into # the rail - raises it legitimately: the loop asks for more than the rating and @@ -849,7 +849,7 @@ check("...with a fixed notification_id so repeats replace, not stack", check("...and it says the controller is still running", bool(notes) and "STILL RUNNING" in notes[0]["message"]) check("the status page carries a failed row saying so", - len(rows) == 1 and not rows[0]["ok"] and not rows[0]["warn"]) + len(frozen_rows) == 1 and not frozen_rows[0]["ok"] and not frozen_rows[0]["warn"]) check("the event log names it", any("METER FROZEN" in e for e in ctl_f.events)) # ⚠️ NOTIFY, NOT TRIP. If a later cut turns this into a trip it must come here # and change this expectation deliberately, not discover it in the field. diff --git a/goodwe_controller/test_freshness.py b/goodwe_controller/test_freshness.py index e04f6ab..89f9005 100644 --- a/goodwe_controller/test_freshness.py +++ b/goodwe_controller/test_freshness.py @@ -69,6 +69,7 @@ from datetime import datetime, timezone from pathlib import Path from app import freshness as F +from named_absence import first, given, nth, present from app.circulation import circulating_w from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness from app.inverter import Inverter @@ -253,11 +254,12 @@ check("FRESH AND UNCHANGED is not stale. Forty identical readings over 39 s " check("⚠️ STALE IS. The same forty readings, the same instants, the same " "numbers - and the command moved 2500 W underneath them. The reading " "becomes None to its consumers, which is the whole card", - _stale.stale and _stale_read[-1] is None) + _stale.stale and bool(_stale_read) and _stale_read[-1] is None) check("...and it becomes None at the BOUND, not before: 35.0 s of unchanged " "arrivals, so the last fresh verdict is at t=34 and the first None at " "t=35", - _stale_read[34] == A_FROZEN_W and _stale_read[35] is None + len(_stale_read) > 35 + and _stale_read[34] == A_FROZEN_W and _stale_read[35] is None and BATT_UNCHANGED_S == 35.0) check("...and NEVER a held number and never a zero - `reading()` has exactly " "two shapes, the arrived value or None", @@ -449,14 +451,14 @@ _cu, _circu, _tripu, _standu, _ = run(_cycles(False)) _WD_S = 2.0 # `unit_sign_disagree_s` as this fixture sets it, above print(f" standing on A (gated): {[round(v) for v in _standg]}") print(f" gated : sign trip tick={_tripg} circ={_circg[:6]}...") -print(f" gate closed after {_tgate:.2f} s of WALL clock, against the " - f"{_WD_S:.0f} s watchdog window ({_WD_S / _tgate:.1f}x margin)") +print(f" gate closed after {given(_tgate):.2f} s of WALL clock, against the " + f"{_WD_S:.0f} s watchdog window ({_WD_S / given(_tgate):.1f}x margin)") print(f" counterfact: sign trip tick={_tripu} circ={_circu[:6]}...") check("⚠️ PRECONDITION ON THE WALL CLOCK, AND IT IS A CHECK RATHER THAN AN " "ASSUMPTION (CI-06). The gate closes on the INJECTED clock, the watchdog " "on the real one, so this fixture only means something while the gate " - f"closes well inside the window: it closed at {_tgate:.2f} s against " + f"closes well inside the window: it closed at {given(_tgate):.2f} s against " f"{_WD_S:.0f} s. If this fails the machine was slow, not the code - and " "saying so here is what stops that being read as a defect", _tgate is not None and _tgate < 0.5 * _WD_S) @@ -667,13 +669,13 @@ print("6. THE BOUNDS, graded against the SHIPPED config.yaml at BOTH ends") def option_default(key): - """`key`'s default out of config.yaml's `options:` block, at two spaces.""" + """`key`'s default out of config.yaml's `options:` block, at two spaces - or ABSENT.""" body = io.open(HERE / "config.yaml", encoding="utf-8").read() body = body[body.index("\noptions:"):body.index("\nschema:")] for line in body.splitlines(): if line.startswith(f" {key}:") and not line[2:3].isspace(): return float(line.split(":", 1)[1].strip()) - return None + return given(None, f"{key} not in config.yaml options") _circ_hold = option_default("circulation_hold_s") @@ -922,11 +924,11 @@ for _day in _loop_days: _prev8b = _f8b.stale _t += 1.0 _waits.sort() -_p50 = _waits[len(_waits) // 2] +_p50 = nth(_waits, len(_waits) // 2) print(f" {len(_loop_days)} days replayed at 1 Hz in " f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips") print(f" wait for the register's next change after a trip: " - f"min {_waits[0]:.1f} s median {_p50:.1f} s max {_waits[-1]:.1f} s; " + f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; " f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's " f"710 s") @@ -940,7 +942,7 @@ check("⚠️ THE OLD EXIT WAS NOT RARE AND IT WAS NOT SHORT. Ninety-three trips "143.4 s and as long as 21440.1 s. Seventeen of the ninety-three are at " "or beyond the 710 s the live site was parked for on 2026-09-21", _trips8b == 93 and abs(_p50 - 143.4) < 0.1 - and abs(_waits[-1] - 21440.1) < 0.1 + and abs(nth(_waits, -1) - 21440.1) < 0.1 and sum(1 for w in _waits if w >= 710.0) == 17) check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE - " "which is the whole reason the exit could not stay as it was. The short " @@ -1072,6 +1074,8 @@ def _decoder(): def hms(t): + if not present(t): + return repr(t) return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{int(t % 60):02d}" @@ -1241,9 +1245,9 @@ _holds = [(_bt_moves[i + 1][0] - _bt_moves[i][0], _bt_moves[i][0], _bt_moves[i + 1][0], _bt_moves[i][1]) for i in range(len(_bt_moves) - 1)] _HOLD_S, _HOLD_FROM, _REG_MOVES, _HOLD_W = max(_holds) -_TRIP = next(t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM) -_PARK = next(t for t, v in _sp if t > _TRIP and v == 0.0) -_HUMAN = next(t for t, v in _sp if t > _PARK and v != 0.0) +_TRIP = first((t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM), "no trip") +_PARK = first((t for t, v in _sp if t > _TRIP and v == 0.0), "no 0 W after the trip") +_HUMAN = first((t for t, v in _sp if t > _PARK and v != 0.0), "no command after the park") _blips = [t for t, s, _h, _sw, _r in _edges(_rows_after) if s and t < _HOLD_FROM] print(f" longest register hold {_HOLD_S:.1f} s at {_HOLD_W:+.0f} W, " @@ -1259,8 +1263,8 @@ def _first_clear_after(rows, t_from): return next((t for t, s, _h, _sw, _r in rows if t > t_from and not s), None) -_clear_before = _first_clear_after(_rows_before, _TRIP) -_clear_after = _first_clear_after(_rows_after, _TRIP) +_clear_before = given(_first_clear_after(_rows_before, _TRIP), "no clear") +_clear_after = given(_first_clear_after(_rows_after, _TRIP), "no clear") print(f" BEFORE (release_s=inf): stale {hms(_TRIP)} -> clear " f"{hms(_clear_before)} {_clear_before - _TRIP:.0f} s") print(f" AFTER (release_s={BOUNDS['batt'].release_s:.0f}): stale " @@ -1279,7 +1283,8 @@ check("PRECONDITION: the episode is in the capture where the manifest says it " "several. 0x0518 answers -3 W for 850.0 s from 10:51:24, the gate arms " "at 10:52:00, the site's own 0 W reaches the wire at 10:52:21, and " "nothing else moves until a human does at 11:05:31", - abs(_HOLD_S - 850.0) < 0.1 and _HOLD_W == -3.0 + present(_TRIP, _PARK, _HUMAN) + and abs(_HOLD_S - 850.0) < 0.1 and _HOLD_W == -3.0 and hms(_HOLD_FROM) == "10:51:24" and hms(_TRIP) == "10:52:00" and hms(_PARK) == "10:52:21" and hms(_HUMAN) == "11:05:31" and hms(_REG_MOVES) == "11:05:34" @@ -1310,7 +1315,7 @@ check("⚠️ GREEN AFTER, AND THE REGISTER NEVER MOVES - THIS IS THE CARD. The < _PARK + BOUNDS["batt"].release_s + 1.0 and hms(_clear_after) == "10:52:39" and _f_after.releases >= 1 and _D.held_at(_bt, _clear_after) == _D.held_at(_bt, _TRIP) == -3.0 - and next(r for r in _rows_after if r[0] == _clear_after)[2] == -3.0) + and first(r for r in _rows_after if r[0] == _clear_after)[2] == -3.0) check("...and the recovery is worth 776 s of this one episode: the site is " "handed its telemetry back at 10:52:39 instead of at 11:05:35, and 772 " "of those seconds are BEFORE the human touched anything. That is the " @@ -1555,8 +1560,9 @@ async def _wired_release(): _c57, _seen57 = run(_wired_release()) -_trip57 = next(t for t, b, _ok in _seen57 if b is None) -_back57 = next(t for t, b, _ok in _seen57 if t > _trip57 and b is not None) +_trip57 = first((t for t, b, _ok in _seen57 if b is None), "batt never None") +_back57 = first((t for t, b, _ok in _seen57 if t > _trip57 and b is not None), + "batt never back") print(f" real read path: batt None from t={_trip57}, a number again at " f"t={_back57}, register held {A_FROZEN_W:+.0f} throughout") check("⚠️ THROUGH THE SHIPPED READ PATH, NOT THE POLICY OBJECT. " @@ -1581,12 +1587,12 @@ check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATO "the event log and the logger, in order, naming the unit", len(_ev57) == 2 and "STALE inverter-a" in _ev57[1] and "STALE WITHDRAWN inverter-a" in _ev57[0] - and len(_rel57) == 1 and "inverter-a" in _rel57[0]) + and len(_rel57) == 1 and "inverter-a" in nth(_rel57, 0)) check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has " "happened: it says in its own words that nothing has been learned about " "the register, and it prints the value the register is STILL holding", - "NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in _rel57[0] - and f"still holding {A_FROZEN_W:+.0f}" in _rel57[0]) + "NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in nth(_rel57, 0) + and f"still holding {A_FROZEN_W:+.0f}" in nth(_rel57, 0)) # --- 10h. the two sentences, as ORDERED PAIRS (habit 14) --------------------- @@ -1596,7 +1602,7 @@ check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has # escape clause, and 250 W and 17.0 s again in the release note - so each is # asserted with its value embedded in its own literal position. _stale57 = [m for m in _msgs57 if "SAFETY-37" in m] -print(f" escape clause: ...{_stale57[0][-150:]}") +print(f" escape clause: ...{nth(_stale57, 0)[-150:]}") check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED " "PAIR. It used to end 'It clears itself the moment the register reports " "a different value.' - which was FALSE, because the 0 W in the sentence " @@ -1604,16 +1610,16 @@ check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDER "17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes " "red", f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing " - f"command falls below {BOUNDS['batt'].move_w:.0f} W" in _stale57[0]) + f"command falls below {BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0)) check("...and the release note names 250 W in the POWER slot and 17 s in the " "TIME slot, in that order, which is the reverse arrangement of the same " "two numbers", f"sat below {BOUNDS['batt'].move_w:.0f} W for " - f"{BOUNDS['batt'].release_s:.0f} s" in _rel57[0]) + f"{BOUNDS['batt'].release_s:.0f} s" in nth(_rel57, 0)) check("...and the 35.0 s the register is re-graded over is named in the note " "too, because it is what the operator is being told to wait out", f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window" - in _rel57[0]) + in nth(_rel57, 0)) check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, " "WHICH IS WHAT THIS CHECK WAS FOR. It used to require 'battery-power " "register is not following its own command' to still be present, so " @@ -1622,7 +1628,8 @@ check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, "requirement is now the inverse: the accusation is GONE from this line " "and part 11 grades what replaced it. The escape clause above is " "SAFETY-57's and is untouched by it", - "battery-power register is not following its own command" + present(nth(_stale57, 0)) + and "battery-power register is not following its own command" not in _stale57[0] and "did not move under a command that did" in _stale57[0]) check("...and a re-trip's reason names the TWO durations separately, each " @@ -1678,11 +1685,11 @@ print("11. SAFETY-56: the sentence this gate can honestly say") # story. Every boundary is derived in part 10b from the register's own longest # hold; nothing here is typed. _S56_CMDS_HELD = sorted({_D.held_at(_sp, t) - for t in range(int(_HOLD_FROM), int(_PARK))}) + for t in range(int(_HOLD_FROM), int(_PARK) if present(_PARK) else 0)}) _S56_SOC_HELD = sorted({_D.held_at(_soc, t) - for t in range(int(_HOLD_FROM), int(_HUMAN))}) -_S56_CHARGE_T, _S56_CHARGE_W = next((t, v) for t, v in _sp - if t > _PARK and v < 0.0) + for t in range(int(_HOLD_FROM), int(_HUMAN) if present(_HUMAN) else 0)}) +_S56_CHARGE = first(((t, v) for t, v in _sp if t > _PARK and v < 0.0), "no charge command") +_S56_CHARGE_T, _S56_CHARGE_W = _S56_CHARGE[0], _S56_CHARGE[1] _S56_AFTER = _D.held_at(_bt, _REG_MOVES + 5.0) print(f" during the 850 s hold: commands {_S56_CMDS_HELD} W, " f"SoC {_S56_SOC_HELD} %, register {_HOLD_W:+.0f} W") @@ -1695,7 +1702,7 @@ check("11a. ⚠️ THE OLD DIAGNOSIS WAS FALSE ON THE ONE SITE IT WAS EVER " "-3 W. Then the first CHARGE command arrives and it moves within ONE " "17.0 s obligation window. A register that answers a reversed command in " "one window had not stopped reporting; what would not move was the PACK", - all(v > 0.0 for v in _S56_CMDS_HELD) + bool(_S56_CMDS_HELD) and all(v > 0.0 for v in _S56_CMDS_HELD) and min(_S56_CMDS_HELD) == 260.0 and max(_S56_CMDS_HELD) == 1060.0 and _S56_SOC_HELD == [11.0] and _HOLD_W == -3.0 and _S56_CHARGE_W < 0.0 @@ -1711,7 +1718,7 @@ check("...and the gate was RIGHT to fire anyway, which is why this card touches and BOUNDS["batt"].move_w == MOVE_W and BOUNDS["batt"].release_s == F.BATT_OBLIGED_S and hms(_TRIP) == "10:52:00" and _f_after.releases == 1 - and next(r for r in _rows_after if r[0] == _TRIP)[1] is True) + and first(r for r in _rows_after if r[0] == _TRIP)[1] is True) # --- 11b. two worlds, one published stream, one verdict ---------------------- # ⚠️ GROUND TRUTH IS WRITTEN DOWN, NOT DERIVED FROM THE PUBLISHED STREAM @@ -1817,10 +1824,10 @@ def _s56_row(c): retried rather than read. Measured: 0 named reds before, 2 after. """ hits = [r for r in c.checks() if r["text"].startswith("battery power")] - if len(hits) != 1: - return {"ok": None, "warn": None, - "text": f""} - return hits[0] + if len(hits) == 1: + return hits[0] + return {"ok": None, "warn": None, + "text": f""} _row56_ref = _s56_row(_c56_ref) diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py index 066051c..5e2e622 100644 --- a/goodwe_controller/test_inverter.py +++ b/goodwe_controller/test_inverter.py @@ -73,6 +73,8 @@ import time from datetime import datetime, timedelta, timezone from pathlib import Path +from named_absence import first, given, nth, present + from app import main as app_main from app.inverter import LINK_STALE_S, UNIT_A, Inverter, build_roster from app.main import WRITE_BUDGET_S, Controller @@ -993,8 +995,9 @@ _compute_calls = [n for n in ast.walk(MAIN_AST) and n.func.id == "compute"] check("main.py has exactly ONE `compute(` call site, so the argument graded " "below is unambiguously the shipped one", len(_compute_calls) == 1) -_actual_src = ast.unparse( - next(k.value for k in _compute_calls[0].keywords if k.arg == "actual_w")) +_actual_kw = first((k.value for k in nth(_compute_calls, 0).keywords if k.arg == "actual_w"), + "no actual_w= keyword") +_actual_src = ast.unparse(_actual_kw) if present(_actual_kw) else repr(_actual_kw) _site_batt_assign = [ast.unparse(n.value) for n in ast.walk(CTRL) if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name) @@ -1073,7 +1076,7 @@ print("6. AC 6: independent heartbeats, with the hang INJECTED") # # Two things fix it and both are graded: the writes go out CONCURRENTLY, and # each one is bounded by `WRITE_BUDGET_S`. -_HB_MAX = read_schema_max("heartbeat_s") +_HB_MAX = given(read_schema_max("heartbeat_s"), "heartbeat_s bound") print(f" WRITE_BUDGET_S {WRITE_BUDGET_S:.0f} s + the schema's largest " f"heartbeat_s {_HB_MAX:.0f} s = {WRITE_BUDGET_S + _HB_MAX:.0f} s, against " f"layer 1's {FIRMWARE_INGEST_VETO_S:.0f} s veto") diff --git a/goodwe_controller/test_named_absence.py b/goodwe_controller/test_named_absence.py new file mode 100644 index 0000000..416fee8 --- /dev/null +++ b/goodwe_controller/test_named_absence.py @@ -0,0 +1,426 @@ +"""Runnable check for TEST-26 - a missing value must be a NAMED red. `python3 test_named_absence.py` + +Every test file here is one script. A check or print that RAISES on a missing +value (an empty list indexed, `next()` exhausted, `:.0f` on None) kills every +check after it: the mutation it should name shows up as one unnamed traceback, +and the checks below it are never graded (MUTATION.md, FCST-06). + +1. `named_absence.py`, the shared non-raising accessors. +2. A scan of every `test_*.py` for the shapes that raise, which must find none. +3. What the scan cannot see, counted on every run. +""" +import ast +import sys +from collections import Counter +from pathlib import Path + +from named_absence import field, first, given, nth, present + +HERE = Path(__file__).resolve().parent + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +# ============================================================================== +print("1. the accessors never raise, and an absence is never a pass") +# ============================================================================== +_a = nth([], 0) +_b = first(x for x in (1, 2) if x > 5) +_c = field({"a": 1}, "pv") +_d = given(None) +print(f" {_a!r} {_b!r} {_c!r} {_d!r}") +check("each accessor hands back the value when it is there", + nth([7, 8], -1) == 8 and first(iter([3])) == 3 + and field({"pv": 0.0}, "pv") == 0.0 and given(0.0) == 0.0 + and present(0.0, None, "", [])) +check("...and a stand-in that says what was missing when it is not", + repr(_a) == "" and repr(_c) == "" + and not present(_a) and not present(1, _b) and not present(_c, _d)) +check("formatting, indexing, attributes and arithmetic do not raise", + f"{_a:.2f}|{_a[2]['x']:+.0f}|{_d.attr.get('k'):,}|{_d / 2 - 1:.1f}" + == "|".join([""] * 2 + [""] * 2)) +check("an absence is never equal, unequal, ordered or truthy - so `==`, `!=` " + "and `<` grade it FAIL", + not (_a == 0) and not (_a != 0) and not (_a < 1) and not (_a >= 1) + and not bool(_a) and not (_a == _a)) + + +# ============================================================================== +print() +print("2. no test file reads a possibly-missing value in a way that raises") +# ============================================================================== +# The scan flags, anywhere in a file: +# index `name[k]` (constant k) where `name` holds a FILTERED list - a +# comprehension with an `if`, `filter()`, or an accumulator started +# as `[]` - directly or as returned by a function in the same file; +# next `next(it)` without a default; +# format a numeric format spec on a name that may be None - assigned None, +# an `... else None`, a one-argument `.get()`, or tested `is None`. +# A site is guarded when an earlier operand of the same `and`/`or`, or the +# test of an enclosing `if`/`while`/conditional expression, names the value, +# or it sits in a `try`. A check's NAME is evaluated before its condition, so +# a guard in the condition does not guard the name. +_NUMFMT = set("fdeEgGn%xXbo") +_FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) +_COMPS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp) + + +def _filtered(rhs, returns): + if isinstance(rhs, (ast.ListComp, ast.GeneratorExp)): + return any(g.ifs for g in rhs.generators) + if isinstance(rhs, ast.List): + return not rhs.elts + if isinstance(rhs, ast.Call) and isinstance(rhs.func, ast.Name): + f = rhs.func.id + if f == "filter": + return True + if f in ("list", "sorted", "tuple") and rhs.args: + return _filtered(rhs.args[0], returns) + return "listy" in returns.get((f, None), ()) + return False + + +def _maybe_none(rhs, returns): + if isinstance(rhs, ast.Constant): + return rhs.value is None + if isinstance(rhs, ast.IfExp): + return _maybe_none(rhs.body, returns) or _maybe_none(rhs.orelse, returns) + if isinstance(rhs, ast.Call): + if isinstance(rhs.func, ast.Attribute) and rhs.func.attr == "get": + return len(rhs.args) == 1 or (len(rhs.args) == 2 and _maybe_none(rhs.args[1], returns)) + if isinstance(rhs.func, ast.Name): + return "noney" in returns.get((rhs.func.id, None), ()) + return False + + +class _Scope: + def __init__(self, node, parent): + self.node, self.parent = node, parent + self.bound, self.listy, self.noney = set(), set(), set() + + +def _dump(n): + return ast.dump(n) + + +def _mentions(expr, base): + """`base` appears in `expr` other than as the thing being indexed, so + `x[0] == 1 and x[1]` does not count as guarding `x[1]`.""" + b = _dump(base) + inner = {id(n.value) for n in ast.walk(expr) if isinstance(n, ast.Subscript)} + return any(_dump(x) == b and id(x) not in inner for x in ast.walk(expr)) + + +def _exits_on(st, base): + """`if not x: return` (or continue/break/raise) - x is guarded below it.""" + return (isinstance(st, ast.If) and _mentions(st.test, base) and st.body + and isinstance(st.body[-1], (ast.Return, ast.Continue, ast.Break, ast.Raise))) + + +def scan(src, census=None): + """[(line, kind, text)] for every unguarded raising read in `src`.""" + tree = ast.parse(src) + parent = {c: p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)} + + def scope_node(n): + p = parent.get(n) + while p is not None and not isinstance(p, _FUNCS + _COMPS + (ast.Module,)): + p = parent.get(p) + return p + + scopes = {tree: _Scope(tree, None)} + for n in ast.walk(tree): + if isinstance(n, _FUNCS + _COMPS): + scopes[n] = None + for n in list(scopes): + if n is not tree: + scopes[n] = _Scope(n, None) + for n, s in scopes.items(): + if n is not tree: + s.parent = scopes[scope_node(n)] + + def targets(t): + if isinstance(t, ast.Name): + yield t + elif isinstance(t, (ast.Tuple, ast.List)): + for e in t.elts: + yield from targets(e) + elif isinstance(t, ast.Starred): + yield from targets(t.value) + + for n in ast.walk(tree): + if isinstance(n, _FUNCS): + a = n.args + for arg in a.posonlyargs + a.args + a.kwonlyargs + [a.vararg, a.kwarg]: + if arg is not None: + scopes[n].bound.add(arg.arg) + elif isinstance(n, ast.comprehension): + for t in targets(n.target): + scopes[scope_node(n)].bound.add(t.id) + elif isinstance(n, (ast.For, ast.AsyncFor, ast.With, ast.AugAssign, ast.NamedExpr)): + ts = ([n.target] if not isinstance(n, ast.With) + else [i.optional_vars for i in n.items if i.optional_vars]) + for t0 in ts: + for t in targets(t0): + scopes[scope_node(n)].bound.add(t.id) + + # `x = if x is None else x` makes x not-None: a scope that + # normalises x this way does not get x marked by its `is None` tests. + normalised = set() + for n in ast.walk(tree): + if (isinstance(n, ast.Assign) and isinstance(n.value, ast.IfExp) + and isinstance(n.value.test, ast.Compare)): + for t in n.targets: + if isinstance(t, ast.Name) and any(isinstance(x, ast.Name) and x.id == t.id + for x in ast.walk(n.value.test)): + normalised.add((scope_node(n), t.id)) + + # Two passes, so a function's return shape is known before its callers. + returns = {} + for _ in range(2): + for n in ast.walk(tree): + if isinstance(n, (ast.Assign, ast.AnnAssign)) and n.value is not None: + s = scopes[scope_node(n)] + for t0 in (n.targets if isinstance(n, ast.Assign) else [n.target]): + if isinstance(t0, ast.Name): + s.bound.add(t0.id) + if _filtered(n.value, returns): + s.listy.add(t0.id) + if _maybe_none(n.value, returns): + s.noney.add(t0.id) + elif (isinstance(t0, ast.Tuple) and isinstance(n.value, ast.Tuple) + and len(t0.elts) == len(n.value.elts)): + for e, v in zip(t0.elts, n.value.elts): + for t in targets(e): + s.bound.add(t.id) + if _filtered(v, returns): + s.listy.add(t.id) + if _maybe_none(v, returns): + s.noney.add(t.id) + elif (isinstance(t0, ast.Tuple) and isinstance(n.value, ast.Call) + and isinstance(n.value.func, ast.Name)): + for i, e in enumerate(t0.elts): + for t in targets(e): + s.bound.add(t.id) + got = returns.get((n.value.func.id, i), ()) + if "listy" in got: + s.listy.add(t.id) + if "noney" in got: + s.noney.add(t.id) + else: + for t in targets(t0): + s.bound.add(t.id) + elif (isinstance(n, ast.Compare) + and any(isinstance(o, (ast.Is, ast.IsNot)) for o in n.ops) + and any(isinstance(c, ast.Constant) and c.value is None + for c in [n.left] + n.comparators)): + for x in [n.left] + n.comparators: + if isinstance(x, ast.Name) and (scope_node(n), x.id) not in normalised: + s = scopes[scope_node(n)] + while s.parent is not None and x.id not in s.bound: + s = s.parent + s.noney.add(x.id) + for fn in tree.body: + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + s = scopes[fn] + for r in ast.walk(fn): + if not isinstance(r, ast.Return) or r.value is None or scope_node(r) is not fn: + continue + vals = r.value.elts if isinstance(r.value, ast.Tuple) else [r.value] + for i, v in enumerate(vals): + key = (fn.name, i if isinstance(r.value, ast.Tuple) else None) + tags = returns.setdefault(key, set()) + if isinstance(v, ast.Name) and v.id in s.listy or _filtered(v, returns): + tags.add("listy") + if isinstance(v, ast.Name) and v.id in s.noney or _maybe_none(v, returns): + tags.add("noney") + + def resolve(name_node, attr): + s = scopes[scope_node(name_node)] + while s is not None: + if name_node.id in s.bound or name_node.id in getattr(s, attr): + return name_node.id in getattr(s, attr) + s = s.parent + return False + + def guarded(node, base): + ch, p = node, parent.get(node) + while p is not None: + if isinstance(p, ast.BoolOp) and ch in p.values: + if any(_mentions(v, base) for v in p.values[:p.values.index(ch)]): + return True + elif isinstance(p, (ast.IfExp, ast.If, ast.While)): + if ch is not p.test and _mentions(p.test, base): + return True + elif isinstance(p, (ast.ListComp, ast.GeneratorExp, ast.SetComp)): + if ch is p.elt and any(_mentions(i, base) for g in p.generators for i in g.ifs): + return True + elif isinstance(p, ast.Try) and ch in p.body: + return True + for field_ in ("body", "orelse"): + block = getattr(p, field_, None) + if (isinstance(block, list) and ch in block + and any(_exits_on(st, base) for st in block[:block.index(ch)])): + return True + if isinstance(p, _FUNCS): + return False + ch, p = p, parent.get(p) + return False + + out = [] + for n in ast.walk(tree): + if (isinstance(n, ast.Subscript) and isinstance(n.ctx, ast.Load) + and isinstance(n.value, ast.Name)): + k = n.slice + if isinstance(k, ast.UnaryOp) and isinstance(k.op, ast.USub): + k = k.operand + if (isinstance(k, ast.Constant) and type(k.value) is int + and resolve(n.value, "listy") and not guarded(n, n.value)): + out.append((n.lineno, "index", ast.unparse(n))) + elif (isinstance(n, ast.Call) and isinstance(n.func, ast.Name) + and n.func.id == "next" and len(n.args) == 1 and not n.keywords): + if not guarded(n, n.args[0]): + out.append((n.lineno, "next", ast.unparse(n)[:60])) + elif (isinstance(n, ast.FormattedValue) and n.format_spec is not None + and isinstance(n.value, ast.Name)): + spec = "".join(v.value for v in n.format_spec.values + if isinstance(v, ast.Constant)) + if (spec and (spec[-1] in _NUMFMT or "," in spec) + and resolve(n.value, "noney") and not guarded(n, n.value)): + out.append((n.lineno, "format", f"{{{n.value.id}:{spec}}}")) + if census is not None: + _count_blind(tree, resolve, census) + return sorted(set(out)) + + +def _count_blind(tree, resolve, census): + """Count, inside check() and print() arguments, the forms part 2 skips.""" + for call in ast.walk(tree): + if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name) + and call.func.id in ("check", "print")): + continue + for n in (x for a in call.args for x in ast.walk(a)): + if isinstance(n, ast.Subscript) and isinstance(n.ctx, ast.Load): + k = n.slice.operand if isinstance(n.slice, ast.UnaryOp) else n.slice + if not isinstance(k, ast.Constant): + continue + if isinstance(k.value, str): + census["key"] += 1 + elif isinstance(n.value, ast.Name): + if not resolve(n.value, "listy"): + census["name-list"] += 1 + elif isinstance(n.value, ast.Attribute): + census["attr-list"] += 1 + else: + census["other-index"] += 1 + elif isinstance(n, ast.FormattedValue) and n.format_spec is not None: + if not isinstance(n.value, ast.Name): + census["format-expr"] += 1 + elif not resolve(n.value, "noney"): + census["format-name"] += 1 + elif isinstance(n, ast.BinOp) and isinstance(n.op, (ast.Div, ast.FloorDiv)): + census["division"] += 1 + + +# The scan must bite before its silence means anything: one planted site of +# each kind, and the guarded spelling of each, which must NOT be flagged. +_PLANTED = ''' +def run(): + seen = [] + t_gate = None + return seen, t_gate +rows = [r for r in range(3) if r > 9] +seen, t_gate = run() +waits, n = [], 0 +check(f"first {rows[0]}", bool(rows)) +print(waits[0]) +check("pair", rows[0] == 1 and rows[1] == 2) +print(seen[-1]) +print(f"{t_gate:.2f}") +print(next(r for r in rows)) +''' +_GUARDED = ''' +def run(): + seen = [] + t_gate = None + return seen, t_gate +rows = [r for r in range(3) if r > 9] +seen, t_gate = run() +check("first", bool(rows) and rows[0] == 1) +print(seen[-1] if seen else "-") +check("pair", len(rows) == 2 and rows[0] < rows[1]) +print(f"{t_gate:.2f}" if t_gate is not None else "-") +print(next((r for r in rows), None)) +def show(rows): + print(rows[0]) +def head(): + if not rows: + return None + return rows[0] +''' +_hit = scan(_PLANTED) +print(f" planted: {_hit}") +check("the scan flags a planted site of each kind - a filtered list indexed in " + "a check NAME whose condition guards it, an accumulator bound in a tuple " + "and one returned from a function, a maybe-None formatted, and next() " + "without a default", + [(k, t) for _l, k, t in _hit] == [ + ("index", "rows[0]"), ("index", "waits[0]"), ("index", "rows[0]"), + ("index", "rows[1]"), ("index", "seen[-1]"), + ("format", "{t_gate:.2f}"), + ("next", "next((r for r in rows))")]) +check("...and flags none of the guarded spellings - `and`, a conditional, " + "an early return, a default - nor a parameter that shadows a filtered " + "name", scan(_GUARDED) == []) + +_SUITES = sorted(HERE.glob("test_*.py")) +_found = {p.name: scan(p.read_text(encoding="utf-8")) for p in _SUITES} +_all = [(f, *h) for f, hs in _found.items() for h in hs] +for f, line, kind, text in _all: + print(f" {f}:{line} {kind:6} {text}") +check(f"no unguarded raising read in any of the {len(_SUITES)} test files " + f"({len(_all)} found) - read it through named_absence.py and conjoin " + f"present() in the check", _all == [] and len(_SUITES) >= 30) + + +# ============================================================================== +print() +print("3. what part 2 cannot see, counted - these can still raise") +# ============================================================================== +# The scan knows a list is filtered, or a name may be None, only from its own +# file's syntax. Each form below can still raise and is NOT graded; the counts +# are of every such site in check() and print() arguments, largest first. +_BLIND = { + "key": "`x['k']` - a dict key the code under test may omit", + "name-list": "`x[k]` on a list the scan cannot see is filtered - returned " + "by another module or a method, or of a fixed length", + "format-name": "a numeric format on a name the scan has no reason to think " + "is None - e.g. set from another module's function", + "format-expr": "a numeric format on an attribute, subscript or call", + "attr-list": "`obj.items[k]` - an attribute list", + "other-index": "`x[k]` on a call, a slice or a nested subscript", + "division": "`/` or `//` by a value that can be 0", +} +_census = Counter() +for p in _SUITES: + scan(p.read_text(encoding="utf-8"), _census) +for k, why in sorted(_BLIND.items(), key=lambda kv: -_census[kv[0]]): + print(f" {_census[k]:5d} {why}") +print(" never counted: these forms outside check()/print() arguments; " + "attribute access, iteration, int() or range() on a None or an ABSENT; " + "a raise inside a fixture function or inside app/.") +check("every counted form is in the printed table", set(_census) <= set(_BLIND)) + + +print() +if fails: + print(f"{len(fails)} CHECK(S) FAILED") + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/test_p1.py b/goodwe_controller/test_p1.py index 4983200..ed83cda 100644 --- a/goodwe_controller/test_p1.py +++ b/goodwe_controller/test_p1.py @@ -1320,8 +1320,8 @@ check("homewizard_local is enabled here too", row = [s for s in SENSORS if s[0] == "p1_age"] check("the age sensor is declared exactly once", len(row) == 1) check("its object_id pins entity_id to sensor.p1_sample_age_s", - row[0][1] == "p1_sample_age_s") -check("it is published in seconds", row[0][3] == "s") + len(row) == 1 and row[0][1] == "p1_sample_age_s") +check("it is published in seconds", len(row) == 1 and row[0][3] == "s") class _RecordingClient: diff --git a/goodwe_controller/test_packshare.py b/goodwe_controller/test_packshare.py index 19dfcc3..561e9ad 100644 --- a/goodwe_controller/test_packshare.py +++ b/goodwe_controller/test_packshare.py @@ -36,6 +36,7 @@ from math import inf from pathlib import Path from app import arbitrage, packshare +from named_absence import first, nth, present from app.allocator import plan from app.arbiter import ( ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve, @@ -347,12 +348,12 @@ check("...and the shipped behaviour really was the other one, so the line " "above is not vacuous: an equal split of 1500 W is 750/750, and 750 W is " "what a pack at its cut-out was being told to produce", _r_now.shares == {A: 750, Bee: 750}) -_plan_a = next(p for p in _r_opt.allocation.plans if p.unit == A) +_plan_a = first((p for p in _r_opt.allocation.plans if p.unit == A), "no plan for A") print(f" {_plan_a.explain()}") check("...and the RECORD says why, in the line an operator is already reading. " "`UnitPlan.within_envelope` is asked rather than assumed, and SAFETY-28's " "write gate refuses a record that contradicts itself", - _plan_a.within_envelope and _plan_a.share_w == 0 + present(_plan_a) and _plan_a.within_envelope and _plan_a.share_w == 0 and _plan_a.hi == 0.0 and _r_opt.allocated) check("a pack at 100 % is not allocated a CHARGE either, and the sibling takes " "the whole of it - the same sentence in the other direction", @@ -477,12 +478,12 @@ check("...and the two halves are separated from EACH OTHER as well: on a pack " "grades the ENVELOPE. Case three - both packs with room, nothing closed - " "is the one that can only be the WEIGHTS", _env_only.shares == {A: 0, Bee: 1500} - and _broken[2][1] != _broken[2][2]) + and len(_broken) > 2 and _broken[2][1] != _broken[2][2]) check("...and the third case is the one that isolates the WEIGHTS from the " "ENVELOPE: both packs have room, no interval is closed, nothing is " "refused - and the split still moves from 500/500 to 97/903 because the " "energy headroom says so", - _broken[2][1] == {A: 97, Bee: 903} and _broken[2][2] == {A: 500, Bee: 500}) + nth(_broken, 2)[1] == {A: 97, Bee: 903} and nth(_broken, 2)[2] == {A: 500, Bee: 500}) # ============================================================================== @@ -1156,7 +1157,7 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE # reads the allocation's own `unmet_w`, is EDGE-TRIGGERED against the previous # cycle's value, and logs. What it does NOT grade is the sentence it logs or # that a real cycle reaches it; that is stated here rather than implied. -_cycle_src = next( +_cycle_src = first( f for cls in ast.walk(ast.parse( (Path(__file__).resolve().parent / "app" / "main.py") .read_text(encoding="utf-8"))) diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index a90b20d..9f19bd9 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -115,6 +115,12 @@ from math import inf, isinf from pathlib import Path from app import peakguard +from named_absence import first, given, nth, present + + +def _src(node): + """`ast.unparse(node)`, or what was there instead - never a raise.""" + return ast.unparse(node) if isinstance(node, ast.AST) else repr(node) # ⚠️ THE DROP AND REFUSAL REASONS ARE IMPORTED, NOT TRANSCRIBED. A check that # matched the English would stay green against an allocator that renamed the # case out from under it, which is this repository's own named failure mode. @@ -265,18 +271,18 @@ def _schema_bound(key: str, which: int): # 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") +CAP_W = given(read_option("peak_cap_w"), "peak_cap_w not in config.yaml") +MAX_HOLD_S = given(read_option("meter_max_age_s"), "meter_max_age_s not in config.yaml") +FREEZE_S = given(read_option("meter_freeze_s"), "meter_freeze_s not in config.yaml") +CYCLE_S = given(read_option("cycle_floor_s"), "cycle_floor_s not in config.yaml") # The live install runs max_w 5000 (NOTES.md, freeze.py). That is also the schema # ceiling and the GW5000S-BP's rating, which is why the two agree - so the # device figure is taken from the schema rather than typed. -LIVE_MAX_W = read_schema_max("max_w") +LIVE_MAX_W = given(read_schema_max("max_w"), "max_w not in config.yaml") # SAFETY-24's two options, out of the shipped file for the same reason. -SOC_FLOOR = read_option("peak_soc_floor") -BMS_FLOOR = read_option("bms_soc_floor") -MAINT_FLOOR = read_option("maintenance_soc_floor") +SOC_FLOOR = given(read_option("peak_soc_floor"), "peak_soc_floor not in config.yaml") +BMS_FLOOR = given(read_option("bms_soc_floor"), "bms_soc_floor not in config.yaml") +MAINT_FLOOR = given(read_option("maintenance_soc_floor"), "maintenance_soc_floor not in config.yaml") # A state of charge that comfortably permits a forced discharge, DERIVED from # the floor rather than typed: every part of this file that is not about # SAFETY-24 has to grade SAFETY-07's behaviour at a SoC where the new floor is @@ -291,8 +297,8 @@ print(f" read from config.yaml: peak_cap_w {CAP_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)) + present(CAP_W, MAX_HOLD_S, FREEZE_S, CYCLE_S, LIVE_MAX_W, + SOC_FLOOR, BMS_FLOOR, MAINT_FLOOR)) check("...and the SoC these checks use as 'plenty of battery' is comfortably " "clear of the floor, so nothing outside part 9 is silently grading " "SAFETY-24 instead of SAFETY-07", SOC_OK > SOC_FLOOR + 10.0) @@ -437,7 +443,7 @@ print(f" five cycles with the inverter not following: delivered-power 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) + and nth(intent_floor, -1) > nth(intent_floor, 0) * 4) # ============================================================================== @@ -796,7 +802,7 @@ def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W, pack=None, """ 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}'}") + f"{'' if pack is None else f'-p{given(soc_floor):.0f}'}") tracker = PeakTracker(store, cap_w=cap_w) batt = 0.0 # delivered discharge, W (positive) next_cycle = None @@ -1049,7 +1055,7 @@ 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") + f"{nth(hass_off.writes, -1):+.0f} W") print(" status row: " + (off_rows[0]["text"] if off_rows else "(none)")) check("with no P1 the guard does not run at all - it files nothing, and the " "legacy path can still charge", c_off.peak_guard is None @@ -1242,7 +1248,7 @@ 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) + bool(hass2.writes) and abs(hass2.writes[-1]) <= LIVE_MAX_W + 1e-6) # ============================================================================== # THE WIRING'S OWN MUTATIONS. Everything above this line grades `peakguard.py`, @@ -1439,7 +1445,7 @@ 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] + _i_sat_at is not None and len(_i_track) > 1 and _i_track[-1] > _i_track[0] and len({f"{v:.3f}" for v in _d_track}) == 1 and not b3.peak_guard.saturated) # Graded on the parsed call syntax too, so a rename cannot quietly reintroduce @@ -1456,9 +1462,9 @@ _peak_calls = [n for n in ast.walk(ast.parse(_mainsrc)) and n.func.attr == "decide" and isinstance(n.func.value, ast.Name) and n.func.value.id == "peakguard"] -_call = _peak_calls[0] -_batt_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "batt_w")) -_soc_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "soc")) +_call = nth(_peak_calls, 0, "peakguard.decide() call in main.py") +_batt_src = _src(first(k.value for k in _call.keywords if k.arg == "batt_w")) +_soc_src = _src(first(k.value for k in _call.keywords if k.arg == "soc")) print(f" main.py's decide(batt_w=...) is `{_batt_src}`, decide(soc=...) is " f"`{_soc_src}`") check("...and the call site says so in the source: the argument is the measured " @@ -1497,8 +1503,8 @@ check(f"...and main.py has exactly ONE `peakguard.decide(` call site (it has " # 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("'", '"') +_floor_src = _src(first(k.value for k in _call.keywords + if k.arg == "soc_floor")).replace("'", '"') print(f" main.py's decide(soc_floor=...) is `{_floor_src}`") check("...and the floor comes from `peak_soc_floor`, defaulted from the ONE " "mirror - not from `maintenance_soc_floor` (a drain target, not a spend " @@ -1535,8 +1541,9 @@ check("SAFETY-24 adds exactly the two keys it names and no more: §14's kW/W " # 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 = first((n for n in ast.walk(ast.parse(src)) + if isinstance(n, ast.FunctionDef) and n.name == "decide"), + "def decide in peakguard.py") _DECIDE_ARGS = [a.arg for a in _decide.args.args + _decide.args.kwonlyargs] _getters = [n for n in ast.walk(ast.parse(src)) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) @@ -2010,7 +2017,8 @@ 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] + len(below) == 1 + and 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 " @@ -2245,7 +2253,7 @@ def replay_loop(window, *, start_soc, soc_floor, usable_wh=REF_PACK_WH): # measuring something else. EVE = evening(19, 24) EVE_W = [w for _, w in EVE] -print(f" the evening of 2026-08-20, {EVE[0][0]:%H:%M}-{EVE[-1][0]:%H:%M} UTC: " +print(f" the evening of 2026-08-20, {nth(EVE, 0)[0]:%H:%M}-{nth(EVE, -1)[0]:%H:%M} UTC: " f"{len(EVE)} telegrams, mean {sum(EVE_W) / len(EVE_W):.0f} W, " f"min {min(EVE_W):.0f} W, max {max(EVE_W):.0f} W") check("the window really is a SUSTAINED-LOAD evening and not a quiet one - " @@ -2324,7 +2332,7 @@ check("...and the sweep spans the whole schema range, rule 8's refused low end " HOT = evening(15, 17) HOT_R = replay_loop(HOT, start_soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR) print(f" the same day's high-load block " - f"{HOT[0][0]:%H:%M}-{HOT[-1][0]:%H:%M} UTC, pack started one point BELOW " + f"{nth(HOT, 0)[0]:%H:%M}-{nth(HOT, -1)[0]:%H:%M} UTC, pack started one point BELOW " f"the floor:") print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, " f"{HOT_R['limited_discharging']} of them with the loop simultaneously " @@ -2492,13 +2500,14 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}") # The guard's own line, which the banner has always carried and which is not # this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter # that is going over the ceiling. -_expl25 = rows25[0]["text"].split(". NOTE:")[0] +_expl25 = nth(rows25, 0)["text"].split(". NOTE:")[0] check("the NOT READY banner is the guard's own line plus a SHORT scope tag - " "under 90 characters of it - and NOT the row's whole paragraph repeated " "at the top of the page. A headline that is a paragraph is a headline " "nobody reads, and this one is followed three lines later by the same " "text verbatim", - _pay25["level"] == "bad" + present(_expl25) + and _pay25["level"] == "bad" and _pay25["banner"].startswith("NOT READY") and _expl25 in _pay25["banner"] and "BOUNDS THE PEAK GUARD ONLY" in _pay25["banner"].upper() @@ -2507,7 +2516,8 @@ check("the NOT READY banner is the guard's own line plus a SHORT scope tag - " check("...and shortening the banner did NOT shorten the row: the full AC 3 " "sentence is still what the checks list carries, which is the surface the " "operator reads to find out what the floor does not bound", - len(rows25[0]["text"]) > 300 + len(rows25) == 1 + and len(rows25[0]["text"]) > 300 and "not a pack-wide reserve" in rows25[0]["text"] and "BMS cut-out" in rows25[0]["text"]) @@ -2627,12 +2637,13 @@ CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak", CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"} _main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8")) -_cycle_def = next( +_cycle_def = first(( f for cls in ast.walk(_main_tree) if isinstance(cls, ast.ClassDef) and cls.name == "Controller" for f in cls.body - if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle") + if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle"), + "Controller.cycle in main.py") def claim_source(node): @@ -2957,9 +2968,7 @@ def append_actuator(node): dynamic check in this file is blind to the swap at N=1. """ kw = [k for k in node.keywords if k.arg == "actuator"] - if len(kw) != 1: - return None - return ast.unparse(kw[0].value) + return ast.unparse(kw[0].value) if len(kw) == 1 else None # The appends that BUILD their claim in place - the nine whose routing is @@ -3959,8 +3968,8 @@ check("...and with the key absent entirely the one pack takes the SHIPPED " # the floor comes from is a claim genuinely about the code's shape and is pinned # on the source in part 4, where it already lives; what is graded here is the # VALUE that reaches `decide()`. -_floor_call = ast.unparse(next(k.value for k in _call.keywords - if k.arg == "soc_floor")) +_floor_call = _src(first(k.value for k in _call.keywords + if k.arg == "soc_floor")) print(f" main.py's decide(soc_floor=...) is `{_floor_call}`") @@ -4243,12 +4252,12 @@ check("...and the baseline the table is measured against is the shipped call " # rewritten as `self.inverters[:2]` leaves this expression identical, and it is # the three-pack runs' `len(ctl.packs) == 3` that goes red on it. And it cannot # see the reduction INSIDE `binding_soc_floor()` at all; that is 12c2's. -_floor_node = next(k.value for k in _call.keywords if k.arg == "soc_floor") +_floor_node = first(k.value for k in _call.keywords if k.arg == "soc_floor") _floor_roster_arg = (_floor_node.args[0] if isinstance(_floor_node, ast.Call) and _floor_node.args else None) print(f" the ROSTER argument main.py reduces is " - f"`{ast.unparse(_floor_roster_arg)}`, an AST " + f"`{_src(_floor_roster_arg)}`, an AST " f"{type(_floor_roster_arg).__name__}") check("⚠️ THE STRUCTURAL HALF, AND IT IS EXACT RATHER THAN A SUBSTRING. The " "roster handed to `binding_soc_floor()` is the WHOLE property " @@ -4744,28 +4753,31 @@ check("⚠️ THE NONE GUARD IS THE SAME FAMILY. One silent pack at ANY position # killed by the roster set), it cannot see `inv.soc` become `inv.bms_soc_floor` # (killed by the runs' held-flat cut-outs), and it cannot see a change inside # `Inverter.soc` at all. -_pfs_fn = next(n for n in ast.walk(ast.parse(_mainsrc)) - if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) - and n.name == "pack_floor_soc") -_pfs_vals = next(n.value for n in _pfs_fn.body - if isinstance(n, ast.Assign) - and [ast.unparse(t) for t in n.targets] == ["vals"]) +_pfs_fn = first((n for n in ast.walk(ast.parse(_mainsrc)) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) + and n.name == "pack_floor_soc"), "def pack_floor_soc in main.py") +_pfs_vals = first((n.value for n in _pfs_fn.body + if isinstance(n, ast.Assign) + and [ast.unparse(t) for t in n.targets] == ["vals"]), + "`vals = ...` in pack_floor_soc") def _gen_iter_of(fn_node, name): """The iterable of the comprehension inside the single `name(...)` call.""" - call = next(n for n in ast.walk(fn_node) - if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) - and n.func.id == name) - comp = call.args[0] - return comp.generators[0].iter if len(comp.generators) == 1 else None + call = first((n for n in ast.walk(fn_node) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) + and n.func.id == name), f"{name}() call") + comp = nth(call.args, 0) + gens = getattr(comp, "generators", []) + return gens[0].iter if len(gens) == 1 else None _pfs_min_iter = _gen_iter_of(_pfs_fn, "min") +_pfs_vals_iter = nth(getattr(_pfs_vals, "generators", []), 0).iter _pfs_any_iter = _gen_iter_of(_pfs_fn, "any") -print(f" pack_floor_soc reduces: list over `{ast.unparse(_pfs_vals.generators[0].iter)}`" - f", any() over `{ast.unparse(_pfs_any_iter)}`, min() over " - f"`{ast.unparse(_pfs_min_iter)}`") +print(f" pack_floor_soc reduces: list over `{_src(_pfs_vals_iter)}`" + f", any() over `{_src(_pfs_any_iter)}`, min() over " + f"`{_src(_pfs_min_iter)}`") check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduces " "the WHOLE list `vals` - a bare Name, not a Subscript, not a call, not a " "second comprehension - and `vals` is built by ONE comprehension over the " @@ -4777,7 +4789,7 @@ check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduce "to length 12. This grades the code's SHAPE and nothing about the values; " "what the expression MEANS is graded by the runs", isinstance(_pfs_min_iter, ast.Name) and _pfs_min_iter.id == "vals" - and ast.unparse(_pfs_min_iter) == "vals" + and _src(_pfs_min_iter) == "vals" and isinstance(_pfs_vals, ast.ListComp) and len(_pfs_vals.generators) == 1 and not _pfs_vals.generators[0].ifs @@ -4785,13 +4797,13 @@ check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduce and isinstance(_pfs_vals.generators[0].iter.value, ast.Name) and _pfs_vals.generators[0].iter.value.id == "self" and _pfs_vals.generators[0].iter.attr == "inverters" - and ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters") + and _src(_pfs_vals_iter) == "self.inverters") check("...and the NONE GUARD reduces that same whole list, pinned the same way. " "`any(v is None for v in vals[:1])` is +4 bytes and leaves every other " "line in this file identical; the sweep above reaches it up to length 12 " "and this reaches it at every length", isinstance(_pfs_any_iter, ast.Name) and _pfs_any_iter.id == "vals" - and ast.unparse(_pfs_any_iter) == "vals") + and _src(_pfs_any_iter) == "vals") check("...and the counterfactual that shows these pins are not vacuous (habit " "7): the very slices they exist for really do unparse to something else, " "so the equalities above are doing work rather than being trivially true " @@ -4813,8 +4825,8 @@ _arb_calls = [n for n in ast.walk(ast.parse(_mainsrc)) and n.func.attr == "decide" and isinstance(n.func.value, ast.Name) and n.func.value.id == "arbitrage"] -_arb_soc = ast.unparse(next(k.value for k in _arb_calls[0].keywords - if k.arg == "soc")) +_arb_soc = _src(first(k.value for k in nth(_arb_calls, 0).keywords + if k.arg == "soc")) print(f" main.py's arbitrage.decide(soc=...) is `{_arb_soc}` " f"({len(_arb_calls)} call site)") check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()` " @@ -5688,8 +5700,8 @@ MUT.append(("`binding_soc_floor()` reduced over a WINDOW - `max(floors[-2:])` - # cut-out at index 0, 1 and 2 reach it. MUT.append(("main.py grading the site against a WINDOW of roster positions - " "`self.packs[:2]` - ROUND 3 SURVIVOR 2, +4 bytes", - _g3_runs[2][3].soc_floor == float(_G3_DEEP_SIM) - and binding_soc_floor(_g3_runs[2][2].packs[:2], SOC_FLOOR) + nth(_g3_runs, 2)[3].soc_floor == float(_G3_DEEP_SIM) + and binding_soc_floor(nth(_g3_runs, 2)[2].packs[:2], SOC_FLOOR) == float(SOC_FLOOR) and all(r[3].soc_floor == float(_G3_DEEP_SIM) for r in _g3_runs) and all(_slice_kills.values()), @@ -5711,7 +5723,7 @@ MUT.append(("main.py grading the site against a WINDOW of roster positions - " MUT.append(("main.py grading the site against a THREE-wide window - " "`self.packs[:3]` / `self.packs[-3:]`, found by THIS round's " "battery, invisible to every behavioural run in this file", - ast.unparse(_floor_roster_arg) == "self.packs" + _src(_floor_roster_arg) == "self.packs" and isinstance(_floor_roster_arg, ast.Attribute) and all(binding_soc_floor(ctl.packs[:3], SOC_FLOOR) == dec.soc_floor and binding_soc_floor(ctl.packs[-3:], SOC_FLOOR) @@ -5758,7 +5770,7 @@ MUT.append(("`pack_floor_soc` reduced over a WINDOW - `vals[:2]` (+4 bytes) or " MUT.append(("`pack_floor_soc` reduced over a THREE-wide window - `vals[:3]` / " "`vals[-3:]` - invisible to every cycle in this file", _s_head_kill[3] and _s_tail_kill[3] - and ast.unparse(_pfs_min_iter) == "vals", + and _src(_pfs_min_iter) == "vals", "part 12i's LENGTH SWEEP (widths 1-11, on the REAL property over " "rosters of 1 to 12 real inverters) and its STRUCTURAL PIN. The " "six real cycles cannot see it - three packs is the longest roster " @@ -5777,7 +5789,7 @@ MUT.append(("`pack_floor_soc` reduced with `max()` - the FULLEST pack, which is MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - " "`any(v is None for v in vals[:1])`, +4 bytes", all(g is None for _n, _p, g in _s_none_rows) - and ast.unparse(_pfs_any_iter) == "vals", + and _src(_pfs_any_iter) == "vals", "part 12i's NONE-GUARD SWEEP - one silent pack at every position of " "every length 1..12 - and the same structural pin. ⚠️ 12d's " "two-pack fixture silences the SECOND pack only, so a guard " @@ -5786,7 +5798,7 @@ MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - " # 32. and the roster the list itself is built from. MUT.append(("`pack_floor_soc` building `vals` from a SLICE of the roster - " "`[inv.soc for inv in self.inverters[:2]]`, +4 bytes", - ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters" + _src(_pfs_vals_iter) == "self.inverters" and _s_head_kill[2] and not _SLICED, "part 12i's sweep reads the REAL property on real rosters so it " "sees this too, its pin is on the comprehension's own iterable, " @@ -5818,7 +5830,7 @@ check("...and the seventh is honestly reported as not caught, because it cannot "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] + "not consumed here", present(nth(MUT, 6)) and not MUT[6][1] and quarter_ceiling_w(CAP_W, _zero_peak)[0] == CAP_W) check("...and SAFETY-32's own eight defeats are each caught too - the flat " "cut-out inherited, the site floor graded in place of the pack's, the " @@ -5851,7 +5863,7 @@ check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row "pin would refuse honest refactors, so the hole is reported with its size " "instead of hidden. THIS is the row that a fifth review should argue " "with, and it exists to be argued with", - not MUT[27][1] + present(nth(MUT, 27)) and not MUT[27][1] and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N))) check("...⚠️ AND ROUND 4's SIX, WHICH ARE THE SAME FAMILY AT THE SIBLING " "ARGUMENT OF THE SAME CALL. `pack_floor_soc` is the MINIMUM over the " @@ -5873,7 +5885,7 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF " "and a `lo` limit cannot cap a discharge. Saying so here is worth more " "than a green line that would be untrue - and part 10 now says it with a " "number instead of a sentence, which is worth more again", - not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05) + present(nth(MUT, 12)) and not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05) # ============================================================================== @@ -5953,7 +5965,8 @@ check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), re check("...and the row is green and un-warned, which is what makes the " "transposed version dangerous rather than merely untidy: an OK row " "carrying a projection 2713 W OVER its own ceiling is the one an " - "operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"] + "operator scrolls past", len(_rows38) == 1 + and _rows38[0]["ok"] and not _rows38[0]["warn"] and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0) # ============================================================================== @@ -6211,8 +6224,9 @@ check("a FLOOR-LIMITED decision - the guard wanted a discharge and SoC refused " # been inverted. _H45_SRC = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8") _H45_TREE = ast.parse(_H45_SRC) -_h45_fn = next(n for n in ast.walk(_H45_TREE) - if isinstance(n, ast.FunctionDef) and n.name == "decide") +_h45_fn = first((n for n in ast.walk(_H45_TREE) + if isinstance(n, ast.FunctionDef) and n.name == "decide"), + "def decide in peakguard.py") _H45_RUNG = [n for n in ast.walk(_h45_fn) if isinstance(n, ast.If) and ast.unparse(n.test) == "wanted <= 0.0"] check("the `wanted <= 0.0` rung is still in decide(), spelled exactly that " @@ -6223,8 +6237,9 @@ check("...and it still DISENGAGES rather than merely reporting: the fixture " "13a is built on reaches it and files no claim, so the pin above is not " "grading a rung that has been inverted under it", peakguard.claim(_h45_idle) is None) -_h45_head_fn = next(n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef) - and n.name == "charge_headroom_w") +_h45_head_fn = first((n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef) + and n.name == "charge_headroom_w"), + "def charge_headroom_w in peakguard.py") check("...and charge_headroom_w() is a PURE READ of the decision - one " "positional parameter, no projection, no clock and no options - so it " "cannot become a second answer to the question decide() already answers", diff --git a/goodwe_controller/test_price.py b/goodwe_controller/test_price.py index b544281..0925e84 100644 --- a/goodwe_controller/test_price.py +++ b/goodwe_controller/test_price.py @@ -60,6 +60,8 @@ import os import re import shutil import sys + +from named_absence import first, nth import tempfile from datetime import date, datetime, timedelta, timezone from textwrap import dedent @@ -310,8 +312,10 @@ check("...and every one of them is in the schema block too", _unschemad == []) # it. So the schema is deliberately WIDER than the registry, by exactly # RETIRED_PROVIDERS and by nothing else. Both halves are graded: an id that is # in neither set, and a retired id that has gone missing. -_line = next(ln for ln in _schema_block if ln.startswith(" price_provider:")) -_schema_ids = set(_line.split("list(", 1)[1].rstrip(")").split("|")) +_line = first((ln for ln in _schema_block if ln.startswith(" price_provider:")), + "price_provider in the schema") +_schema_ids = (set(_line.split("list(", 1)[1].rstrip(")").split("|")) + if "list(" in _line else set()) _wanted = set(PROVIDERS) | set(RETIRED_PROVIDERS) | {"off"} check(f"the schema offers exactly the registered plugins, the retired ids and " f"'off' {sorted(_schema_ids)}", @@ -954,7 +958,7 @@ def asked_url(since, until, extra=None): t = canned(_REAL[_TD]) o = opts(price_provider="generic_dayahead", **(extra or {})) run(load_prices(o, since, until, transport=t)) - return t.calls[0][0] + return nth(t.calls, 0)[0] # The expectation is built HERE, from the window, by different arithmetic than @@ -2069,7 +2073,7 @@ check(f"every networked plugin declares at least one host, and manual_csv " check("the timeout the plugin passes to the transport is the configured one, " "not a hardcoded default", (lambda t: (load("generic_dayahead", {"price_timeout_s": 7}, - transport=t), t.calls[0][1] == 7.0)[1])( + transport=t), nth(t.calls, 0)[1] == 7.0)[1])( canned(columnar_feed(EPEX_EUR_MWH)))) @@ -2477,9 +2481,9 @@ def _started(sites, opts): _MAIN_TREE = derived(lambda: ast.parse(open(os.path.join(_APP, "main.py"), encoding="utf-8").read())) -_AMAIN = derived(lambda: next(n for n in ast.walk(_MAIN_TREE) - if isinstance(n, ast.AsyncFunctionDef) - and n.name == "amain")) +_AMAIN = derived(lambda: next((n for n in ast.walk(_MAIN_TREE) + if isinstance(n, ast.AsyncFunctionDef) + and n.name == "amain"), None)) _SITES = derived(lambda: _task_sites(_AMAIN)) _GRAPH = derived(lambda: _call_graph(_APP)) _FETCH_ENTRY = "resolve_tariff" @@ -3491,8 +3495,8 @@ _cf_t = canned(_REAL[_TD]) run(load_prices(opts(price_provider="custom_formula", **_CF_OPTS), _w0, _w0 + timedelta(hours=48), transport=_cf_t)) check(f"...and asks for the same Brussels range generic_dayahead asks for " - f"{_cf_t.calls[0][0]!r}", - _cf_t.calls[0][0] == asked_url(_w0, _w0 + timedelta(hours=48))) + f"{nth(_cf_t.calls, 0)[0]!r}", + nth(_cf_t.calls, 0)[0] == asked_url(_w0, _w0 + timedelta(hours=48))) # The price at each instant is the document's own index through the card's # arithmetic - computed HERE from the captured bytes, by different arithmetic. @@ -4684,9 +4688,25 @@ _T17_TAB_OUT = derived(lambda: assemble(_T17_CAPS, _T17_RAW, MARKUP, _T17_TABLE)) check("the table parses, through static_tariff's OWN grammar", not isinstance(_T17_TABLE, _Raised)) +# Graded on this file's own two calls: the arms may differ only in `taxes`. +_T17_ARMS = {t.id: [ast.unparse(a) for a in n.value.args[0].body.args] + for n in ast.parse(open(__file__, encoding="utf-8").read()).body + if isinstance(n, ast.Assign) and isinstance(n.value, ast.Call) + and isinstance(n.value.func, ast.Name) + and n.value.func.id == "derived" and n.value.args + and isinstance(n.value.args[0], ast.Lambda) + and isinstance(n.value.args[0].body, ast.Call) + for t in n.targets if isinstance(t, ast.Name) + and t.id in ("_T17_FLAT_OUT", "_T17_TAB_OUT")} +print(f" arms: {_T17_ARMS}") check(f"the two arms are the same feed: same caps, same {len(_T17_RAW)} " - f"RawPoints and the same markup {MARKUP}, by IDENTITY and not by value", - _T17_CAPS is _T17_CAPS and _T17_RAW is _T17_RAW) + f"RawPoints and the same markup {MARKUP} - the two `assemble()` calls " + f"share their first three arguments by NAME and differ only in the fourth", + sorted(_T17_ARMS) == ["_T17_FLAT_OUT", "_T17_TAB_OUT"] + and [len(v) for v in _T17_ARMS.values()] == [4, 4] + and _T17_ARMS["_T17_FLAT_OUT"][:3] == _T17_ARMS["_T17_TAB_OUT"][:3] + == ["_T17_CAPS", "_T17_RAW", "MARKUP"] + and _T17_ARMS["_T17_FLAT_OUT"][3] != _T17_ARMS["_T17_TAB_OUT"][3]) _T17_BANDS = derived(lambda: sorted({p.eur_per_kwh for p in _T17_TABLE.periods})) check(f"...and the table is genuinely hour-varying: {_T17_BANDS} EUR/kWh, two " @@ -5562,10 +5582,11 @@ print("17f. the SCALAR path is unchanged - arithmetically, not merely in " # ⚠️ THE ACCEPTANCE CRITERION TARIFF-17 GRADED THE SAME WAY (16f). A site with # a flat injection price is every site that exists today, and "we did not mean # to change it" is not a measurement. -_T16_OLD_SCALAR = [ast.unparse(n) for n in ast.walk(next( - n for n in ast.parse(open(os.path.join(HERE, "app", "price.py"), - encoding="utf-8").read()).body - if isinstance(n, ast.FunctionDef) and n.name == "export_price_eur_kwh")) +_T16_OLD_SCALAR = [ast.unparse(n) for n in ast.walk(first( + (n for n in ast.parse(open(os.path.join(HERE, "app", "price.py"), + encoding="utf-8").read()).body + if isinstance(n, ast.FunctionDef) and n.name == "export_price_eur_kwh"), + "def export_price_eur_kwh in price.py")) if isinstance(n, ast.Return)] check(f"`export_price_eur_kwh()`'s BODY is still the single expression it has " f"always been, pinned on the AST rather than on the source text: " diff --git a/goodwe_controller/test_price_forecast.py b/goodwe_controller/test_price_forecast.py index 77f9617..9ec5298 100644 --- a/goodwe_controller/test_price_forecast.py +++ b/goodwe_controller/test_price_forecast.py @@ -62,6 +62,8 @@ import inspect import itertools import os import sys + +from named_absence import first, nth from datetime import datetime, timedelta, timezone from app import price as _price @@ -638,7 +640,7 @@ _late = fill_from_yesterday( series_of(DAY, lambda u: t_price(brussels(u).hour), PriceProduct.DAY_AHEAD, "generic_dayahead", provisional=False), yesterday=YESTERDAY, since=D0, until=D1, stamp=D1) -_h20 = DAY[20] +_h20 = nth(DAY, 20) _before = derived(lambda: FILLED.at(_h20)) _after = derived(lambda: _late.at(_h20)) check("before the print, hour 20 is provisional and carries YESTERDAY's number", @@ -728,7 +730,8 @@ check("an action that commits nothing is not an irreversible one and cannot be " # prices that were published for the morning and guessed for the evening. _m = maint() _gate_bad = derived(lambda: begin_phase(_m, START, FILLED, MAINTENANCE_DRAIN)) -_first_prov = derived(lambda: next(u for u in _hours if FILLED.at(u).provisional)) +_first_prov = derived(lambda: first((u for u in _hours if FILLED.at(u).provisional), + "a carried hour in the window")) check("the drain's window really does reach past the published hours while " "staying INSIDE the series, so the refusal below is about provenance and " "not about a short series", @@ -743,7 +746,7 @@ check("...and the phase machine did not move: it is still idle, so the " check("...and the reason names §8.3, the offending hour, and when the real " "price is expected", "§8.3" in _gate_bad.reason - and _first_prov.isoformat() in _gate_bad.reason + and bool(_first_prov) and _first_prov.isoformat() in _gate_bad.reason and "14-16" in _gate_bad.reason) # The positive arm, so the block above is discriminating rather than universal. diff --git a/goodwe_controller/test_price_history.py b/goodwe_controller/test_price_history.py index 9a3c733..488731d 100644 --- a/goodwe_controller/test_price_history.py +++ b/goodwe_controller/test_price_history.py @@ -104,6 +104,8 @@ import json import os import shutil import sys + +from named_absence import first, nth import tempfile from datetime import datetime, timedelta, timezone @@ -239,7 +241,8 @@ YDAY_H = bday_hours(YDAY_D) def at_local(bdate: str, hh: int) -> datetime: """The UTC instant whose Brussels wall clock reads hh:00 on `bdate`.""" - return next(u for u in bday_hours(bdate) if brussels(u).hour == hh) + return first((u for u in bday_hours(bdate) if brussels(u).hour == hh), + f"{bdate} {hh:02d}:00 Brussels") # ⚠️ AND IT IS NOT ON THE HOUR. A controller's `now` is @@ -791,7 +794,7 @@ print("2b. a QUARTER-HOURLY feed keeps every print, and its spread with them") _QSTEP_MIN = 15 _qrows = [] for _n in range(len(TODAY_H) * (60 // _QSTEP_MIN)): - _qu = TODAY_H[0] + timedelta(minutes=_QSTEP_MIN * _n) + _qu = nth(TODAY_H, 0) + timedelta(minutes=_QSTEP_MIN * _n) # A spiky within-the-hour profile, so decimation is visible in the SPREAD # and not only in a count. _qrows.append((_qu, @@ -2009,9 +2012,9 @@ check("...so calling it without one is a TypeError, not a quiet regression", _MAIN = open(os.path.join(_APP, "main.py"), encoding="utf-8").read() _main_tree = ast.parse(_MAIN) -_refresh = next(n for n in ast.walk(_main_tree) - if isinstance(n, ast.AsyncFunctionDef) - and n.name == "run_tariff_refresh") +_refresh = first((n for n in ast.walk(_main_tree) + if isinstance(n, ast.AsyncFunctionDef) + and n.name == "run_tariff_refresh"), "run_tariff_refresh in main.py") _kw = [k.arg for n in ast.walk(_refresh) if isinstance(n, ast.Call) for k in n.keywords] check(f"the shipped refresh task passes a history {_kw} - the wiring is what " @@ -2033,9 +2036,9 @@ check("...and `words` reaches the STATUS PAGE as its own row, so what the fill " "has to carry from is a fact an operator can read rather than one that " "only exists in a log line", '("Price history", self.price_history.words)' in _MAIN) -_rows_fn = next(n for n in ast.walk(_main_tree) - if isinstance(n, ast.FunctionDef) - and n.name == "status_payload") +_rows_fn = first((n for n in ast.walk(_main_tree) + if isinstance(n, ast.FunctionDef) + and n.name == "status_payload"), "status_payload in main.py") _row_labels = [e.elts[0].value for e in ast.walk(_rows_fn) if isinstance(e, ast.Tuple) and len(e.elts) == 2 and isinstance(e.elts[0], ast.Constant) diff --git a/goodwe_controller/test_pv.py b/goodwe_controller/test_pv.py index 2d83c83..044490a 100644 --- a/goodwe_controller/test_pv.py +++ b/goodwe_controller/test_pv.py @@ -69,6 +69,7 @@ import sys from pathlib import Path from app import mqtt, pv +from named_absence import field, nth, present from app.main import Controller HERE = Path(__file__).resolve().parent @@ -348,12 +349,12 @@ check("...and a kW sensor is read in kW: 3.21 kW is 3210 W, not 3.21 W. There " # ⚠️ THE ONE THAT SEPARATES "ABSENT" FROM "STALE", AND IT NEEDS TWO CYCLES. c_drop, _, p_drop = run(cycles=2, states=[state("4100", "W"), None], **PV_ON) -print(f" published pv by cycle: {[f['pv'] for f in p_drop.frames]}") +print(f" published pv by cycle: {[field(f, 'pv') for f in p_drop.frames]}") check("⭐ A FAILED READ CLEARS THE PREVIOUS GOOD ONE. The cycle after a 4100 W " "reading disappears publishes an ABSENCE, not 4100 W again - a value " "that survives its own sensor is indistinguishable from a real one at " "the moment somebody is working out whether the sun was shining", - [f["pv"] for f in p_drop.frames] == [4100.0, None] + [field(f, "pv") for f in p_drop.frames] == [4100.0, None] and c_drop.pv_w is None and "no state" in c_drop.pv_why) @@ -378,9 +379,9 @@ def wire(values, include=("pv",)): return pub.client.msgs -_zero = run(cycles=1, states=[state("0", "W")], **PV_ON)[2].frames[-1] -_absent = run(cycles=1, states=[None], **PV_ON)[2].frames[-1] -_nosrc = p_off.frames[-1] +_zero = nth(run(cycles=1, states=[state("0", "W")], **PV_ON)[2].frames, -1) +_absent = nth(run(cycles=1, states=[None], **PV_ON)[2].frames, -1) +_nosrc = nth(p_off.frames, -1) print(f" no source : 'pv' in frame = {'pv' in _nosrc}") def pv_msg(values): """The `goodwe_ctl/pv` message the shipped publisher emits, or None.""" @@ -388,17 +389,17 @@ def pv_msg(values): return got[0] if got else None -print(f" no reading: {_absent['pv']!r} -> wire {pv_msg(_absent)}") -print(f" zero watts: {_zero['pv']!r} -> wire {pv_msg(_zero)}") +print(f" no reading: {field(_absent, 'pv')!r} -> wire {pv_msg(_absent)}") +print(f" zero watts: {field(_zero, 'pv')!r} -> wire {pv_msg(_zero)}") check("NO SOURCE: an install that never set `pv_entity` publishes no `pv` key " "at all - there is no entity to feed, so feeding one would be a topic " "nothing announced", - "pv" not in _nosrc) + present(_nosrc) and "pv" not in _nosrc) check("NO READING: the key is PRESENT and its value is None, which is a " "different frame from the one above. 'the option is off' and 'the sensor " "did not answer' are two facts and one field cannot carry both", "pv" in _absent and _absent["pv"] is None) -check("ZERO WATTS: 0.0, published as a number", _zero["pv"] == 0.0) +check("ZERO WATTS: 0.0, published as a number", field(_zero, "pv") == 0.0) check("⭐ AND ON THE WIRE THE LAST TWO ARE DIFFERENT BYTES ON THE SAME TOPIC: " "`None` - which is Home Assistant's own payload for 'this sensor has no " "value', so the entity goes to `unknown` and the recorder records a GAP " @@ -441,7 +442,7 @@ def announced(opts): check("with a source: `pv` is announced AND published", "pv" in announced(PV_ON) and "pv" in _absent) check("without one: announced by nothing and published by nothing", - "pv" not in announced({}) and "pv" not in _nosrc) + "pv" not in announced({}) and present(_nosrc) and "pv" not in _nosrc) check("...and the predicate is ONE expression, `mqtt.optional_keys()`, read by " "the announcement and by `Controller.publish()` alike - two spellings is " "how an entity gets announced that nothing feeds", @@ -473,7 +474,7 @@ _a1 = mqtt.discovery_payloads(include=mqtt.optional_keys(PV_ON)) _a2 = mqtt.discovery_payloads(include=mqtt.optional_keys(PV_ON)) _pv_cfg = [c for _t, c in _a1 if c["unique_id"] == "goodwe_ctl_pv"] print(f" composed entity_id: " - f"{compose(mqtt.DEVICE['name'], _pv_cfg[0]['name'])}") + f"{compose(mqtt.DEVICE['name'], nth(_pv_cfg, 0)['name'])}") check("a restart re-announces the SAME bytes, so Home Assistant sees the entity " "it already has rather than a new one beside a dead one - the history is " "continuous across a restart of the add-on", @@ -483,7 +484,8 @@ check("a restart re-announces the SAME bytes, so Home Assistant sees the entity check("...and the id Home Assistant composes for it is " "`sensor.goodwe_pv_power`, which is what DOCS.md tells an operator to put " "on a dashboard - device name plus entity name, NOT the object_id", - compose(mqtt.DEVICE["name"], _pv_cfg[0]["name"]) + len(_pv_cfg) == 1 + and compose(mqtt.DEVICE["name"], _pv_cfg[0]["name"]) == "sensor.goodwe_pv_power" and _pv_cfg[0]["state_topic"] == "goodwe_ctl/pv") @@ -536,7 +538,8 @@ check("⭐ ...and it is a CAUTION, never a `bad` row: `status_payload()` builds "NOT READY out of rows that are neither ok nor warn, and a telemetry " "channel that could put a battery site into NOT READY would be " "observability with a foot in the control path", - _bad_row[0] == {"ok": False, "warn": True, "text": _bad_row[0]["text"]} + len(_bad_row) == 1 + and _bad_row[0] == {"ok": False, "warn": True, "text": _bad_row[0]["text"]} and not c_bad.status_payload()["banner"].startswith("NOT READY") and c_bad.status_payload()["level"] != "bad" and c_bad.status_payload()["banner"] @@ -867,8 +870,8 @@ print("9. ⭐ publication at a roster of three, as PAYLOAD BYTES") # Part 3 did this for one entity. The new state is "several configured, one of # them gone", and the number that must NOT appear on the topic is a specific # plausible number rather than a wrong shape. -_m_full = run(cycles=1, by_entity=FULL, **PV_MANY)[2].frames[-1] -print(f" 3 healthy : {_m_full['pv']!r} -> wire {pv_msg(_m_full)}") +_m_full = nth(run(cycles=1, by_entity=FULL, **PV_MANY)[2].frames, -1) +print(f" 3 healthy : {field(_m_full, 'pv')!r} -> wire {pv_msg(_m_full)}") check("⭐ THREE HEALTHY: one topic, one number, the SITE total - 3750.0. There " "is deliberately no per-entity topic: the recorder statistic OPT-10 reads " "back is the site figure, and a per-inverter breakdown is a dashboard " @@ -878,7 +881,7 @@ check("⭐ THREE HEALTHY: one topic, one number, the SITE total - 3750.0. There == [pv_msg(_m_full)]) _holed = [] for _miss, (_script, _partial) in HOLES.items(): - _fr = run(cycles=1, by_entity=_script, **PV_MANY)[2].frames[-1] + _fr = nth(run(cycles=1, by_entity=_script, **PV_MANY)[2].frames, -1) _msg = pv_msg(_fr) print(f" {_miss} dead -> wire {_msg} (partial would have been " f"{_partial:.0f} W -> {str(_partial)!r})") @@ -950,7 +953,8 @@ check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at "neither ok nor warn, and a telemetry channel that could put a battery " "site into NOT READY would be observability with a foot in the control " "path", - _gap_row[0] == {"ok": False, "warn": True, "text": _gap_row[0]["text"]} + len(_gap_row) == 1 + and _gap_row[0] == {"ok": False, "warn": True, "text": _gap_row[0]["text"]} and _c_gap.status_payload()["level"] != "bad" and _c_gap.status_payload()["banner"] == c_off.status_payload()["banner"]) diff --git a/goodwe_controller/test_static_tariff.py b/goodwe_controller/test_static_tariff.py index d5d6d38..4078ae5 100644 --- a/goodwe_controller/test_static_tariff.py +++ b/goodwe_controller/test_static_tariff.py @@ -63,6 +63,8 @@ import ast import gzip import json import sys + +from named_absence import first from datetime import datetime, timedelta, timezone from pathlib import Path @@ -1213,8 +1215,9 @@ check("...and the pin is not vacuous: each of those names really is a " def _t17_sig(module, name): tree = ast.parse((HERE / "app" / module).read_text(encoding="utf-8")) - fn = next(n for n in tree.body - if isinstance(n, ast.FunctionDef) and n.name == name) + fn = first((n for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name == name), + f"def {name} in {module}") return [a.arg for a in fn.args.args] diff --git a/goodwe_controller/test_tariff.py b/goodwe_controller/test_tariff.py index e18a076..9511151 100644 --- a/goodwe_controller/test_tariff.py +++ b/goodwe_controller/test_tariff.py @@ -114,6 +114,8 @@ import tempfile from datetime import datetime, timedelta, timezone from pathlib import Path +from named_absence import given, nth, present + from app.main import Controller from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed from app.tariff import ( @@ -260,7 +262,7 @@ deg = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) deg_closed = [] for i in range(0, QUARTER_S + 1, 5): deg_closed += deg.add(sample(B + timedelta(seconds=i), HELD_W)) -d_block = deg_closed[0].offtake_avg_w +d_block = nth(deg_closed, 0).offtake_avg_w deg2 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) LAST_S = QUARTER_S - 5 for i in range(0, LAST_S + 1, 5): @@ -311,7 +313,7 @@ def feed(avg, t0_s, t1_s, w=RUN_W): # (a) the control: no restart at all. ctrl = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) ctrl_closed = feed(ctrl, 0, QUARTER_S) -CTRL_W = ctrl_closed[0].offtake_avg_w +CTRL_W = nth(ctrl_closed, 0).offtake_avg_w # (b) restarted at minute 10, checkpoint restored through a real SQLite file. st = fresh_store("restart") @@ -332,8 +334,8 @@ a3 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) n_closed = feed(a3, RESTART_AT_S + STEP_S, QUARTER_S) NORESTORE_W = n_closed[0].offtake_avg_w if n_closed else None -print(f" uninterrupted {CTRL_W:.2f} W | restored {RESTART_W:.2f} W | " - f"restart with no restore {NORESTORE_W:.2f} W") +print(f" uninterrupted {CTRL_W:.2f} W | restored {given(RESTART_W):.2f} W | " + f"restart with no restore {given(NORESTORE_W):.2f} W") check(f"a restart at {RESTART_AT_S} s lands on the SAME billed block as an " f"uninterrupted run", near(RESTART_W, CTRL_W, 1e-6)) check("...and without the restore it lands LOWER - so 'the restore works' is " @@ -357,18 +359,18 @@ 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 " f"{OUTAGE_UNCOVERED_S:.0f} s is past the {MAX_HOLD_S:.0f} s hold: " - f"{o_closed[0].offtake_avg_w:.2f} W") + f"{nth(o_closed, 0).offtake_avg_w:.2f} W") check(f"...reads lower than the uninterrupted block by exactly the uncovered " f"seconds ({RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S:.2f} W) - a gap is " f"never held forward across the whole outage", - near(CTRL_W - o_closed[0].offtake_avg_w, + near(CTRL_W - nth(o_closed, 0).offtake_avg_w, RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S, 1e-6)) # (e) double counting is answered by the primary key, not by care. st5 = fresh_store("dedupe") -st5.record_block(ctrl_closed[0]) -st5.record_block(ctrl_closed[0]) -st5.record_block(ctrl_closed[0]) +if ctrl_closed: + for _ in range(3): + st5.record_block(ctrl_closed[0]) rows = st5.blocks_in(month_key(B)) check("banking the same quarter three times leaves ONE row - quarter_start is " "the primary key, so a replay or a restart cannot double-count", @@ -376,7 +378,9 @@ check("banking the same quarter three times leaves ONE row - quarter_start is " # ...and a later pass with LESS coverage may not lower a banked figure. from app.tariff import _ClosedBlock as _CB -st5.record_block(_CB(start=B, offtake_avg_w=CTRL_W / 2, per_phase_offtake_avg_w=None)) +if present(CTRL_W): + st5.record_block(_CB(start=B, offtake_avg_w=CTRL_W / 2, + per_phase_offtake_avg_w=None)) after = st5.blocks_in(month_key(B)) check(f"...and re-banking the same quarter at half the figure leaves it at " f"{CTRL_W:.0f} W - a restart's partial re-run must never lower a " @@ -665,10 +669,10 @@ u = QuarterAverager(phases=3, max_hold_s=MAX_HOLD_S) u_closed = [] for i in range(0, QUARTER_S + 1, 5): u_closed += u.add(sample(B + timedelta(seconds=i), NET, phases=3, phase_net=PH)) -blk = u_closed[0] +blk = nth(u_closed, 0) vals = {p: billed_w(blk, p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)} print(f" phases {PH} (net {NET:+.0f} W) -> " - + ", ".join(f"{k}={v:.0f} W" for k, v in vals.items())) + + ", ".join(f"{k}={given(v):.0f} W" for k, v in vals.items())) check(f"the NET policy bills the connection's net offtake ({NET:.0f} W)", near(vals[POLICY_NET], NET, 1e-6)) check(f"the SUM policy clips each phase first, so the exporting phase does not " @@ -687,7 +691,7 @@ b_closed = [] for i in range(0, QUARTER_S + 1, 5): b_closed += bal.add(sample(B + timedelta(seconds=i), 3000.0, phases=3, phase_net=(1000.0, 1000.0, 1000.0))) -b_vals = {p: billed_w(b_closed[0], p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)} +b_vals = {p: billed_w(nth(b_closed, 0), p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)} check(f"...and on a BALANCED load NET and SUM coincide ({b_vals[POLICY_NET]:.0f} W), " f"so a balanced fixture could not have told them apart", near(b_vals[POLICY_NET], b_vals[POLICY_SUM], 1e-6)) @@ -746,7 +750,7 @@ check(f"...and record_block() with NO policy argument banks the NET figure " # fixture change ever made two of these coincide, the triple above would start # passing under a hardcoded policy and would be silently back where it started. print(f" banked through the store: " - + ", ".join(f"{k}={v:.0f} W" for k, v in _banked.items())) + + ", ".join(f"{k}={given(v):.0f} W" for k, v in _banked.items())) check("the three BANKED figures are three distinct numbers, so this triple is " "not itself degenerate - it can only pass if the argument is read", len({round(v, 6) for v in _banked.values() if v is not None}) == 3) @@ -855,16 +859,16 @@ for path in REPLAY: print(f" {path.name}: {span_h:.1f} h, {len(qs)} closed quarters, " f"month peak {peak:.0f} W, " f"{ok_q}/{len(qs)} = {100.0 * ok_q / max(len(qs), 1):.1f} % under the " - f"{SHIPPED_CAP_W:.0f} W soft limit") + f"{given(SHIPPED_CAP_W):.0f} W soft limit") check(f"{path.name}: the month's peak equals its own largest quarter", - near(st9.peak(month=sorted(months)[0]).peak_w, peak, 1e-9)) + bool(months) and near(st9.peak(month=sorted(months)[0]).peak_w, peak, 1e-9)) check(f"{path.name}: a full day of real telegrams produces ~96 quarters, " f"got {len(qs)}", 90 <= len(qs) <= 96) PCT = 100.0 * under / max(total_q, 1) print() print(f" AC 3 MEASURED: {under}/{total_q} quarters = {PCT:.1f} % under the " - f"{SHIPPED_CAP_W:.0f} W soft limit, over {replay_days:.1f} DAYS of " + f"{given(SHIPPED_CAP_W):.0f} W soft limit, over {replay_days:.1f} DAYS of " f"replay, UNCONTROLLED (no peak shaving was running on either day).") print(f" AC 3 ASKS FOR 30 DAYS and this run is {replay_days:.1f}, so it " f"does not settle it either way. The 30 days DO now exist - " @@ -928,7 +932,7 @@ def built(db_path, opts=None): c = built(TMP / "wired.db") check(f"a Controller built with peak_cap_w ABSENT takes the SHIPPED " - f"{SHIPPED_CAP_W:.0f} W, so config.yaml and main.py's fallback cannot " + f"{given(SHIPPED_CAP_W):.0f} W, so config.yaml and main.py's fallback cannot " f"drift apart unnoticed", c.peak.cap_w == SHIPPED_CAP_W) # Drive one real quarter through the real ingest, then the real cycle's sync. @@ -981,7 +985,7 @@ check("the status page carries exactly one capacity-tariff peak row", check("...naming the peak, the soft limit and a coverage figure, all derived " "from the controller that was actually built", bool(peak_rows) and "4998 W" in peak_rows[0]["text"] - and f"{SHIPPED_CAP_W:.0f} W soft limit" in peak_rows[0]["text"] + and f"{given(SHIPPED_CAP_W):.0f} W soft limit" in peak_rows[0]["text"] and "quarter(s) banked" in peak_rows[0]["text"]) # ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH, @@ -1006,7 +1010,7 @@ check("the row's coverage figure is the LIVE month's tally - graded against a " bool(peak_rows) and f"; {_live_rows} quarter(s) banked" in peak_rows[0]["text"]) check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point " - f"on a {SHIPPED_CAP_W:.0f} W limit", + f"on a {given(SHIPPED_CAP_W):.0f} W limit", bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"]) # The half that matters: the same page with a store that cannot answer. diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 8ec0ea0..8d8cf38 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -20,6 +20,8 @@ Read MUTATION.md before you report a survivor. import sys from datetime import datetime, timedelta, timezone +from named_absence import nth, present + from app.watchdog import ( SignWatchdog, SignWatchdogConfig, loop_is_following, ) @@ -1102,7 +1104,7 @@ check("...under a fixed notification_id of its own, so a second episode " all(c[2]["notification_id"] == "goodwe_circulating_power" for c in _notes1 + _notes2) and len({c[2]["notification_id"] for c in _notes1 + _notes2}) == 1) -_msg = _notes2[0][2]["message"] +_msg = nth(_notes2, 0)[2]["message"] check("...and the message tells the operator the three things only this alarm " "can: that the meter cannot see it, that control has been stopped, and " "that if the grid reading does not move afterwards one inverter is no " @@ -1706,7 +1708,7 @@ print(f" {_b30.sign_wd.reason!r}") # the objects - and not off the fixture's constants, so the check cannot be # satisfied by two numbers the fixture happens to hold anyway (habit 2). _preb30 = [x for x in _s30 if not x["b"]] -_midb30 = _preb30[-1] +_midb30 = nth(_preb30, -1, "no cycle before B latched") print(f" last cycle before B latched: B stood " f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W") check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A " @@ -1840,7 +1842,7 @@ _c31, _h31, _s31 = asyncio.run(_site_reversed()) # stopped. This is `Run.p2p_between`'s lesson from the rig, in a unit test: name # the window rather than inferring it. _pre31 = [x for x in _s31 if not x["site"]] -_mid31 = _pre31[-1] +_mid31 = nth(_pre31, -1, "no cycle before the trip") print(f" last cycle before the trip: stood " f"{[round(x) for x in _mid31['stood']]} " f"measured {[round(x) for x in _mid31['meas']]} " @@ -1849,7 +1851,8 @@ check("PRECONDITION: EVERY inverter is obeying - each unit's measured power " "agrees in sign with its own share - and the site is still charging into " "an importing house. Without this the site trip below could be a " "per-unit fault in disguise", - all(s * m > 0 for s, m in zip(_mid31["stood"], _mid31["meas"])) + present(_mid31) + and all(s * m > 0 for s, m in zip(_mid31["stood"], _mid31["meas"])) and sum(_mid31["stood"]) * _mid31["grid"] < 0) check("⚠️ AC 3. THE SITE WATCHDOG TRIPS AND NOT ONE PER-UNIT WATCHDOG DOES. " "That asymmetry is the card: the fault is in the number the site was " @@ -2053,7 +2056,7 @@ check("...the reason sentence still says 'a meter reading', word for word - it " # consecutive commands IDENTICAL; at a rail-following slew the loop ramps and # they differ by exactly one step, which is how this was found. _first1s = [x for x in _seen1s if x["tripped"]] -_mid1s = _first1s[0] +_mid1s = nth(_first1s, 0, "no tripped cycle") print(f" the cycle the latch happened on: stood {_mid1s['stood']:+.0f} W, " f"meter {_mid1s['grid']:+.0f} W") print(f" {_inv1.sign_wd.reason!r}") @@ -2501,13 +2504,13 @@ _c38, _s38seen = asyncio.run(_s38_site()) # The last sample BEFORE the trip, named rather than indexed - part 5's lesson. # After the trip every command is 0 W, so a fixed index reads the REMEDY. _pre38 = [x for x in _s38seen if not x["site"]] -_mid38 = _pre38[-1] +_mid38 = nth(_pre38, -1, "no cycle before the trip") _GATE38 = _c38.site_sign_wd.cfg.min_w _share38 = list(_mid38["stood"]) _red38 = {"sum": sum(_share38), "mean": sum(_share38) / max(1, len(_share38)), - "max(key=abs)": max(_share38, key=abs), - "[0]": _share38[0]} + "max(key=abs)": max(_share38, key=abs, default=nth([], 0)), + "[0]": nth(_share38, 0)} print(f" last cycle before the trip: stood " f"{[round(x) for x in _share38]} measured " f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W") @@ -2538,7 +2541,8 @@ check("PRECONDITION: EVERY inverter is obeying - each unit's measured power " "agrees in sign with its own share - and the site is charging into an " "importing house. Without this the trip below could be a per-unit fault " "in disguise rather than the site fault this grades", - all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"])) + present(_mid38) + and all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"])) and sum(_mid38["stood"]) * _mid38["grid"] < 0 and _mid38["following"] is True) check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. "