Compare commits

...
10 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 e3ccd9f6ea SAFETY-47: what a shadow run can show, and the watts on its status row
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
2026-09-15 01:26:57 +02:00
admin e6da478f87 Merge pull request 'SAFETY-10: shadow mode, and 0 W is a number rather than a silence' (#89) from SAFETY-10 into release/1.0
The closed-loop rig made the case for this: with arbitrage armed and a binding cap the loop ran 28 charge onsets an hour (SAFETY-45); the price lookup returned the wrong quarter-hour for 45 minutes in every 60 (TARIFF-14); the SoC ceiling churns 598 Wh to gain 310 (SAFETY-46). Every one was invisible from outside, and there was no way to watch what the optimiser WOULD do without letting it do it.

THE TICKET SAID DEFAULT-ON AND THE AGENT REFUSED IT, correctly. The Supervisor resolves options as {**config.yaml defaults, **stored user options}, so a NEW key with a new default reaches every EXISTING install on the next update - including the one regulating the author's battery. `shadow_mode: true` would have stopped it silently, and the only symptom would be a battery that stopped doing anything. There is no mechanism by which one key defaults differently for a new install and an existing one - `options:` IS the default and there is one of it.

So the default is a value the add-on RESOLVES: shadow_mode: list(auto|on|off), shipped `auto` = shadow while control is STOPPED, live once STARTED. A fresh install ships auto_start: false, so it comes up in shadow and observes before it ever commands - default-on delivered where it is safe. An existing install that is controlling does not change by one watt; verified before merge, `auto` resolves to `not control_started`.

SECOND PUSHBACK, ALSO RIGHT: commands nothing is a NUMBER, not a silence. The inverter holds its last command forever, so a shadow mode that stopped writing would leave whatever was standing STANDING, under a page saying nothing is commanded - the exact looks-healthy-but-is-inert failure this repo keeps finding - and would hold the ESP32's 30 s stale-input watchdog tripped for the whole run, an alarm used as a mode. The actuator is held at 0.0 W, refreshed on the ordinary heartbeat, by the ordinary single writer, as a substitution inside the existing single choke point. Not a second write path. It is the LAST substitution, below SAFETY-28's gate and SAFETY-03's clamp, so both still grade the number the decision produced.

Every failsafe's behaviour is decided and recorded rather than inherited. The sign watchdogs are INERT because they grade standing_w(), held at 0 W - feeding them the decision would have them disagree with a meter that never saw it and LATCH A TRIP SURVIVING INTO LIVE OPERATION AND A RESTART. Clamp, write gate, meter freeze and peak guard OBSERVE the real decision. Circulation and delivery may legitimately latch, because their evidence is measured power. The operator stop is enforced at the wire rather than as a P_SAFETY claim, which would make every recorded decision read `stopped`. A standing latch is named in the shadow banner and turns it red.

Graded on the writes and the rendered surface, never on the flag, with the precondition asserted first: the same stimulus at `off` produces 30 non-zero writes with a largest of 5000 W; at `on`, set(writes) == {0.0} and 26 of them. Closed loop over the same hour twice: live -2000 W throughout, SoC 50.000 -> 62.331 %, grid to 2350 W; shadow 0 W throughout, SoC 50.0 -> 50.0 exactly, grid = the house exactly - with the arbitrage decision identical in action, instant, signed power, reason verbatim and arbiter winner across 3600/3600 samples.

Evidence: 29/29 green, CI green. Twenty-two mutations, no survivors, including a write escaping, silence instead of 0 W, observers blinded, default-on-for-everyone, the stop claim refiled, and a re-seed moved one line earlier which blinds SAFETY-18. Two mutation findings recorded: one first read as a survivor and was not, because a TypeError killed the section and reported zero reds - FCST-06's shape; and one had to become a PAIR because two fields both hold the detector at 0 W - TEST-16's shape.

Recorded limitation rather than glossed: the re-seed makes the exit safe by construction, and the price is that the loop leg's shadow decision is one slew step rather than a trajectory. Graded by name. The strategies are unaffected, which is why all three motivating defects remain visible.
2026-09-14 17:44:26 +02:00
Claude 8fa4febfef SAFETY-10: the whole decision path runs and nothing reaches the inverter
checks / unit (pull_request) Successful in 7m58s
Every optimiser defect this project has found was invisible from outside
while it was happening, and each was paid for at the meter first: SAFETY-45's
28 charge onsets an hour against a binding peak_cap_w, TARIFF-14's wrong
quarter-hour for 45 minutes in every 60, SAFETY-46's 598 Wh spent to gain
310 Wh. There was no way to watch what the optimiser WOULD do without letting
it do it.

`shadow_mode` runs strategies, arbiter, clamps and guards, records the
decision with its reason, and commands nothing.

⚠️ IT WRITES. It writes 0 W, on the ordinary heartbeat, through the ordinary
single writer. The inverter holds its last command forever - it has no meter
timeout - so "commands nothing" implemented as "stops writing" would leave
whatever was standing when shadow mode came on standing for as long as it was
left on, under a page saying nothing is being commanded. That is the failure
this repository keeps finding. Refreshing 0 W is also what
firmware/goodwe-master.yaml's 30 s stale-input watchdog requires of us; going
quiet would keep that board tripped for the whole run, which is an alarm being
used as a mode.

⚠️ IT IS NOT A SECOND WRITE PATH. No claim, no transport, no arithmetic of its
own. It is a substitution inside Controller._write_unit() - the single choke
point every write already reaches - in the same form as the three above it,
and it is the LAST of them, so SAFETY-28's gate and SAFETY-03's clamp still
grade the number the decision path produced.

⚠️ THE DEFAULT IS `auto`, NOT `true`, AND THAT IS THE CARD'S REAL CONTENT. The
Supervisor composes options as {**config.yaml defaults, **stored user
options}, so a new key with a new default reaches EVERY EXISTING INSTALL on
the next update - including the one regulating a real battery. A boolean
default-on would have stopped it silently. There is no mechanism by which one
key can default differently for a new install and an existing one, so
"default-on for new" is a value the add-on RESOLVES: `auto` is shadow while
control is STOPPED and live once it is STARTED. A fresh install ships
`auto_start: false` and comes up observing; an install that is controlling is
started and does not change by one watt.

THE FAILSAFES. Sign watchdogs (SAFETY-02/30): INERT - they grade
Inverter.standing_w(), which shadow mode holds at 0 W. Fed the shadow decision
they would disagree with a meter that never saw it and LATCH A TRIP THAT
SURVIVES INTO LIVE OPERATION AND A RESTART. Clamp (SAFETY-03), write gate
(SAFETY-28), meter freeze (SAFETY-18), peak guard: OBSERVE. Circulation
(SAFETY-31) and delivery (SAFETY-42): observe and may legitimately latch,
because their evidence is measured power. The operator stop is the one
deliberate change: enforced at the wire rather than as a P_SAFETY claim, which
would otherwise make every recorded decision read "stopped".

⚠️ RECORDED LIMITATION: _shadow_settle() re-seeds the carried command to what
stands on the wire, which makes the exit safe by construction - and therefore
the loop leg's shadow decision is ONE SLEW STEP, not a trajectory. A shadow
run answers "what would it command now", not "what would it have done next".
The strategies are unaffected; all three defects above are visible from one.

Graded on the writes and on the rendered surface, never on the flag: 22
mutations, no survivors, including one that lets a write escape and one that
goes silent instead of commanding 0 W. Closed loop on TEST-23 stage 3's rig,
same hour twice: live drives the pack to -2000 W and SoC 50.000 -> 62.331 %;
shadow commands 0 W throughout, SoC stays 50.0 exactly, and the arbitrage
decision is identical in action, instant, signed power, reason and arbiter
winner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-14 17:25:43 +02:00
admin 8f82dd4e06 Merge pull request 'SAFETY-45: size the trade to the headroom instead of interlocking against it' (#88) from SAFETY-45 into release/1.0
With arbitrage armed and a binding peak_cap_w, the loop did not settle: 28 charge onsets an hour, period 131 s, grid swinging -455 to +3200 W, and a billed quarter 59 W OVER the cap it had just spent fifteen minutes holding. Section 10.2 is explicit that an EMS which oscillates is worse than no EMS, for hardware wear and for the peak it is trying to avoid. Not reachable by any unit test in the repo - it needed the closed loop.

THE TICKET'S MECHANISM WAS WRONG AND THE AGENT DISPROVED IT. I wrote that the quarter boundary drives the cycle. Of the 27 gaps between the 28 onsets, 23 are 90-120 s entirely INSIDE one quarter; the four long gaps are exactly the four that SPAN a boundary, so the boundary lengthens the interval rather than causing it. A fix aimed at boundary handling would have removed 4 of 28.

The real cycle is a binary interlock keyed on a continuous quantity: the trade charges at max_w while `wanted = batt_w + excess` is still negative and the guard files nothing; `wanted` crosses zero; the guard engages on a FIVE WATT floor; that flips guard_state(), which cancels the WHOLE trade; the grid-following loop - which targets grid = 0, not grid = ceiling - takes the actuator, discharges to +1870 W and exports 420 W; the projection collapses; the trade re-arms at max_w.

So the fix sizes the buy leg rather than interlocking it. control.maintenance_charge_floor() already caps MAINTENANCE charging at the headroom under the site's cap, because a maintenance charge was the only thing big enough and long enough to set the peak - and that stopped being true when OPT-04 landed. peakguard.charge_headroom_w() publishes the same quantity off the MEASURED guard rather than the optional forecast entity, arbitrage.decide() takes it keyword-only and sizes the buy, main.py passes it. It is wanted_w read in the other direction: no new arithmetic and no new constant.

THE GUARD'S DECISION FUNCTION IS UNTOUCHED, and that is the proof 2026-08-20's bang-bang did not come back. peakguard.py is +139/-0 - zero deleted lines - and sha256(decide) is identical on both trees at 7252 bytes; replay_ac3.py reproduces 190/190 records, 110 engaged, 878 Wh identically. Verified independently before merge. Reverting that rung is red on eight named checks.

Rejected and recorded: a negative `lo`, because it creates a lo > hi contradiction against main.py's cheap-window maintenance shaping that resolves to 0 W - the battery parked in the billed quarter - and closing that would mean suspending maintenance shaping permanently on every affected site. And a backoff timer in arbitrage, because both modules' notes forbid inventing timers; the trade now re-arms at `ceiling - load`, which IS learning from the refusal.

After, on the same rig and the same command: onsets 28 -> 1, grid swing 3655 -> 282 W, and the billed quarters land at exactly 1500.0 W three times over. At the shipped defaults the two affected rows buy ~2% less energy and the worst quarter goes from 59 W over the cap to zero - which is section 10.1's ordering, since the tariff bills the month's single worst quarter. On an aggressive cap the sized trade buys 58% MORE. The non-binding control row is bit-identical.

Evidence: 29/29 green, CI green. Fourteen mutations, all red, two of them zero-byte. The regression closes the loop over the shipped guard, strategy, arbiter, control law and the real QuarterAverager, and asserts BOTH directions from one changed argument plus an inert-where-inert control.

TWO DEFECTS FOUND BY THE DISCIPLINE RATHER THAN BY REVIEW. The agent's own harness restored two edits to one file in list order and left one on disk - caught by the post-revert fingerprint, habit 18 biting a tool written the same afternoon. And test_peakguard.py part 1 formatted floor_w with a bare :.0f one line after the check that reverting the 2026-08-20 rung turns red, so the file ABORTED and parts 2-13 went ungraded: that mutation's coverage was one check and is now eight.
2026-09-14 16:20:39 +02:00
glenn schrooyenandClaude Opus 5 e1b9254626 SAFETY-45: a price-driven charge is SIZED by the capacity guard, not switched by it
checks / unit (pull_request) Successful in 7m42s
With arbitrage armed at the shipped defaults, arbitrage and the capacity-tariff
guard drove each other in a sustained limit cycle. Re-measured on TEST-23 stage
3's closed rig against 478ba93, metrics recomputed rather than taken from the
card: `peak_cap_w` 1500 / 1200 W house -> 28 charge onsets an hour, mean period
131.1 s, the grid swinging -455.5 to +3200.0 W; at the SHIPPED
`peak_cap_w: 3500` with an 1800 W house -> 8/h, with a 3000 W house -> 32/h.
The bite rule is `load + max_w > peak_cap_w`, i.e. any house over 1500 W in the
cheapest quarter. §10.2: "an EMS that oscillates is worse than no EMS, both for
hardware wear and - per §9.1 - for the peak it is trying to avoid." And it was
losing the argument it was having: EVERY billed quarter in the three affected
configurations finished OVER the cap.

⚠️ THE CARD'S STATED MECHANISM IS WRONG AND THE CORRECTION IS ON THE RECORD.
The card, and .cache/s45.md before it, say the loop is the quarter boundary
resetting the projection and arbitrage re-arming at full power. It is not. Of
the 27 gaps between the 28 onsets, 23 are 90-120 s (mean 111.5 s) and lie
ENTIRELY INSIDE one quarter; the four long gaps - 230, 240, 250, 255 s - are
exactly the four that SPAN a boundary, so the boundary LENGTHENS the interval
rather than causing an onset. The real cycle, off the recorded fields: the trade
charges at max_w while `wanted = batt_w + excess` is negative and the guard
files NOTHING; `wanted` crosses zero; the guard engages on a floor of 5.3 W;
that flips `arbitrage.guard_state()`, which cancels the WHOLE trade; the
grid-following loop - which targets grid = 0, not grid = ceiling - takes the
actuator and discharges to +1870 W, exporting 420 W; the projection collapses;
the guard releases; the trade re-arms at max_w. A binary interlock keyed on a
continuous quantity. A fix aimed at the boundary would have removed four of
twenty-eight onsets.

THE FIX IS A SHAPE THIS ADD-ON ALREADY HAD. `control.maintenance_charge_floor()`
caps a MAINTENANCE charge at "the headroom left under the site's cap", because
"a maintenance charge is the only thing this system does that is big enough and
long enough to set that peak". ⚠️ That sentence stopped being true when OPT-04
landed. `peakguard.charge_headroom_w()` publishes the same quantity off the
MEASURED guard rather than off the optional `peak_forecast_entity` - the hole
SAFETY-25 had to close in `shaping_suspended()` - and `arbitrage.decide()` sizes
its BUY leg to it, keyword-only and with no default.

NO NEW ARITHMETIC AND NO NEW CONSTANT. The number is `wanted_w` read in the
other direction: `decide()` already computes the DISCHARGE that lands the
quarter on the ceiling, and a negative one is a permitted CHARGE of `-wanted`.
⚠️ `peakguard.decide()` IS UNCHANGED - sha256(decide) = 200090d0835a3a55, 7252 B
on this branch and on 478ba93 - so 2026-08-20's anti-bang-bang rung is exactly
where it was, and tools/replay_ac3.py reproduces 190/190 guarded quarters, 110
engaged cycles, 5 blind and 878 Wh on both trees.

TWO PROPERTIES MAKE IT SAFE TO HAND A STRATEGY, BOTH SWEPT RATHER THAN ARGUED.
It is INVARIANT IN THE BATTERY - raise the charge by dW, the meter rises by dW,
so `excess` rises by dW while `batt_w` falls by dW and `wanted` does not move:
1350.0 W at every point across batt_w -5000..+2000 W on one banked quarter,
while the `excess` those runs see takes six distinct values. It is feed-forward,
so consuming it adds no feedback path around the plant. And HOLDING IMPLIES ZERO
OR UNKNOWN: across 1080 swept combinations not one ENGAGED decision answers a
positive headroom.

AFTER, same rig, same command. 1500/1200: 28 onsets -> 1, grid swing 3655.5 ->
282.0 W, energy bought 180.0 -> 285.2 Wh (+58%, because a continuous 300 W trade
beats a 2000 W trade cancelled for 57% of the hour), billed quarters
1490/1497/1509 -> 1500.0/1500.0/1500.0. 3500/1800: 8 -> 1, 3511/3536/3559 ->
3500/3500/3500. 3500/3000: 32 -> 1, 3519/3542/3557 -> 3500/3500/3500. The two
shipped-default rows buy 2.2% and 1.6% LESS energy and in exchange the worst
quarter goes from 59 W over the cap to zero - which is the ordering §10.1 asks
for, since the capacity tariff bills the month's single worst quarter.
⚠️ AND THE NEGATIVE CONTROL: at peak_cap_w 5000 with a 1200 W house, where
`load + max_w` never reaches the cap, the two runs are BIT-IDENTICAL.

⚠️ A NEGATIVE `lo` WAS CONSIDERED AND REJECTED, AND THE REASON IS IN THE CODE.
Deleting `decide()`'s `wanted <= 0` gate and filing `lo = wanted` is the tidier
answer arithmetically. It creates a new `lo > hi` CONTRADICTION against
main.py's cheap-window maintenance shaping, which files a NEGATIVE `hi`;
arbiter.py clause 2 has no notion of one limit out-ranking another, so it
resolves to 0 W - the battery PARKED in the quarter that sets the bill - and the
only way to close that suspends the maintenance charge shaping PERMANENTLY on
every site this card is about. A backoff or cooldown in arbitrage was rejected
too: both modules' design notes say no timer is invented, and the trade now
re-arms at `ceiling - load` rather than at max_w, which is what learning from a
refusal means when the refusal is a measurement.

THE REGRESSION IS THE LOOP SETTLING, NOT A UNIT TEST ON decide().
test_arbitrage.py 3c closes the loop over the shipped guard, strategy, arbiter,
control law and QuarterAverager with a modelled plant, and asserts BOTH
directions: unsized it must run away (24 onsets, -414..+3200 W, a quarter over
the cap) and sized it must settle (1 onset, 303 W swing, three quarters at
exactly 1500.0 W). The two runs differ in one argument. A third pair asserts the
card is INERT where the defect is inert.

Evidence: 29/29 green. 14 mutations, every one RED on a named check, no
survivors; two are ZERO BYTES - `min()` -> `max()` on the sizing, and the
sized-trade sentence's two operands transposed - and both are red. M13 reverts
the 2026-08-20 rung and is red on EIGHT named checks in test_peakguard and three
in test_arbitrage. Every revert verified by a tree fingerprint AFTER the revert,
which is how a defect in the battery's own harness was found: two edits to one
file, restored in list order, left the first one on disk (MUTATION.md habit 18).
test_peakguard.py part 1 also gained a None-safe format, because the mutation it
exists to catch made it raise TypeError one line later and ABORT the file -
parts 2-13 were ungraded and M13's recorded coverage was one check instead of
eight.

Not fixed, and named: TEST-23 finding 1's export overshoot at the END of a trade
(356-938 W, ~0.37 x max_w) is a different card; a house whose load ALONE exceeds
the cap still has the trade refused outright and the loop drive to grid = 0
rather than to the ceiling, and no swept pair reaches that regime; the container
leg (hadrive.py) was not re-run because test23s3-addon is deliberately stopped
as a second writer on the setpoint entity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-14 16:10:34 +02:00
admin 478ba93710 Merge pull request 'TARIFF-16: the injection leg gets its own coefficients' (#87) from TARIFF-16 into release/1.0
export_price_eur_kwh() modelled injection as one scalar. Eneco publishes 0,1 x BELPEX-H - 1,188 on the same card as its consumption formula, identical on the January and September 2026 cards; ENGIE publishes (B x eSpot_15) - A; Aspiravi uses 0,07 against ~0,10 on its own consumption leg. Reproduced against the shipped readers over one real BELPEX day: the published formula gives 24 distinct prices with a spread of 0.16710 EUR/kWh, the scalar gives 0.00000, and 5 of 24 hours are NEGATIVE - down to -0.03008 EUR/kWh - which are the midday hours a PV household exports in. The 6% VAT asymmetry is not wrong in the scalar, it is unstateable.

THE CARD'S BEFORE-STATE WAS WRONG AND THE AGENT CORRECTED IT. I wrote that this affects what the operator is shown. It did not: export_price_eur_kwh() had ZERO callers under app/ - not arbitrage, not the status page, not MQTT. Verified independently before merge; the only references are its own definition, the defaults entry, FRAMEWORK_KEYS and two comments. So the honest before-state is weaker than the card claimed: the scalar was not a misleading display, it was an UNREAD OPTION. DOCS, CHANGELOG and the startup line say so, and a check grades it. An operator actually seeing their injection price is a separate ticket.

Machinery reused rather than copied, which is TARIFF-17's move applied a second time: parse_export_formula() is parse_price_formula() with ONE argument moved, selecting a row of a _LEGS table, so everything that differs between the legs is a string and the units table, NaN rule, closed enumerations, unknown-key refusal, card comparison and DST handling are one implementation. Pinned by a check that parses every file under app/ and asserts all 32 grammar names are defined in exactly one module, plus an AST pin on the wrapper's body, plus twelve refusal sentences DERIVED from the consumption leg's own.

A NEGATIVE INJECTION PRICE IS ACCEPTED AND AN IMPLAUSIBLE ONE IS STILL REFUSED, and the bound had to move to achieve it: implausible_all_in() floors consumption at +0.02 EUR/kWh of energy, which is fatal on a leg that is legitimately negative. The bound is now off the PRICE and onto the COEFFICIENTS, where it is index-free and sign-free - a pass-through band (0.10-10x; Eneco 1.00x, Aspiravi 0.70x) and an offset-magnitude bound (0.20 against published 0.01188 and 0.02). One spec is put through both bands and the answers printed: the consumption band refuses -0.03008, the injection band accepts it.

The missing printed estimate is stated, not worked around. Eneco's injection line has no worked example beside it, so `card` is optional on this leg and unchanged on the other; where an estimate exists the self-check runs at the same tolerance, and where it does not validate.py logs that the coefficients are UNGRADED and names the two bounds that ran instead. A check MEASURES the resulting gap: a factor of 0.070 where the contract says 0.100 - 30% of every export payment - passes both bounds, because 0.70x is a pass-through somebody really publishes.

The scalar path is genuinely unchanged: the function body is pinned on the AST and seven option dicts are compared against the pre-TARIFF-16 expression with ==.

Evidence: 29/29 green, CI green. Twenty mutations, every one red on a named check. Giving the injection band TARIFF-15's floor is red in test_price.py and GREEN IN THE OTHER 28 FILES; so are the second-grammar and ignore-the-formula mutants. One is a single byte, one is ZERO bytes and moves no decision - legs transposed in the VAT banner, caught by an ordered-pair check. One is recorded for its SHAPE rather than its result: rerouting the scalar through the seam recurses, so the suite dies naming nothing, and a second mutation of the same property exists so a check can name it.

Two sharp edges flagged rather than smoothed: `index` and `resolution` on the injection leg name a series nobody fetches, which an operator could misread as a promise to fetch it; and the coefficient bands are materially weaker than a card check, catching the factor-of-10 class and nothing else.
2026-09-13 18:51:25 +02:00
glenn schrooyenandClaude Opus 5 995da777ce TARIFF-16: the injection leg stops being one number
checks / unit (pull_request) Successful in 7m42s
`price.py` modelled what a site is PAID for export as ONE scalar. The market
publishes a line, and the cards say so in their own words: Eneco Zon & Wind
Dynamisch `0,1 x BELPEX-H - 1,188` c EUR/kWh - "het injectietarief is dynamisch
en kan per uur wijzigen", identical on the January and the September 2026 cards;
ENGIE Dynamic `(B x eSpot_15) - A`; Aspiravi `0,07 x Belpex - 2`.
`price_export_formula` carries that line with its OWN coefficients.

⚠️ THIS IS NOT A WRONG-TRADE FIX, AND SAYING SO IS PART OF SHIPPING IT.
`arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment
says so, citing §9.3's near-zero Belgian injection price as the reason the
economically sound trade on this market is charge-cheap-to-displace-expensive-
IMPORT. Both hours it compares are all-in CONSUMPTION prices. No trade this
add-on takes today is decided by an injection price and nothing here changes
one. What it changes is what an operator is told, and what a future
export-aware strategy rests on: OPT-06 and any export logic would otherwise
inherit a scalar that cannot represent the market, and the 6% VAT asymmetry
would be invisible in the arithmetic. test_price.py 17h parses every module
under app/ and goes red the day something calls an injection reader, so that
sentence is graded rather than attested.

FOUR PROPERTIES A SCALAR CANNOT HOLD. Its own factor (Eneco 0,102 consumption
against 0,100 injection; Aspiravi 0,07 against ~0,10 - not the consumption B
with a sign flip). A negative offset, which `price_markup_eur_kwh: float(0,1)`
cannot express at all. VAT-exemption - "injectietarieven zijn vrijgesteld van
btw" - against 6% on consumption, representable only because `vat_pct` is PER
SPEC. And a price that goes below zero with no floor, under 11,88 EUR/MWh of
BELPEX-H, which is the middle of a sunny day.

⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, AND THAT NEEDED A DIFFERENT BAND
RATHER THAN A LOWER FLOOR. `implausible_all_in()` floors the consumption leg at
+0.02 EUR/kWh of energy; applied to injection that refuses the real market in
the hours it matters most. Measured rather than argued: 17c puts ONE spec
through BOTH bands and prints that the consumption band refuses -0.03008
EUR/kWh while the injection band accepts it. The floor was not decoration - it
is what catches a card read a hundred times too small, the one unit error the
card self-check cannot see because it cancels out of a comparison between the
two sides - so the bound moves OFF the price and ONTO the coefficients, where
it is index-free and sign-free: a pass-through band (Eneco's injection leg is
1.00x, Aspiravi's 0.70x; the band is 0.10-10x) and an offset magnitude bound
(0.20 EUR/kWh against published 0.01188 and 0.02). Two rungs that can both
answer a hundredfold error, so each is graded on its REASON, each gets a
stimulus the other cannot reach with the precondition asserted as its own
check, and they are mutated TOGETHER (TEST-16).

⚠️ THE CARD SELF-CHECK APPLIES WHERE THERE IS A CARD, AND WHERE THERE IS NOT
THAT IS STATED RATHER THAN WORKED AROUND. `price_formula` can require a `card`
block because a Belgian card prints the consumption formula, the reference
index AND the resulting price. Eneco's INJECTION formula is printed with no
price beside it. Requiring a block would make the published line inexpressible;
inventing the estimate would grade the coefficients against arithmetic done
with the same coefficients. So the block is OPTIONAL on this leg and unchanged
on the other, the self-check runs at the same tolerance wherever a printed
estimate exists, and validate.py says at startup - "its coefficients are
UNGRADED" - when it does not, naming the two bounds that ran instead. This is
TARIFF-15 demonstrating two tariffs end to end rather than three, one card
later. 17e also MEASURES the size of that gap: a factor of 0.070 where the
contract says 0.100 - 30% of every export payment - passes both bounds, because
0.70x is a pass-through somebody really publishes.

ONE PARSER, NOT TWO. `parse_export_formula()` is `parse_price_formula()` with
its OPTION NAME as an argument - TARIFF-17's move on `parse_static_profile()`,
exactly - and everything that differs between the legs is a string in one
`_LEGS` table. A second copy would be a second unit table, a second NaN rule, a
second unknown-key rule and a second `to_resolution()`, diverging first on the
23- and 25-hour days. test_price.py 17b pins it two ways: all 32 grammar names
are defined in exactly ONE module under app/, with the scope COMPUTED by
parsing every file rather than listed; and twelve refusal sentences for the new
option are DERIVED from the old option's own sentence rather than transcribed.
The four sentences that deliberately differ are named, with what differs, and
asserted to differ.

NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "";
`export_price_eur_kwh()`'s body is byte-identical, pinned on the AST rather
than on the source text; `export_price_at()` falls through to it for every
index value. 17f compares seven option dicts x five index values against the
pre-TARIFF-16 expression with `==`.

`price_export_formula` joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived
from that set, and `code_leaks()` refuses a plugin that names it with nothing
else edited.

ALSO CORRECTED HERE: config.yaml's comment beside `export_price_eur_kwh` still
read "no shipped supplier publishes a dynamic injection curve". TARIFF-07
struck that claim from app/price.py and DOCS.md and did not reach config.yaml,
where it has been shipping since.

20 mutations, every one red on a named check, ledger in test_price.py below
section 17 - including a ZERO-byte one (the two legs transposed in the
VAT-asymmetry banner: no decision moves, only the sentence) and a ONE-byte one
(`card_required=True` on the injection leg). Three run over all 29 files and
red on test_price.py alone. 29/29 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 18:41:06 +02:00
admin 4dcd9b0fc6 Merge pull request 'TARIFF-18: the taxes default is half the real Flemish figure' (#86) from TARIFF-18 into release/1.0
price_taxes_eur_kwh shipped at 0.06 against a real Flemish 0.11736 - Fluvius Antwerpen 5,35 + bijzondere accijns 4,876 + groene stroom/WKK 1,51 + energiebijdrage 0, incl. 6% VAT, from the Eneco residential card for September 2026. Across the eight Fluvius DSOs the range is 0.117-0.131, so the shipped default was roughly half. The components ship as rows in the code, and the exact unrounded sum is used so the check asserts an EQUALITY against them rather than a tolerance. The energiebijdrage is a ZERO ROW, not an absence - it went to 0 EUR/MWh on 2026-08-01 and was merged into the excise, so anyone hand-computing this from an older guide double-counts it; deleting the row changes no sum, so its PRESENCE is graded separately.

AN UPGRADE HAZARD THE TICKET DID NOT NAME, found and closed. App.options is a deepmerge of config.yaml's defaults over the stored options, so the moment the default moves, every site STORING 0.06 becomes non-default with nobody having edited anything. Combined with TARIFF-17's new rung - a taxes period table beside a non-default scalar is a startup refusal - a working Walloon or Brussels site with a table would have hit SystemExit(78) on the upgrade, over an inert number the table overrides anyway. Closed with SUPERSEDED_TAXES_DEFAULTS_EUR_KWH: validate.py reads a superseded default as untouched too. The rung is NOT weakened - a genuinely custom 0.1198 beside a table is still refused by name, and the refusal now names both figures.

AND THE TICKET'S FRAMING WAS WRONG. I wrote that a site which took the default silently gets a different number. That is not what the deepmerge does: an existing install keeps its stored 0.06 and sees nothing change. The new default reaches a FRESH install, and an existing one only when its options are re-saved. Said plainly in the CHANGELOG, flagged as not re-measured on this branch (the 70-of-71-keys figure is 0.9.0's), with predeploy_check.py --options named as what would confirm it.

THE FIGURE IS THE BOTTOM OF THE BAND, DELIBERATELY, and the argument is the agent's rather than the ticket's: the error directions are not symmetric. Set too high, a genuinely negative hour reads positive and the add-on exports into paid disposal - section 17's own regression case. Set too low, it only declines a trade that would have paid. One default standing in for eight DSOs belongs at the bottom, and Antwerpen's 0.11736 is a real card row there rather than a synthesised minimum.

A CHECK HAD TO BE REWRITTEN RATHER THAN RE-POINTED. test_price.py section 0 justified its own constants by '0.06 is exactly twice 0.03'. This card ends that coincidence, so the sentence is false and is gone; it now asserts per figure that neither shipped default equals the fixture, which is stronger than the tuple compare it replaced. Recorded rather than quietly dropped, along with three other now-false claims the change created in test_price.py's docstring, MUTATION.md and DECISIONS/TARIFF-15 - which had predicted this card and is annotated as shipped.

Evidence: baseline 29/29 before any edit, 29/29 on the committed tree. Ten mutations, all red, every revert sha256-verified and every grade taken in a container with the tree :ro so no .pyc can exist. Two are zero-byte. The interesting one moves the component rows AND the default AND config.yaml together into a coherent West-Vlaanderen card - caught only by the literal typed in the test file and the band check, which is why the sum is graded from two independent sides. What the suite cannot see is stated in the PR: it grades the default against the card as a number, never the card against Belgium.
2026-09-13 16:33:37 +02:00
glenn schrooyenandClaude Opus 5 9c65ac2cf8 TARIFF-18: price_taxes_eur_kwh stops shipping at half the real figure
checks / unit (pull_request) Successful in 7m48s
`price_taxes_eur_kwh` shipped at 0.06 EUR/kWh with no recorded provenance -
no card, no date, no component list - and roughly HALF the true Flemish
all-in non-energy figure. It now ships at 0.11736, and the rows it is made
of are in the code rather than in this message:

  Fluvius distribution (Antwerpen)              5,35   c EUR/kWh
  Bijzondere accijns, "Alle verbruik"           4,876
  Bijdrage groene stroom en WKK Vlaanderen      1,51
  Energiebijdrage (0 EUR/MWh since 2026-08-01)  0
                                               ------
                                               11,736 c EUR/kWh = 0.11736

Eneco residential card for Flanders, September 2026, incl. 6 % VAT.
`app/price.py`'s FLEMISH_TAXES_COMPONENTS_EUR_KWH carries the rows and
test_price.py section 1b re-adds them - and also compares the total against
the card's own figures typed in the test file, so an edit that moved the
rows and the default together is still caught.

WHAT THIS IS NOT. It is not an arbitrage-correctness fix and it cannot be.
This scalar is a LEVEL component: an additive constant cancels exactly in
`max - min` and leaves the argmin where it was. TARIFF-17 shipped that proof
and it is unchanged - test_price.py 16b drives six flat scalars spanning and
exceeding the whole per-DSO range and gets the identical cheapest hour and
the identical spread to the last digit. DOCS and the CHANGELOG say so in
those words.

WHAT IT IS. A display and guard fix:

  * every number an operator reads, which at half the true value made the
    add-on look considerably better than it is; and
  * §17's negative-price guard in arbitrage.py - the one rung that tests an
    ABSOLUTE all-in price rather than a difference. It fires when
    `spot < -(markup + taxes)`, so its boundary moves -0.09 -> -0.14736
    EUR/kWh of spot. test_arbitrage.py section 2c drives both sides through
    the shipped decide() and pins the boundary from below as well.

The default is Antwerpen's real row, near the LOW end of the 0.117-0.131
per-DSO band, and that is deliberate: the error directions are not
symmetric. Too high carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal - §17's own case. Too low only
declines a discharge that would have paid.

TARIFF-17's INTERACTION, WHICH HAD TO BE DECIDED. Nothing in that rung
hardcoded 0.06; it read PRICE_OPTION_DEFAULTS, so moving the default moves
what it refuses. The Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`, so a saved blob carries the key whether it was
typed or not - every stored 0.06 became "non-default" with nobody editing
anything, and a working Walloon or Brussels site with a taxes table would
have met SystemExit(78) on the upgrade over an inert number. price.py now
carries SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) and validate.py reads a
superseded default as untouched too. The rung is NOT weakened: 0.1198 beside
a table is still refused by name, and the refusal now names both figures.

config.yaml and PRICE_OPTION_DEFAULTS moved together; CFG-03's tripwire
(test_price.py section 1, test_defaults_pin.py) stayed green by agreeing.
test_price.py section 0's fourth check was REWRITTEN rather than re-pointed:
it used to justify the file's own grading constants by observing that the
shipped pair was degenerate (0.06 is exactly twice 0.03). TARIFF-18 ends
that coincidence, so the sentence is no longer true and is not kept.

29/29 test files green. Mutation ledger on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 16:24:56 +02:00
admin 7796f168a9 Merge pull request 'TARIFF-17: a charge that varies by hour gets somewhere to live' (#85) from TARIFF-17 into release/1.0
price.py modelled taxes as one flat scalar. In Flanders that is correct - Fluvius distribution is a single rate - but the Walloon DSOs publish day/night bands on the same card: ORES 13,27 vs 7,39, AIESH 15,17 vs 8,21, ORES IMPACT 16,578 vs 5,090 c EUR/kWh. The bands are ANTI-CORRELATED with what the battery wants to do - cheap network hours coincide with cheap spot hours - so flattening them systematically UNDERSTATES the spread and declines cycles that are genuinely profitable.

The flat scalar was never a wrong number. It sits in the additive bucket and provably cannot change which hour is cheapest; the defect was that a component belonging in the order-changing bucket had nowhere to live. That framing is the ticket, and it is what the regression grades.

Reproduced on one ordinary Belgian winter Thursday, same feed, same markup, taxes the only argument differing: five flat scalars spanning AND exceeding the table's own range - 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 - all give cheapest hour 13 and spread 0.1500 to the last digit, while the ORES table gives hour 03 and 0.1788. Asserted through the shipped decision, static_tariff.arbitrage().cheapest, not through assemble()'s output. Before this existed the shipped tree answered assemble(..., <table>) -> TypeError.

The fixture differs in exactly ONE way and that is its own precondition - same caps object, same RawPoint list, same markup, by identity - and the flat arm is a FAMILY rather than a single scalar, so what the comparison measures cannot be a level effect. That closes the shape that produced a whole-suite survivor in TARIFF-11, where a fixture varied two things at once.

GRAMMAR REUSED, NOT DUPLICATED. parse_taxes_profile() is parse_static_profile() with option= and means= as default arguments, so every pre-existing call site and sentence is byte-identical. Graded two ways: one check parses every module under app/ and asserts the 14 grammar names are defined in exactly one of them, with an AST pin that the wrapper's whole body is one call; another DERIVES all thirteen refusal sentences for the new option from the old option's own sentence. Copying the tiling check into price.py is red.

A WHOLE CARD TYPED IN CENTS IS ITS OWN RUNG, and not a restatement of the parser's plausibility bound. ORES's 13,27 trips that bound - but SIBELGA'S ENTIRE BRUSSELS CARD (9,39 / 7,10) IS UNDER IT and would have started as a 9.39 EUR/kWh levy. Graded by its own reason, and the mutation is red on the Sibelga line only.

Malformed tables are refused at STARTUP by validate.py across three rungs. If one ever gets past, load_prices() returns a section 11.4 no-data read naming the key and logs an error - no silent fall back to the scalar, no traceback into the tick.

Both plugins that need the framework to add taxes move 13 -> 03 (custom_formula {ENERGY,MARKUP} and generic_dayahead {ENERGY}); manual_csv, which is all-in, stays at 13. Enumerated from PROVIDERS rather than typed.

Evidence: 29/29 green. Thirteen mutations, all red, and no row is an edit-and-revert - each gets a fresh rsync'd copy with no __pycache__, mounted :ro, then deleted, which closes habit 18 structurally rather than by discipline. Hoisting the lookup out of assemble()'s loop (+17 B) keeps the number right while stopping it varying by hour: red on six named checks in test_price.py and GREEN IN THE OTHER 28 FILES.

Three things named rather than rounded off: one mutant is equivalent and unseparable by any fixture, because StaticProfile.at() calls brussels() on whatever instant it is given; DOCS' bucket table is prose that nothing asserts, though the numbers it quotes are printed by the checks on every run; and the card's own 0.2240/0.2422 figures were NOT verified and were not repeated - replaced with a claim that is derivable from the contract price, that the spread is proportional to factor and the offset drops out.

One design point is explicitly the owner's, not the agent's: a table beside a NON-DEFAULT price_taxes_eur_kwh is a refusal, so an operator adopting the table must set that key back to 0.06. The alternative - warn, table wins - is the silent-loser shape this repo refuses everywhere else, so the refusal was taken and flagged rather than quietly chosen.
2026-09-13 15:40:17 +02:00
27 changed files with 6883 additions and 206 deletions
+398
View File
@@ -25,6 +25,123 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| _this branch_ | TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving |
| _this branch_ | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface |
| _this branch_ | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest |
| _this branch_ | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** |
| _this branch_ | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero |
| _this branch_ | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** |
| _this branch_ | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** |
| _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
A new option, `shadow_mode`, with three values and a shipped default of
`auto`. In shadow mode the add-on runs its entire decision path — strategies,
arbiter, clamps, guards — and **sends nothing to the inverter**: every inverter
is held at **0 W**, refreshed on the ordinary heartbeat by the ordinary single
writer, and the status page says plainly that the battery is not being
controlled. What it *would* have commanded, and why, is on the status page and
in the action log.
⚠️ **WHAT HAPPENS TO AN EXISTING INSTALL: NOTHING.** `auto` resolves to shadow
mode only while control is **stopped**. An install that is controlling —
`auto_start: true`, or *Start / stop control* pressed — is started, so `auto`
resolves to ordinary control and not one watt changes. An install that is
**stopped** keeps its actuator at 0 W exactly as it did before, from the same
writer on the same heartbeat; what changes for it is what the status page and
the action log **say**, and that the decision path now runs and is recorded.
⚠️ **AND THAT IS WHY IT IS NOT A BOOLEAN.** The card is titled "default-on".
Taken literally — `shadow_mode: true` in `config.yaml` — it would be the worst
change in this repository's history: the Supervisor composes an add-on's
options as *its defaults, overlaid with the stored user options*, so a new key
with a new default reaches **every existing install** on the next update,
including the one regulating a real battery at the reference site. It would
have silently stopped it, and the only symptom would have been a battery that
stopped doing anything. There is no mechanism in the add-on options system that
lets one key default differently for a new install and an existing one, so
"default-on for new" is expressed as a value the add-on **resolves** rather
than a constant the Supervisor merges.
A **new** install ships `auto_start: false`, so it comes up in shadow mode and
observes a day of decisions before it is allowed to command anything. That is
the default-on the card asked for, delivered where it is safe and withheld
where it is not.
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
@@ -60,6 +177,182 @@ separate card. *(Annotated 2026-09-13: that card is **TARIFF-15**, below, and it
shipped on this branch as `custom_formula`. It is still not an alias — the
operator types their own coefficients; nothing is invented on their behalf.)*
### SAFETY-45 - the trade is sized by the capacity guard, not switched by it
**A price-driven charge now asks the peak guard HOW HARD it may pull, instead of
only whether it may pull at all.** On any site where `load + max_w` reaches
`peak_cap_w` - at the shipped `peak_cap_w: 3500` and `max_w: 2000`, any house
drawing over 1500 W in the cheapest quarter - arbitrage and the capacity-tariff
guard were driving each other in a sustained limit cycle. Measured on TEST-23
stage 3's closed rig at `peak_cap_w: 1500` against a 1200 W house: **28 charge
onsets in one hour, mean period 131 s, the grid swinging −456 to +3200 W**, and
every billed quarter in the three affected configurations finishing **over** the
cap it was oscillating about. §10.2: *"an EMS that oscillates is worse than no
EMS, both for hardware wear and - per §9.1 - for the peak it is trying to
avoid."*
**What was happening.** The guard computes `wanted = batt_w + excess` - the
battery discharge that would land the quarter exactly on the ceiling - and files
a claim only while that number is positive. While a trade charges at `max_w`
the number is negative for the first minute or so of every cycle, so the guard
files **nothing** and the trade is not constrained at all. `wanted` then crosses
zero, the guard engages on a floor of a few watts, and that flips
`arbitrage.guard_state()` - which cancels **the whole trade**. The
grid-following loop takes the actuator, drives the meter to zero and past it,
the projection collapses, the guard releases, and the trade comes back at
`max_w`. 115 s later, again.
**The fix, and it is a shape this add-on already had.**
`control.maintenance_charge_floor()` has always capped a MAINTENANCE charge at
"the headroom left under the site's cap", because *"a maintenance charge is the
only thing this system does that is big enough and long enough to set that
peak"*. ⚠️ That stopped being true when OPT-04 landed: a trade commands `max_w`
for a whole tariff interval, deliberately, against the meter.
`peakguard.charge_headroom_w()` publishes the same quantity off the **measured**
guard rather than off the optional `peak_forecast_entity`, and
`arbitrage.decide()` sizes the buy leg to it.
**No new arithmetic and no new constant.** The number is `wanted_w` read in the
other direction: a negative `wanted` is not "nothing to say", it is a permitted
charge of `−wanted`. `peakguard.decide()` is **unchanged** - byte-identical to
`release/1.0`, `sha256 = 200090d0835a3a55` - so 2026-08-20's anti-bang-bang rung
is exactly where it was, and `tools/replay_ac3.py` reproduces the same 190/190
guarded quarters, 110 engaged cycles and 878 Wh on this branch as on the tip.
**After, on the same rig, same command:**
| `peak_cap_w` / house | onsets per hour | grid swing | energy bought | billed quarters |
|---|---|---|---|---|
| 1500 / 1200 W | 28 → **1** | 3656 → **282 W** | 180 → **285 Wh** | 1490/1497/**1509** → **1500/1500/1500** |
| 3500 / 1800 W | 8 → **1** | 3673 → 1688 W | 1653 → 1616 Wh | **3511/3536/3559** → **3500/3500/3500** |
| 3500 / 3000 W | 32 → **1** | 3798 → **475 W** | 483 → 475 Wh | **3519/3542/3557** → **3500/3500/3500** |
| 5000 / 1200 W | 1 → 1 | *identical* | *identical* | *identical* |
One onset means the trade starts once and runs for the whole window - the
battery is now trading 100 % of the cheap hour where it traded 43 %, 92 % and
54 %. **Every billed quarter now lands on the cap and none over it.** At the
aggressive cap the site buys 58 % MORE energy, because a continuous 300 W trade
beats a 2000 W trade that is cancelled for well over half the hour; at the two
shipped-default rows it buys about 2 % less and the worst quarter drops from
59 W over the cap to zero. The last row is the negative control - `load + max_w`
never reaches the cap there, and the two runs are bit-identical. **This costs
nothing on the installs it is not about.**
⚠️ **NOT A CONFIG CHANGE.** No option is added, removed or re-defaulted, and
arbitrage remains off in the shipped configuration. A site that has not armed
`arbitrage_enabled` is unaffected in every respect.
⚠️ **WHAT IS NOT FIXED, AND IT IS NAMED RATHER THAN IMPLIED.** TEST-23 finding 1
- the export overshoot at the END of any trade, 356-938 W, about 0.37 × `max_w`
- is untouched and is a different card. A house whose load ALONE exceeds
`peak_cap_w` still has the guard engage and the trade refused outright, which is
correct, and the grid-following loop then drives to grid = 0 rather than to the
ceiling. See `DECISIONS/SAFETY-45-sizing-the-trade.md` §5, and §2 there for the
negative-`lo` design that was rejected because it creates a new `lo > hi`
contradiction against the maintenance charge shaping.
### TARIFF-16 - the injection leg stops being one number
**`price_export_formula`: what a site is PAID for export, as `factor × index +
offset` with its own coefficients, alongside the flat `export_price_eur_kwh`.**
⚠️ **THIS IS NOT A WRONG-TRADE FIX, AND SAYING SO IS PART OF SHIPPING IT.**
`arbitrage.py` does not read `export_price_eur_kwh` at all — its own comment
says so, and gives §9.3's near-zero Belgian injection price as the reason the
economically sound trade on this market is *charge cheap to displace expensive
import*. Both hours it compares are all-in **consumption** prices. So no trade
this add-on takes today is decided by an injection price, and nothing here
changes one. What it changes is what an operator is told, and what any future
export-aware strategy rests on: OPT-06 and any export logic would otherwise
inherit a scalar that cannot represent the market, and the 6% VAT asymmetry
between the two legs would be invisible in the arithmetic. `test_price.py` 17h
parses every module under `app/` and goes red the day something calls an
injection reader, so that claim is graded rather than attested.
**The market publishes a line, not a number.** Verbatim off the cards: Eneco
Zon & Wind Dynamisch `0,1 x BELPEX-H - 1,188` c€/kWh — *"Het injectietarief is
dynamisch en kan per uur wijzigen"*, identical on the January and the September
2026 cards; ENGIE Dynamic `(B x eSpot_15) - A`; Aspiravi `0,07 x Belpex - 2`.
Four properties a scalar cannot hold:
1. **Its own factor.** Eneco `0,102` on consumption against `0,100` on
injection; Aspiravi `0,07` against about `0,10` on its consumption leg. Not
the consumption `B` with a sign flip.
2. **A negative offset** — `− 1,188`, `− 2`. `price_markup_eur_kwh: float(0,1)`
cannot express a negative markup at all, so it has no home on the
consumption side either.
3. **VAT-exempt** — *"Injectietarieven zijn vrijgesteld van btw"* — while
consumption is at 6%. `vat_pct` is now **per leg**, so the six-point
asymmetry is expressible; a scalar carries no VAT field at all.
4. **It goes negative with no floor**, below `11,88 EUR/MWh` of BELPEX-H — the
midday hours a PV household exports in. You can be charged to export.
⚠️ **A NEGATIVE INJECTION PRICE IS ACCEPTED, NOT REFUSED**, and that needed a
different band rather than a lower floor. `implausible_all_in()` floors the
consumption leg at `+0.02 EUR/kWh` of energy, which is right there and fatal
here — measured, not assumed: `test_price.py` 17c puts one spec through **both**
bands and prints that the consumption band refuses `−0.03008 EUR/kWh` while the
injection band accepts it. The floor was not decoration, though: it is what
catches a card read a hundred times too small, the one unit error the card
self-check cannot see because it cancels out of a comparison between the two
sides. So the bound moves **off the price and onto the coefficients**, where it
is index-free and sign-free — a **pass-through** band (`factor` as a share of
the wholesale price; Eneco's injection leg is 1.00×, Aspiravi's 0.70×, the band
is 0.10–10×) and an **offset magnitude** bound (0.20 EUR/kWh against published
values of 0.01188 and 0.02). Two rungs that can both answer a hundredfold
error, so each is graded on its **reason**, each gets a stimulus the other
cannot reach, and they are mutated **together** (TEST-16).
⚠️ **THE CARD SELF-CHECK APPLIES WHERE THERE IS A CARD, AND WHERE THERE IS NOT
THAT IS STATED RATHER THAN WORKED AROUND.** `price_formula` can require a `card`
block because a Belgian card prints the consumption formula, the reference index
*and* the resulting price. **Eneco's injection formula is printed with no price
beside it.** Requiring a block would make the market's own published line
inexpressible; inventing the estimate would grade the coefficients against
arithmetic done with the same coefficients. So the block is **optional on this
leg and unchanged on the other**, the self-check runs at the same tolerance
wherever a printed estimate exists, and validate.py says at startup —
*"its coefficients are UNGRADED"* — when it does not, naming the two bounds that
ran instead. This is TARIFF-15 demonstrating two tariffs end to end rather than
three, one card later.
**ONE PARSER, NOT TWO.** `custom_formula.py` already shipped the units table,
the NaN rule, the closed enumerations, the unknown-key refusal, the card
comparison and `to_resolution()`'s DST handling, so `parse_export_formula()` is
that parser with its **option name as an argument** — TARIFF-17's move on
`parse_static_profile()`, exactly. Everything that differs between the two legs
is a string in one `_LEGS` table. `test_price.py` 17b pins it two ways: by
parsing every module under `app/` and asserting all 32 grammar names are defined
in exactly **one** of them, and by deriving twelve refusal sentences for the new
option from the old option's own sentence rather than transcribing them. The
three sentences that deliberately **differ** are named, with what differs, and
asserted to differ.
**NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT.** The option ships `""`, and
`export_price_eur_kwh()`'s body is byte-identical — pinned on the AST, not on
the source text — with `export_price_at()` falling through to it for every index
value. `test_price.py` 17f compares seven option dicts × five index values
against the pre-TARIFF-16 expression with `==`.
`export_price_eur_kwh` and the new key are both in `FRAMEWORK_KEYS`;
`BANNED_IN_PLUGIN_CODE` is derived from that set, so `code_leaks()` refuses a
plugin that names either with nothing else edited.
**Also corrected here:** `config.yaml`'s comment beside `export_price_eur_kwh`
still read *"no shipped supplier publishes a dynamic injection curve"*.
TARIFF-07 struck that claim from `app/price.py` and `DOCS.md` and did not reach
`config.yaml`, where it has been shipping since.
**20 mutations, every one red on a named check**, ledger in `test_price.py`
below section 17 with byte deltas — including a **zero-byte** one (the two legs
transposed in the VAT-asymmetry banner, which changes no decision and only
moves the sentence) and a **one-byte** one (`card_required=True` on the
injection leg). Three were run over all 29 files and are red on `test_price.py`
alone; the rest over the five suites that can reach this card. Each row gets a
fresh copy of the tree under a `mktemp` path, mounted `:ro` — no revert to fail
and no shared backup path, which is MUTATION.md habit 18 closed structurally.
29/29 green.
### TARIFF-17 - a price component that varies by hour has somewhere to live
**`price_taxes_profile`: the taxes component as a period table, alongside the
@@ -131,6 +424,111 @@ figure is correct. This is a product concern rather than a site one: the add-on
is sold to clients, and a Walloon or Brussels client could not express their
own tariff.
### TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real figure
⚠️ **A SHIPPED DEFAULT CHANGED: `price_taxes_eur_kwh` 0.06 -> 0.11736 EUR/kWh.**
Same shape as 0.8.0's `slew_w` rule, and the same instruction follows — **read
the live options before `ha addons update`**, this time to see whether
`price_taxes_profile` is set, which is the one shape the move could have
refused. The "who is affected" section below is the part that matters, and it
is shorter on consequences than a changed default usually is.
The old `0.06` had **no recorded provenance anywhere**: not a card, not a date,
not a component list. It is roughly **half** the Flemish figure. The new default
is the Eneco residential card for Flanders, September 2026, incl. 6 % VAT, and
its rows are now **in the code** (`app/price.py`'s
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, re-added by `test_price.py` section 1b):
| component | c€/kWh |
|---|---|
| Fluvius distribution (**Antwerpen**) | 5,35 |
| Bijzondere accijns, *"Alle verbruik"* — no volume bands | 4,876 |
| Bijdrage groene stroom en WKK Vlaanderen | 1,51 |
| Energiebijdrage — **0 EUR/MWh since 2026-08-01**, merged into the accijns | 0 |
| **total** | **11,736 c€/kWh = 0.11736** |
⚠️ **IT IS PER-DSO, AND THE DEFAULT IS NOT AN AVERAGE.** Only the distribution
row moves: across the eight Fluvius DSOs it runs 5,28 c€/kWh
(Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about **0.117 to
0.131**. The default is a real DSO's real row, near the **low** end, and DOCS
tells an operator to look up their own. A **Walloon or Brussels** site should
not use this scalar at all — its distribution tariff has a day and a night band
and belongs in TARIFF-17's `price_taxes_profile`.
⚠️ **THIS IS A DISPLAY AND GUARD FIX. IT DOES NOT IMPROVE ARBITRAGE AND CANNOT.**
This scalar is a **LEVEL** component: an additive constant cancels exactly in
`max − min` and leaves the argmin where it was. TARIFF-17 shipped the machinery
and the proof — six flat scalars spanning and exceeding the whole per-DSO range
give the **identical** cheapest hour and the **identical** spread to the last
digit (`test_price.py` 16b, printed on every run), and none of that changes
here. No trade happens that did not happen before *because of the level*. What
does change is two things, and nothing else:
* **every number an operator reads** — the all-in prices on the status page,
`custom_formula`'s startup preview and the recorded price history. At half
the true non-energy component they made the add-on look considerably better
than it is; and
* **§17's negative-price guard** (`arbitrage.py`), the one rung that tests an
**absolute** all-in price rather than a difference. It fires when
`spot < −(markup + taxes)`, so its boundary moves **−0.09000 -> −0.14736
EUR/kWh of spot**. A −0.11868 EUR/kWh spot hour used to read **−0.02868**
all-in and the discharge was refused as paid-for disposal; at the real tax
level it reads **+0.02868** and discharges, because that hour genuinely
paid.
`test_arbitrage.py` section 2c drives both sides through the shipped
`decide()`, and pins the boundary from below as well.
⚠️ **AND THAT ASYMMETRY IS WHY THE DEFAULT SITS AT THE BOTTOM OF THE BAND.** Set
too **high**, the figure carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal — §17's own case. Set too **low**, it only
declines a discharge that would have paid. One default standing in for eight
DSOs belongs at the bottom.
#### Who is affected
* **A site that set `price_taxes_eur_kwh` itself: nothing changes.** Its value
is kept and is never silently replaced.
* **A site whose stored options blob already carries the key: nothing changes
either, and this is most existing installs.** The Supervisor sets
`App.options = deepmerge(config.yaml defaults, stored options)`
(`tools/supervisor_selfapi_probe.py` §D), so a moved default reaches only
keys the blob does **not** carry — and the reference install's export carries
**70 of the 71**, the one absentee being a key added after its last save.
Such a site goes on running at `0.06` and reading numbers that are about half
its real all-in price until somebody opens the Configuration tab. ⚠️ **That
is a real limit of this card and it is not fixed here**: the change reaches a
**fresh install**, and an existing one only when its options are re-saved.
⚠️ **NOT re-measured on this branch** — the 70-of-71 figure is 0.9.0's, and
PKG-09's `tools/predeploy_check.py --options <live blob>` against the live
options is what would confirm it before deploying.
* ⚠️ **A site with `price_taxes_profile` SET would have been refused at startup,
and is deliberately not.** TARIFF-17 made a taxes period table beside a
**non-default** `price_taxes_eur_kwh` a `SystemExit(78)` refusal, comparing
against the shipped default precisely because the Supervisor writes the key
whether it was typed or not. Moving the default turns every stored `0.06`
into "non-default" **without anybody editing anything**, so a working Walloon
or Brussels site would have stopped starting on the upgrade over an inert
number the table overrides anyway. `price.py` now carries
`SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)` and `validate.py` treats a
superseded default as *untouched* as well. **The rung is not weakened**: a
figure that is neither the shipped nor a superseded default — ORES's 0.1198,
say — is still refused, by name, and the refusal now says which of the two
figures it will accept (`test_validate.py`, four new checks plus the
difference precondition).
Nothing hardcoded `0.06` in that rung — it read `PRICE_OPTION_DEFAULTS` — which
is why the interaction is a *behaviour* change rather than a drift, and why it
had to be decided rather than noticed.
`config.yaml` and `PRICE_OPTION_DEFAULTS` moved together and CFG-03's tripwire
(`test_price.py` section 1, `test_defaults_pin.py`) stayed green by agreeing,
not by being widened. ⚠️ One check did have to be **rewritten rather than
re-pointed**: `test_price.py` section 0 used to justify its own grading
constants by observing that the shipped pair was degenerate — `0.06` is exactly
twice `0.03`. TARIFF-18 ends that coincidence, so the sentence is no longer true
and is not kept; the file now asserts per-figure that neither shipped default is
what it grades with, which is the property that was actually load-bearing.
### TARIFF-13 - the coverage figure beside the peak, and which quarter gets it
**The capacity-tariff status row's `N quarter(s) banked` is read from the
@@ -0,0 +1,406 @@
# 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
the CI image and on TEST-23 stage 3's closed-loop rig.
---
## 0. The four things a reader should take away
1. **"Default-on" is delivered as `auto`, not as `true`, and the difference is
an install that is regulating a real battery today.** The Supervisor
composes an add-on's options as *its defaults, overlaid with the stored user
options*, so a new key with a new default reaches **every existing install**
on the next update. A boolean `shadow_mode: true` would silently stop the
reference site. §1.
2. **Shadow mode WRITES. It writes 0 W, on the ordinary heartbeat, through the
ordinary single writer.** "Commands nothing" implemented as "stops writing"
would leave the inverter latched on whatever was standing — it has no meter
timeout — and would put the ESP32 into a tripped stale-input watchdog for
the whole run. §2.
3. **The sign watchdogs are inert, and that was the decision that had to be got
right.** Fed the shadow decision they would trip within `sign_disagree_s`
and **latch a trip that survives into live operation and a restart**. §4.
4. **The loop leg's shadow decision is one slew step, not a trajectory, and
that is a real limitation of this feature.** §5.
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.
---
## 1. The default
### The literal reading, and what it costs
`config.yaml`'s `options:` block **is** the default. There is exactly one of it,
and HA's Supervisor resolves an add-on's effective options as
```
{**config.yaml options, **the stored user options}
```
A key that has never been stored therefore takes the file's value — on a fresh
install and on an install that has been running for months, identically. There
is **no mechanism in the add-on options system** by which one key can default
differently for the two. So the card's title, taken at face value, is:
> the next `ha addons update` stops the battery at the reference site, and the
> only symptom is a battery that has stopped doing anything.
That is strictly worse than every defect shadow mode exists to catch. It is
refused.
### What was shipped instead
`shadow_mode` is a three-valued `list(auto|on|off)` defaulting to `"auto"`:
| value | resolves to |
|---|---|
| `off` | live |
| `on` | shadow, always |
| `auto` | shadow **while control is stopped**; live once it is started |
`auto` reads `Controller.auto` — the persisted start/stop state, which a fresh
install takes from `auto_start` and which the *Start / stop control* button
moves. No new persisted state, no migration, no guess:
* a **fresh** install ships `auto_start: false`, has no stored `auto` key, and
therefore comes up **in shadow mode**. It observes a day of decisions before
it is ever allowed to command anything. That is the card's intent, delivered
where it is safe;
* an install that is **controlling** is started, so `auto` resolves to live and
**not one watt changes**;
* an install that is **stopped** keeps its actuator at 0 W exactly as before —
the same number, from the same writer, on the same heartbeat. What changes is
what the status page and the action log **say**, and that the decision path
now runs and is recorded.
⚠️ **Under `auto`, pressing Start leaves shadow mode.** That is the point of
`auto`: the button means "control the battery". An operator who wants the
decisions recorded *while the loop runs* — the configuration this card exists
to make possible — sets `on` explicitly, and cannot reach it by accident.
⚠️ **An unrecognised value resolves to SHADOW, and is REFUSED at startup.**
`shadow.mode()` fails toward inaction; `validate.py` rule 11 refuses the value
by name so the reinterpretation is never silent. A typo is not a decision, and
an install silently left in shadow mode commands nothing indefinitely while its
status page calmly explains that this is intentional.
---
## 2. "Commands nothing" is a number, not a silence
The inverter holds its last command **forever** — `HANDOVER.md` §6 says so, and
`firmware/goodwe-master.yaml`'s header records what it cost when a controller
stopped refreshing: *"Illusmart went silent mid-command and the inverter held
+5000 W for 113 s"*. `firmware/goodwe-master.yaml` states the contract that follows — *"HA
must now REWRITE the setpoint every ~10 s even when the value has not
changed"* — because without a refresh, **"stale" and "steady" are the same
thing on the wire**.
So shadow mode holds the actuator at `shadow.WIRE_W` = **0.0 W**, refreshed on
the ordinary heartbeat. Two consequences were weighed and are recorded:
* **it is not silence.** A shadow mode that stopped writing would leave the
standing command up until the ESP32's 30 s watchdog noticed — and at a site
with no such board, forever;
* **it does not use an alarm as a mode.** Letting that watchdog trip for the
duration of a shadow run teaches the operator that `wd_tripped` is normal,
which is exactly the training that makes it useless when the add-on has
actually died.
### It is not a second writer
SAFETY-28's rule is one actuator, one writer. Shadow mode files **no claim**,
owns **no transport**, and calls nothing in `hass.py`. It is a **substitution**
inside `Controller._write_unit()` — the single choke point every write in this
add-on already reaches, including the heartbeat, the stop button, `shutdown()`
and `maint_abort()` — in exactly the form of the three substitutions above it
(non-finite → 0 W, unauthorised → 0 W, clamped → the rating).
It is the **last** of them. Below SAFETY-28's gate and below SAFETY-03's clamp,
so both still grade the number the decision path actually produced, and a
shadow run can report *"it would have hit the rating here"*.
---
## 3. What the run records
| surface | what it carries |
|---|---|
| status banner | `SHADOW MODE - the battery is NOT being controlled...`, **first in the cascade**, amber — red, with the latch named, if one is standing |
| status row `Shadow mode` | which rung decided, the watts it **would have commanded 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, 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
a line per cycle would flush the context of the run's own findings inside four
minutes at the 15 s cycle floor.
---
## 4. The failsafes, one by one
| failsafe | in shadow mode | why |
|---|---|---|
| SAFETY-02/30 sign watchdogs | **INERT** | They grade `Inverter.standing_w()` — what this controller has standing on the wire — and shadow mode holds that at 0 W, so they cannot arm. ⚠️ Fed the shadow decision instead they would disagree with a meter that never saw it and **latch a trip that survives into live operation and a restart**: shadow mode would manufacture the failsafe it exists to help debug. ⚠️ **Two rungs hold it there** — `last_write` (the wire gate) and `inv.target` (the re-seed) — which is MUTATION.md's masking shape, so the mutation that reproduces the hazard defeats both as a pair (M6). Driven singly, dropping the re-seed does not reproduce it at all — measured |
| SAFETY-03 clamp | **OBSERVES** | Runs on the decision, sets `clamp_last`, logs every engagement. Changes no command |
| SAFETY-28 write gate | **OBSERVES** | Same placement, same reason: the question is who chose the number |
| SAFETY-18 meter freeze | **OBSERVES**, unchanged | It takes no action live either; both halves of its conjunction are as true in shadow |
| SAFETY-07/24/25 peak guard | **OBSERVES, 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 |
| inputs missing, non-finite, allocator refusal, arbiter contradiction | **UNCHANGED** | All resolve to 0 W, which is what the wire gets anyway |
⚠️ **A latch that is already up stays up.** Nothing here clears one. The shadow
banner **names** any standing latch and goes red for it, because a latch that is
up while the controller is inert is up the moment it stops being inert.
⚠️ **Maintenance is not in that table because shadow mode does not touch it.**
The state machines are ticked on every cycle whether control is started or not,
so a stopped install already runs them against a battery that does not move.
Shadow mode inherits that, unchanged and unimproved. Recorded here rather than
quietly fixed under another card's name.
---
## 5. The limitation, stated rather than discovered later
`Controller._shadow_settle()` re-seeds the carried command (`self.target` and
each `inv.target`) to `WIRE_W` at the **end** of every shadow cycle — after
every in-cycle guard has read the decision. Three machines read that value as a
statement about the **hardware**: the loop's `prev_w` and integrator,
`standing_w()`, and SAFETY-42's share comparison. Re-seeded, they track reality,
and **the exit is safe by construction** — the carried command already equals
the standing one, so the first live cycle slews from 0 W like any other.
⚠️ **The cost is real.** The loop's `prev_w` is therefore 0 W on every shadow
cycle, so the loop leg's recorded decision is **one slew step from the standing
command** and stays there. A shadow run answers *"what would this controller
command NOW, from where the plant really is"* — a true statement about the
present — and **not** *"what trajectory would it have followed"*. The trajectory
is a closed-loop property, it did not happen, and reporting it would be a
simulation of a plant that does not exist; three cycles in, such a number
describes nothing.
⚠️ **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`.
---
## 6. How "nothing reaches the inverter" is graded
**Not on a flag.** A check that asserted `Controller.shadow` would pass on a
build in which the flag is set and the substitution is deleted — which is
exactly mutation **M1**.
`test_clamp.py` part 8 drives the **shipped** `Controller.cycle()` and the
shipped heartbeat against a recording Home Assistant stub, and asserts on what
reached `set_number()`:
* **the precondition first** — the same stimulus with `shadow_mode: off` puts
30 non-zero writes on the wire, the largest 5000 W. MUTATION.md habit 4: a
check whose data cannot reach the failure it names is decoration;
* then `set(writes) == {0.0}`, **and** that there were 26 of them — a check
that only said "no non-zero write" would pass on the silent build too;
* entry and exit are measured on `inv.last_write` — what the inverter is
latched on, updated only on an **accepted** write — either side of the
boundary, against the same run that never entered shadow mode.
The closed-loop half is `/home/homelab/s10-mut/shadowloop.py` on TEST-23 stage
3's rig: the same hour, twice, against `plantsim.Plant` through the firmware's
own frame encoder.
⚠️ **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 |
|---|---|---|
| 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** |
**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.
⚠️ **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,
which drives the real `Controller`. ⚠️ **And the rig's transcribed cycle
predates SAFETY-45**: `arbitrage.decide()` gained a required
`charge_headroom_w` after `478ba93`, and the harness supplies it from the
**shipped** `peakguard.charge_headroom_w()` reading the **shipped** peak
decision rather than from a constant, so SAFETY-45 stays in the run.
---
## 7. The mutation battery — 22 mutations, no survivors
Harness: `/home/homelab/s10-mut/run.py`, backups beside it. Each run restores
from backup, purges `__pycache__` **on the host**, and grades through the CI
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
into the tree under test (TEST-15). The full table is in the branch report;
one per new behaviour, including **M1** (the substitution deleted — a write
escapes), **M2** (it returns instead of substituting — the inverter is left
latched), **M3** (it moves above SAFETY-28 and SAFETY-03 — the observers go
blind), **M8** (`auto` resolves to shadow unconditionally — the literal
default-on that stops the live install), **M12** (the stop claim is filed in
shadow — every recorded decision reads `stopped`), **M21** (the substitution
and the re-seed together) and **M22** (the re-seed moved one line earlier —
SAFETY-18 grades a 0 W no decision produced and goes permanently blind).
⚠️ **Two findings from driving it, both of the kind MUTATION.md says to expect.**
* **M2 first came back a SURVIVOR and was not one.** The "stop writing
altogether" build left `inv.last_write` as `None`, the check did `float()` on
it, and the section died with a `TypeError` — grading none of the checks
after it and reporting **zero reds**, which reads exactly like a survivor.
That is FCST-06's shape. The fixture now hands `None` back and names it as
its own check; M2 is red on three.
* **M6 had to become a PAIR, and only the battery said so.** `standing_w()`
prefers `last_write` — the 0.0 W the wire gate accepted — and falls back to
`inv.target`, which the re-seed also holds at 0.0. Either rung alone keeps
the detector inert, so dropping the re-seed **singly** came back red on the
limitation check and on **nothing about the latch**: the hazard it names was
not reproduced. TEST-16's rule with a new name on it.
* **M7/M8/M9/M19 had to MOVE SUITES.** They defeat `shadow.engaged()`, and a
defeated default stops `test_clamp.py` part 3's wired run reaching the wire
at all — aborting the file with an `IndexError` before part 8 is graded. The
red count there was a lower bound naming the saturation freeze. The mode
table now lives in `test_validate.py`, which drives no Controller.
* **`shadow_would_w = float(self.target)` is an EQUIVALENT mutation and was
measured as one.** `_shadow_settle()` runs before the re-seed, so at that
line `self.target` **is** `resolution.target_w`. It is recorded as equivalent
rather than reported as a survivor, and the non-equivalent form of the same
defect — the record becoming the **wire** — is driven instead.
### The table
Each row: the mutation, the file, the byte delta, the suite it was graded in,
and how many **named** checks went red. `⚠️` marks a row whose run did not
reach the end of its file, so its count is a **lower bound** (MUTATION.md).
| # | mutation | file | Δ | suite | reds |
|---|---|---|---|---|---|
| M1 | **a write escapes** — the substitution deleted | `main.py` | −6 B | clamp | 5 |
| M2 | **silence instead of 0 W** — it returns rather than substituting | `main.py` | −15 B | clamp | 3 |
| M3 | **the observers go blind** — it moves above SAFETY-28 and SAFETY-03 | `main.py` | +58 B | clamp | 1 |
| M4 | the site re-seed dropped — the exit resumes from a fiction | `main.py` | −36 B | clamp | 3 |
| M5 | the per-unit re-seed dropped | `main.py` | −22 B | clamp | 1 |
| M6 | **PAIRED**: `standing_w()` reads the target **and** the re-seed dropped — the sign latch that survives into live operation | `inverter.py` + `main.py` | −170 B | watchdog | 3 |
| M7 | `auto` inverted — shadow while started, live while stopped | `shadow.py` | +2 B | validate | 4 |
| M8 | **default-on for everyone** — `auto` always shadow; the literal reading that stops the live install | `shadow.py` | −15 B | validate | 2 |
| M9 | an unreadable value resolves to LIVE instead of shadow | `shadow.py` | +1 B | validate | 1 |
| M10 | `validate.py` stops refusing an unknown value | `shadow.py` | −9 B | validate | 9 |
| M11 | the loop leg gated on `auto` alone again — the decision path stops running | `main.py` | −17 B | clamp | 1 |
| M12 | the stop claim filed in shadow — every decision reads `stopped` | `main.py` | −20 B | clamp | 1 |
| M13 | the banner goes green while nothing is commanded | `shadow.py` | −2 B | clamp | 1 |
| M14 | the banner loses the standing latch | `shadow.py` | −2 B | clamp | 1 |
| M15 | the row renders the standing command instead of the decision | `shadow.py` | −8 B | clamp | 1 |
| M16 ⚠️ | the row disappears when live | `main.py` | +43 B | clamp | 1 |
| M17 | the record becomes the wire | `main.py` | −6 B | clamp | 7 |
| M18 | level-logged — the 40-row action log floods | `main.py` | −23 B | clamp | 1 |
| M19 | the two rungs render identically (masking) | `shadow.py` | +22 B | validate | 1 |
| M20 | the shipped default moves to `off` in `config.yaml` alone | `config.yaml` | −1 B | defaults_pin | 1 |
| M21 | **PAIRED**: the substitution **and** the re-seed deleted together | `main.py` | −103 B | clamp | 10 |
| M22 | the re-seed moved one line earlier — SAFETY-18 grades a 0 W no decision produced and goes permanently blind | `main.py` | +40 B | clamp | 1 |
**Survivors: none.** ⚠️ **M17's `+0 B`-adjacent sibling** —
`shadow_would_w = float(self.target)` — is recorded in §7 as **equivalent**
rather than counted here.
@@ -0,0 +1,221 @@
# SAFETY-45 — the trade is SIZED by the capacity guard, not switched by it
**Status: closed.** `peakguard.charge_headroom_w()` publishes the watts the open
billed quarter can still afford; `arbitrage.decide()` takes it as a required
keyword-only argument and sizes the BUY leg to it; `main.py` passes the measured
guard's own number at its one call site. `peakguard.decide()` is **not changed** —
byte-identical to `release/1.0` at `478ba93`, `sha256(decide) = 200090d0835a3a55`,
7252 B on both trees.
⚠️ **THE HOUSE LOAD IN EVERY FIGURE BELOW IS SYNTHETIC.** TEST-23 stage 3's
`priceloop.py` drives a flat modelled house against a modelled pack
(`plantsim.Plant`, 800 W/s ramp into a 10 s lag, 15 360 Wh). The PRICES are real,
the decision code is the shipped code, the billed-quarter accumulator is
`app/p1.py`'s own — the load is not. Nothing here is a measurement of the live
site at 192.168.2.158, which this work did not touch.
## 0. The defect, measured
TEST-23 stage 3 found a sustained limit cycle with arbitrage armed and a charge
decision active. Re-measured here on the same rig against the same checkout,
metrics recomputed rather than copied — a **charge onset** is a cycle at which
`arbitrage.decide()` transitions into `ARBITRAGE_CHARGE`, which is the edge the
rig already logs:
| `peak_cap_w` | house | onsets/h | mean period | grid swing | billed quarters (W) |
|---|---|---|---|---|---|
| 1500 | 1200 W | **28** | 131.1 s | **−455.5 … +3200.0 W** | 1490.1 / 1496.5 / **1508.8** |
| 3500 *(shipped)* | 1800 W | 8 | 509.3 s | +127.5 … +3800.0 W | **3511.3 / 3536.0 / 3559.1** |
| 3500 *(shipped)* | 3000 W | 32 | 114.8 s | +1202.0 … +5000.0 W | **3518.9 / 3542.2 / 3556.7** |
| 5000 | 1200 W | 1 | — | +1240.6 … +3200.0 W | 3169.5 / 3200.0 / 3200.0 |
The last row is the **negative control**: `load + max_w = 3200 W` never reaches
the cap, so there is no cycle to remove. The bite rule is `load + max_w >
peak_cap_w`, which at the shipped `peak_cap_w: 3500` / `max_w: 2000` is any house
drawing over 1500 W in the cheapest quarter.
§10.1's ordering did win on the money — the guard kept the quarters within tens
of watts of the cap — but **every billed quarter in the three biting rows
finished OVER it**, which is the thing §9.1 bills. §10.2: *"an EMS that
oscillates is worse than no EMS, both for hardware wear and — per §9.1 — for the
peak it is trying to avoid."*
## 1. ⚠️ The mechanism is NOT mainly the quarter boundary, and the card says it is
The card, and `.cache/s45.md` before it, describe the loop as *"at each quarter
boundary the projection resets to 0, the guard disengages, and arbitrage re-arms
at full power."* **That is not what the 28 onsets are.** Their timestamps, off
the run above:
1, 231, 351, 471, 581, 696, 806, 896, 1136, 1256, 1371, 1486, 1596,
1711, 1811, 2061, 2176, 2296, 2411, 2521, 2626, 2716, 2971, 3091,
3206, 3321, 3431, 3541 (s, quarter boundaries at 900/1800/2700)
**23 of the 27 gaps are 90–120 s, mean 111.5 s, entirely inside one quarter.**
The four long gaps — 230, 240, 250, 255 s — are precisely the four that SPAN a
boundary, so the boundary **lengthens** the interval rather than causing an
onset. Only three onsets land within 15 s of a boundary and one of those is
`t = 1`, the start of the run.
The real cycle, read off the recorded fields at t = 1000…1180 s:
1. the trade charges at `−2000 W`; the meter sits at 3200 W against a 1500 W
ceiling; `wanted = batt_w + excess` is **negative** (−132 → −8 W), so
`decide()` files **nothing** and arbitrage is not constrained at all;
2. `wanted` drifts up as the quarter's own history worsens and the `900 /
remaining` gain grows, and crosses zero at t = 1055 with **`floor_w = 5.3 W`**;
3. that 5 W floor flips `arbitrage.guard_state()` to `True`, which refuses the
**whole trade** — so the setpoint goes from −2000 W to +5 W and the
grid-following loop, which targets grid = 0 and not grid = ceiling, takes the
actuator and discharges to **+1870 W**, exporting 420 W;
4. the projection collapses, `wanted` goes negative, the guard releases, and
arbitrage re-arms at **`max_w`**. 115 s later, again.
So the defect is **a binary interlock keyed on a continuous quantity**, and the
quarter boundary is one of the ways it is crossed rather than the cause. The
card's other claim — *"projection 3092 W against a 1509 W ceiling, `engaged=False`,
for 170 consecutive seconds"* — reproduces, but it is the **first** such window
(`first_engaged_t_s = 151.0`); the steady-state silent window is ~40–65 s.
None of this changes what the fix has to do. It is recorded because a card that
says "the boundary is the trigger" invites a fix in the boundary handling, and
such a fix would have removed four of twenty-eight onsets.
## 2. What was rejected, and why
### 2a. Reverting `decide()`'s `wanted <= 0.0` rung — rejected, and it is a trap
That rung is 2026-08-20's anti-bang-bang. Its own comment records the cost of not
having it: 17 engage/release cycles in 900 s and a quarter finishing 49 W over
the limit it had spent a quarter of an hour holding. It is untouched, pinned on
the AST in `test_peakguard.py` 13g, and `tools/replay_ac3.py` reproduces the same
190/190, 110 engaged cycles, 5 blind, 878 Wh on this branch as on `478ba93`.
### 2b. Filing the affordable charge as a NEGATIVE `lo` — rejected
Arithmetically this is the prettiest answer: `lo = wanted` with no sign gate is
continuous, is exactly the control law `decide()` already computes, and would
have put the constraint on the arbiter where it binds every claimant rather than
only arbitrage. It was rejected on two grounds, in this order:
1. ⚠️ **IT CREATES A NEW `lo > hi` CONTRADICTION CLASS AGAINST THE MAINTENANCE
SHAPING.** `main.py`'s cheap-window maintenance charge files
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` — a NEGATIVE `hi`.
`arbiter.py` clause 2 applies every limit at or above the winner's priority
and takes the most restrictive, with **no notion of one limit out-ranking
another**; a peak `lo = −300 W` against a shaping `hi = −1000 W` is `lo > hi`,
which resolves to **0 W — the battery parked, in the one quarter of the month
that sets the bill**. That is the exact failure `peakguard.shaping_suspended()`
exists to prevent, and the only way to close it would be to key that
suppression on "a claim exists" — which, on every site this card is about,
means the maintenance charge-only shaping is suspended **permanently**. A
silent, large change to a different subsystem is not a price this card should
pay.
2. It changes the meaning of `PeakDecision.engaged` at five call sites outside
this module — `arbitrage.guard_state()`, `shaping_suspended()`,
`claim()`, `main.py`'s edge-logged action line and `main.py`'s status row —
each of which today means "the guard is HOLDING" and would silently start
meaning "a claim exists".
`test_peakguard.py` part 5's sweep asserts **no negative floor, ever**, and the
reason it gives ("a `lo` limit is incapable of capping discharge or export only
while it is non-negative") is, strictly, not the true reason — a `lo` of any sign
caps neither. The invariant is still right and is still worth holding; §2b above
is the argument that actually supports it, and it is recorded here rather than
left for the next person to re-derive.
### 2c. A backoff, cooldown or hysteresis constant in `arbitrage.py` — rejected
The card asks whether the second half of the fix belongs in arbitrage: *"a trade
refused at 14:59 and re-armed at 15:00 has learned nothing from the refusal."*
It does belong there, but as a **size**, not as a memory. `arbitrage.py`'s
hysteresis note is explicit that the switching decision is "which hour is it" and
that **no timer is needed and none is invented**; `peakguard.py`'s design note
spends a paragraph on why it carries no hysteresis constant. A cooldown would be
a second control loop compensating for a limit the first one declined to file,
and its constant would outlive whatever it was tuned against. The trade now
re-arms at `ceiling − load` instead of at `max_w`, which is what "learning from
the refusal" means when the refusal is a measurement.
## 3. What was done, and the precedent it follows
`control.maintenance_charge_floor()` has capped MAINTENANCE charging at *"the
headroom left under the site's cap"* since the loop was written, and its
docstring says why:
> "A maintenance charge is the only thing this system does that is big enough and
> long enough to set that peak, so it is capped by the headroom left under the
> site's cap."
⚠️ **THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED.** Price arbitrage is a
second thing that is big enough and long enough — it commands `max_w` for a whole
tariff interval, deliberately, against the meter — and it was given no such cap.
This card gives it the same shape, with one difference that matters:
`maintenance_charge_floor()` sizes itself from `peak_forecast_entity`, which is
OPTIONAL and empty on a default install — the exact hole SAFETY-25 had to close
in `shaping_suspended()`. `charge_headroom_w()` comes off `decide()`, which runs
wherever P1 does.
**The number is `wanted_w` read in the other direction.** `decide()` already
computes `wanted = batt_w + excess` as the DISCHARGE that lands the quarter on the
ceiling; a negative `wanted` is a permitted CHARGE of `−wanted`. No new
arithmetic, no new constant.
Two properties make it safe to hand a strategy, and both are swept rather than
argued (`test_peakguard.py` part 13):
* ⚠️ **IT IS INVARIANT IN THE BATTERY.** Raise the charge by dW and the meter
rises by dW, so `excess` rises by exactly dW while `batt_w` falls by dW:
`wanted` does not move. Measured across `batt_w` from −5000 W to +2000 W on one
banked quarter — the headroom is 1350.0 W at every one of the six points while
the `excess` those runs see moves through six distinct values. It is a
**feed-forward** number, so consuming it adds no feedback path around the plant.
The fixed point is `charge = ceiling − load`, with the meter ON the ceiling and
`wanted` parked a whole `ceiling − load` clear of the engagement boundary.
* **HOLDING IMPLIES ZERO OR UNKNOWN.** Across 1080 swept combinations, not one
ENGAGED decision answers a positive headroom, so the function can never hand a
caller permission the guard's own `lo` is at that instant refusing.
## 4. After, on the same rig, same command, same checkout
| `peak_cap_w` | house | onsets/h | grid swing | energy bought | billed quarters (W) |
|---|---|---|---|---|---|
| 1500 | 1200 W | 28 → **1** | 3655.5 → **282.0 W** | 180.0 → **285.2 Wh** | **1500.0 / 1500.0 / 1500.0** |
| 3500 | 1800 W | 8 → **1** | 3672.5 → 1687.6 W | 1652.9 → 1616.1 Wh | **3500.0 / 3500.0 / 3500.0** |
| 3500 | 3000 W | 32 → **1** | 3798.0 → **474.7 W** | 482.9 → 475.3 Wh | **3500.0 / 3500.0 / 3500.0** |
| 5000 | 1200 W | 1 → 1 | **identical** | **identical** | **identical** |
* **One onset** means the trade starts once and runs for the whole window. The
battery is trading 100 % of the hour where it used to trade 43 %, 92 % and 54 %.
* **Every billed quarter lands exactly on the cap and none over it**, where every
quarter in the three biting rows used to finish over.
* **The money.** At 1500/1200 the site buys **58 % MORE** energy, because a
continuous 300 W trade beats a 2000 W trade that is cancelled for 57 % of the
hour. At the two shipped-default rows it buys **2.2 % and 1.6 % less** — and in
exchange the worst quarter drops from 59 W over the cap to 0 W over it. The
capacity tariff bills the month's single worst quarter with no averaging and no
forgiveness (specs.md:583), so that is not a trade-off, it is the ordering
§10.1 asks for.
* The remaining 1687.6 W at 3500/1800 is the opening **ramp**, not a cycle: the
pack goes from 0 to 1700 W of charge in about ten seconds and the meter with
it. One onset, no export, no reversal.
* The last row is bit-identical before and after. **This card costs nothing on
the installs it is not about.**
## 5. What this does NOT fix, with sizes
* **TEST-23 finding 1 — the export overshoot at the END of a trade** (356–938 W,
≈0.37 × `max_w`) is untouched and is a different card. It happens when the
cheap interval ends and the grid-following loop takes back an actuator that was
holding a charge; nothing here changes the handover.
* **A house whose load ALONE exceeds the cap** (`load > peak_cap_w`) still puts
`wanted > 0`, still engages the guard, and still refuses the trade outright
through the interlock — correctly, since you cannot buy in a quarter you are
already over on. The loop then drives to grid = 0 rather than to grid =
ceiling, which is more discharge than the guard asked for. Not swept here: all
four (cap, load) pairs on the record have `load < cap`.
* **`arbitrage.claim()` still files the SIZED number, but the ARBITER can clamp
it further** and the strategy is not told. The reason line names the guard's
headroom, not the arbiter's `bound_by`. Pre-existing; not widened.
* **The container leg was not re-run.** `hadrive.py` needs `test23s3-addon`
started, and that container is deliberately stopped because it is a second
writer on the setpoint entity under measurement. The in-process rig's twelve
transcribed lines are pinned by `test_arbitrage.py` 3b's AST check on
`main.py`'s call site, which is the line this card adds to them.
@@ -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.
@@ -85,11 +85,14 @@ refusal: the card's own point must land inside 0.02–1.00 EUR/kWh.
⚠️ **Its floor is applied to the ENERGY figure and not to the all-in one, and
that asymmetry was found by a mutation rather than by reasoning.** The shipped
`price_taxes_eur_kwh` is 0.06 — three times the floor — so a card read a
`price_taxes_eur_kwh` was 0.06 — three times the floor — so a card read a
hundred times too small comes out at 0.0016 EUR/kWh of energy, is carried over
0.02 by the flat tax addend alone, and passes a band applied to the total. The
hundredfold error is exactly what the band exists for. Graded from both
directions in `test_price.py` 15e/15i and driven red as M10.
directions in `test_price.py` 15e/15i and driven red as M10. *(Annotated
2026-09-13: TARIFF-18 raised the default to 0.11736, nearly six times the
floor. The asymmetry is unchanged in kind and larger in degree — the flat
addend that carries a hundredfold-small card over 0.02 got bigger.)*
## 4. Fixed coefficients. No expression evaluation of any kind
@@ -155,4 +158,9 @@ granularity it was handed. Graded.
* **The hour-varying network charge that bites Wallonia.** Separate card.
* **Raising `price_taxes_eur_kwh` from 0.06 to ~0.12.** Separate card, and an
owner's decision. Note §3b: the band's floor is deliberately insensitive to
whatever that number becomes.
whatever that number becomes. *(Annotated 2026-09-13: that card is
**TARIFF-18** and it shipped on this branch at **0.11736** — the Eneco
residential card for Flanders, September 2026, incl. 6 % VAT, summed from
Fluvius Antwerpen distribution 5,35 + bijzondere accijns 4,876 + bijdrage
groene stroom en WKK 1,51 c€/kWh. §3b held: the floor is still on the energy
figure and nothing in this module needed a new number.)*
+390 -29
View File
@@ -601,6 +601,168 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below |
#### Shadow mode (`shadow_mode`, SAFETY-10)
Shadow mode runs the **whole** decision path — the price strategy, the capacity
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
them, SAFETY-28's authorisation gate and SAFETY-03's clamp — and **sends nothing
to the inverter**. It is how you find out what this add-on *would* do on a site
before you let it do it.
Every defect in the optimiser that this project has found was invisible from
outside while it was happening: a price lookup answering for the wrong
quarter-hour, a trade against a binding capacity cap that ran 28 charge onsets
in an hour, an SoC ceiling churning 598 Wh to gain 310 Wh. Each one was paid for
at the meter first and noticed afterwards. Shadow mode is the surface that
would have shown all three on the day.
**What "commands nothing" means, exactly.** Every inverter is held at **0 W**,
written by the ordinary single writer and refreshed on the ordinary heartbeat.
It is not silence. ⚠️ **The inverter holds its last command forever** — it has
no meter timeout — so a mode that simply stopped writing would leave whatever
was standing when you switched it on standing for as long as you left it on,
under a page saying nothing was being commanded. Refreshing 0 W is also what
the ESP32's 30 s stale-input watchdog requires of us: going quiet would put
that board into a *tripped watchdog* state for the whole run, which is an alarm
being used as a mode.
**The three values.**
| value | what happens |
|---|---|
| `auto` | **the shipped default.** Shadow mode while control is **stopped**; ordinary control once control is **started**. A new install ships `auto_start: false`, so it comes up in shadow mode and observes before it ever commands anything. An install that is already controlling is started, so nothing about it changes |
| `on` | shadow mode **always**, whatever the *Start / stop control* button says. This is the one to pick to watch an armed site for a day — the loop runs, arbitrage decides, the peak guard files its claims, and the battery does not move |
| `off` | ordinary control |
⚠️ **Existing installs are not changed.** The default is `auto` rather than a
boolean `true` precisely because a boolean would have changed them: the
Supervisor composes an add-on's options as *its defaults, overlaid with your
stored options*, so a new key with a new default reaches every install on the
next update. `shadow_mode: true` would have silently stopped a battery that is
regulating today, and the only symptom would be a battery that stopped doing
anything.
⚠️ **Under `auto`, pressing *Start* leaves shadow mode.** That is the point of
`auto` — the button means "control the battery". Set `on` if you want the
decisions recorded while the loop runs.
**What you get out of it.** The status page carries a red-amber banner saying
the battery is not being controlled, a `Shadow mode` row saying which rung
decided, what it **would have commanded 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`.
**The failsafes.** The sign watchdogs (SAFETY-02/30) are **inert**: they grade
what this controller has standing on the wire, and that is 0 W, so they cannot
arm. That is deliberate and it is the single most important decision in this
feature — fed the shadow decision instead they would disagree with a meter that
never saw it, and **latch a trip that survives into live operation and a
restart**. The clamp (SAFETY-03), the write-authorisation gate (SAFETY-28), the
frozen-meter alarm (SAFETY-18) and the capacity guard all **observe**: they run
on the number the decision path produced, so a shadow run can tell you the
clamp would have engaged. ⚠️ **The 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.
⚠️ **A latch that was already up stays up.** Shadow mode does not clear
anything. The banner names any standing latch and goes red for it, because a
latch that is up while the controller is inert is up the moment it stops being
inert.
⚠️ **Maintenance is unchanged.** The maintenance state machines are ticked on
every cycle whether control is started or not, so they already run against a
battery that does not move on a stopped install. Shadow mode inherits that
exactly; it neither improves it nor makes it worse.
⚠️ **What a shadow run will NOT tell you — 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
@@ -979,6 +1141,7 @@ What exists today, and what does not:
|---|---|
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
| Maintenance charging capped under `peak_cap_w` | ✅ |
| **Arbitrage charging capped under `peak_cap_w`** | ✅ — SAFETY-45, sized to the guard's own MEASURED headroom rather than to `peak_forecast_entity` |
| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
@@ -1041,6 +1204,19 @@ projected over — raises one notification (`goodwe_peak_saturated`), because th
is the one state where the money will be spent and shedding load is the only
thing left that changes the bill.
**And it sizes a price-driven charge, not only a forced discharge (SAFETY-45).**
The same arithmetic read the other way answers *"how hard may this site charge
without putting the quarter over"* — `peakguard.charge_headroom_w()` — and
`arbitrage.decide()` sizes its buy leg to it. Before that, a trade commanded
`max_w` until the projection rose far enough to engage the guard, at which point
the interlock cancelled the trade outright, the loop took the actuator, the
projection collapsed and the trade came back at `max_w`: **28 charge onsets in
one hour** on TEST-23 stage 3's closed rig, with the grid swinging −456 to
+3200 W and every billed quarter finishing over the cap. It is one onset now,
the trade runs continuously at the affordable rate, and the billed quarters land
**on** the cap. ⚠️ The guard is what changed *around* arbitrage; `decide()`
itself is untouched, so nothing above this line is different.
**No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The
coverage threshold is `meter_max_age_s`, the freeze predicate is
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
@@ -1371,8 +1547,9 @@ does, and only after you have read why.
|---|---|
| `price_provider` | `off` (default) or one of the three plugins below |
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule |
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
| `price_taxes_eur_kwh` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
| `price_csv_path` | Where `manual_csv` reads its file |
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
| `price_horizon_h` | How far ahead to ask for, hours |
@@ -1691,10 +1868,13 @@ the default markup and taxes, not an illustration.)
##### What it does not do
The **injection** leg is a separate card. Eneco uses `0,102` on consumption and
The **injection** leg is a separate card, and it has its own option:
`price_export_formula` (TARIFF-16). Eneco uses `0,102` on consumption and
`0,100` on injection, and injection is VAT-exempt where consumption is at 6 %,
so it is a second per-contract formula rather than a second copy of this one.
`export_price_eur_kwh` is still the scalar you enter by hand.
so it is a second per-contract formula rather than a second copy of this one —
the same parser, its own coefficients. `export_price_eur_kwh` remains the flat
injection price for a site that is paid one, and **no strategy reads either**;
see *Injection is priced separately* above.
#### `manual_csv` — the offline fallback
@@ -1845,25 +2025,122 @@ logged and reported on the status row, never raised. A cache that cannot answer
is not allowed to be a controller that cannot regulate, and that applies to the
volume being full exactly as it applies to the volume being absent.
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
often near zero, which by itself strongly favours self-consumption over export.
Negative values are accepted — you can be paid to *not* export at midday in
summer.
### Injection is priced separately
⚠️ It is a single number rather than a curve, and the reason given here until
TARIFF-07 — *"no supported supplier publishes a dynamic injection tariff"* — was
**false**. Suppliers do. Eneco publishes `0,1 x BELPEX-H − 1,188` on the same
tariff card as its consumption formula (identical on the January and the
September 2026 cards), and ENGIE publishes one in the same shape,
`(B x eSpot_15) − A`. What is true is narrower: an injection curve is the **same
two-coefficient formula** as the consumption curve but with **its own**
coefficients and **its own VAT treatment** — injection is VAT-exempt where
consumption is at 6% — so it is a second per-contract formula rather than a copy
of the first, and the add-on cannot evaluate either one yet. Until the formula
provider ships, **this scalar is a simplification you enter by hand — an average
of what you are actually paid — and not a reflection of the market.**
There are two ways to say what you are paid for an exported kWh, and which one
you want depends on your contract:
**The old Home Assistant price sensors.** `price_now_entity` and
| your contract | what to set |
|---|---|
| a flat injection price | `export_price_eur_kwh` — one number, EUR/kWh. This is the default and what every existing install uses |
| a price that moves with the market | `price_export_formula` — your card's injection line as `factor × index + offset`, in exactly the shape `price_formula` uses for consumption |
Negative values are accepted on both — you can be charged to export at midday in
summer, and the add-on says so rather than flooring it.
#### ⚠️ What the add-on does *not* do with an injection price
**No strategy in this add-on reads one.** Not the scalar, not the formula.
`arbitrage.py` prices the sell side as **displaced import** — both hours it
compares are all-in *consumption* prices — and its own comment gives the reason:
§9.3's near-zero Belgian injection price is exactly why the economically sound
trade on this market is *charge cheap to displace expensive import*, not *charge
cheap to export dear*. So setting `price_export_formula` changes **no decision
the add-on takes today**. It changes what you are shown at startup, and it is
the shape any future export-aware strategy would read.
That is the argument for doing it now rather than later: OPT-06 and any export
logic would otherwise inherit a scalar that cannot represent the market, and the
6% VAT asymmetry between the two legs would be invisible in the arithmetic.
`test_price.py` 17h parses every module under `app/` and goes red the day
something calls an injection reader, so this paragraph is graded rather than
attested.
#### Why a scalar was not enough
Belgian suppliers publish the injection leg as a formula, on the same card as
the consumption one. The reason given here until TARIFF-07 — *"no supported
supplier publishes a dynamic injection tariff"* — was **false**, and what they
publish has four properties one number cannot hold:
```
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c/kWh
"Het injectietarief is dynamisch en kan per uur wijzigen"
- identical on the January and the September 2026 cards
ENGIE Dynamic (B x eSpot_15) - A
Aspiravi (non-dynamic) 0,07 x Belpex - 2
```
1. **Its own factor.** Eneco uses `0,102` on consumption and `0,100` on
injection; Aspiravi `0,07` against about `0,10` on its consumption leg. It is
**not** the consumption `B` with a sign flip.
2. **A negative offset** — `− 1,188`, `− 2`. Note that
`price_markup_eur_kwh: float(0,1)` cannot express a negative markup at all,
so the offset has no home on the consumption side either.
3. **VAT-exempt.** *"Injectietarieven zijn vrijgesteld van btw"*, while
consumption is at 6%. `vat_pct` is **per leg**, so that six-point asymmetry
is expressible; a single scalar carries no VAT field at all.
4. **It goes negative, with no floor.** Eneco's injection price is below zero
for any BELPEX-H under **11,88 EUR/MWh** — and a PV household exports
precisely in the hours most likely to be negative. **You can be charged to
export.**
#### `price_export_formula`, field by field
Same grammar as `price_formula` — literally the same parser, with its option
name as an argument, so there is no second unit table and no second card rule to
diverge from the first. Eneco's published injection line, typed out:
```json
{"index":"epex_be","resolution":"hourly","factor":0.1,"offset":-1.188,
"units":"c_eur_per_kwh","vat_pct":0}
```
⚠️ **`vat_pct` is 0 here and 6 on the consumption leg.** That is the one field
people copy across from the other formula and get wrong.
⚠️ **The `card` block is optional on this leg and required on the other**, and
that is a statement about the cards rather than a relaxed rule. `price_formula`
can demand one because a Belgian card prints the consumption formula, the
reference index it assumed *and* the resulting price — so the add-on can
recompute that price and refuse to start unless your numbers reproduce it.
**Eneco's injection formula is printed with no price beside it.** There is
nothing to grade against on that card. Requiring a block would make the market's
own published line inexpressible; inventing the estimate would grade your
coefficients against arithmetic done with your coefficients, which is not a
check. So:
* if **your** card prints an injection estimate, fill the block in and it is
graded at the same tolerance as the consumption leg — the add-on refuses to
start if your numbers do not reproduce it;
* if it does not, the add-on **says so at startup** — *"its coefficients are
UNGRADED"* — and names what it checked instead.
What it checks instead, always, on both paths:
* **pass-through**: `factor`, converted to a share of the wholesale price, must
be between 0.10× and 10×. Eneco's injection leg is `1.00×` and Aspiravi's
`0.70×`; a coefficient typed a hundred times out reads `0.01×` or `100×`.
* **offset magnitude**: `|offset|` must be at most 0.20 EUR/kWh. The published
ones are `−0.01188` and `−0.02`.
Both bounds are on the **coefficients**, not on the price, which is why they can
bound a leg whose price legitimately goes below zero. `price_formula`'s
plausibility band is deliberately **not** reused here: its floor is `+0.02
EUR/kWh` of energy, and applying that to injection would refuse the real market
in exactly the hours where knowing the number matters most.
⚠️ **And the gap those bounds leave, with its size.** They bound the *shape* of
the coefficients, never their truth. With no printed estimate, a `factor` of
`0.070` where your contract says `0.100` — 30% of every export payment — passes
both, because `0.70×` is a pass-through somebody really publishes. Only a
printed estimate can catch a wrong-contract pair, and on Eneco's card there is
not one. That is why the startup line says the leg is ungraded instead of
implying it was checked.
### The old Home Assistant price sensors
`price_now_entity` and
`price_avg_entity` still work and are unchanged: the add-on compares them and
uses the result as a cheap-window hint for the maintenance charge. They are
**not compatible with `price_provider`, and not with `static_profile` either**
@@ -1891,10 +2168,11 @@ size:
| **ORDER** | **varies** by hour, block or day-type | **Yes, and worst.** It changes *which hour is cheapest* |
Measured on the real arithmetic (`test_price.py` section 16b, printed on every
run): with a flat taxes figure of 0.00, 0.0739, 0.1198, 0.1327 or 1.00 EUR/kWh,
the same 24-hour feed is cheapest at **13:00 in all five cases** and its spread
is **0.1500 EUR/kWh in all five cases**. That is the LEVEL bucket doing
nothing, and it is not a rounding artefact — it is arithmetic.
run): with a flat taxes figure of 0.00, 0.0739, 0.11736 (the shipped default),
0.1198, 0.1327 or 1.00 EUR/kWh, the same 24-hour feed is cheapest at **13:00 in
all six cases** and its spread is **0.1500 EUR/kWh in all six cases**. That is
the LEVEL bucket doing nothing, and it is not a rounding artefact — it is
arithmetic.
Three consequences worth having in mind:
@@ -1925,6 +2203,83 @@ Three consequences worth having in mind:
is the only ORDER component the add-on has. Anything added next belongs in one
of the three, and saying which is part of adding it.
### What `price_taxes_eur_kwh` is made of, and why yours is different (TARIFF-18)
**It ships at `0.11736` EUR/kWh.** That is the all-in non-energy part of a
Flemish residential bill — everything on your invoice that is *not* the energy
itself — read off the Eneco residential card for Flanders, **September 2026**,
**including 6 % VAT**:
| component | c€/kWh |
|---|---|
| Fluvius distribution (**Antwerpen**) | 5,35 |
| Bijzondere accijns, *"Alle verbruik"* — no volume bands | 4,876 |
| Bijdrage groene stroom en WKK Vlaanderen | 1,51 |
| Energiebijdrage | **0** |
| **total** | **11,736 c€/kWh = 0.11736 €/kWh** |
The rows are in the code, in `app/price.py`'s
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, and `test_price.py` section 1b re-adds them
and refuses a default that is not their sum. It replaces a shipped `0.06`,
which was roughly **half** the true figure and had no recorded provenance at
all.
⚠️ **`energiebijdrage` is zero and that is not a mistake.** *"Vanaf 1 augustus
2026 bedraagt de energiebijdrage 0 EUR/MWh"* — it was merged into the
bijzondere accijns. If you are re-deriving this total from a guide written
before that date you will find a separate energiebijdrage line; adding it would
count it twice. It is carried as a **zero row** rather than left out for
exactly that reason.
⚠️ **THIS IS A PER-DSO FIGURE AND YOURS IS PROBABLY NOT 0.11736.** Only the
distribution row moves. Across the eight Fluvius DSOs it runs **5,28 c€/kWh**
(Midden-Vlaanderen) to **6,70** (West), so the Flemish all-in total runs about
**0.117 to 0.131 €/kWh** — a 1,42 c€/kWh spread, which on 4 000 kWh a year is
about €57. The other two rows are Flanders-wide and do not move. **Look up your
own distribution row, add the same accijns and groenestroombijdrage, and type
the total.**
⚠️ **A WALLOON OR BRUSSELS SITE SHOULD NOT USE THIS SCALAR AT ALL.** Their
distribution tariff has a **day and a night band** — `Enkelvoudig | Dag |
Nacht | Uitsl. nacht` on the same card, where Fluvius prints a single rate —
and a flat figure flattens exactly the component that is anti-correlated with
what the battery wants to do. Use **`price_taxes_profile`** (the next section);
it is the only ORDER-bucket component the add-on has.
⚠️ **THIS IS A DISPLAY AND GUARD FIX. IT DOES NOT IMPROVE ARBITRAGE, AND IT
CANNOT.** `price_taxes_eur_kwh` is a **LEVEL** component — added identically to
every hour — so it **cancels exactly** in `max − min` and leaves the argmin
where it was. It does not change which hour is cheapest, it does not change the
spread, and it does not make any trade happen that did not happen before. See
*The three buckets* above, where six flat scalars spanning and exceeding the
whole per-DSO range give the identical cheapest hour and the identical spread
to the last digit. What raising it **does** change is exactly two things:
* **every figure an operator reads** — the all-in prices on the status page,
`custom_formula`'s startup preview and the recorded price history. At half
the true non-energy component they made the add-on look considerably better
than it is.
* **§17's negative-price guard** (`arbitrage.py`), which is the one rung that
tests an **absolute** all-in price rather than a difference. The guard fires
when `spot < −(markup + taxes)`, so its boundary moved from **−0.09000** to
**−0.14736 €/kWh** of spot. A spot hour at −0.11868 €/kWh used to read
**−0.02868 €/kWh** all-in and the discharge was refused as paid-for disposal;
at the real tax level it reads **+0.02868** and the discharge is allowed,
because the operator really does pay those levies and that hour really was
profitable (`test_arbitrage.py` section 2c drives both, and pins the boundary
from below as well).
⚠️ **Which is why the default sits near the LOW end of the per-DSO band rather
than in the middle of it.** The two error directions are not symmetric. Set too
**high**, the figure carries a genuinely negative hour up above zero and the
add-on exports into paid-for disposal — §17's own regression case. Set too
**low**, it only declines a discharge that would have paid. Where one default
has to stand in for eight DSOs, it belongs at the bottom.
**If you set this yourself, nothing above affects you.** A site with its own
`price_taxes_eur_kwh` keeps it; the default only answers where the option is
absent from the stored options blob.
### Levies and VAT by time of day (`price_taxes_profile`)
**Leave this empty unless your network tariff really does have a day and a
@@ -1979,8 +2334,12 @@ second set of 23- and 25-hour-day bugs.
- ⚠️ **Set this *or* `price_taxes_eur_kwh`, not both.** The table wins when it
is set, so a scalar left at a non-default value beside it would be a number
you typed that decides nothing. That is refused at startup rather than
silently resolved; leave `price_taxes_eur_kwh` at its shipped `0.06` when you
fill this in.
silently resolved; leave `price_taxes_eur_kwh` at its shipped `0.11736` when
you fill this in. ⚠️ **A scalar still sitting on the superseded `0.06` is
also accepted**, and deliberately so — TARIFF-18 moved that default, and your
stored options blob carries the old number whether you typed it or not, so
refusing it would have stopped working Walloon and Brussels sites on an
upgrade over a figure the table overrides anyway.
- A **malformed table is refused at startup**, not at the next fetch. It is
read on the ~30 s planning path, and a table that first fails at 03:00 is a
site that stops pricing at 03:00 — which §11.4 turns into a quiet fallback
@@ -1991,7 +2350,9 @@ second set of 23- and 25-hour-day bugs.
is untouched — the table is routed through the same `capabilities().adds`
rule as the scalar, not bolted on.
- It does **not** apply to `static_profile`, whose prices are already all-in,
nor to `export_price_eur_kwh`.
nor to `export_price_eur_kwh` or `price_export_formula`. Those are the
injection leg, and nothing is added to what you are *paid* for export:
`price_taxes_eur_kwh` is a consumption component.
### Static day/night and fully-fixed tariffs (`static_profile`)
+44 -3
View File
@@ -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
@@ -831,13 +869,16 @@ document (`' 216.080 -> 0.30123'`), and M13 is red.
**M10 is `+0 B` and it is a real defect, not a synthetic one.** It came out of
writing the mutation rather than out of review: `implausible_all_in()`'s floor
was applied to the all-in figure, and `price_taxes_eur_kwh` ships at 0.06 —
was applied to the all-in figure, and `price_taxes_eur_kwh` shipped at 0.06 —
**three times the 0.02 floor** — so a card read a hundred times too small
(0.0016 EUR/kWh of energy) is carried over the floor by the flat tax addend
alone and passes. The hundredfold error is exactly what that band exists for.
The floor is applied to the **energy** figure now and the ceiling to the all-in
one, both directions are graded, and the trap is stated as a check in its own
right so the asymmetry cannot be "tidied up" later.
right so the asymmetry cannot be "tidied up" later. *(Annotated 2026-09-13:
TARIFF-18 raised that default to 0.11736, **nearly six times** the floor. The
addend that does the carrying got BIGGER, so the trap is wider than when M10
was measured and the asymmetry is more load-bearing rather than less.)*
**M15 is the one the framework CANNOT catch, and the reds say which half does.**
Declaring `ALL_IN` makes `assemble()` add nothing, and section 3's all-in check
+123 -6
View File
@@ -184,6 +184,17 @@ IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
IDLE_NO_SIZE = (
"no pack's state of charge permits this side of the trade, so it is sized "
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
# SAFETY-45. THE BUY LEG'S SIZE, AND ONLY THE BUY LEG'S - §9.1: "Discharge and
# export are never capped by the peak guard; only charging and import are."
IDLE_NO_HEADROOM = (
"the capacity-tariff guard has measured this billed quarter and it can "
"afford no charging at all, so a trade of any size would push it over the "
"ceiling - and a 0 W arbitrage claim is a park order, not a trade")
IDLE_HEADROOM_UNMEASURED = (
"the capacity-tariff guard did not size this quarter's charging headroom "
"this cycle, and an UNMEASURED headroom is not an unlimited one - charging "
"creates the billed quantity (§9.1) and nothing is measuring the quarter "
"this one would land in")
NEGATIVE_PRICE = (
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
"energy away and pay for the privilege. Arbitrage must not export at a "
@@ -303,7 +314,7 @@ def _point_at(plan, when: datetime):
return None
def decide(plan, now: datetime, *, peak_guard_active, soc,
def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, soc,
min_spread_eur_kwh, uncertainty, charge_w, discharge_w,
soc_floor_pct, soc_ceiling_pct) -> ArbitrageDecision:
"""Should the battery trade this cycle, and how hard.
@@ -314,6 +325,18 @@ def decide(plan, now: datetime, *, peak_guard_active, soc,
None refuses, because the alternative is a strategy that creates the billed
quantity while the thing that would have limited it is switched off.
⚠️ `charge_headroom_w` IS SAFETY-45's SECOND, CONTINUOUS ANSWER FROM THE
SAME GUARD, AND IT IS NOT A DUPLICATE OF THE FIRST. `peak_guard_active` is a
SWITCH - it says whether the guard is holding - and a switch keyed on a
continuous quantity is what made the loop oscillate: the guard engaged at a
5 W floor, this module refused the whole trade, the loop took the actuator,
the meter fell, the guard released and the trade re-armed AT FULL POWER
130 s later. 28 times an hour, measured. `peakguard.charge_headroom_w()` is
the watts the billed quarter can still afford; sizing the buy leg to it means
the trade is CLAMPED rather than switched, which is the same shape
`control.maintenance_charge_floor()` has always given a maintenance charge.
⚠️ None IS NOT UNLIMITED here either - see IDLE_HEADROOM_UNMEASURED.
⚠️ EVERY OTHER ARGUMENT IS KEYWORD-ONLY AND HAS NO DEFAULT, for
`TariffPlan.arbitrage()`'s reason. A defaulted `soc_floor_pct` is a reserve
floor this module asserted rather than one the operator configured, and the
@@ -330,10 +353,12 @@ def decide(plan, now: datetime, *, peak_guard_active, soc,
return verdict.match(
refused=lambda v: _idle(v.reason),
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
soc_floor_pct, soc_ceiling_pct))
soc_floor_pct, soc_ceiling_pct,
charge_headroom_w))
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
headroom_w):
"""The permitted case: which hour is this, and may the pack do it."""
here = _point_at(plan, now)
if here is None:
@@ -403,18 +428,110 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
f"nowhere to put the cheap energy", **common)
if not abs(float(charge_w)) > 0.0:
return _idle(IDLE_NO_SIZE, **common)
# SAFETY-45. THE BILLED QUARTER IS ASKED LAST, AND THE PLACEMENT IS
# DELIBERATE: every refusal that existed before this card keeps the
# precedence it had, so no sentence an operator already reads changes when
# it fires, and the two added here are placed where they cannot displace
# one. The pack's reasons come first because they are properties of the
# hardware in the cupboard; this one is a property of the fifteen minutes
# the trade would land in.
if headroom_w is None:
# ⚠️ NOT "the guard is clear". This is `guard_state()`'s None argument
# one layer down: a headroom nothing measured is not an unlimited one,
# and charging at max_w creates the billed quantity (§9.1).
return _idle(IDLE_HEADROOM_UNMEASURED, **common)
asked_w = abs(float(charge_w))
size_w = min(asked_w, max(0.0, float(headroom_w)))
if not size_w > 0.0:
# ⚠️ NOT IDLE_NO_SIZE, WHICH IS THE LINE ABOVE AND BLAMES THE PACKS.
# `asked_w > 0` is already established here, so the pack may be empty
# and willing and it is the billed quarter that has run out. A refusal
# naming the wrong subsystem sends an operator to the wrong page
# (§10.2), and these two are a whole quarter-hour apart in what they
# ask a human to do about them.
return _idle(IDLE_NO_HEADROOM, **common)
# ⚠️ THE SENTENCE CARRIES BOTH NUMBERS IN THEIR OWN SLOTS, not one figure
# twice. MUTATION.md habit 14: two values in one sentence can be transposed
# by a ZERO-BYTE edit that no byte delta and no shape pin can see, so the
# trade's own size and the guard's headroom are each named by the literal
# text around them.
capped = size_w < asked_w
return ArbitrageDecision(
action=CHARGE, power_w=-abs(float(charge_w)),
action=CHARGE, power_w=-size_w,
reason=(f"{CHARGE}: this is the cheapest firm hour at "
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
f"had to cover; charging {abs(float(charge_w)):.0f} W from "
f"{soc:g} %"),
f"had to cover; charging {size_w:.0f} W from {soc:g} %"
+ (f" - SIZED BY THE CAPACITY-TARIFF GUARD: the trade asked for "
f"{asked_w:.0f} W and this billed quarter can afford "
f"{float(headroom_w):.0f} W (§9.1, §10.1 priority 2)"
if capped else "")),
spread_eur_kwh=v.spread_eur_kwh,
required_eur_kwh=v.required_spread_eur_kwh,
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.
+457 -68
View File
@@ -125,6 +125,49 @@ units and the market aggregator prints both conventions in one column, so the
add-on has to be told which one the card is in - and the unit conversion is the
add-on's job precisely because it is one of the two steps that silently costs a
factor of 10, 100 or 1000. The other is VAT, which is also the add-on's.
TARIFF-16 - THE SECOND LEG, AND WHY IT IS THIS PARSER AND NOT A SECOND ONE
==========================================================================
A Belgian dynamic contract has TWO published lines, not one, and the second is
what the site is PAID for export:
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
ENGIE Dynamic (B x eSpot_15) - A
Aspiravi (non-dynamic) 0,07 x Belpex - 2
Same `B x index + A` shape - which is why `price_export_formula` is this
module's parser with ONE argument moved (`option`), exactly as TARIFF-17 made
`parse_static_profile()`'s option name a parameter rather than forking the
period grammar. What differs between the two legs is FOUR numbers and a
handful of sentences, and all of them are rows of `_LEGS`:
* its OWN factor - Eneco 0,102 on consumption against 0,100 on injection,
Aspiravi 0,07 against ~0,10. NOT the consumption `B` with a sign flip;
* a NEGATIVE offset, which `factor`/`offset` already allow and which
`price_markup_eur_kwh: float(0,1)` cannot express at all;
* `vat_pct: 0` - "Injectietarieven zijn vrijgesteld van btw" - against 6 on
consumption, which is a SIX PERCENT ASYMMETRY between the buy leg and the
sell leg and is representable only because `vat_pct` is PER SPEC;
* a price that goes BELOW ZERO with no floor, in the hours a PV household
exports. `implausible_injection()` is the band that accepts that and still
refuses an implausible one; `implausible_all_in()` is NOT it, and its
docstring says why.
⚠️ AND THE CARD CANNOT ALWAYS GRADE IT. Eneco's injection formula is printed
with NO price beside it, so on that card there is nothing to reproduce. The
`card` block is therefore OPTIONAL on this leg and REQUIRED on the other, the
self-check runs wherever there is a printed estimate, and validate.py says at
startup when a leg is ungraded. Inventing an estimate to grade against would
be grading arithmetic against itself, which is the defect MUTATION.md habit 4
is about; TARIFF-15 demonstrated two tariffs end to end rather than three for
the same reason.
⚠️ NOTHING TRADES ON THIS TODAY, AND SAYING SO IS PART OF SHIPPING IT.
`arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment
says so, citing §9.3's near-zero Belgian injection price - and both hours it
compares are all-in CONSUMPTION prices. So this is not a wrong-trade defect
and must not be described as one. What it changes is what an operator is told,
and what a future export-aware strategy would rest on.
"""
import json
@@ -183,6 +226,150 @@ _CARD_KEYS = ("month", "index_eur_per_mwh", "expect_incl_vat")
_TOP_KEYS = ("index", "resolution", "factor", "offset", "units", "vat_pct",
"card")
# --------------------------------------------------------------------------- #
# TARIFF-16 - the SECOND leg, and the one parameter that carries it
# --------------------------------------------------------------------------- #
EXPORT_FORMULA_OPTION = "price_export_formula"
# ⚠️ THE PASS-THROUGH FRACTION, WHICH IS WHY THE NEXT TWO BOUNDS CAN BE
# INDEX-FREE. One EUR/MWh IS 0.001 EUR/kWh, so `factor x UNITS[units] / 0.001`
# is the dimensionless share of the wholesale price the contract passes
# through - 1.00 for Eneco's injection leg (0,100 c EUR/kWh per EUR/MWh), 0.70
# for Aspiravi's (0,07). A coefficient typed a hundred times too small or too
# large moves this by a hundred, whatever the index and whatever the sign of
# the price.
EUR_KWH_PER_EUR_MWH = 0.001
# The two plausibility bounds for an INJECTION leg. ⚠️ NEITHER LOOKS AT THE
# SIGN OF THE PRICE, AND THAT IS THE WHOLE POINT - see
# `implausible_injection()`.
PASSTHROUGH_LO = 0.10 # 10% of the wholesale price
PASSTHROUGH_HI = 10.0 # 1000% of it
INJECTION_OFFSET_MAX_EUR_KWH = 0.20
@dataclass(frozen=True)
class _Leg:
"""What ONE option's coefficients price, as sentences its refusals read.
⚠️ TARIFF-16 MADE `option` A PARAMETER RATHER THAN FORKING THE PARSER,
exactly as TARIFF-17 made `option`/`means` parameters of
`parse_static_profile()` rather than forking the period grammar. A second
copy of this parser would be a second `_finite()`, a second unknown-key
rule, a second unit table and a second `to_resolution()` - and the two
would diverge first on the 23- and 25-hour days, where nobody looks.
Everything that differs between the consumption leg and the injection leg
is a STRING in this table; the arithmetic, the enumerations, the card
comparison and the resolution rule are ONE implementation with one set of
checks over it.
"""
option: str
what: str # "consumption" / "injection", for a sentence
example: str # a VALID blob for this leg, in the JSON-error hint
when: str # when this option is read at all
flat: str # where a FLAT price of this kind already lives
zero_factor: str # what a factor of 0 actually costs on this leg
neg_factor: str # why a negative FACTOR is wrong on this leg
card_required: bool
card_note: str # why it is required, or what is lost without it
card_causes: str # the leg-specific tail of `card_refusal()`
_LEGS = {
"price_formula": _Leg(
option="price_formula",
what="consumption",
example=('{"index":"epex_be","resolution":"hourly","factor":0.102,'
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
'"expect_incl_vat":15.55}}'),
when="Leave it empty unless price_provider is 'custom_formula'.",
zero_factor=("every spread is exactly 0, and arbitrage can never "
"clear any threshold - a strategy that silently never "
"fires rather than one that is off"),
flat=("that is `static_profile` with a single period covering "
"00:00-24:00 on all days and price_provider 'off'; the add-on "
"then says so at startup instead of leaving you to find out "
"from a year of unchanged bills"),
neg_factor=("No consumption tariff is indexed negatively; if your "
"card shows a minus sign it is on the OFFSET (`A`), "
"which is allowed here and common on injection"),
card_required=True,
card_note=("It is the only thing that can tell a correct pair of "
"coefficients from a plausible-looking wrong one"),
card_causes=(
"or - the expensive one - network charges, levies or excise "
"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian "
"card's Tariefformule line is the ENERGY component alone (Eneco "
"tabulates network tariffs and levies separately; ENGIE says in "
"so many words that the formula excludes network, distribution, "
"transport, levies and excise), the add-on adds "
"price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of "
"network charge into `offset` bills you for it twice. That is "
"the error this check exists to catch, and it is why it cannot "
"be switched off."),
),
EXPORT_FORMULA_OPTION: _Leg(
option=EXPORT_FORMULA_OPTION,
what="injection",
# ⚠️ NO `card` BLOCK IN THE EXAMPLE, because Eneco's injection line has
# none to copy - the formula is printed with no price beside it. An
# example carrying one would invite an operator to compute the figure
# themselves, which is the one thing this leg must not encourage.
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
when=("Leave it empty unless your supplier pays you an injection "
"price that moves with the market; a flat one is "
"export_price_eur_kwh. ⚠️ This option is read whatever "
"price_provider is set to - it is the leg you are PAID on, not "
"the one you buy on."),
zero_factor=("every hour pays the same, which is a FLAT injection "
"price written as a formula. ⚠️ Note this is NOT the "
"arbitrage argument the consumption leg gives: no "
"strategy in this add-on reads an injection price at "
"all, so a flat one here fires nothing and declines "
"nothing - it is simply the wrong option"),
flat=("that is `export_price_eur_kwh`, the flat injection scalar, "
"which is what every site used before this option existed and "
"which still answers when this one is empty"),
neg_factor=("No Belgian injection tariff is indexed negatively - you "
"are paid MORE when the market is dearer, not less. The "
"minus sign on an injection card is on the OFFSET (`A`): "
"Eneco prints `0,1 x BELPEX-H - 1,188` and Aspiravi "
"`0,07 x Belpex - 2`, and a negative offset is accepted "
"here"),
card_required=False,
card_note=(
"⚠️ IT IS OPTIONAL ON THIS LEG, AND THAT IS A STATED GAP RATHER "
"THAN A RELAXED RULE. A Belgian card prints the CONSUMPTION "
"formula with the reference index and the resulting price beside "
"it, which is what makes `price_formula`'s self-check possible. "
"Eneco's injection line has NO printed price beside it, so on "
"that card there is nothing to grade against and requiring a "
"`card` block would make the market's own published formula "
"inexpressible - or, worse, invite an operator to compute the "
"estimate themselves, at which point the check grades the "
"coefficients against arithmetic done with the same coefficients. "
"Supply it if YOUR card prints one; when it is absent the add-on "
"says at startup that this leg is UNGRADED and the two "
"coefficient bounds below are all there is"),
card_causes=(
"or VAT added where there is none. ⚠️ INJECTION IS VAT-EXEMPT IN "
"BELGIUM - \"Injectietarieven zijn vrijgesteld van btw\" - while "
"consumption is at 6%, so `vat_pct` on THIS option is 0 on every "
"Belgian card the research found, and a 6 typed here because it "
"is 6 on the consumption leg is a ratio of exactly 1.06. Nothing "
"is added to this leg by the framework: price_taxes_eur_kwh is a "
"CONSUMPTION component and is not applied to what you are paid "
"for export, so an offset carrying network charges is a "
"different error here than it is on price_formula and this "
"check cannot tell you which one you made."),
),
}
@dataclass(frozen=True)
class FormulaSpec:
@@ -199,14 +386,44 @@ class FormulaSpec:
offset: float
units: str
vat_pct: float
card_month: str # "YYYY-MM", as the card is dated
card_index_eur_per_mwh: float
card_expect_incl_vat: float # in `units`, as the card prints it
card_month: str = "" # "YYYY-MM", as the card is dated
card_index_eur_per_mwh: float = None
card_expect_incl_vat: float = None # in `units`, as the card prints it
# TARIFF-16. Which OPTION this spec was read from, so a refusal, a log line
# and `card_graded` name the leg the operator actually typed.
option: str = "price_formula"
@property
def unit_to_eur_kwh(self) -> float:
return UNITS[self.units]
@property
def card_graded(self) -> bool:
"""Is there a printed estimate to grade these coefficients against?
⚠️ FALSE IS A REAL, SHIPPED STATE ON THE INJECTION LEG and is reported
rather than defaulted: Eneco's injection formula is printed with no
price beside it. Never False on `price_formula`, where `_card()`
refuses an absent block.
"""
return self.card_expect_incl_vat is not None
@property
def passthrough(self) -> float:
"""The dimensionless share of the wholesale price this passes through.
`factor x UNITS[units] / 0.001`. 1.00 for Eneco's injection leg, 0.70
for Aspiravi's. ⚠️ INDEX-FREE AND SIGN-FREE, which is what lets
`implausible_injection()` bound a leg whose price legitimately goes
below zero.
"""
return self.factor * self.unit_to_eur_kwh / EUR_KWH_PER_EUR_MWH
@property
def offset_eur_kwh(self) -> float:
"""The offset in EUR/kWh, whatever units the card printed it in."""
return self.offset * self.unit_to_eur_kwh
# --------------------------------------------------------------------------- #
# The arithmetic. Two functions, and the SECOND one is the contract.
@@ -241,7 +458,18 @@ def card_discrepancy(spec: FormulaSpec) -> float:
In the card's units, signed: positive means the formula computes MORE than
the card prints, which is the direction an `offset` carrying network
charges moves it.
⚠️ RAISES ON A SPEC WITH NO CARD RATHER THAN RETURNING 0.0 (TARIFF-16). An
ungraded injection leg has no printed estimate, and a discrepancy of zero
is exactly what "the coefficients reproduce the card" looks like - so a
silent zero here would report the strongest possible result for the one
state in which nothing was compared at all.
"""
if not spec.card_graded:
raise FormulaInvalid(
f"{spec.option} has no card block, so there is no printed "
f"estimate to measure a discrepancy against. Ask "
f"`spec.card_graded` before calling this.")
return in_card_units(spec, spec.card_index_eur_per_mwh) \
- spec.card_expect_incl_vat
@@ -250,7 +478,7 @@ def card_discrepancy(spec: FormulaSpec) -> float:
# Reading the option
# --------------------------------------------------------------------------- #
def _finite(value, field: str) -> float:
def _finite(value, field: str, option: str = "price_formula") -> float:
"""A real, finite number - or a refusal naming the field.
⚠️ SAME CLASS AS `_validate_raw()`'s NaN RULE, one layer earlier. A NaN
@@ -260,26 +488,37 @@ def _finite(value, field: str) -> float:
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise FormulaInvalid(
f"price_formula.{field} is {value!r}, which is not a number. "
f"{option}.{field} is {value!r}, which is not a number. "
f"Write the coefficient as a bare JSON number - 0.102, not "
f"\"0.102\" and not an expression: this provider takes FIXED "
f"coefficients and evaluates nothing.")
if not math.isfinite(float(value)):
raise FormulaInvalid(
f"price_formula.{field} is {value!r}, which is not finite. A NaN "
f"{option}.{field} is {value!r}, which is not finite. A NaN "
f"or an infinity here makes every price NaN, every price "
f"comparison False, and an arbitrage strategy that never fires "
f"while reporting that it is armed.")
return float(value)
def _card(raw) -> tuple:
"""The `card` block: the estimate the coefficients are graded against."""
def _card(raw, leg: _Leg = None) -> tuple:
"""The `card` block: the estimate the coefficients are graded against.
⚠️ REQUIRED ON THE CONSUMPTION LEG AND OPTIONAL ON THE INJECTION ONE, and
the asymmetry is a fact about the CARDS rather than a relaxed rule. See
`_LEGS[EXPORT_FORMULA_OPTION].card_note`: Eneco's injection formula is
printed with no price beside it, so on that card there is nothing to grade
against. `("", None, None)` is the ungraded answer and
`FormulaSpec.card_graded` is how every caller asks.
"""
if leg is None:
leg = _LEGS["price_formula"]
if raw is None:
if not leg.card_required:
return ("", None, None)
raise FormulaInvalid(
"price_formula has no \"card\" block, and it is REQUIRED. It is "
"the only thing that can tell a correct pair of coefficients from "
"a plausible-looking wrong one: the add-on recomputes your "
f"{leg.option} has no \"card\" block, and it is REQUIRED. "
f"{leg.card_note}: the add-on recomputes your "
"supplier's own printed example from the numbers you typed and "
"refuses to start if it does not come out. Copy the three figures "
"off the same tariff card as the coefficients: "
@@ -287,24 +526,26 @@ def _card(raw) -> tuple:
"\"expect_incl_vat\": 15.55}.")
if not isinstance(raw, dict):
raise FormulaInvalid(
f"price_formula.card is {type(raw).__name__}, not an object with "
f"{leg.option}.card is {type(raw).__name__}, not an object with "
f"{', '.join(_CARD_KEYS)}.")
unknown = sorted(set(raw) - set(_CARD_KEYS))
if unknown:
raise FormulaInvalid(
f"price_formula.card has no field(s) {unknown}. The fields it "
f"{leg.option}.card has no field(s) {unknown}. The fields it "
f"takes are {list(_CARD_KEYS)}; a key that is merely ignored is a "
f"number the operator believes took effect.")
month = str(raw.get("month", "") or "").strip()
if not _month_date(month):
raise FormulaInvalid(
f"price_formula.card.month is {raw.get('month')!r}; write the "
f"{leg.option}.card.month is {raw.get('month')!r}; write the "
f"month the card is dated as \"YYYY-MM\", e.g. \"2026-09\". It is "
f"not used in any price - it is what lets the add-on tell you "
f"your card has gone stale.")
return (month,
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh"),
_finite(raw.get("expect_incl_vat"), "card.expect_incl_vat"))
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
leg.option),
_finite(raw.get("expect_incl_vat"), "card.expect_incl_vat",
leg.option))
def _month_date(month: str):
@@ -316,9 +557,16 @@ def _month_date(month: str):
return None
def parse_price_formula(value):
def parse_price_formula(value, *, option: str = "price_formula"):
"""`price_formula` -> a `FormulaSpec`, or None when unset.
⚠️ `option` IS TARIFF-16'S WHOLE REUSE, and it is TARIFF-17's move. It
selects a row of `_LEGS` and nothing else: the enumerations, the
arithmetic, `to_resolution()`, the unknown-key rule and the card
comparison are shared, and every pre-existing call site - which passes no
`option` at all - lands on the consumption row and reads byte-identical
sentences. `parse_export_formula()` is a two-line wrapper, not a fork.
Accepts what the option can actually arrive as, exactly as
`parse_static_profile()` does:
@@ -335,6 +583,7 @@ def parse_price_formula(value):
fetch - go through this one function, so there is no route to a price that
has not been graded against the card.
"""
leg = _LEGS[option]
if value is None:
return None
if isinstance(value, str):
@@ -345,16 +594,13 @@ def parse_price_formula(value):
value = json.loads(text)
except ValueError as e:
raise FormulaInvalid(
f"price_formula is not valid JSON ({e}). It is a JSON object "
f"{option} is not valid JSON ({e}). It is a JSON object "
f"on one line, e.g.\n"
f' {{"index":"epex_be","resolution":"hourly","factor":0.102,'
f'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
f'"card":{{"month":"2026-09","index_eur_per_mwh":133.9905,'
f'"expect_incl_vat":15.55}}}}\n'
f"Leave it empty unless price_provider is 'custom_formula'.")
f" {leg.example}\n"
f"{leg.when}")
if not isinstance(value, dict):
raise FormulaInvalid(
f"price_formula is {type(value).__name__}, not a JSON object with "
f"{option} is {type(value).__name__}, not a JSON object with "
f"{', '.join(_TOP_KEYS)}.")
# ⚠️ AN UNKNOWN KEY IS REFUSED, NOT IGNORED, AND THIS IS THE HALF THAT
@@ -366,7 +612,7 @@ def parse_price_formula(value):
unknown = sorted(set(value) - set(_TOP_KEYS))
if unknown:
raise FormulaInvalid(
f"price_formula has no field(s) {unknown}. The fields it takes "
f"{option} has no field(s) {unknown}. The fields it takes "
f"are {list(_TOP_KEYS)}. ⚠️ There is deliberately NO field that "
f"takes an expression: this provider multiplies and adds two "
f"FIXED coefficients and evaluates nothing, because two "
@@ -378,7 +624,7 @@ def parse_price_formula(value):
index = str(value.get("index", "") or "").strip()
if index not in INDEXES:
raise FormulaInvalid(
f"price_formula.index is {value.get('index')!r}. The reference "
f"{option}.index is {value.get('index')!r}. The reference "
f"series this add-on can fetch are "
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}. "
f"If your card names a different index, the add-on cannot get it "
@@ -388,7 +634,7 @@ def parse_price_formula(value):
resolution = str(value.get("resolution", "") or "").strip()
if resolution not in RESOLUTIONS:
raise FormulaInvalid(
f"price_formula.resolution is {value.get('resolution')!r}; it "
f"{option}.resolution is {value.get('resolution')!r}; it "
f"must be one of {list(RESOLUTIONS)}. Belgium has been on a "
f"15-minute MTU since 2025-10-01 and the feed publishes 96 points "
f"a day; a contract written against the HOURLY series is priced "
@@ -402,16 +648,16 @@ def parse_price_formula(value):
units = str(value.get("units", "") or "").strip()
if units not in UNITS:
raise FormulaInvalid(
f"price_formula.units is {value.get('units')!r} and is REQUIRED - "
f"{option}.units is {value.get('units')!r} and is REQUIRED - "
f"there is no default, on purpose. It must be one of "
f"{sorted(UNITS)}. Your card prints the formula in ONE of these "
f"and two suppliers use two different conventions for the same "
f"shape, so assuming one would be a factor of 100 or 1000 on "
f"every hour with nothing able to notice.")
factor = _finite(value.get("factor"), "factor")
offset = _finite(value.get("offset"), "offset")
vat_pct = _finite(value.get("vat_pct"), "vat_pct")
factor = _finite(value.get("factor"), "factor", option)
offset = _finite(value.get("offset"), "offset", option)
vat_pct = _finite(value.get("vat_pct"), "vat_pct", option)
# ⚠️ ZERO IS NOT "NO INDEXATION", IT IS A FIXED TARIFF WEARING A DYNAMIC
# ONE'S CLOTHES. Every hour comes out at the same price, so every spread is
@@ -421,15 +667,10 @@ def parse_price_formula(value):
# the operator at it rather than let them build it by accident.
if factor == 0.0:
raise FormulaInvalid(
"price_formula.factor is 0, so the index would not enter the "
f"{option}.factor is 0, so the index would not enter the "
"price at all: every hour of every day comes out at "
f"{offset:g} x (1 + {vat_pct:g}/100), every spread is exactly 0, "
"and arbitrage can never clear any threshold - a strategy that "
"silently never fires rather than one that is off. If your tariff "
"really is one flat price, that is `static_profile` with a single "
"period covering 00:00-24:00 on all days and price_provider "
"'off'; the add-on then says so at startup instead of leaving you "
"to find out from a year of unchanged bills.")
f"{offset:g} x (1 + {vat_pct:g}/100), {leg.zero_factor}. If "
f"your {leg.what} tariff really is one flat price, {leg.flat}.")
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
# the dearest hour becomes the cheapest one, so the battery is told to
@@ -437,37 +678,96 @@ def parse_price_formula(value):
# mispriced site, it is a site actively trading against itself.
if factor < 0.0:
raise FormulaInvalid(
f"price_formula.factor is {factor:g}, which is NEGATIVE. That "
f"{option}.factor is {factor:g}, which is NEGATIVE. That "
f"inverts the market: the dearest hour of the day comes out as "
f"the cheapest one, so the battery would charge at the evening "
f"peak and discharge at 03:00 - trading against itself on every "
f"cycle. No consumption tariff is indexed negatively; if your "
f"card shows a minus sign it is on the OFFSET (`A`), which is "
f"allowed here and common on injection.")
f"cycle. {leg.neg_factor}.")
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
# and some cards are already quoted VAT-inclusive. The bound exists for the
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
if not 0.0 <= vat_pct <= 30.0:
raise FormulaInvalid(
f"price_formula.vat_pct is {vat_pct:g} and must be between 0 and "
f"{option}.vat_pct is {vat_pct:g} and must be between 0 and "
f"30. It is a PERCENTAGE, so Belgian residential electricity is "
f"6, not 0.06 and not 106. Zero is legitimate and is not refused: "
f"injection is VAT-exempt, and a card that already quotes "
f"VAT-inclusive coefficients needs no second multiplication.")
month, card_index, card_expect = _card(value.get("card"))
month, card_index, card_expect = _card(value.get("card"), leg)
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
offset=offset, units=units, vat_pct=vat_pct,
card_month=month, card_index_eur_per_mwh=card_index,
card_expect_incl_vat=card_expect)
card_expect_incl_vat=card_expect, option=option)
delta = card_discrepancy(spec)
if abs(delta) > CARD_TOLERANCE:
raise FormulaInvalid(card_refusal(spec, delta))
# ⚠️ THE CARD SELF-CHECK STILL RUNS WHEREVER THERE IS A CARD TO RUN IT
# AGAINST, on either leg, with the same tolerance. What TARIFF-16 adds is
# the honest answer for the leg where the card prints no estimate: NOT a
# weakened comparison and NOT an estimate computed here, but two bounds on
# the COEFFICIENTS that need no printed price - see
# `implausible_injection()` - and a startup line saying the leg is
# ungraded.
if spec.card_graded:
delta = card_discrepancy(spec)
if abs(delta) > CARD_TOLERANCE:
raise FormulaInvalid(card_refusal(spec, delta))
if option == EXPORT_FORMULA_OPTION:
band = implausible_injection(spec)
if band:
raise FormulaInvalid(band)
return spec
def parse_export_formula(value):
"""`price_export_formula` -> the INJECTION leg's `FormulaSpec`, or None.
TARIFF-16. THE DEFECT THIS EXISTS TO CLOSE: `price.py` modelled injection
as ONE scalar, and the market publishes a line with four properties a
scalar cannot hold. Verbatim off the cards:
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
"Het injectietarief is dynamisch en kan per uur wijzigen"
- identical on the January and the September 2026 cards
ENGIE Dynamic (B x eSpot_15) - A c EUR/kWh
Aspiravi (non-dynamic) 0,07 x Belpex - 2
1. ITS OWN FACTOR. Eneco uses 0,102 on consumption and 0,100 on
injection; Aspiravi 0,07 against ~0,10 on its consumption leg. It is
NOT the consumption `B` with a sign flip.
2. A NEGATIVE OFFSET - `- 1,188`, `- 2`. Note that
`price_markup_eur_kwh: float(0,1)` cannot express a negative markup
at all, so the offset has no home on the consumption side either.
3. VAT-EXEMPT. "Injectietarieven zijn vrijgesteld van btw", while
consumption is at 6% - a SIX PERCENT ASYMMETRY between the buy leg
and the sell leg, which a single scalar cannot even state.
4. IT GOES NEGATIVE, WITH NO FLOOR. Eneco's injection price is negative
for any BELPEX-H below 11,88 EUR/MWh, and a PV household exports
precisely in the hours most likely to be negative. You can be charged
to export.
⚠️ AND WHAT THIS DOES **NOT** AFFECT TODAY, SAID HERE SO NOBODY READS THE
ABOVE AS A TRADING BUG. `arbitrage.py` does not read
`export_price_eur_kwh` at all - its own comment says so, and gives §9.3's
near-zero Belgian injection price as the reason the economically sound
trade on this market is charge-cheap-to-displace-expensive-IMPORT. Both
hours it compares are all-in CONSUMPTION prices. So no trade the add-on
takes today is decided by this number, and this card is not a correction
of a wrong trade. What it changes is what an operator is told, and what
any future export-aware strategy would rest on: OPT-06 and any export
logic would otherwise inherit a scalar that cannot represent the market,
and the 6% VAT asymmetry would be invisible in the arithmetic.
⚠️ THE SCALAR IS NOT REPLACED. `export_price_eur_kwh()` is untouched and
still answers for every site with a flat injection price, which is every
site that exists today; this option ships "". That is `price.py`'s own
prediction coming true rather than a new design - "when it does land, an
injection curve gets a `PriceProvider` of its own and this scalar becomes
its FIXED-product default; the seam is already the right shape for that."
"""
return parse_price_formula(value, option=EXPORT_FORMULA_OPTION)
def card_refusal(spec: FormulaSpec, delta: float) -> str:
"""The sentence an operator reads when their formula misses its own card.
@@ -479,8 +779,9 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
got = in_card_units(spec, spec.card_index_eur_per_mwh)
ratio = (got / spec.card_expect_incl_vat
if spec.card_expect_incl_vat else float("inf"))
leg = _LEGS[spec.option]
return (
f"price_formula does not reproduce its own card, so the add-on will "
f"{spec.option} does not reproduce its own card, so the add-on will "
f"NOT start on it. Your contract's arithmetic is "
f"price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); "
f"on the card's own index that is "
@@ -496,15 +797,7 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
f"1000 - `units` must be the one the formula line is printed in); VAT "
f"left out or applied twice (ratio 1.06 or 1/1.06); a transposed "
f"digit; the two sides copied off DIFFERENT cards or different "
f"months; or - the expensive one - network charges, levies or excise "
f"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian card's "
f"Tariefformule line is the ENERGY component alone (Eneco tabulates "
f"network tariffs and levies separately; ENGIE says in so many words "
f"that the formula excludes network, distribution, transport, levies "
f"and excise), the add-on adds price_taxes_eur_kwh on top of it, and "
f"folding ~12 c EUR/kWh of network charge into `offset` bills you for "
f"it twice. That is the error this check exists to catch, and it is "
f"why it cannot be switched off.")
f"months; {leg.card_causes}")
# --------------------------------------------------------------------------- #
@@ -520,13 +813,16 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
⚠️ THE LOW END IS GRADED ON THE ENERGY COMPONENT AND THE HIGH END ON THE
ALL-IN FIGURE, AND THAT ASYMMETRY IS THE CHECK RATHER THAN AN OVERSIGHT.
`price_taxes_eur_kwh` ships at 0.06, which is three times the low bound -
so a card read a HUNDRED times too small comes out at 0.0016 EUR/kWh of
energy, is carried over the floor by the flat tax addend alone, and passes
a band applied to the all-in total. The hundredfold error is precisely
what this exists to catch, so the floor is applied where it can still see
it. The ceiling is applied to the all-in figure because that is the number
the site is really billed at, and taxes only push it up.
`price_taxes_eur_kwh` ships at 0.11736 (TARIFF-18; it was 0.06 before, and
the argument was already decisive at that figure), which is nearly SIX
times the low bound - so a card read a HUNDRED times too small comes out at
0.0016 EUR/kWh of energy, is carried over the floor by the flat tax addend
alone, and passes a band applied to the all-in total. The hundredfold error
is precisely what this exists to catch, so the floor is applied where it
can still see it. ⚠️ Raising the default made this trap WIDER, not
narrower: the addend that does the carrying got bigger. The ceiling is
applied to the all-in figure because that is the number the site is really
billed at, and taxes only push it up.
⚠️ AND THE LIMIT, WITH THE CLAIM. This grades the CARD'S OWN POINT - one
price, at the monthly index the card assumed - and not every hour of every
@@ -536,13 +832,27 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
fetched series. What it catches is a formula whose whole LEVEL is wrong,
which is the factor-of-1000 class arriving from the end
`MAX_PLAUSIBLE_EUR_KWH` does not bound.
⚠️ AND IT IS THE CONSUMPTION LEG'S BAND ONLY - TARIFF-16 DELIBERATELY DID
NOT POINT IT AT THE INJECTION LEG. Two reasons, and the second is an
acceptance criterion rather than a preference:
* it adds `taxes_eur_kwh`, and `price_taxes_eur_kwh` is a CONSUMPTION
component. Nothing is added to what a site is PAID for export, so the
`all_in` half would be adding a number the injection leg never carries;
* its floor is `energy >= 0.02 EUR/kWh`, and a REAL Eneco injection price
is negative for any BELPEX-H below 11,88 EUR/MWh - the hours a PV
household actually exports in. Applied to injection this floor would
refuse the market, and refuse it hardest in the hours where knowing the
number matters most. `implausible_injection()` is the injection band
and it is deliberately NOT this function with a lower floor.
"""
energy = price_eur_kwh(spec, spec.card_index_eur_per_mwh)
all_in = energy + float(taxes_eur_kwh)
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
return None
return (
f"price_formula's own card works out at {energy:.5f} EUR/kWh of "
f"{spec.option}'s own card works out at {energy:.5f} EUR/kWh of "
f"energy ({spec.card_expect_incl_vat:g} {spec.units} as the card "
f"prints it) and {all_in:.5f} EUR/kWh all-in once "
f"price_taxes_eur_kwh is added, which is outside the plausible band "
@@ -558,6 +868,85 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
f"the best charging opportunity of the year - is never refused.")
def implausible_injection(spec: FormulaSpec):
"""The INJECTION leg's plausibility band. Returns a refusal or None.
⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, NOT REFUSED, AND NEITHER RUNG
BELOW LOOKS AT THE SIGN OR THE LEVEL OF A PRICE. Eneco's published line
`0,1 x BELPEX-H - 1,188` is negative for any BELPEX-H under 11,88 EUR/MWh;
those are the midday hours a PV household exports in, so a band with a
floor above zero - which is what `implausible_all_in()` has, for the
consumption leg's own good reasons - would refuse the real market and
would hide it in exactly the hours it costs money. You CAN be charged to
export. The add-on says so rather than declining to represent it.
THE PROBLEM THAT LEAVES, STATED BEFORE THE SOLUTION. The consumption
floor was not decoration: it is what catches a whole card read a hundred
times too small, the one unit error the card self-check cannot see because
it cancels out of a comparison between the two sides. A band that spans
zero cannot catch it - 0.00132 EUR/kWh is inside any interval that
contains -0.03. So the bound moves OFF the price and ONTO the
coefficients, where it is index-free and sign-free:
* PASS-THROUGH. `factor x UNITS[units] / 0.001` is the dimensionless
share of the wholesale price the contract passes through, because one
EUR/MWh IS 0.001 EUR/kWh. Eneco's injection leg is 1.00 and Aspiravi's
is 0.70. A factor typed 100x too small reads 0.01 and 100x too large
reads 100, whatever the index and whatever the sign of the result.
* OFFSET MAGNITUDE. Eneco's is 1,188 c EUR/kWh = 0.01188 EUR/kWh and
Aspiravi's is 2 c = 0.02. A hundredfold error is 1.188 EUR/kWh, which
is not an injection tariff in any direction. The bound is on the
ABSOLUTE value, so a negative offset of a believable size passes and
that is the normal case on this leg.
⚠️ TWO RUNGS THAT CAN BOTH ANSWER ONE STIMULUS, SO THEY ARE GRADED ON
THEIR REASONS AND MUTATED TOGETHER (MUTATION.md, TEST-16). A whole card
read 100x too large trips both. The fixtures in test_price.py 17d are
built so that each rung is the ONLY one that fires on its own stimulus.
⚠️ AND WHAT THIS CANNOT SEE, WITH THE CLAIM (habit 13). It bounds the
SHAPE of the coefficients, never their truth: a contract really written
against a different index, or last year's coefficients, passes both rungs
and reproduces any card typed beside them. Where a card DOES print an
estimate the self-check grades the pair against it; where it does not -
which is Eneco's injection line - these two bounds are the whole of what
is mechanised, and validate.py says so at startup.
"""
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
return (
f"{spec.option}.factor is {spec.factor:g} {spec.units} per "
f"EUR/MWh, which passes through {spec.passthrough:.4g}x the "
f"wholesale price - outside the plausible "
f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x for an injection tariff. A "
f"Belgian injection formula pays a share of the market price a "
f"little under or about equal to it: Eneco's "
f"`0,1 x BELPEX-H - 1,188` c EUR/kWh is 1.00x and Aspiravi's "
f"`0,07 x Belpex - 2` is 0.70x. {spec.passthrough:.4g}x is what a "
f"factor typed in the wrong units looks like - check `units` "
f"({spec.units!r}) against the unit printed beside your formula, "
f"because the card self-check cannot see a factor of 10, 100 or "
f"1000 applied to BOTH sides at once. ⚠️ This bounds the "
f"COEFFICIENT and not the price: an injection price below zero is "
f"accepted and is the real market - Eneco's goes negative under "
f"11.88 EUR/MWh of BELPEX-H, which is the middle of a sunny day.")
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
return (
f"{spec.option}.offset is {spec.offset:g} {spec.units} = "
f"{spec.offset_eur_kwh:+.5f} EUR/kWh, and |offset| above "
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh is not a Belgian "
f"injection tariff in either direction. The published ones are "
f"small and NEGATIVE - Eneco -1,188 c EUR/kWh (-0.01188), "
f"Aspiravi -2 c (-0.02) - so a minus sign here is expected and is "
f"not what this refuses; a MAGNITUDE two orders out is. The usual "
f"cause is the offset typed in a different unit from `units` "
f"({spec.units!r}), or a consumption offset copied onto the "
f"injection leg: they are different numbers on the same card. "
f"⚠️ Network charges and levies do not belong here either - "
f"nothing is added to what you are PAID for export, so an offset "
f"carrying them is simply wrong rather than double-counted.")
return None
def stale_card(spec: FormulaSpec, today=None):
"""A warning when the card is older than ~6 months, or None. NEVER a
refusal: ENGIE freezes a contract's coefficients for its duration, so an
@@ -568,7 +957,7 @@ def stale_card(spec: FormulaSpec, today=None):
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
return None
return (
f"price_formula's card is dated {spec.card_month}, which is "
f"{spec.option}'s card is dated {spec.card_month}, which is "
f"{(now - dated).days} days ago. Suppliers reprice: if your contract "
f"has been renewed or rolled over since then, the coefficients here "
f"are last year's and every price this add-on computes is wrong by "
+316 -3
View File
@@ -66,6 +66,7 @@ from .mqtt import (MqttPublisher, announced_units, unit_slugs,
unit_state_key)
from . import peakguard
from . import packshare
from . import shadow
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option, provider_configured
from .price_history import PriceHistory
@@ -830,6 +831,36 @@ class Controller:
# live state
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
# --- SAFETY-10 -------------------------------------------------------
# What the decision path reached on the last shadow cycle, in W, or
# None before the first one. ⚠️ IT IS KEPT SEPARATELY FROM
# `self.target` BECAUSE THEY ARE DIFFERENT FACTS: `self.target` is what
# is STANDING ON THE WIRE - held at `shadow.WIRE_W` for the whole of a
# shadow run, and re-seeded to it at the end of every shadow cycle so
# the loop and the sign watchdogs track reality - and this is what a
# live controller WOULD have put there. Folding them into one field is
# how a shadow run starts telling an operator a number nothing is
# doing.
self.shadow_would_w = None
# 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
# operator to infer it from a page nobody is looking at yet. Coming up
# LIVE is silent - see `_shadow_settle()`, which says why.
self._shadow_was = None
self._shadow_winner = None
self.target = 0.0
self.sat_count = 0
self.i_w = 0.0 # the loop's integrator, carried between cycles
@@ -904,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
@@ -1739,6 +1776,42 @@ class Controller:
"reports a different value.",
inv.name, inv.batt_fresh.reason)
@property
def shadow(self) -> bool:
"""SAFETY-10. Is the battery being left alone right now?
⚠️ A PROPERTY, NOT A FIELD, AND FOR THE REASON `self.units` IS ONE.
Three surfaces ask this question at three different moments - the
cycle, the write, and the status page rendered between two cycles - and
a cached flag is a fourth answer that can disagree with the option and
the button it is derived from. Derived, they cannot.
⚠️ AND THE WRITE GATE ASKS THIS, NOT A PARAMETER PASSED DOWN TO IT. A
suppression that had to be threaded through `write_setpoint()` would be
absent from every call site that forgot it - the heartbeat, the stop
button, `shutdown()` - which is exactly the shape of hole this add-on's
single choke point exists to close.
"""
return shadow.engaged(self.o, control_started=self.auto)
def shadow_latches(self) -> tuple:
"""The failsafe latches that are UP, named, for the shadow banner.
A latch that is standing while the controller is inert is standing when
it stops being inert. `shadow.banner()` carries them so an operator who
turns shadow mode off is not handed a battery that will not move and a
page that never mentioned why.
"""
out = []
if self.circ_wd.tripped:
out.append("circulating power")
if self.site_sign_wd.tripped and len(self.inverters) > 1:
out.append("site sign watchdog")
for inv in self.inverters:
if inv.sign_wd.tripped:
out.append(f"{inv.name} sign watchdog")
return tuple(out)
def write_refusal(self, value: float, inv=None) -> str | None:
"""Why `value` may NOT be written to `inv` right now, or None if it may.
@@ -1930,6 +2003,40 @@ class Controller:
self.log_event(f"{inv.name}: {result.describe()}")
value = result.applied_w
# --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE -----------
# Shadow mode runs the whole decision path and commands nothing. The
# suppression is HERE, in the one method every write in this add-on
# reaches - the cycle, the heartbeat, the stop button, shutdown() and
# maint_abort() alike - so there is no call site that can route round
# it. app/shadow.py carries the whole argument; three things about the
# placement belong here:
#
# ⚠️ IT IS A SUBSTITUTION, NOT A RETURN, exactly like the two refusals
# above it. Returning would leave the inverter LATCHED ON ITS LAST
# COMMAND with the ESP32's heartbeat still being fed - a battery held
# at whatever it was doing when shadow mode was switched on, under a
# status page saying nothing is being commanded. That is the failure
# this add-on keeps finding, and shadow mode must not be a new way to
# reach it. `shadow.WIRE_W` is 0.0 W and it is REFRESHED on the
# ordinary heartbeat, which is also what firmware/goodwe-master.yaml's
# 30 s stale-input watchdog requires of us.
#
# ⚠️ IT IS BELOW SAFETY-28's GATE AND BELOW SAFETY-03's CLAMP, so both
# still grade the number the decision path actually produced. A shadow
# run that could not say "it would have hit the rating here" would be
# answering a different question from the one it is for.
#
# ⚠️ IT IS NOT A SECOND WRITER. No claim is filed, no transport is
# opened and no arithmetic of its own reaches the wire: one actuator,
# one writer, and this is that writer emitting the value it emits for
# every other refusal.
if self.shadow and value != shadow.WIRE_W:
_LOG.info("SHADOW MODE: not commanding %+.0f W to %s - holding "
"%+.0f W. The decision was reached and recorded; "
"nothing was sent to the inverter.",
value, inv.name, shadow.WIRE_W)
value = shadow.WIRE_W
changed = (inv.last_write is None or value != inv.last_write)
if not (changed or force):
return
@@ -2549,7 +2656,16 @@ class Controller:
# operator switching control off for the whole site; "inputs missing" is
# the SITE's own telemetry gone - the meter this controller regulates
# against. Neither is about a pack, and both must stop every inverter.
if not self.auto:
# ⚠️ SAFETY-10: `and not self.shadow`, AND IT IS THE ONE FAILSAFE THIS
# CARD CHANGES. `P_SAFETY` out-ranks every other claim, so leaving the
# stop claim in while shadow mode is engaged would make the recorded
# decision "stopped" on every cycle - a shadow run that answers the
# wrong question, which is the same as no shadow run. The actuator ends
# at the same number either way, and the substitution in `_write_unit()`
# that replaces it is STRICTLY STRONGER than this claim: a claim can be
# out-ranked by a higher priority, and the wire gate cannot be
# out-ranked by anything.
if not self.auto and not self.shadow:
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped",
actuator=self.actuator_of("safety")))
elif not self.inputs_ok:
@@ -2720,12 +2836,35 @@ 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),
# SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS
# ONE. `guard_state()` is a switch and it was the whole loop:
# the guard engaged on a 5 W floor, the trade was refused
# outright, the loop took the actuator, the meter fell, the
# guard released and the trade came back AT max_w. Measured on
# TEST-23 stage 3 at the shipped peak_cap_w/max_w: 28 charge
# onsets an hour, grid -456 <-> +3200 W. This is the watts the
# billed quarter can still afford, so the buy leg is CLAMPED
# instead - the shape control.maintenance_charge_floor() has
# always given a maintenance charge, off the MEASURED guard
# rather than off the optional peak_forecast_entity.
# ⚠️ SAME ORDERING ARGUMENT AS THE LINE ABOVE: this reads the
# 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=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
@@ -2877,7 +3016,14 @@ class Controller:
inv)))
# --- the grid-following loop -----------------------------------------
if self.auto and self.inputs_ok:
# ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most
# needs and the one that is switched off by `auto_start: false`, so
# without this an install in `auto` shadow mode would record every
# strategy's decision and a permanent silence where the grid-following
# loop should be. It is safe because the loop's `prev_w` is
# `self.target`, which the end of this method re-seeds to what is
# actually standing on the wire.
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
# actually did, and the controller must track reality or it jumps
@@ -3211,6 +3357,133 @@ class Controller:
# reads only `self.p1`, which no line in this method writes.
self.peak.sync(self.p1)
# --- SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS -----
# Every guard above has now read `self.target` - the resolved intent -
# and SAFETY-18 in particular grades exactly that. Re-seeding before
# them would hand each one a 0 W that no decision produced. Re-seeding
# AFTER them hands the NEXT cycle, and the sign watchdogs, what is
# actually standing on the wire. See `_shadow_settle()`.
self._shadow_settle(resolution)
def _shadow_settle(self, resolution) -> None:
"""SAFETY-10. Record the decision, then re-seed the carried command.
TWO JOBS, AND THEY ARE IN THIS ORDER BECAUSE THE SECOND DESTROYS THE
FIRST'S EVIDENCE.
1. THE RECORD. A shadow run is only worth running if it answers "what
would it have done, and why". `self.shadow_would_w` and
`Resolution.explain()` are that answer, and the action log gets one
line per CHANGE OF DECISION rather than one per cycle - the log
holds 40 rows, and a level-logged shadow run would flush the context
of its own findings inside four minutes at a 15 s cycle floor. The
same edge rule the peak guard and the arbitrage strategy already
use, for the same reason.
2. THE RE-SEED. `self.target` and each `inv.target` are set back to
`shadow.WIRE_W` - what the write gate actually put on the wire.
⚠️ WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. Three
machines read the carried command as a statement about the HARDWARE:
* `compute(prev_w=self.target, ...)` - the loop's slew limiter and
its integrator. Left holding a shadow decision, the first cycle
after shadow mode is turned off would slew from a base that never
existed: up to a full `slew_w` step onto a battery sitting at 0 W.
Re-seeded, the exit is safe BY CONSTRUCTION and needs no separate
transition code - the carried command already equals the standing
one.
* `Inverter.standing_w()`, which is what SAFETY-02/30's sign
watchdogs grade. Fed a shadow decision they would compare a number
against a meter that never saw it, disagree for as long as the
decision asked for power, and LATCH A TRIP THAT SURVIVES INTO LIVE
OPERATION AND A RESTART. Shadow mode would manufacture the failsafe
it exists to help debug. ⚠️ THIS ONE IS ALREADY HELD BY THE WIRE
GATE - `standing_w()` prefers `last_write`, and that is the 0.0 W
the gate accepted - so the re-seed is the SECOND rung, covering
the fallback before any write has been accepted. Two rungs
answering one question is MUTATION.md's masking shape, and the
mutation that reproduces the hazard has to defeat BOTH: driven
singly, dropping this line leaves the detector inert and names
nothing about the latch. Measured.
* SAFETY-42's delivery watchdog compares `inv.target` against what
the pack is producing. A share nothing was told to produce is a
delinquency report on every cycle of every shadow run.
⚠️ AND EVERY CYCLE'S DECISION IS THEREFORE COMPUTED FROM WHERE THE
PLANT REALLY IS, not from a counterfactual plant that drifts further
from reality every tick. "What would this controller command NOW,
given this meter and a battery at 0 W" is a true statement about the
present; "what would the eleventh cycle of a run that never happened
have commanded" is not a statement about anything.
"""
engaged = self.shadow
if engaged != self._shadow_was:
# ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The
# edge is against `None` on cycle one, and an install that comes up
# CONTROLLING has nothing to report - a "shadow mode off" line at
# every start is one more row of boilerplate in a 40-row log, and
# it was measured pushing a real clamp episode's context out of
# test_clamp.py part 2's ring. Coming up IN shadow mode is the
# other direction and it is always worth a line: it is the log an
# operator reads to find out why nothing is being commanded.
first = self._shadow_was is None
self._shadow_was = engaged
if engaged:
self.log_event(
"SHADOW MODE ON - " + shadow.why(
self.o, control_started=self.auto)
+ f"; inverter held at {shadow.WIRE_W:.0f} W")
_LOG.warning(
"SAFETY-10 SHADOW MODE IS ON. The full decision path runs "
"and NOTHING is commanded: every inverter is held at "
"%+.0f W on the ordinary heartbeat. %s", shadow.WIRE_W,
shadow.why(self.o, control_started=self.auto))
elif not first:
self.log_event(
"SHADOW MODE OFF - the battery is being controlled again, "
f"resuming from {shadow.WIRE_W:.0f} W")
_LOG.warning(
"SAFETY-10 shadow mode is OFF. This controller is "
"commanding the battery again, starting from the "
"%+.0f W that was standing on the wire.", shadow.WIRE_W)
if not engaged:
self.shadow_would_w = None
self.shadow_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.
# ⚠️ 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(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
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
@@ -4000,6 +4273,14 @@ class Controller:
or (self.site_sign_wd.tripped
and len(self.inverters) > 1)
or self.circ_wd.tripped
# SAFETY-10: "shadow" is its own value and not a
# flavour of "stopped". A fleet dashboard reporting a
# shadow install as `running` would be calling a
# battery nobody is controlling healthy; one reporting
# it as `stopped` would lose the fact that the decision
# path is live and being recorded. ⚠️ BELOW `failsafe`:
# a latch that is up is up, and shadow does not clear it.
else "shadow" if self.shadow
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
# `site_circulating_w`. `publish()` drops None values, so a cycle
@@ -4500,7 +4781,24 @@ class Controller:
def status_payload(self) -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
if self.circ_wd.tripped:
# --- SAFETY-10, AND IT IS FIRST IN THE CASCADE -----------------------
# "The battery is NOT being controlled" is the one sentence on this
# page that may never be displaced by another condition. A banner that
# can be out-ranked does not STATE a thing plainly; it states it
# whenever nothing else is happening, which is the half of the time
# nobody needs telling.
#
# ⚠️ AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. The three
# `bad` branches below all say some version of "held at 0 W", which is
# still true in shadow mode - but an operator who turns shadow mode off
# with a latch up gets a battery that will not move and a page that
# never mentioned it. `shadow.banner()` names the latch and goes red
# for it, so nothing this cascade would have said is lost.
_shadow = shadow.banner(self.o, control_started=self.auto,
latched=self.shadow_latches())
if _shadow is not None:
level, banner = _shadow
elif self.circ_wd.tripped:
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are
# latched P_SAFETY stops, so the site is at 0 W either way and the
# banner can only carry one sentence. This one goes first because it
@@ -4600,6 +4898,21 @@ class Controller:
("State of charge", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
# "Commanded 0 W" above and a "Why" below that explains a trade is
# the exact pair an operator would otherwise have to guess at; this
# row is what makes the two of them consistent, and it is rendered
# in EVERY state - including `off`, where it says the battery IS
# being controlled - because a surface whose ABSENCE is how you
# learn something is a surface you have to already know about.
("Shadow mode", shadow.words(
self.o, control_started=self.auto,
would_w=self.shadow_would_w,
# 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})"),
+139
View File
@@ -822,6 +822,145 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
f"only give {ceil_top:.0f} W" if saturated else "")))
def charge_headroom_w(decision) -> float | None:
"""SAFETY-45. The hardest this SITE may charge without putting the billed
quarter over the ceiling, in W, positive - or None when nothing measured it.
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
`discharge_permitted()`, `shaping_suspended()`, `guard_state()` and
`looks_frozen()` are: the check that grades it has to assert on THE SHIPPED
EXPRESSION, and a predicate transcribed into its own test is one copy plus a
future disagreement about what the check means.
------------------------------------------------------------------------
THE DEFECT IT PREVENTS, AND IT IS NOT A NEW IDEA IN THIS REPOSITORY
`control.maintenance_charge_floor()` already caps MAINTENANCE charging at
"the headroom left under the site's cap", and its docstring says why in
words this card only has to widen:
"A maintenance charge is the only thing this system does that is big
enough and long enough to set that peak, so it is capped by the
headroom left under the site's cap."
⚠️ THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED. Price arbitrage is a
SECOND thing that is big enough and long enough - it commands `max_w` for a
whole tariff interval, deliberately, against the meter - and it was given no
such cap. Measured on TEST-23 stage 3's closed rig at the shipped
`peak_cap_w: 3500` / `max_w: 2000`: 28 charge onsets in one hour, mean period
131 s, grid swinging -456 <-> +3200 W, on any house drawing over 1500 W in
the cheapest quarter. §10.2: "an EMS that oscillates is worse than no EMS,
both for hardware wear and - per §9.1 - for the peak it is trying to avoid."
⚠️ AND IT IS THE **MEASURED** GUARD, NOT `peak_forecast_entity`.
`maintenance_charge_floor()` sizes itself from the forecast sensor, which is
OPTIONAL and empty on a default install - the exact hole SAFETY-25 had to
close in `shaping_suspended()`, where a suspension gated on the forecast
alone could not fire on the very site that needed it. This number comes off
`decide()`, which runs wherever P1 does.
------------------------------------------------------------------------
WHERE THE NUMBER COMES FROM: IT IS `wanted_w`, READ IN THE OTHER DIRECTION
`decide()` already computes
wanted = batt_w + excess = (the offtake there would be with the battery
at zero) - (the level this quarter can still afford)
as the battery DISCHARGE that would land the quarter exactly on the ceiling.
A negative `wanted` is not "nothing to say" - it is a permitted CHARGE of
`-wanted` watts, and throwing it away is SAFETY-45's whole defect. So this
function invents no arithmetic; it reads the sign the guard already had.
⚠️ AND THE QUANTITY IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE
TO IT CANNOT OSCILLATE. Raise the charge by dW and the meter rises by dW, so
`excess` rises by exactly dW while `batt_w` falls by dW: `wanted` does not
move. It is a FEED-FORWARD number - a function of the house, the ceiling and
the seconds already banked - so consuming it adds no feedback path around the
plant. The fixed point is `charge = ceiling - load`, with the meter sitting
ON the ceiling and `wanted` parked at `load - ceiling`, a whole `ceiling -
load` clear of the engagement boundary rather than chattering across it.
⚠️ AND A QUARTER BOUNDARY DOES NOT HAND IT BACK AT FULL POWER, WHICH IS THE
OTHER HALF OF SAFETY-45 AND IS NOT THE SAME CLAIM AS "IT DOES NOT CHANGE".
It DOES change - it is `(900 / remaining) * (ceiling - projected)`, so it is
largest in the last seconds of a block and collapses at the boundary. What
matters is WHAT IT COLLAPSES TO: at `elapsed = 0` the projection IS the held
level, so the answer is `ceiling - (load - pv)` - the house and the cap, and
nothing else. A trade refused at 14:59 therefore resumes at 15:00 at the rate
the new quarter can afford, not at `max_w`, and §10.1's answer survives the
boundary because the arithmetic does: no timer, no backoff constant, no state
carried between calls.
⚠️ AND THE AMPLIFICATION IS BOUNDED IN ITS EFFECT, NOT MERELY LARGE. Hold a
charge of exactly this size for the rest of the block and the block's average
lands on `ceiling` EXACTLY, wherever in the block it started:
(acc + (held + headroom) * remaining) / 900
= projected + (900/remaining) * (ceiling - projected) * remaining/900
= ceiling
so 162 kW of headroom with 5 s left and 900 W of it with the whole quarter to
go are the same statement about the bill. `test_peakguard.py` part 13 sweeps
that identity across the block rather than arguing it here.
------------------------------------------------------------------------
THE THREE ANSWERS
`None` nothing measured it: the guard never ran (`meter_source: off`), or
it ran with no battery reading to size a floor from, or the block
had no seconds left to influence. ⚠️ None IS NOT UNLIMITED. A caller
that treats it as "charge away" is `arbitrage.guard_state()`'s
collapse of None into False, one layer down - a strategy creating
the billed quantity while nothing measures the quarter it lands in.
`0.0` measured, and the answer is no charging: the guard is BLIND (its
claim already says so at `floor 0 W` and this agrees with it rather
than restating it), or it is holding and `wanted >= 0`.
`> 0` the watts a charge may pull this cycle.
⚠️ HOLDING IMPLIES ZERO, BY CONSTRUCTION AND NOT BY COINCIDENCE. Every state
that files a claim with `engaged=True` has `wanted > 0`, `wanted is None` or
`blind`, so this answers 0.0 or None there and can never hand a strategy
permission the guard's own `lo` is at that instant refusing.
------------------------------------------------------------------------
⚠️ WHY THIS IS A SIZING AND NOT A CLAIM, AND DO NOT "SIMPLIFY" IT INTO ONE
The obvious tidier answer is to delete `decide()`'s `wanted <= 0` gate and
let it file `lo = wanted` unconditionally - a NEGATIVE floor, meaning "you
may charge, but no harder than this". The arithmetic is identical, it is
continuous, and it would bind every claimant rather than only arbitrage. It
was measured, considered and REJECTED, and the reason is not style:
* `main.py`'s cheap-window maintenance charge files
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` - a NEGATIVE `hi`.
arbiter.py clause 2 applies EVERY limit at or above the winner's priority
and takes the most restrictive, with no notion of one limit out-ranking
another. A peak `lo = -300 W` against a shaping `hi = -1000 W` is
`lo > hi`: a CONTRADICTION, which resolves to 0 W - the battery PARKED,
in the one quarter of the month that sets the bill. That is the exact
failure `shaping_suspended()` exists to prevent, and the only way to
close it would be to key that suppression on "a claim exists", which on
every site this card is about suspends the maintenance charge-only
shaping PERMANENTLY.
* and it would change what `engaged` MEANS at five call sites outside this
module - `arbitrage.guard_state()`, `shaping_suspended()`, `claim()`,
main.py's edge-logged action line and main.py's status row - each of
which today reads it as "the guard is HOLDING".
`control.maintenance_charge_floor()` is a sizing and not a limit for the same
reason, which is why this is the shape that already fits.
DECISIONS/SAFETY-45-sizing-the-trade.md §2b carries the full argument.
"""
if decision is None:
return None
if decision.blind:
return 0.0
if decision.wanted_w is None:
return None
return max(0.0, -float(decision.wanted_w))
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
"""Must the maintenance charge-only shaping stand down this cycle?
+158 -18
View File
@@ -151,8 +151,8 @@ from textwrap import dedent
from urllib.parse import urlencode
from .custom_formula import (
FormulaInvalid, PREVIEW_HOURS, parse_price_formula, preview_lines,
price_eur_kwh, to_resolution)
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
_LOG = logging.getLogger("goodwe.price")
@@ -818,6 +818,69 @@ def plan_is_provisional(read: PriceRead) -> bool:
# Options: what a plugin may see, and what it may not
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
# TARIFF-18: what `price_taxes_eur_kwh` IS MADE OF
# --------------------------------------------------------------------------- #
# ⚠️ THE DEFECT THIS TABLE PREVENTS IS THE ONE THE SHIPPED 0.06 WAS: A NUMBER
# NOBODY COULD SOURCE. 0.06 EUR/kWh is roughly HALF the real Flemish non-energy
# figure, and because no reader could see what it was made of there was nothing
# to argue with. Every row below is one line of the Eneco residential card for
# Flanders, September 2026, INCLUDING 6 % VAT, converted from the c EUR/kWh the
# card prints to the EUR/kWh this option takes. `price_taxes_eur_kwh`'s default
# is their sum, and test_price.py section 1b re-adds them and refuses a default
# that is not exactly that.
#
# ⚠️ THIS IS A PER-DSO FIGURE, AND IT IS THE DISTRIBUTION ROW THAT MOVES. Across
# the eight Fluvius DSOs that row runs 5,28 c EUR/kWh (Midden-Vlaanderen) to
# 6,70 (West), so the all-in Flemish total runs about 0.117 to 0.131 EUR/kWh.
# A site outside Antwerpen should type its own total; the other two rows are
# Flanders-wide and do not move with the DSO.
#
# ⚠️ AND THE DEFAULT SITS NEAR THE LOW END OF THAT BAND ON PURPOSE, BECAUSE THE
# TWO ERROR DIRECTIONS ARE NOT SYMMETRIC. This scalar is a LEVEL component: it
# cancels exactly in `max - min` and cannot move which hour is cheapest
# (test_price.py 16b measures that as a family). The one DECISION it reaches is
# §17's negative-price guard in arbitrage.py, which tests the ABSOLUTE all-in
# price of the sell hour. A figure set too HIGH carries a genuinely negative
# hour up above zero, and the add-on exports into paid-for disposal - the exact
# case §17 exists to refuse. A figure set too LOW only declines a trade that
# would have paid. So where a default must stand in for eight different DSOs,
# it belongs at the bottom of the band and not in the middle of it.
#
# ⚠️ `energiebijdrage` IS ZERO, AND IT IS A ROW HERE RATHER THAN AN ABSENCE.
# "Vanaf 1 augustus 2026 bedraagt de energiebijdrage 0 EUR/MWh" - it was merged
# into the bijzondere accijns. Anyone re-deriving this total from a guide
# written before that date will find a separate energiebijdrage line and add it
# on top of an accijns that already contains it. The zero row is here so that
# the double-count is visible as a decision rather than as an omission.
FLEMISH_TAXES_COMPONENTS_EUR_KWH = (
("Fluvius distribution, Antwerpen", 0.0535),
("Bijzondere accijns, 'Alle verbruik' - no volume bands", 0.04876),
("Bijdrage groene stroom en WKK Vlaanderen", 0.0151),
("Energiebijdrage - 0 EUR/MWh since 2026-08-01, inside the accijns", 0.0),
)
# ⚠️ TARIFF-18, AND IT IS NOT HOUSEKEEPING - IT DECIDES WHICH CONFIGS START.
# TARIFF-17's rung in validate.py refuses a site that sets `price_taxes_profile`
# AND leaves `price_taxes_eur_kwh` at something other than the shipped default,
# because the table wins and the scalar would decide nothing. It compares
# against the DEFAULT rather than against presence, because the Supervisor sets
# `App.options = deepmerge(config.yaml defaults, stored options)` and a saved
# blob therefore carries every schema key whether the operator typed it or not
# (measured: the reference install's export is 70 of 71 keys - CHANGELOG 0.9.0,
# `tools/supervisor_selfapi_probe.py` §D).
#
# So moving the default turns a previously ACCEPTED configuration into
# `SystemExit(78)`. A site that stored 0.06 - which is every install that has
# ever saved its configuration - and that also carries a taxes table would stop
# starting on the upgrade, over a number it never typed and that decides
# nothing. That refusal would be TRUE and USELESS: the rung's subject is a
# number the OPERATOR typed, and a superseded shipped default is not one. It
# still reads as "untouched" here, and the rung's sentence names both figures.
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) # TARIFF-02 .. TARIFF-17
# ⚠️ CFG-03, AND HOW THIS TICKET AVOIDS WIDENING IT. Option defaults in this
# add-on already live in three places - config.yaml, main.py's `o.get(k, X)`
# fallbacks, and a dataclass default - held apart only by a tripwire in
@@ -836,7 +899,9 @@ def plan_is_provisional(read: PriceRead) -> bool:
PRICE_OPTION_DEFAULTS = {
"price_provider": "off",
"price_markup_eur_kwh": 0.03, # §14 tariffs.energy_price.markup_eur_kwh
"price_taxes_eur_kwh": 0.06, # §14 tariffs.energy_price.taxes_eur_kwh
# TARIFF-18: the sum of FLEMISH_TAXES_COMPONENTS_EUR_KWH above, to the
# card's own last digit. §14 tariffs.energy_price.taxes_eur_kwh.
"price_taxes_eur_kwh": 0.11736,
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "",
@@ -870,6 +935,20 @@ PRICE_OPTION_DEFAULTS = {
# `static_profile` precedent exactly, and here for the same CFG-03 reason:
# this dict is the ONE in-code mirror of config.yaml.
"price_formula": "",
# TARIFF-16. The INJECTION leg, in `price_formula`'s OWN grammar and read
# by `custom_formula.parse_export_formula()` - the same parser with its
# option name as an argument, so a second unit table and a second unknown-
# key rule do not exist to diverge. "" is unset and the scalar
# `export_price_eur_kwh` above decides, which is every site today.
#
# ⚠️ IT IS IN FRAMEWORK_KEYS, for the reason the scalar is: a CONSUMPTION
# plugin that could read an injection curve could fold it into a
# consumption price, and nothing downstream could tell.
#
# ⚠️ AND IT LIVES HERE RATHER THAN IN custom_formula.py FOR CFG-03's SAKE,
# exactly as `price_formula` and `price_taxes_profile` do: this dict is the
# ONE in-code mirror of config.yaml.
"price_export_formula": "",
# OPT-04. §10.1 priority 3, and §14's `optimizer.cycle_cost_threshold_eur`.
#
# ⚠️ THEY LIVE HERE FOR CFG-03's SAKE, exactly as `static_profile` does, and
@@ -902,6 +981,13 @@ FRAMEWORK_KEYS = frozenset({
# derived from this set, so `code_leaks()` refuses a plugin that names it
# without anything else being edited.
"price_taxes_profile",
# TARIFF-16. The hour-varying spelling of `export_price_eur_kwh`, and it
# belongs here for the same reason the scalar does: a plugin that could
# read an INJECTION curve could fold it into the CONSUMPTION series it
# emits, and `assemble()` has no way to notice a component that arrived
# inside a RawPoint. `BANNED_IN_PLUGIN_CODE` is derived from this set, so
# `code_leaks()` refuses a plugin that names it with nothing else edited.
EXPORT_FORMULA_OPTION,
})
@@ -1015,28 +1101,82 @@ def export_price_eur_kwh(opts: dict) -> float:
consumption is at 6% - so it is a second per-contract formula, not a second
copy of the first.
⚠️ TARIFF-15 LANDED THE EVALUATOR AND DID NOT LAND THIS CURVE, and the
difference is the point rather than an omission. `custom_formula` can now
express `B x index + A` exactly - it is what it exists for - but it is a
CONSUMPTION provider: it declares {ENERGY, MARKUP}, the framework adds the
consumption taxes, and an injection leg carries its OWN coefficients
(Eneco: 0,102 consumption against 0,100 injection) and its own VAT
treatment (injection is exempt; consumption is at 6%). Pointing this
scalar at that machinery would mean pricing an injection curve with a
consumption card, which is the wrong-card defect the card self-check
exists to refuse. So until the injection card lands this scalar remains a
SIMPLIFICATION an operator enters by hand - an average of what they are
actually paid - and not a reflection of the market. When it does land, an
injection curve gets a `PriceProvider` of its own and this scalar becomes
its FIXED-product default; the seam is already the right shape for that.
⚠️ TARIFF-16 LANDED THE INJECTION LEG, AND THIS FUNCTION IS UNCHANGED. The
curve lives in `price_export_formula` and is read by `export_formula()`
below; this reader still answers the FLAT injection price, still from one
option, still with no formula anywhere in its arithmetic. That is the
arrangement this docstring predicted before either existed - "an injection
curve gets a `PriceProvider` of its own and this scalar becomes its
FIXED-product default" - and it is why a site with a flat injection price
sees nothing change: `price_export_formula` ships "", `export_price_at()`
falls through to this function, and this function's body is byte-identical
to the one TARIFF-02 shipped.
⚠️ It is NOT reachable from a plugin: `export_price_eur_kwh` is in
FRAMEWORK_KEYS, so a consumption plugin cannot quietly fold an injection
price into a consumption price.
price into a consumption price. `price_export_formula` is in the same set
for the same reason.
⚠️ AND NOTHING IN THIS ADD-ON READS EITHER ONE TO TAKE A DECISION.
`arbitrage.py` prices the sell side as DISPLACED IMPORT and says so in its
own comment; both hours it compares are all-in consumption prices. This
reader has no caller under `app/` and neither does `export_price_at()`.
They are what an operator is shown and what a future export-aware strategy
would call - not a number any trade turns on today.
"""
return float(price_option(opts, "export_price_eur_kwh"))
def export_formula(opts: dict):
"""The configured `price_export_formula` as a `FormulaSpec`, or None.
TARIFF-16. Raises `custom_formula.FormulaInvalid` on a malformed leg - and
⚠️ THAT EXCEPTION IS NOT MEANT TO REACH A RUNNING SITE, by the rule
`taxes_profile()` states above and `static_profile` established:
validate.py parses the same option at STARTUP and refuses to start on it,
because a formula that only fails when something reads it is a defect
discovered at the worst possible moment and §11.4 makes every price
defect quiet.
⚠️ THE PARSE IS THE SAME ONE THE CONSUMPTION LEG GETS. There is no second
parser, no second unit table and no second resolution rule -
`parse_export_formula()` is `parse_price_formula()` with its `option`
argument moved, so every refusal an operator can hit on this leg is a
refusal that already had checks over it.
"""
return parse_export_formula(price_option(opts, EXPORT_FORMULA_OPTION))
def export_price_at(opts: dict, index_eur_per_mwh: float) -> float:
"""What the site is PAID for one exported kWh at one index value, EUR/kWh.
THE SEAM, AND ITS HONEST SCOPE. This is the one function a future
export-aware strategy calls, and it has NO CALLER UNDER `app/` today -
stated here rather than implied, because the add-on takes no decision on an
injection price (see `export_price_eur_kwh()` above and `arbitrage.py`'s
accounting-basis comment). What it buys now is that OPT-06 and anything
after it inherit a shape the market fits, instead of a scalar that cannot
hold its own factor, a negative offset, or a VAT rate different from the
consumption leg's.
⚠️ THE FLAT PATH IS THE FALLTHROUGH AND IT IGNORES THE INDEX, which is
CORRECT rather than a simplification: a site with a flat injection price is
paid the same for every kWh whatever the market did, and that is what the
scalar means. With `price_export_formula` empty - every site today - this
returns exactly `export_price_eur_kwh(opts)` for every index.
⚠️ A NEGATIVE ANSWER IS A REAL ANSWER. Eneco's `0,1 x BELPEX-H - 1,188` is
below zero for any BELPEX-H under 11,88 EUR/MWh, which is the middle of a
sunny day and precisely when a PV household exports. Nothing here floors
it: a caller that silently clamped it to zero would hide the hours in which
exporting COSTS money, which is the one thing an injection price is for.
"""
spec = export_formula(opts)
if spec is None:
return export_price_eur_kwh(opts)
return price_eur_kwh(spec, index_eur_per_mwh)
def taxes_profile(opts: dict):
"""The configured `price_taxes_profile` as a period table, or None.
+518
View File
@@ -0,0 +1,518 @@
"""SAFETY-10 - shadow mode: the whole decision path, and no command.
WHAT DEFECT THIS PREVENTS
=========================
Not one defect - the CLASS of them. Every optimiser fault this repository has
found was invisible from outside while it was happening, and each one was paid
for at the meter before anybody could see it:
* SAFETY-45 arbitrage against a binding `peak_cap_w` ran 28 charge onsets an
hour, the grid swinging -455 .. +3200 W, and put a billed
quarter 59 W over the cap;
* TARIFF-14 the price lookup answered for the wrong quarter-hour 45 minutes
in every 60, and traded on it;
* SAFETY-46 the SoC ceiling churns 598 Wh to gain 310 Wh, no hysteresis.
There was no way to WATCH what the optimiser would do without letting it do it.
A commissioning engineer could arm arbitrage and find out, or not arm it and
learn nothing. Shadow mode is the third option: strategies, arbiter, clamps and
guards all run, the decision is recorded with its reason, and NOTHING THE
CONTROLLER DECIDES REACHES THE INVERTER.
⚠️ 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
=======================================================================
The inverter holds its last command FOREVER - it has no meter timeout
(HANDOVER.md §6), and firmware/goodwe-master.yaml's header records what that
cost when a controller stopped refreshing: Illusmart went silent mid-command
and the inverter held +5000 W for 113 s. main.py's own header carries the same
measurement. So a shadow mode that
simply stopped writing would leave whatever was standing when it was switched
on standing for as long as it was left on. That is the exact failure this
repository keeps finding - LOOKS HEALTHY BUT IS INERT - with the status page
calmly reporting that nothing is being commanded.
So shadow mode does write, and it writes ONE number:
the actuator is held at WIRE_W (0.0 W), refreshed on the ordinary
heartbeat, by the ordinary single writer.
That is a DEFINED, SAFE actuator state, it is the same state the operator's
stop button reaches, and it honours the contract firmware/goodwe-master.yaml
§"CONTRACT CHANGE" imposes on us - HA must rewrite the setpoint every ~10 s or
the ESP32's 30 s stale-input watchdog cannot tell "steady" from "dead". Going
silent would put that board into a tripped-watchdog state for the whole shadow
run, which is an ALARM being used as a MODE: the operator who learns that
`wd_tripped` is normal during shadow runs is the operator who ignores it when
the add-on has actually died.
⚠️ AND IT IS NOT A SECOND WRITE PATH. SAFETY-28's rule is one actuator, one
writer. Shadow mode files no claim, owns no transport and calls nothing in
hass.py. It is a SUBSTITUTION inside `Controller._write_unit()` - the single
choke point every write in this add-on already goes through - in exactly the
form the three substitutions above it take (non-finite -> 0 W, unauthorised ->
0 W, clamped -> the rating). It is the LAST of them, so SAFETY-28's
authorisation gate and SAFETY-03's clamp still grade the number the decision
path actually produced, and a shadow run therefore records that the clamp WOULD
have engaged.
THE DEFAULT, AND WHY IT IS NOT A PLAIN `true` (§ the ticket's own warning)
==========================================================================
The card is titled "default-on". Taken literally - `shadow_mode: true` in
`config.yaml`'s `options:` block - it is the worst change in this repository's
history. Home Assistant's Supervisor composes an add-on's effective options as
`{**config.yaml defaults, **the stored user options}`, so a NEW key with a new
default reaches EVERY install on the next update, including the one regulating
a real battery in the author's house today. Literal default-on silently stops
an existing install from controlling, and the only symptom is a battery that
has stopped doing anything.
There is no mechanism in the add-on options system that lets one key default
differently for a new install and an existing one: `options:` IS the default,
and there is exactly one of it. So "default-on for new" has to be expressed as
a value the add-on RESOLVES, not as a constant the Supervisor merges.
Hence three values rather than two, defaulting to `auto`:
off never shadow. The controller commands the battery.
on always shadow, whatever the start/stop button says.
auto shadow WHILE CONTROL IS STOPPED; live once it is started.
`auto` is default-on for a new install and a no-op for an existing one, and it
is that without any persisted marker, any migration, and any guess:
* a FRESH install ships `auto_start: false` and has no stored `auto` key, so
it comes up stopped -> shadow. It observes a day of decisions before it is
ever allowed to command anything. That is the card's intent, delivered.
* an install that is CONTROLLING - `auto_start: true`, or the operator has
pressed *Start / stop control* and the store carries `auto: true` - is
started, so `auto` resolves to LIVE and its behaviour does not change by
one watt.
* an install that is STOPPED keeps its actuator at 0 W exactly as before -
the same number, from the same writer, on the same heartbeat - and gains
the recording. The only thing that changes for it is what the status page
and the action log SAY.
⚠️ AND `auto` MEANS THE START BUTTON LEAVES SHADOW MODE. That is deliberate:
under `auto` the button reads "Start / stop control" and pressing Start is the
operator saying "control the battery". An operator who wants the decisions
recorded WHILE the loop runs sets `shadow_mode: on` explicitly - that is the
configuration this card exists to make possible, and it is the one that cannot
be reached by accident.
⚠️ AN UNRECOGNISED VALUE RESOLVES TO SHADOW, NOT TO LIVE. `validate.py` refuses
it at startup so this is not meant to be reachable, but the fail-safe direction
for "the operator's intent is unreadable" is the one that does not move energy.
THE FAILSAFES, ONE BY ONE (the card asks for each to be decided and recorded)
============================================================================
| failsafe | in shadow mode |
|--------------------------------|-------------------------------------------|
| SAFETY-02/30 sign watchdogs | INERT, and by construction rather than by |
| (per-unit and site) | a special case. They grade |
| | `Inverter.standing_w()` - what this |
| | controller has standing on the wire - and |
| | shadow mode holds that at 0 W. A 0 W |
| | command cannot oppose a meter, so the |
| | detector never arms. ⚠️ THIS IS THE ONE |
| | THAT HAD TO BE GOT RIGHT: fed the SHADOW |
| | decision instead, every shadow run would |
| | latch a trip within `sign_disagree_s` - |
| | the decision opposes a meter that never |
| | saw it - and that latch SURVIVES INTO |
| | LIVE OPERATION and a restart. Shadow mode |
| | would manufacture the failsafe it is |
| | meant to help debug. |
| SAFETY-03 clamp | OBSERVES. It runs on the number the |
| | decision path produced, `clamp_last` is |
| | set and every engagement goes to the |
| | action log, because "it would have hit |
| | the rating" is part of what a shadow run |
| | is for. It changes no command: the value |
| | it narrows is replaced by 0 W below it. |
| SAFETY-28 write authorisation | OBSERVES, for the same reason and in the |
| | same place - above the substitution, so |
| | it still asks who chose the number. |
| SAFETY-18 meter freeze | OBSERVES, unchanged. It takes no action |
| | in live operation either; it grades the |
| | resolved intent against a meter that has |
| | stopped moving, and both halves are as |
| | true in shadow as out of it. |
| SAFETY-07/24/25 peak guard | OBSERVES, 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 |
| | while every one of them is told 0 W is a |
| | real fault and a latch is the right |
| | answer to it. |
| operator stop (`auto` False) | SUPPRESSED AS A CLAIM, ENFORCED AT THE |
| | WIRE. It is the one deliberate change. |
| | Its `P_SAFETY` 0 W claim out-ranks |
| | everything, so leaving it in would make |
| | the recorded decision "stopped" on every |
| | cycle - the shadow run would answer the |
| | wrong question. The actuator state is |
| | identical either way, and the wire |
| | substitution is STRICTLY STRONGER than |
| | the claim: a claim can be out-ranked, and |
| | this cannot. |
| inputs missing, non-finite, | UNCHANGED. All of them resolve to 0 W, |
| allocator refusal, arbiter | which is what the wire gets in shadow |
| contradiction | mode anyway, so they act redundantly and |
| | are recorded as they always were. |
⚠️ MAINTENANCE IS NOT IN THAT TABLE BECAUSE SHADOW MODE DOES NOT TOUCH IT. The
state machines are ticked on every cycle today whether control is started or
not, so a stopped install already runs them against a battery that does not
move. Shadow mode inherits that, unchanged and unimproved; it is a pre-existing
property of `auto_start: false` and it is recorded here rather than quietly
fixed under another card's name.
LEAVING SHADOW MODE
===================
`Controller.cycle()` re-seeds its carried command (`self.target` and each
`inv.target`) to WIRE_W at the END of every shadow cycle, AFTER every in-cycle
guard has read the decision. Two things follow, and both are safety properties:
* the loop's `prev_w` and its integrator track WHAT IS ACTUALLY STANDING ON
THE WIRE, so every shadow cycle answers "from where the plant really is,
what would this controller do NOW" - a true statement about the present,
rather than a simulation of a counterfactual plant that drifts further from
reality every tick;
* the exit is safe BY CONSTRUCTION. The carried command already equals the
standing command, so the first live cycle slews from 0 W like any other
cycle. Carrying the shadow decision instead would let the slew limiter
depart from a base that never existed - up to a full `slew_w` step onto a
battery sitting at zero.
The entry is safe for the ordinary reason: the substitution happens on the very
first write of the very first shadow cycle, and `last_write` was not 0 W, so
that write is issued rather than skipped as unchanged.
"""
from __future__ import annotations
# ⚠️ CFG-03's mirror. `test_defaults_pin.py` part 2 discovers every
# module-level name ending in OPTION_DEFAULTS and asserts each value
# reproduces config.yaml EXACTLY, so this table cannot drift from the shipped
# default. Nothing in the add-on may carry a literal fallback for a key a
# mirror answers for - part 3 scans for that - which is why `main.py` asks the
# functions below and never reads `shadow_mode` itself.
SHADOW_OPTION_DEFAULTS = {
"shadow_mode": "auto",
}
#: The three values `shadow_mode` may take, in the order config.yaml's schema
#: lists them. `validate.py` refuses anything else by name.
MODES = ("auto", "on", "off")
#: What stands on the wire while shadow mode is engaged. ⚠️ A NUMBER, NOT A
#: SILENCE - see this module's header. It is 0.0 W for the same reason every
#: other refusal in this add-on is 0.0 W: fail toward inaction, and keep
#: commanding it so the inverter cannot be left latched on anything else.
WIRE_W = 0.0
#: 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`.
⚠️ AN UNRECOGNISED VALUE ANSWERS `"on"`, NOT `"auto"` AND NOT `"off"`.
`validate.py` refuses such a value at startup, so this branch is not meant
to be reachable at all; it exists because the fail-safe direction for "the
operator's intent cannot be read" is the one that does not move energy. A
typo that silently resolved to `off` would put a battery under a controller
whose configuration nobody could parse.
"""
raw = opts.get("shadow_mode", SHADOW_OPTION_DEFAULTS["shadow_mode"])
text = str("" if raw is None else raw).strip().lower()
if text in MODES:
return text
return "on"
def engaged(opts: dict, *, control_started: bool) -> bool:
"""Is the battery being left alone right now?
`control_started` is `Controller.auto` - the persisted start/stop state,
which a fresh install gets from `auto_start` and an operator moves with the
*Start / stop control* button. See the header on why `auto` reads it.
"""
m = mode(opts)
if m == "off":
return False
if m == "on":
return True
return not control_started
def why(opts: dict, *, control_started: bool) -> str:
"""Which rung decided, in the words an operator can act on.
⚠️ THE REASON, NOT ONLY THE VERDICT - MUTATION.md's masking rule. `on` and
`auto`-while-stopped produce the same boolean and want different operator
actions: one is turned off by editing an option, the other by pressing a
button. A surface that showed only "shadow" would make a mutation that
swapped the two rungs invisible.
"""
# ⚠️ THE LIVE SENTENCES SAY "NOT IN SHADOW MODE", NOT "THE BATTERY IS
# BEING CONTROLLED". The second is FALSE on a stopped install with
# `shadow_mode: off` - nothing is regulating there either - and a row that
# is false in one of the four live states is a row an operator learns to
# discount. What is true in every live state is that what this add-on
# decides reaches the inverter; whether control is RUNNING is the
# `Stopped` banner's question and it is answered there.
m = mode(opts)
if m == "off":
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
"add-on decides DOES reach the inverter")
if m == "on":
return ("on (shadow_mode: on) - decisions are recorded and nothing "
"is commanded")
if control_started:
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
"mode: what this add-on decides DOES reach the inverter")
return ("on (shadow_mode: auto, control is stopped) - decisions are "
"recorded and nothing is commanded")
def words(opts: dict, *, control_started: bool, would_w: float | None,
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
ABSENCE is how you learn which mode you are in is a surface an operator
has to already know about; TARIFF-11's rule is that the rendered text is
what gets graded, so there has to be text in both states.
`would_w` is the decision the path reached this cycle - what a live
controller would have put on the wire 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):
return head
if would_w is None:
return (f"{head}; no decision has been reached yet - the inverter is "
f"held at {WIRE_W:.0f} W")
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
# the whole sentence it ate the comma out of `head` - and `head` is the
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
# masking rule says must survive.
num = f"{float(would_w):+,.0f}".replace(",", " ")
out = (f"{head}; 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.
⚠️ IT NAMES A STANDING LATCH AND GOES RED FOR ONE. The shadow banner sits
ABOVE every other branch of the cascade, because "the battery is not being
controlled" is the one sentence that may never be displaced - but a
failsafe latch that is up while the controller is inert is up when it
stops being inert, and an operator who turns shadow mode off without
clearing it gets a battery that will not move and a green page that
never mentioned why. So the latch is carried INTO the shadow banner
rather than losing its own.
"""
if not engaged(opts, control_started=control_started):
return None
text = ("SHADOW MODE - the battery is NOT being controlled. Every "
f"inverter is held at {WIRE_W:.0f} W and nothing this add-on "
"decides reaches the inverter.")
if latched:
return ("bad", text + " ⚠️ A FAILSAFE LATCH IS STILL UP ("
+ ", ".join(latched) + ") and it will still be up when shadow "
"mode is turned off.")
return ("warn", text)
def problems(opts: dict) -> list:
"""`validate.py`'s rule: `shadow_mode` must be one of the three values.
⚠️ THE SENTENCE SPELLS `shadow_mode`. `configui.problems_by_field()` files
a refusal under the field whose key its text contains; a rule that never
names a key lands in the page's top-level block instead, where the operator
cannot see which control is wrong. UX-02's whole point is that the shipped
validator is the only rule surface, so the rule has to be readable there.
⚠️ AND IT IS A REFUSAL RATHER THAN A COERCION. `mode()` resolves an
unreadable value to shadow, which is the safe direction but is also a
SILENT reinterpretation of what the operator wrote - and an install left in
shadow mode by a typo commands nothing at all, indefinitely.
"""
raw = opts.get("shadow_mode", SHADOW_OPTION_DEFAULTS["shadow_mode"])
text = str("" if raw is None else raw).strip().lower()
if text in MODES:
return []
return [f"shadow_mode is {raw!r}, which is not one of "
+ ", ".join(f"'{m}'" for m in MODES)
+ ". 'auto' leaves the battery alone while control is stopped and "
"commands it once control is started; 'on' records every "
"decision and commands nothing at all; 'off' is ordinary "
"control."]
+138 -6
View File
@@ -325,12 +325,16 @@ import logging
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .custom_formula import (
FormulaInvalid, implausible_all_in, parse_price_formula, stale_card)
EXPORT_FORMULA_OPTION, FormulaInvalid, implausible_all_in,
parse_export_formula, parse_price_formula, stale_card)
from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
from .peakguard import PEAK_OPTION_DEFAULTS
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS
from .price import (
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
from .shadow import problems as shadow_problems
from .static_tariff import (
ProfileInvalid, TAXES_PROFILE_OPTION, parse_static_profile,
parse_taxes_profile)
@@ -837,6 +841,18 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# missing rating one line up.
problems.extend(_roster_problems(opts))
# --- 11. SAFETY-10's shadow mode, and it is a REFUSAL ---------------------
# `shadow_mode` decides whether this controller commands the battery at all,
# so an unreadable value is not a detail to log and carry on past.
# `shadow.mode()` resolves an unrecognised one to SHADOW - the direction
# that does not move energy - and an install silently reinterpreted that way
# commands nothing, indefinitely, while its status page calmly explains
# that this is intentional. UX-02 made this validator the ONE rule surface
# both editors read, so the refusal belongs here rather than in a coercion
# nobody can see. The sentence spells `shadow_mode` so
# `configui.problems_by_field()` can file it under the field.
problems.extend(shadow_problems(opts))
# --- 2. the configured ceiling against the device's own rating -----------
# ⚠️ THE FLAT `max_w`, AND ONLY THE FLAT ONE. `dev_min`/`dev_max` are ONE
# device's range - the flat `setpoint_entity`'s - so this rule can only ever
@@ -1444,13 +1460,32 @@ def _price_problems(opts: dict) -> list[str]:
# the price-entity/static_profile pair above. The table decides when it
# is set, so a non-default scalar left beside it is a number the
# operator typed that changes nothing, on the quantity §11.4 makes
# hardest to notice. It is compared against the SHIPPED DEFAULT rather
# hardest to notice. It is compared against the SHIPPED DEFAULTS rather
# than against presence, because the Supervisor writes every schema key
# into the options file whether the operator touched it or not.
# into the options file whether the operator touched it or not - so
# "the operator did not type this" has to be read off the VALUE.
#
# ⚠️ TARIFF-18: "UNTOUCHED" IS A SET, NOT ONE NUMBER, AND THE DEFECT
# THAT MAKES IT ONE IS AN UPGRADE THAT STOPS A BATTERY CONTROLLER
# STARTING. The Supervisor sets `App.options = deepmerge(config.yaml
# defaults, stored options)`, so a saved blob carries every schema key
# whether the operator typed it or not - the reference install's export
# is 70 of 71. When TARIFF-18 moved this default 0.06 -> 0.11736, every
# such blob went from "equal to the shipped default" to "not equal to
# it" without anybody editing anything, and a site that also carries a
# taxes table would have met `SystemExit(78)` on the upgrade. The
# sentence below is about a number the OPERATOR TYPED; a superseded
# shipped default is not one, so it still reads as untouched. The set
# is in price.py beside the default it supersedes, because the two only
# ever move together.
scalar = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
if scalar is not None and abs(scalar - shipped) > 1e-12:
untouched = (shipped,) + tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
superseded = ", ".join(
str(u) for u in SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
if scalar is not None \
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
problems.append(
f"{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is "
f"{scalar} rather than its shipped {shipped}. THE TABLE WINS: "
@@ -1458,7 +1493,10 @@ def _price_problems(opts: dict) -> list[str]:
f"the scalar, so the number you typed there would decide "
f"nothing and nothing would say so. Set price_taxes_eur_kwh "
f"back to {shipped}, or clear {TAXES_PROFILE_OPTION} to keep "
f"the flat figure.")
f"the flat figure. (A scalar still sitting on a SUPERSEDED "
f"default - {superseded} - is accepted and NOT refused: "
f"that is a number this add-on wrote into your options "
f"blob, not one you typed.)")
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
@@ -1523,6 +1561,7 @@ def _price_problems(opts: dict) -> list[str]:
# CARD AGAINST REALITY. A consistent-but-wrong pair - both numbers off the
# wrong product - reproduces its own printed price and passes. Nothing can
# catch that.
spec = None # TARIFF-16 reads this back, below
raw_formula = opts.get("price_formula", "")
has_formula = bool(str(raw_formula or "").strip()) \
or isinstance(raw_formula, dict)
@@ -1574,6 +1613,99 @@ def _price_problems(opts: dict) -> list[str]:
# cannot start.
_LOG.warning("%s", aged)
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
#
# THE DEFECT THIS EXISTS TO PREVENT: `export_price_eur_kwh` models
# injection as ONE scalar, and the published market is a line -
# Eneco `0,1 x BELPEX-H - 1,188` c EUR/kWh, "het injectietarief is
# dynamisch en kan per uur wijzigen", identical on the January and the
# September 2026 cards. A scalar cannot hold its own factor (0,100 against
# 0,102 on the same supplier's consumption leg), cannot hold a negative
# offset, cannot state that injection is VAT-EXEMPT while consumption is
# at 6%, and cannot go below zero in the midday hours a PV household
# actually exports in.
#
# ⚠️ AND WHAT IT DOES NOT AFFECT TODAY, said here rather than left to be
# inferred: `arbitrage.py` does not read the injection price at all. Both
# hours it compares are all-in CONSUMPTION prices, by its own
# accounting-basis comment. No trade turns on this option. What it changes
# is what the operator is told and what a future export-aware strategy
# rests on - which is why it is validated here rather than trusted.
raw_export = opts.get(EXPORT_FORMULA_OPTION, "")
has_export = bool(str(raw_export or "").strip()) \
or isinstance(raw_export, dict)
if has_export:
try:
xspec = parse_export_formula(raw_export)
except FormulaInvalid as e:
xspec = None
problems.append(f"{EXPORT_FORMULA_OPTION} cannot be used: {e}")
if xspec is not None:
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
# 0. Printing both beside each other is the only place an operator
# can see that the add-on knows it - a status page shows one price
# and no arithmetic.
_LOG.info(
"%s is set: injection priced as (%g x index %+g) x "
"(1 + %g/100) %s, i.e. %.4g x the wholesale price with a "
"%+.5f EUR/kWh offset. ⚠️ NO STRATEGY IN THIS ADD-ON READS "
"IT: arbitrage.py prices the sell side as displaced IMPORT "
"and compares two all-in CONSUMPTION prices, so this option "
"changes no decision the add-on takes today. It is what you "
"are shown, and the shape a future export-aware strategy "
"would read.",
EXPORT_FORMULA_OPTION, xspec.factor, xspec.offset,
xspec.vat_pct, xspec.units, xspec.passthrough,
xspec.offset_eur_kwh)
if spec is not None:
# ⚠️ THE ASYMMETRY, AS AN ORDERED PAIR RATHER THAN TWO NUMBERS
# IN ONE SENTENCE (MUTATION.md habit 14). "6 and 0 appear
# somewhere" is satisfied by the two legs transposed, which is
# the sentence telling an operator that the add-on has their
# VAT treatment exactly backwards.
_LOG.info(
"the two legs are priced independently: consumption "
"factor %g at vat_pct %g, injection factor %g at vat_pct "
"%g. In Belgium those differ - \"injectietarieven zijn "
"vrijgesteld van btw\" while consumption is at 6%% - and "
"%s is the option that makes the difference "
"expressible; a single scalar could not state it.",
spec.factor, spec.vat_pct, xspec.factor, xspec.vat_pct,
EXPORT_FORMULA_OPTION)
if not xspec.card_graded:
# ⚠️ A WARNING AND NEVER A REFUSAL, AND THE GAP IS NAMED
# RATHER THAN WORKED AROUND. `price_formula`'s card block is
# required because a Belgian card prints the consumption
# formula, the reference index AND the resulting price. Eneco's
# INJECTION formula is printed with no price beside it, so on
# that card there is nothing to reproduce. Requiring a block
# here would make the market's own published line
# inexpressible; inventing the estimate would grade the
# coefficients against arithmetic done with the same
# coefficients. So the leg is accepted, the two coefficient
# bounds in `implausible_injection()` are applied, and the
# operator is told which of the two checks did NOT run.
_LOG.warning(
"%s has no \"card\" block, so its coefficients are "
"UNGRADED: nothing recomputed a printed price from them. "
"That is accepted because it is the real market - Eneco "
"prints its injection formula with no price beside it, so "
"there is nothing to grade against on that card - and "
"refusing would make the published line inexpressible. "
"What IS checked is the shape of the coefficients: the "
"pass-through (%.4g x the wholesale price) and the offset "
"(%+.5f EUR/kWh). If YOUR card prints an injection "
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
"..., \"expect_incl_vat\": ...} and the add-on will "
"refuse to start unless your numbers reproduce it.",
EXPORT_FORMULA_OPTION, xspec.passthrough,
xspec.offset_eur_kwh)
else:
xaged = stale_card(xspec)
if xaged:
_LOG.warning("%s", xaged)
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
# site with no price data falls back to the static profile; a dynamic site
# with none configured has nothing to fall back to when its feed dies. That
+102 -4
View File
@@ -291,13 +291,58 @@ options:
# price_markup_eur_kwh is ignored under it, with a line in the log saying so.
price_provider: "off"
price_markup_eur_kwh: 0.03
price_taxes_eur_kwh: 0.06
# TARIFF-18. The all-in Flemish non-energy figure, EUR/kWh, incl. 6 % VAT.
# Its components are named in app/price.py's FLEMISH_TAXES_COMPONENTS_EUR_KWH
# and test_price.py section 1b re-adds them:
#
# Fluvius distribution (Antwerpen) 5,35 c EUR/kWh
# Bijzondere accijns, "Alle verbruik" 4,876
# Bijdrage groene stroom en WKK Vlaanderen 1,51
# Energiebijdrage (0 EUR/MWh since 2026-08-01) 0
# ------
# 11,736 c EUR/kWh = 0.11736
#
# ⚠️ THIS IS PER-DSO AND YOURS IS PROBABLY DIFFERENT. Only the distribution
# row moves: across the eight Fluvius DSOs it runs 5,28 c EUR/kWh
# (Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about 0.117
# to 0.131. Antwerpen is near the LOW end, which is the direction this
# default should err in - it is inert on which hour is cheapest, but §17's
# negative-price guard reads the ABSOLUTE all-in price, and a figure set too
# high would carry a genuinely negative hour above zero.
#
# ⚠️ WALLONIA AND BRUSSELS DO NOT BELONG IN THIS SCALAR AT ALL. Their
# distribution tariff has a day and a night band; use price_taxes_profile.
price_taxes_eur_kwh: 0.11736
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
# which by itself strongly favours self-consumption over export. A scalar, not
# a second series: no shipped supplier publishes a dynamic injection curve.
# which by itself strongly favours self-consumption over export. This is the
# FLAT injection price, for a site paid the same for every exported kWh.
#
# ⚠️ THE REASON THIS COMMENT USED TO GIVE - "no shipped supplier publishes a
# dynamic injection curve" - WAS FALSE, and TARIFF-07 struck the same
# sentence from app/price.py and DOCS.md without reaching this file. Eneco
# publishes `0,1 x BELPEX-H - 1,188` c EUR/kWh on the same card as its
# consumption formula, ENGIE publishes `(B x eSpot_15) - A`, and Aspiravi
# `0,07 x Belpex - 2`. A site on one of those sets price_export_formula
# below; this scalar stays the fixed-product default and is what every site
# uses today.
export_price_eur_kwh: 0.04
# TARIFF-16. The INJECTION leg as a formula, in price_formula's own grammar
# and with its OWN coefficients: Eneco uses 0,102 on consumption and 0,100 on
# injection, and injection is VAT-EXEMPT where consumption is at 6%, so
# `vat_pct` here is normally 0. A negative `offset` is expected - it is what
# the published lines carry - and the resulting price legitimately goes below
# zero in the midday hours a PV household exports in.
#
# ⚠️ NO STRATEGY IN THIS ADD-ON READS IT. arbitrage.py prices the sell side
# as displaced IMPORT and compares two all-in CONSUMPTION prices; it does not
# read the injection price at all. This option is what the operator is shown
# and the shape a future export-aware strategy would rest on. Empty by
# default, which leaves every existing site on the scalar above.
price_export_formula: ""
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
# of the installed base is on [a static profile]" - this is what makes those
# sites first-class rather than "price_provider: off, good luck".
@@ -359,7 +404,10 @@ options:
#
# ⚠️ SET THIS *OR* price_taxes_eur_kwh, NOT BOTH. Two writers of one quantity,
# one of which silently wins, is refused at startup: leave the scalar at its
# shipped default when you fill this in.
# shipped default when you fill this in. A scalar still sitting on the
# SUPERSEDED 0.06 is accepted too (TARIFF-18 moved that default, and your
# stored options carry the old number whether you typed it or not) - the
# refusal is about a figure you chose, not about one this add-on wrote.
price_taxes_profile: ""
# TARIFF-15. YOUR OWN TARIFF CARD, as two fixed coefficients. Used only when
@@ -698,6 +746,38 @@ options:
# and not 150 or 200. 37 control cycles.
delivery_hold_s: 185
# SAFETY-10. Run the whole decision path and command NOTHING - strategies,
# arbiter, clamps and guards all run, the decision and its reason are
# recorded, and every inverter is held at 0 W. `app/shadow.py` carries the
# argument; the three things to know before changing this line are:
#
# auto shadow WHILE CONTROL IS STOPPED, live once it is started. This is
# the shipped value and it is what "default-on" means here.
# on always shadow, whatever the Start / stop button says. This is the
# one an operator picks to watch a day of decisions on an armed site
# before letting it trade.
# off ordinary control.
#
# ⚠️ IT IS NOT A BOOLEAN DEFAULTING TO `true`, AND THAT IS THE WHOLE CARD.
# The Supervisor composes an add-on's options as `{**these defaults, **the
# stored user options}`, so a NEW key with a new default reaches every
# EXISTING install on the next update - including one regulating a real
# battery. `shadow_mode: true` would silently stop it, and the only symptom
# would be a battery that has stopped doing anything. There is no mechanism
# in this options system that lets one key default differently for a new
# install and an existing one, so "default-on for new" has to be a value the
# add-on RESOLVES. `auto` is that value: a fresh install ships
# `auto_start: false` and therefore comes up in shadow mode; an install that
# is controlling is started, so `auto` resolves to live and NOTHING about it
# changes.
#
# ⚠️ AND UNDER `auto` THE START BUTTON LEAVES SHADOW MODE. Pressing *Start /
# stop control* is the operator saying "control the battery". Somebody who
# wants the decisions recorded WHILE the loop runs sets `on` explicitly -
# which is the configuration this option exists for, and the one that cannot
# be reached by accident.
shadow_mode: "auto"
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
@@ -876,6 +956,15 @@ schema:
# bottoms out at 0 would make that site inexpressible.
export_price_eur_kwh: float(-1,1)
# TARIFF-16. A JSON string in `price_formula`'s grammar, parsed by
# `custom_formula.parse_export_formula()` - the SAME parser with its option
# name as an argument, so a second unit table and a second card rule do not
# exist to diverge. The `card` block is OPTIONAL on this leg and required on
# the other: Eneco prints its injection formula with no price beside it, so
# on that card there is nothing to grade against, and the add-on says at
# startup that the leg is ungraded rather than inventing an estimate.
price_export_formula: str?
# A JSON string rather than a nested schema, deliberately: it is ONE option
# key (§14 has one), it survives config.yaml's flat options block, and
# parse_static_profile() accepts an already-parsed dict too, so a future
@@ -1008,6 +1097,15 @@ schema:
# running short of one unit's share must be reported well inside one.
delivery_hold_s: int(30,900)
# SAFETY-10. ⚠️ A THREE-VALUED LIST AND NOT A `bool`, for the reason written
# against the default in the options block above: a boolean has one default
# and the Supervisor gives it to every install, new and existing alike.
# `app/shadow.MODES` is this tuple and `validate.py` rule 11 refuses anything
# else BY NAME, because `shadow.mode()` resolves an unreadable value to
# SHADOW - safe, but a silent reinterpretation that commands nothing for as
# long as nobody notices.
shadow_mode: list(auto|on|off)
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
File diff suppressed because it is too large Load Diff
+444 -3
View File
@@ -74,6 +74,7 @@ Read MUTATION.md before you report a survivor.
import asyncio
import io
import math
import re
import sys
from datetime import datetime, timedelta, timezone
@@ -87,6 +88,7 @@ from app.arbiter import P_LOOP, Claim, UnitBounds, allocate, resolve
from app.control import Tuning, compute
from app.inverter import Inverter
from app.main import Controller
from app import shadow
fails = []
T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
@@ -670,8 +672,15 @@ def arm(c, value):
async def _direct():
hass = _Hass(0.0)
# ⚠️ SAFETY-10: `shadow_mode: "off"` EXPLICITLY, BECAUSE THIS FIXTURE
# NEVER PRESSES START. The shipped default is `auto`, which is SHADOW
# MODE while control is stopped - so without this line the probe below
# would be graded against a controller that is correctly refusing to
# command anything, and the clamp it is here to exercise would never be
# reached. The shipped write path is unaffected: a stopped controller
# only ever writes 0 W in production, which shadow mode also writes.
c = Controller({"setpoint_entity": "number.sp", "max_w": 2000,
"meter_source": "off"},
"meter_source": "off", "shadow_mode": "off"},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
# The shape the heartbeat, shutdown() and handle_action() all use - each one
@@ -1121,10 +1130,16 @@ A_UNIT, B_HYPO = "inverter-a", "inverter-b"
def fresh(**over):
"""A Controller with a stub HA, no cycle run, and therefore NO record."""
"""A Controller with a stub HA, no cycle run, and therefore NO record.
⚠️ SAFETY-10: `shadow_mode: "off"` for `_direct()`'s reason - this fixture
never presses Start, and the shipped default is shadow mode while control
is stopped. Part 7 grades who is allowed to reach the WIRE; a controller
that is correctly commanding nothing cannot answer that question.
"""
hass = _Hass(0.0)
c = Controller(dict({"setpoint_entity": "number.sp", "max_w": 2000,
"meter_source": "off"}, **over),
"meter_source": "off", "shadow_mode": "off"}, **over),
hass, _Store(), _Pub(), stop=asyncio.Event())
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
return hass, c
@@ -1414,6 +1429,432 @@ check("...and the numbers those cycles put on the wire are the arbiter's own "
hass_r.writes and all(abs(w) <= LIVE_MAX_W for w in hass_r.writes))
# --------------------------------------------------------------------------- #
# 8. SAFETY-10 - SHADOW MODE: THE DECISION PATH RUNS AND NOTHING REACHES THE
# INVERTER
# --------------------------------------------------------------------------- #
# This part belongs in THIS file and not in a new one: shadow mode is a
# substitution inside `_write_unit()`, one line below SAFETY-03's clamp and two
# below SAFETY-28's gate, and the property it claims - "nothing reaches the
# inverter" - is the same property parts 5 and 7 already grade from the other
# side. The fake `set_number()` in this file IS the wire.
#
# ⚠️ EVERY CHECK BELOW IS GRADED ON THE WRITES OR ON THE RENDERED SURFACE, NOT
# ON `Controller.shadow`. A check that asserted the flag would pass on a build
# in which the flag is set and the substitution is deleted, which is precisely
# the build this section exists to refuse.
#
# ⚠️ AND EVERY ONE OF THEM RUNS THE SAME STIMULUS IN BOTH MODES. MUTATION.md's
# rule: a per-thing check that has never seen the things DISAGREE is a total.
# `_s10_live` and `_s10_shadow` are the same 6000 W import through the same
# `Controller.cycle()`; the only difference is one option.
print("\n8. SAFETY-10 - shadow mode\n")
def _s10(shadow_mode, *, auto=True, cycles=25, grid=6000.0, dev=None, **over):
"""The same wired run as part 3, with `shadow_mode` and the start button.
⚠️ `auto` IS A PARAMETER BECAUSE `shadow_mode: auto` READS IT. The three
values and the two button positions are six states and they are not all
the same state; the pure-function table over all six is in
`test_validate.py`, and 8f says why it is not here.
"""
async def go():
hass = _Hass(grid, track=True)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": shadow_mode},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = auto
# `dev` narrows what the DEVICE says it will accept, which is the
# clamp's second stage - the only way to make the clamp engage on a
# target the loop has already bounded at `max_w`.
c.dev_min, c.dev_max = ((-LIVE_MAX_W, LIVE_MAX_W) if dev is None
else (-dev, dev))
# ⚠️ CAPTURED PER CYCLE, BECAUSE THE HEARTBEAT OVERWRITES IT.
# `clamp_last` is assigned by every write, and the heartbeat below
# writes `inv.target` - 0 W in shadow mode - so the clamp's verdict on
# the DECISION is gone by the end of the run. Looking for it afterwards
# would grade the refresh instead of the cycle.
c.clamp_seen = []
for n in range(cycles):
await c.read_inputs()
await c.cycle()
if c.clamp_last is not None:
c.clamp_seen.append(c.clamp_last)
# ⚠️ THE HEARTBEAT IS DRIVEN HERE AND NOT LEFT OUT, BECAUSE IT IS
# THE HALF THAT PROVES SHADOW MODE IS NOT SILENCE. `_write_unit()`
# skips an unchanged value unless `force` is set, so a cycles-only
# fixture sees exactly ONE 0 W write and could not tell "holds the
# inverter at 0 W" from "wrote 0 W once and went quiet" - and the
# second of those leaves a real install's ESP32 in a tripped
# stale-input watchdog for the whole run. `heartbeat_s=0` makes
# every tick due, which is the shipped path with its clock wound
# forward rather than a second implementation of it.
await c.send_heartbeats(float(n), 0.0)
return hass, c
return asyncio.run(go())
_live_h, _live_c = _s10("off")
_shad_h, _shad_c = _s10("on")
# --- 8a. THE PRECONDITION, WHICH IS THE HALF THAT MAKES THE REST MEAN ANYTHING
# MUTATION.md habit 4: a check whose DATA cannot reach the failure it names is
# decoration. "No non-zero value was written" is trivially true of a stimulus
# that would not have produced one, so the same stimulus is first shown to
# produce a large one with the option off.
check("PRECONDITION: with shadow_mode off this stimulus really does drive the "
f"wire - {len(_live_h.writes)} writes, "
f"{len([w for w in _live_h.writes if w != 0.0])} of them non-zero, "
f"largest {max((abs(w) for w in _live_h.writes), default=0):.0f} W",
len([w for w in _live_h.writes if w != 0.0]) >= 10
and max((abs(w) for w in _live_h.writes), default=0) > 1000.0
and 0.0 not in _live_h.writes)
# --- 8b. NOTHING THE DECISION PATH PRODUCED REACHES THE INVERTER -------------
check("AC 1: in shadow mode EVERY value that reached set_number() is 0 W. "
f"Distinct values written: {sorted(set(_shad_h.writes))}",
_shad_h.writes and set(_shad_h.writes) == {0.0})
# ⚠️ AND IT IS NOT SILENCE. The inverter holds its last command forever and the
# ESP32's stale-input watchdog needs a refresh; a shadow mode that stopped
# writing would leave a standing command up under a page saying nothing is
# commanded. A check that only asserted "no non-zero write" would pass on that
# build too - this is the half that separates them.
check("...and it WROTE that 0 W rather than going silent, which is what keeps "
f"the inverter from holding its last command ({len(_shad_h.writes)} "
"writes)",
len(_shad_h.writes) >= 2)
# --- 8c. THE DECISION PATH ACTUALLY RAN ------------------------------------
# "Nothing reached the inverter" is also true of a controller that decided
# nothing. Both halves, or the feature is an off switch with a long name.
check("the full decision path still ran: the loop reached a target and the "
f"arbiter resolved one ({_shad_c.loop_w!r} W, "
f"would have commanded {_shad_c.shadow_would_w!r} W)",
_shad_c.loop_w is not None and abs(_shad_c.loop_w) > 500.0
and _shad_c.shadow_would_w is not None
and abs(_shad_c.shadow_would_w) > 500.0)
check("...and the decision it recorded is a number a live controller would "
"have written - it is inside the rail and it is not the 0 W that went "
"on the wire",
_shad_c.shadow_would_w != 0.0
and abs(_shad_c.shadow_would_w) <= LIVE_MAX_W)
# --- 8d. THE CARRIED COMMAND TRACKS THE WIRE, WHICH IS WHAT MAKES THE EXIT
# SAFE
check("the carried site command is what is STANDING ON THE WIRE, not the "
f"decision ({_shad_c.target!r} W standing, "
f"{_shad_c.shadow_would_w!r} W decided)",
_shad_c.target == 0.0 and _shad_c.shadow_would_w != 0.0)
check("...and every unit's share is too, so SAFETY-42's delivery watchdog is "
"not handed a share nothing was told to produce",
all(i.target == 0.0 for i in _shad_c.inverters))
def _s10_exit(shadow_first):
"""Run twelve cycles, force shadow mode off, take ONE more cycle.
Answers with `inv.last_write` either side of that boundary - WHAT STANDS ON
THE WIRE, updated only on an accepted write, which is the number the
inverter is actually latched on. Reading `hass.writes` instead would be
blind to the converged case, where the controller correctly re-sends
nothing at all.
⚠️ THE COMPARISON IS AGAINST THE SAME RUN THAT NEVER ENTERED SHADOW MODE,
not against a constant. `slew_w` bounds a step from the standing command,
so "did it resume from the wire or from a fiction" is a question about a
DIFFERENCE, and a hard-coded expectation would be the number the code
produces compared with itself.
"""
async def go():
hass = _Hass(6000.0, track=True)
c = Controller(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": "on" if shadow_first else "off"},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
for _ in range(12):
await c.read_inputs()
await c.cycle()
before = c.inverters[0].last_write
c.o["shadow_mode"] = "off"
await c.read_inputs()
await c.cycle()
# ⚠️ NOT `float()`. `last_write` is None until something is ACCEPTED,
# and a build in which shadow mode writes nothing at all would turn
# this fixture into a TypeError - which MUTATION.md's FCST-06 section
# is about: a traceback grades none of the checks after it AND reads
# like a survivor. It is handed back as None and named below instead.
return before, c.inverters[0].last_write, c
return asyncio.run(go())
_exit_before, _exit_after, _exit_c = _s10_exit(True)
_never_before, _never_after, _never_c = _s10_exit(False)
print(f" shadow run: standing {_exit_before!r} W -> {_exit_after!r} W on "
"the first live cycle")
print(f" never in shadow: standing {_never_before!r} W -> "
f"{_never_after!r} W on the same cycle")
check("SOMETHING REACHED THE WIRE IN SHADOW MODE. `last_write` is set only on "
"an ACCEPTED write, so None here is the build that stops writing "
"altogether and leaves the inverter latched on its last command - the "
"failure shadow mode must not be a new way to reach",
_exit_before is not None and _exit_after is not None
and _never_before is not None and _never_after is not None)
_exit_before = -1.0 if _exit_before is None else float(_exit_before)
_exit_after = -1.0 if _exit_after is None else float(_exit_after)
_never_before = 0.0 if _never_before is None else float(_never_before)
_never_after = 0.0 if _never_after is None else float(_never_after)
check("PRECONDITION: the two arrangements DISAGREE - the run that was never in "
"shadow mode is standing somewhere else entirely at that boundary, so "
"the comparison below is not a number against itself",
_never_before != _exit_before and abs(_never_before) > 1000.0)
check("PRECONDITION: and the shadow run really was standing at 0 W, so the "
"step below is measured from the wire and not from a coincidence",
_exit_before == 0.0)
check("AC 2, leaving: the first live command after shadow mode is one slew "
"step from the 0 W that was actually standing on the wire - NOT one step "
"from the decision shadow mode reached, which never existed at the "
f"battery. |{_exit_after:.0f}| <= slew 1000 W",
abs(_exit_after) <= 1000.0)
check("...and the run that was never in shadow mode is NOT inside that bound "
"at the same cycle, which is what says the bound is a real constraint "
"here and not a property of the stimulus",
abs(_never_after) > 1000.0)
def _s10_enter():
"""Live first, then shadow mode, on a battery standing somewhere else."""
async def go():
hass = _Hass(6000.0, track=True)
c = Controller(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"shadow_mode": "off"}, hass, _Store(), _Pub(),
stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
for _ in range(12):
await c.read_inputs()
await c.cycle()
standing = hass.writes[-1]
c.o["shadow_mode"] = "on"
await c.read_inputs()
await c.cycle()
# ⚠️ `inverters[0].last_write`, NOT `hass.writes[-1]`. The question is
# WHAT THE INVERTER IS LATCHED ON after the first shadow cycle, and a
# build that writes NOTHING leaves it latched on the live value while
# `hass.writes[-1]` still reads that same number - the two look
# identical from the write list and are opposite at the battery.
return standing, float(c.inverters[0].last_write), c
return asyncio.run(go())
_stood, _first_shadow, _enter_c = _s10_enter()
print(f" standing when shadow mode came on: {_stood:.0f} W")
check("PRECONDITION: the battery was standing at a real, non-zero command when "
"shadow mode was switched on - otherwise 'it reached 0 W' says nothing",
abs(_stood) > 500.0)
check("AC 2, entering: after ONE shadow cycle the inverter is latched on 0 W, "
"not on what it was doing a cycle earlier. The inverter holds its last "
"command FOREVER, so a shadow mode that merely stopped writing would "
"leave that command standing under a page saying nothing is commanded "
f"({_stood:+.0f} W -> {_first_shadow:+.0f} W)",
_first_shadow == 0.0)
# --- 8e. THE OTHER GUARDS ON THE WRITE PATH STILL OBSERVE --------------------
# The substitution is the LAST thing before the wire, below SAFETY-28's gate and
# below SAFETY-03's clamp, so both still grade the number the decision path
# produced. A shadow run that could not say "it would have hit the rating here"
# is answering a different question from the one it is for.
_cl_h, _cl_c = _s10("on", cycles=12, grid=9000.0, dev=400.0)
_cl_req = [r.requested_w for r in _cl_c.clamp_seen]
print(f" decision {_cl_c.shadow_would_w!r} W; what the clamp was asked "
f"about at each cycle boundary: {sorted(set(_cl_req))}")
check("PRECONDITION: the decision is a real, non-zero number and the wire "
"carried only 0 W on this run, so the two are distinguishable",
_cl_c.shadow_would_w is not None and _cl_c.shadow_would_w != 0.0
and set(_cl_h.writes) == {0.0})
check("SAFETY-03 still OBSERVES in shadow mode: the clamp was asked about the "
"number THE DECISION PATH PRODUCED, not about the 0 W that went on the "
"wire. That is what puts the substitution BELOW the clamp, and it is "
"what lets a shadow run report that the rating would have been hit",
_cl_req and all(r == _cl_c.shadow_would_w for r in _cl_req))
check("...and the clamp's own alarm still ran on it - the winding-loop "
"engagement reached the action log on a run where nothing was commanded",
any("clamp" in e.lower() or "CLAMP" in e for e in _cl_c.events))
check("SAFETY-28's gate still OBSERVES too - it refused nothing on this run, "
"which is the same answer it gives live, so shadow mode has not been "
"implemented by making every write unauthorised",
not [e for e in _cl_c.events if "WRITE REFUSED" in e])
# ⚠️ AND SAFETY-18 IS GRADED ON WHAT IT WAS HANDED, WHICH IS AN ORDERING
# CLAIM. `check_meter_frozen()` takes `self.target` - the arbiter's RESOLVED
# INTENT - and `_shadow_settle()` re-seeds that to 0 W at the very end of the
# cycle, AFTER it. Re-seed one line earlier and the freeze detector grades a
# 0 W that no decision produced: its conjunction needs a command at the rail,
# so it goes permanently blind and the whole suite stays green. The detector's
# own `evaluate` is wrapped here because the arguments are the only place that
# ordering is observable.
_fz_seen = []
_fz_real = _shad_c.freeze_wd.evaluate
def _fz_spy(unchanged, target, *a, **k):
_fz_seen.append(target)
return _fz_real(unchanged, target, *a, **k)
_shad_c.freeze_wd.evaluate = _fz_spy
async def _one_more():
await _shad_c.read_inputs()
await _shad_c.cycle()
asyncio.run(_one_more())
_shad_c.freeze_wd.evaluate = _fz_real
print(f" SAFETY-18 was handed: {_fz_seen}")
check("PRECONDITION: the freeze detector was consulted at all on that cycle",
len(_fz_seen) == 1)
check("SAFETY-18 still OBSERVES the DECISION: it is handed the arbiter's "
"resolved intent, not the 0 W the wire gate substituted. Re-seeding one "
"line earlier would hand it a command no decision produced and its "
"conjunction - a command at the rail against a meter that has stopped "
"moving - could never be satisfied again",
_fz_seen and _fz_seen[0] == _shad_c.shadow_would_w
and _fz_seen[0] != shadow.WIRE_W)
# --- 8f. THE MODE TABLE LIVES IN test_validate.py ---------------------------
# `shadow.engaged()` is a pure function of the option and the start button, and
# the six states it answers for are graded beside the rule that validates the
# option - not here, where a defeat of the DEFAULT stops part 3's wired run
# reaching the wire at all and takes this whole file down before part 8 runs.
# ⚠️ THAT IS MEASURED, NOT ANTICIPATED: the mutation that makes `auto` resolve
# to shadow unconditionally aborts part 3 with an IndexError, so its red count
# here is a LOWER BOUND and none of part 8 is graded (MUTATION.md, FCST-06).
# --- 8g. THE OPERATOR STOP, WHICH IS THE ONE FAILSAFE THIS CARD CHANGES ------
_stop_live_h, _stop_live_c = _s10("off", auto=False, cycles=6)
_stop_shad_h, _stop_shad_c = _s10("auto", auto=False, cycles=6)
print(f" stopped, shadow off: {_stop_live_c.reason!r}")
print(f" stopped, shadow on : {_stop_shad_c.reason!r}")
check("PRECONDITION: with shadow mode off, a stopped controller files the "
"P_SAFETY stop claim and the arbiter resolves to THAT source, at 0 W",
_stop_live_c.reason.startswith("safety")
and _stop_live_c.target == 0.0)
check("in shadow mode that claim is NOT filed, so the arbiter resolves to the "
"REAL winner and the recorded decision is the real one. A P_SAFETY claim "
"out-ranks everything - left in, every shadow run would answer the wrong "
f"question ({_stop_shad_c.reason!r})",
_stop_shad_c.reason.startswith("loop")
and _stop_shad_c.shadow_would_w is not None
and abs(_stop_shad_c.shadow_would_w) > 0.0)
check("...and the actuator ends at exactly the same place either way, because "
"the substitution that replaced the claim is STRICTLY STRONGER: a claim "
"can be out-ranked and a wire gate cannot",
set(_stop_shad_h.writes) == {0.0} == set(_stop_live_h.writes))
# --- 8h. THE RENDERED SURFACE (TARIFF-11's pattern) -------------------------
# Graded on `status_payload()`, which IS the status page: web.py's `PAGE` is a
# static shell whose banner and rows are filled from this JSON. Named slots, not
# substring tests, so a transposition moves a slot.
_ROW = "Shadow mode"
_shad_pay = _shad_c.status_payload()
_live_pay = _live_c.status_payload()
_shad_rows = dict(_shad_pay["rows"])
_live_rows = dict(_live_pay["rows"])
print(f" banner: {_shad_pay['banner']!r}")
print(f" row : {_shad_rows.get(_ROW)!r}")
check("the status page has a Shadow mode row in BOTH states, so its ABSENCE is "
"never how an operator learns which one they are in",
_ROW in _shad_rows and _ROW in _live_rows)
check("AC 3: the banner states plainly that the battery is not being "
"controlled",
"SHADOW MODE" in _shad_pay["banner"]
and "NOT being controlled" in _shad_pay["banner"])
check("...and it is not green. A page that reads `ok` while nothing is being "
"commanded is the 'looks healthy but is inert' failure itself",
_shad_pay["level"] == "warn")
check("...and it is FIRST in the cascade: the same controller has other "
"conditions to report and none of them displaces this sentence",
_live_pay["banner"] != _shad_pay["banner"]
and "SHADOW MODE" not in _live_pay["banner"])
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
r"([-\d]+) W", _shad_rows[_ROW])
check("the row answers 'what would it have done' with a NUMBER in its own "
"named slot, and says where the inverter actually is in a second one - "
"two slots in one sentence, so a transposition moves them",
_slot is not None)
if _slot is not None:
_would = float(_slot.group(1).replace(" ", ""))
check("...and that number is the decision the path reached, not the 0 W "
f"standing on the wire ({_would:.0f} vs {_shad_c.target:.0f})",
_would == round(float(_shad_c.shadow_would_w))
and _would != _shad_c.target)
check("...and the second slot is the STANDING command, so the two cannot "
"be read as one number",
float(_slot.group(2)) == 0.0)
check("the row answers 'and why' by carrying the arbiter's own sentence, "
"rather than a re-derivation of it",
_shad_c.reason in _shad_rows[_ROW])
check("with shadow mode off the row says so, and says what follows from it - "
"which is the arrangement that makes the one above a claim about this "
"controller and not about the string",
"NOT in shadow mode" in _live_rows[_ROW]
and "DOES reach the inverter" in _live_rows[_ROW]
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW])
# ⚠️ A STANDING LATCH IS NAMED RATHER THAN HIDDEN. The shadow banner sits above
# the three `bad` branches, so it has to carry what they would have said - a
# latch that is up while the controller is inert is up the moment it stops
# being inert.
_shad_c.inverters[0].sign_wd.tripped = True
_latched = _shad_c.status_payload()
print(f" banner with a latch up: {_latched['banner']!r}")
check("a failsafe latch that is standing is NAMED in the shadow banner and "
"turns it red, so nobody turns shadow mode off into a battery that will "
"not move and a page that never mentioned why",
_latched["level"] == "bad"
and "LATCH IS STILL UP" in _latched["banner"]
and _shad_c.inverters[0].name in _latched["banner"])
check("...and it still says the battery is not being controlled, so the latch "
"has not displaced the sentence that may never be displaced",
"NOT being controlled" in _latched["banner"])
_shad_c.inverters[0].sign_wd.tripped = False
# --- 8i. THE RECORD A LATER ANALYSIS READS ----------------------------------
# A shadow run is only worth running if it answers "what would it have done,
# and why" AFTERWARDS, from the 40-row action log, and not only on a page
# somebody happened to be looking at.
_shadow_lines = [e for e in _shad_c.events if "SHADOW" in e]
print(f" action log, shadow lines: {len(_shadow_lines)} of "
f"{len(_shad_c.events)}")
check("the action log records that shadow mode came on, in words",
any("SHADOW MODE ON" in e for e in _shadow_lines))
check("...and what it would have commanded, with the arbiter's reason beside "
"it",
any("would have commanded" in e and "loop" in e for e in _shadow_lines))
check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
"log that has to carry the context of what the run found",
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+10 -1
View File
@@ -224,9 +224,18 @@ A_RATING = 5000.0
# had become so; validate.py rule 10 refused it the day it existed, which is
# what a rule about a relationship is for. 0 means "follow the rail"
# (`control.SLEW_RAIL_FRAC * max_w`) and is what config.yaml ships.
# ⚠️ SAFETY-10: `shadow_mode="off"` EXPLICITLY, BECAUSE NOTHING HERE PRESSES
# START. The shipped default is `auto`, which is SHADOW MODE while control is
# stopped - and in shadow mode every write is substituted with 0 W at the one
# choke point these parts grade. Without this line the per-unit clamp, the
# per-unit write budget and the per-unit heartbeat would all be measured on a
# controller that is correctly refusing to command anything, which is a
# different (and vacuous) claim. The shipped write path is unaffected: a
# stopped controller only ever writes 0 W in production anyway.
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
max_w=A_RATING, gain=0.6, slew_w=0, deadband_w=15,
target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15)
target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15,
shadow_mode="off")
def built(opts=None, store=None, hass=None, second=None):
+9 -4
View File
@@ -1067,14 +1067,19 @@ class _V:
_here = _Pt(_NOW)
_other = _Pt(_NOW - timedelta(hours=3))
# SAFETY-45's `headroom_w` is the last positional. It is set to a value that
# CANNOT BIND on either call - larger than every sizing below - so this part
# goes on grading OPT-09's per-pack sizing and not the capacity-tariff cap that
# now sits beside it. test_arbitrage.py section 3b is where it is made to bind.
_CLEAR_W = 1e9
_sell = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
0.0, 0.0, 20.0, 90.0)
0.0, 0.0, 20.0, 90.0, _CLEAR_W)
_sell_ok = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
0.0, 4600.0, 20.0, 90.0)
0.0, 4600.0, 20.0, 90.0, _CLEAR_W)
_buy = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0,
0.0, 0.0, 20.0, 90.0)
0.0, 0.0, 20.0, 90.0, _CLEAR_W)
_buy_ok = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0, 5000.0,
0.0, 20.0, 90.0)
0.0, 20.0, 90.0, _CLEAR_W)
print(f" sized at 0 W: sell -> {_sell.action}, buy -> {_buy.action}; "
f"sized at a rating: sell -> {_sell_ok.action}, buy -> {_buy_ok.action}")
check("⚠️ A TRADE NOTHING CAN TAKE IS NOT A TRADE. A 0 W sizing IDLES on both "
+287 -3
View File
@@ -138,8 +138,8 @@ from app.peakguard import (
BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP,
CEILING_MONTH_PEAK, FLOOR_AT_SOC_FLOOR, FLOOR_SOC_UNREADABLE,
PEAK_OPTION_DEFAULTS, Pack, PeakDecision, binding_soc_floor, blindness,
decide, discharge_permitted, excess_w, pack_floor_pct, pack_permissions,
quarter_ceiling_w, uncovered_s,
charge_headroom_w, decide, discharge_permitted, excess_w, pack_floor_pct,
pack_permissions, quarter_ceiling_w, uncovered_s,
)
from app.validate import validate_startup_config
from app.tariff import (
@@ -379,8 +379,17 @@ check("at the engagement boundary the excess is zero, so the floor is exactly "
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0
# ⚠️ `floor_w` IS FORMATTED DEFENSIVELY, AND MUTATION.md's OWN LEDGER IS WHY.
# The mutation this check exists for - reverting the `floor > 0` rung to
# `excess > 0` - makes `floor_w` None HERE, so a bare `:.0f` raises TypeError
# and ABORTS THE WHOLE FILE on the line after the check that just went red.
# Parts 2-13 are then ungraded and the recorded coverage of that mutation is
# smaller than the truth: exactly what MAINT-05's `delinquent(...)['b']`
# subscript did to test_delivery.py, and habit 5 ("a check whose failure mode
# is a hang - or a crash - needs a deadline, or a name") in the other costume.
print(f" at the boundary: excess {e_edge:.3f} W (an `excess > 0` test would "
f"RELEASE: {would_release_on_excess}), floor {released.floor_w:.0f} W "
f"RELEASE: {would_release_on_excess}), floor "
f"{'(none)' if released.floor_w is None else f'{released.floor_w:.0f} W'} "
f"(the shipped `floor > 0` test holds: {released.engaged})")
check("...and the claim is held there, not released. `excess` falls to zero as "
"soon as the battery is already taking the watts, so thresholding IT is "
@@ -5947,6 +5956,281 @@ check("...and the row is green and un-warned, which is what makes the "
"operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"]
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
# ==============================================================================
print()
print("13. SAFETY-45 - charge_headroom_w(): the number decide() used to throw "
"away")
# ---------------------------------------------------------------------------
# `decide()` computes `wanted = batt_w + excess` - the DISCHARGE that lands the
# quarter exactly on the ceiling - and returns `engaged=False, floor_w=None`
# the moment that number is not positive. A negative `wanted` is not "nothing
# to say": it is a permitted CHARGE of `-wanted` W, and discarding it is what
# let an arbitrage charge sit at max_w against a quarter it was blowing.
#
# ⚠️ NOTHING IN decide() CHANGES IN THIS CARD, AND THAT IS THE POINT. The
# `wanted <= 0` branch is 2026-08-20's anti-bang-bang rung and part 6's replay
# is what proves it still holds; this part grades a PURE READ of the number it
# already carried. 13g asserts the branch is still there rather than trusting
# the diff.
# ---------------------------------------------------------------------------
def _proj_at(net_w, elapsed):
return PeakTracker.project(averager_at(net_w, elapsed),
B + timedelta(seconds=elapsed))
def _decide_on(projection, *, batt_w, soc=SOC_OK, cap_w=CAP_W,
achievable_w=LIVE_MAX_W, reading=None, frozen=False):
return decide(projection, batt_w=batt_w, cap_w=cap_w, reading=reading,
achievable_w=achievable_w, soc=soc, soc_floor=SOC_FLOOR,
max_hold_s=MAX_HOLD_S, frozen=frozen)
def _decide_at(net_w, elapsed, **kw):
return _decide_on(_proj_at(net_w, elapsed), **kw)
def _avg_held(base_w, elapsed, held_w, *, start=B, step=5.0):
"""`elapsed` seconds BANKED at `base_w`, then `held_w` standing at the edge.
⚠️ THE HISTORY AND THE HELD VALUE ARE SEPARATE FIXTURE AXES, and 13c is why
this helper exists rather than `averager_at(load - batt, ...)`. The averager
credits a sample from its own timestamp to the NEXT one, so the last sample
contributes ZERO accumulated seconds and only becomes `held`. That is the
real shape of a battery whose command changed a moment ago - the quarter's
banked seconds do not retroactively change - and a fixture that moved the
whole history with it would be grading a different quarter each time.
"""
a = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
t = 0.0
while t < elapsed:
a.add(sample(start + timedelta(seconds=t), base_w))
t += step
a.add(sample(start + timedelta(seconds=elapsed), held_w))
return a
# --- 13a. the number, and it is DERIVED rather than read back ---------------
# The house is flat at 2600 W under a 3500 W cap, the battery is idle, and 300 s
# of the block are banked. 600 s remain, so the block can still absorb
# 900 W x 900/600 = 1350 W of extra offtake and finish exactly on the ceiling.
_H45_GAP = 900.0
_H45_LOAD = CAP_W - _H45_GAP
_H45_EL, _H45_REM = 300.0, QUARTER_S - 300.0
_h45_idle = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0)
_h45_head = charge_headroom_w(_h45_idle)
# The CONSEQUENCE, computed from the fixture's own literals and not from the
# module's arithmetic: hold that charge for the rest of the block and the
# BILLED AVERAGE is the ceiling. This is the meaning of the number, which is a
# different statement from the expression that produces it (habit 2).
_H45_FINAL = (_H45_LOAD * _H45_EL
+ (_H45_LOAD + _h45_head) * _H45_REM) / QUARTER_S
print(f" house {_H45_LOAD:.0f} W under a {CAP_W:.0f} W cap, battery 0 W, "
f"{_H45_EL:.0f} s banked: engaged={_h45_idle.engaged}, "
f"wanted={_h45_idle.wanted_w:+.0f} W, headroom={_h45_head:.0f} W")
print(f" holding that charge for the remaining {_H45_REM:.0f} s bills the "
f"block at {_H45_FINAL:.1f} W against a {CAP_W:.0f} W ceiling")
check("the guard files NOTHING here - this is the shipped `wanted <= 0` branch, "
"untouched, and the state SAFETY-45 is about",
not _h45_idle.engaged and _h45_idle.floor_w is None
and _h45_idle.wanted_w is not None and _h45_idle.wanted_w < 0.0)
check(f"...and yet the quarter can afford {_h45_head:.0f} W of charging, which "
f"is the number that was being discarded. Graded by its CONSEQUENCE - "
f"the block lands on the ceiling to the watt - rather than by "
f"re-deriving -wanted_w with the module's own formula",
near(_H45_FINAL, CAP_W, 0.5))
check(f"...and it is none of the other numbers this fixture has: not the cap "
f"({CAP_W:.0f}), not max_w ({LIVE_MAX_W:.0f}), not the house "
f"({_H45_LOAD:.0f}) and not the raw gap under the ceiling "
f"({_H45_GAP:.0f}) - the 900/remaining gain is 1.5 here and a check that "
f"could not tell those apart would be decoration",
not any(near(_h45_head, v, 1.0)
for v in (CAP_W, LIVE_MAX_W, _H45_LOAD, _H45_GAP)))
# --- 13a2. AND THE IDENTITY HOLDS ACROSS THE WHOLE BLOCK --------------------
# ⚠️ THE AMPLIFICATION IS REAL AND IT IS REPORTED WITH ITS SIZE (habit 13).
# 900/remaining reaches 180 in the last five seconds, so the headroom there is
# enormous - and it is bounded in EFFECT, not in magnitude: at every elapsed,
# holding it bills the block at exactly the ceiling.
H45_ID = []
for _el in (0.0, 60.0, 300.0, 600.0, 870.0, 895.0):
_rem = QUARTER_S - _el
_hd = charge_headroom_w(_decide_at(_H45_LOAD, _el, batt_w=0.0))
H45_ID.append((_el, _hd,
(_H45_LOAD * _el + (_H45_LOAD + _hd) * _rem) / QUARTER_S))
print(" headroom across the block (elapsed, headroom W, billed W): "
+ ", ".join(f"({a:.0f}, {b:.0f}, {c:.1f})" for a, b, c in H45_ID))
check("at EVERY point in the block, holding the headroom for the rest of it "
"bills the block at exactly the ceiling - so the 162 kW answer five "
"seconds from the boundary and the 900 W answer at the top of it are the "
"same statement about the bill",
all(near(c, CAP_W, 0.5) for _, _, c in H45_ID))
check("...and the fixture SPANS the amplification rather than sitting in one "
f"corner of it: the headroom moves by a factor of "
f"{max(b for _, b, _ in H45_ID) / min(b for _, b, _ in H45_ID):.0f} "
f"across those six points",
max(b for _, b, _ in H45_ID) > 100.0 * min(b for _, b, _ in H45_ID))
# --- 13b. HOLDING implies ZERO OR UNKNOWN, swept - the family, not one case -
# ⚠️ THE PROPERTY THAT MAKES THIS SAFE TO HAND A STRATEGY. Every state that
# files a claim has `wanted > 0`, `wanted is None` or `blind`, so this function
# can never hand a caller permission the guard's own `lo` is at that instant
# refusing. Swept over the same grid part 5's no-negative-floor proof uses,
# widened with the two ways a cycle can fail to measure at all.
H45_N = H45_BAD = H45_POS = H45_NONE = H45_ZERO = 0
for _load in (0.0, CAP_W - 500.0, CAP_W, CAP_W + 300.0, CAP_W + 3000.0,
LIVE_MAX_W * 3):
for _el in (30.0, 450.0, 870.0):
for _bw in (-LIVE_MAX_W, -2000.0, 0.0, 400.0, 4000.0, None):
for _sc in (None, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0):
for _fz in (False, True):
_d45 = _decide_at(_load, _el, batt_w=_bw, soc=_sc,
frozen=_fz)
_hr = charge_headroom_w(_d45)
H45_N += 1
if _hr is None:
H45_NONE += 1
elif _hr > 0.0:
H45_POS += 1
else:
H45_ZERO += 1
if _d45.engaged and _hr not in (None, 0.0):
H45_BAD += 1
print(f" swept {H45_N} (load x elapsed x battery x SoC x frozen) "
f"combinations: {H45_BAD} ENGAGED decisions answering a positive "
f"headroom; {H45_POS} permit charging, {H45_ZERO} answer 0 W, "
f"{H45_NONE} unmeasured")
check(f"NOT ONE of the {H45_N} combinations answers a positive headroom while "
f"the guard is ENGAGED. A decision whose `lo` is at that instant "
f"forbidding charging can never be read as permission to charge",
H45_BAD == 0 and H45_N > 800)
check("...and the sweep is vacuous in no direction: it reaches decisions that "
f"permit charging ({H45_POS}), decisions that permit none ({H45_ZERO}) "
f"and decisions nothing measured ({H45_NONE}), so all three answers are "
f"exercised", H45_POS > 0 and H45_ZERO > 0 and H45_NONE > 0)
# --- 13c. IT IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE TO IT
# CANNOT OSCILLATE -------------------------------------------------
# Raise the charge by dW and the METER rises by dW, so `excess` rises by exactly
# dW while `batt_w` falls by dW: `wanted` does not move. The headroom is a
# FEED-FORWARD number - a function of the house, the ceiling and the seconds
# already banked - so consuming it adds no feedback path around the plant.
# Without this property the whole fix would be one loop replacing another.
H45_INV, H45_EX = [], []
for _bw in (-LIVE_MAX_W, -2000.0, -500.0, 0.0, 500.0, 2000.0):
# ONE banked history, and the battery's change visible only in the HELD
# value - which is what a command given a moment ago actually looks like.
_d = _decide_on(PeakTracker.project(
_avg_held(_H45_LOAD, _H45_EL, _H45_LOAD - _bw),
B + timedelta(seconds=_H45_EL)), batt_w=_bw)
H45_INV.append(charge_headroom_w(_d))
H45_EX.append(round(_d.excess_w, 1))
print(f" same house, same banked quarter, battery {-LIVE_MAX_W:+.0f}.."
f"{2000.0:+.0f} W: headroom {[round(v, 3) for v in H45_INV]}")
print(f" ...while the EXCESS those runs see is {H45_EX}")
check("the headroom does NOT move when the battery moves across the whole "
"range of what it can do - it is a property of the HOUSE and the "
"CEILING, not of what the battery happens to be doing, which is what "
"makes a trade sized from it feed-forward rather than a second loop "
"around the plant",
max(H45_INV) - min(H45_INV) < 1.0)
check(f"...and the fixture SEPARATES it from the two quantities it is built "
f"out of: the battery moved {LIVE_MAX_W + 2000.0:.0f} W and the excess "
f"with it, over {len(set(H45_EX))} distinct values, so an implementation "
f"answering `-batt_w` or `abs(excess)` would give six different answers "
f"here (habit 4)",
len(set(H45_EX)) == len(H45_EX)
and len(set(round(v, 3) for v in H45_INV)) == 1)
# --- 13d. AND A BOUNDARY HANDS IT BACK AT THE AFFORDABLE RATE, NOT AT max_w --
# ⚠️ SAFETY-45's SECOND HALF, AND THE CLAIM IS NOT "IT DOES NOT CHANGE".
# `project()` resets to the held level at `elapsed = 0`, and the headroom
# collapses with it - from 162 kW to 900 W here. What the card is about is WHAT
# IT COLLAPSES TO: the house and the cap, and nothing else. The behaviour being
# removed re-armed at `max_w` regardless of either.
_H45_END = charge_headroom_w(_decide_at(_H45_LOAD, 895.0, batt_w=0.0))
_H45_TOP = charge_headroom_w(_decide_at(_H45_LOAD, 0.0, batt_w=0.0))
print(f" across a boundary, same house: {_H45_END:.0f} W with 5 s of the "
f"old quarter left -> {_H45_TOP:.0f} W with the new one 0 s old, against "
f"a max_w of {LIVE_MAX_W:.0f} W")
check(f"at the top of the new quarter the headroom is exactly the ceiling less "
f"the house ({CAP_W:.0f} - {_H45_LOAD:.0f} = {_H45_GAP:.0f} W). A trade "
f"refused at 14:59 resumes at 15:00 at the rate the new quarter can "
f"afford - the projection resets, this does not reset TO max_w",
near(_H45_TOP, _H45_GAP, 1.0) and _H45_TOP < LIVE_MAX_W)
check("...and the check is not passing because nothing moved: the headroom on "
"the other side of that boundary is two orders of magnitude larger, and "
"the PROJECTION really did reset",
_H45_END > 100.0 * _H45_TOP and _proj_at(_H45_LOAD, 0.0).elapsed_s < 1.0)
# --- 13e. the unmeasured answers, and None is NOT unlimited -----------------
_h45_nobatt = _decide_at(_H45_LOAD, _H45_EL, batt_w=None)
_h45_blind = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0, frozen=True)
print(f" no battery reading: {charge_headroom_w(_h45_nobatt)}; "
f"BLIND: {charge_headroom_w(_h45_blind)}; no decision at all: "
f"{charge_headroom_w(None)}")
check("a guard that never ran answers None, not a number - `meter_source: off` "
"reaches this with no decision at all and a caller that read it as a "
"large headroom would be guard_state()'s None-is-not-False collapse one "
"layer down", charge_headroom_w(None) is None)
check("...and a cycle with NO BATTERY MEASUREMENT answers None too: there is "
"nothing to size a floor from, so there is nothing to size a trade from "
"either", charge_headroom_w(_h45_nobatt) is None
and _h45_nobatt.wanted_w is None)
check("...and BLIND answers 0.0 - a NUMBER, and the number is no charging. "
"The claim it files already says exactly that at `floor 0 W`, and this "
"agrees with it rather than restating it",
charge_headroom_w(_h45_blind) == 0.0 and _h45_blind.blind
and _h45_blind.floor_w == 0.0)
check("...and the three are TOLD APART: blind is 0.0 and the two unmeasured "
"states are None, so a caller can refuse for the reason that is true "
"rather than for whichever fired first",
charge_headroom_w(_h45_blind) is not None
and charge_headroom_w(_h45_nobatt) is None)
# --- 13f. the SoC floor does not become charging permission -----------------
# ⚠️ THE ONE ENGAGED STATE WHERE THE GUARD IS DELIBERATELY NOT DISCHARGING.
# SAFETY-24's floor-limited decision files `lo = 0.0` - "charging stays
# blocked" - on a quarter that IS over the ceiling. If that read as headroom,
# an empty pack in a blown quarter would be told to buy.
_h45_lim = _decide_at(CAP_W + 2000.0, _H45_EL, batt_w=0.0, soc=SOC_FLOOR - 1.0)
print(f" floor-limited over a blown quarter: engaged={_h45_lim.engaged}, "
f"floor_limited={_h45_lim.floor_limited}, wanted="
f"{_h45_lim.wanted_w:+.0f} W, headroom={charge_headroom_w(_h45_lim)}")
check("a FLOOR-LIMITED decision - the guard wanted a discharge and SoC refused "
"to size it - answers 0.0 headroom. The quarter is over the ceiling; the "
"pack being empty is not a reason to fill it from the grid in it",
_h45_lim.floor_limited and _h45_lim.engaged
and charge_headroom_w(_h45_lim) == 0.0)
# --- 13g. decide() ITSELF IS UNCHANGED, PINNED -----------------------------
# ⚠️ MUTATION.md habit 4, in the direction a diff cannot cover. The whole safety
# argument of this card is "the 2026-08-20 rung is still there and part 6 still
# grades it". That is a claim about the SOURCE, so it is pinned on the source -
# and on the BEHAVIOUR, because a shape pin alone would pass a rung that had
# been inverted.
_H45_SRC = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8")
_H45_TREE = ast.parse(_H45_SRC)
_h45_fn = next(n for n in ast.walk(_H45_TREE)
if isinstance(n, ast.FunctionDef) and n.name == "decide")
_H45_RUNG = [n for n in ast.walk(_h45_fn) if isinstance(n, ast.If)
and ast.unparse(n.test) == "wanted <= 0.0"]
check("the `wanted <= 0.0` rung is still in decide(), spelled exactly that "
"way, on the AST rather than as a substring - it is the rung whose "
"removal put 2026-08-20's quarter 49 W over the limit after seventeen "
"engage/release cycles in 900 s", len(_H45_RUNG) == 1)
check("...and it still DISENGAGES rather than merely reporting: the fixture "
"13a is built on reaches it and files no claim, so the pin above is not "
"grading a rung that has been inverted under it",
peakguard.claim(_h45_idle) is None)
_h45_head_fn = next(n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef)
and n.name == "charge_headroom_w")
check("...and charge_headroom_w() is a PURE READ of the decision - one "
"positional parameter, no projection, no clock and no options - so it "
"cannot become a second answer to the question decide() already answers",
[a.arg for a in _h45_head_fn.args.args] == ["decision"]
and not _h45_head_fn.args.kwonlyargs)
# ==============================================================================
print()
shutil.rmtree(TMP, ignore_errors=True)
File diff suppressed because it is too large Load Diff
+23 -9
View File
@@ -1117,34 +1117,48 @@ _T15_KEYS = ["price_formula"]
# config.yaml and nowhere else, which is `static_profile`'s arrangement for
# `static_profile`'s reason.
_T17_KEYS = ["price_taxes_profile"]
# TARIFF-16. `price_export_formula` - the INJECTION leg as `factor x index +
# offset` with its OWN coefficients and its OWN vat_pct, because a Belgian card
# prices injection on a second formula (Eneco 0,100 against 0,102 on
# consumption, VAT-exempt against 6%). Like TARIFF-17's key it is an ADDITION:
# `export_price_eur_kwh` is untouched, every site on the scalar is unaffected,
# and it lives in this dict and in config.yaml and nowhere else.
_T16_KEYS = ["price_export_formula"]
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
f"TARIFF-15's and TARIFF-17's keys - FCST-03 still added none of its own "
f"{_defaults}",
f"TARIFF-15's, TARIFF-17's and TARIFF-16's keys - FCST-03 still added "
f"none of its own {_defaults}",
_defaults == sorted(["price_provider", "price_markup_eur_kwh",
"price_taxes_eur_kwh", "export_price_eur_kwh",
"price_csv_path", "price_feed_url",
"price_horizon_h", "price_timeout_s",
"static_profile"]
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS))
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS
+ _T16_KEYS))
# ...and the later keys are genuinely NEW, not a rename of something that was
# already there, or the line above would pass while the mirror silently lost a
# key.
check("...and OPT-04's four, TARIFF-15's one and TARIFF-17's one are additions "
"rather than replacements: the nine TARIFF-02/03 keys are all still "
"present",
check("...and OPT-04's four, TARIFF-15's one, TARIFF-17's one and TARIFF-16's "
"one are additions rather than replacements: the nine TARIFF-02/03 keys "
"are all still present",
isinstance(_defaults, list)
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)
- set(_T17_KEYS)) == 9)
- set(_T17_KEYS) - set(_T16_KEYS)) == 9)
# ⚠️ AND THE ONE TARIFF-17 IS THE HOUR-VARYING TWIN OF IS STILL THERE, BY NAME.
# The line above counts nine, and a rename of `price_taxes_eur_kwh` to
# `price_taxes_profile` would also count nine. The scalar path being unchanged
# is this card's first acceptance criterion, and a count cannot grade it.
# ⚠️ THE LITERAL 0.11736 IS TARIFF-18's, AND IT IS TYPED HERE ON PURPOSE. The
# claim is "the scalar is still its own key, holding its own real number", and
# reading that number out of the dict under test would compare it against
# itself. TARIFF-18 raised it from 0.06; if it moves again this line goes red
# and whoever moved it comes here and says which card.
check("...and `price_taxes_eur_kwh` is still its own key beside the table, so "
"TARIFF-17 widened the framework rather than renaming the scalar",
"TARIFF-17 widened the framework rather than renaming the scalar - and "
"it still holds TARIFF-18's Flemish figure",
isinstance(_defaults, list)
and "price_taxes_eur_kwh" in _defaults
and "price_taxes_profile" in _defaults
and _price.PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"] == 0.06
and _price.PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"] == 0.11736
and _price.PRICE_OPTION_DEFAULTS["price_taxes_profile"] == "")
check("...and every one of them is still in config.yaml, which stays the "
"source of truth",
+10
View File
@@ -147,6 +147,16 @@ DERIVED = {
# where the ratio came from - a citation alone would leave an operator
# reading "0" on the tab with no idea it is not "off".
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
# TARIFF-18. `price_taxes_eur_kwh` joins the table for `slew_w`'s reason:
# it stopped being a figure somebody picked and became one SUMMED FROM
# NAMED CARD ROWS. Three needles, because three different things about it
# can go wrong on the Configuration tab and only the first is provenance:
# where the number came from, that the operator's own DSO gives a
# different one, and that changing it moves no trade - which is the claim
# an operator is most likely to assume the opposite of when a number
# doubles.
"price_taxes_eur_kwh": ("TARIFF-18", "YOUR OWN DSO",
"MOVES NO TRADING DECISION"),
}
_uncited = []
for key, needles in sorted(DERIVED.items()):
+317 -4
View File
@@ -18,7 +18,10 @@ import asyncio
import sys
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
from app.price import (
PRICE_OPTION_DEFAULTS, SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
from app import shadow
from app.validate import (
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
actuator_writers, refuse, site_rail_w, validate_startup_config,
@@ -1378,6 +1381,8 @@ print("TARIFF-17 - the taxes period table is refused HERE, at startup, and not "
# healthy fixed-tariff day. That is `static_profile`'s argument, one option
# over, and this is the same rule.
_T17_DAG, _T17_NACHT = 0.1327, 0.0739
_T18_SHIPPED_TAXES = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
_T18_SUPERSEDED = tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
_T17_OK = ('{"periods":['
'{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",'
f'"eur_per_kwh":{_T17_DAG}}},'
@@ -1427,11 +1432,66 @@ check("the table AND a non-default price_taxes_eur_kwh is refused, and the "
and why(_T17_P_BOTH, "THE TABLE WINS"))
check("...and the SHIPPED scalar beside the table is NOT refused, or every "
"Walloon site would have to clear a key it never touched. The refusal is "
"about a value the operator chose, and 0.06 is the one they did not",
validate_startup_config(base(price_taxes_profile=_T17_OK,
price_taxes_eur_kwh=0.06),
-5000, 5000) == []
"about a value the operator chose, and the shipped default is the one "
"they did not",
validate_startup_config(
base(price_taxes_profile=_T17_OK,
price_taxes_eur_kwh=_T18_SHIPPED_TAXES),
-5000, 5000) == []
and len(_T17_P_BOTH) == 1)
# ⚠️ TARIFF-18 - "UNTOUCHED" IS A SET, AND THIS IS THE HALF THAT DECIDES
# WHETHER AN UPGRADE STOPS A RUNNING SITE. The Supervisor sets `App.options =
# deepmerge(config.yaml defaults, stored options)`, so a saved blob carries
# every schema key whether the operator typed it or not - the reference
# install's export is 70 of 71 (CHANGELOG 0.9.0). When TARIFF-18 moved this
# default 0.06 -> 0.11736, every such blob went from "equal to the shipped
# default" to "not equal to it" WITHOUT ANYBODY EDITING ANYTHING. A site that
# also carries a taxes table would have met `SystemExit(78)` on the upgrade,
# over a number it never typed and that decides nothing. So a SUPERSEDED
# default still reads as untouched.
#
# ⚠️ AND THE PRECONDITION FIRST (MUTATION.md habit 1): if the shipped default
# and the superseded one were ever the same number, the two checks below would
# be one check written twice, and dropping the superseded set entirely would
# leave both green.
check(f"the shipped taxes default {_T18_SHIPPED_TAXES} and the superseded "
f"{_T18_SUPERSEDED} are DIFFERENT numbers, so 'the shipped one is "
f"accepted' and 'the superseded one is accepted' are two claims",
_T18_SHIPPED_TAXES not in _T18_SUPERSEDED and len(_T18_SUPERSEDED) >= 1)
check(f"...and the superseded set is non-empty ({_T18_SUPERSEDED}), or the "
f"loop below quantifies over nothing and passes on a tree that dropped "
f"the set entirely - UX-01 AC 4's vacuous clause, one file over",
len(_T18_SUPERSEDED) >= 1)
for _old in _T18_SUPERSEDED:
check(f"...and a blob that still carries the SUPERSEDED default {_old} "
f"beside the table STARTS. That value was written into the options "
f"file by this add-on, not typed by the operator, so refusing it "
f"would stop a working site on an upgrade over an inert number",
validate_startup_config(base(price_taxes_profile=_T17_OK,
price_taxes_eur_kwh=_old),
-5000, 5000) == [])
# ⚠️ AND THE RUNG IS STILL A RUNG. A superseded default is not a licence for
# any value: 0.1198 is ORES's enkelvoudig rate, a number an operator plausibly
# types, and it is still refused beside the table. Without this line the set
# above could be widened to "anything" and nothing would report.
check("...while a value that is NEITHER the shipped default NOR a superseded "
"one is still REFUSED beside the table, naming it - the set of "
"'untouched' figures widened by exactly the old defaults and by nothing "
"else",
why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is "
"0.1198")
and 0.1198 not in _T18_SUPERSEDED
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
# ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told
# both which figure to return to and that the old default is not the problem.
check("...and the refusal names the SHIPPED default to return to AND says the "
"superseded default is accepted, so the sentence is actionable on both "
"sides of the upgrade",
bool(_T18_SUPERSEDED)
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}")
and why(_T17_P_BOTH, "SUPERSEDED")
and all(why(_T17_P_BOTH, str(u)) for u in _T18_SUPERSEDED))
check("...and the same non-default scalar WITHOUT the table still starts, "
"which is what makes the line above about the PAIR rather than about "
"0.1198 being disliked",
@@ -1494,6 +1554,259 @@ check("...and BOTH cards divided by 100 start, which is habit 7: the refusals "
.replace("7.10", "0.0710")), -5000, 5000) == [])
# --------------------------------------------------------------------------- #
print()
print("TARIFF-16 - the INJECTION leg is refused HERE, at startup, and the leg "
"the card cannot grade is SAID to be ungraded")
# ⚠️ WHY IT IS REFUSED IN THIS MODULE. `export_price_at()` is the seam a future
# export-aware strategy reads, and a formula that first fails when something
# reads it is a defect discovered at the worst possible moment - which is
# `static_profile`'s and `price_taxes_profile`'s argument, one option over.
#
# ⚠️ AND WHAT THIS OPTION DOES **NOT** DO, said before what it does: no
# strategy in this add-on reads an injection price. `arbitrage.py` prices the
# sell side as displaced IMPORT and compares two all-in CONSUMPTION prices.
# test_price.py 17h grades that by parsing every module under app/; what is
# graded HERE is that the operator is TOLD it, at startup, in the same breath
# as the formula being accepted.
import logging as _t16_logging # noqa: E402
class _T16Log(_t16_logging.Handler):
"""Every record app.validate emits during one call, rendered."""
def __init__(self):
super().__init__()
self.lines = []
def emit(self, record):
self.lines.append((record.levelname, record.getMessage()))
def _t16_start(**over):
"""`validate_startup_config()` -> (problems, rendered log lines).
⚠️ THE LEVEL IS FORCED TO INFO FOR THE DURATION AND RESTORED. Without it
the capture returns an EMPTY list on a tree that logs correctly, which
reads exactly like "the line is not there" - a check whose data cannot
reach the failure it names, in the instrument rather than in the check.
"""
log = _t16_logging.getLogger("goodwe.config")
h = _T16Log()
was, log.level = log.level, _t16_logging.INFO
log.addHandler(h)
try:
return validate_startup_config(base(**over), -5000, 5000), h.lines
finally:
log.removeHandler(h)
log.level = was
# The two published injection lines, transcribed from the cards. ⚠️ NEITHER
# CARRIES A `card` BLOCK, because neither card prints a price beside its
# injection formula - that is the state this section exists to grade.
_T16_ENECO = ('{"index":"epex_be","resolution":"hourly","factor":0.1,'
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}')
_T16_CONS = ('{"index":"epex_be","resolution":"hourly","factor":0.102,'
'"offset":1.0,"units":"c_eur_per_kwh","vat_pct":6,'
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
'"expect_incl_vat":15.55}}')
# ⚠️ THE ACCEPTED BASELINE COMES FIRST (habit 7). Every refusal below varies
# ONE field from this line, so a refusal has nowhere else to have come from.
_T16_OK, _T16_OK_LOG = _t16_start(price_export_formula=_T16_ENECO)
check("Eneco's real published injection line `0,1 x BELPEX-H - 1,188` STARTS - "
"including the part that makes its price NEGATIVE below 11,88 EUR/MWh, "
"which is the market and not a misconfiguration",
_T16_OK == [])
check("...and so does the shipped \"\", which is every install that exists: "
"the option is an addition and the scalar still answers without it",
_t16_start(price_export_formula="")[0] == []
and _t16_start()[0] == [])
_T16_BAD_PT = _T16_ENECO.replace('"factor":0.1,', '"factor":10.0,')
_T16_BAD_OFF = _T16_ENECO.replace('"offset":-1.188', '"offset":-118.8')
_T16_P_PT = _t16_start(price_export_formula=_T16_BAD_PT)[0]
_T16_P_OFF = _t16_start(price_export_formula=_T16_BAD_OFF)[0]
print(f" {(_T16_P_PT or [''])[0][:118]}")
print(f" {(_T16_P_OFF or [''])[0][:118]}")
check("PRECONDITION: each of those two blobs differs from the accepted "
"baseline in exactly one coefficient, so the refusals below are about "
"the field each names",
_T16_BAD_PT != _T16_ENECO and _T16_BAD_OFF != _T16_ENECO
and _T16_BAD_PT.replace('"factor":10.0,', '"factor":0.1,') == _T16_ENECO
and _T16_BAD_OFF.replace('"offset":-118.8', '"offset":-1.188')
== _T16_ENECO)
check("a factor 100x out is REFUSED at startup, by the pass-through rung, "
"naming the option the operator typed",
why(_T16_P_PT, "price_export_formula cannot be used:")
and why(_T16_P_PT, "passes through")
and not why(_T16_P_PT, "|offset| above"))
check("...and an offset 100x out is refused by the OFFSET rung, so the two "
"rungs are graded on their reasons and cannot mask each other",
why(_T16_P_OFF, "price_export_formula cannot be used:")
and why(_T16_P_OFF, "|offset| above")
and not why(_T16_P_OFF, "passes through"))
check("...and a malformed blob is refused as a malformed blob, naming the "
"field rather than 'invalid configuration'",
why(_t16_start(price_export_formula='{"index":"belpex_h"}')[0],
"price_export_formula.index is 'belpex_h'"))
# ⚠️ THE HONEST HALF, AND IT IS A WARNING RATHER THAN A REFUSAL. Eneco prints
# its injection formula with NO price beside it, so on that card there is
# nothing for the self-check to reproduce. Requiring a `card` block would make
# the published line inexpressible; inventing the estimate would grade the
# coefficients against arithmetic done with the same coefficients. So the leg
# starts, and the operator is told WHICH check did not run.
_T16_UNGRADED = [m for lvl, m in _T16_OK_LOG
if lvl == "WARNING" and "UNGRADED" in m]
print(f" {(_T16_UNGRADED or [''])[0][:118]}")
check("a leg with no card block starts, and says at startup that its "
"coefficients are UNGRADED - naming what was checked instead, so the "
"gap is stated rather than worked around",
len(_T16_UNGRADED) == 1
and "no \"card\" block" in _T16_UNGRADED[0]
and "pass-through" in _T16_UNGRADED[0])
_T16_WITH_CARD, _T16_CARD_LOG = _t16_start(
price_export_formula=_T16_CONS, price_formula=_T16_CONS,
price_provider="custom_formula")
# ...and a SECOND run with the two legs genuinely different, for the ordered
# pair below. ⚠️ THE FIRST ONE PUTS THE CONSUMPTION CARD ON BOTH LEGS, so
# 0.102/6 appears twice and an assertion about arrangement would pass on a
# TRANSPOSED rendering - MUTATION.md habit 4, in the fixture of a habit-14
# check. The precondition is asserted below rather than trusted.
_T16_BOTH_LEGS, _T16_BOTH_LOG = _t16_start(
price_export_formula=_T16_ENECO, price_formula=_T16_CONS,
price_provider="custom_formula")
check("...and a leg that DOES carry a card does NOT print it, or the sentence "
"would be noise an operator learns to ignore rather than a report of "
"which check ran",
_T16_WITH_CARD == []
and not [m for lvl, m in _T16_CARD_LOG if "UNGRADED" in m])
# ⚠️ AND A CARD ON THIS LEG IS STILL GRADED, at startup, by the same rung.
check("...and a card ON THIS LEG that the coefficients do not reproduce is "
"REFUSED at startup, so the optional block is optional and not inert",
why(_t16_start(price_export_formula=_T16_CONS.replace(
'"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0],
"price_export_formula does not reproduce its own card"))
# ⚠️ THE SENTENCE DOCS.md RESTS ON, AND IT IS THE ONE MOST LIKELY TO ROT.
_T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO"
and "NO STRATEGY IN THIS ADD-ON READS IT" in m]
check("the startup line says, in so many words, that NO STRATEGY IN THIS "
"ADD-ON READS IT - the operator is told what the option does not do in "
"the same breath as it being accepted",
len(_T16_INFO) == 1 and "displaced IMPORT" in _T16_INFO[0])
# ⚠️ THE ASYMMETRY IS ASSERTED AS AN ORDERED PAIR, NOT AS TWO NUMBERS IN ONE
# SENTENCE (MUTATION.md habit 14). "6 and 0 appear somewhere" is satisfied by
# the two legs TRANSPOSED - which is the sentence telling an operator that the
# add-on has their VAT treatment exactly backwards, on the one line where they
# could have seen it.
_T16_PAIR = [m for _l, m in _T16_BOTH_LOG if "priced independently" in m]
print(f" {(_T16_PAIR or [''])[0][:118]}")
check("PRECONDITION: the two legs of that run carry DIFFERENT factors and "
"DIFFERENT VAT rates (0.102 at 6 against 0.1 at 0), or the ordered-pair "
"assertion below would be satisfied by the transposed rendering it "
"exists to refuse",
_T16_BOTH_LEGS == [] and '"factor":0.102' in _T16_CONS
and '"vat_pct":6' in _T16_CONS and '"factor":0.1,' in _T16_ENECO
and '"vat_pct":0' in _T16_ENECO)
check("and the 6% asymmetry is reported as an ORDERED PAIR: the transposed "
"rendering would read `consumption factor 0.1 at vat_pct 0, injection "
"factor 0.102 at vat_pct 6`, which is a DIFFERENT string - so an "
"argument swap at the call site cannot hide behind `0.102 is in there "
"somewhere`",
len(_T16_PAIR) == 1
and "consumption factor 0.102 at vat_pct 6, injection factor 0.1 at "
"vat_pct 0" in _T16_PAIR[0]
and "consumption factor 0.1 at vat_pct 0, injection factor 0.102 at "
"vat_pct 6" not in _T16_PAIR[0])
# --------------------------------------------------------------------------- #
print("\nSAFETY-10 - shadow_mode is validated like every other option")
# ⚠️ WHY THIS IS A REFUSAL AND NOT A COERCION. `shadow.mode()` resolves an
# unrecognised value to SHADOW - the direction that does not move energy - and
# an install silently reinterpreted that way COMMANDS NOTHING, indefinitely,
# while its status page calmly explains that this is intentional. A typo is not
# a decision, and UX-02 made this validator the one rule surface both editors
# read, so the rule has to be here rather than in a quiet fallback.
_S10_BAD = ["true", "yes", "1", "shadow", "", "autoo", None, 1]
for _v in _S10_BAD:
_p = validate_startup_config(base(shadow_mode=_v), -5000, 5000)
check(f"shadow_mode={_v!r} is REFUSED, and the refusal SPELLS the key so "
"configui.problems_by_field() can file it under the right control",
why(_p, "shadow_mode is") and why(_p, "'auto'")
and why(_p, "'on'") and why(_p, "'off'"))
for _v in ("auto", "on", "off", "AUTO", " on ", "Auto "):
check(f"shadow_mode={_v!r} starts - the three values and the tolerant "
"spellings of them are accepted, so the rule above refuses garbage "
"and not case",
validate_startup_config(base(shadow_mode=_v), -5000, 5000) == [])
check("PRECONDITION: and the key being ABSENT starts too, which is what every "
"install that upgrades into this version has. A rule that refused the "
"absent key would refuse every existing install at once",
validate_startup_config(base(), -5000, 5000) == []
and "shadow_mode" not in base())
# The two arrangements have to DISAGREE somewhere, or `why()` is grading a
# sentence that is in the list for another reason entirely.
check("...and a refused shadow_mode is the ONLY thing wrong with that config, "
"so the refusal above is this rule's and not some other rule's",
len(validate_startup_config(base(shadow_mode="maybe"), -5000, 5000)) == 1)
# --- THE SIX STATES, AND THE ONE THAT PROTECTS THE EXISTING INSTALL ---------
# ⚠️ GRADED HERE RATHER THAN IN test_clamp.py, WHERE IT STARTED, AND THE REASON
# IS MEASURED. `shadow.engaged()` is a pure function of the option and the
# start button; a mutation that defeats the DEFAULT stops test_clamp.py's
# part 3 reaching the wire at all, which aborts that file with an IndexError
# before part 8 runs - so the mutation's red count there is a lower bound and
# the table itself is never graded (MUTATION.md, FCST-06's shape). Nothing in
# this file drives a Controller, so nothing here can be taken down that way.
# Six states. The one that matters most is the LAST row: an install that is
# controlling today must not be changed by this option arriving with a default.
_TABLE = [
("off", True, False), ("off", False, False),
("on", True, True), ("on", False, True),
("auto", False, True), ("auto", True, False),
]
for _m, _started, _want in _TABLE:
check(f"shadow_mode {_m!r} with control "
f"{'started' if _started else 'stopped'} -> "
f"{'SHADOW' if _want else 'LIVE'}",
shadow.engaged({"shadow_mode": _m}, control_started=_started)
is _want)
check("THE EXISTING INSTALL: the SHIPPED DEFAULT, on an install that is "
"controlling, is LIVE. This is the whole reason the option is not a "
"boolean defaulting to true - the Supervisor merges a new default into "
"every existing install, and a boolean would have stopped a battery that "
"is regulating a real house today",
shadow.engaged({}, control_started=True) is False)
check("...and the SAME shipped default on a FRESH install - `auto_start` is "
"false, so nothing has started - is SHADOW. That is what 'default-on' "
"means here, delivered where it is safe",
shadow.engaged({}, control_started=False) is True)
# ⚠️ THE REASON, NOT THE VERDICT (MUTATION.md's masking rule). `on` and
# `auto`-while-stopped are the same boolean and want DIFFERENT operator
# actions: one is an option, the other is a button. A surface showing only the
# verdict would be blind to the two rungs being swapped.
check("the two ways of being in shadow mode are told apart in words, so a "
"build that swapped the rungs could not render identically",
shadow.why({"shadow_mode": "on"}, control_started=False)
!= shadow.why({"shadow_mode": "auto"}, control_started=False))
# ⚠️ AND THE FAIL-SAFE DIRECTION OF AN UNREADABLE VALUE IS GRADED, THOUGH THE
# rule above means a running install can never hold one. It survived the first
# mutation battery as M9 for exactly that reason: everything else refused the
# value before `mode()` ever saw it, so the direction it fails in was ungraded
# and could have been flipped to "live" with the whole suite green.
check("an UNREADABLE shadow_mode resolves to SHADOW, not to live - the "
"direction that does not move energy. It is reachable only if this "
"file's rule is ever weakened, which is the case it exists for",
shadow.engaged({"shadow_mode": "garbage"}, control_started=True) is True
and shadow.engaged({"shadow_mode": ""}, control_started=True) is True
and shadow.mode({"shadow_mode": "garbage"}) == "on")
# --------------------------------------------------------------------------- #
print()
if fails:
+118
View File
@@ -2858,6 +2858,124 @@ check("...and the SITE sign detector is still absent at a roster of one - part "
not _c38one.site_sign_wd.tripped
and _c38one.site_sign_wd.disagree_since is None)
# =========================================================================== #
# 10. SAFETY-10 - THE SIGN WATCHDOG IN SHADOW MODE
# =========================================================================== #
# THE QUESTION THE CARD ASKS OF EVERY FAILSAFE: does it observe, does it latch,
# or is it inert in shadow mode? For this one the answer had to be INERT, and
# the reason is the worst outcome in the feature:
#
# SAFETY-02 grades the command STANDING ON THE WIRE against the meter. Shadow
# mode holds the wire at 0 W, so the detector cannot arm. Fed the SHADOW
# DECISION instead it would compare a number against a meter that never saw
# it - guaranteed disagreement for as long as the decision asked for power -
# and TRIP. That latch survives into live operation AND across a restart
# (part 3 of this file is that property), so a shadow run would hand the
# operator a battery that will not move, from a run in which nothing moved.
# Shadow mode would manufacture the failsafe it exists to help debug.
#
# ⚠️ GRADED ON THE SAME STIMULUS THAT TRIPS IT LIVE, NOT ON A QUIET ONE.
# MUTATION.md: a per-thing check that has never seen the things DISAGREE is a
# total. `_s10_sign()` is `_e2e()` above with one option changed - the same
# inverted loop, the same steady 2000 W import, the same 1 s window.
print("\n10. SAFETY-10 - the sign watchdog in shadow mode\n")
async def _s10_sign(shadow_mode):
hass = _Hass()
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": -0.6,
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True,
# ⚠️ AN EXPLICIT SLEW, AND THE REASON IS THE LIMITATION GRADED
# BELOW. At `slew_w: 0` the rail is `max_w / 9` = 222 W, and in
# shadow mode the loop starts from the 0 W standing on the wire
# EVERY cycle - so its decision never leaves 222 W and never
# reaches the 300 W gate at all. The detector would then be
# "inert" for a reason that has nothing to do with this card.
"slew_w": 1000,
"shadow_mode": shadow_mode}
c = Controller(opts, hass, _S(), _P(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -5000.0, 5000.0
wanted = []
for _ in range(12):
await c.read_inputs()
await c.cycle()
wanted.append(c.shadow_would_w if c.shadow else c.target)
await asyncio.sleep(0.25)
return c, hass, wanted
_s10_live, _s10_lh, _s10_lw = asyncio.run(_s10_sign("off"))
_s10_shad, _s10_sh, _s10_sw = asyncio.run(_s10_sign("on"))
print(f" live : tripped={_s10_live.sign_wd.tripped} "
f"wanted={[round(w) for w in _s10_lw[:4]]}")
print(f" shadow: tripped={_s10_shad.sign_wd.tripped} "
f"wanted={[round(w) for w in _s10_sw[:4]]}")
check("PRECONDITION: the same stimulus TRIPS the watchdog with shadow mode "
"off, so what follows is a claim about shadow mode and not about a "
"stimulus that could never have tripped anything",
_s10_live.sign_wd.tripped)
check("PRECONDITION: and in shadow mode the decision path still asked for "
"charge into that import - well past the 300 W gate - so the detector "
"had a wrong-way number available to it and did not use it",
any(w is not None and w < -300.0 for w in _s10_sw))
check("SAFETY-10: the sign watchdog is INERT in shadow mode. It grades what "
"this controller has standing on the wire, and that is 0 W - so a latch "
"that would survive into live operation and across a restart is never "
"armed by a run in which nothing was commanded",
not _s10_shad.sign_wd.tripped
and _s10_shad.sign_wd.disagree_since is None)
check("...and it is inert BY CONSTRUCTION, not by a special case: the value it "
"was handed is `Inverter.standing_w()`, which is the 0 W shadow mode "
"actually wrote and not the decision it recorded",
_s10_shad.inverters[0].standing_w() == 0.0
and set(_s10_sh.writes) == {0.0})
# ⚠️ AND THE LIMITATION IS RECORDED HERE RATHER THAN LEFT FOR SOMEBODY TO
# DISCOVER. `_shadow_settle()` re-seeds the loop's `prev_w` to what is standing
# on the wire, so in shadow mode the loop leg answers "what would this
# controller command NOW, from a battery at 0 W" - ONE STEP - and not "what
# trajectory would it have followed". The trajectory is a closed-loop property
# and it did not happen; reporting it would be a simulation of a plant that
# does not exist.
#
# ⚠️ 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 "
f"it have followed' (shadow {[round(w) for w in _s10_sw[:4]]} vs live "
f"{[round(w) for w in _s10_lw[:4]]})",
len({round(w) for w in _s10_sw if w is not None}) == 1
and len({round(w) for w in _s10_lw[:4]}) > 1)
# ⚠️ AND "INERT" HAD TO BE SHOWN TO MEAN STARVED, NOT DISABLED. A detector
# that had been switched off would satisfy every check above and would still
# be off the moment shadow mode ends. So the SAME object, off the SAME
# controller, is handed the disagreement the run never let it see.
_s10_n0 = datetime.now(timezone.utc)
_s10_wd = _s10_shad.inverters[0].sign_wd
check("PRECONDITION: it is not tripped going in, so what follows is this "
"stimulus and not a latch left over from the run",
not _s10_wd.tripped)
_s10_wd.evaluate(-1500.0, 2000.0, True, _s10_n0)
_s10_fired = _s10_wd.evaluate(-1500.0, 2000.0, True,
_s10_n0 + timedelta(seconds=5))
check("...and the failsafe is STARVED, not DISABLED: handed -1500 W standing "
"against a +2000 W import - the evidence shadow mode never lets it see - "
"the very same detector object trips. It is armed for the moment shadow "
"mode ends",
bool(_s10_fired) and _s10_wd.tripped)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+8 -2
View File
@@ -148,8 +148,8 @@ configuration:
name: "Tariff · Supplier markup (EUR/kWh)"
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under manual_csv, whose figures are all-in, it is ignored, and logged as ignored, never applied twice."
price_taxes_eur_kwh:
name: "Tariff · Levies and VAT (EUR/kWh)"
description: "Taxes and levies per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them."
name: "Tariff · Distribution, levies and VAT (EUR/kWh)"
description: "Everything on your bill that is not the energy itself - distribution, levies, excise and VAT - per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them. ⚠️ THIS NUMBER IS NOT A PREFERENCE AND IT IS NOT YOUR INVERTER'S. It ships at 0.11736, DERIVED in TARIFF-18 from the Eneco residential card for Flanders of September 2026 including 6% VAT: Fluvius distribution for Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. The energiebijdrage is 0 since 1 August 2026 - it was merged into the excise, so a figure copied from an older guide would count it twice. ⚠️ CHECK IT AGAINST YOUR OWN DSO. Only the distribution row moves: across the eight Fluvius DSOs it runs from 5,28 c/kWh in Midden-Vlaanderen to 6,70 in West-Vlaanderen, so the Flemish total runs from about 0.117 to 0.131. In Wallonia or Brussels the distribution tariff has a separate day and night rate and does not belong in this field at all - use the levies-by-time-of-day option below instead. ⚠️ CHANGING IT MOVES NO TRADING DECISION: the same amount is added to every hour, so it cancels out of both the cheapest hour and the spread. What it does move is every price you are shown, and the rule that refuses to discharge into an hour whose all-in price is below zero."
export_price_eur_kwh:
name: "Tariff · Injection price (EUR/kWh)"
description: "What you are paid for feeding a kWh back into the grid, all in. One number rather than a series: no supplier here publishes a dynamic injection curve. In Belgium it is often close to zero, which on its own strongly favours using your own production over exporting it. It may be negative - at midday in summer you can be paying to export."
@@ -162,6 +162,9 @@ configuration:
price_formula:
name: "Tariff · Your own tariff formula (one line of JSON)"
description: "For price source custom_formula only: your own contract's two coefficients, written as a single line of JSON. Your supplier's tariff card prints the formula as B x index + A - those two numbers exist only on your card, there is no public index of them anywhere, and you are the only person who can read them. Type what the card prints, IN THE UNITS THE CARD PRINTS: every unit conversion and the VAT step are the add-on's job, because those are the two places a human silently loses a factor of 10, 100 or 1.06. Example: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ THE card BLOCK IS REQUIRED AND IT IS WHAT MAKES THIS SAFE: your card also prints the reference index it assumed and the price that comes out, so the add-on recomputes that price from your numbers and REFUSES TO START if it does not come out, telling you by how much. That catches a wrong unit, VAT left out or counted twice, a transposed digit, and the expensive one - network charges folded into the offset, which the add-on would then bill you for a second time. ⚠️ AND ITS LIMIT, IN THE SAME BREATH: it checks your coefficients against your card, not your card against reality, so two numbers copied off the wrong product agree with each other and pass. Fixed numbers only - no expression of any kind is evaluated, and an unrecognised field is refused rather than ignored."
price_export_formula:
name: "Tariff · Your own INJECTION formula (one line of JSON)"
description: "Optional, and only if your supplier pays you a price that moves with the market. Your tariff card has a SECOND formula for what you are paid for the electricity you export, and it is not the first one with a minus in front: Eneco prints `0,1 x BELPEX-H - 1,188` c/kWh for injection against `0,102 x ...` for consumption, and Aspiravi `0,07 x Belpex - 2`. Written in exactly the same shape as your own tariff formula above. ⚠️ INJECTION IS EXEMPT FROM VAT in Belgium while consumption is at 6%, so vat_pct here is normally 0 - that is the one field people copy across from the other formula and get wrong. ⚠️ THE PRICE THIS GIVES CAN BE NEGATIVE, and that is not an error: Eneco pays below zero whenever BELPEX-H is under 11,88 EUR/MWh, which is the middle of a sunny day - you can be charged to export. The add-on accepts that rather than hiding it. The card block is OPTIONAL here and required above, because Eneco prints the injection formula with NO example price beside it and there would be nothing to check your numbers against; if your card does print one, fill it in and the add-on will refuse to start unless your numbers reproduce it. ⚠️ WHAT THIS DOES NOT DO: no part of the add-on trades on an injection price today - it charges cheap to avoid buying dear, and both sides of that comparison are consumption prices. This is what you are shown, and what a future export strategy would read. Leave it empty and the flat injection price above is used, which is what every installation does now."
price_csv_path:
name: "Tariff · Price CSV file"
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
@@ -246,6 +249,9 @@ configuration:
delivery_hold_s:
name: "Safety · Not-delivering report after (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
shadow_mode:
name: "Safety · Shadow mode — run every decision, command nothing"
description: "Runs the whole decision path — strategies, arbiter, clamps and guards — and sends NOTHING to the inverter. Every inverter is held at 0 W, refreshed on the ordinary heartbeat, and the status page says plainly that the battery is not being controlled; the decision it WOULD have commanded, and why, is on the status page and in the action log. This is how you watch a site for a day before letting it trade. 'auto' (the shipped value) means shadow mode while control is stopped and ordinary control once you press Start — so a NEW install observes before it ever commands anything, and an install that is already controlling is not changed by one watt. 'on' means shadow mode always, whatever the Start / stop button says: that is the setting to pick when you want the loop and the strategies running on a live site with nothing reaching the battery. 'off' is ordinary control. ⚠️ While this is on the battery is NOT being regulated — the grid is not held at zero, no arbitrage trade is executed and no capacity peak is shaved. ⚠️ A failsafe latch that was already up stays up: shadow mode does not clear it, and the banner names it."
auto_start:
name: "Advanced · Start controlling automatically on boot"
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
+8 -2
View File
@@ -128,8 +128,8 @@ configuration:
name: "Tarief · Marge van de leverancier (EUR/kWh)"
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij manual_csv, waarvan de bedragen alles inbegrepen zijn, wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
price_taxes_eur_kwh:
name: "Tarief · Heffingen en btw (EUR/kWh)"
description: "Belastingen en heffingen per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat."
name: "Tarief · Distributie, heffingen en btw (EUR/kWh)"
description: "Alles op uw factuur dat niet de energie zelf is - distributie, heffingen, accijns en btw - per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat. ⚠️ DIT GETAL IS GEEN VOORKEUR EN HET IS NIET DAT VAN UW OMVORMER. Het staat standaard op 0.11736, AFGELEID in TARIFF-18 van de Eneco-kaart voor residentiele klanten in Vlaanderen van september 2026, inclusief 6% btw: Fluvius-distributie voor Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. De energiebijdrage is sinds 1 augustus 2026 nul - ze is opgegaan in de accijns, dus een bedrag uit een oudere gids telt ze dubbel. ⚠️ CONTROLEER HET BIJ UW EIGEN DISTRIBUTIENETBEHEERDER. Alleen de distributieregel verschilt: over de acht Fluvius-netgebieden loopt die van 5,28 c/kWh in Midden-Vlaanderen tot 6,70 in West-Vlaanderen, zodat het Vlaamse totaal ongeveer van 0.117 tot 0.131 loopt. In Wallonie en Brussel heeft het distributietarief een apart dag- en nachttarief en hoort het hier helemaal niet thuis - gebruik dan de optie voor heffingen per tijdstip hieronder. ⚠️ DIT WIJZIGEN VERANDERT GEEN ENKELE HANDELSBESLISSING: hetzelfde bedrag komt bij elk uur, dus het valt weg uit zowel het goedkoopste uur als het prijsverschil. Wat het wel verandert is elke prijs die u te zien krijgt, en de regel die weigert te ontladen in een uur waarvan de totaalprijs onder nul ligt."
export_price_eur_kwh:
name: "Tarief · Injectievergoeding (EUR/kWh)"
description: "Wat u krijgt voor een kWh die u op het net zet, alles inbegrepen. Eén getal en geen reeks: geen enkele leverancier hier publiceert een dynamische injectiecurve. In België ligt dit vaak dicht bij nul, wat op zich al sterk pleit voor eigenverbruik in plaats van injectie. De waarde mag negatief zijn - op een zomerse middag betaalt u soms om te injecteren."
@@ -142,6 +142,9 @@ configuration:
price_formula:
name: "Tarief · Uw eigen tariefformule (één regel JSON)"
description: "Alleen voor prijsbron custom_formula: de twee coëfficiënten van uw eigen contract, als één regel JSON. De tariefkaart van uw leverancier drukt de formule af als B x index + A - die twee getallen staan alleen op uw kaart, er bestaat nergens een publieke lijst van, en u bent de enige die ze kan lezen. Typ wat de kaart afdrukt, IN DE EENHEID DIE DE KAART GEBRUIKT: elke eenheidsomrekening en de btw-stap zijn het werk van de add-on, want dat zijn precies de twee plaatsen waar een mens stilzwijgend een factor 10, 100 of 1,06 verliest. Voorbeeld: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ HET BLOK card IS VERPLICHT EN MAAKT DIT VEILIG: uw kaart vermeldt ook de referentie-index die ze veronderstelt en de prijs die eruit volgt, dus de add-on berekent die prijs opnieuw uit uw getallen en START NIET als het resultaat niet klopt - met vermelding van het verschil. Dat vangt een verkeerde eenheid, btw die ontbreekt of dubbel geteld is, een omgewisseld cijfer, en de dure fout: nettarieven die in de offset verwerkt zitten en die de add-on er dan nog een tweede keer bovenop zou rekenen. ⚠️ EN DE GRENS DAARVAN, IN DEZELFDE ZIN: ze toetst uw coëfficiënten aan uw kaart, niet uw kaart aan de werkelijkheid - twee getallen van het verkeerde product kloppen onderling en komen er dus door. Alleen vaste getallen: er wordt geen enkele expressie geëvalueerd, en een onbekend veld wordt geweigerd in plaats van genegeerd."
price_export_formula:
name: "Tarief · Uw eigen INJECTIEformule (één regel JSON)"
description: "Optioneel, en alleen als uw leverancier u een prijs betaalt die met de markt meebeweegt. Op uw tariefkaart staat een TWEEDE formule voor wat u krijgt voor de stroom die u injecteert, en dat is niet de eerste met een minteken ervoor: Eneco drukt `0,1 x BELPEX-H - 1,188` c/kWh af voor injectie tegenover `0,102 x ...` voor afname, en Aspiravi `0,07 x Belpex - 2`. Geschreven in precies dezelfde vorm als uw eigen tariefformule hierboven. ⚠️ INJECTIE IS IN BELGIË VRIJGESTELD VAN BTW terwijl afname op 6% staat, dus vat_pct is hier normaal 0 - dat is het ene veld dat men van de andere formule overneemt en verkeerd invult. ⚠️ DE PRIJS DIE HIERUIT VOLGT KAN NEGATIEF ZIJN, en dat is geen fout: Eneco betaalt onder nul zodra BELPEX-H lager is dan 11,88 EUR/MWh, en dat is het midden van een zonnige dag - u kunt moeten betalen om te injecteren. De add-on aanvaardt dat in plaats van het te verbergen. Het blok card is hier OPTIONEEL en hierboven verplicht, omdat Eneco de injectieformule afdrukt ZONDER voorbeeldprijs ernaast en er dan niets is om uw getallen aan te toetsen; drukt uw kaart er wél een af, vul ze dan in en de add-on start niet als uw getallen die prijs niet reproduceren. ⚠️ WAT DIT NIET DOET: geen enkel onderdeel van de add-on handelt vandaag op een injectieprijs - hij laadt goedkoop op om duur te vermijden, en beide zijden van die vergelijking zijn afnameprijzen. Dit is wat u te zien krijgt, en wat een toekomstige exportstrategie zou lezen. Laat het leeg en de vaste injectieprijs hierboven wordt gebruikt, zoals elke installatie nu doet."
price_csv_path:
name: "Tarief · CSV-bestand met prijzen"
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
@@ -226,6 +229,9 @@ configuration:
delivery_hold_s:
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
shadow_mode:
name: "Veiligheid · Schaduwmodus — alles beslissen, niets sturen"
description: "Doorloopt het volledige beslispad — strategieën, arbiter, begrenzers en bewakingen — en stuurt NIETS naar de omvormer. Elke omvormer wordt op 0 W gehouden, ververst op de gewone hartslag, en de statuspagina zegt onomwonden dat de batterij niet wordt aangestuurd; wat er gestuurd ZOU zijn, en waarom, staat op de statuspagina en in het actielogboek. Zo bekijkt u een installatie een dag lang voordat u hem laat handelen. 'auto' (de meegeleverde waarde) betekent schaduwmodus zolang de regeling gestopt is en gewone regeling zodra u op Start drukt — een NIEUWE installatie kijkt dus eerst toe voordat ze ooit iets stuurt, en een installatie die al regelt verandert geen watt. 'on' betekent altijd schaduwmodus, wat de Start/stop-knop ook zegt: die stand kiest u als u de lus en de strategieën wilt laten draaien op een werkende installatie zonder dat er iets bij de batterij aankomt. 'off' is gewone regeling. ⚠️ Zolang dit aanstaat wordt de batterij NIET geregeld — het net wordt niet op nul gehouden, er wordt geen prijshandel uitgevoerd en er wordt geen capaciteitspiek afgevlakt. ⚠️ Een vergrendeling die al aanstond blijft aanstaan: schaduwmodus heft die niet op, en de banner noemt hem."
auto_start:
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."