Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md
T
glenn schrooyenandClaude Opus 5 e3ccd9f6ea
checks / unit (pull_request) Successful in 7m48s
SAFETY-47: what a shadow run can show, and the watts on its status row
Not a leak and not a write-path defect. Shadow mode's safety is intact -
the substitution, the re-seed, the authorisation gate and the clamp are
untouched. What was wrong is what three merged documents SAY a shadow run
can show you, and a number on the status row that can be 6.61x what a live
controller would command.

SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT, AND THE DOCUMENTS NOW SAY
SO. The battery is held at 0 W and the carried command is re-seeded to it,
so every measured input - the meter, the billed-quarter accumulator behind
it, the battery power, the SoC - describes a plant that did not respond. A
closed-loop defect is one whose stimulus IS that response. This is the same
property that makes shadow mode safe, so it is not a gap to close later:
the closed-loop answer comes from the closed-loop rig or from a live run.
SAFETY-10 §5 already conceded this for the grid-following loop. It was
never the loop leg's limitation; it is the general case.

THE "STRATEGIES ARE UNAFFECTED" EXEMPTION WAS TRUE UNTIL SAFETY-45 MERGED,
ONE COMMIT EARLIER. That card sizes the buy leg as min(asked, headroom)
with headroom = (900/remaining) x (ceiling - projected), and `projected`
reads the open quarter's accumulator - the meter - which is a function of
what the battery did earlier in that quarter. Measured on TEST-23 stage 3
at peak_cap_w 1500 / house 1200 / max_w 2000, one hour at 5 s:

  SAFETY-45's limit cycle  NOT visible. 28 onsets live vs 1; grid
                           -455.5..+3200.0 W vs flat at the 1200 W house;
                           guard engaged 410/720 cycles vs 0/720.
  SAFETY-46's SoC churn    NOT visible. The SoC moves 0.000000000 pp.
  TARIFF-14's wrong hour   VISIBLE, and it is the only one: which hour, at
                           what price, clearing what spread - identical on
                           720 of 720 charging cycles at a binding cap.

The exemption narrows to exactly what survives: WHICH hour a strategy picks
and WHY are unaffected; HOW HARD it trades is not. The peak guard's ceiling
and reasons are worth reading from a shadow run; its ENGAGEMENTS are not.

