|
|
|
@@ -96,11 +96,17 @@ fails = []
|
|
|
|
|
T0 = datetime(2026, 8, 26, 7, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
|
|
|
|
|
|
|
|
# The live install's numbers, which are the ones the exposure is written at.
|
|
|
|
|
LIVE_MAX_W = 5000.0
|
|
|
|
|
# ⚠️ NOT config.yaml's shipped max_w. This is the LIVE INSTALL's own setting -
|
|
|
|
|
# the schema maximum, 5000 - which this file uses on purpose wherever the
|
|
|
|
|
# scenario under test is "a device smaller than the configured max_w" (freeze.py
|
|
|
|
|
# describes exactly this install). Named LIVE_INSTALL_MAX_W, not the shorter
|
|
|
|
|
# LIVE_MAX_W it used to be called, so it cannot be misread as "the current
|
|
|
|
|
# shipped default" - see SHIPPED_MAX_W below for that number, and the pin two
|
|
|
|
|
# sections down for what stops the two from drifting apart unnoticed (TEST-10).
|
|
|
|
|
LIVE_INSTALL_MAX_W = 5000.0
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# THE SHIPPED THRESHOLD, READ FROM config.yaml (TEST-06 / AC3)
|
|
|
|
|
# THE SHIPPED DEFAULTS, READ FROM config.yaml (TEST-06 / TEST-10 / AC3)
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
# ⚠️ `meter_freeze_s: 300` used to appear in this file only as the literal 300,
|
|
|
|
|
# retyped six times, so `300 -> 3600` in config.yaml left every check here
|
|
|
|
@@ -118,9 +124,18 @@ def _cfg_default(key):
|
|
|
|
|
return float(found[0]) if len(found) == 1 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SHIPPED_HOLD_S = _cfg_default("meter_freeze_s")
|
|
|
|
|
SHIPPED_DEADBAND_W = _cfg_default("deadband_w")
|
|
|
|
|
SHIPPED_MAX_W = _cfg_default("max_w")
|
|
|
|
|
# THE KEYS THIS FILE PINS - one place, so the next key added to config.yaml's
|
|
|
|
|
# options block can be checked against it rather than discovered unread later
|
|
|
|
|
# (TEST-10 / AC3). Deliberately not every key: max_w, deadband_w and
|
|
|
|
|
# meter_freeze_s are the ones this file's arithmetic actually reasons about
|
|
|
|
|
# (the mutual-exclusion argument in section 2b needs all three together).
|
|
|
|
|
# Everything else in config.yaml's options block is out of scope here - see
|
|
|
|
|
# test_validate.py for the general "no default silently drifts" pin, and do
|
|
|
|
|
# NOT add a maintenance_* key here: CFG-03 is moving that default between
|
|
|
|
|
# config.yaml, main.py and maintenance.py concurrently with this ticket.
|
|
|
|
|
PINNED_KEYS = ("meter_freeze_s", "deadband_w", "max_w")
|
|
|
|
|
SHIPPED_HOLD_S, SHIPPED_DEADBAND_W, SHIPPED_MAX_W = (
|
|
|
|
|
_cfg_default(key) for key in PINNED_KEYS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check(name, cond):
|
|
|
|
@@ -129,7 +144,7 @@ def check(name, cond):
|
|
|
|
|
fails.append(name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tuning(max_w=LIVE_MAX_W):
|
|
|
|
|
def tuning(max_w=LIVE_INSTALL_MAX_W):
|
|
|
|
|
"""The shipped defaults from config.yaml, at the live install's max_w."""
|
|
|
|
|
return Tuning(gain=0.6, max_w=max_w, slew_w=1000, deadband_w=15,
|
|
|
|
|
target_grid_w=-10, step_w=10, saturation_w=500,
|
|
|
|
@@ -184,34 +199,34 @@ print("SAFETY-20: the rail against the device rating")
|
|
|
|
|
DEV = dict(dev_min=-4700.0, dev_max=4700.0)
|
|
|
|
|
|
|
|
|
|
check("the rail is the DEVICE's rail, not max_w's, when the device is smaller",
|
|
|
|
|
rail_w(LIVE_MAX_W, **DEV) == 4700.0 * RAIL_FRAC
|
|
|
|
|
and rail_w(LIVE_MAX_W, **DEV) != LIVE_MAX_W * RAIL_FRAC)
|
|
|
|
|
rail_w(LIVE_INSTALL_MAX_W, **DEV) == 4700.0 * RAIL_FRAC
|
|
|
|
|
and rail_w(LIVE_INSTALL_MAX_W, **DEV) != LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
|
|
|
|
check("a command pinned at the device rating with a meter frozen for an hour "
|
|
|
|
|
"FIRES - this is the ticket, and it returned False before the fix",
|
|
|
|
|
looks_frozen(3600, 4700, LIVE_MAX_W, cfg, **DEV))
|
|
|
|
|
looks_frozen(3600, 4700, LIVE_INSTALL_MAX_W, cfg, **DEV))
|
|
|
|
|
check("...and the identical call without the rating is still False, so the "
|
|
|
|
|
"check above is measuring the rating and not something else",
|
|
|
|
|
not looks_frozen(3600, 4700, LIVE_MAX_W, cfg))
|
|
|
|
|
not looks_frozen(3600, 4700, LIVE_INSTALL_MAX_W, cfg))
|
|
|
|
|
check("the charge rail moves with it too - the runaway can be either sign",
|
|
|
|
|
looks_frozen(3600, -4700, LIVE_MAX_W, cfg, **DEV))
|
|
|
|
|
looks_frozen(3600, -4700, LIVE_INSTALL_MAX_W, cfg, **DEV))
|
|
|
|
|
check("just under the DEVICE rail is still silent, so the fix widened term 2 "
|
|
|
|
|
"by exactly the rating and not into a bare unchanged_s threshold",
|
|
|
|
|
not looks_frozen(3600, 4700 * RAIL_FRAC - 1, LIVE_MAX_W, cfg, **DEV))
|
|
|
|
|
not looks_frozen(3600, 4700 * RAIL_FRAC - 1, LIVE_INSTALL_MAX_W, cfg, **DEV))
|
|
|
|
|
check("a device BIGGER than max_w cannot raise the rail - max_w is still a "
|
|
|
|
|
"ceiling the operator set and this is a min(), not a swap",
|
|
|
|
|
rail_w(LIVE_MAX_W, dev_min=-9000.0, dev_max=9000.0)
|
|
|
|
|
== LIVE_MAX_W * RAIL_FRAC)
|
|
|
|
|
rail_w(LIVE_INSTALL_MAX_W, dev_min=-9000.0, dev_max=9000.0)
|
|
|
|
|
== LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
|
|
|
|
check("an unread rating (ESPHome not up) leaves the old behaviour exactly - "
|
|
|
|
|
"no device clamp is applied on that path either, so max_w IS the ceiling",
|
|
|
|
|
rail_w(LIVE_MAX_W, dev_min=None, dev_max=None) == LIVE_MAX_W * RAIL_FRAC)
|
|
|
|
|
rail_w(LIVE_INSTALL_MAX_W, dev_min=None, dev_max=None) == LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
|
|
|
|
check("one bound alone still lowers it - hass.limits() reads min and max "
|
|
|
|
|
"independently and either can come back None on its own",
|
|
|
|
|
rail_w(LIVE_MAX_W, dev_max=4000.0) == 4000.0 * RAIL_FRAC)
|
|
|
|
|
rail_w(LIVE_INSTALL_MAX_W, dev_max=4000.0) == 4000.0 * RAIL_FRAC)
|
|
|
|
|
check("a non-finite rating is IGNORED, never read as a 0 W ceiling",
|
|
|
|
|
rail_w(LIVE_MAX_W, dev_min=float("nan"), dev_max=float("inf"))
|
|
|
|
|
== LIVE_MAX_W * RAIL_FRAC)
|
|
|
|
|
rail_w(LIVE_INSTALL_MAX_W, dev_min=float("nan"), dev_max=float("inf"))
|
|
|
|
|
== LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
|
|
|
|
check("the ceiling helper says what it measured",
|
|
|
|
|
rail_ceiling_w(LIVE_MAX_W, **DEV) == 4700.0)
|
|
|
|
|
rail_ceiling_w(LIVE_INSTALL_MAX_W, **DEV) == 4700.0)
|
|
|
|
|
|
|
|
|
|
# The degenerate end. A ceiling of 0 W does not disarm term 2, it makes it TRUE
|
|
|
|
|
# OF EVERY COMMAND - including a parked 0 W one - which decays the conjunction
|
|
|
|
@@ -219,27 +234,27 @@ check("the ceiling helper says what it measured",
|
|
|
|
|
# this controller's own operating point.
|
|
|
|
|
check("a device that can command nothing makes the rail UNREACHABLE, with a "
|
|
|
|
|
"reason for the status page",
|
|
|
|
|
bool(rail_unreachable(LIVE_MAX_W, dev_min=0.0, dev_max=0.0)))
|
|
|
|
|
bool(rail_unreachable(LIVE_INSTALL_MAX_W, dev_min=0.0, dev_max=0.0)))
|
|
|
|
|
check("...and a reachable rail says so with an empty string, or the row above "
|
|
|
|
|
"would be permanent",
|
|
|
|
|
rail_unreachable(LIVE_MAX_W, **DEV) == ""
|
|
|
|
|
and rail_unreachable(LIVE_MAX_W) == "")
|
|
|
|
|
rail_unreachable(LIVE_INSTALL_MAX_W, **DEV) == ""
|
|
|
|
|
and rail_unreachable(LIVE_INSTALL_MAX_W) == "")
|
|
|
|
|
check("...and the predicate is SILENT there rather than degenerately true - "
|
|
|
|
|
"0 W would otherwise satisfy `abs(command) >= 0`",
|
|
|
|
|
not looks_frozen(3600, 0.0, LIVE_MAX_W, cfg, dev_min=0.0, dev_max=0.0)
|
|
|
|
|
and not looks_frozen(3600, 5000, LIVE_MAX_W, cfg, dev_min=0.0,
|
|
|
|
|
not looks_frozen(3600, 0.0, LIVE_INSTALL_MAX_W, cfg, dev_min=0.0, dev_max=0.0)
|
|
|
|
|
and not looks_frozen(3600, 5000, LIVE_INSTALL_MAX_W, cfg, dev_min=0.0,
|
|
|
|
|
dev_max=0.0))
|
|
|
|
|
|
|
|
|
|
# The alarm text is the third surface that carried the untrue number - it
|
|
|
|
|
# outlives the page, in the log and in the HA notification.
|
|
|
|
|
_wd20 = FreezeWatchdog(cfg)
|
|
|
|
|
_wd20.evaluate(3600, 4700, LIVE_MAX_W, T0, **DEV)
|
|
|
|
|
print(f" at max_w {LIVE_MAX_W:.0f} on a +-4700 W device: "
|
|
|
|
|
_wd20.evaluate(3600, 4700, LIVE_INSTALL_MAX_W, T0, **DEV)
|
|
|
|
|
print(f" at max_w {LIVE_INSTALL_MAX_W:.0f} on a +-4700 W device: "
|
|
|
|
|
f"{_wd20.reason}")
|
|
|
|
|
check("the alarm reason quotes the rail it actually used and NOT the max_w one "
|
|
|
|
|
"the command could never have reached",
|
|
|
|
|
f"command over {4700 * RAIL_FRAC:.0f} W" in _wd20.reason
|
|
|
|
|
and f"{LIVE_MAX_W * RAIL_FRAC:.0f} W" not in _wd20.reason)
|
|
|
|
|
and f"{LIVE_INSTALL_MAX_W * RAIL_FRAC:.0f} W" not in _wd20.reason)
|
|
|
|
|
check("...and it says the device is what capped it, so the operator is not "
|
|
|
|
|
"left wondering why the guard is armed somewhere other than max_w",
|
|
|
|
|
"rating" in _wd20.reason and "max_w" in _wd20.reason)
|
|
|
|
@@ -277,6 +292,18 @@ check("the code default and the shipped default are the same number - "
|
|
|
|
|
check("this file's `tuning()` really is config.yaml's deadband, so the "
|
|
|
|
|
"mutual-exclusion arithmetic below is about the shipped law",
|
|
|
|
|
tuning().deadband_w == SHIPPED_DEADBAND_W)
|
|
|
|
|
# TEST-10: max_w had no such pin - `max_w: 2000 -> 4500` in config.yaml left
|
|
|
|
|
# every check in this file green, the same gap TEST-06 closed for
|
|
|
|
|
# meter_freeze_s one key over. `Tuning`'s own bare default (control.py) is
|
|
|
|
|
# 2000.0 because that IS the shipped default; comparing the two means a
|
|
|
|
|
# config.yaml edit that is not matched in control.py goes red here, the same
|
|
|
|
|
# way FreezeWatchdogConfig().hold_s catches a drifted meter_freeze_s above.
|
|
|
|
|
# This is deliberately NOT a check against LIVE_INSTALL_MAX_W (5000) - that
|
|
|
|
|
# constant is the live install's own override and is supposed to differ from
|
|
|
|
|
# the shipped default; see its definition for why.
|
|
|
|
|
check("the code default and the shipped default agree on max_w too - "
|
|
|
|
|
"Tuning() is a default, config.yaml is the ship",
|
|
|
|
|
Tuning().max_w == SHIPPED_MAX_W)
|
|
|
|
|
|
|
|
|
|
# The upper bound is money: config.yaml's own comment says 300 s is "still
|
|
|
|
|
# inside the 900 s quarter that bills the capacity tariff". A hold that outlives
|
|
|
|
@@ -335,7 +362,7 @@ def replay(hold_s, max_w, cycle_floor_s=15.0):
|
|
|
|
|
return wd, peak_cmd, peak_unchanged, fired_at
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for mw in (2000.0, LIVE_MAX_W):
|
|
|
|
|
for mw in (SHIPPED_MAX_W, LIVE_INSTALL_MAX_W):
|
|
|
|
|
wd_r, peak_cmd, peak_unchanged, fired = replay(SHIPPED_HOLD_S, mw)
|
|
|
|
|
print(f" max_w {mw:.0f}: worst |command| {peak_cmd:.0f} W (rail "
|
|
|
|
|
f"{rail_w(mw):.0f} W), worst unchanged {peak_unchanged:.1f} s "
|
|
|
|
@@ -347,7 +374,7 @@ for mw in (2000.0, LIVE_MAX_W):
|
|
|
|
|
# The margin on the OTHER term, as numbers rather than as trust. Both figures
|
|
|
|
|
# are quoted in config.yaml and freeze.py, so they are re-measured every run and
|
|
|
|
|
# a capture swapped underneath them cannot leave the prose saying the old thing.
|
|
|
|
|
_, _, worst_unchanged, _ = replay(SHIPPED_HOLD_S, LIVE_MAX_W)
|
|
|
|
|
_, _, worst_unchanged, _ = replay(SHIPPED_HOLD_S, LIVE_INSTALL_MAX_W)
|
|
|
|
|
gaps, last, prev = [], rows[0]["t"], None
|
|
|
|
|
for r in rows:
|
|
|
|
|
if prev is None or r["pwr"] != prev:
|
|
|
|
@@ -381,7 +408,7 @@ check(f"the shipped {SHIPPED_HOLD_S:.0f} s is at least 15x the worst of the two"
|
|
|
|
|
# that happens not to move.
|
|
|
|
|
print("TEL-05's low-power case - a converged meter that legitimately repeats")
|
|
|
|
|
tel05 = FreezeWatchdog(FreezeWatchdogConfig(hold_s=SHIPPED_HOLD_S))
|
|
|
|
|
t5 = tuning(LIVE_MAX_W)
|
|
|
|
|
t5 = tuning(LIVE_INSTALL_MAX_W)
|
|
|
|
|
tgt, iw, sc = 0.0, 0.0, 0
|
|
|
|
|
bare_would_fire = False
|
|
|
|
|
tel05_cmd = 0.0
|
|
|
|
@@ -397,7 +424,7 @@ for k in range(TEL05_S):
|
|
|
|
|
tel05_cmd = max(tel05_cmd, abs(tgt))
|
|
|
|
|
# The REJECTED design, written out so the case can be shown to be real.
|
|
|
|
|
bare_would_fire = bare_would_fire or float(k) >= SHIPPED_HOLD_S
|
|
|
|
|
tel05.evaluate(float(k), tgt, LIVE_MAX_W, T0 + timedelta(seconds=k))
|
|
|
|
|
tel05.evaluate(float(k), tgt, LIVE_INSTALL_MAX_W, T0 + timedelta(seconds=k))
|
|
|
|
|
print(f" {TEL05_S / 60:.0f} min at -14.2 W: worst |command| {tel05_cmd:.0f} W, "
|
|
|
|
|
f"unchanged_s reaches {TEL05_S - 1} s, episodes {tel05.episodes}")
|
|
|
|
|
check("the bare unchanged_s threshold TEL-05 rejected WOULD fire here",
|
|
|
|
@@ -405,7 +432,7 @@ check("the bare unchanged_s threshold TEL-05 rejected WOULD fire here",
|
|
|
|
|
check("the conjunction does not - this is what the rail term buys",
|
|
|
|
|
tel05.episodes == 0)
|
|
|
|
|
check("...and the command stayed nowhere near the rail",
|
|
|
|
|
tel05_cmd < rail_w(LIVE_MAX_W))
|
|
|
|
|
tel05_cmd < rail_w(LIVE_INSTALL_MAX_W))
|
|
|
|
|
|
|
|
|
|
# The structural version of the same point, re-derived rather than asserted in
|
|
|
|
|
# prose: the slowest register a HEALTHY meter can have while still being seen as
|
|
|
|
@@ -421,7 +448,7 @@ print(f" at the shipped {SHIPPED_HOLD_S:.0f} s hold, the slowest healthy "
|
|
|
|
|
check("a healthy meter slow enough to look unchanged is inside the deadband",
|
|
|
|
|
slowest_healthy_w < DEADBAND_W)
|
|
|
|
|
_park = compute(prev_w=0.0, grid_w=-slowest_healthy_w, actual_w=0.0,
|
|
|
|
|
tuning=tuning(LIVE_MAX_W), sat_count=0, i_w=0.0)
|
|
|
|
|
tuning=tuning(LIVE_INSTALL_MAX_W), sat_count=0, i_w=0.0)
|
|
|
|
|
check("...and the shipped law really does park the command there",
|
|
|
|
|
_park.target_w == 0.0)
|
|
|
|
|
|
|
|
|
@@ -500,7 +527,7 @@ while True:
|
|
|
|
|
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def live_run(port, mode, hold_s=10.0, seconds=16.0, max_w=LIVE_MAX_W):
|
|
|
|
|
async def live_run(port, mode, hold_s=10.0, seconds=16.0, max_w=LIVE_INSTALL_MAX_W):
|
|
|
|
|
"""Poll a real HTTP meter through the real transport and drive the detector."""
|
|
|
|
|
ingest = P1Ingest(phases=1, max_age_s=30)
|
|
|
|
|
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s))
|
|
|
|
@@ -583,7 +610,7 @@ fired2, polls2, unch2, cmd2 = with_server(
|
|
|
|
|
print(f" moving: {polls2} polls, worst unchanged {unch2:.1f} s, "
|
|
|
|
|
f"worst |command| {cmd2:.0f} W, alarm at "
|
|
|
|
|
f"{'never' if fired2 is None else f'{fired2:.1f} s'}")
|
|
|
|
|
check("the moving meter also drove the command to the rail", cmd2 >= rail_w(LIVE_MAX_W))
|
|
|
|
|
check("the moving meter also drove the command to the rail", cmd2 >= rail_w(LIVE_INSTALL_MAX_W))
|
|
|
|
|
check("a REAL railed condition against a live meter stays silent", fired2 is None)
|
|
|
|
|
check("...because the meter kept moving", unch2 < 10.0)
|
|
|
|
|
|
|
|
|
@@ -671,7 +698,7 @@ STALE_INPUT_S = 15.0
|
|
|
|
|
def _opts(**over):
|
|
|
|
|
o = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
|
|
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
|
|
|
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
|
|
|
|
|
"meter_source": "off", "max_w": LIVE_INSTALL_MAX_W, "gain": 0.6,
|
|
|
|
|
"slew_w": 1000, "deadband_w": SHIPPED_DEADBAND_W,
|
|
|
|
|
"target_grid_w": -10, "step_w": 10, "heartbeat_s": 10,
|
|
|
|
|
"stale_input_s": STALE_INPUT_S}
|
|
|
|
@@ -811,7 +838,7 @@ 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.
|
|
|
|
|
check("and it does NOT command 0 W - this cut is an alarm only",
|
|
|
|
|
abs(ctl_f.target) >= rail_w(LIVE_MAX_W))
|
|
|
|
|
abs(ctl_f.target) >= rail_w(LIVE_INSTALL_MAX_W))
|
|
|
|
|
|
|
|
|
|
# ⚠️ THE THREE SCENARIOS THAT SAFETY-03 FORCED A DECISION ON, decided one at a
|
|
|
|
|
# time and not reflexively narrowed to green. Measured on the merged tree:
|
|
|
|
@@ -848,7 +875,7 @@ print(f" real 8 kW load, moving meter: surface "
|
|
|
|
|
f"freeze {len(notes_of(hass_m))}")
|
|
|
|
|
check("wired up, a real railed load against a moving meter raises nothing FROM "
|
|
|
|
|
"THIS GUARD (the clamp alarm there is SAFETY-03's and is correct)",
|
|
|
|
|
not notes_of(hass_m) and abs(ctl_m.target) >= rail_w(LIVE_MAX_W))
|
|
|
|
|
not notes_of(hass_m) and abs(ctl_m.target) >= rail_w(LIVE_INSTALL_MAX_W))
|
|
|
|
|
|
|
|
|
|
hass_u = wired(None, 4000.0)[0]
|
|
|
|
|
print(f" uncovered transport: surface "
|
|
|
|
@@ -902,7 +929,7 @@ check("an uncovered transport (no unchanged_s) raises nothing FROM THIS GUARD, "
|
|
|
|
|
print("WHERE the detector is evaluated in cycle() - the ordering AC1 protects")
|
|
|
|
|
|
|
|
|
|
_railed = [i for i, (t, _, _) in enumerate(trace_f)
|
|
|
|
|
if abs(t) >= rail_w(LIVE_MAX_W)]
|
|
|
|
|
if abs(t) >= rail_w(LIVE_INSTALL_MAX_W)]
|
|
|
|
|
_alarmed = [i for i, (_, _, n) in enumerate(trace_f) if n >= 1]
|
|
|
|
|
_first_railed = _railed[0] if _railed else None
|
|
|
|
|
_first_alarm = _alarmed[0] if _alarmed else None
|
|
|
|
@@ -972,7 +999,7 @@ print(f" poll task killed: alarming at {_before[0]:+.0f} W "
|
|
|
|
|
for i, (t, a, n) in enumerate(_dead)))
|
|
|
|
|
check("the episode really was standing before the poll task died - otherwise "
|
|
|
|
|
"the release below is vacuous",
|
|
|
|
|
_before[1] and _before[2] == 1 and abs(_before[0]) >= rail_w(LIVE_MAX_W))
|
|
|
|
|
_before[1] and _before[2] == 1 and abs(_before[0]) >= rail_w(LIVE_INSTALL_MAX_W))
|
|
|
|
|
check("a dead poll task drives the command to 0 W",
|
|
|
|
|
bool(_zeroed) and _zeroed[0] == 0)
|
|
|
|
|
check("...and on THAT SAME cycle the alarm is already clear, because the "
|
|
|
|
@@ -997,7 +1024,7 @@ check("...and it stays clear for as long as the failsafe holds it there",
|
|
|
|
|
# loop re-rails, and that is a new episode which MUST speak. What must not
|
|
|
|
|
# happen is a second notification stacking up beside the first.
|
|
|
|
|
_re_railed = [i for i, (t, _, _) in enumerate(_revived)
|
|
|
|
|
if abs(t) >= rail_w(LIVE_MAX_W)]
|
|
|
|
|
if abs(t) >= rail_w(LIVE_INSTALL_MAX_W)]
|
|
|
|
|
print(f" transport revived: command re-rails on cycle "
|
|
|
|
|
f"{_re_railed[0] if _re_railed else None}, "
|
|
|
|
|
f"{_ctl_d.freeze_wd.episodes} episode(s), {len(_freeze_notes)} "
|
|
|
|
@@ -1084,7 +1111,7 @@ check("...and it quotes the hold and the rail it is armed at, derived from the "
|
|
|
|
|
"config the controller was actually built with",
|
|
|
|
|
bool(_rows_c)
|
|
|
|
|
and f"{_ctl_c.freeze_wd.cfg.hold_s:.0f} s unchanged" in _rows_c[0]["text"]
|
|
|
|
|
and f"{rail_w(LIVE_MAX_W):.0f} W" in _rows_c[0]["text"])
|
|
|
|
|
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" in _rows_c[0]["text"])
|
|
|
|
|
check("...as an ok row, since a covered armed guard is not a warning",
|
|
|
|
|
bool(_rows_c) and _rows_c[0]["ok"] and not _rows_c[0]["warn"])
|
|
|
|
|
|
|
|
|
@@ -1120,16 +1147,16 @@ check("PRECONDITION: the device rating - not max_w - is what stopped the "
|
|
|
|
|
abs(_c20.target) == 4700.0)
|
|
|
|
|
check("...and that command is BELOW the max_w rail, which is why the shipped "
|
|
|
|
|
"guard could never fire on this install",
|
|
|
|
|
abs(_c20.target) < rail_w(LIVE_MAX_W))
|
|
|
|
|
abs(_c20.target) < rail_w(LIVE_INSTALL_MAX_W))
|
|
|
|
|
check("...and at or above the device rail, which is why it must",
|
|
|
|
|
abs(_c20.target) >= rail_w(LIVE_MAX_W, dev_min=-4700.0, dev_max=4700.0))
|
|
|
|
|
abs(_c20.target) >= rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0))
|
|
|
|
|
check("THE ALARM FIRES through the real cycle() - the rating reaches the "
|
|
|
|
|
"predicate from the controller, not just from a unit call",
|
|
|
|
|
len(_n20) == 1 and _c20.freeze_wd.active)
|
|
|
|
|
check("...and the notification the operator gets quotes the capped rail, "
|
|
|
|
|
"not the max_w one",
|
|
|
|
|
bool(_n20)
|
|
|
|
|
and f"{rail_w(LIVE_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
|
|
|
|
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
|
|
|
|
in str(_n20[0].get("message", "")))
|
|
|
|
|
# p1_enabled is what a homewizard_local install has - set by hand here for the
|
|
|
|
|
# same reason part 4 sets it, see freeze_rows().
|
|
|
|
@@ -1162,10 +1189,10 @@ check("a device smaller than max_w still gets exactly one freeze-detector row",
|
|
|
|
|
check("...and the page does NOT print the max_w rail the command can never "
|
|
|
|
|
"reach - THE DEFECT, in one assertion",
|
|
|
|
|
bool(_rows_s)
|
|
|
|
|
and f"{rail_w(LIVE_MAX_W):.0f} W" not in _rows_s[0]["text"])
|
|
|
|
|
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" not in _rows_s[0]["text"])
|
|
|
|
|
check("...it prints the rail the predicate actually uses",
|
|
|
|
|
bool(_rows_s)
|
|
|
|
|
and f"{rail_w(LIVE_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
|
|
|
|
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
|
|
|
|
in _rows_s[0]["text"])
|
|
|
|
|
check("...and it names the device range as the reason, since the operator "
|
|
|
|
|
"configured max_w and is owed the reason the guard is armed elsewhere",
|
|
|
|
@@ -1190,7 +1217,7 @@ check("...and THE RAIL THE PAGE ADVERTISES IS ONE THE WIRE CAN ACTUALLY CARRY. "
|
|
|
|
|
check("...and a command at exactly that advertised rail, with the meter frozen, "
|
|
|
|
|
"really does trip the shipped predicate the row is describing",
|
|
|
|
|
_page_rail is not None
|
|
|
|
|
and looks_frozen(3600, _page_rail, LIVE_MAX_W, cfg,
|
|
|
|
|
and looks_frozen(3600, _page_rail, LIVE_INSTALL_MAX_W, cfg,
|
|
|
|
|
dev_min=_ctl_s.dev_min, dev_max=_ctl_s.dev_max))
|
|
|
|
|
check("...as an ok row - a guard armed at a lower rail is still armed, not a "
|
|
|
|
|
"warning", bool(_rows_s) and _rows_s[0]["ok"] and not _rows_s[0]["warn"])
|
|
|
|
@@ -1205,7 +1232,7 @@ check("...flagged as a warning rather than a silent ok, like the uncovered "
|
|
|
|
|
"transport row it shares",
|
|
|
|
|
bool(_rows_z) and not _rows_z[0]["ok"] and _rows_z[0]["warn"])
|
|
|
|
|
check("...and the predicate agrees with the page rather than firing behind it",
|
|
|
|
|
not looks_frozen(3600, 0.0, LIVE_MAX_W, cfg,
|
|
|
|
|
not looks_frozen(3600, 0.0, LIVE_INSTALL_MAX_W, cfg,
|
|
|
|
|
dev_min=_ctl_z.dev_min, dev_max=_ctl_z.dev_max))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|