SAFETY-47: what a shadow run can show, and the watts on its status row
checks / unit (pull_request) Successful in 7m48s
checks / unit (pull_request) Successful in 7m48s
Not a leak and not a write-path defect. Shadow mode's safety is intact -
the substitution, the re-seed, the authorisation gate and the clamp are
untouched. What was wrong is what three merged documents SAY a shadow run
can show you, and a number on the status row that can be 6.61x what a live
controller would command.
SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT, AND THE DOCUMENTS NOW SAY
SO. The battery is held at 0 W and the carried command is re-seeded to it,
so every measured input - the meter, the billed-quarter accumulator behind
it, the battery power, the SoC - describes a plant that did not respond. A
closed-loop defect is one whose stimulus IS that response. This is the same
property that makes shadow mode safe, so it is not a gap to close later:
the closed-loop answer comes from the closed-loop rig or from a live run.
SAFETY-10 §5 already conceded this for the grid-following loop. It was
never the loop leg's limitation; it is the general case.
THE "STRATEGIES ARE UNAFFECTED" EXEMPTION WAS TRUE UNTIL SAFETY-45 MERGED,
ONE COMMIT EARLIER. That card sizes the buy leg as min(asked, headroom)
with headroom = (900/remaining) x (ceiling - projected), and `projected`
reads the open quarter's accumulator - the meter - which is a function of
what the battery did earlier in that quarter. Measured on TEST-23 stage 3
at peak_cap_w 1500 / house 1200 / max_w 2000, one hour at 5 s:
SAFETY-45's limit cycle NOT visible. 28 onsets live vs 1; grid
-455.5..+3200.0 W vs flat at the 1200 W house;
guard engaged 410/720 cycles vs 0/720.
SAFETY-46's SoC churn NOT visible. The SoC moves 0.000000000 pp.
TARIFF-14's wrong hour VISIBLE, and it is the only one: which hour, at
what price, clearing what spread - identical on
720 of 720 charging cycles at a binding cap.
The exemption narrows to exactly what survives: WHICH hour a strategy picks
and WHY are unaffected; HOW HARD it trades is not. The peak guard's ceiling
and reasons are worth reading from a shadow run; its ENGAGEMENTS are not.
THE NUMBER IS CAVEATED, NOT RE-COMPUTED, AND THAT IS THE DECISION. The
arithmetic is not wrong - 900/remaining is correct for a meter genuinely
under the ceiling, and in shadow the meter genuinely is. It is the right
answer to a question nobody asked. Both surfaces now introduce the watts as
what would have been commanded ON THIS CYCLE, in every state, and carry
shadow.SIZED_FROM_METER whenever a measured billed quarter sized them.
Rejected, and recorded in the decision doc: re-computing it (the recorded
decision would no longer be the decision the shipped path reached, while the
arbiter, clamp, SAFETY-28 gate, log and MQTT all ran on the real one - a
second controller everywhere except the wire); recording nothing (blanks the
row exactly when arbitrage is trading, and the log's edge key is the watts);
and recording the unsized ask beside it (a second number, no warning, and at
the shipped 3500/2000 both figures read 2000 W).
"UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED. Charging
only adds offtake, so the shadow size is >= the live size with EQUALITY at
the start of every quarter. Pairwise over 3600 charging cycles in five
configurations: 0 below, 730 exactly equal. The first draft said "smaller",
which would have been false on every one of those 730.
AND THE PREDICATE IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED. arbitrage's
own sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the
headroom came out below the ask - in a shadow run, the START of the quarter,
where the two runs agree to the float. By the end, 900/remaining has
multiplied the headroom past the ask, the trade is not capped, the sentence
is silent, and the record is at its most wrong. A caveat keyed on it would
be quietest exactly where it is needed loudest.
SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED, FOR TWO
INDEPENDENT REASONS, AND CLOSING EITHER ALONE CLOSES NOTHING. Its fixture is
peak_cap_w 15000 against a 350 W house, so the guard never engages and the
sizing is inert - the harness's own comment says so. AND every identity row
reads the FIRST charging sample, where at elapsed=0 the projection IS the
held level and the two runs agree to the float even at a cap that does bind.
Measured: widening the fixture alone leaves that comparison passing. That
second reason is a new shape for MUTATION.md's habit-4 list - an INDEX, not
a fixture - and it is recorded there as the twelfth instance. §6 is re-taken
at a binding cap across the whole run: 716 of 720 charging cycles differ,
headroom 300.0..54000.0 W in shadow against 299.9..307.8 W live, recorded
-2000.0 W against a live -302.4 W.
THE GRADER IS test_arbitrage.py PART 3d, beside SAFETY-45's own closed-loop
regression and built on the same s45_loop() harness, so the runs differ in
one argument. Three lines stand in for _write_unit()'s substitution and
_shadow_settle()'s record and re-seed, marked as a transcription;
test_clamp.py part 8 remains the check on the shipped placement. The section
carries its own precondition into every check: a mutation that stopped
either run trading made it raise on its first min(), kill the file and
report ZERO reds - FCST-06's shape, driven rather than imagined.
AND THE FIX'S OWN FIRST DRAFT CARRIED THE SAME DEFECT. Deleting the caveat
from the action-log line left all 29 files green, because the sentence was
an f-string inside _shadow_settle() and the only suite that drives it uses a
meter_source:off fixture where no arbitrage decision exists. It is
shadow.log_line() now, for the reason words() is a function.
14 mutations, no survivors; M13 and M14 are +0 B and were confirmed on disk
by sha256 with every run purged. M14's first form was aimed at the wrong
direction and correctly survived - min->max makes the shadow record larger,
which is still an upper bound. All 29 check files green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
co-authored by
Claude Opus 5
parent
e6da478f87
commit
e3ccd9f6ea
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
+88
-14
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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})"),
|
||||
|
||||
+169
-12
@@ -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.
|
||||
|
||||
|
||||
@@ -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.<name>` 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 = "<start>"
|
||||
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="<no charging cycle>",
|
||||
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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user