THE NUMBER IS CAVEATED, NOT RE-COMPUTED, AND THAT IS THE DECISION. The
arithmetic is not wrong - 900/remaining is correct for a meter genuinely
under the ceiling, and in shadow the meter genuinely is. It is the right
answer to a question nobody asked. Both surfaces now introduce the watts as
what would have been commanded ON THIS CYCLE, in every state, and carry
shadow.SIZED_FROM_METER whenever a measured billed quarter sized them.
Rejected, and recorded in the decision doc: re-computing it (the recorded
decision would no longer be the decision the shipped path reached, while the
arbiter, clamp, SAFETY-28 gate, log and MQTT all ran on the real one - a
second controller everywhere except the wire); recording nothing (blanks the
row exactly when arbitrage is trading, and the log's edge key is the watts);
and recording the unsized ask beside it (a second number, no warning, and at
the shipped 3500/2000 both figures read 2000 W).

"UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED. Charging
only adds offtake, so the shadow size is >= the live size with EQUALITY at
the start of every quarter. Pairwise over 3600 charging cycles in five
configurations: 0 below, 730 exactly equal. The first draft said "smaller",
which would have been false on every one of those 730.

AND THE PREDICATE IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED. arbitrage's
own sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the
headroom came out below the ask - in a shadow run, the START of the quarter,
where the two runs agree to the float. By the end, 900/remaining has
multiplied the headroom past the ask, the trade is not capped, the sentence
is silent, and the record is at its most wrong. A caveat keyed on it would
be quietest exactly where it is needed loudest.

SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED, FOR TWO
INDEPENDENT REASONS, AND CLOSING EITHER ALONE CLOSES NOTHING. Its fixture is
peak_cap_w 15000 against a 350 W house, so the guard never engages and the
sizing is inert - the harness's own comment says so. AND every identity row
reads the FIRST charging sample, where at elapsed=0 the projection IS the
held level and the two runs agree to the float even at a cap that does bind.
Measured: widening the fixture alone leaves that comparison passing. That
second reason is a new shape for MUTATION.md's habit-4 list - an INDEX, not
a fixture - and it is recorded there as the twelfth instance. §6 is re-taken
at a binding cap across the whole run: 716 of 720 charging cycles differ,
headroom 300.0..54000.0 W in shadow against 299.9..307.8 W live, recorded
-2000.0 W against a live -302.4 W.

THE GRADER IS test_arbitrage.py PART 3d, beside SAFETY-45's own closed-loop
regression and built on the same s45_loop() harness, so the runs differ in
one argument. Three lines stand in for _write_unit()'s substitution and
_shadow_settle()'s record and re-seed, marked as a transcription;
test_clamp.py part 8 remains the check on the shipped placement. The section
carries its own precondition into every check: a mutation that stopped
either run trading made it raise on its first min(), kill the file and
report ZERO reds - FCST-06's shape, driven rather than imagined.

AND THE FIX'S OWN FIRST DRAFT CARRIED THE SAME DEFECT. Deleting the caveat
from the action-log line left all 29 files green, because the sentence was
an f-string inside _shadow_settle() and the only suite that drives it uses a
meter_source:off fixture where no arbitrage decision exists. It is
shadow.log_line() now, for the reason words() is a function.

14 mutations, no survivors; M13 and M14 are +0 B and were confirmed on disk
by sha256 with every run purged. M14's first form was aimed at the wrong
direction and correctly survived - min->max makes the shadow record larger,
which is still an upper bound. All 29 check files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-15 01:26:57 +02:00

320 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SAFETY-47 — what a shadow run can show, and what it cannot
**Status:** decided and implemented on branch `SAFETY-47`.
**Card:** SAFETY-47 — *"shadow mode's claims, and the number on the status
row"*.
**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.
⚠️ **This is not a write-path defect and nothing in the write path was
touched.** SAFETY-10's substitution, re-seed, authorisation gate and clamp are
unchanged. What was wrong is what three merged documents **say** a shadow run
can show you, and a number on the status row that can be **6.61×** what a live
controller would command.
---
## 0. The four things a reader should take away
1. **Shadow mode cannot surface a closed-loop defect. Not partially — not at
all**, and by the same construction that makes it safe. §1.
2. **The "strategies are unaffected" exemption was true until SAFETY-45 merged,
one commit before SAFETY-10.** Sizing the buy leg to the billed-quarter
headroom made the strategy closed-loop. §2.
3. **The recorded watts are the right answer to a question nobody asked, and
they are CAVEATED rather than re-computed.** Re-computing would mean the
recorded decision was no longer the decision the shipped path reached, which
is the one thing shadow mode has to be able to say. §3 records what was
rejected.
4. **SAFETY-10 §6's committed evidence could never have failed, for two
independent reasons, and closing either one alone closes nothing.** §4.
---
## 1. Why the answer is "not at all", and not "not very well"
Shadow mode holds every actuator at `shadow.WIRE_W` and re-seeds the carried
command to it at the end of each cycle. So every **measured** input — the
meter, the billed-quarter accumulator behind it, the battery power, the state
of charge — describes a plant that **did not respond**.
A closed-loop defect is, by definition, one whose stimulus **is** the plant's
response to the controller. No response, no stimulus, nothing to see. This is
not an implementation gap:
* feeding the guards a **simulated** plant instead is exactly what SAFETY-10 §4
refuses for the sign watchdogs, and the reason generalises — a controller
reacting to a plant that does not exist drifts further from reality every
tick, and three cycles in its numbers describe nothing;
* and SAFETY-10 §5 already conceded the same fact for the grid-following loop
("one slew step, not a trajectory"). That was never a property of the loop.
**It is the general case**, and the loop leg was simply where it had already
been noticed.
**So the closed-loop answer comes from a closed-loop rig (TEST-23 stage 3) or
from a live run. There is no third place, and saying that plainly is worth more
than the three sentences it replaces.**
### Measured, on the rig, at a cap that binds
`peak_cap_w` 1500 / house 1200 / `max_w` 2000, one hour at a 5 s cycle. Of the
three defects SAFETY-10 was written for:
| | visible? | measured |
|---|---|---|
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow. Grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow. Guard engaged 410/720 cycles live, **0/720** in shadow. (Taken with SAFETY-45 reverted — that is the defect being looked for.) |
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. The SoC is the pack's integral of what the pack was commanded, and it was commanded 0 W. No SoC limit can be *reached* in a shadow run at all |
| TARIFF-14's wrong quarter-hour | **yes**, and it is the only one | the price lookup's inputs are the clock and the price series, neither a function of the battery. Which hour, at what price, clearing what spread: **identical live and shadow on 720 of 720 charging cycles, at a binding cap** |
### What a shadow run genuinely is for
*Which* strategy wins, on *which* hour, at *which* price, for *which* reason,
against which limits — the wrong-quarter-hour class, a trade armed on a day it
should not be, a refusal whose sentence names the wrong subsystem, a clamp that
would have engaged, a standing failsafe latch. That is a real and useful set,
and it is the set the documents now claim.
---
## 2. The exemption, and exactly how much of it survives
All three documents carried a version of:
> 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**.
**True before SAFETY-45, false after it.** SAFETY-45 makes the buy leg
`min(asked, headroom)`, and
```
headroom = (900 / remaining) x (ceiling - projected)
```
where `projected` reads the **open quarter's accumulator** — the meter — which
is a function of what the battery did earlier in that same quarter. SAFETY-45
merged one commit before SAFETY-10, so the exemption was stale on the day it
was written.
**What survives, and it is now graded rather than asserted:**
> **Which** hour a strategy picks, and **why**, are unaffected — the clock and
> the price series are not functions of the battery.
> **How hard** it trades is not.
⚠️ **The peak guard is not in the surviving half either.** Every term of
`wanted = batt_w + excess` is measured, so in a shadow run the guard engages
only if the *house alone* would put the quarter over the ceiling. Its
**ceiling** and its **reasons** are worth reading from a shadow run; its
**engagements** are not evidence of anything.
---
## 3. The number on the status row — decided, and what was rejected
At `peak_cap_w` 1500 under a 1200 W house the status row read **"WOULD HAVE
COMMANDED −2 000 W"**. A live controller commands **−302.4 W**. Measured on the
shipped build:
| cap / house | live commanded | shadow recorded | over by |
|---|---|---|---|
| 1500 / 1200 | −302.4 W | **−2000.0 W** | **6.61×** |
| 3500 / 3000 | −502.6 W | −2000.0 W | 3.98× |
| 3500 / 2000 *(shipped cap)* | −1502.6 W | −2000.0 W | 1.33× |
| 3500 / 1800 | −1702.6 W | −2000.0 W | 1.17× |
| 15000 / 350 | −2000.0 W | −2000.0 W | 1.00× |
⚠️ **The arithmetic is not wrong.** `900/remaining` is correct for a meter that
is genuinely under the ceiling, and in a shadow run the meter genuinely *is*
under it, because the battery is not charging. **The recorded number is the
right answer to a question nobody asked** — and the last row of that table is
the fixture SAFETY-10's own evidence was taken on, which is why nothing
reported it.
### What shipped
1. **Unconditionally**, the row and the log line stop forecasting: the watts
are introduced as what would have been commanded **ON THIS CYCLE**. No
number a shadow run records is a prediction of a live run, so no state
should read like one.
2. **Conditionally**, whenever a **measured** billed quarter sized the number
being recorded, both surfaces carry `shadow.SIZED_FROM_METER`:
> ⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE
> HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same
> trade against a meter the battery is moving, so this is an UPPER BOUND and
> not a prediction; how far under it a live run lands is a closed-loop
> question and a shadow run cannot answer it.
⚠️ **"Upper bound", and not "smaller", and the difference was measured.**
Charging only *adds* offtake, so a live `projected` is never below a shadow one
and the headroom never above it — with **equality at the start of every
quarter**, where `900/remaining` is 1.0 and neither plant has moved. Measured
pairwise over 3600 charging cycles in five configurations: **0 cycles below,
730 exactly equal.** A sentence reading "smaller" would have been false on
every one of those 730, and the first draft of this card said "smaller".
⚠️ **And it names no factor.** How far under the bound a live run lands is a
property of the site. A number invented for the row would be a second helping
of the defect the sentence exists to disclose.
⚠️ **The predicate is NOT keyed on whether the trade was capped, and that is
the trap this card nearly walked into.** `arbitrage`'s own sentence says *"SIZED
BY THE CAPACITY-TARIFF GUARD"* only when the headroom came out **below** the
ask — which in a shadow run is the **start** of the quarter, where the two runs
still agree to the float. By the end, `900/remaining` has multiplied the
headroom **past** the ask, the trade is **not** capped, the sentence is silent,
and the recorded number is at its **most** wrong. A caveat keyed on it would be
quietest exactly where it is needed loudest. `arbitrage.sized_from_meter()` is
keyed on the headroom having been **measured**, so it fires on both.
`test_arbitrage.py` part 3d-8 drives the inversion.
### Rejected, and why
* **Re-compute the number so it does not amplify** — e.g. record
`ceiling − projected` (gain 1.0) instead of the sized figure. It matches the
live answer almost exactly at this cap, and it is the **worst** of the
options. It would be a **shadow-only sizing**: the recorded decision would no
longer be the decision the shipped path reached, while the arbiter, the
clamp, SAFETY-28's gate, the action log and MQTT all ran on the real one. A
second copy of the sizing arithmetic living in the display layer is
MUTATION.md's transcribed-predicate defect on an operator-facing surface, and
it generalises to no other strategy. SAFETY-10's whole safety argument is
that shadow mode is a **substitution at the wire**, not a second controller;
this would have made it a second controller everywhere except the wire.
* **Record nothing where the sizing depends on measured state.** Tempting —
"no number beats a number 6.61× out". Rejected on three counts: it blanks the
row exactly when arbitrage is trading, which is when a commissioning engineer
most wants it; the action log's edge key is the rounded watts, so blanking
them either floods or silences the log; and a surface that flickers between a
number and a dash is harder to read than a number with a standing caveat.
The narrow version of this — dropping the watts only — was the closest
runner-up.
* **Record the unsized ask alongside the sized one.** It adds a number without
adding a warning. The unsized ask is `max_w`, a constant the operator
configured; on the shipped 3500/2000 row both figures are 2000 W, so the pair
is exactly as misleading as the single figure was.
* **A caveat on every cycle in every state.** Rejected as the *whole* answer,
accepted as half of it: the unconditional part is a two-word narrowing of the
verb ("on this cycle"), not a warning, and the warning proper is conditional
so that it still means something when it appears.
---
## 4. SAFETY-10 §6's evidence, and why it could not fail
§6 tabulated `arb_power_w` −2000 W *"identical"*, `arb_reason` *"identical,
verbatim"*, arbiter winner *"identical"*. Two independent reasons it could
never have gone red:
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the guard
never engages and SAFETY-45's sizing is in the run and inert. ⚠️ **The
harness's own comment says so** — *"at this scenario's `peak_cap_w: 15000`
it is not binding anyway"* — which is MUTATION.md habit 4 written down
beside the check it disables;
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0` the
projection *is* the held level, so the two runs agree to the float there
**even at a cap that does bind**.
⚠️ **AND THAT SECOND REASON IS THE ONE A REVIEWER CANNOT SEE FROM A DIFF.**
Widening the fixture alone — the obvious fix — leaves the `[0]` comparison
passing. Measured, not reasoned: at 1500/1200 the first charging cycle is
−300.0000 W in **both** runs while 716 of the 720 cycles differ. Part 3d-2
grades that agreement **as an expectation**, so nobody rebuilds a check on
`[0]` believing it covers something.
This is habit 4's **twelfth** instance in this repository, its **second**
inside a committed evidence artifact, and — like the seventh and the eleventh —
it was in a **measuring instrument** rather than in a check.
§6 is re-taken at `peak_cap_w` 1500 / house 1200 / `max_w` 2000, across the
whole run. Harness `/home/homelab/s47-mut/shadowloop47.py`.
---
## 5. The grader, so this class cannot recur
`test_arbitrage.py` part **3d** — placed beside part 3c, which is SAFETY-45's
own closed-loop regression, and built on the **same** `s45_loop()` harness so
the two runs share every line of shipped code and differ in **one argument**:
```
s45_loop(sized=True) the live hour
s45_loop(sized=True, shadow=True) the same hour, battery held at WIRE_W
s45_loop(sized=False, shadow=True) SAFETY-45's own defect, watched from
inside shadow mode
```
Three lines of `s45_loop()` stand in for `Controller._write_unit()`'s
substitution and `_shadow_settle()`'s record and re-seed. They are marked as a
**transcription**; `test_clamp.py` part 8 is the check on the shipped
placement, and it drives the real `Controller`. Everything that produces a
number — the averager, the projection, the guard, the headroom, the strategy,
the arbiter, the row and the log sentence — is the shipped one.
What 3d grades, in order: that the cap **binds** (the precondition the old
evidence lacked); that the first charging cycle agrees **anyway**; that the
whole run diverges, and by how much; that the record is an **upper bound**, and
the two-point arithmetic that makes it one; what survives of the exemption;
that SAFETY-45's limit cycle and the guard's engagements are **not** visible;
the four rungs of `sized_from_meter()`, each reached with the other three clear
(TEST-16); both operator surfaces; the `capped`-keying trap; and main.py's
three call sites, pinned on the AST.
⚠️ **The section carries its own precondition into every check.** A mutation
that stops either run trading left the lists empty, the first `min()` raised,
the file died before part 4 and the run reported **zero reds — which reads
exactly like a survivor**. That is FCST-06's shape and it was **driven**, not
imagined. `s47check()` now fails every check in the section when the stimulus
was not produced, and the one division in it answers `nan` rather than raising.
---
## 6. The mutation battery — 14 mutations, no survivors
Harness `/home/homelab/s47-mut/run.py`, backups inside 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). Fingerprints are printed before the first
run and re-checked after the last restore.
| # | mutation | file | Δ | reds |
|---|---|---|---|---|
| M1 | the row drops the caveat branch | `shadow.py` | −63 B | 2 |
| M2 | **the trap**: `sized_from_meter()` keyed on the trade being CAPPED — silent where the record is multiples out | `arbitrage.py` | +33 B | 5 |
| M3 | the CHARGE rung dropped — a sell carries a caveat §9.1 makes false | `arbitrage.py` | −30 B | 1 |
| M4 | the measured-headroom rung dropped | `arbitrage.py` | −18 B | 1 |
| M5 | the arbiter-winner rung dropped — a caveat on somebody else's number | `arbitrage.py` | −33 B | 1 |
| M6 | `main.py` renders the row with `sized_from_meter=False` | `main.py` | −23 B | 1 |
| M7 | `main.py` drops `winner=` — the rung exists and is never reached | `main.py` | −1 B | 1 |
| M8 | `main.py` sizes the trade from a literal — SAFETY-45 out of the shipped path, which the hoist put at risk | `main.py` | −41 B | 2 |
| M9 | the headroom assignment moves out of `cycle()` | `main.py` | −39 B | 1 |
| M10 | `sized_from_meter` gains a `False` default (TEST-14) | `shadow.py` | +8 B | 1 |
| M11 | `ON THIS CYCLE` deleted — the row forecasts again | `shadow.py` | −14 B | 1 |
| M12 | `log_line()` drops the caveat — the page discloses, the record does not | `shadow.py` | −63 B | 2 |
| M13 ⚠️ | `main.py` re-assembles the log sentence at the call site | `main.py` | **+0 B** | 1 |
| M14 ⚠️ | `wanted = batt_w − excess` — the headroom stops being monotone in the battery and the row's UPPER BOUND sentence becomes false | `peakguard.py` | **+0 B** | 38 |
**Survivors: none.**
⚠️ **M13 and M14 are `+0 B`, which MUTATION.md names as the risky delta.** Both
were confirmed on disk by `sha256` before the run started — the harness prints
the fingerprint of every graded file and the hash moved for each — and every
run is purged, so neither is at risk of the stale-`.pyc` failure.
⚠️ **Two findings from driving it, both reported rather than tidied away.**
* **M14's first form was aimed at the wrong direction and correctly survived.**
`min` → `max` in the buy leg's sizing makes the shadow record *larger*, which
is still an upper bound, so 3d-3b was right not to fail. The mutation that
actually inverts the bound is the sign of the excess term in `wanted`. A
survivor that is right is worth more than a red aimed at the wrong claim.
* **M12 was a survivor before the fix it forced.** Deleting the caveat from the
action-log line left **all 29 files green**: the only check that could see
that line drives a fixture with `meter_source: off`, where no arbitrage
decision — and therefore no caveat — is reachable. The sentence was an
f-string inside `Controller._shadow_settle()`, i.e. a surface no check could
grade. It is `shadow.log_line()` now, for the reason `words()` is a function,
and M13 is the mutation that puts it back at the call site.