Merge pull request 'TEST-26: a missing value in a check is a named FAIL, not a traceback' (#122) from TEST-26 into release/1.0
This commit was merged in pull request #122.
This commit is contained in:
@@ -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 `<ABSENT ...>` 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"<ABSENT {self.what}>"
|
||||
|
||||
__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
|
||||
@@ -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.
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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",))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"<NO SINGLE `battery power` ROW: {len(hits)} found>"}
|
||||
return hits[0]
|
||||
if len(hits) == 1:
|
||||
return hits[0]
|
||||
return {"ok": None, "warn": None,
|
||||
"text": f"<NO SINGLE `battery power` ROW: {len(hits)} found>"}
|
||||
|
||||
|
||||
_row56_ref = _s56_row(_c56_ref)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) == "<ABSENT [0] of 0 item(s)>" and repr(_c) == "<ABSENT key 'pv'>"
|
||||
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(["<ABSENT [0] of 0 item(s)>"] * 2 + ["<ABSENT None>"] * 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 = <default> 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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. "
|
||||
|
||||
Reference in New Issue
Block a user