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
362 lines
20 KiB
Python
362 lines
20 KiB
Python
"""SAFETY-10 - shadow mode: the whole decision path, and no command.
|
|
|
|
WHAT DEFECT THIS PREVENTS
|
|
=========================
|
|
Not one defect - the CLASS of them. Every optimiser fault this repository has
|
|
found was invisible from outside while it was happening, and each one was paid
|
|
for at the meter before anybody could see it:
|
|
|
|
* SAFETY-45 arbitrage against a binding `peak_cap_w` ran 28 charge onsets an
|
|
hour, the grid swinging -455 .. +3200 W, and put a billed
|
|
quarter 59 W over the cap;
|
|
* TARIFF-14 the price lookup answered for the wrong quarter-hour 45 minutes
|
|
in every 60, and traded on it;
|
|
* SAFETY-46 the SoC ceiling churns 598 Wh to gain 310 Wh, no hysteresis.
|
|
|
|
There was no way to WATCH what the optimiser would do without letting it do it.
|
|
A commissioning engineer could arm arbitrage and find out, or not arm it and
|
|
learn nothing. Shadow mode is the third option: strategies, arbiter, clamps and
|
|
guards all run, the decision is recorded with its reason, and NOTHING THE
|
|
CONTROLLER DECIDES REACHES THE INVERTER.
|
|
|
|
⚠️ "REACHES NOTHING" IS NOT "WRITES NOTHING", AND THE DIFFERENCE IS THE WHOLE
|
|
SAFETY ARGUMENT
|
|
=======================================================================
|
|
The inverter holds its last command FOREVER - it has no meter timeout
|
|
(HANDOVER.md §6), and firmware/goodwe-master.yaml's header records what that
|
|
cost when a controller stopped refreshing: Illusmart went silent mid-command
|
|
and the inverter held +5000 W for 113 s. main.py's own header carries the same
|
|
measurement. So a shadow mode that
|
|
simply stopped writing would leave whatever was standing when it was switched
|
|
on standing for as long as it was left on. That is the exact failure this
|
|
repository keeps finding - LOOKS HEALTHY BUT IS INERT - with the status page
|
|
calmly reporting that nothing is being commanded.
|
|
|
|
So shadow mode does write, and it writes ONE number:
|
|
|
|
the actuator is held at WIRE_W (0.0 W), refreshed on the ordinary
|
|
heartbeat, by the ordinary single writer.
|
|
|
|
That is a DEFINED, SAFE actuator state, it is the same state the operator's
|
|
stop button reaches, and it honours the contract firmware/goodwe-master.yaml
|
|
§"CONTRACT CHANGE" imposes on us - HA must rewrite the setpoint every ~10 s or
|
|
the ESP32's 30 s stale-input watchdog cannot tell "steady" from "dead". Going
|
|
silent would put that board into a tripped-watchdog state for the whole shadow
|
|
run, which is an ALARM being used as a MODE: the operator who learns that
|
|
`wd_tripped` is normal during shadow runs is the operator who ignores it when
|
|
the add-on has actually died.
|
|
|
|
⚠️ AND IT IS NOT A SECOND WRITE PATH. SAFETY-28's rule is one actuator, one
|
|
writer. Shadow mode files no claim, owns no transport and calls nothing in
|
|
hass.py. It is a SUBSTITUTION inside `Controller._write_unit()` - the single
|
|
choke point every write in this add-on already goes through - in exactly the
|
|
form the three substitutions above it take (non-finite -> 0 W, unauthorised ->
|
|
0 W, clamped -> the rating). It is the LAST of them, so SAFETY-28's
|
|
authorisation gate and SAFETY-03's clamp still grade the number the decision
|
|
path actually produced, and a shadow run therefore records that the clamp WOULD
|
|
have engaged.
|
|
|
|
THE DEFAULT, AND WHY IT IS NOT A PLAIN `true` (§ the ticket's own warning)
|
|
==========================================================================
|
|
The card is titled "default-on". Taken literally - `shadow_mode: true` in
|
|
`config.yaml`'s `options:` block - it is the worst change in this repository's
|
|
history. Home Assistant's Supervisor composes an add-on's effective options as
|
|
`{**config.yaml defaults, **the stored user options}`, so a NEW key with a new
|
|
default reaches EVERY install on the next update, including the one regulating
|
|
a real battery in the author's house today. Literal default-on silently stops
|
|
an existing install from controlling, and the only symptom is a battery that
|
|
has stopped doing anything.
|
|
|
|
There is no mechanism in the add-on options system that lets one key default
|
|
differently for a new install and an existing one: `options:` IS the default,
|
|
and there is exactly one of it. So "default-on for new" has to be expressed as
|
|
a value the add-on RESOLVES, not as a constant the Supervisor merges.
|
|
|
|
Hence three values rather than two, defaulting to `auto`:
|
|
|
|
off never shadow. The controller commands the battery.
|
|
on always shadow, whatever the start/stop button says.
|
|
auto shadow WHILE CONTROL IS STOPPED; live once it is started.
|
|
|
|
`auto` is default-on for a new install and a no-op for an existing one, and it
|
|
is that without any persisted marker, any migration, and any guess:
|
|
|
|
* a FRESH install ships `auto_start: false` and has no stored `auto` key, so
|
|
it comes up stopped -> shadow. It observes a day of decisions before it is
|
|
ever allowed to command anything. That is the card's intent, delivered.
|
|
* an install that is CONTROLLING - `auto_start: true`, or the operator has
|
|
pressed *Start / stop control* and the store carries `auto: true` - is
|
|
started, so `auto` resolves to LIVE and its behaviour does not change by
|
|
one watt.
|
|
* an install that is STOPPED keeps its actuator at 0 W exactly as before -
|
|
the same number, from the same writer, on the same heartbeat - and gains
|
|
the recording. The only thing that changes for it is what the status page
|
|
and the action log SAY.
|
|
|
|
⚠️ AND `auto` MEANS THE START BUTTON LEAVES SHADOW MODE. That is deliberate:
|
|
under `auto` the button reads "Start / stop control" and pressing Start is the
|
|
operator saying "control the battery". An operator who wants the decisions
|
|
recorded WHILE the loop runs sets `shadow_mode: on` explicitly - that is the
|
|
configuration this card exists to make possible, and it is the one that cannot
|
|
be reached by accident.
|
|
|
|
⚠️ AN UNRECOGNISED VALUE RESOLVES TO SHADOW, NOT TO LIVE. `validate.py` refuses
|
|
it at startup so this is not meant to be reachable, but the fail-safe direction
|
|
for "the operator's intent is unreadable" is the one that does not move energy.
|
|
|
|
THE FAILSAFES, ONE BY ONE (the card asks for each to be decided and recorded)
|
|
============================================================================
|
|
| failsafe | in shadow mode |
|
|
|--------------------------------|-------------------------------------------|
|
|
| SAFETY-02/30 sign watchdogs | INERT, and by construction rather than by |
|
|
| (per-unit and site) | a special case. They grade |
|
|
| | `Inverter.standing_w()` - what this |
|
|
| | controller has standing on the wire - and |
|
|
| | shadow mode holds that at 0 W. A 0 W |
|
|
| | command cannot oppose a meter, so the |
|
|
| | detector never arms. ⚠️ THIS IS THE ONE |
|
|
| | THAT HAD TO BE GOT RIGHT: fed the SHADOW |
|
|
| | decision instead, every shadow run would |
|
|
| | latch a trip within `sign_disagree_s` - |
|
|
| | the decision opposes a meter that never |
|
|
| | saw it - and that latch SURVIVES INTO |
|
|
| | LIVE OPERATION and a restart. Shadow mode |
|
|
| | would manufacture the failsafe it is |
|
|
| | meant to help debug. |
|
|
| SAFETY-03 clamp | OBSERVES. It runs on the number the |
|
|
| | decision path produced, `clamp_last` is |
|
|
| | set and every engagement goes to the |
|
|
| | action log, because "it would have hit |
|
|
| | the rating" is part of what a shadow run |
|
|
| | is for. It changes no command: the value |
|
|
| | it narrows is replaced by 0 W below it. |
|
|
| SAFETY-28 write authorisation | OBSERVES, for the same reason and in the |
|
|
| | same place - above the substitution, so |
|
|
| | it still asks who chose the number. |
|
|
| SAFETY-18 meter freeze | OBSERVES, unchanged. It takes no action |
|
|
| | in live operation either; it grades the |
|
|
| | resolved intent against a meter that has |
|
|
| | stopped moving, and both halves are as |
|
|
| | true in shadow as out of it. |
|
|
| SAFETY-07/24/25 peak guard | OBSERVES. Level-triggered, unlatched, and |
|
|
| | it files its claim as usual so the |
|
|
| | recorded decision is the real one. |
|
|
| | SAFETY-45's limit cycle is visible from a |
|
|
| | shadow run; that is the point. |
|
|
| SAFETY-31 circulation | OBSERVES AND MAY LATCH, legitimately. Its |
|
|
| SAFETY-42 delivery | evidence is MEASURED power, not a |
|
|
| | command: packs pushing against each other |
|
|
| | while every one of them is told 0 W is a |
|
|
| | real fault and a latch is the right |
|
|
| | answer to it. |
|
|
| operator stop (`auto` False) | SUPPRESSED AS A CLAIM, ENFORCED AT THE |
|
|
| | WIRE. It is the one deliberate change. |
|
|
| | Its `P_SAFETY` 0 W claim out-ranks |
|
|
| | everything, so leaving it in would make |
|
|
| | the recorded decision "stopped" on every |
|
|
| | cycle - the shadow run would answer the |
|
|
| | wrong question. The actuator state is |
|
|
| | identical either way, and the wire |
|
|
| | substitution is STRICTLY STRONGER than |
|
|
| | the claim: a claim can be out-ranked, and |
|
|
| | this cannot. |
|
|
| inputs missing, non-finite, | UNCHANGED. All of them resolve to 0 W, |
|
|
| allocator refusal, arbiter | which is what the wire gets in shadow |
|
|
| contradiction | mode anyway, so they act redundantly and |
|
|
| | are recorded as they always were. |
|
|
|
|
⚠️ MAINTENANCE IS NOT IN THAT TABLE BECAUSE SHADOW MODE DOES NOT TOUCH IT. The
|
|
state machines are ticked on every cycle today whether control is started or
|
|
not, so a stopped install already runs them against a battery that does not
|
|
move. Shadow mode inherits that, unchanged and unimproved; it is a pre-existing
|
|
property of `auto_start: false` and it is recorded here rather than quietly
|
|
fixed under another card's name.
|
|
|
|
LEAVING SHADOW MODE
|
|
===================
|
|
`Controller.cycle()` re-seeds its carried command (`self.target` and each
|
|
`inv.target`) to WIRE_W at the END of every shadow cycle, AFTER every in-cycle
|
|
guard has read the decision. Two things follow, and both are safety properties:
|
|
|
|
* the loop's `prev_w` and its integrator track WHAT IS ACTUALLY STANDING ON
|
|
THE WIRE, so every shadow cycle answers "from where the plant really is,
|
|
what would this controller do NOW" - a true statement about the present,
|
|
rather than a simulation of a counterfactual plant that drifts further from
|
|
reality every tick;
|
|
* the exit is safe BY CONSTRUCTION. The carried command already equals the
|
|
standing command, so the first live cycle slews from 0 W like any other
|
|
cycle. Carrying the shadow decision instead would let the slew limiter
|
|
depart from a base that never existed - up to a full `slew_w` step onto a
|
|
battery sitting at zero.
|
|
|
|
The entry is safe for the ordinary reason: the substitution happens on the very
|
|
first write of the very first shadow cycle, and `last_write` was not 0 W, so
|
|
that write is issued rather than skipped as unchanged.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
# ⚠️ CFG-03's mirror. `test_defaults_pin.py` part 2 discovers every
|
|
# module-level name ending in OPTION_DEFAULTS and asserts each value
|
|
# reproduces config.yaml EXACTLY, so this table cannot drift from the shipped
|
|
# default. Nothing in the add-on may carry a literal fallback for a key a
|
|
# mirror answers for - part 3 scans for that - which is why `main.py` asks the
|
|
# functions below and never reads `shadow_mode` itself.
|
|
SHADOW_OPTION_DEFAULTS = {
|
|
"shadow_mode": "auto",
|
|
}
|
|
|
|
#: The three values `shadow_mode` may take, in the order config.yaml's schema
|
|
#: lists them. `validate.py` refuses anything else by name.
|
|
MODES = ("auto", "on", "off")
|
|
|
|
#: What stands on the wire while shadow mode is engaged. ⚠️ A NUMBER, NOT A
|
|
#: SILENCE - see this module's header. It is 0.0 W for the same reason every
|
|
#: other refusal in this add-on is 0.0 W: fail toward inaction, and keep
|
|
#: commanding it so the inverter cannot be left latched on anything else.
|
|
WIRE_W = 0.0
|
|
|
|
|
|
def mode(opts: dict) -> str:
|
|
"""The configured `shadow_mode`, normalised to one of `MODES`.
|
|
|
|
⚠️ AN UNRECOGNISED VALUE ANSWERS `"on"`, NOT `"auto"` AND NOT `"off"`.
|
|
`validate.py` refuses such a value at startup, so this branch is not meant
|
|
to be reachable at all; it exists because the fail-safe direction for "the
|
|
operator's intent cannot be read" is the one that does not move energy. A
|
|
typo that silently resolved to `off` would put a battery under a controller
|
|
whose configuration nobody could parse.
|
|
"""
|
|
raw = opts.get("shadow_mode", SHADOW_OPTION_DEFAULTS["shadow_mode"])
|
|
text = str("" if raw is None else raw).strip().lower()
|
|
if text in MODES:
|
|
return text
|
|
return "on"
|
|
|
|
|
|
def engaged(opts: dict, *, control_started: bool) -> bool:
|
|
"""Is the battery being left alone right now?
|
|
|
|
`control_started` is `Controller.auto` - the persisted start/stop state,
|
|
which a fresh install gets from `auto_start` and an operator moves with the
|
|
*Start / stop control* button. See the header on why `auto` reads it.
|
|
"""
|
|
m = mode(opts)
|
|
if m == "off":
|
|
return False
|
|
if m == "on":
|
|
return True
|
|
return not control_started
|
|
|
|
|
|
def why(opts: dict, *, control_started: bool) -> str:
|
|
"""Which rung decided, in the words an operator can act on.
|
|
|
|
⚠️ THE REASON, NOT ONLY THE VERDICT - MUTATION.md's masking rule. `on` and
|
|
`auto`-while-stopped produce the same boolean and want different operator
|
|
actions: one is turned off by editing an option, the other by pressing a
|
|
button. A surface that showed only "shadow" would make a mutation that
|
|
swapped the two rungs invisible.
|
|
"""
|
|
# ⚠️ THE LIVE SENTENCES SAY "NOT IN SHADOW MODE", NOT "THE BATTERY IS
|
|
# BEING CONTROLLED". The second is FALSE on a stopped install with
|
|
# `shadow_mode: off` - nothing is regulating there either - and a row that
|
|
# is false in one of the four live states is a row an operator learns to
|
|
# discount. What is true in every live state is that what this add-on
|
|
# decides reaches the inverter; whether control is RUNNING is the
|
|
# `Stopped` banner's question and it is answered there.
|
|
m = mode(opts)
|
|
if m == "off":
|
|
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
|
|
"add-on decides DOES reach the inverter")
|
|
if m == "on":
|
|
return ("on (shadow_mode: on) - decisions are recorded and nothing "
|
|
"is commanded")
|
|
if control_started:
|
|
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
|
|
"mode: what this add-on decides DOES reach the inverter")
|
|
return ("on (shadow_mode: auto, control is stopped) - decisions are "
|
|
"recorded and nothing is commanded")
|
|
|
|
|
|
def words(opts: dict, *, control_started: bool, would_w: float | None,
|
|
reason: str = "") -> str:
|
|
"""The status page's `Shadow mode` row.
|
|
|
|
⚠️ THE ROW IS RENDERED IN EVERY STATE, INCLUDING `off`. A surface whose
|
|
ABSENCE is how you learn which mode you are in is a surface an operator
|
|
has to already know about; TARIFF-11's rule is that the rendered text is
|
|
what gets graded, so there has to be text in both states.
|
|
|
|
`would_w` is the decision the path reached this cycle - what a live
|
|
controller would have put on the wire - and `reason` is
|
|
`Resolution.explain()`, the same sentence the `Why` row carries. Together
|
|
they are the whole of "what would it have done, and why"; without them the
|
|
row would say a shadow run happened and nothing about what it found.
|
|
"""
|
|
head = why(opts, control_started=control_started)
|
|
if not engaged(opts, control_started=control_started):
|
|
return head
|
|
if would_w is None:
|
|
return (f"{head}; no decision has been reached yet - the inverter is "
|
|
f"held at {WIRE_W:.0f} W")
|
|
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
|
|
# the whole sentence it ate the comma out of `head` - and `head` is the
|
|
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
|
|
# masking rule says must survive.
|
|
num = f"{float(would_w):+,.0f}".replace(",", " ")
|
|
out = (f"{head}; WOULD HAVE COMMANDED {num} W, inverter held at "
|
|
f"{WIRE_W:.0f} W")
|
|
if reason:
|
|
out += f" - {reason}"
|
|
return out
|
|
|
|
|
|
def banner(opts: dict, *, control_started: bool, latched: tuple = ()) -> tuple:
|
|
"""`(level, text)` for the top of the status page, or `None` when live.
|
|
|
|
⚠️ IT NAMES A STANDING LATCH AND GOES RED FOR ONE. The shadow banner sits
|
|
ABOVE every other branch of the cascade, because "the battery is not being
|
|
controlled" is the one sentence that may never be displaced - but a
|
|
failsafe latch that is up while the controller is inert is up when it
|
|
stops being inert, and an operator who turns shadow mode off without
|
|
clearing it gets a battery that will not move and a green page that
|
|
never mentioned why. So the latch is carried INTO the shadow banner
|
|
rather than losing its own.
|
|
"""
|
|
if not engaged(opts, control_started=control_started):
|
|
return None
|
|
text = ("SHADOW MODE - the battery is NOT being controlled. Every "
|
|
f"inverter is held at {WIRE_W:.0f} W and nothing this add-on "
|
|
"decides reaches the inverter.")
|
|
if latched:
|
|
return ("bad", text + " ⚠️ A FAILSAFE LATCH IS STILL UP ("
|
|
+ ", ".join(latched) + ") and it will still be up when shadow "
|
|
"mode is turned off.")
|
|
return ("warn", text)
|
|
|
|
|
|
def problems(opts: dict) -> list:
|
|
"""`validate.py`'s rule: `shadow_mode` must be one of the three values.
|
|
|
|
⚠️ THE SENTENCE SPELLS `shadow_mode`. `configui.problems_by_field()` files
|
|
a refusal under the field whose key its text contains; a rule that never
|
|
names a key lands in the page's top-level block instead, where the operator
|
|
cannot see which control is wrong. UX-02's whole point is that the shipped
|
|
validator is the only rule surface, so the rule has to be readable there.
|
|
|
|
⚠️ AND IT IS A REFUSAL RATHER THAN A COERCION. `mode()` resolves an
|
|
unreadable value to shadow, which is the safe direction but is also a
|
|
SILENT reinterpretation of what the operator wrote - and an install left in
|
|
shadow mode by a typo commands nothing at all, indefinitely.
|
|
"""
|
|
raw = opts.get("shadow_mode", SHADOW_OPTION_DEFAULTS["shadow_mode"])
|
|
text = str("" if raw is None else raw).strip().lower()
|
|
if text in MODES:
|
|
return []
|
|
return [f"shadow_mode is {raw!r}, which is not one of "
|
|
+ ", ".join(f"'{m}'" for m in MODES)
|
|
+ ". 'auto' leaves the battery alone while control is stopped and "
|
|
"commands it once control is started; 'on' records every "
|
|
"decision and commands nothing at all; 'off' is ordinary "
|
|
"control."]
|