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:
@@ -2858,6 +2858,115 @@ check("...and the SITE sign detector is still absent at a roster of one - part "
|
||||
not _c38one.site_sign_wd.tripped
|
||||
and _c38one.site_sign_wd.disagree_since is None)
|
||||
|
||||
# =========================================================================== #
|
||||
# 10. SAFETY-10 - THE SIGN WATCHDOG IN SHADOW MODE
|
||||
# =========================================================================== #
|
||||
# THE QUESTION THE CARD ASKS OF EVERY FAILSAFE: does it observe, does it latch,
|
||||
# or is it inert in shadow mode? For this one the answer had to be INERT, and
|
||||
# the reason is the worst outcome in the feature:
|
||||
#
|
||||
# SAFETY-02 grades the command STANDING ON THE WIRE against the meter. Shadow
|
||||
# mode holds the wire at 0 W, so the detector cannot arm. Fed the SHADOW
|
||||
# DECISION instead it would compare a number against a meter that never saw
|
||||
# it - guaranteed disagreement for as long as the decision asked for power -
|
||||
# and TRIP. That latch survives into live operation AND across a restart
|
||||
# (part 3 of this file is that property), so a shadow run would hand the
|
||||
# operator a battery that will not move, from a run in which nothing moved.
|
||||
# Shadow mode would manufacture the failsafe it exists to help debug.
|
||||
#
|
||||
# ⚠️ GRADED ON THE SAME STIMULUS THAT TRIPS IT LIVE, NOT ON A QUIET ONE.
|
||||
# MUTATION.md: a per-thing check that has never seen the things DISAGREE is a
|
||||
# total. `_s10_sign()` is `_e2e()` above with one option changed - the same
|
||||
# inverted loop, the same steady 2000 W import, the same 1 s window.
|
||||
print("\n10. SAFETY-10 - the sign watchdog in shadow mode\n")
|
||||
|
||||
|
||||
async def _s10_sign(shadow_mode):
|
||||
hass = _Hass()
|
||||
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": -0.6,
|
||||
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True,
|
||||
# ⚠️ AN EXPLICIT SLEW, AND THE REASON IS THE LIMITATION GRADED
|
||||
# BELOW. At `slew_w: 0` the rail is `max_w / 9` = 222 W, and in
|
||||
# shadow mode the loop starts from the 0 W standing on the wire
|
||||
# EVERY cycle - so its decision never leaves 222 W and never
|
||||
# reaches the 300 W gate at all. The detector would then be
|
||||
# "inert" for a reason that has nothing to do with this card.
|
||||
"slew_w": 1000,
|
||||
"shadow_mode": shadow_mode}
|
||||
c = Controller(opts, hass, _S(), _P(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -5000.0, 5000.0
|
||||
wanted = []
|
||||
for _ in range(12):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
wanted.append(c.shadow_would_w if c.shadow else c.target)
|
||||
await asyncio.sleep(0.25)
|
||||
return c, hass, wanted
|
||||
|
||||
|
||||
_s10_live, _s10_lh, _s10_lw = asyncio.run(_s10_sign("off"))
|
||||
_s10_shad, _s10_sh, _s10_sw = asyncio.run(_s10_sign("on"))
|
||||
print(f" live : tripped={_s10_live.sign_wd.tripped} "
|
||||
f"wanted={[round(w) for w in _s10_lw[:4]]}")
|
||||
print(f" shadow: tripped={_s10_shad.sign_wd.tripped} "
|
||||
f"wanted={[round(w) for w in _s10_sw[:4]]}")
|
||||
check("PRECONDITION: the same stimulus TRIPS the watchdog with shadow mode "
|
||||
"off, so what follows is a claim about shadow mode and not about a "
|
||||
"stimulus that could never have tripped anything",
|
||||
_s10_live.sign_wd.tripped)
|
||||
check("PRECONDITION: and in shadow mode the decision path still asked for "
|
||||
"charge into that import - well past the 300 W gate - so the detector "
|
||||
"had a wrong-way number available to it and did not use it",
|
||||
any(w is not None and w < -300.0 for w in _s10_sw))
|
||||
check("SAFETY-10: the sign watchdog is INERT in shadow mode. It grades what "
|
||||
"this controller has standing on the wire, and that is 0 W - so a latch "
|
||||
"that would survive into live operation and across a restart is never "
|
||||
"armed by a run in which nothing was commanded",
|
||||
not _s10_shad.sign_wd.tripped
|
||||
and _s10_shad.sign_wd.disagree_since is None)
|
||||
check("...and it is inert BY CONSTRUCTION, not by a special case: the value it "
|
||||
"was handed is `Inverter.standing_w()`, which is the 0 W shadow mode "
|
||||
"actually wrote and not the decision it recorded",
|
||||
_s10_shad.inverters[0].standing_w() == 0.0
|
||||
and set(_s10_sh.writes) == {0.0})
|
||||
# ⚠️ AND THE LIMITATION IS RECORDED HERE RATHER THAN LEFT FOR SOMEBODY TO
|
||||
# DISCOVER. `_shadow_settle()` re-seeds the loop's `prev_w` to what is standing
|
||||
# on the wire, so in shadow mode the loop leg answers "what would this
|
||||
# controller command NOW, from a battery at 0 W" - ONE STEP - and not "what
|
||||
# trajectory would it have followed". The trajectory is a closed-loop property
|
||||
# and it did not happen; reporting it would be a simulation of a plant that
|
||||
# does not exist. The strategies are unaffected: arbitrage, the peak guard and
|
||||
# maintenance all file SET claims at a fixed power from level-triggered inputs,
|
||||
# which is why the three defects this card was written for are all visible from
|
||||
# a shadow run and this one is not.
|
||||
check("RECORDED LIMITATION: the loop leg's shadow decision is ONE slew step "
|
||||
"from the standing command and stays there, while the live run RAMPS. A "
|
||||
"shadow run answers 'what would it do now', not 'what trajectory would "
|
||||
f"it have followed' (shadow {[round(w) for w in _s10_sw[:4]]} vs live "
|
||||
f"{[round(w) for w in _s10_lw[:4]]})",
|
||||
len({round(w) for w in _s10_sw if w is not None}) == 1
|
||||
and len({round(w) for w in _s10_lw[:4]}) > 1)
|
||||
# ⚠️ AND "INERT" HAD TO BE SHOWN TO MEAN STARVED, NOT DISABLED. A detector
|
||||
# that had been switched off would satisfy every check above and would still
|
||||
# be off the moment shadow mode ends. So the SAME object, off the SAME
|
||||
# controller, is handed the disagreement the run never let it see.
|
||||
_s10_n0 = datetime.now(timezone.utc)
|
||||
_s10_wd = _s10_shad.inverters[0].sign_wd
|
||||
check("PRECONDITION: it is not tripped going in, so what follows is this "
|
||||
"stimulus and not a latch left over from the run",
|
||||
not _s10_wd.tripped)
|
||||
_s10_wd.evaluate(-1500.0, 2000.0, True, _s10_n0)
|
||||
_s10_fired = _s10_wd.evaluate(-1500.0, 2000.0, True,
|
||||
_s10_n0 + timedelta(seconds=5))
|
||||
check("...and the failsafe is STARVED, not DISABLED: handed -1500 W standing "
|
||||
"against a +2000 W import - the evidence shadow mode never lets it see - "
|
||||
"the very same detector object trips. It is armed for the moment shadow "
|
||||
"mode ends",
|
||||
bool(_s10_fired) and _s10_wd.tripped)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
Reference in New Issue
Block a user