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

17 KiB
Raw Blame History

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.