diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index ebcbf1d..6e47338 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -29,6 +29,84 @@ if it has, it needs 0.10.0. Whoever deploys decides. | _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** | +| _this branch_ | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** | + +### SAFETY-47 — what a shadow run can show you, and the watts on its status row + +⚠️ **Nothing about the write path changed, and nothing needed to.** Shadow +mode still holds every inverter at 0 W through the one writer. What was wrong +is what three merged documents **said** a shadow run can show you, and a number +on the status page that could be **6.61×** what a live controller would +command. + +**A shadow run cannot show you a closed-loop defect. Not partially — not at +all.** The battery is held at 0 W, 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, and a closed-loop defect is one whose +stimulus *is* the plant's response. That is the same property that makes shadow +mode safe, so it is not a gap to be closed later: **the closed-loop answer +comes from the closed-loop rig or from a live run.** + +SAFETY-10 promised otherwise, because of a sentence that was true when it was +written and false one commit later: + +> the **strategies** are unaffected — they 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. + +SAFETY-45 (above) sizes the buy leg to the billed quarter's charging headroom, +and that headroom is `(900 / seconds remaining) × (ceiling − projected)` with +the projection read off the **open quarter's meter**. The strategy became +closed-loop the day that landed. Measured on the rig, one hour at `peak_cap_w` +1500 under a 1200 W house: + +| | live | shadow | +|---|---|---| +| charge onsets | 28 | **1** | +| grid | −455.5 … +3200.0 W | **1200.0 W flat — the house** | +| capacity guard engaged | 410 / 720 cycles | **0 / 720** | +| state of charge | 50.000019 → 51.856789 % | **50.000000 → 50.000000 %** | + +**What the documents now say.** The exemption narrows to exactly what is still +true — **which** hour a strategy picks and **why** are unaffected, because the +clock and the price series are not functions of the battery; **how hard** it +trades is not. Of the three defects shadow mode was written for, one — +TARIFF-14's wrong quarter-hour — is genuinely visible from a shadow run, and it +is graded that way: identical live and shadow on 720 of 720 charging cycles at +a binding cap. + +**What you will see on the status page.** The `Shadow mode` row and the action +log now introduce the watts as what the controller would have commanded **on +this cycle** — in every state, because no number a shadow run records is a +forecast of a live run. And when a **measured** billed quarter sized those +watts, both surfaces carry: + +> ⚠️ 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. + +⚠️ **The number is caveated, not re-computed, and that was the decision.** +Re-computing it to "look right" would mean the recorded decision was no longer +the decision the shipped path reached — while the arbiter, the clamp, the +write-authorisation gate, the action log and MQTT all ran on the real one. A +shadow run's whole worth is that what it records is what the controller +actually decided. `DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md` §3 +records the three alternatives that were rejected and why. + +⚠️ **On a site whose cap does not bind, nothing changes by one watt.** At +`peak_cap_w` 15000 against a 350 W house the recorded figure is the live one +exactly, and no caveat is a lie there either — the row still says the size came +off a meter with no battery in it, which is true and is the point. + +**And SAFETY-10's own committed evidence could never have failed.** Its +comparison table ran at a cap the guard never reaches, *and* read only the +first charging sample — the one instant at which the two runs agree even at a +cap that does bind. Both had to be closed; closing either alone closes nothing. +The table is re-taken across the whole run at a binding cap, and +`test_arbitrage.py` part 3d is the CI-side grader so this class cannot recur. +14 mutations, no survivors; all 29 check files green. ### SAFETY-10 — shadow mode, and what its default does to YOUR install diff --git a/goodwe_controller/DECISIONS/SAFETY-10-shadow-mode.md b/goodwe_controller/DECISIONS/SAFETY-10-shadow-mode.md index a6c2abb..3d83cb1 100644 --- a/goodwe_controller/DECISIONS/SAFETY-10-shadow-mode.md +++ b/goodwe_controller/DECISIONS/SAFETY-10-shadow-mode.md @@ -1,6 +1,12 @@ # SAFETY-10 — shadow mode, and why "default-on" is not `true` **Status:** decided and implemented on branch `SAFETY-10`. +⚠️ **AMENDED BY SAFETY-47, and three of the claims below were false when this +was merged.** §5's "the strategies are unaffected" exemption, §4's peak-guard +row and §6's evidence table are corrected in place and each correction is +marked. The amendments are marked rather than rewritten silently, because what +this card believed is part of why the defect shipped. See +`DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md`. **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 @@ -26,6 +32,12 @@ the CI image and on TEST-23 stage 3's closed-loop rig. 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. +5. ⚠️ **SAFETY-47: and neither is anything else that depends on the plant + answering back. A shadow run cannot surface a closed-loop defect at all**, + by the same construction that makes it safe — the battery is held at 0 W, so + every measured input describes a plant that did not respond. Of the three + defects §"WHAT DEFECT THIS PREVENTS" was written for, exactly one — + TARIFF-14's wrong quarter-hour — is visible from a shadow run. §5, §6. --- @@ -130,9 +142,9 @@ shadow run can report *"it would have hit the rating here"*. | 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 `Shadow mode` | which rung decided, the watts it **would have commanded on this cycle**, where the inverter actually is, and `Resolution.explain()` — plus, when a measured billed quarter sized those watts, SAFETY-47's caveat saying so (`shadow.SIZED_FROM_METER`) | | 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 | +| action log | one line when shadow mode comes on; one line per **change of decision** (winner, rounded watts, or SAFETY-47's caveat state), carrying the arbiter's own sentence and the same caveat the row carries — `shadow.log_line()` is the one sentence both read | | MQTT | `status: shadow`, its own value, below `failsafe` | The action log is **edge**-logged, not level-logged: the log holds 40 rows, and @@ -149,7 +161,7 @@ minutes at the 15 s cycle floor. | 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-07/24/25 peak guard | **OBSERVES, AND SEES ONLY THE HOUSE** | Level-triggered, unlatched, files its claim as usual, so the recorded decision is the real one *for this meter*. ⚠️ **AMENDED BY SAFETY-47.** This row used to end "SAFETY-45's limit cycle is visible from a shadow run, which is the point". It is not: every term of `wanted = batt_w + excess` is measured, and in shadow mode the battery contributes nothing to either. Measured on the rig, same hour, same unsized code — **410 of 720 cycles engaged live, 0 of 720 in shadow** | | 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 | @@ -186,12 +198,47 @@ 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`. +⚠️ **AMENDED BY SAFETY-47 — THE PARAGRAPH THAT STOOD HERE WAS THE DEFECT.** +It read: + +> 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. + +It was true until **SAFETY-45 merged, one commit before this card**. That +change makes the buy leg `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 same quarter. The strategy +is closed-loop, and the loop's one-slew-step limitation above is not a special +case of anything: **it is the general case**, and the limitation is the whole +feature's, not the loop leg's. + +**The correct statement, and it is now graded rather than asserted:** + +> A shadow run cannot surface a **closed-loop** defect — one whose stimulus is +> the plant's response to the controller — because the battery is held at 0 W +> and there is no response to be the stimulus. **Which** hour a strategy picks +> and **why** are unaffected, because the clock and the price series are not +> functions of the battery. **How hard** it trades is not. + +Of the three defects this card was written for: + +| | visible from a shadow run? | 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 live, **0/720** in shadow | +| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. No SoC limit can be *reached* in one | +| TARIFF-14's wrong quarter-hour | **yes**, and it is the one | which hour, at what price, clearing what spread: identical live and shadow on **720 of 720** charging cycles, at a binding cap | + +Graded in `test_arbitrage.py` part **3d** — the same closed loop part 3c uses, +run once live and once in shadow — and the loop leg's own limitation stays +named in `test_watchdog.py` part 10 as `RECORDED LIMITATION`. --- @@ -218,22 +265,60 @@ 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. +⚠️ **SUPERSEDED BY SAFETY-47 — THE TABLE THAT STOOD HERE COULD NEVER HAVE GONE +RED, FOR TWO INDEPENDENT REASONS.** It read `arb_power_w` −2000 W *"identical"*, +`arb_reason` *"identical, verbatim"*, arbiter winner *"identical"*, and +concluded *"the decision happened, at the same instant, for the same reason, at +the same signed power — and the battery did not move."* The battery half is +true and is unchanged. The **decision** half was decoration: + +1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the + capacity 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**. Measured: closing reason (1) alone leaves + that comparison passing. + +Both had to be closed. Re-taken below at **`peak_cap_w` 1500 / house 1200 / +`max_w` 2000**, one hour at a 5 s cycle, **across the whole run** — harness +`/home/homelab/s47-mut/shadowloop47.py`, same rig, same three transcribed +lines. + | | 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** | +| grid | 1224.1 … 1506.0 W — **held up at the ceiling** | **1200.0 W flat = the house, exactly** | +| SoC | 50.000019 → **51.856789 %** | 50.000000 → **50.000000 %** | +| `ARBITRAGE_CHARGE` cycles | 720 / 720 | **720 / 720** | +| first charging cycle, `arb_power_w` | −300.0000 W | **identical — and this is why the old table passed** | +| `arb_power_w`, **whole run** | −307.799 … −299.930 W | **−2000.000 … −300.000 W** | +| …cycles where the two differ | — | **716 of 720** | +| `arb_reason`, whole run | the strategy's own sentence | **differs on 719 of 720** | +| measured charging headroom | 299.93 … 307.80 W | **300.0 … 54 000.0 W** | +| final recorded command | **−302.4 W** | **−2000.0 W — 6.61× the live figure** | +| price / spread / which hour | | **identical on 720 of 720** | | 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. +**What the re-taken table says.** The decision still *happens*, at the same +instant, on the same hour, at the same price, for the same *kind* of reason, +and the battery still does not move — those were the card's real claims and +they hold. What does **not** hold is that the decision is the *same decision*: +its **size** is a function of a meter the battery never moved, and at this cap +the recorded figure is 6.61× what a live controller commands. 54 000 W of +"headroom" under a 1500 W ceiling is `900/remaining` multiplying a gap the +battery never closed. -⚠️ **Three lines of that harness are a TRANSCRIPTION and are marked as one.** +⚠️ **And the SoC row is the second half of it.** 0.000000000 pp of movement is +not a curiosity: it is the proof that **no SoC-limit behaviour is reachable +from a shadow run at all**, which §5 used to promise. + +SAFETY-47 puts the caveat on both surfaces that carry the number and narrows +the three documents' claims; `test_arbitrage.py` part 3d is the CI-side grader +so this class cannot recur. + +⚠️ **Three lines of each 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, diff --git a/goodwe_controller/DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md b/goodwe_controller/DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md new file mode 100644 index 0000000..270732b --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md @@ -0,0 +1,319 @@ +# 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. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 7b3d1b2..9994a32 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -650,8 +650,9 @@ 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 +decided, what it **would have commanded on that cycle** and why — with the +caveat below, where the number was sized from a meter the battery never +moved — 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`. @@ -663,7 +664,12 @@ 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) +clamp would have engaged. ⚠️ **The capacity guard observes a meter with no +battery in it.** Every term of the quantity it thresholds is measured, so it +engages in a shadow run only if the *house alone* would have put the quarter +over the ceiling — 410 engaged cycles live against 0 in shadow, on the hour +tabulated below. Its *ceiling* and its *reasons* are worth reading from a +shadow run; its *engagements* are not evidence of anything. 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. @@ -678,17 +684,85 @@ 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. +⚠️ **What a shadow run will NOT tell you — and it is a whole category, not a +footnote.** 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. Everything below follows from the same fact. + +**A shadow run cannot show you a closed-loop defect. Not partially — not at +all.** The battery is held at 0 W, 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 one whose +stimulus **is** the plant's response, and there is no response to be the +stimulus. This is not a gap waiting to be closed: feeding the guards a +simulated plant instead is exactly what the sign-watchdog decision below +refuses, and for the same reason — a controller reacting to a plant that does +not exist drifts further from reality every tick. **The closed-loop answer +comes from the closed-loop rig or from a live run, and there is no third +place.** + +Concretely, measured on the rig at `peak_cap_w` 1500 under a 1200 W house, +`max_w` 2000, one hour at a 5 s cycle — the same hour run twice, live and in +shadow, on the same code: + +| | live | shadow | +|---|---|---| +| charge onsets in the hour | **28** | **1** | +| grid | **−455.5 … +3200.0 W** | **1200.0 W flat — the house** | +| capacity guard engaged | **410 / 720 cycles** | **0 / 720** | +| state of charge | 50.000019 → 51.856789 % | **50.000000 → 50.000000 %** | + +(The onset and engagement figures are taken with the trade-sizing fix reverted, +because that is the defect being looked for; the SoC and grid figures are the +shipped build.) + +⚠️ **The "strategies are unaffected" exemption that used to stand here was true +until the trade-sizing fix landed, and is now false by hours.** That fix makes +the buy leg `min(asked, headroom)`, where the headroom is +`(900 / seconds remaining) × (ceiling − projected quarter average)` — and the +projection reads the **open quarter's accumulator**, i.e. the meter, i.e. a +function of what the battery did earlier in that same quarter. What survives +of the exemption is exactly this much: + +> **Which** hour a strategy picks, and **why**, are unaffected — the clock and +> the price series are not functions of the battery. Measured: identical live +> and shadow on 720 of 720 charging cycles at a binding cap. **How hard** it +> trades is not. + +⚠️ **So the watts on the `Shadow mode` row are the right answer to a question +nobody asked, and the row now says so.** In a shadow run the billed quarter +really *is* under its ceiling, because the battery really *is* not charging, so +`900 / remaining` multiplies a gap a live run would have closed. On the same +rig, shipped code: + +| 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× | + +Whenever a measured billed quarter sized the number, the row and the action log +carry **⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE +HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W** — and the watts are introduced +as what it would have commanded **on this cycle**, in every state, because no +number a shadow run records is a forecast of a live one. The figure is **not** +re-computed to look right: a shadow-only sizing would mean the recorded +decision was no longer the decision the shipped path reached, which is the one +thing this feature has to be able to say. + +The **grid-following loop** is the same story told first: its recorded decision +is **one slew step** and stays there, because a trajectory depends on a battery +responding and none did. + +**What a shadow run genuinely is for**, then: *which* strategy wins, on *which* +hour, at *which* price, for *which* reason, against which limits — the +wrong-quarter-hour class of defect, 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 this feature covers. #### The sign-disagreement failsafe diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index 97b4555..0f8011a 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -172,7 +172,7 @@ manufactures them from a **fixture**: the check runs, the assertion is evaluated, the sentence above it is true — and the numbers it compares are arranged so that the failure it describes could not have changed the answer. -It is not hypothetical and it is not rare. ELEVEN instances in two weeks; four +It is not hypothetical and it is not rare. TWELVE instances in two weeks; four were found by review, two - CFG-04's fixture pair - by an author's own mutation battery, which is the cheaper place, one by review again, in the one place nobody had thought to point the rule at: not a check in the suite, but a @@ -414,6 +414,44 @@ same place twice, the check is decoration. The general habits are **14** and **15** below. +* ⚠️ **SAFETY-47 IS THE TWELFTH, AND IT IS THE SECOND INSIDE A COMMITTED + EVIDENCE ARTIFACT.** `DECISIONS/SAFETY-10-shadow-mode.md` §6 tabulated a live + run and a shadow run agreeing on `arb_power_w`, on the reason **verbatim** + and on the arbiter's winner, and concluded that shadow mode reaches the same + decision. **Two independent reasons it could not have gone red, and closing + either one alone closes nothing:** + * its fixture is `peak_cap_w: 15000` against a 350 W house, so the capacity + guard never engages and the thing whose invariance is being claimed — + SAFETY-45's sizing of the buy leg from the billed-quarter meter — 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"*. The rule was + written down beside the check it disabled and nobody read it as a + disclosure; + * and every identity row reads `_sa[0]`/`_la[0]` — **the first charging + sample**. At `elapsed = 0` the projection **is** the held level, so the two + runs agree **to the float even at a cap that does bind**. Measured: + widening the fixture alone — the obvious fix — leaves that comparison + passing. + + ⚠️ **THE SECOND REASON IS A NEW SHAPE FOR THIS LIST: AN INDEX, NOT A + FIXTURE.** Ask of every identity claim not only *"could this data differ"* + but *"**at the point I sampled it**, could it differ yet"*. A comparison + taken at `t = 0` between two runs that diverge **because one of them acts** + is a comparison of a plant with itself, and no amount of widening the fixture + changes that. The replacement, `test_arbitrage.py` part 3d, grades the whole + run — 716 of 720 charging cycles differ — and grades the `[0]` agreement **as + an expectation**, so nobody rebuilds a check on it believing it covers + something. + + ⚠️ **AND THE FIX'S OWN FIRST DRAFT CARRIED A THIRTEENTH.** The new part's + action-log check could not reach the log line at all: the sentence was an + f-string inside `Controller._shadow_settle()`, and the only suite that drives + that method uses a `meter_source: off` fixture where no arbitrage decision — + and therefore no caveat — exists. Deleting the caveat from it left **all 29 + files green**. It is `shadow.log_line()` now. *A sentence assembled at a call + site is a sentence no check can grade*, which is CFG-04's substring lesson + arriving from the other direction. + Note what these have in common with a masked rung: the mutation *runs*, and the suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly can see it, because the code under test really did behave identically on the diff --git a/goodwe_controller/app/arbitrage.py b/goodwe_controller/app/arbitrage.py index e5a367a..ff417f1 100644 --- a/goodwe_controller/app/arbitrage.py +++ b/goodwe_controller/app/arbitrage.py @@ -471,6 +471,67 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct, price_now_eur_kwh=price) +def sized_from_meter(decision: ArbitrageDecision, headroom_w, *, + winner: str | None) -> bool: + """SAFETY-47. Was the number a shadow run just recorded SIZED FROM THE + BILLED QUARTER - i.e. from a measurement of what the battery itself did? + + THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason + `peakguard.charge_headroom_w()`, `discharge_permitted()` and + `freeze.looks_frozen()` are: the check that grades it has to assert on THE + SHIPPED EXPRESSION. A predicate transcribed into its own test is one copy + plus a future disagreement about what the check means. + + ------------------------------------------------------------------------ + THE DEFECT IT PREVENTS + + `shadow.words()` and the action log carry "would have commanded N W". In + SHADOW MODE the battery is held at `shadow.WIRE_W`, so the billed quarter + the buy leg was sized against contains THE HOUSE AND NOTHING ELSE - while + a live run's meter rises as the trade charges. The same code, the same + hour, `peak_cap_w` 1500 under a 1200 W house: live commands -302.4 W and a + shadow run records -2000.0 W. 6.61x, on the status row, with no caveat. + This is the flag that puts the caveat there. + + ⚠️ IT IS TRUE WHENEVER A MEASURED HEADROOM ENTERED THE ARITHMETIC, NOT + ONLY WHEN IT BOUND. `_act()` sizes the buy leg as `min(asked, headroom)`, + and "the headroom did not bind" is ITSELF a measured fact about a quarter + the battery never entered. Keying this on `capped` - on whether the + shipped sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" - would answer + True at the START of the quarter, where a shadow run and a live run still + agree to the watt, and False at the END, where `(900/remaining)` has + multiplied the headroom past the ask and the recorded number is at its + most wrong. The quietest caveat exactly where it is needed loudest. + + ⚠️ THE BUY LEG ONLY. §9.1: "Discharge and export are never capped by the + peak guard; only charging and import are." A sell is sized from + `discharge_w`, which is the roster's own rating and not a measurement of + the quarter, so a sell's recorded watts are NOT subject to this and must + not carry a caveat that would then be false. + + ⚠️ AND THE ARBITER'S WINNER IS THE THIRD RUNG, NOT AN AFTERTHOUGHT. A trade + that was sized from the meter and then OUT-RANKED is not the number being + recorded - `Resolution.target_w` belongs to somebody else - and a caveat + filed against somebody else's number is a false sentence on the status row, + which is the defect class this whole card is about. It is in HERE and not + in `main.py` for the reason the two rungs above are: all three answer one + question, a check has to be able to reach each of them with the other two + clear (TEST-16), and a rung that lives at a call site is a rung no check + can drive. + + `headroom_w` is whatever `peakguard.charge_headroom_w()` answered for the + cycle this decision was taken on. ⚠️ None IS "NOTHING MEASURED IT", and + `decide()` REFUSES the trade there (IDLE_HEADROOM_UNMEASURED), so a None + can only ever arrive here beside a non-trading decision - but it is tested + rather than assumed, because the caller is `main.py` and not this module. + + `winner` is `Resolution.winner`: the source that actually set the watts. + """ + return (winner == SOURCE + and decision is not None and decision.action == CHARGE + and headroom_w is not None) + + def claim(decision: ArbitrageDecision) -> Claim | None: """The decision as one arbiter claim, or None when nothing is to be traded. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 858d92a..56acb77 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -842,6 +842,18 @@ class Controller: # how a shadow run starts telling an operator a number nothing is # doing. self.shadow_would_w = None + # SAFETY-47. Was the number above SIZED FROM THE BILLED-QUARTER METER + # on the cycle it was recorded? ⚠️ IT IS RECORDED WHERE THE DECISION + # WAS TAKEN, NOT RE-DERIVED WHERE THE PAGE IS RENDERED. The status page + # runs on its own task and `self.peak_guard` has moved on by then, so a + # page that asked the question again would be answering it about a + # different cycle from the one whose watts it is displaying - which is + # the same two-facts-one-field defect the comment above this one is + # about. ⚠️ False AND NOT None BEFORE THE FIRST SHADOW CYCLE, unlike + # the field above: this one is a claim ABOUT a number, and there is no + # number yet, so the honest value is "nothing to disclose". The row's + # `would_w is None` branch renders no watts at all there. + self.shadow_sized_from_meter = False # 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 @@ -923,6 +935,12 @@ class Controller: # None (startup) or the PREVIOUS cycle's decision (an input hold), and # the status row rendered all three identically. self.arb_at = None + # SAFETY-45/47. The billed-quarter charging headroom the arbitrage step + # was handed on the cycle above, or None when nothing measured it. It + # is CARRIED rather than recomputed for `arbitrage.sized_from_meter()`, + # because `self.peak_guard` is a different cycle's decision by the time + # the status page renders. + self.arb_headroom_w = None # TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at` # is the exact, derived statement of "the latest cycle did not get as # far as the arbitrage step", which is the fact the *Arbitrage* row @@ -2818,9 +2836,16 @@ class Controller: # `arb_at` is then the age the status row reports rather than a # decision presented as current. self.arb, self.arb_at = None, now + self.arb_headroom_w = None if arbitrage.enabled(self.o): floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct) + # SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. One evaluation of + # the shipped expression feeds both the sizing below and + # `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the + # caveat on the recorded watts can never be answered from a + # different cycle's guard than the watts were. + self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard) self.arb = arbitrage.decide( self.fresh_tariff_plan(now), now, peak_guard_active=arbitrage.guard_state(self.peak_guard), @@ -2839,7 +2864,7 @@ class Controller: # peak block's conclusion, ~50 lines up, so it must stay after # it. Evaluated earlier it would size this quarter's trade from # the previous cycle's quarter. - charge_headroom_w=peakguard.charge_headroom_w(self.peak_guard), + charge_headroom_w=self.arb_headroom_w, # The emptiest pack again, and for the sell side that is the # right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there # is one `soc` argument - `pack_floor_soc` names that residual @@ -3424,20 +3449,37 @@ class Controller: "%+.0f W that was standing on the wire.", shadow.WIRE_W) if not engaged: self.shadow_would_w = None + self.shadow_sized_from_meter = False self._shadow_winner = None return self.shadow_would_w = float(resolution.target_w) + # SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. All three rungs - the + # arbiter's winner, the leg being a CHARGE, and the headroom having + # been MEASURED - live inside `arbitrage.sized_from_meter()`, which + # says why, including why it is NOT keyed on whether the trade was + # capped. A rung written at a call site is a rung no check can reach + # with the other two clear, which is TEST-16's rule. + self.shadow_sized_from_meter = arbitrage.sized_from_meter( + self.arb, self.arb_headroom_w, winner=resolution.winner) # ⚠️ 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))) + # ⚠️ SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE. The same winner at the + # same watts with and without the caveat are two different claims about + # the same number, and an edge key blind to that would print the first + # one and silently keep it standing. It cannot flood: the flag only + # moves when arbitrage starts or stops owning the resolution, or when + # the guard starts or stops measuring the quarter, and both of those + # move the winner or the watts as well on every cycle measured. + edge = (resolution.winner, round(float(resolution.target_w)), + self.shadow_sized_from_meter) 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.log_event(shadow.log_line( + would_w=resolution.target_w, reason=resolution.explain(), + sized_from_meter=self.shadow_sized_from_meter)) self.target = shadow.WIRE_W for inv in self.inverters: inv.target = shadow.WIRE_W @@ -4865,7 +4907,12 @@ class Controller: # 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)), + would_w=self.shadow_would_w, + # SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE + # - the page runs on its own task and `self.peak_guard` has + # moved on. See `Controller.__init__`. + sized_from_meter=self.shadow_sized_from_meter, + 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 index 1172382..7cd020f 100644 --- a/goodwe_controller/app/shadow.py +++ b/goodwe_controller/app/shadow.py @@ -19,6 +19,83 @@ 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. +⚠️ AND OF THOSE THREE, A SHADOW RUN CAN SHOW YOU EXACTLY ONE - SEE THE NEXT + SECTION. This header used to name SAFETY-45's limit cycle as the example + of what shadow mode surfaces. That was false, and SAFETY-47 measured it. + +⚠️ WHAT A SHADOW RUN CAN AND CANNOT SHOW (SAFETY-47) +===================================================== +SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT. Not badly, not partially - +NOT AT ALL, and by the same construction that makes it safe. The actuator is +held at WIRE_W and the carried command is re-seeded to WIRE_W, so every +MEASURED input - the meter, the 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, no defect to see. + +That is not a gap to be closed later. Feeding the guards a simulated plant +instead is what SAFETY-10 §4 refuses for the sign watchdogs, for a reason that +applies to all of this: a controller reacting to a plant that does not exist +drifts further from reality every tick, and three cycles in its numbers +describe nothing. THE CLOSED-LOOP ANSWER COMES FROM A CLOSED-LOOP RIG +(TEST-23 stage 3) OR FROM A LIVE RUN. There is no third place. + +So, of the three defects above, measured on that rig at `peak_cap_w` 1500 / +house 1200 / `max_w` 2000, one hour at a 5 s cycle: + + * SAFETY-45's limit cycle NOT VISIBLE. Live: 28 charge onsets, the grid + swinging -455.5 .. +3200.0 W, the guard engaged + on 410 of 720 cycles. Shadow, same hour, same + code: 1 onset, the grid flat at the 1200 W + house, the guard engaged on 0 of 720. Every + term of `wanted = batt_w + excess` is measured. + * SAFETY-46's SoC churn NOT VISIBLE. The SoC moves 0.000000000 pp in a + shadow run, because it is the pack's integral of + what the pack was commanded and the pack was + commanded 0 W. No SoC limit can be REACHED in a + shadow run; the ones already crossed when it + started are reported, and that is all. + * TARIFF-14's wrong quarter VISIBLE, and it is the one. The price lookup's + inputs are the CLOCK and the PRICE SERIES, and + neither is a function of what the battery did. + Measured: which hour the strategy picked, its + price and the spread it cleared are identical + live and shadow on 720 of 720 charging cycles, + AT A BINDING CAP. + +⚠️ THE EXEMPTION THAT USED TO BE HERE WAS TRUE UNTIL SAFETY-45 MERGED. This +module, DOCS.md and DECISIONS/SAFETY-10-shadow-mode.md all said "the strategies +are unaffected - they ask for a fixed power from inputs that do not depend on +what the battery did". SAFETY-45 made the buy leg `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 same quarter. The strategy +became closed-loop the day that landed. What survives of the exemption, and +only this: + + WHICH hour a strategy picks, and WHY, are unaffected. + HOW HARD it trades is not. + +⚠️ AND THE SIZE A SHADOW RUN RECORDS IS THE RIGHT ANSWER TO A QUESTION NOBODY + ASKED. `900/remaining` is correct arithmetic for a meter that is genuinely +under the ceiling - and in shadow the meter genuinely IS under it, because the +battery is not charging. So the number is not a bug; it is an answer computed +for a plant that did not exist. Measured on the same rig, shipped code: + + cap / house live commanded shadow recorded over + 1500 / 1200 -302.4 W -2000.0 W 6.61x + 3500 / 3000 -502.6 W -2000.0 W 3.98x + 3500 / 2000 -1502.6 W -2000.0 W 1.33x + 3500 / 1800 -1702.6 W -2000.0 W 1.17x + 15000 / 350 -2000.0 W -2000.0 W 1.00x + +The last row is the fixture SAFETY-10's own evidence was taken on, which is why +nothing reported this. `words()` carries the caveat rather than a re-computed +number, and `DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md` §3 records the +three alternatives that were rejected and why. + ⚠️ "REACHES NOTHING" IS NOT "WRITES NOTHING", AND THE DIFFERENCE IS THE WHOLE SAFETY ARGUMENT ======================================================================= @@ -138,11 +215,18 @@ THE FAILSAFES, ONE BY ONE (the card asks for each to be decided and recorded) | | 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-07/24/25 peak guard | OBSERVES, AND SEES ONLY THE HOUSE. It is | +| | level-triggered and unlatched and it files| +| | its claim as usual, so the recorded | +| | decision is the real one FOR THIS METER - | +| | but every term of `wanted = batt_w + | +| | excess` is measured, and in shadow mode | +| | the battery contributes nothing to either.| +| | ⚠️ SAFETY-47: this row used to claim | +| | SAFETY-45's limit cycle was visible from a| +| | shadow run. It is not - 410/720 engaged | +| | cycles live against 0/720 in shadow, on | +| | the same hour and the same code. | | SAFETY-31 circulation | OBSERVES AND MAY LATCH, legitimately. Its | | SAFETY-42 delivery | evidence is MEASURED power, not a | | | command: packs pushing against each other | @@ -215,6 +299,29 @@ MODES = ("auto", "on", "off") #: commanding it so the inverter cannot be left latched on anything else. WIRE_W = 0.0 +#: SAFETY-47. What the recorded watts mean when a MEASURED billed quarter sized +#: them. +#: +#: ⚠️ "UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED RATHER +#: THAN ARGUED. Charging only ADDS offtake, so a live meter's `projected` is +#: never below a shadow meter's and the headroom is never above it: the shadow +#: size is >= the live size, 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 IT NAMES NO FACTOR. How far under the bound a live run lands is a +#: property of the site - 6.61x at a 1500 W cap under a 1200 W house, 1.00x at +#: a cap that does not bind - and a number invented for the row would be a +#: second helping of the defect this constant exists to disclose. +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") + def mode(opts: dict) -> str: """The configured `shadow_mode`, normalised to one of `MODES`. @@ -279,7 +386,7 @@ def why(opts: dict, *, control_started: bool) -> str: def words(opts: dict, *, control_started: bool, would_w: float | None, - reason: str = "") -> str: + sized_from_meter: bool, reason: str = "") -> str: """The status page's `Shadow mode` row. ⚠️ THE ROW IS RENDERED IN EVERY STATE, INCLUDING `off`. A surface whose @@ -288,10 +395,35 @@ def words(opts: dict, *, control_started: bool, would_w: float | None, 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. + controller would have put on the wire GIVEN THIS METER AND A BATTERY AT + 0 W - 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. + + ⚠️ `sized_from_meter` HAS NO DEFAULT, AND THAT IS SAFETY-47's WHOLE POINT. + It is True when a MEASURED billed-quarter headroom sized the number being + recorded - `arbitrage.sized_from_meter()` answers it, and it is the one + place the question is asked. A default of False here would be a caller + that forgot the caveat rendering a row that reads exactly like a caller + that decided it did not apply, which is the shape of defect this card is + about. TEST-14's rule with a new name on it. + + ⚠️ AND THE PHRASE IS "ON THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE + CAVEAT FIRES. Every number a shadow run records was reached from a plant + that did not respond; "WOULD HAVE COMMANDED -2 000 W" reads as a forecast + of a live run and no number here is one. The ⚠️ below is the STRONGER, + conditional statement about the cycles where the answer can be multiples + out - see this module's header for the measured table. + + ⚠️ AND IT 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 - which in a shadow run is the START of the + quarter, where the two runs still agree. By the end of the quarter + `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 that sentence would be quietest exactly where it + is needed loudest. `test_arbitrage.py` part 3d-8 drives that. """ head = why(opts, control_started=control_started) if not engaged(opts, control_started=control_started): @@ -304,13 +436,38 @@ def words(opts: dict, *, control_started: bool, would_w: float | None, # 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") + out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter " + f"held at {WIRE_W:.0f} W") + if sized_from_meter: + out += f" {SIZED_FROM_METER}" if reason: out += f" - {reason}" return out +def log_line(*, would_w: float, reason: str, sized_from_meter: bool) -> str: + """The action-log line for one change of shadow decision. + + ⚠️ IT IS A FUNCTION FOR THE REASON `words()` IS ONE, AND SAFETY-47 LEARNED + IT THE EXPENSIVE WAY. This sentence used to be an f-string inside + `Controller._shadow_settle()`. Deleting the caveat from it there left all + 29 files GREEN - the only check that could see the line drove a fixture + with `meter_source: off`, where no arbitrage decision and therefore no + caveat can ever be reached. A sentence assembled at a call site is a + sentence no check can grade, and the page would have said one thing while + the record a later analysis reads said another. + + ⚠️ AND IT IS THE SAME `SIZED_FROM_METER` CONSTANT THE ROW CARRIES, not a + second wording of it. Two sentences meaning one thing is how the two + surfaces start disagreeing about which cycles are caveated. + """ + out = (f"SHADOW: would have commanded {float(would_w):+.0f} W ON THIS " + f"CYCLE - {reason}") + if sized_from_meter: + out += f" {SIZED_FROM_METER}" + 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. diff --git a/goodwe_controller/test_arbitrage.py b/goodwe_controller/test_arbitrage.py index 5b40b25..737ef3d 100644 --- a/goodwe_controller/test_arbitrage.py +++ b/goodwe_controller/test_arbitrage.py @@ -94,6 +94,8 @@ from app.arbitrage import ( CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds, threshold_eur_kwh, ) +# SAFETY-47 section 3d. The shipped shadow-mode row, not a transcription of it. +from app import shadow as _shadow from app.price import ( PRICE_OPTION_DEFAULTS, Absence, PricePoint, PriceProduct, PriceRead, Uncertainty, WIDEN_FACTOR, brussels, required_spread, widen_factor, @@ -890,18 +892,61 @@ check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export " # caller fills is a TypeError only on the one path a suite does not run. Same # pin, same reason, as `weights=` in test_packshare.py part 10. _S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read() -_s45_calls = [n for n in ast.walk(ast.parse(_S45_MAIN)) +_S45_MAIN_TREE = ast.parse(_S45_MAIN) +_s45_calls = [n for n in ast.walk(_S45_MAIN_TREE) if isinstance(n, ast.Call) and ast.unparse(n.func).endswith("arbitrage.decide")] _s45_arg = [ast.unparse(k.value) for c in _s45_calls for k in c.keywords if k.arg == "charge_headroom_w"] + + +def _s45_assigned(name, clear=("None",)): + """Every expression main.py assigns to `self.` that is not a CLEAR. + + ⚠️ SAFETY-47 HOISTED THE HEADROOM OUT OF THE CALL, so pinning the call + argument alone stopped covering anything: the literal a mutation would put + there now goes one line up instead. Both ends are pinned. + + ⚠️ `clear` NAMES THE CONSTANTS THAT MEAN "NOTHING HAS ANSWERED THIS YET" - + `__init__`'s value and the per-cycle reset - and they are not a second + source for the number. Excluding them does NOT soften the pin: a mutation + that replaces the real assignment with one of those constants leaves this + list EMPTY, which matches no expected value and is red. + """ + return [ast.unparse(n.value) for n in ast.walk(_S45_MAIN_TREE) + if isinstance(n, ast.Assign) + and [ast.unparse(t) for t in n.targets] == [f"self.{name}"] + and ast.unparse(n.value) not in clear] + + +_s45_head_src = _s45_assigned("arb_headroom_w") print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); " - f"charge_headroom_w= is {_s45_arg}") + f"charge_headroom_w= is {_s45_arg}, and self.arb_headroom_w is assigned " + f"from {_s45_head_src}") check("main.py passes the guard's OWN measured headroom at its one " "arbitrage.decide() call site - the shipped expression, not a " "transcription of it, so a mutation back to a literal or to max_w is red " "rather than silent", - _s45_arg == ["peakguard.charge_headroom_w(self.peak_guard)"]) + _s45_arg == ["self.arb_headroom_w"] + and _s45_head_src == ["peakguard.charge_headroom_w(self.peak_guard)"]) +# ⚠️ AND THE HOIST DID NOT MOVE IT OUT OF THE CYCLE. An `arb_headroom_w` set +# once in `__init__` and never refreshed would satisfy the pin above and would +# size every trade from the guard decision of whatever cycle happened to run +# last - which is the defect the hoist's own comment warns about, one level up. +_s45_cycle = next(n for n in ast.walk(_S45_MAIN_TREE) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) + and n.name == "cycle" + and any(isinstance(c, ast.Call) + and ast.unparse(c.func).endswith("arbitrage.decide") + for c in ast.walk(n))) +check("...and the assignment is INSIDE the same `cycle()` that makes the call, " + "so the headroom the trade is sized from is this cycle's guard decision " + "and not one carried from an arbitrary earlier one", + any(isinstance(n, ast.Assign) + and [ast.unparse(t) for t in n.targets] == ["self.arb_headroom_w"] + and ast.unparse(n.value) + == "peakguard.charge_headroom_w(self.peak_guard)" + for n in ast.walk(_s45_cycle))) # --------------------------------------------------------------------------- # @@ -946,14 +991,26 @@ S45_T0 = D0 + timedelta(hours=CHEAP_H) def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W, - seconds=S45_RUN_S): + seconds=S45_RUN_S, shadow=False): """One closed hour. `sized=False` is the behaviour this card removes. ⚠️ THE COUNTERFACTUAL IS ONE ARGUMENT, NOT A SECOND CODE PATH. `sized=False` hands `decide()` a headroom nothing can bind - which is exactly what the tree did before this card, because nothing was passing one - so the two runs below differ in one value and share every line of shipped code. Habit 7. + + ⚠️ SAFETY-47's `shadow=True` IS THE SAME DISCIPLINE, ONE ARGUMENT AGAIN. It + reproduces the THREE things `Controller._write_unit()` and + `_shadow_settle()` do and nothing else: the resolved command is RECORDED, + the plant is driven from `shadow.WIRE_W` instead of from it, and the + carried command is RE-SEEDED to `shadow.WIRE_W` after the record is taken. + Those three lines are a TRANSCRIPTION of main.py's placement and are marked + as one - `test_clamp.py` part 8 is the check on the shipped placement, and + it drives the real `Controller`. What is NOT transcribed is every number + that matters here: the averager, the projection, the guard, the headroom, + the strategy, the arbiter and the row's own words are all the shipped ones. """ + avg = QuarterAverager(phases=1, max_hold_s=900.0) tuning = Tuning(gain=0.6, max_w=MAX_W, slew_w=0.0) t0 = S45_T0 @@ -962,6 +1019,7 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W, loop_w = None batt_cmd = batt = 0.0 # + discharge, as main.py's site_batt_w onsets = blocks = 0 + rec = [] prev_action = "" grid_lo, grid_hi = None, None billed = [] @@ -1008,7 +1066,32 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W, claims.append(Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W, "max_w")) claims.append(Claim.set("loop", P_LOOP, d.target_w, d.reason)) - target = resolve(claims).target_w + res = resolve(claims) + target = res.target_w + head_w = _pg.charge_headroom_w(pk) if sized else HEADROOM_CLEAR + rec.append(dict( + t_s=t, action=arb.action, power_w=arb.power_w, + reason=arb.reason, price=arb.price_now_eur_kwh, + spread=arb.spread_eur_kwh, winner=res.winner, + target_w=float(res.target_w), headroom_w=head_w, + engaged=bool(pk.engaged), + sized_from_meter=_arb.sized_from_meter( + arb, head_w, winner=res.winner))) + if shadow: + # --- TRANSCRIBED 1/3: `_shadow_settle()`'s record, taken + # BEFORE the re-seed destroys its evidence. + rec[-1]["would_w"] = float(res.target_w) + rec[-1]["row"] = _shadow.words( + {"shadow_mode": "on"}, control_started=False, + would_w=float(res.target_w), + sized_from_meter=rec[-1]["sized_from_meter"], + reason=res.explain()) + # --- TRANSCRIBED 2/3: `_write_unit()`'s substitution - the + # plant below is driven from WIRE_W, not from the decision. + # --- TRANSCRIBED 3/3: `_shadow_settle()`'s re-seed of the + # carried command, which is what `compute(prev_w=...)`, + # `standing_w()` and SAFETY-42 read next cycle. + target = _shadow.WIRE_W # THE PLANT, MODELLED: an 800 W/s ramp into a 10 s first-order lag. step = min(1.0, acc) delta = target - batt_cmd @@ -1020,7 +1103,9 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W, grid_hi = grid if grid_hi is None else max(grid_hi, grid) return dict(onsets=onsets, grid_lo=round(grid_lo, 1), grid_hi=round(grid_hi, 1), swing=round(grid_hi - grid_lo, 1), - billed=billed, blocks=blocks) + billed=billed, blocks=blocks, rec=rec, + engaged=sum(1 for r in rec if r["engaged"]), + cycles=len(rec)) S45_BEFORE = derived(lambda: s45_loop(sized=False)) S45_AFTER = derived(lambda: s45_loop(sized=True)) @@ -1076,11 +1161,440 @@ S45_WIDE_S = derived(lambda: s45_loop(sized=True, cap_w=5000.0, load_w=1200.0, print(f" peak_cap_w 5000 W, 1200 W house (load + max_w = " f"{S45_LOAD_W + MAX_W:.0f} W, under the cap): unsized " f"{S45_WIDE['billed']} W, sized {S45_WIDE_S['billed']} W") + + + +def _s45_outcome(r): + """Everything the two runs put on the PLANT and the METER. + + ⚠️ `rec` IS EXCLUDED AND THE EXCLUSION IS NAMED. SAFETY-47 added a + per-cycle record to this harness, and it carries `headroom_w` and + `sized_from_meter` - which are THE ONE ARGUMENT the two runs differ in, by + construction. Comparing them would make this check fail for the reason it + exists to rule out. The resolved command per cycle IS compared, below, and + that is a stronger statement than the six aggregates ever were. + """ + return {k: v for k, v in r.items() if k != "rec"} + + check("on a site where the trade cannot reach the cap the two runs are " "IDENTICAL to the watt - so this card costs nothing on the installs it " "is not about, and the check above is not passing because the cap " "dominates everything", - S45_WIDE == S45_WIDE_S and S45_WIDE["onsets"] == 1) + _s45_outcome(S45_WIDE) == _s45_outcome(S45_WIDE_S) + and S45_WIDE["onsets"] == 1) +check("...and identical CYCLE BY CYCLE, not merely in the six aggregates: " + "every resolved command and every traded size matches to the float", + [(r["target_w"], r["power_w"]) for r in S45_WIDE["rec"]] + == [(r["target_w"], r["power_w"]) for r in S45_WIDE_S["rec"]]) + + +# --------------------------------------------------------------------------- # +print() +print("3d. SAFETY-47 - the SHADOW record against a LIVE run, at a BINDING cap") +# --------------------------------------------------------------------------- +# WHAT THIS PART EXISTS FOR, AND IT IS NOT A SECOND SAFETY-45 CHECK. +# +# SAFETY-10 shipped shadow mode saying, in three merged documents, that "the +# strategies are unaffected - they 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". That was +# true until 3c's own fix merged. SAFETY-45 makes the buy leg +# `min(asked, headroom)`, and the headroom is +# +# (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. The strategy +# became closed-loop the day 3c landed, and shadow mode holds the battery at +# 0 W, so in a shadow run that accumulator contains THE HOUSE AND NOTHING ELSE. +# +# ⚠️ AND SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED - TWO +# INDEPENDENT REASONS, AND CLOSING EITHER ONE ALONE CLOSES NOTHING. +# (1) its fixture is `peak_cap_w: 15000` against a 350 W house, so the guard +# never engages and the sizing is inert - MUTATION.md habit 4, inside a +# merged card's own proof; +# (2) every identity row reads THE FIRST CHARGING SAMPLE, and at `elapsed=0` +# the projection IS the held level, so the two runs agree to the float +# there even AT a binding cap. 3d-2 drives that and says so. +# +# So this part is the grader for the class: a shadow run and a live run, the +# same hour, the same code, at a cap that BINDS. What is REAL and what is +# TRANSCRIBED is in `s45_loop()`'s own docstring - the three lines that stand +# in for `Controller._write_unit()` and `_shadow_settle()` are named there, and +# `test_clamp.py` part 8 is the check on the shipped placement. +# --------------------------------------------------------------------------- +S47_LIVE = derived(lambda: s45_loop(sized=True)) +S47_SHAD = derived(lambda: s45_loop(sized=True, shadow=True)) +# The SAFETY-45 defect itself, watched from inside shadow mode - which is what +# SAFETY-10's header claimed a shadow run would surface. +S47_UNSIZED_SHAD = derived(lambda: s45_loop(sized=False, shadow=True)) + +_s47_lc = derived(lambda: [r for r in S47_LIVE["rec"] if r["action"] == CHARGE]) +_s47_sc = derived(lambda: [r for r in S47_SHAD["rec"] if r["action"] == CHARGE]) + +# ⚠️ THE SECTION CARRIES ITS OWN PRECONDITION INTO EVERY CHECK, AND THIS IS +# FCST-06's SHAPE RATHER THAN TIDINESS. A mutation that stops either run +# trading at all leaves these lists EMPTY; the first `min()` below then raises, +# the file dies before part 4, and the run reports ZERO reds - which reads +# exactly like a survivor. Measured: driving `wanted = batt_w - ex` through +# this section did precisely that. So the lists are replaced by a sentinel that +# cannot satisfy anything, and `s47check()` fails every check in the section +# when the stimulus was not produced. +_S47_DEAD = dict(action=None, power_w=0.0, reason="", + price=None, spread=None, winner=None, target_w=0.0, + headroom_w=0.0, engaged=False, sized_from_meter=False, + would_w=0.0, row="", t_s=0.0) +_S47_OK = bool(_s47_lc) and bool(_s47_sc) +if not _S47_OK: + _s47_lc = _s47_sc = [dict(_S47_DEAD)] +_s47_n = min(len(_s47_lc), len(_s47_sc)) + + +def s47check(name, cond): + """A 3d check, AND the section's own precondition, in one verdict.""" + check(name, _S47_OK and bool(cond)) + + +check("PRECONDITION: both runs actually reached a charging decision - without " + "this the section raises on its first `min()`, the file dies before " + "part 4 and the run reports ZERO reds, which reads exactly like a " + "survivor (MUTATION.md, FCST-06's shape). Measured, not imagined", + _S47_OK) +print(f" live : {len(_s47_lc)} charging cycles, traded " + f"{min(r['power_w'] for r in _s47_lc):+.1f} .. " + f"{max(r['power_w'] for r in _s47_lc):+.1f} W, " + f"headroom {min(r['headroom_w'] for r in _s47_lc):.1f} .. " + f"{max(r['headroom_w'] for r in _s47_lc):.1f} W, " + f"grid {S47_LIVE['grid_lo']:.0f} .. {S47_LIVE['grid_hi']:.0f} W") +print(f" shadow: {len(_s47_sc)} charging cycles, RECORDED " + f"{min(r['power_w'] for r in _s47_sc):+.1f} .. " + f"{max(r['power_w'] for r in _s47_sc):+.1f} W, " + f"headroom {min(r['headroom_w'] for r in _s47_sc):.1f} .. " + f"{max(r['headroom_w'] for r in _s47_sc):.1f} W, " + f"grid {S47_SHAD['grid_lo']:.0f} .. {S47_SHAD['grid_hi']:.0f} W") + +# --- 3d-1. THE PRECONDITION: the cap binds, and the meter proves it -------- +# ⚠️ WITHOUT THIS EVERY CHECK BELOW IS SAFETY-10 §6's DEFECT IN A NEW FILE. At +# a cap the trade cannot reach, live and shadow agree exactly and nothing here +# could ever go red - which is precisely what the committed evidence did. +s47check("PRECONDITION: the cap BINDS - the live run's trade is sized by the " + f"guard on every charging cycle, never reaching the {MAX_W:.0f} W ask", + _s47_lc and all(abs(r["power_w"]) < MAX_W - 1.0 for r in _s47_lc)) +s47check("...and the live METER is what closed the gap: it is held up at the " + f"{S45_CAP_W:.0f} W ceiling rather than sitting at the " + f"{S45_LOAD_W:.0f} W house, which is the feedback a shadow run does not " + f"have", + S47_LIVE["grid_hi"] > S45_LOAD_W + 200.0) +s47check("...and the SHADOW meter sees the house and nothing else for the whole " + "run, which is what makes its billed quarter a different quarter", + near(S47_SHAD["grid_lo"], S45_LOAD_W, 0.001) + and near(S47_SHAD["grid_hi"], S45_LOAD_W, 0.001)) + +# --- 3d-2. WHY THE OLD SHAPE COULD NOT FAIL -------------------------------- +# Graded as an EXPECTATION rather than left as a footnote, so that nobody +# rebuilds a check on `[0]` and believes it covers something. +print(f" first charging cycle: live {_s47_lc[0]['power_w']:+.4f} W, " + f"shadow {_s47_sc[0]['power_w']:+.4f} W") +s47check("⚠️ THE FIRST CHARGING CYCLE IS IDENTICAL IN BOTH RUNS EVEN HERE, so " + "widening SAFETY-10 §6's fixture ALONE would not have made its table go " + "red: at elapsed=0 the projection IS the held level and neither plant " + "has moved yet. Both reasons had to be closed, and this is the one a " + "reviewer cannot see from a diff", + _s47_lc[0]["power_w"] == _s47_sc[0]["power_w"] + and _s47_lc[0]["reason"] == _s47_sc[0]["reason"]) + +# --- 3d-3. AND ACROSS THE WHOLE RUN THEY DIVERGE --------------------------- +_s47_mis = sum(1 for i in range(_s47_n) + if _s47_lc[i]["power_w"] != _s47_sc[i]["power_w"]) +_s47_worst = min(_s47_sc, key=lambda r: r["power_w"]) +_s47_live_worst = min(_s47_lc, key=lambda r: r["power_w"]) +# ⚠️ NaN AND NOT AN EXCEPTION when the sentinel above is in play: `nan > 2.0` +# is False, so the check goes RED, while a ZeroDivisionError here would kill +# the file and report zero reds. Same defect, one line further down - it was +# driven, and it is why this is not a plain division. +_s47_over = (abs(_s47_worst["power_w"]) / abs(_s47_live_worst["power_w"]) + if _s47_live_worst["power_w"] else float("nan")) +print(f" size mismatches {_s47_mis}/{_s47_n} charging cycles; the shadow " + f"run records {_s47_worst['power_w']:+.0f} W where the live run's " + f"hardest trade is {_s47_live_worst['power_w']:+.1f} W - " + f"{_s47_over:.2f}x") +s47check("ACROSS THE WHOLE RUN the recorded trade size diverges on all but the " + "first few cycles - the check SAFETY-10 §6 should have been", + _s47_mis > 0.9 * _s47_n) +s47check(f"...and the shadow run RECORDS a trade the live run never commanded, " + f"over by {_s47_over:.2f}x. `900/remaining` amplifies a " + f"(ceiling - projected) gap the battery never closed, and the row " + f"carried that number to a commissioning engineer with no caveat", + _s47_over > 2.0) +s47check("...and the amplification is IN THE HEADROOM, which is where the " + f"measurement enters: the shadow guard offers up to " + f"{max(r['headroom_w'] for r in _s47_sc):.0f} W of charging headroom " + f"against a {S45_CAP_W:.0f} W ceiling and a {S45_LOAD_W:.0f} W house, " + f"while the live guard never exceeds " + f"{max(r['headroom_w'] for r in _s47_lc):.0f} W", + max(r["headroom_w"] for r in _s47_sc) > MAX_W + and max(r["headroom_w"] for r in _s47_lc) < MAX_W) + +# --- 3d-3b. THE SENTENCE THE ROW PRINTS, GRADED AS ARITHMETIC -------------- +# ⚠️ THE ROW CLAIMS THE RECORDED NUMBER IS AN **UPPER BOUND**, and a claim on +# an operator-facing surface is a claim this file has to be able to fail. +# Charging only ADDS offtake, so a live `projected` is never below a shadow +# one and the headroom is never above it. The earlier wording said "smaller", +# which is FALSE on every cycle where the two are equal - the start of each +# quarter, where `900/remaining` is 1.0 and neither plant has moved. +_s47_below = [i for i in range(_s47_n) + if abs(_s47_sc[i]["power_w"]) < abs(_s47_lc[i]["power_w"]) - 1e-9] +_s47_equal = sum(1 for i in range(_s47_n) + if abs(_s47_sc[i]["power_w"]) == abs(_s47_lc[i]["power_w"])) +print(f" the shadow record is below the live command on " + f"{len(_s47_below)}/{_s47_n} cycles and exactly equal on {_s47_equal}") +s47check("the recorded size is an UPPER BOUND on the live one, on every compared " + "cycle - which is the sentence `shadow.SIZED_FROM_METER` puts on the " + "status row, so the row is making a claim this file can fail", + not _s47_below) +s47check("...and it is NOT strictly greater: the two are equal at the start of " + f"each quarter ({_s47_equal} cycles here), which is why the row says " + f"'upper bound' and not 'smaller' - the second would be a false " + f"sentence on exactly those cycles", + _s47_equal > 0) + +# ⚠️ AND THE MECHANISM BEHIND THE BOUND, AS TWO POINTS OF THE SHIPPED +# ARITHMETIC RATHER THAN AS ONE PROPERTY OF A WHOLE RUN. Same ceiling, same +# elapsed, same house: one quarter that carried the battery's charge at the +# meter (LIVE) and one that did not (SHADOW). The closed-loop version above +# cannot be driven red by any mutation that ALSO stops the fixture trading - +# measured - so the relation it rests on is graded directly here. +def _s47_headroom(*, meter_w, batt_w, elapsed_s=450.0): + """`charge_headroom_w()` for a quarter that banked `meter_w` for + `elapsed_s`, with `batt_w` measured at the pack. All shipped.""" + avg = QuarterAverager(phases=1, max_hold_s=900.0) + t = 0.0 + while t <= elapsed_s: + imp, exp = split_signed(meter_w) + avg.add(make_sample("ha_signed", imp, exp, phases=1, + ingest_ts=S45_T0 + timedelta(seconds=t), + ingest_mono=(S45_T0.timestamp() + t))) + t += 5.0 + pk = _pg.decide( + PeakTracker.project(avg, S45_T0 + timedelta(seconds=elapsed_s)), + batt_w=batt_w, cap_w=S45_CAP_W, reading=None, achievable_w=MAX_W, + soc=50.0, soc_floor=20.0, max_hold_s=900.0, frozen=False) + return _pg.charge_headroom_w(pk) + + +_S47_TRADE_W = 300.0 +_s47_h_shadow = derived(lambda: _s47_headroom(meter_w=S45_LOAD_W, batt_w=0.0)) +_s47_h_live = derived(lambda: _s47_headroom( + meter_w=S45_LOAD_W + _S47_TRADE_W, batt_w=-_S47_TRADE_W)) +print(f" half-way through one quarter, {S45_LOAD_W:.0f} W house under a " + f"{S45_CAP_W:.0f} W ceiling: the SHADOW meter (battery at 0 W) offers " + f"{_s47_h_shadow:.0f} W of headroom, the LIVE meter (the same house " + f"plus a {_S47_TRADE_W:.0f} W charge) offers {_s47_h_live:.0f} W") +s47check("the SHADOW meter offers STRICTLY MORE charging headroom than the " + "live one on the same house, same ceiling and same elapsed - which " + "is the whole mechanism, in two points of the shipped arithmetic " + "rather than as a property of a whole run", + _s47_h_shadow > _s47_h_live) +s47check("...and the live answer is the one that lands the block ON the " + "ceiling, so the shadow answer is the one that is wrong about the " + "bill rather than the other way round", + near(_s47_h_live, S45_CAP_W - S45_LOAD_W, 1.0)) + +# --- 3d-4. WHAT SURVIVES OF THE EXEMPTION, GRADED -------------------------- +# The narrowed claim the three documents now make, as a check rather than as a +# sentence: WHICH hour and WHY are unaffected; HOW HARD is not. +s47check("WHICH hour the strategy picked and at WHAT price are identical on " + f"every one of the {_s47_n} compared cycles - the clock and the price " + f"series are not functions of what the battery did, which is the half " + f"of the old exemption that is still true", + all(_s47_lc[i]["price"] == _s47_sc[i]["price"] for i in range(_s47_n))) +s47check("...and so is the spread it had to clear", + all(_s47_lc[i]["spread"] == _s47_sc[i]["spread"] + for i in range(_s47_n))) + +# --- 3d-5. THE CLOSED-LOOP DEFECT THE DOCUMENTS SAID WAS VISIBLE ----------- +# ⚠️ SAFETY-10's HEADER NAMED SAFETY-45's LIMIT CYCLE AS EXAMPLE #1 OF WHAT A +# SHADOW RUN SURFACES. 3c's own PRECONDITION establishes that the unsized loop +# really does run away; the same stimulus watched from inside shadow mode is +# a settled, uneventful hour. +print(f" SAFETY-45's own defect, unsized: live {S45_BEFORE['onsets']} " + f"onsets/swing {S45_BEFORE['swing']:.0f} W, guard engaged " + f"{S45_BEFORE['engaged']}/{S45_BEFORE['cycles']} -> in shadow mode " + f"{S47_UNSIZED_SHAD['onsets']} onsets/swing " + f"{S47_UNSIZED_SHAD['swing']:.0f} W, guard engaged " + f"{S47_UNSIZED_SHAD['engaged']}/{S47_UNSIZED_SHAD['cycles']}") +s47check("⚠️ SAFETY-45's LIMIT CYCLE IS NOT VISIBLE FROM A SHADOW RUN, and three " + "merged documents said it was. The same hour, the same unsized code: " + f"{S45_BEFORE['onsets']} charge onsets and a " + f"{S45_BEFORE['swing']:.0f} W grid swing live, " + f"{S47_UNSIZED_SHAD['onsets']} onsets and " + f"{S47_UNSIZED_SHAD['swing']:.0f} W in shadow", + S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0 + and S47_UNSIZED_SHAD["onsets"] <= 2 + and S47_UNSIZED_SHAD["swing"] < 1.0) +s47check("...and neither are the guard ENGAGEMENTS the same documents promised: " + f"{S45_BEFORE['engaged']} engaged cycles live against " + f"{S47_UNSIZED_SHAD['engaged']} in shadow. Every term of " + f"`wanted = batt_w + excess` is MEASURED, and in shadow mode the " + f"battery contributes nothing to either term", + S45_BEFORE["engaged"] > 100 and S47_UNSIZED_SHAD["engaged"] == 0) + +# --- 3d-6. THE THREE RUNGS OF `sized_from_meter()`, SEPARATED -------------- +# TEST-16's rule: three rungs answer one question, so each is reached with the +# other two clear. Any two of them left standing would mask the third. +_s47_chg = _s47_worst +_s47_sell = derived(lambda: act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER, + headroom=0.0)) +s47check("the CHARGE rung: a metered headroom that sized a buy leg answers True, " + "which is the state the caveat exists for", + _arb.sized_from_meter( + ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x", + spread_eur_kwh=1.0, required_eur_kwh=0.1, + price_now_eur_kwh=0.05), + 300.0, winner=_arb.SOURCE) is True) +s47check("...the SELL rung: a DISCHARGE answers False however the headroom reads. " + "§9.1 - discharge and export are never capped by the peak guard - so a " + "caveat on a sell would be a FALSE sentence on the status row", + _s47_sell.action == DISCHARGE + and _arb.sized_from_meter(_s47_sell, 300.0, winner=_arb.SOURCE) is False) +s47check("...the UNMEASURED rung: a None headroom answers False. Nothing sized " + "anything from a meter, so there is nothing to disclose - and `decide()` " + "refuses the trade there anyway", + _arb.sized_from_meter( + ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x", + spread_eur_kwh=1.0, required_eur_kwh=0.1, + price_now_eur_kwh=0.05), + None, winner=_arb.SOURCE) is False) +s47check("...the WINNER rung: the same sized charge answers False once something " + "else owns the resolution. `Resolution.target_w` is then somebody " + "else's number and a caveat filed against it would be false", + _arb.sized_from_meter( + ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x", + spread_eur_kwh=1.0, required_eur_kwh=0.1, + price_now_eur_kwh=0.05), + 300.0, winner="loop") is False) + +# --- 3d-7. THE ROW, AT THE WORST CYCLE ------------------------------------- +_s47_row = _s47_chg["row"] +_s47_first_row = _s47_sc[0]["row"] +print(f" worst cycle row: {_s47_row[:230]}") +s47check("the shipped row carries the caveat at the worst cycle: the operator is " + "told the number was sized from a meter with only the house in it", + _shadow.SIZED_FROM_METER in _s47_row) +s47check("...and it still carries the watts, in their own slot, and the standing " + "0 W beside them - the caveat NARROWS the claim rather than deleting " + "the record a shadow run exists to produce", + "WOULD HAVE COMMANDED" in _s47_row and "inverter held at 0 W" in _s47_row + and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "") +s47check("...and the watts are introduced as what it would have commanded ON " + "THIS CYCLE, in every shadow state and not only the caveated ones - no " + "number a shadow run records is a forecast of a live run", + "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_row + and "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_first_row) + +# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS ----------- +# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY. +# Driven as one mutation, deleting the caveat from the log line alone 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 is `shadow.log_line()` now, so it can be graded. +_s47_log = derived(lambda: _shadow.log_line( + would_w=_s47_chg["would_w"], reason="arbitrage -> -2000 W", + sized_from_meter=_s47_chg["sized_from_meter"])) +_s47_log_plain = derived(lambda: _shadow.log_line( + would_w=_s47_chg["would_w"], reason="loop -> -300 W", + sized_from_meter=False)) +print(f" action log: {_s47_log[:190]}") +s47check("the ACTION LOG line carries the same caveat the row does - a page that " + "discloses and a 40-row record that does not is the worse half of the " + "two, because the record is what is read after the run", + _shadow.SIZED_FROM_METER in _s47_log) +s47check("...and it is the SAME constant, not a second wording of it, so the two " + "surfaces cannot start disagreeing about which cycles are caveated", + _shadow.SIZED_FROM_METER in _s47_row and _shadow.SIZED_FROM_METER + in _s47_log) +s47check("...and an uncaveated cycle gets the plain sentence, so the caveat is " + "not simply printed on everything", + _shadow.SIZED_FROM_METER not in _s47_log_plain + and "would have commanded" in _s47_log_plain + and "ON THIS CYCLE" in _s47_log_plain) + +# --- 3d-8. ⚠️ AND IT IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED ---------- +# THE TRAP THIS PART EXISTS TO SPRING. `arbitrage`'s own sentence says "SIZED +# BY THE CAPACITY-TARIFF GUARD" only when the headroom came out BELOW the ask. +# In a shadow run that 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 that sentence would +# be quietest exactly where it is needed loudest. +_S47_CAPPED = "SIZED BY THE CAPACITY-TARIFF GUARD" +print(f" at the worst cycle the shipped trade sentence says " + f"{'' if _S47_CAPPED in _s47_chg['reason'] else 'NOT '}capped; at the " + f"first it says " + f"{'' if _S47_CAPPED in _s47_sc[0]['reason'] else 'NOT '}capped") +s47check("PRECONDITION: the shipped 'capped' sentence IS present at the first " + "charging cycle, where the shadow number is RIGHT - so the inverse " + "below is a real inversion and not an absence everywhere", + _S47_CAPPED in _s47_sc[0]["reason"]) +s47check("⚠️ ...and ABSENT at the worst cycle, where the shadow number is " + f"{_s47_over:.2f}x out. A caveat keyed on `capped` would fire only " + f"where the record is accurate and go silent where it is multiples " + f"wrong; `sized_from_meter()` is keyed on the headroom having been " + f"MEASURED, so it fires on both", + _S47_CAPPED not in _s47_chg["reason"] + and _s47_chg["sized_from_meter"] is True + and _s47_sc[0]["sized_from_meter"] is True) + +# --- 3d-9. main.py's CALL SITES, PINNED ------------------------------------ +# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (habit 15), same as 3b's. Nothing +# behavioural in this file can see main.py render the row with a hard-coded +# False, because every check above calls `shadow.words()` itself. +_s47_words = [n for n in ast.walk(_S45_MAIN_TREE) + if isinstance(n, ast.Call) + and ast.unparse(n.func).endswith("shadow.words")] +_s47_flag = [ast.unparse(k.value) for c in _s47_words for k in c.keywords + if k.arg == "sized_from_meter"] +_s47_flag_src = _s45_assigned("shadow_sized_from_meter", + clear=("None", "False")) +print(f" main.py: shadow.words(sized_from_meter={_s47_flag}), and " + f"self.shadow_sized_from_meter is assigned from {_s47_flag_src}") +s47check("main.py renders the row from the flag THE CYCLE RECORDED, at its one " + "shadow.words() call site - not from a literal and not re-derived on " + "the status task, where `self.peak_guard` is a different cycle's", + _s47_flag == ["self.shadow_sized_from_meter"]) +s47check("...and that flag comes from the SHIPPED predicate with all three of its " + "arguments - the decision, the headroom the trade was sized from, and " + "the arbiter's winner - so a mutation to `False`, to a literal or to a " + "two-argument transcription is red rather than silent", + _s47_flag_src == ["arbitrage.sized_from_meter(" + "self.arb, self.arb_headroom_w, " + "winner=resolution.winner)"]) +_s47_logcalls = [n for n in ast.walk(_S45_MAIN_TREE) + if isinstance(n, ast.Call) + and ast.unparse(n.func).endswith("shadow.log_line")] +_s47_logflag = [ast.unparse(k.value) for c in _s47_logcalls for k in c.keywords + if k.arg == "sized_from_meter"] +print(f" main.py: shadow.log_line(sized_from_meter={_s47_logflag})") +s47check("...and the ACTION LOG line comes from the shipped sentence with the " + "same flag, at main.py's one call site - not re-assembled as an " + "f-string where no check can reach it, which is how it survived a whole " + "mutation battery once already", + _s47_logflag == ["self.shadow_sized_from_meter"]) +# ⚠️ AND THE PARAMETER HAS NO DEFAULT. TEST-14's rule, and this card is the +# reason it matters here: a caller that FORGOT the caveat and a caller that +# DECIDED it did not apply would render the identical row. +_s47_sig = inspect.signature(_shadow.words) +_s47_logsig = inspect.signature(_shadow.log_line) +s47check("`shadow.words()` and `shadow.log_line()` BOTH require " + "`sized_from_meter` - no default on either, so a caller cannot omit the " + "caveat by accident and get the un-caveated surface", + "sized_from_meter" in _s47_sig.parameters + and _s47_sig.parameters["sized_from_meter"].default + is inspect.Parameter.empty + and "sized_from_meter" in _s47_logsig.parameters + and _s47_logsig.parameters["sized_from_meter"].default + is inspect.Parameter.empty) # --------------------------------------------------------------------------- # diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 50884ef..8ec0ea0 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -2938,10 +2938,19 @@ check("...and it is inert BY CONSTRUCTION, not by a special case: the value it " # 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. +# does not exist. +# +# ⚠️ AMENDED BY SAFETY-47. The three lines that stood here said "the strategies +# are unaffected ... which is why the three defects this card was written for +# are all visible from a shadow run and this one is not." That was true until +# SAFETY-45 merged, one commit earlier: the buy leg is now sized from the +# BILLED-QUARTER METER, which is a function of what the battery did. So this is +# NOT the loop leg's own limitation - it is the GENERAL case, and the loop leg +# is merely where it had already been noticed. A shadow run cannot surface any +# closed-loop defect, and of the three this card was written for exactly one - +# TARIFF-14's wrong quarter-hour - is visible from one. `test_arbitrage.py` +# part 3d grades that; this check keeps grading the loop leg, which is what it +# has always been for. 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 "