SAFETY-10: the whole decision path runs and nothing reaches the inverter
checks / unit (pull_request) Successful in 7m58s
checks / unit (pull_request) Successful in 7m58s
Every optimiser defect this project has found was invisible from outside while it was happening, and each was paid for at the meter first: SAFETY-45's 28 charge onsets an hour against a binding peak_cap_w, TARIFF-14's wrong quarter-hour for 45 minutes in every 60, SAFETY-46's 598 Wh spent to gain 310 Wh. There was no way to watch what the optimiser WOULD do without letting it do it. `shadow_mode` runs strategies, arbiter, clamps and guards, records the decision with its reason, and commands nothing. ⚠️ IT WRITES. It writes 0 W, on the ordinary heartbeat, through the ordinary single writer. The inverter holds its last command forever - it has no meter timeout - so "commands nothing" implemented as "stops writing" would leave whatever was standing when shadow mode came on standing for as long as it was left on, under a page saying nothing is being commanded. That is the failure this repository keeps finding. Refreshing 0 W is also what firmware/goodwe-master.yaml's 30 s stale-input watchdog requires of us; going quiet would keep that board tripped for the whole run, which is an alarm being used as a mode. ⚠️ IT IS NOT A SECOND WRITE PATH. No claim, no transport, no arithmetic of its own. It is a substitution inside Controller._write_unit() - the single choke point every write already reaches - in the same form as the three above it, and it is the LAST of them, so SAFETY-28's gate and SAFETY-03's clamp still grade the number the decision path produced. ⚠️ THE DEFAULT IS `auto`, NOT `true`, AND THAT IS THE CARD'S REAL CONTENT. The Supervisor composes options as {**config.yaml defaults, **stored user options}, so a new key with a new default reaches EVERY EXISTING INSTALL on the next update - including the one regulating a real battery. A boolean default-on would have stopped it silently. There is no mechanism by which one key can default differently for a new install and an existing one, so "default-on for new" is a value the add-on RESOLVES: `auto` is shadow while control is STOPPED and live once it is STARTED. A fresh install ships `auto_start: false` and comes up observing; an install that is controlling is started and does not change by one watt. THE FAILSAFES. Sign watchdogs (SAFETY-02/30): INERT - they grade Inverter.standing_w(), which shadow mode holds at 0 W. Fed the shadow decision they would disagree with a meter that never saw it and LATCH A TRIP THAT SURVIVES INTO LIVE OPERATION AND A RESTART. Clamp (SAFETY-03), write gate (SAFETY-28), meter freeze (SAFETY-18), peak guard: OBSERVE. Circulation (SAFETY-31) and delivery (SAFETY-42): observe and may legitimately latch, because their evidence is measured power. The operator stop is the one deliberate change: enforced at the wire rather than as a P_SAFETY claim, which would otherwise make every recorded decision read "stopped". ⚠️ RECORDED LIMITATION: _shadow_settle() re-seeds the carried command to what stands on the wire, which makes the exit safe by construction - and therefore the loop leg's shadow decision is ONE SLEW STEP, not a trajectory. A shadow run answers "what would it command now", not "what would it have done next". The strategies are unaffected; all three defects above are visible from one. Graded on the writes and on the rendered surface, never on the flag: 22 mutations, no survivors, including one that lets a write escape and one that goes silent instead of commanding 0 W. Closed loop on TEST-23 stage 3's rig, same hour twice: live drives the pack to -2000 W and SoC 50.000 -> 62.331 %; shadow commands 0 W throughout, SoC stays 50.0 exactly, and the arbitrage decision is identical in action, instant, signed power, reason and arbiter winner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -74,6 +74,7 @@ Read MUTATION.md before you report a survivor.
|
||||
import asyncio
|
||||
import io
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -87,6 +88,7 @@ from app.arbiter import P_LOOP, Claim, UnitBounds, allocate, resolve
|
||||
from app.control import Tuning, compute
|
||||
from app.inverter import Inverter
|
||||
from app.main import Controller
|
||||
from app import shadow
|
||||
|
||||
fails = []
|
||||
T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -670,8 +672,15 @@ def arm(c, value):
|
||||
|
||||
async def _direct():
|
||||
hass = _Hass(0.0)
|
||||
# ⚠️ SAFETY-10: `shadow_mode: "off"` EXPLICITLY, BECAUSE THIS FIXTURE
|
||||
# NEVER PRESSES START. The shipped default is `auto`, which is SHADOW
|
||||
# MODE while control is stopped - so without this line the probe below
|
||||
# would be graded against a controller that is correctly refusing to
|
||||
# command anything, and the clamp it is here to exercise would never be
|
||||
# reached. The shipped write path is unaffected: a stopped controller
|
||||
# only ever writes 0 W in production, which shadow mode also writes.
|
||||
c = Controller({"setpoint_entity": "number.sp", "max_w": 2000,
|
||||
"meter_source": "off"},
|
||||
"meter_source": "off", "shadow_mode": "off"},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
# The shape the heartbeat, shutdown() and handle_action() all use - each one
|
||||
@@ -1121,10 +1130,16 @@ A_UNIT, B_HYPO = "inverter-a", "inverter-b"
|
||||
|
||||
|
||||
def fresh(**over):
|
||||
"""A Controller with a stub HA, no cycle run, and therefore NO record."""
|
||||
"""A Controller with a stub HA, no cycle run, and therefore NO record.
|
||||
|
||||
⚠️ SAFETY-10: `shadow_mode: "off"` for `_direct()`'s reason - this fixture
|
||||
never presses Start, and the shipped default is shadow mode while control
|
||||
is stopped. Part 7 grades who is allowed to reach the WIRE; a controller
|
||||
that is correctly commanding nothing cannot answer that question.
|
||||
"""
|
||||
hass = _Hass(0.0)
|
||||
c = Controller(dict({"setpoint_entity": "number.sp", "max_w": 2000,
|
||||
"meter_source": "off"}, **over),
|
||||
"meter_source": "off", "shadow_mode": "off"}, **over),
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
return hass, c
|
||||
@@ -1414,6 +1429,432 @@ check("...and the numbers those cycles put on the wire are the arbiter's own "
|
||||
hass_r.writes and all(abs(w) <= LIVE_MAX_W for w in hass_r.writes))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 8. SAFETY-10 - SHADOW MODE: THE DECISION PATH RUNS AND NOTHING REACHES THE
|
||||
# INVERTER
|
||||
# --------------------------------------------------------------------------- #
|
||||
# This part belongs in THIS file and not in a new one: shadow mode is a
|
||||
# substitution inside `_write_unit()`, one line below SAFETY-03's clamp and two
|
||||
# below SAFETY-28's gate, and the property it claims - "nothing reaches the
|
||||
# inverter" - is the same property parts 5 and 7 already grade from the other
|
||||
# side. The fake `set_number()` in this file IS the wire.
|
||||
#
|
||||
# ⚠️ EVERY CHECK BELOW IS GRADED ON THE WRITES OR ON THE RENDERED SURFACE, NOT
|
||||
# ON `Controller.shadow`. A check that asserted the flag would pass on a build
|
||||
# in which the flag is set and the substitution is deleted, which is precisely
|
||||
# the build this section exists to refuse.
|
||||
#
|
||||
# ⚠️ AND EVERY ONE OF THEM RUNS THE SAME STIMULUS IN BOTH MODES. MUTATION.md's
|
||||
# rule: a per-thing check that has never seen the things DISAGREE is a total.
|
||||
# `_s10_live` and `_s10_shadow` are the same 6000 W import through the same
|
||||
# `Controller.cycle()`; the only difference is one option.
|
||||
print("\n8. SAFETY-10 - shadow mode\n")
|
||||
|
||||
|
||||
def _s10(shadow_mode, *, auto=True, cycles=25, grid=6000.0, dev=None, **over):
|
||||
"""The same wired run as part 3, with `shadow_mode` and the start button.
|
||||
|
||||
⚠️ `auto` IS A PARAMETER BECAUSE `shadow_mode: auto` READS IT. The three
|
||||
values and the two button positions are six states and they are not all
|
||||
the same state; the pure-function table over all six is in
|
||||
`test_validate.py`, and 8f says why it is not here.
|
||||
"""
|
||||
async def go():
|
||||
hass = _Hass(grid, track=True)
|
||||
c = Controller(dict(
|
||||
{"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,
|
||||
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
|
||||
"shadow_mode": shadow_mode},
|
||||
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = auto
|
||||
# `dev` narrows what the DEVICE says it will accept, which is the
|
||||
# clamp's second stage - the only way to make the clamp engage on a
|
||||
# target the loop has already bounded at `max_w`.
|
||||
c.dev_min, c.dev_max = ((-LIVE_MAX_W, LIVE_MAX_W) if dev is None
|
||||
else (-dev, dev))
|
||||
# ⚠️ CAPTURED PER CYCLE, BECAUSE THE HEARTBEAT OVERWRITES IT.
|
||||
# `clamp_last` is assigned by every write, and the heartbeat below
|
||||
# writes `inv.target` - 0 W in shadow mode - so the clamp's verdict on
|
||||
# the DECISION is gone by the end of the run. Looking for it afterwards
|
||||
# would grade the refresh instead of the cycle.
|
||||
c.clamp_seen = []
|
||||
for n in range(cycles):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
if c.clamp_last is not None:
|
||||
c.clamp_seen.append(c.clamp_last)
|
||||
# ⚠️ THE HEARTBEAT IS DRIVEN HERE AND NOT LEFT OUT, BECAUSE IT IS
|
||||
# THE HALF THAT PROVES SHADOW MODE IS NOT SILENCE. `_write_unit()`
|
||||
# skips an unchanged value unless `force` is set, so a cycles-only
|
||||
# fixture sees exactly ONE 0 W write and could not tell "holds the
|
||||
# inverter at 0 W" from "wrote 0 W once and went quiet" - and the
|
||||
# second of those leaves a real install's ESP32 in a tripped
|
||||
# stale-input watchdog for the whole run. `heartbeat_s=0` makes
|
||||
# every tick due, which is the shipped path with its clock wound
|
||||
# forward rather than a second implementation of it.
|
||||
await c.send_heartbeats(float(n), 0.0)
|
||||
return hass, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
_live_h, _live_c = _s10("off")
|
||||
_shad_h, _shad_c = _s10("on")
|
||||
|
||||
# --- 8a. THE PRECONDITION, WHICH IS THE HALF THAT MAKES THE REST MEAN ANYTHING
|
||||
# MUTATION.md habit 4: a check whose DATA cannot reach the failure it names is
|
||||
# decoration. "No non-zero value was written" is trivially true of a stimulus
|
||||
# that would not have produced one, so the same stimulus is first shown to
|
||||
# produce a large one with the option off.
|
||||
check("PRECONDITION: with shadow_mode off this stimulus really does drive the "
|
||||
f"wire - {len(_live_h.writes)} writes, "
|
||||
f"{len([w for w in _live_h.writes if w != 0.0])} of them non-zero, "
|
||||
f"largest {max((abs(w) for w in _live_h.writes), default=0):.0f} W",
|
||||
len([w for w in _live_h.writes if w != 0.0]) >= 10
|
||||
and max((abs(w) for w in _live_h.writes), default=0) > 1000.0
|
||||
and 0.0 not in _live_h.writes)
|
||||
|
||||
# --- 8b. NOTHING THE DECISION PATH PRODUCED REACHES THE INVERTER -------------
|
||||
check("AC 1: in shadow mode EVERY value that reached set_number() is 0 W. "
|
||||
f"Distinct values written: {sorted(set(_shad_h.writes))}",
|
||||
_shad_h.writes and set(_shad_h.writes) == {0.0})
|
||||
|
||||
# ⚠️ AND IT IS NOT SILENCE. The inverter holds its last command forever and the
|
||||
# ESP32's stale-input watchdog needs a refresh; a shadow mode that stopped
|
||||
# writing would leave a standing command up under a page saying nothing is
|
||||
# commanded. A check that only asserted "no non-zero write" would pass on that
|
||||
# build too - this is the half that separates them.
|
||||
check("...and it WROTE that 0 W rather than going silent, which is what keeps "
|
||||
f"the inverter from holding its last command ({len(_shad_h.writes)} "
|
||||
"writes)",
|
||||
len(_shad_h.writes) >= 2)
|
||||
|
||||
# --- 8c. THE DECISION PATH ACTUALLY RAN ------------------------------------
|
||||
# "Nothing reached the inverter" is also true of a controller that decided
|
||||
# nothing. Both halves, or the feature is an off switch with a long name.
|
||||
check("the full decision path still ran: the loop reached a target and the "
|
||||
f"arbiter resolved one ({_shad_c.loop_w!r} W, "
|
||||
f"would have commanded {_shad_c.shadow_would_w!r} W)",
|
||||
_shad_c.loop_w is not None and abs(_shad_c.loop_w) > 500.0
|
||||
and _shad_c.shadow_would_w is not None
|
||||
and abs(_shad_c.shadow_would_w) > 500.0)
|
||||
check("...and the decision it recorded is a number a live controller would "
|
||||
"have written - it is inside the rail and it is not the 0 W that went "
|
||||
"on the wire",
|
||||
_shad_c.shadow_would_w != 0.0
|
||||
and abs(_shad_c.shadow_would_w) <= LIVE_MAX_W)
|
||||
|
||||
# --- 8d. THE CARRIED COMMAND TRACKS THE WIRE, WHICH IS WHAT MAKES THE EXIT
|
||||
# SAFE
|
||||
check("the carried site command is what is STANDING ON THE WIRE, not the "
|
||||
f"decision ({_shad_c.target!r} W standing, "
|
||||
f"{_shad_c.shadow_would_w!r} W decided)",
|
||||
_shad_c.target == 0.0 and _shad_c.shadow_would_w != 0.0)
|
||||
check("...and every unit's share is too, so SAFETY-42's delivery watchdog is "
|
||||
"not handed a share nothing was told to produce",
|
||||
all(i.target == 0.0 for i in _shad_c.inverters))
|
||||
|
||||
|
||||
def _s10_exit(shadow_first):
|
||||
"""Run twelve cycles, force shadow mode off, take ONE more cycle.
|
||||
|
||||
Answers with `inv.last_write` either side of that boundary - WHAT STANDS ON
|
||||
THE WIRE, updated only on an accepted write, which is the number the
|
||||
inverter is actually latched on. Reading `hass.writes` instead would be
|
||||
blind to the converged case, where the controller correctly re-sends
|
||||
nothing at all.
|
||||
|
||||
⚠️ THE COMPARISON IS AGAINST THE SAME RUN THAT NEVER ENTERED SHADOW MODE,
|
||||
not against a constant. `slew_w` bounds a step from the standing command,
|
||||
so "did it resume from the wire or from a fiction" is a question about a
|
||||
DIFFERENCE, and a hard-coded expectation would be the number the code
|
||||
produces compared with itself.
|
||||
"""
|
||||
async def go():
|
||||
hass = _Hass(6000.0, track=True)
|
||||
c = Controller(
|
||||
{"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,
|
||||
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
|
||||
"shadow_mode": "on" if shadow_first else "off"},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
for _ in range(12):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
before = c.inverters[0].last_write
|
||||
c.o["shadow_mode"] = "off"
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
# ⚠️ NOT `float()`. `last_write` is None until something is ACCEPTED,
|
||||
# and a build in which shadow mode writes nothing at all would turn
|
||||
# this fixture into a TypeError - which MUTATION.md's FCST-06 section
|
||||
# is about: a traceback grades none of the checks after it AND reads
|
||||
# like a survivor. It is handed back as None and named below instead.
|
||||
return before, c.inverters[0].last_write, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
_exit_before, _exit_after, _exit_c = _s10_exit(True)
|
||||
_never_before, _never_after, _never_c = _s10_exit(False)
|
||||
print(f" shadow run: standing {_exit_before!r} W -> {_exit_after!r} W on "
|
||||
"the first live cycle")
|
||||
print(f" never in shadow: standing {_never_before!r} W -> "
|
||||
f"{_never_after!r} W on the same cycle")
|
||||
check("SOMETHING REACHED THE WIRE IN SHADOW MODE. `last_write` is set only on "
|
||||
"an ACCEPTED write, so None here is the build that stops writing "
|
||||
"altogether and leaves the inverter latched on its last command - the "
|
||||
"failure shadow mode must not be a new way to reach",
|
||||
_exit_before is not None and _exit_after is not None
|
||||
and _never_before is not None and _never_after is not None)
|
||||
_exit_before = -1.0 if _exit_before is None else float(_exit_before)
|
||||
_exit_after = -1.0 if _exit_after is None else float(_exit_after)
|
||||
_never_before = 0.0 if _never_before is None else float(_never_before)
|
||||
_never_after = 0.0 if _never_after is None else float(_never_after)
|
||||
check("PRECONDITION: the two arrangements DISAGREE - the run that was never in "
|
||||
"shadow mode is standing somewhere else entirely at that boundary, so "
|
||||
"the comparison below is not a number against itself",
|
||||
_never_before != _exit_before and abs(_never_before) > 1000.0)
|
||||
check("PRECONDITION: and the shadow run really was standing at 0 W, so the "
|
||||
"step below is measured from the wire and not from a coincidence",
|
||||
_exit_before == 0.0)
|
||||
check("AC 2, leaving: the first live command after shadow mode is one slew "
|
||||
"step from the 0 W that was actually standing on the wire - NOT one step "
|
||||
"from the decision shadow mode reached, which never existed at the "
|
||||
f"battery. |{_exit_after:.0f}| <= slew 1000 W",
|
||||
abs(_exit_after) <= 1000.0)
|
||||
check("...and the run that was never in shadow mode is NOT inside that bound "
|
||||
"at the same cycle, which is what says the bound is a real constraint "
|
||||
"here and not a property of the stimulus",
|
||||
abs(_never_after) > 1000.0)
|
||||
|
||||
|
||||
def _s10_enter():
|
||||
"""Live first, then shadow mode, on a battery standing somewhere else."""
|
||||
async def go():
|
||||
hass = _Hass(6000.0, track=True)
|
||||
c = Controller(
|
||||
{"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,
|
||||
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
|
||||
"shadow_mode": "off"}, hass, _Store(), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
for _ in range(12):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
standing = hass.writes[-1]
|
||||
c.o["shadow_mode"] = "on"
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
# ⚠️ `inverters[0].last_write`, NOT `hass.writes[-1]`. The question is
|
||||
# WHAT THE INVERTER IS LATCHED ON after the first shadow cycle, and a
|
||||
# build that writes NOTHING leaves it latched on the live value while
|
||||
# `hass.writes[-1]` still reads that same number - the two look
|
||||
# identical from the write list and are opposite at the battery.
|
||||
return standing, float(c.inverters[0].last_write), c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
_stood, _first_shadow, _enter_c = _s10_enter()
|
||||
print(f" standing when shadow mode came on: {_stood:.0f} W")
|
||||
check("PRECONDITION: the battery was standing at a real, non-zero command when "
|
||||
"shadow mode was switched on - otherwise 'it reached 0 W' says nothing",
|
||||
abs(_stood) > 500.0)
|
||||
check("AC 2, entering: after ONE shadow cycle the inverter is latched on 0 W, "
|
||||
"not on what it was doing a cycle earlier. The inverter holds its last "
|
||||
"command FOREVER, so a shadow mode that merely stopped writing would "
|
||||
"leave that command standing under a page saying nothing is commanded "
|
||||
f"({_stood:+.0f} W -> {_first_shadow:+.0f} W)",
|
||||
_first_shadow == 0.0)
|
||||
|
||||
# --- 8e. THE OTHER GUARDS ON THE WRITE PATH STILL OBSERVE --------------------
|
||||
# The substitution is the LAST thing before the wire, below SAFETY-28's gate and
|
||||
# below SAFETY-03's clamp, so both still grade the number the decision path
|
||||
# produced. A shadow run that could not say "it would have hit the rating here"
|
||||
# is answering a different question from the one it is for.
|
||||
_cl_h, _cl_c = _s10("on", cycles=12, grid=9000.0, dev=400.0)
|
||||
_cl_req = [r.requested_w for r in _cl_c.clamp_seen]
|
||||
print(f" decision {_cl_c.shadow_would_w!r} W; what the clamp was asked "
|
||||
f"about at each cycle boundary: {sorted(set(_cl_req))}")
|
||||
check("PRECONDITION: the decision is a real, non-zero number and the wire "
|
||||
"carried only 0 W on this run, so the two are distinguishable",
|
||||
_cl_c.shadow_would_w is not None and _cl_c.shadow_would_w != 0.0
|
||||
and set(_cl_h.writes) == {0.0})
|
||||
check("SAFETY-03 still OBSERVES in shadow mode: the clamp was asked about the "
|
||||
"number THE DECISION PATH PRODUCED, not about the 0 W that went on the "
|
||||
"wire. That is what puts the substitution BELOW the clamp, and it is "
|
||||
"what lets a shadow run report that the rating would have been hit",
|
||||
_cl_req and all(r == _cl_c.shadow_would_w for r in _cl_req))
|
||||
check("...and the clamp's own alarm still ran on it - the winding-loop "
|
||||
"engagement reached the action log on a run where nothing was commanded",
|
||||
any("clamp" in e.lower() or "CLAMP" in e for e in _cl_c.events))
|
||||
check("SAFETY-28's gate still OBSERVES too - it refused nothing on this run, "
|
||||
"which is the same answer it gives live, so shadow mode has not been "
|
||||
"implemented by making every write unauthorised",
|
||||
not [e for e in _cl_c.events if "WRITE REFUSED" in e])
|
||||
|
||||
# ⚠️ AND SAFETY-18 IS GRADED ON WHAT IT WAS HANDED, WHICH IS AN ORDERING
|
||||
# CLAIM. `check_meter_frozen()` takes `self.target` - the arbiter's RESOLVED
|
||||
# INTENT - and `_shadow_settle()` re-seeds that to 0 W at the very end of the
|
||||
# cycle, AFTER it. Re-seed one line earlier and the freeze detector grades a
|
||||
# 0 W that no decision produced: its conjunction needs a command at the rail,
|
||||
# so it goes permanently blind and the whole suite stays green. The detector's
|
||||
# own `evaluate` is wrapped here because the arguments are the only place that
|
||||
# ordering is observable.
|
||||
_fz_seen = []
|
||||
_fz_real = _shad_c.freeze_wd.evaluate
|
||||
|
||||
|
||||
def _fz_spy(unchanged, target, *a, **k):
|
||||
_fz_seen.append(target)
|
||||
return _fz_real(unchanged, target, *a, **k)
|
||||
|
||||
|
||||
_shad_c.freeze_wd.evaluate = _fz_spy
|
||||
|
||||
|
||||
async def _one_more():
|
||||
await _shad_c.read_inputs()
|
||||
await _shad_c.cycle()
|
||||
|
||||
|
||||
asyncio.run(_one_more())
|
||||
_shad_c.freeze_wd.evaluate = _fz_real
|
||||
print(f" SAFETY-18 was handed: {_fz_seen}")
|
||||
check("PRECONDITION: the freeze detector was consulted at all on that cycle",
|
||||
len(_fz_seen) == 1)
|
||||
check("SAFETY-18 still OBSERVES the DECISION: it is handed the arbiter's "
|
||||
"resolved intent, not the 0 W the wire gate substituted. Re-seeding one "
|
||||
"line earlier would hand it a command no decision produced and its "
|
||||
"conjunction - a command at the rail against a meter that has stopped "
|
||||
"moving - could never be satisfied again",
|
||||
_fz_seen and _fz_seen[0] == _shad_c.shadow_would_w
|
||||
and _fz_seen[0] != shadow.WIRE_W)
|
||||
|
||||
# --- 8f. THE MODE TABLE LIVES IN test_validate.py ---------------------------
|
||||
# `shadow.engaged()` is a pure function of the option and the start button, and
|
||||
# the six states it answers for are graded beside the rule that validates the
|
||||
# option - not here, where a defeat of the DEFAULT stops part 3's wired run
|
||||
# reaching the wire at all and takes this whole file down before part 8 runs.
|
||||
# ⚠️ THAT IS MEASURED, NOT ANTICIPATED: the mutation that makes `auto` resolve
|
||||
# to shadow unconditionally aborts part 3 with an IndexError, so its red count
|
||||
# here is a LOWER BOUND and none of part 8 is graded (MUTATION.md, FCST-06).
|
||||
|
||||
# --- 8g. THE OPERATOR STOP, WHICH IS THE ONE FAILSAFE THIS CARD CHANGES ------
|
||||
_stop_live_h, _stop_live_c = _s10("off", auto=False, cycles=6)
|
||||
_stop_shad_h, _stop_shad_c = _s10("auto", auto=False, cycles=6)
|
||||
print(f" stopped, shadow off: {_stop_live_c.reason!r}")
|
||||
print(f" stopped, shadow on : {_stop_shad_c.reason!r}")
|
||||
check("PRECONDITION: with shadow mode off, a stopped controller files the "
|
||||
"P_SAFETY stop claim and the arbiter resolves to THAT source, at 0 W",
|
||||
_stop_live_c.reason.startswith("safety")
|
||||
and _stop_live_c.target == 0.0)
|
||||
check("in shadow mode that claim is NOT filed, so the arbiter resolves to the "
|
||||
"REAL winner and the recorded decision is the real one. A P_SAFETY claim "
|
||||
"out-ranks everything - left in, every shadow run would answer the wrong "
|
||||
f"question ({_stop_shad_c.reason!r})",
|
||||
_stop_shad_c.reason.startswith("loop")
|
||||
and _stop_shad_c.shadow_would_w is not None
|
||||
and abs(_stop_shad_c.shadow_would_w) > 0.0)
|
||||
check("...and the actuator ends at exactly the same place either way, because "
|
||||
"the substitution that replaced the claim is STRICTLY STRONGER: a claim "
|
||||
"can be out-ranked and a wire gate cannot",
|
||||
set(_stop_shad_h.writes) == {0.0} == set(_stop_live_h.writes))
|
||||
|
||||
# --- 8h. THE RENDERED SURFACE (TARIFF-11's pattern) -------------------------
|
||||
# Graded on `status_payload()`, which IS the status page: web.py's `PAGE` is a
|
||||
# static shell whose banner and rows are filled from this JSON. Named slots, not
|
||||
# substring tests, so a transposition moves a slot.
|
||||
_ROW = "Shadow mode"
|
||||
_shad_pay = _shad_c.status_payload()
|
||||
_live_pay = _live_c.status_payload()
|
||||
_shad_rows = dict(_shad_pay["rows"])
|
||||
_live_rows = dict(_live_pay["rows"])
|
||||
print(f" banner: {_shad_pay['banner']!r}")
|
||||
print(f" row : {_shad_rows.get(_ROW)!r}")
|
||||
check("the status page has a Shadow mode row in BOTH states, so its ABSENCE is "
|
||||
"never how an operator learns which one they are in",
|
||||
_ROW in _shad_rows and _ROW in _live_rows)
|
||||
check("AC 3: the banner states plainly that the battery is not being "
|
||||
"controlled",
|
||||
"SHADOW MODE" in _shad_pay["banner"]
|
||||
and "NOT being controlled" in _shad_pay["banner"])
|
||||
check("...and it is not green. A page that reads `ok` while nothing is being "
|
||||
"commanded is the 'looks healthy but is inert' failure itself",
|
||||
_shad_pay["level"] == "warn")
|
||||
check("...and it is FIRST in the cascade: the same controller has other "
|
||||
"conditions to report and none of them displaces this sentence",
|
||||
_live_pay["banner"] != _shad_pay["banner"]
|
||||
and "SHADOW MODE" not in _live_pay["banner"])
|
||||
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
|
||||
r"([-\d]+) W", _shad_rows[_ROW])
|
||||
check("the row answers 'what would it have done' with a NUMBER in its own "
|
||||
"named slot, and says where the inverter actually is in a second one - "
|
||||
"two slots in one sentence, so a transposition moves them",
|
||||
_slot is not None)
|
||||
if _slot is not None:
|
||||
_would = float(_slot.group(1).replace(" ", ""))
|
||||
check("...and that number is the decision the path reached, not the 0 W "
|
||||
f"standing on the wire ({_would:.0f} vs {_shad_c.target:.0f})",
|
||||
_would == round(float(_shad_c.shadow_would_w))
|
||||
and _would != _shad_c.target)
|
||||
check("...and the second slot is the STANDING command, so the two cannot "
|
||||
"be read as one number",
|
||||
float(_slot.group(2)) == 0.0)
|
||||
check("the row answers 'and why' by carrying the arbiter's own sentence, "
|
||||
"rather than a re-derivation of it",
|
||||
_shad_c.reason in _shad_rows[_ROW])
|
||||
check("with shadow mode off the row says so, and says what follows from it - "
|
||||
"which is the arrangement that makes the one above a claim about this "
|
||||
"controller and not about the string",
|
||||
"NOT in shadow mode" in _live_rows[_ROW]
|
||||
and "DOES reach the inverter" in _live_rows[_ROW]
|
||||
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW])
|
||||
|
||||
# ⚠️ A STANDING LATCH IS NAMED RATHER THAN HIDDEN. The shadow banner sits above
|
||||
# the three `bad` branches, so it has to carry what they would have said - a
|
||||
# latch that is up while the controller is inert is up the moment it stops
|
||||
# being inert.
|
||||
_shad_c.inverters[0].sign_wd.tripped = True
|
||||
_latched = _shad_c.status_payload()
|
||||
print(f" banner with a latch up: {_latched['banner']!r}")
|
||||
check("a failsafe latch that is standing is NAMED in the shadow banner and "
|
||||
"turns it red, so nobody turns shadow mode off into a battery that will "
|
||||
"not move and a page that never mentioned why",
|
||||
_latched["level"] == "bad"
|
||||
and "LATCH IS STILL UP" in _latched["banner"]
|
||||
and _shad_c.inverters[0].name in _latched["banner"])
|
||||
check("...and it still says the battery is not being controlled, so the latch "
|
||||
"has not displaced the sentence that may never be displaced",
|
||||
"NOT being controlled" in _latched["banner"])
|
||||
_shad_c.inverters[0].sign_wd.tripped = False
|
||||
|
||||
# --- 8i. THE RECORD A LATER ANALYSIS READS ----------------------------------
|
||||
# A shadow run is only worth running if it answers "what would it have done,
|
||||
# and why" AFTERWARDS, from the 40-row action log, and not only on a page
|
||||
# somebody happened to be looking at.
|
||||
_shadow_lines = [e for e in _shad_c.events if "SHADOW" in e]
|
||||
print(f" action log, shadow lines: {len(_shadow_lines)} of "
|
||||
f"{len(_shad_c.events)}")
|
||||
check("the action log records that shadow mode came on, in words",
|
||||
any("SHADOW MODE ON" in e for e in _shadow_lines))
|
||||
check("...and what it would have commanded, with the arbiter's reason beside "
|
||||
"it",
|
||||
any("would have commanded" in e and "loop" in e for e in _shadow_lines))
|
||||
check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
|
||||
"log that has to carry the context of what the run found",
|
||||
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
Reference in New Issue
Block a user