From 8fa4febfef5bae7e65db9f726b54071cf97db12d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 17:25:43 +0200 Subject: [PATCH] SAFETY-10: the whole decision path runs and nothing reaches the inverter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 36 ++ .../DECISIONS/SAFETY-10-shadow-mode.md | 321 +++++++++++++ goodwe_controller/DOCS.md | 88 ++++ goodwe_controller/app/main.py | 256 +++++++++- goodwe_controller/app/shadow.py | 361 ++++++++++++++ goodwe_controller/app/validate.py | 13 + goodwe_controller/config.yaml | 41 ++ goodwe_controller/test_clamp.py | 447 +++++++++++++++++- goodwe_controller/test_inverter.py | 11 +- goodwe_controller/test_validate.py | 85 ++++ goodwe_controller/test_watchdog.py | 109 +++++ goodwe_controller/translations/en.yaml | 3 + goodwe_controller/translations/nl.yaml | 3 + 13 files changed, 1767 insertions(+), 7 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SAFETY-10-shadow-mode.md create mode 100644 goodwe_controller/app/shadow.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 0ce550c..ebcbf1d 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -28,6 +28,42 @@ if it has, it needs 0.10.0. Whoever deploys decides. | _this branch_ | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** | | _this branch_ | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero | | _this branch_ | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** | +| _this branch_ | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** | + +### SAFETY-10 — shadow mode, and what its default does to YOUR install + +A new option, `shadow_mode`, with three values and a shipped default of +`auto`. In shadow mode the add-on runs its entire decision path — strategies, +arbiter, clamps, guards — and **sends nothing to the inverter**: every inverter +is held at **0 W**, refreshed on the ordinary heartbeat by the ordinary single +writer, and the status page says plainly that the battery is not being +controlled. What it *would* have commanded, and why, is on the status page and +in the action log. + +⚠️ **WHAT HAPPENS TO AN EXISTING INSTALL: NOTHING.** `auto` resolves to shadow +mode only while control is **stopped**. An install that is controlling — +`auto_start: true`, or *Start / stop control* pressed — is started, so `auto` +resolves to ordinary control and not one watt changes. An install that is +**stopped** keeps its actuator at 0 W exactly as it did before, from the same +writer on the same heartbeat; what changes for it is what the status page and +the action log **say**, and that the decision path now runs and is recorded. + +⚠️ **AND THAT IS WHY IT IS NOT A BOOLEAN.** The card is titled "default-on". +Taken literally — `shadow_mode: true` in `config.yaml` — it would be the worst +change in this repository's history: the Supervisor composes an add-on's +options as *its defaults, overlaid with the 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 at the reference site. It would +have silently stopped it, and the only symptom would have been a battery that +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, so +"default-on for new" is expressed as a value the add-on **resolves** rather +than a constant the Supervisor merges. + +A **new** install ships `auto_start: false`, so it comes up in shadow mode and +observes a day of decisions before it is allowed to command anything. That is +the default-on the card asked for, delivered where it is safe and withheld +where it is not. ⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and diff --git a/goodwe_controller/DECISIONS/SAFETY-10-shadow-mode.md b/goodwe_controller/DECISIONS/SAFETY-10-shadow-mode.md new file mode 100644 index 0000000..a6c2abb --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-10-shadow-mode.md @@ -0,0 +1,321 @@ +# SAFETY-10 — shadow mode, and why "default-on" is not `true` + +**Status:** decided and implemented on branch `SAFETY-10`. +**Card:** SAFETY-10 — *"Shadow mode (default-on, log-only decisions)"*. +**Nothing here reached the inverter.** No deployment, no `ha addons update`, no +contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in +the CI image and on TEST-23 stage 3's closed-loop rig. + +--- + +## 0. The four things a reader should take away + +1. **"Default-on" is delivered as `auto`, not as `true`, and the difference is + an install that is regulating a real battery today.** The Supervisor + composes an add-on's options as *its defaults, overlaid with the stored user + options*, so a new key with a new default reaches **every existing install** + on the next update. A boolean `shadow_mode: true` would silently stop the + reference site. §1. +2. **Shadow mode WRITES. It writes 0 W, on the ordinary heartbeat, through the + ordinary single writer.** "Commands nothing" implemented as "stops writing" + would leave the inverter latched on whatever was standing — it has no meter + timeout — and would put the ESP32 into a tripped stale-input watchdog for + the whole run. §2. +3. **The sign watchdogs are inert, and that was the decision that had to be got + right.** Fed the shadow decision they would trip within `sign_disagree_s` + and **latch a trip that survives into live operation and a restart**. §4. +4. **The loop leg's shadow decision is one slew step, not a trajectory, and + that is a real limitation of this feature.** §5. + +--- + +## 1. The default + +### The literal reading, and what it costs + +`config.yaml`'s `options:` block **is** the default. There is exactly one of it, +and HA's Supervisor resolves an add-on's effective options as + +``` +{**config.yaml options, **the stored user options} +``` + +A key that has never been stored therefore takes the file's value — on a fresh +install and on an install that has been running for months, identically. There +is **no mechanism in the add-on options system** by which one key can default +differently for the two. So the card's title, taken at face value, is: + +> the next `ha addons update` stops the battery at the reference site, and the +> only symptom is a battery that has stopped doing anything. + +That is strictly worse than every defect shadow mode exists to catch. It is +refused. + +### What was shipped instead + +`shadow_mode` is a three-valued `list(auto|on|off)` defaulting to `"auto"`: + +| value | resolves to | +|---|---| +| `off` | live | +| `on` | shadow, always | +| `auto` | shadow **while control is stopped**; live once it is started | + +`auto` reads `Controller.auto` — the persisted start/stop state, which a fresh +install takes from `auto_start` and which the *Start / stop control* button +moves. No new persisted state, no migration, no guess: + +* a **fresh** install ships `auto_start: false`, has no stored `auto` key, and + therefore comes up **in shadow mode**. It observes a day of decisions before + it is ever allowed to command anything. That is the card's intent, delivered + where it is safe; +* an install that is **controlling** is started, so `auto` resolves to live and + **not one watt changes**; +* 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. What changes is + what the status page and the action log **say**, and that the decision path + now runs and is recorded. + +⚠️ **Under `auto`, pressing Start leaves shadow mode.** That is the point of +`auto`: the button means "control the battery". An operator who wants the +decisions recorded *while the loop runs* — the configuration this card exists +to make possible — sets `on` explicitly, and cannot reach it by accident. + +⚠️ **An unrecognised value resolves to SHADOW, and is REFUSED at startup.** +`shadow.mode()` fails toward inaction; `validate.py` rule 11 refuses the value +by name so the reinterpretation is never silent. A typo is not a decision, and +an install silently left in shadow mode commands nothing indefinitely while its +status page calmly explains that this is intentional. + +--- + +## 2. "Commands nothing" is a number, not a silence + +The inverter holds its last command **forever** — `HANDOVER.md` §6 says so, and +`firmware/goodwe-master.yaml`'s header records what it cost when a controller +stopped refreshing: *"Illusmart went silent mid-command and the inverter held ++5000 W for 113 s"*. `firmware/goodwe-master.yaml` states the contract that follows — *"HA +must now REWRITE the setpoint every ~10 s even when the value has not +changed"* — because without a refresh, **"stale" and "steady" are the same +thing on the wire**. + +So shadow mode holds the actuator at `shadow.WIRE_W` = **0.0 W**, refreshed on +the ordinary heartbeat. Two consequences were weighed and are recorded: + +* **it is not silence.** A shadow mode that stopped writing would leave the + standing command up until the ESP32's 30 s watchdog noticed — and at a site + with no such board, forever; +* **it does not use an alarm as a mode.** Letting that watchdog trip for the + duration of a shadow run teaches the operator that `wd_tripped` is normal, + which is exactly the training that makes it useless when the add-on has + actually died. + +### It is not a second writer + +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 reaches, including the heartbeat, the stop button, `shutdown()` +and `maint_abort()` — in exactly the form of the three substitutions above it +(non-finite → 0 W, unauthorised → 0 W, clamped → the rating). + +It is the **last** of them. Below SAFETY-28's gate and below SAFETY-03's clamp, +so both still grade the number the decision path actually produced, and a +shadow run can report *"it would have hit the rating here"*. + +--- + +## 3. What the run records + +| surface | what it carries | +|---|---| +| status banner | `SHADOW MODE - the battery is NOT being controlled...`, **first in the cascade**, amber — red, with the latch named, if one is standing | +| status row `Shadow mode` | which rung decided, the watts it **would have commanded**, where the inverter actually is, and `Resolution.explain()` | +| status row `Commanded` | `0 W` — the truth, which the row above reconciles with the `Why` row below it | +| action log | one line when shadow mode comes on; one line per **change of decision** (winner or rounded watts), carrying the arbiter's own sentence | +| MQTT | `status: shadow`, its own value, below `failsafe` | + +The action log is **edge**-logged, not level-logged: the log holds 40 rows, and +a line per cycle would flush the context of the run's own findings inside four +minutes at the 15 s cycle floor. + +--- + +## 4. The failsafes, one by one + +| failsafe | in shadow mode | why | +|---|---|---| +| SAFETY-02/30 sign watchdogs | **INERT** | They grade `Inverter.standing_w()` — what this controller has standing on the wire — and shadow mode holds that at 0 W, so they cannot arm. ⚠️ Fed the shadow decision instead they would disagree with a meter that never saw it and **latch a trip that survives into live operation and a restart**: shadow mode would manufacture the failsafe it exists to help debug. ⚠️ **Two rungs hold it there** — `last_write` (the wire gate) and `inv.target` (the re-seed) — which is MUTATION.md's masking shape, so the mutation that reproduces the hazard defeats both as a pair (M6). Driven singly, dropping the re-seed does not reproduce it at all — measured | +| SAFETY-03 clamp | **OBSERVES** | Runs on the decision, sets `clamp_last`, logs every engagement. Changes no command | +| SAFETY-28 write gate | **OBSERVES** | Same placement, same reason: the question is who chose the number | +| SAFETY-18 meter freeze | **OBSERVES**, unchanged | It takes no action live either; both halves of its conjunction are as true in shadow | +| SAFETY-07/24/25 peak guard | **OBSERVES** | Level-triggered, unlatched, files its claim as usual — SAFETY-45's limit cycle is visible from a shadow run, which is the point | +| SAFETY-31 circulation | **OBSERVES AND MAY LATCH** | Its evidence is *measured* power. Packs pushing against each other while all are told 0 W is a real fault | +| SAFETY-42 delivery | **OBSERVES AND MAY LATCH** | Same, against a share that is 0 W | +| operator stop | **SUPPRESSED AS A CLAIM, ENFORCED AT THE WIRE** | The one deliberate change. Its `P_SAFETY` 0 W claim out-ranks everything, so leaving it in makes the recorded decision `"stopped"` on every cycle and the run answers the wrong question. The actuator ends at the same number either way, and the wire substitution is **strictly stronger**: a claim can be out-ranked, and it cannot | +| inputs missing, non-finite, allocator refusal, arbiter contradiction | **UNCHANGED** | All resolve to 0 W, which is what the wire gets anyway | + +⚠️ **A latch that is already up stays up.** Nothing here clears one. The shadow +banner **names** any standing latch and goes red for it, because a latch that is +up while the controller is inert is up the moment it stops being inert. + +⚠️ **Maintenance is not in that table because shadow mode does not touch it.** +The state machines are ticked on every cycle 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. Recorded here rather than +quietly fixed under another card's name. + +--- + +## 5. The limitation, stated rather than discovered later + +`Controller._shadow_settle()` re-seeds the 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. Three machines read that value as a +statement about the **hardware**: the loop's `prev_w` and integrator, +`standing_w()`, and SAFETY-42's share comparison. Re-seeded, they track reality, +and **the exit is safe by construction** — the carried command already equals +the standing one, so the first live cycle slews from 0 W like any other. + +⚠️ **The cost is real.** The loop's `prev_w` is therefore 0 W on every shadow +cycle, so the loop leg's recorded decision is **one slew step from the standing +command** and stays there. A shadow run answers *"what would this controller +command NOW, from where the plant really is"* — a true statement about the +present — and **not** *"what trajectory would it have followed"*. The trajectory +is a closed-loop property, it did not happen, and reporting it would be a +simulation of a plant that does not exist; three cycles in, such a number +describes nothing. + +The strategies are unaffected: arbitrage, the peak guard and maintenance all +file `SET` claims at a fixed power from level-triggered inputs. That is why the +three defects this card was written for — SAFETY-45's 28 onsets an hour, +TARIFF-14's wrong quarter-hour, SAFETY-46's ceiling churn — are all visible from +a shadow run, and the loop's ramp is not. Graded, named, in +`test_watchdog.py` part 10 as `RECORDED LIMITATION`. + +--- + +## 6. How "nothing reaches the inverter" is graded + +**Not on a flag.** A check that asserted `Controller.shadow` would pass on a +build in which the flag is set and the substitution is deleted — which is +exactly mutation **M1**. + +`test_clamp.py` part 8 drives the **shipped** `Controller.cycle()` and the +shipped heartbeat against a recording Home Assistant stub, and asserts on what +reached `set_number()`: + +* **the precondition first** — the same stimulus with `shadow_mode: off` puts + 30 non-zero writes on the wire, the largest 5000 W. MUTATION.md habit 4: a + check whose data cannot reach the failure it names is decoration; +* then `set(writes) == {0.0}`, **and** that there were 26 of them — a check + that only said "no non-zero write" would pass on the silent build too; +* entry and exit are measured on `inv.last_write` — what the inverter is + latched on, updated only on an **accepted** write — either side of the + boundary, against the same run that never entered shadow mode. + +The closed-loop half is `/home/homelab/s10-mut/shadowloop.py` on TEST-23 stage +3's rig: the same hour, twice, against `plantsim.Plant` through the firmware's +own frame encoder. + +| | live | shadow | +|---|---|---| +| `plant_cmd_w` | −2000 W throughout | **0 W throughout** | +| `batt_w` | −2000 .. −40.6 W | **0.0 W, exactly** | +| SoC | 50.000 → **62.331 %** | 50.0 → **50.0 %** | +| grid | 390.6 .. 2350 W | **350 W = the house, exactly** | +| `ARBITRAGE_CHARGE` samples | 3600 | **3600** | +| first charge instant | 2026-01-15T00:00:00+00:00 | **identical** | +| `arb_power_w` | −2000 W | **identical** | +| `arb_reason` | the strategy's own sentence | **identical, verbatim** | +| arbiter winner | `arbitrage` | **identical** | + +The decision happened, at the same instant, for the same reason, at the same +signed power — and the battery did not move. + +⚠️ **Three lines of that harness are a TRANSCRIPTION and are marked as one.** +`priceloop.Rig` has no `_write_unit()`, no clamp and no failsafe machine +(divergence 2 of its README), so the substitution's **placement** is +transcribed there. The shipped placement is graded by `test_clamp.py` part 8, +which drives the real `Controller`. ⚠️ **And the rig's transcribed cycle +predates SAFETY-45**: `arbitrage.decide()` gained a required +`charge_headroom_w` after `478ba93`, and the harness supplies it from the +**shipped** `peakguard.charge_headroom_w()` reading the **shipped** peak +decision rather than from a constant, so SAFETY-45 stays in the run. + +--- + +## 7. The mutation battery — 22 mutations, no survivors + +Harness: `/home/homelab/s10-mut/run.py`, backups beside it. Each run restores +from backup, purges `__pycache__` **on the host**, and grades through the CI +image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache +into the tree under test (TEST-15). The full table is in the branch report; +one per new behaviour, including **M1** (the substitution deleted — a write +escapes), **M2** (it returns instead of substituting — the inverter is left +latched), **M3** (it moves above SAFETY-28 and SAFETY-03 — the observers go +blind), **M8** (`auto` resolves to shadow unconditionally — the literal +default-on that stops the live install), **M12** (the stop claim is filed in +shadow — every recorded decision reads `stopped`), **M21** (the substitution +and the re-seed together) and **M22** (the re-seed moved one line earlier — +SAFETY-18 grades a 0 W no decision produced and goes permanently blind). + +⚠️ **Two findings from driving it, both of the kind MUTATION.md says to expect.** + +* **M2 first came back a SURVIVOR and was not one.** The "stop writing + altogether" build left `inv.last_write` as `None`, the check did `float()` on + it, and the section died with a `TypeError` — grading none of the checks + after it and reporting **zero reds**, which reads exactly like a survivor. + That is FCST-06's shape. The fixture now hands `None` back and names it as + its own check; M2 is red on three. +* **M6 had to become a PAIR, and only the battery said so.** `standing_w()` + prefers `last_write` — the 0.0 W the wire gate accepted — and falls back to + `inv.target`, which the re-seed also holds at 0.0. Either rung alone keeps + the detector inert, so dropping the re-seed **singly** came back red on the + limitation check and on **nothing about the latch**: the hazard it names was + not reproduced. TEST-16's rule with a new name on it. +* **M7/M8/M9/M19 had to MOVE SUITES.** They defeat `shadow.engaged()`, and a + defeated default stops `test_clamp.py` part 3's wired run reaching the wire + at all — aborting the file with an `IndexError` before part 8 is graded. The + red count there was a lower bound naming the saturation freeze. The mode + table now lives in `test_validate.py`, which drives no Controller. +* **`shadow_would_w = float(self.target)` is an EQUIVALENT mutation and was + measured as one.** `_shadow_settle()` runs before the re-seed, so at that + line `self.target` **is** `resolution.target_w`. It is recorded as equivalent + rather than reported as a survivor, and the non-equivalent form of the same + defect — the record becoming the **wire** — is driven instead. + +### The table + +Each row: the mutation, the file, the byte delta, the suite it was graded in, +and how many **named** checks went red. `⚠️` marks a row whose run did not +reach the end of its file, so its count is a **lower bound** (MUTATION.md). + +| # | mutation | file | Δ | suite | reds | +|---|---|---|---|---|---| +| M1 | **a write escapes** — the substitution deleted | `main.py` | −6 B | clamp | 5 | +| M2 | **silence instead of 0 W** — it returns rather than substituting | `main.py` | −15 B | clamp | 3 | +| M3 | **the observers go blind** — it moves above SAFETY-28 and SAFETY-03 | `main.py` | +58 B | clamp | 1 | +| M4 | the site re-seed dropped — the exit resumes from a fiction | `main.py` | −36 B | clamp | 3 | +| M5 | the per-unit re-seed dropped | `main.py` | −22 B | clamp | 1 | +| M6 | **PAIRED**: `standing_w()` reads the target **and** the re-seed dropped — the sign latch that survives into live operation | `inverter.py` + `main.py` | −170 B | watchdog | 3 | +| M7 | `auto` inverted — shadow while started, live while stopped | `shadow.py` | +2 B | validate | 4 | +| M8 | **default-on for everyone** — `auto` always shadow; the literal reading that stops the live install | `shadow.py` | −15 B | validate | 2 | +| M9 | an unreadable value resolves to LIVE instead of shadow | `shadow.py` | +1 B | validate | 1 | +| M10 | `validate.py` stops refusing an unknown value | `shadow.py` | −9 B | validate | 9 | +| M11 | the loop leg gated on `auto` alone again — the decision path stops running | `main.py` | −17 B | clamp | 1 | +| M12 | the stop claim filed in shadow — every decision reads `stopped` | `main.py` | −20 B | clamp | 1 | +| M13 | the banner goes green while nothing is commanded | `shadow.py` | −2 B | clamp | 1 | +| M14 | the banner loses the standing latch | `shadow.py` | −2 B | clamp | 1 | +| M15 | the row renders the standing command instead of the decision | `shadow.py` | −8 B | clamp | 1 | +| M16 ⚠️ | the row disappears when live | `main.py` | +43 B | clamp | 1 | +| M17 | the record becomes the wire | `main.py` | −6 B | clamp | 7 | +| M18 | level-logged — the 40-row action log floods | `main.py` | −23 B | clamp | 1 | +| M19 | the two rungs render identically (masking) | `shadow.py` | +22 B | validate | 1 | +| M20 | the shipped default moves to `off` in `config.yaml` alone | `config.yaml` | −1 B | defaults_pin | 1 | +| M21 | **PAIRED**: the substitution **and** the re-seed deleted together | `main.py` | −103 B | clamp | 10 | +| M22 | the re-seed moved one line earlier — SAFETY-18 grades a 0 W no decision produced and goes permanently blind | `main.py` | +40 B | clamp | 1 | + +**Survivors: none.** ⚠️ **M17's `+0 B`-adjacent sibling** — +`shadow_would_w = float(self.target)` — is recorded in §7 as **equivalent** +rather than counted here. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index d20779a..7b3d1b2 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -601,6 +601,94 @@ behind it would trip the firmware watchdog on a system that is working fine. | `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise | | `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two | | `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share | +| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below | + +#### Shadow mode (`shadow_mode`, SAFETY-10) + +Shadow mode runs the **whole** decision path — the price strategy, the capacity +guard, the maintenance shaping, the grid-following loop, the arbiter that ranks +them, SAFETY-28's authorisation gate and SAFETY-03's clamp — and **sends nothing +to the inverter**. It is how you find out what this add-on *would* do on a site +before you let it do it. + +Every defect in the optimiser that this project has found was invisible from +outside while it was happening: a price lookup answering for the wrong +quarter-hour, a trade against a binding capacity cap that ran 28 charge onsets +in an hour, an SoC ceiling churning 598 Wh to gain 310 Wh. Each one was paid for +at the meter first and noticed afterwards. Shadow mode is the surface that +would have shown all three on the day. + +**What "commands nothing" means, exactly.** Every inverter is held at **0 W**, +written by the ordinary single writer and refreshed on the ordinary heartbeat. +It is not silence. ⚠️ **The inverter holds its last command forever** — it has +no meter timeout — so a mode that simply stopped writing would leave whatever +was standing when you switched it on standing for as long as you left it on, +under a page saying nothing was being commanded. Refreshing 0 W is also what +the ESP32's 30 s stale-input watchdog requires of us: going quiet would put +that board into a *tripped watchdog* state for the whole run, which is an alarm +being used as a mode. + +**The three values.** + +| value | what happens | +|---|---| +| `auto` | **the shipped default.** Shadow mode while control is **stopped**; ordinary control once control is **started**. A new install ships `auto_start: false`, so it comes up in shadow mode and observes before it ever commands anything. An install that is already controlling is started, so nothing about it changes | +| `on` | shadow mode **always**, whatever the *Start / stop control* button says. This is the one to pick to watch an armed site for a day — the loop runs, arbitrage decides, the peak guard files its claims, and the battery does not move | +| `off` | ordinary control | + +⚠️ **Existing installs are not changed.** The default is `auto` rather than a +boolean `true` precisely because a boolean would have changed them: the +Supervisor composes an add-on's options as *its defaults, overlaid with your +stored options*, so a new key with a new default reaches every install on the +next update. `shadow_mode: true` would have silently stopped a battery that is +regulating today, and the only symptom would be a battery that stopped doing +anything. + +⚠️ **Under `auto`, pressing *Start* leaves shadow mode.** That is the point of +`auto` — the button means "control the battery". Set `on` if you want the +decisions recorded while the loop runs. + +**What you get out of it.** The status page carries a red-amber banner saying +the battery is not being controlled, a `Shadow mode` row saying which rung +decided, what it **would have commanded** and why, and the `Why` row with the +arbiter's own sentence. The action log gets one line each time the decision +changes — the winner or the watts — rather than one per cycle, so a 40-row log +still contains the context of what it found. MQTT reports `status: shadow`. + +**The failsafes.** The sign watchdogs (SAFETY-02/30) are **inert**: they grade +what this controller has standing on the wire, and that is 0 W, so they cannot +arm. That is deliberate and it is the single most important decision in this +feature — fed the shadow decision instead they would disagree with a meter that +never saw it, and **latch a trip that survives into live operation and a +restart**. The clamp (SAFETY-03), the write-authorisation gate (SAFETY-28), the +frozen-meter alarm (SAFETY-18) and the capacity guard all **observe**: they run +on the number the decision path produced, so a shadow run can tell you the +clamp would have engaged. The circulation (SAFETY-31) and delivery (SAFETY-42) +watchdogs observe and may legitimately latch, because their evidence is +*measured* power, not a command. The operator stop is enforced at the wire +instead of as a claim, so that the recorded decision is the real one. + +⚠️ **A latch that was already up stays up.** Shadow mode does not clear +anything. The banner names any standing latch and goes red for it, because a +latch that is up while the controller is inert is up the moment it stops being +inert. + +⚠️ **Maintenance is unchanged.** The maintenance state machines are ticked on +every cycle whether control is started or not, so they already run against a +battery that does not move on a stopped install. Shadow mode inherits that +exactly; it neither improves it nor makes it worse. + +⚠️ **What a shadow run will NOT tell you.** The controller's carried command is +re-seeded to the 0 W standing on the wire at the end of every shadow cycle — +that is what makes turning shadow mode off safe, because the loop then resumes +from where the battery actually is. The consequence is that the +**grid-following loop's** recorded decision is **one slew step** and stays +there: a shadow run answers *"what would it command now"*, not *"what +trajectory would it have followed"*, because the trajectory depends on a +battery responding and none did. The **strategies** are unaffected — price +arbitrage, the capacity guard and maintenance all ask for a fixed power from +inputs that do not depend on what the battery did — so a shadow run does show +you the trades, the peak-guard engagements and the SoC-limit behaviour. #### The sign-disagreement failsafe diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 13b183b..858d92a 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -66,6 +66,7 @@ from .mqtt import (MqttPublisher, announced_units, unit_slugs, unit_state_key) from . import peakguard from . import packshare +from . import shadow from .p1 import P1Ingest, build_source, is_enabled from .price import Uncertainty, price_option, provider_configured from .price_history import PriceHistory @@ -830,6 +831,24 @@ class Controller: # live state self.auto = bool(store.data.get("auto", opts.get("auto_start", False))) + # --- SAFETY-10 ------------------------------------------------------- + # What the decision path reached on the last shadow cycle, in W, or + # None before the first one. ⚠️ IT IS KEPT SEPARATELY FROM + # `self.target` BECAUSE THEY ARE DIFFERENT FACTS: `self.target` is what + # is STANDING ON THE WIRE - held at `shadow.WIRE_W` for the whole of a + # shadow run, and re-seeded to it at the end of every shadow cycle so + # the loop and the sign watchdogs track reality - and this is what a + # live controller WOULD have put there. Folding them into one field is + # how a shadow run starts telling an operator a number nothing is + # doing. + self.shadow_would_w = None + # Edge state for the action log, both of them. `_shadow_was` is None + # until the first cycle, so an install that comes up IN shadow mode + # says so in the log it will be read from rather than leaving the + # operator to infer it from a page nobody is looking at yet. Coming up + # LIVE is silent - see `_shadow_settle()`, which says why. + self._shadow_was = None + self._shadow_winner = None self.target = 0.0 self.sat_count = 0 self.i_w = 0.0 # the loop's integrator, carried between cycles @@ -1739,6 +1758,42 @@ class Controller: "reports a different value.", inv.name, inv.batt_fresh.reason) + @property + def shadow(self) -> bool: + """SAFETY-10. Is the battery being left alone right now? + + ⚠️ A PROPERTY, NOT A FIELD, AND FOR THE REASON `self.units` IS ONE. + Three surfaces ask this question at three different moments - the + cycle, the write, and the status page rendered between two cycles - and + a cached flag is a fourth answer that can disagree with the option and + the button it is derived from. Derived, they cannot. + + ⚠️ AND THE WRITE GATE ASKS THIS, NOT A PARAMETER PASSED DOWN TO IT. A + suppression that had to be threaded through `write_setpoint()` would be + absent from every call site that forgot it - the heartbeat, the stop + button, `shutdown()` - which is exactly the shape of hole this add-on's + single choke point exists to close. + """ + return shadow.engaged(self.o, control_started=self.auto) + + def shadow_latches(self) -> tuple: + """The failsafe latches that are UP, named, for the shadow banner. + + A latch that is standing while the controller is inert is standing when + it stops being inert. `shadow.banner()` carries them so an operator who + turns shadow mode off is not handed a battery that will not move and a + page that never mentioned why. + """ + out = [] + if self.circ_wd.tripped: + out.append("circulating power") + if self.site_sign_wd.tripped and len(self.inverters) > 1: + out.append("site sign watchdog") + for inv in self.inverters: + if inv.sign_wd.tripped: + out.append(f"{inv.name} sign watchdog") + return tuple(out) + def write_refusal(self, value: float, inv=None) -> str | None: """Why `value` may NOT be written to `inv` right now, or None if it may. @@ -1930,6 +1985,40 @@ class Controller: self.log_event(f"{inv.name}: {result.describe()}") value = result.applied_w + # --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE ----------- + # Shadow mode runs the whole decision path and commands nothing. The + # suppression is HERE, in the one method every write in this add-on + # reaches - the cycle, the heartbeat, the stop button, shutdown() and + # maint_abort() alike - so there is no call site that can route round + # it. app/shadow.py carries the whole argument; three things about the + # placement belong here: + # + # ⚠️ IT IS A SUBSTITUTION, NOT A RETURN, exactly like the two refusals + # above it. Returning would leave the inverter LATCHED ON ITS LAST + # COMMAND with the ESP32's heartbeat still being fed - a battery held + # at whatever it was doing when shadow mode was switched on, under a + # status page saying nothing is being commanded. That is the failure + # this add-on keeps finding, and shadow mode must not be a new way to + # reach it. `shadow.WIRE_W` is 0.0 W and it is REFRESHED on the + # ordinary heartbeat, which is also what firmware/goodwe-master.yaml's + # 30 s stale-input watchdog requires of us. + # + # ⚠️ IT IS BELOW SAFETY-28's GATE AND BELOW SAFETY-03's CLAMP, so both + # still grade the number the decision path actually produced. A shadow + # run that could not say "it would have hit the rating here" would be + # answering a different question from the one it is for. + # + # ⚠️ IT IS NOT A SECOND WRITER. No claim is filed, no transport is + # opened and no arithmetic of its own reaches the wire: one actuator, + # one writer, and this is that writer emitting the value it emits for + # every other refusal. + if self.shadow and value != shadow.WIRE_W: + _LOG.info("SHADOW MODE: not commanding %+.0f W to %s - holding " + "%+.0f W. The decision was reached and recorded; " + "nothing was sent to the inverter.", + value, inv.name, shadow.WIRE_W) + value = shadow.WIRE_W + changed = (inv.last_write is None or value != inv.last_write) if not (changed or force): return @@ -2549,7 +2638,16 @@ class Controller: # operator switching control off for the whole site; "inputs missing" is # the SITE's own telemetry gone - the meter this controller regulates # against. Neither is about a pack, and both must stop every inverter. - if not self.auto: + # ⚠️ SAFETY-10: `and not self.shadow`, AND IT IS THE ONE FAILSAFE THIS + # CARD CHANGES. `P_SAFETY` out-ranks every other claim, so leaving the + # stop claim in while shadow mode is engaged would make the recorded + # decision "stopped" on every cycle - a shadow run that answers the + # wrong question, which is the same as no shadow run. The actuator ends + # at the same number either way, and the substitution in `_write_unit()` + # that replaces it is STRICTLY STRONGER than this claim: a claim can be + # out-ranked by a higher priority, and the wire gate cannot be + # out-ranked by anything. + if not self.auto and not self.shadow: claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped", actuator=self.actuator_of("safety"))) elif not self.inputs_ok: @@ -2893,7 +2991,14 @@ class Controller: inv))) # --- the grid-following loop ----------------------------------------- - if self.auto and self.inputs_ok: + # ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most + # needs and the one that is switched off by `auto_start: false`, so + # without this an install in `auto` shadow mode would record every + # strategy's decision and a permanent silence where the grid-following + # loop should be. It is safe because the loop's `prev_w` is + # `self.target`, which the end of this method re-seeds to what is + # actually standing on the wire. + if (self.auto or self.shadow) and self.inputs_ok: # ⚠️ prev is the ARBITER's last output, not the loop's own last # wish. If something outranked the loop, that is what the hardware # actually did, and the controller must track reality or it jumps @@ -3227,6 +3332,116 @@ class Controller: # reads only `self.p1`, which no line in this method writes. self.peak.sync(self.p1) + # --- SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS ----- + # Every guard above has now read `self.target` - the resolved intent - + # and SAFETY-18 in particular grades exactly that. Re-seeding before + # them would hand each one a 0 W that no decision produced. Re-seeding + # AFTER them hands the NEXT cycle, and the sign watchdogs, what is + # actually standing on the wire. See `_shadow_settle()`. + self._shadow_settle(resolution) + + def _shadow_settle(self, resolution) -> None: + """SAFETY-10. Record the decision, then re-seed the carried command. + + TWO JOBS, AND THEY ARE IN THIS ORDER BECAUSE THE SECOND DESTROYS THE + FIRST'S EVIDENCE. + + 1. THE RECORD. A shadow run is only worth running if it answers "what + would it have done, and why". `self.shadow_would_w` and + `Resolution.explain()` are that answer, and the action log gets one + line per CHANGE OF DECISION rather than one per cycle - the log + holds 40 rows, and a level-logged shadow run would flush the context + of its own findings inside four minutes at a 15 s cycle floor. The + same edge rule the peak guard and the arbitrage strategy already + use, for the same reason. + + 2. THE RE-SEED. `self.target` and each `inv.target` are set back to + `shadow.WIRE_W` - what the write gate actually put on the wire. + + ⚠️ WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. Three + machines read the carried command as a statement about the HARDWARE: + + * `compute(prev_w=self.target, ...)` - the loop's slew limiter and + its integrator. Left holding a shadow decision, the first cycle + after shadow mode is turned off would slew from a base that never + existed: up to a full `slew_w` step onto a battery sitting at 0 W. + Re-seeded, the exit is safe BY CONSTRUCTION and needs no separate + transition code - the carried command already equals the standing + one. + * `Inverter.standing_w()`, which is what SAFETY-02/30's sign + watchdogs grade. Fed a shadow decision they would compare a number + against a meter that never saw it, disagree for as long as the + decision asked for power, and LATCH A TRIP THAT SURVIVES INTO LIVE + OPERATION AND A RESTART. Shadow mode would manufacture the failsafe + it exists to help debug. ⚠️ THIS ONE IS ALREADY HELD BY THE WIRE + GATE - `standing_w()` prefers `last_write`, and that is the 0.0 W + the gate accepted - so the re-seed is the SECOND rung, covering + the fallback before any write has been accepted. Two rungs + answering one question is MUTATION.md's masking shape, and the + mutation that reproduces the hazard has to defeat BOTH: driven + singly, dropping this line leaves the detector inert and names + nothing about the latch. Measured. + * SAFETY-42's delivery watchdog compares `inv.target` against what + the pack is producing. A share nothing was told to produce is a + delinquency report on every cycle of every shadow run. + + ⚠️ AND EVERY CYCLE'S DECISION IS THEREFORE COMPUTED FROM WHERE THE + PLANT REALLY IS, not from a counterfactual plant that drifts further + from reality every tick. "What would this controller command NOW, + given this meter and a battery at 0 W" is a true statement about the + present; "what would the eleventh cycle of a run that never happened + have commanded" is not a statement about anything. + """ + engaged = self.shadow + if engaged != self._shadow_was: + # ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The + # edge is against `None` on cycle one, and an install that comes up + # CONTROLLING has nothing to report - a "shadow mode off" line at + # every start is one more row of boilerplate in a 40-row log, and + # it was measured pushing a real clamp episode's context out of + # test_clamp.py part 2's ring. Coming up IN shadow mode is the + # other direction and it is always worth a line: it is the log an + # operator reads to find out why nothing is being commanded. + first = self._shadow_was is None + self._shadow_was = engaged + if engaged: + self.log_event( + "SHADOW MODE ON - " + shadow.why( + self.o, control_started=self.auto) + + f"; inverter held at {shadow.WIRE_W:.0f} W") + _LOG.warning( + "SAFETY-10 SHADOW MODE IS ON. The full decision path runs " + "and NOTHING is commanded: every inverter is held at " + "%+.0f W on the ordinary heartbeat. %s", shadow.WIRE_W, + shadow.why(self.o, control_started=self.auto)) + elif not first: + self.log_event( + "SHADOW MODE OFF - the battery is being controlled again, " + f"resuming from {shadow.WIRE_W:.0f} W") + _LOG.warning( + "SAFETY-10 shadow mode is OFF. This controller is " + "commanding the battery again, starting from the " + "%+.0f W that was standing on the wire.", shadow.WIRE_W) + if not engaged: + self.shadow_would_w = None + self._shadow_winner = None + return + self.shadow_would_w = float(resolution.target_w) + # ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. A decision + # that moves from the loop to the peak guard at the same number is a + # different decision and the operator needs the line; a loop target + # that wanders by a watt is not, and 15 s of those would be the whole + # log. + edge = (resolution.winner, round(float(resolution.target_w))) + if edge != self._shadow_winner: + self._shadow_winner = edge + self.log_event( + f"SHADOW: would have commanded {resolution.target_w:+.0f} W - " + + resolution.explain()) + self.target = shadow.WIRE_W + for inv in self.inverters: + inv.target = shadow.WIRE_W + async def check_output_clamp(self) -> None: """SAFETY-03. Alarm whenever a clamp actually engaged this cycle. @@ -4016,6 +4231,14 @@ class Controller: or (self.site_sign_wd.tripped and len(self.inverters) > 1) or self.circ_wd.tripped + # SAFETY-10: "shadow" is its own value and not a + # flavour of "stopped". A fleet dashboard reporting a + # shadow install as `running` would be calling a + # battery nobody is controlling healthy; one reporting + # it as `stopped` would lose the fact that the decision + # path is live and being recorded. ⚠️ BELOW `failsafe`: + # a latch that is up is up, and shadow does not clear it. + else "shadow" if self.shadow else "running" if self.auto else "stopped"), # SAFETY-31. ⚠️ MEASURED, not the allocation record - see # `site_circulating_w`. `publish()` drops None values, so a cycle @@ -4516,7 +4739,24 @@ class Controller: def status_payload(self) -> dict: checks = self.checks() bad = [c for c in checks if not c["ok"] and not c["warn"]] - if self.circ_wd.tripped: + # --- SAFETY-10, AND IT IS FIRST IN THE CASCADE ----------------------- + # "The battery is NOT being controlled" is the one sentence on this + # page that may never be displaced by another condition. A banner that + # can be out-ranked does not STATE a thing plainly; it states it + # whenever nothing else is happening, which is the half of the time + # nobody needs telling. + # + # ⚠️ AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. The three + # `bad` branches below all say some version of "held at 0 W", which is + # still true in shadow mode - but an operator who turns shadow mode off + # with a latch up gets a battery that will not move and a page that + # never mentioned it. `shadow.banner()` names the latch and goes red + # for it, so nothing this cascade would have said is lost. + _shadow = shadow.banner(self.o, control_started=self.auto, + latched=self.shadow_latches()) + if _shadow is not None: + level, banner = _shadow + elif self.circ_wd.tripped: # ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are # latched P_SAFETY stops, so the site is at 0 W either way and the # banner can only carry one sentence. This one goes first because it @@ -4616,6 +4856,16 @@ class Controller: ("State of charge", "—" if self.pack_floor_soc is None else f"{self.pack_floor_soc:g} %"), ("Commanded", w(self.target)), + # SAFETY-10, and it sits BETWEEN the two rows it reconciles. + # "Commanded 0 W" above and a "Why" below that explains a trade is + # the exact pair an operator would otherwise have to guess at; this + # row is what makes the two of them consistent, and it is rendered + # in EVERY state - including `off`, where it says the battery IS + # being controlled - because a surface whose ABSENCE is how you + # learn something is a surface you have to already know about. + ("Shadow mode", shadow.words( + self.o, control_started=self.auto, + would_w=self.shadow_would_w, reason=self.reason)), ("Why", self.reason), ("Maintenance phase", self.maint.phase if len(self.inverters) <= 1 else f"{self.maint.phase} ({self.maint_unit.name})"), diff --git a/goodwe_controller/app/shadow.py b/goodwe_controller/app/shadow.py new file mode 100644 index 0000000..1172382 --- /dev/null +++ b/goodwe_controller/app/shadow.py @@ -0,0 +1,361 @@ +"""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."] diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index 5671425..e0627fd 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -334,6 +334,7 @@ from .peakguard import PEAK_OPTION_DEFAULTS from .price import ( PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS, SUPERSEDED_TAXES_DEFAULTS_EUR_KWH) +from .shadow import problems as shadow_problems from .static_tariff import ( ProfileInvalid, TAXES_PROFILE_OPTION, parse_static_profile, parse_taxes_profile) @@ -840,6 +841,18 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str] # missing rating one line up. problems.extend(_roster_problems(opts)) + # --- 11. SAFETY-10's shadow mode, and it is a REFUSAL --------------------- + # `shadow_mode` decides whether this controller commands the battery at all, + # so an unreadable value is not a detail to log and carry on past. + # `shadow.mode()` resolves an unrecognised one to SHADOW - the direction + # that does not move energy - and an install silently reinterpreted that way + # commands nothing, indefinitely, while its status page calmly explains + # that this is intentional. UX-02 made this validator the ONE rule surface + # both editors read, so the refusal belongs here rather than in a coercion + # nobody can see. The sentence spells `shadow_mode` so + # `configui.problems_by_field()` can file it under the field. + problems.extend(shadow_problems(opts)) + # --- 2. the configured ceiling against the device's own rating ----------- # ⚠️ THE FLAT `max_w`, AND ONLY THE FLAT ONE. `dev_min`/`dev_max` are ONE # device's range - the flat `setpoint_entity`'s - so this rule can only ever diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 52be613..8bb947d 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -746,6 +746,38 @@ options: # and not 150 or 200. 37 control cycles. delivery_hold_s: 185 + # SAFETY-10. Run the whole decision path and command NOTHING - strategies, + # arbiter, clamps and guards all run, the decision and its reason are + # recorded, and every inverter is held at 0 W. `app/shadow.py` carries the + # argument; the three things to know before changing this line are: + # + # auto shadow WHILE CONTROL IS STOPPED, live once it is started. This is + # the shipped value and it is what "default-on" means here. + # on always shadow, whatever the Start / stop button says. This is the + # one an operator picks to watch a day of decisions on an armed site + # before letting it trade. + # off ordinary control. + # + # ⚠️ IT IS NOT A BOOLEAN DEFAULTING TO `true`, AND THAT IS THE WHOLE CARD. + # The Supervisor composes an add-on's options as `{**these defaults, **the + # stored user options}`, so a NEW key with a new default reaches every + # EXISTING install on the next update - including one regulating a real + # battery. `shadow_mode: true` would silently stop it, and the only symptom + # would be a battery that has stopped doing anything. There is no mechanism + # in this options system that lets one key default differently for a new + # install and an existing one, so "default-on for new" has to be a value the + # add-on RESOLVES. `auto` is that value: a fresh install ships + # `auto_start: false` and therefore comes up in shadow mode; an install that + # is controlling is started, so `auto` resolves to live and NOTHING about it + # changes. + # + # ⚠️ AND UNDER `auto` THE START BUTTON LEAVES SHADOW MODE. Pressing *Start / + # stop control* is the operator saying "control the battery". Somebody who + # wants the decisions recorded WHILE the loop runs sets `on` explicitly - + # which is the configuration this option exists for, and the one that cannot + # be reached by accident. + shadow_mode: "auto" + # ========================================================================== # ADVANCED - the control law and diagnostics # ========================================================================== @@ -1065,6 +1097,15 @@ schema: # running short of one unit's share must be reported well inside one. delivery_hold_s: int(30,900) + # SAFETY-10. ⚠️ A THREE-VALUED LIST AND NOT A `bool`, for the reason written + # against the default in the options block above: a boolean has one default + # and the Supervisor gives it to every install, new and existing alike. + # `app/shadow.MODES` is this tuple and `validate.py` rule 11 refuses anything + # else BY NAME, because `shadow.mode()` resolves an unreadable value to + # SHADOW - safe, but a silent reinterpretation that commands nothing for as + # long as nobody notices. + shadow_mode: list(auto|on|off) + # ========================================================================== # ADVANCED - the control law and diagnostics # ========================================================================== diff --git a/goodwe_controller/test_clamp.py b/goodwe_controller/test_clamp.py index 38d5ca1..8659611 100644 --- a/goodwe_controller/test_clamp.py +++ b/goodwe_controller/test_clamp.py @@ -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)}") diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py index d173759..066051c 100644 --- a/goodwe_controller/test_inverter.py +++ b/goodwe_controller/test_inverter.py @@ -224,9 +224,18 @@ A_RATING = 5000.0 # had become so; validate.py rule 10 refused it the day it existed, which is # what a rule about a relationship is for. 0 means "follow the rail" # (`control.SLEW_RAIL_FRAC * max_w`) and is what config.yaml ships. +# ⚠️ SAFETY-10: `shadow_mode="off"` EXPLICITLY, BECAUSE NOTHING HERE PRESSES +# START. The shipped default is `auto`, which is SHADOW MODE while control is +# stopped - and in shadow mode every write is substituted with 0 W at the one +# choke point these parts grade. Without this line the per-unit clamp, the +# per-unit write budget and the per-unit heartbeat would all be measured on a +# controller that is correctly refusing to command anything, which is a +# different (and vacuous) claim. The shipped write path is unaffected: a +# stopped controller only ever writes 0 W in production anyway. BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid", max_w=A_RATING, gain=0.6, slew_w=0, deadband_w=15, - target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15) + target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15, + shadow_mode="off") def built(opts=None, store=None, hass=None, second=None): diff --git a/goodwe_controller/test_validate.py b/goodwe_controller/test_validate.py index 1812428..c38a704 100644 --- a/goodwe_controller/test_validate.py +++ b/goodwe_controller/test_validate.py @@ -21,6 +21,7 @@ from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig from app.price import ( PRICE_OPTION_DEFAULTS, SUPERSEDED_TAXES_DEFAULTS_EUR_KWH) from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC +from app import shadow from app.validate import ( ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused, actuator_writers, refuse, site_rail_w, validate_startup_config, @@ -1722,6 +1723,90 @@ check("and the 6% asymmetry is reported as an ORDERED PAIR: the transposed " "vat_pct 6" not in _T16_PAIR[0]) +# --------------------------------------------------------------------------- # +print("\nSAFETY-10 - shadow_mode is validated like every other option") +# ⚠️ WHY THIS IS A REFUSAL AND NOT A COERCION. `shadow.mode()` resolves an +# unrecognised value to SHADOW - the direction that does not move energy - and +# an install silently reinterpreted that way COMMANDS NOTHING, indefinitely, +# while its status page calmly explains that this is intentional. A typo is not +# a decision, and UX-02 made this validator the one rule surface both editors +# read, so the rule has to be here rather than in a quiet fallback. +_S10_BAD = ["true", "yes", "1", "shadow", "", "autoo", None, 1] +for _v in _S10_BAD: + _p = validate_startup_config(base(shadow_mode=_v), -5000, 5000) + check(f"shadow_mode={_v!r} is REFUSED, and the refusal SPELLS the key so " + "configui.problems_by_field() can file it under the right control", + why(_p, "shadow_mode is") and why(_p, "'auto'") + and why(_p, "'on'") and why(_p, "'off'")) +for _v in ("auto", "on", "off", "AUTO", " on ", "Auto "): + check(f"shadow_mode={_v!r} starts - the three values and the tolerant " + "spellings of them are accepted, so the rule above refuses garbage " + "and not case", + validate_startup_config(base(shadow_mode=_v), -5000, 5000) == []) +check("PRECONDITION: and the key being ABSENT starts too, which is what every " + "install that upgrades into this version has. A rule that refused the " + "absent key would refuse every existing install at once", + validate_startup_config(base(), -5000, 5000) == [] + and "shadow_mode" not in base()) +# The two arrangements have to DISAGREE somewhere, or `why()` is grading a +# sentence that is in the list for another reason entirely. +check("...and a refused shadow_mode is the ONLY thing wrong with that config, " + "so the refusal above is this rule's and not some other rule's", + len(validate_startup_config(base(shadow_mode="maybe"), -5000, 5000)) == 1) + + +# --- THE SIX STATES, AND THE ONE THAT PROTECTS THE EXISTING INSTALL --------- +# ⚠️ GRADED HERE RATHER THAN IN test_clamp.py, WHERE IT STARTED, AND THE REASON +# IS MEASURED. `shadow.engaged()` is a pure function of the option and the +# start button; a mutation that defeats the DEFAULT stops test_clamp.py's +# part 3 reaching the wire at all, which aborts that file with an IndexError +# before part 8 runs - so the mutation's red count there is a lower bound and +# the table itself is never graded (MUTATION.md, FCST-06's shape). Nothing in +# this file drives a Controller, so nothing here can be taken down that way. +# Six states. The one that matters most is the LAST row: an install that is +# controlling today must not be changed by this option arriving with a default. +_TABLE = [ + ("off", True, False), ("off", False, False), + ("on", True, True), ("on", False, True), + ("auto", False, True), ("auto", True, False), +] +for _m, _started, _want in _TABLE: + check(f"shadow_mode {_m!r} with control " + f"{'started' if _started else 'stopped'} -> " + f"{'SHADOW' if _want else 'LIVE'}", + shadow.engaged({"shadow_mode": _m}, control_started=_started) + is _want) +check("THE EXISTING INSTALL: the SHIPPED DEFAULT, on an install that is " + "controlling, is LIVE. This is the whole reason the option is not a " + "boolean defaulting to true - the Supervisor merges a new default into " + "every existing install, and a boolean would have stopped a battery that " + "is regulating a real house today", + shadow.engaged({}, control_started=True) is False) +check("...and the SAME shipped default on a FRESH install - `auto_start` is " + "false, so nothing has started - is SHADOW. That is what 'default-on' " + "means here, delivered where it is safe", + shadow.engaged({}, control_started=False) is True) +# ⚠️ THE REASON, NOT THE VERDICT (MUTATION.md's masking rule). `on` and +# `auto`-while-stopped are the same boolean and want DIFFERENT operator +# actions: one is an option, the other is a button. A surface showing only the +# verdict would be blind to the two rungs being swapped. +check("the two ways of being in shadow mode are told apart in words, so a " + "build that swapped the rungs could not render identically", + shadow.why({"shadow_mode": "on"}, control_started=False) + != shadow.why({"shadow_mode": "auto"}, control_started=False)) + +# ⚠️ AND THE FAIL-SAFE DIRECTION OF AN UNREADABLE VALUE IS GRADED, THOUGH THE +# rule above means a running install can never hold one. It survived the first +# mutation battery as M9 for exactly that reason: everything else refused the +# value before `mode()` ever saw it, so the direction it fails in was ungraded +# and could have been flipped to "live" with the whole suite green. +check("an UNREADABLE shadow_mode resolves to SHADOW, not to live - the " + "direction that does not move energy. It is reachable only if this " + "file's rule is ever weakened, which is the case it exists for", + shadow.engaged({"shadow_mode": "garbage"}, control_started=True) is True + and shadow.engaged({"shadow_mode": ""}, control_started=True) is True + and shadow.mode({"shadow_mode": "garbage"}) == "on") + # --------------------------------------------------------------------------- # print() if fails: diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 8ba9338..50884ef 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -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)}") diff --git a/goodwe_controller/translations/en.yaml b/goodwe_controller/translations/en.yaml index 252c5d4..e31a503 100644 --- a/goodwe_controller/translations/en.yaml +++ b/goodwe_controller/translations/en.yaml @@ -249,6 +249,9 @@ configuration: delivery_hold_s: name: "Safety · Not-delivering report after (s) — derived, do not change" description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery." + shadow_mode: + name: "Safety · Shadow mode — run every decision, command nothing" + description: "Runs the whole decision path — strategies, arbiter, clamps and guards — and sends NOTHING to the inverter. Every inverter is held at 0 W, refreshed on the ordinary heartbeat, and the status page says plainly that the battery is not being controlled; the decision it WOULD have commanded, and why, is on the status page and in the action log. This is how you watch a site for a day before letting it trade. 'auto' (the shipped value) means shadow mode while control is stopped and ordinary control once you press Start — so a NEW install observes before it ever commands anything, and an install that is already controlling is not changed by one watt. 'on' means shadow mode always, whatever the Start / stop button says: that is the setting to pick when you want the loop and the strategies running on a live site with nothing reaching the battery. 'off' is ordinary control. ⚠️ While this is on the battery is NOT being regulated — the grid is not held at zero, no arbitrage trade is executed and no capacity peak is shaved. ⚠️ A failsafe latch that was already up stays up: shadow mode does not clear it, and the banner names it." auto_start: name: "Advanced · Start controlling automatically on boot" description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities." diff --git a/goodwe_controller/translations/nl.yaml b/goodwe_controller/translations/nl.yaml index e3ba589..440dce7 100644 --- a/goodwe_controller/translations/nl.yaml +++ b/goodwe_controller/translations/nl.yaml @@ -229,6 +229,9 @@ configuration: delivery_hold_s: name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen" description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet." + shadow_mode: + name: "Veiligheid · Schaduwmodus — alles beslissen, niets sturen" + description: "Doorloopt het volledige beslispad — strategieën, arbiter, begrenzers en bewakingen — en stuurt NIETS naar de omvormer. Elke omvormer wordt op 0 W gehouden, ververst op de gewone hartslag, en de statuspagina zegt onomwonden dat de batterij niet wordt aangestuurd; wat er gestuurd ZOU zijn, en waarom, staat op de statuspagina en in het actielogboek. Zo bekijkt u een installatie een dag lang voordat u hem laat handelen. 'auto' (de meegeleverde waarde) betekent schaduwmodus zolang de regeling gestopt is en gewone regeling zodra u op Start drukt — een NIEUWE installatie kijkt dus eerst toe voordat ze ooit iets stuurt, en een installatie die al regelt verandert geen watt. 'on' betekent altijd schaduwmodus, wat de Start/stop-knop ook zegt: die stand kiest u als u de lus en de strategieën wilt laten draaien op een werkende installatie zonder dat er iets bij de batterij aankomt. 'off' is gewone regeling. ⚠️ Zolang dit aanstaat wordt de batterij NIET geregeld — het net wordt niet op nul gehouden, er wordt geen prijshandel uitgevoerd en er wordt geen capaciteitspiek afgevlakt. ⚠️ Een vergrendeling die al aanstond blijft aanstaan: schaduwmodus heft die niet op, en de banner noemt hem." auto_start: name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten" description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."