Compare commits

...
13 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 d57122ec74 DATA-02: a real Belgian price year, so AC4 stops being unmeetable
checks / unit (pull_request) Successful in 3m36s
OPT-04 reported AC4 - "net-positive across 12 months of replayed prices" -
as NOT MET and replayed a synthetic year, because scenarios/ held only P1
meter captures. Right call, wrong diagnosis: the criterion was being carried
beside a 30-day METER criterion as though both needed waiting. EPEX BE
day-ahead is public data, not a capture. It needed a fetch.

8760 hourly points, 2025-09-01 to 2026-09-01 Brussels-local, canonical three
fields. Licence checked BEFORE committing: Energy-Charts publishes the BE
zone under CC BY 4.0 from Bundesnetzagentur | SMARD.de and other zones under
private-use-only terms, so this one is redistributable with attribution. The
fetcher refuses to write a file whose per-response license_info stops saying
so. Cross-checked against SMARD's own 4996/DE: 96 of 96 quarter-hours, max
difference 0.00 EUR/MWh.

Raw wholesale, and the file says so. all_in: false, with the markup/tax
assumption carried as numbers to APPLY rather than numbers applied -
TARIFF-02's trap in data form. Section 7B applies it in one visible place and
asserts arithmetically that its additive terms cancel out of every daily
spread, so the trade count turns on the VAT multiplier alone; the raw replay
is run too, so how much rests on the guess is reported (110 vs 88).

DST by construction: iterate in UTC, render through price.brussels().
2025-10-26 is 25 points, 2026-03-29 is 23, verified by counting per Brussels
day rather than asserting 24, and by checking October's 02:00 appears twice
at two offsets with two prices while March's never appears. Both bugs the
point-and-gap accounting caught were real: a UTC-bounded window made the
first day 22 hours, and an inclusive-end off-by-one dropped the last day
silently. A day nobody asked for is not a day with no prices.

THE RESULT, AT THE SHIPPED DEFAULT, WHATEVER IT IS. At 0.10 widened 1.5x to
0.1500: 110 round trips, +24.88 EUR net of the cycle cost. Unwidened, 173.
Nothing tuned - the threshold and the 2.5/1.5 factors are untouched.

And what the owner's decision now has under it: the four reports that a
static ~0.0840 spread does not clear 0.10 all STAND. What was missing is that
a dynamic site clears the widened requirement on 121 of 365 days, median
all-in spread 0.1130, so a median day would clear at 0.0754 - 156 trips,
+35.42 EUR. Reported, not applied.

The synthetic year stays, because the real one cannot grade section 17: not
one of 365 days has a negative DEAREST hour, so a zero there counts something
that could not happen. 7B asserts that degeneracy rather than reporting the
zero.

Size measured, not assumed: 893 KB plain but 74.6 KB as a git object against
56.5 KB gzipped. Git already compresses; 18 KB is not worth making the
provenance block - the licence claim a reviewer must check - unreadable in a
diff.

Nothing wired into the add-on; generic_dayahead is still the live path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 11:01:42 +02:00
admin 4601682815 SAFETY-24: an energy floor under the peak guard's forced discharge (#21)
SAFETY-07 could command a discharge to hold a billed quarter down and nothing below it consulted the state of charge - the BMS was the sole energy backstop. decide() now takes soc and soc_floor, and discharge_permitted() is the one place the question is answered: public, no default arguments, and consulted only AFTER a forced discharge is actually being asked for. The denied action is a 0 W floor - one-directional, forbids charging, structurally cannot cap the discharge the loop is already doing.

Reviewed independently. The trade-off is ruled honest and was made a check rather than a footnote: on a pack deliberately sized so the floor must bind, the worst quarter goes 4338 -> 5030 W while end SoC goes 9.6 -> 20.0 % and cycles pinned at the BMS cut-out go 1227 -> 0. Reproduced on the reviewer's own replay, including the 304.2 Wh withheld against 292.8 Wh between the two floors - half a cycle at the rail apart, so the floor is doing the arithmetic it claims.

The guard's central guarantee was re-derived across 19,800 combinations of load x elapsed x battery x SoC x floor: 0 negative floors, 0 claims with hi other than +inf, 0 decisions both floor-limited and saturated, 0 arbiter resolutions lowered or contradicted. Rule 8's <= is right: at equality the EMS floor could never be the thing that stops the discharge.

Eleven author mutations plus nine reviewer ones, all red under python -B with a purged __pycache__ and real byte deltas. One survivor is an equivalent mutant and provably so: a float(soc or 100) defaulting above the call site is dead code, because a Controller with no SoC never reaches decide() at all - inputs_ok is False and cycle() stops at the safety claim first. That property is now asserted on the service call, replacing an assertion whose sentence was wider than its check.

Merged after two rebases. The second one broke a grader neither card could have seen alone: test_peakguard graded the FIRST .decide( in main.py and required exactly one, which stopped being true when OPT-04 added arbitrage.decide(). It failed loudly rather than grading the wrong call. Fixed by selecting peakguard.decide by qualified name and requiring exactly one of that, verified still red on a decoy and on the maintenance_soc_floor swap.

Follow-ups filed as SAFETY-27, not addressed here: arbitrage_soc_floor has no rule-8 twin against bms_soc_floor, and this card's own 'revisit if a third floor appears' trigger has fired.
2026-08-31 10:24:36 +02:00
glenn schrooyenandClaude Opus 5 9234016419 Merge release/1.0 (OPT-04) into SAFETY-24, and fix one grader the merge broke
checks / unit (pull_request) Successful in 3m36s
OPT-04 landed after the previous rebase. Conflicts were CHANGELOG.md and one
IMPORT LINE in validate.py; both refusal blocks and both option sets survive
untouched, and DOCS.md, main.py and config.yaml auto-merged.

⚠️ AND THE MERGE BROKE A CHECK THAT NEITHER CARD COULD HAVE SEEN ALONE.
test_peakguard.py graded "the first `.decide(` call in main.py" and asserted
there was exactly one. That was true when SAFETY-24 was written; OPT-04 added
`arbitrage.decide(...)`, so the merged tree had two and the assertion failed.
It failed loudly rather than grading the wrong call - the guard did its job -
but it cannot ship red.

Fixed by selecting the call by QUALIFIED NAME (`peakguard.decide`) instead of
by position, and requiring exactly one of THAT. Other modules may now call
their own decide(); this grader can no longer be moved by one of them. Verified
still discriminating: a decoy `peakguard.decide` added to main.py turns it red
on 7 assertions, and the `maintenance_soc_floor` swap still turns it red on 6.

Also folded in the review's wording finding. An assertion read "a CONTROLLER
whose SoC entity does not answer hands decide() None" while driving no
Controller. The wiring property is stronger than the one claimed, and is now
asserted on the SERVICE CALL: a Controller with no SoC never reaches the guard
at all, because `inputs_ok` is False and cycle() stops at the safety claim
first - so a `float(soc or 100)` defaulting line above the call site cannot
disarm the floor. The original decide()-level check is kept, reworded to say
what it actually grades.

Full suite green on the merged tree: 15 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 07:39:40 +02:00
admin b7272169b1 SAFETY-26: pin the negative-price boundary from below, not just from above (#23)
Closes the survivor found reviewing OPT-04: price < 0 weakened to < -0.001 left every suite green while a -0.0005 EUR/kWh sell hour discharged. Pinned as a sweep rather than a point - five magnitudes spanning >10000x down to -0.000001, the last place the canonical 6-decimal format expresses - with the well-formedness of each stimulus asserted first so section 17 is the only rung that can refuse, and the same five sign-flipped asserted to discharge so it is a boundary at exactly zero. app/ untouched.
2026-08-31 07:37:46 +02:00
glenn schrooyenandClaude Opus 5 2b8b8cb97f SAFETY-26: pin the negative-price refusal from below, not only from above
checks / unit (pull_request) Successful in 3m15s
OPT-04's reviewer weakened `if price < 0` to `if price < -0.001` and every
suite stayed green while a -0.0005 EUR/kWh sell hour discharged. The shipped
code was right the whole time; the CHECK could not tell correct from
nearly-correct. The only probe near the boundary sat at 0.0000, which catches a
`<=` and says nothing about a tolerance invented on the other side - and a
tolerance on the other side is §17's named regression walking through with the
suite passing. A dearest hour at -0.0005 is an ordinary Belgian day-ahead
print, not a fuzz value.

`app/` IS NOT TOUCHED. `if price < 0` is already correct and changing it would
be the defect; this card closes the check, not the behaviour. The 0.0000 probe
stays and is now labelled as the half that pins the boundary from above.

The new probe is a SWEEP rather than a point, because one probe at -0.0005
closes `-0.001` and nothing else - a mutation reaching for `-0.01` would be the
identical defect one decimal further out. Five prices span four orders of
magnitude down to -0.000001, the last place the canonical six-decimal format
can express; each is asserted to survive that rounding as strictly negative,
to still be the DEAREST hour, and to still clear the widened threshold, so §17
is the only rung that can refuse. The same five magnitudes with the sign
flipped all discharge, so this is a boundary at exactly zero rather than a
refusal of small numbers.

AC4, the look: every price/quantity boundary in arbitrage.py and
static_tariff.py was given the same tolerance mutation, both directions where
both exist. Two survive, both in static_tariff.py and both NAMED RATHER THAN
FIXED - they are TARIFF-03's rungs and want probes in its own file. `spread ==
0.0` loosened to `<= 0.001` flips a real 0.0005 spread from allowed to refused
at min_spread_eur_kwh=0.0, a documented supported value, and otherwise prints a
false reason about a tariff that is not flat. `len(firm) < 2` loosened to `< 1`
survives at +0 bytes - TEST-15's risky delta - and is masked only by the flat
rung, so the two mask each other and a later edit to either opens a hole
nothing reports.

Mutation verified under `python -B` with a recursive __pycache__ purge, byte
delta printed (+5 B), the mutated line asserted changed, and - per the
reviewer's addition to TEST-15 - the tree re-read from disk and asserted to be
the tree that was mutated before each run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 02:07:56 +02:00
admin 8e589fee3f OPT-04: price arbitrage, at priority 3, shipped switched off (#22)
app/arbitrage.py consumes TARIFF-02s canonical prices, TARIFF-03s Arbitrage verdict and FCST-03s firm/provisional split rather than re-implementing them. An idle decision files NO claim - a 0 W set at P_ARBITRAGE would out-rank the loop and kill self-consumption. Peak shaving is honoured twice: TARIFF-03s required peak_guard_active interlock, and P_PEAK 50 clamping P_ARBITRAGE 30 in the arbiter. The fetch lives in run_tariff_refresh(), asserted on the shipped AST to be absent from cycle() and read_inputs(). Selling into a negative-price hour is refused. arbitrage_enabled ships false.
2026-08-31 01:53:31 +02:00
glenn schrooyenandClaude Opus 5 bdd7f18a54 OPT-04: charge cheap, discharge expensive - and only when it pays
checks / unit (pull_request) Successful in 3m16s
Four cards built the price stack and nothing traded on it. app/arbitrage.py is
§10.1's priority 3, and it consumes all four rather than re-deciding anything:
whether a trade is permitted at all, on which two hours, against which widened
threshold is TariffPlan.arbitrage()'s answer, and that answer cannot be
obtained without first saying whether the peak guard is holding. §9.1's "peak
shaving outranks arbitrage" is therefore honoured structurally - there is no
path to a spread that skips the question - and again in the arbiter, where
P_PEAK (50) clamps a claim filed at P_ARBITRAGE (30).

A guard that never ran is not a guard that is clear. On meter_source off the
peak guard is never evaluated; that third state is carried as None and refuses,
because charging at the rail creates the billed quantity while nothing measures
the quarter. validate.py refuses that configuration too, along with arbitrage
armed with no price source and a SoC floor at or above the ceiling.

§17: never export into a negative price. The guard is on the hour being sold
into, not on the spread - a day priced -0.30 .. -0.05 clears any threshold and
its dearest hour is still negative. Buying in a negative hour stays allowed.

resolve_tariff() may open an HTTPS connection and the tick feeds the ESP32
heartbeat, so the fetch runs in its own task at a 30 min cadence and the tick
reads an attribute and an age bound. test_arbitrage.py section 8 asserts that
structurally on the shipped AST: a fetch put back on cycle() is red.

AC4 is NOT met and is not restated - this repository holds no price history.
Section 7 replays a synthetic year and asserts the property that does not need
real prices: every executed round trip cleared its own widened requirement, and
zero discharges landed in the 522 negative-priced hours. At the shipped 0.10
EUR/kWh threshold a realistic Belgian day/night site does not trade at all.
Reported, not tuned: that number and FCST-03's widening factors are one product
decision and it is the owner's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 01:36:09 +02:00
glenn schrooyenandClaude Opus 5 a1376e44e4 Merge release/1.0 (FCST-03) into SAFETY-24 - CHANGELOG only
checks / unit (pull_request) Successful in 3m28s
FCST-03 landed while this was in review, so both branches had prepended an
Unreleased stanza. Only CHANGELOG.md conflicted, exactly as predicted;
app/, config.yaml, validate.py and DOCS.md all auto-merged, and this card
touches neither price_forecast.py nor static_tariff.py.

Both stanzas kept verbatim, SAFETY-24's above FCST-03's - no text from
either author edited. Full suite green on the merged tree: 14 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 00:43:58 +02:00
glenn schrooyenandClaude Opus 5 e709554857 SAFETY-24: an energy floor under the peak guard's forced discharge
checks / unit (pull_request) Successful in 3m29s
SAFETY-07 can command a discharge to hold a billed quarter down, and
nothing below it consulted the state of charge. On the 2026-08-20 replay
it asks for 878 Wh in a day. Relying on the BMS is not absurd - it is a
real floor and it works - but it is the pack's protection rather than the
operator's, and a month of aggressive quarters leaves the battery
repeatedly at that cut-out with no EMS-side record of why. The first
symptom is a pack never available when wanted, which is indistinguishable
from the SoC drift MAINT-01 exists to correct.

decide() now takes soc and soc_floor, both required and neither
defaulted, and discharge_permitted() is the one place the question is
answered. Above the floor nothing changes. At or below it the guard files
a 0 W floor instead of a sized one - one-directional, so charging stays
blocked and no discharge is capped - and reports floor_limited with the
reason it bound. It is not a trip: every stop in 11.2's table still
outranks the claim, and the floor is never negative, so 9.1's asymmetry
survives as a property of the object (0 negative floors over 1296 swept
combinations, SoC included).

The SoC is graded only after `wanted > 0` is established, so it is never
evaluated where it governs nothing. An unreadable SoC is floor-limited,
not a full battery - a full battery is exactly the value that would
disarm the floor, and this project shipped that defaulting once already.

Failing to shave is not silent: one notification per episode
(goodwe_peak_soc_floor), distinct from the saturation alarm because the
two ask for opposite operator actions, plus its own status row and an
action-log line.

Verified by replay. The 2026-08-20 capture is driven against a modelled
pack sized from the day's own measured appetite, so it genuinely
exhausts: without the floor it reaches the BMS cut-out and holds there
for 1227 guard cycles; with it the pack stops at 20.0 % and never touches
the BMS. The energy withheld equals the energy between the two floors to
within one cycle at the rail. The honest half is reported too - the worst
quarter goes 4338 W -> 5030 W, because a floor-limited guard shaves less
on purpose.

Two new options, mirrored once in peakguard.PEAK_OPTION_DEFAULTS
(TARIFF-02's pattern), imported by validate.py and main.py rather than
typed as literals and pinned to config.yaml by a check that reads the
file - so CFG-03 still tracks three places. validate.py rule 8 refuses
peak_soc_floor <= bms_soc_floor at startup, in CFG-01/MAINT-04's manner.
maintenance_soc_floor is deliberately not reused and not renamed: it is a
drain target, not a spend limit, and the rename is recorded as a decision
to revisit if a third floor appears.

Eleven mutations of the shipped source were run against the checks; all
eleven go red and there are no survivors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 00:29:36 +02:00
admin a30bde1ab1 FCST-03: provisional prices - yesterday flagged as yesterday, and unable to move anything (#20)
app/price_forecast.py fills unknown hours from yesterday, flagged provisional by four independent mechanisms: _carried() has no provisional parameter, published points pass through by identity, _audit() re-derives every flag and raises rather than repairs, and an AST check refuses a non-literal provisional. Gate blocks irreversible actions on provisional hours. Arbitrage refusal 3 rewritten to fewer-than-two-FIRM-hours so filling does not switch arbitrage off every morning, with the 11.4 fallback still refused by a truer route. Real-time products get a measured 2.5x wider threshold, following the declared PriceProduct.
2026-08-31 00:23:41 +02:00
glenn schrooyenandClaude Opus 5 71cd7849e4 Merge release/1.0 (SAFETY-07) into FCST-03, and close two degenerate stimuli
checks / unit (pull_request) Successful in 3m13s
CHANGELOG.md was the entire collision - SAFETY-07 lands in peakguard.py, main.py
and the arbiter, none of which this branch touches. Both entries kept.

X1. `_firm_or_die` guarding only `cheapest` survived, because the check passed
the SAME provisional point as both cheapest and dearest: a guard on either role
alone raises, so the stimulus could not tell "the buy hour is guarded" from
"both are". Now one call per role with a firm point in the other seat, plus the
positive arm so the two refusals are discriminating.

X6. `firm_points()` returning the carried hours too survived, because its two
consumers - the §11.4 refusal and cheap_now - guard provenance independently and
both keep answering correctly. The accessor is now graded directly: exactly the
published points, by identity and in order.

Both are the degeneracy rule this file states at the top, in a new dress - a
stimulus that cannot distinguish the two things it grades.

⚠️ AND THE REASON THE M9 MUTATION "SURVIVED" IN MY EARLIER REPORT: a stale
__pycache__. Reverting `self.provisional` to `here.provisional` changes neither
the file size nor, at one-second resolution, the mtime, so CPython kept the
MUTATED bytecode and a green run afterwards proved nothing about that line. Every
mutation here now clears __pycache__ first; re-run that way, M9 is caught. The
committed source was always correct - the checks were not being run against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 00:15:32 +02:00
admin e7c95bfd8a SAFETY-07: cap the loop before it sets the month's billed capacity peak (#18)
The first thing in this system that can STOP a runaway rather than alarm on one. Each cycle it projects the open 900 s block from P1 telegrams and, over the ceiling, files one arbiter claim: a lower bound at P_PEAK, sized as excess = 900*(projected-ceiling)/remaining added to the MEASURED battery power. No writes, no I/O, no clock, no state between calls.

Replayed independently by the reviewer on a separate harness: 2026-08-20's billed quarter 5029.8 W -> 3485.3 W, both days 189/190 -> 190/190 under the 3500 W soft limit, 878 Wh modelled discharge on the failure day and 0 Wh with 0 engagements on the healthy one.

Verified as properties rather than witnesses: the lo-limit claim cannot cap discharge or export (hi is inf; 0 of 54 loop/floor pairs lowered the target; no floor in 0..5000 W reduces a full-rate discharge; decide() emitted 0 negative floors over 630 input combinations), and the 900/remaining gain is exactly 1.0 at the top of the block, growing, bounded at the end by the achievable_w clamp so it can never file lo > hi.

Round one held this PR on three wiring mutations that left all twelve suites green while a default install parked at 0 W in the billed quarter - including the suppression re-gated on the optional peak_forecast_entity, verbatim the historical defect this card's own comment described. All three are closed. The conjunction moved out of main.py into peakguard.shaping_suspended() so the check can assert on the shipped expression, and part 7 now executes the mutations against the real Controller.cycle(). The reviewer confirmed the harness cannot go vacuous: re-inlining the conjunction in main.py with behaviour-identical code turns B1 and B2 RED rather than green, because they assert the mutation takes effect.

Eight further reviewer mutations this round, all red, no survivors. One note recorded, not blocking: the parsed-syntax grade on decide(batt_w=...) is defeated by a decoy .decide() call or by overwriting self.batt before the call, and it is the behavioural checks beside it that catch both.

AC 3 remains TARIFF-01's and is NOT met - do not close the card as AC 3 met. SAFETY-24, the missing SoC backstop on the forced discharge, is named in peakguard.py with the 878 Wh figure and must land before this reaches a client install.
2026-08-30 23:56:20 +02:00
glenn schrooyenandClaude Opus 5 dd40c56ab4 FCST-03: yesterday's prices, flagged as yesterday's, and unable to move anything
checks / unit (pull_request) Successful in 2m43s
§8.3 fills the hours a day-ahead feed has not published yet with yesterday's
profile and flags them provisional, and forbids any irreversible action on such
a price. The whole risk of this ticket is one line - a carried point and a
published point are three fields and a bool, so `provisional=y.provisional`
copies yesterday's False onto today's guess and produces stale data presented as
current. That is SAFETY-18's frozen-but-fresh meter one layer up.

So the flag is never passed through. Carried points come from a constructor with
no provisional parameter; published points pass through BY IDENTITY, so no copy
exists that could lose one; an audit re-derives the expected flag for every
emitted point and raises rather than repairing; and the check walks the module's
own AST so the pass-through idiom cannot come back looking ordinary. A published
point may itself be provisional (eneco_eddy's imbalance print is), so the rule
for a published hour is "whatever the feed said", never False.

The fill keys on the Brussels wall clock rather than t-24h, so the 23- and
25-hour days are right; a late publication rebuilds the series, moving the
number and the flag together.

AC2 and AC3 landed on TARIFF-03's existing verdict surface rather than beside
it. Refusal 3 became "fewer than two FIRM hours" - refusing on "any point is
provisional" would have switched arbitrage off every morning on every dynamic
site - and refusal 5 is new: the spread must clear a threshold widened by the
widest active uncertainty source (max, never a product). The permitting variant
now refuses to be constructed around a provisional point at all, and the
cheap-window flag became per-hour for the same reason.

The irreversible action is a value carrying the hours it commits, derived from
maintenance.py's own DRAIN_TIMEOUT_MIN; the gate blocks a maintenance drain
whose window reaches into a carried hour, and the phase machine stays idle. It
is the price-justified entry point only - a gate that blocked on no_data would
stop the monthly cycle on every default install.

Zero new config options; TEST-14's signature scan ported to cover both Gate's
and Arbitrage's constructors, closing _Refused.reason's open hole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:54:07 +02:00
18 changed files with 49656 additions and 135 deletions
+232
View File
@@ -2,6 +2,238 @@
## Unreleased
**OPT-04's AC4 is met: twelve months of real EPEX BE day-ahead prices are now in
the repository, and the strategy has been replayed against them (DATA-02).**
AC4 asked for "net-positive across 12 months of replayed prices". OPT-04 shipped
it as NOT MET and replayed a synthetic year instead, because `scenarios/` held
only P1 meter captures. That was the right call at the time and the wrong
diagnosis of why: the criterion was being carried alongside a 30-day *meter*
criterion as though both needed waiting, and EPEX BE day-ahead is **public data,
not a capture**. It needed a fetch, not a month.
`scenarios/epex-be-dayahead-2025-09-01_2026-09-01.json` — **8760 hourly points**,
2025-09-01 to 2026-09-01 Brussels-local, in §9.3's canonical three-field shape.
`scenarios/fetch_epex_be.py` produced it and `--verify` re-derives every claim it
makes about itself without touching the network.
**Licensing was checked before anything was committed.** The source is
Fraunhofer ISE's Energy-Charts API, which publishes the **BE** zone under
**CC BY 4.0 from Bundesnetzagentur | SMARD.de** and other zones under
private-use-only terms — so this zone is redistributable with attribution and the
data is committed rather than left as a script plus a checksum. The fetcher
**refuses to write a file** whose per-response `license_info` stops saying
CC BY 4.0. Cross-checked against the upstream: SMARD's own `4996/DE` series
matched this feed on all 96 quarter-hours of 2026-04-10, maximum difference
0.00 EUR/MWh.
**The figures are raw wholesale and the file says so.** `"all_in": false`, with
the markup/tax assumption carried in `provenance.all_in_assumption` as *numbers
to apply* rather than numbers already applied — TARIFF-02's double-counting trap
in data form. `test_arbitrage.py` section 7B applies it in one visible place and
**asserts arithmetically** that its two additive terms cancel out of every daily
spread, so the trade counts depend on the VAT multiplier alone and not on the
guessed markup. The same replay is run on raw wholesale as well, so how much of
the answer rests on the guess is reported (110 trades all-in, 88 raw).
**DST is handled by construction, not by a special case.** The fetcher iterates
in UTC and renders through `price.brussels()`, so **2025-10-26 comes out 25
points and 2026-03-29 comes out 23** — verified by counting per Brussels day
rather than asserting 24, and by checking that October's 02:00 appears twice at
`+02:00` and `+01:00` with two different prices while March's 02:00 does not
appear at all. The window is bounded at *local* midnight: bounding it at 00:00
UTC produced a 22-hour first day, and a 24-hour day silently missing from the
end, both of which the point-and-gap accounting caught.
**The result, at the shipped default, whatever it is.** At
`cycle_cost_threshold_eur` **0.10** widened by FCST-03's no-weather **1.5×** to a
required 0.1500 EUR/kWh, the real year completes **110 round trips** and is
**net-positive at +24.88 EUR** of benefit net of the cycle cost (2 kWh a trade).
Unwidened it completes 173. **Nothing was tuned:** the shipped default and the
2.5/1.5 factors are untouched, and every figure comes out of the same `decide()`.
**What the product owner's open decision now has under it.** The four
independent reports that a static day/night spread of ~0.0840 does not clear
0.10 **all stand** — a static-tariff site still never trades. What was missing
was the other half: a *dynamic* site's daily spread clears the widened
requirement on **121 of 365 days**, median daily all-in spread **0.1130**. The
threshold at which a median day would clear is **0.0754**, and at that value the
same year completes 156 trips for +35.42 EUR. Both numbers are reported; neither
is applied.
**And the synthetic year stays, because the real one cannot grade §17.** Not one
of the 365 real days has a negative *dearest* hour — 372 negative wholesale hours
and 0 days where the strategy would have sold into one — so "0 discharges into a
negative-price hour" over real prices is a count of something that could not
happen, and deleting the guard would not change it. Section 7B asserts that
degeneracy instead of quietly reporting the zero; section 7's generator is still
the only thing here that puts a negative price where the strategy would sell.
**Size, measured rather than assumed.** Plain JSON: 893 KB in the working tree
but **74.6 KB as a git object**, against 56.5 KB for a committed `.json.gz`. Git
already compresses blobs, so gzipping saves 18 KB and costs a reviewer the
ability to read the provenance block — including the licence claim they are
meant to check — in the diff. Plain, and `load()` still accepts `.gz` for a
future dataset that earns it.
**Nothing is wired into the add-on.** `generic_dayahead` remains the live fetch
path; nothing under `app/` imports the script, and the dataset is for replay.
**The capacity-peak guard's forced discharge now has an EMS-side energy floor
(SAFETY-24).** SAFETY-07 can command a discharge to hold a billed quarter down,
and nothing below it consulted the state of charge — `soc_floor` belongs to the
maintenance cycle, and freeze.py already recorded that for the grid-following
loop "the BMS is the sole energy backstop". On the 2026-08-20 replay the guard
asks for **878 Wh in a day**. Relying on the BMS is not absurd — it is a real
floor and it works — but it is the pack's protection rather than the operator's,
and a month of aggressive quarters leaves the battery repeatedly at that cut-out
with no EMS-side record of why.
`decide()` now takes `soc` and `soc_floor`, both **required** and neither
defaulted. Above the floor nothing changes. At or below it the guard files a
**0 W** floor instead of a sized one — one-directional, so charging stays blocked
and no discharge is capped — and reports `floor_limited` with the reason it
bound. It is not a trip: every stop in §11.2's table still outranks the claim,
and the floor can never be negative, so §9.1's "discharge and export are never
capped by the peak guard" survives as a property of the object (swept: 0 negative
floors across 1296 combinations of load, elapsed, battery, SoC and floor).
**Failing to shave is not silent.** A floor-limited cycle raises one notification
per episode (`goodwe_peak_soc_floor`), distinct from the saturation alarm because
the two ask the operator for opposite things, and renders its own status row.
An **unreadable SoC is floor-limited, not a full battery** — a full battery is
exactly the value that would disarm the floor, and this project has shipped that
defaulting once before (`| float(100)` in the stage-5 loop).
**Verified by replay, not by argument.** The 2026-08-20 capture is driven against
a modelled pack sized from the day's own measured appetite, so it genuinely
exhausts: without the floor it reaches the BMS cut-out and sits there for 1227
guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The
energy withheld equals the energy between the two floors to within one cycle at
the rail. The honest half is reported too — the worst quarter goes from 4338 W to
5030 W, because a floor-limited guard shaves less on purpose.
**Two new options, mirrored once.** `peak_soc_floor` (20) and `bms_soc_floor`
(10), whose defaults live in `peakguard.PEAK_OPTION_DEFAULTS` — TARIFF-02's
pattern — imported by `validate.py` and `main.py` rather than typed as literals,
and pinned to `config.yaml` by a check that reads the file. CFG-03 still tracks
three places. `validate.py` rule 8 refuses `peak_soc_floor <= bms_soc_floor` at
startup, in CFG-01/MAINT-04's manner. `maintenance_soc_floor` is deliberately
**not** reused and deliberately **not** renamed: it is a drain target, not a
spend limit, and the rename is recorded as a decision to revisit if a third
floor ever appears.
**Price arbitrage - the first strategy that trades on a price (OPT-04).**
Four cards built the price stack and nothing traded on it. `app/arbitrage.py`
is §10.1's priority 3: charge in the cheapest published hour, discharge in the
dearest, **and only when the spread covers `cycle_cost_threshold_eur`**. It is
off by default (`arbitrage_enabled: false`) - it is the first strategy in this
add-on that commands the battery against the meter on purpose.
**It consumes the four cards rather than re-deciding anything.** Whether a
trade is permitted at all, on which two hours, and against which widened
threshold is `TariffPlan.arbitrage()`'s answer (TARIFF-03), and that answer
cannot be obtained without first stating whether the peak guard is holding. So
§9.1's "give peak shaving priority over arbitrage" is honoured
**structurally**: there is no code path to a tradeable spread that skips the
question. And because the claim is filed at `P_ARBITRAGE` (30) while peakguard
files its floor at `P_PEAK` (50), the arbiter clamps an arbitrage charge even
if that interlock were bypassed. Two independent mechanisms, neither
re-implemented here.
**A guard that never ran is not a guard that is clear.** On `meter_source: off`
there is no telegram stream, so the peak guard is never evaluated. That third
state is carried as `None` and it **refuses**: charging at the rail creates the
billed quantity while nothing measures the quarter it lands in. `validate.py`
refuses that configuration at startup too, along with arbitrage armed on a site
with no price source at all, and a SoC floor at or above the ceiling.
**§17: never export into a negative price.** The guard is on the hour being
sold into, not on the spread - a day priced -0.30 ... -0.05 has a 0.25 EUR/kWh
spread that clears any shipped threshold and a dearest hour that is still
negative. Buying in a negative hour stays allowed; being paid to charge is the
right trade.
**Prices are fetched in the background, never on the tick.** `resolve_tariff()`
may open an HTTPS connection, and the control tick is what feeds the ESP32
heartbeat - a supplier's TLS timeout there is a supplier able to push the
add-on towards layer 1's 30 s veto. A separate task refreshes every 30 minutes;
the tick reads an attribute and an age bound, and a plan whose refresh has
stopped answering retires rather than standing as if it were current.
**What is honestly not measured.** The acceptance criterion asked for
net-positivity across 12 months of *replayed* prices. This repository holds no
price history - `scenarios/` is P1 meter data - so that criterion is **not met,
and is not restated**. What `test_arbitrage.py` section 7 does instead is
replay a synthetic year and assert the property that does not need real prices:
every executed round trip cleared its own widened requirement, on all of them,
across a year containing 522 hours priced below zero and zero discharges into
one. And at the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night
site **does not trade at all** - reported, not tuned. That number, and the
widening factors beside it, are one product decision and it is open.
**Provisional prices, and what may not rest on one (FCST-03).**
Day-ahead prices arrive once a day, around 14–16 h CET. Until they do, §8.3 says
the unknown hours use yesterday's profile and are flagged `provisional: true` —
and **no irreversible action may be taken on a provisional price.**
`app/price_forecast.py` does the fill, and the whole module is shaped by one
defect: a carried-forward point and a published one are the same three fields
and a bool, so the natural line `PricePoint(..., provisional=y.provisional)`
copies yesterday's `False` onto today's guess and produces stale data presented
as current. That is the meter-side SAFETY-18 failure one layer up — the reading
arrives on time, looks fresh, and is wrong.
**So the flag is never passed through.** Carried points are built by a
constructor that has no `provisional` parameter at all (the literal `True` lives
in one place, and there is nothing for a caller to forget or copy); published
points are passed through **by identity**, so there is no copy that could lose a
flag; and an audit re-derives the expected flag for every emitted point from the
set of hours the feed actually published and *raises* rather than repairing one.
A published point may itself be provisional — `eneco_eddy`'s current-quarter
imbalance print is — so the audit's rule for a published hour is "whatever the
feed said", never "False".
**The fill keys on the Brussels wall clock, not on `t − 24 h`.** A 23-hour day
has no 02:00 and a 25-hour day has two, so a fixed 24-hour subtraction shifts
every carried hour by one across a transition and misprices a whole day with
nothing to show for it. Re-running the fill after a late publication replaces
the carried hours with the real print: the number and the flag move together
because the series is rebuilt, never patched.
**Arbitrage now drops provisional hours instead of refusing the whole day.**
The refusal that read "the plan is provisional" would have switched arbitrage off
every morning on every dynamic site once carry-forward filling landed — a
part-firm series is the *normal* state before the print. What §8.3 forbids is
acting **on** a provisional price, so the provisional hours are dropped and the
spread, the cheapest hour and the dearest hour are computed over the published
ones only; fewer than two firm hours is still a refusal, which is where the
§11.4 fallback (wholly provisional) lands. The permitting variant now refuses to
be *constructed* around a provisional point at all. The cheap-window flag became
per-hour for the same reason, so a site's maintenance charge floor does not go
dark every morning.
**Real-time / imbalance products get measurably wider thresholds.** One widening
path, driven by a set of active uncertainty sources: the product's own (§9.2 row
2, derived from the declared `PriceProduct` and never from a plugin id) and
§11.4's "no weather data" row, which the spec widens by a different trigger and
which is the same mechanism. The combination rule is `max`, not a product — two
independent doubts must not compound into a threshold nothing can clear. The
caller supplies the base round-trip cost threshold, so OPT-04 still owns what
the number *is*; this owns only how much wider it gets and why, and the allowed
verdict reports the threshold it cleared.
**An irreversible action is a value, not a judgement call at each call site.**
`Irreversible` carries the hours an action commits — the maintenance drain's
window is derived from `maintenance.DRAIN_TIMEOUT_MIN` rather than typed twice —
and the gate looks at exactly those hours, blocking on a provisional one, on an
hour the series does not cover, and on no price data at all: three different
reasons, none of them an answer. ⚠️ It is the entry point for a start being
*justified by a price*; the calendar path main.py runs today is untouched,
because a gate that blocked on `no_data` would stop the monthly cycle on every
default install — §12's opening sentence.
**Zero new options.** No `config.yaml` key, no new defaults dict, no `o.get(k,
X)` in main.py: the module reads no options at all, so CFG-03 did not widen.
**The grid-following loop is now capped against the month's billed capacity peak
(SAFETY-07).** New `app/peakguard.py`. TARIFF-01 measured the quarter; this is the
consumer it was built for. Every cycle the guard projects the open quarter,
+92 -10
View File
@@ -420,7 +420,7 @@ help - see "Why the tuning is what it is".
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor |
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
| `maintenance_soc_target` | 99 | Charge target |
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
@@ -430,6 +430,8 @@ help - see "Why the tuning is what it is".
|---|---|
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
| `peak_cap_w` | The site's capacity-tariff target |
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** Not optional and not the same thing as `maintenance_soc_floor` — see below |
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start |
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
On a capacity-tariff site the maintenance charge is capped by the headroom left
@@ -530,11 +532,46 @@ 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.
**No new option.** The ceiling is `peak_cap_w`, which already shipped. The
**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
rating — every one of them a key that already exists.
#### The energy floor under the forced discharge (`peak_soc_floor`)
The guard above can **command a discharge**, and until SAFETY-24 nothing in the
add-on bounded how far down it took the pack — the BMS was the only backstop.
On the 2026-08-20 replay the guard asks for **878 Wh in a day**.
That is not absurd: the BMS floor is real and it works. But it is *the pack's*
protection, not *yours*. A month of aggressive quarters can leave the battery
repeatedly parked at that cut-out with no record on this side of why, and the
first symptom — a battery that is never available when you want it — looks
exactly like the SoC drift the monthly maintenance cycle exists to correct.
- **`peak_soc_floor` (20 %)** is where the forced discharge stops. Below it the
guard files a **0 W** floor instead of a sized one: charging stays blocked
(that can only ever lower your offtake), and no further discharge is asked for.
- **It does not stop the ordinary self-consumption loop.** That loop still has no
SoC guard of its own. This bounds what the *peak guard* spends for money.
- **When it binds you are told**, once per episode, with notification
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
the only thing left that changes the bill. Failing to shave is never silent —
the euro figure is the whole point of the guard.
- **An unreadable SoC is treated as floor-limited, not as a full battery.** A
full battery is exactly the value that would disarm this floor.
- **`bms_soc_floor` (10 %)** is the pack's own cut-out — register `0x0560` on the
reference GW5000S-BP, consistent with DoD 90. It exists so the add-on can
**refuse to start** when `peak_soc_floor` is at or below it: such a floor can
never be the thing that stops the discharge, and a guard that advertises a
backstop it does not have is the one thing this project will not ship. Equal is
refused too — equal buys zero margin.
Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
smaller, or your quarters are worse, raise it.
### The winding-loop alarm (the output clamp)
Every setpoint this add-on writes is clamped to `max_w` before it leaves the
@@ -644,15 +681,16 @@ floor of 60 s that figure is 60 W and only the margin is left.
### Energy price providers
> **No *fetched* price moves energy in 0.5.x.** This release ships the
> *plumbing*: the five provider plugins, the canonical all-in format they emit,
> and the config that selects one. Turning a provider on fetches, validates and
> publishes prices and changes no charging decision; the arbitrage strategy that
> would use it is a later ticket.
> **A fetched price moves energy only when you switch arbitrage on.** This
> release ships the five provider plugins, the canonical all-in format they
> emit, the config that selects one, and — new in OPT-04 — a price-arbitrage
> strategy that trades on them. With `arbitrage_enabled: false`, which is the
> shipped default, turning a provider on still fetches, validates and publishes
> prices and changes no charging decision.
>
> The one exception is `static_profile` below. A static day/night tariff needs
> no network, so its cheap window is computed on every cycle and does reach the
> maintenance cycle's grid top-up.
> `static_profile` below needs no network at all, so its cheap window is
> computed on every cycle and reaches the maintenance cycle's grid top-up
> whether or not arbitrage is armed.
| option | meaning |
|---|---|
@@ -804,6 +842,50 @@ to, and the site simply has no tariff until the feed returns. It is not required
— the add-on warns rather than refusing — but filling it in is what makes that
fallback work.
### Price arbitrage (`arbitrage_enabled`)
§10.1 priority 3: charge in the cheapest published hour, discharge in the
dearest, **and only when the spread covers what the round trip costs.** Off by
default — this is the first strategy in the add-on that commands the battery
*against* the meter on purpose, so it is armed deliberately or not at all.
| option | meaning |
|---|---|
| `arbitrage_enabled` | `false` by default. Arms the strategy |
| `cycle_cost_threshold_eur` | What one full round trip costs per kWh — pack wear plus conversion losses. A spread below this is not traded |
| `arbitrage_soc_floor` | The reserve arbitrage may not spend, % |
| `arbitrage_soc_ceiling` | The headroom it may not overfill, % |
**What it needs, and what the add-on refuses.** Arbitrage requires a price
source (`price_provider` or `static_profile`) and a P1 meter (`meter_source`).
Without a price source there is nothing to tell cheap from expensive; without
P1 the capacity-tariff peak guard never runs, and §9.1 puts peak shaving above
arbitrage — a charge that nothing measures is exactly what that guard exists to
prevent. Both combinations are **refused at startup** rather than left looking
armed.
**Peak shaving always wins.** The strategy cannot obtain a spread at all
without first answering whether the peak guard is holding, and even if it
could, the guard's floor is filed at a higher arbiter priority and clamps an
arbitrage charge. Discharging is never capped by it — only offtake is billed.
**It never exports into a negative price** (§17). A negative hour is the best
possible hour to *charge* in and that is still allowed; selling into one gives
energy away and pays for the privilege.
**At the shipped threshold a static day/night site does not trade.** The
realistic Belgian day/night spread is about 0.08 EUR/kWh and the default
threshold is 0.10, so the honest answer on such a site is "no opportunity", and
the status page says so with the numbers. Whether 0.10 is the right default is
a product decision, not a bug: lower it only if you know what a cycle costs
your pack.
**The prices are fetched in the background**, never on the control tick — a
supplier's TLS timeout on that path would be a supplier able to push the add-on
towards the firmware's 30 s failsafe. The plan refreshes every 30 minutes and
retires itself if the refresh stops answering, at which point arbitrage stops
with it.
### Site
| option | meaning |
+9
View File
@@ -36,6 +36,15 @@ P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
# clamp an arbitrage charge, which is §9.1's "give peak shaving priority over
# arbitrage whenever the two conflict" expressed as arithmetic rather than as a
# rule somebody has to remember. P_MAINT_SHAPE (40) is above it so a speculative
# spread cannot override the pack's balancing cycle. P_LOOP (10) is below it
# because trading against the meter is the entire point - the grid-following
# loop would otherwise cancel every charge the moment it created import.
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
P_LOOP = 10 # ordinary grid-following
SET = "set"
+429
View File
@@ -0,0 +1,429 @@
"""OPT-04 - §10.1 priority 3: charge cheap, discharge expensive, and only when
the round trip is worth more than it costs.
WHERE THIS LIVES, AND WHY IT IS NOT WHERE THE CARD SAYS. The functional notes
on the Trello card reference specs.md §2.2's `ems/optimizer/...` layout. That
module layout does not exist and is not being built; the product owner's
decision of 2026-08-30 is that everything ships in `goodwe_controller/app/`,
beside price.py, static_tariff.py, peakguard.py and arbiter.py. The BEHAVIOUR
§10.1 / §9.1 / §8.3 / §17 describes is unchanged and still authoritative.
WHAT THIS MODULE IS, AND - MORE IMPORTANTLY - WHAT IT IS NOT
============================================================
Four cards landed before this one and this one CONSUMES all four. Almost every
question an arbitrage strategy has to answer is already answered somewhere
else, by an object built for it, and the single biggest way to get this ticket
wrong is to answer any of them again here:
price.py (TARIFF-02) what a price IS, all-in, and what a product is.
`Uncertainty`, `WIDEN_FACTOR`, `required_spread`.
static_tariff.py (TARIFF-03) `TariffPlan.arbitrage()` - THE VERDICT. Whether
a trade is permitted at all, on which two hours,
against which widened threshold. Five refusal
reasons live there, not here.
price_forecast.py (FCST-03) provisional carrying, and the rule that no
irreversible action rests on a provisional
price (§8.3).
peakguard.py (SAFETY-07) the capacity-tariff floor, filed as a `lo`
LIMIT at P_PEAK on the arbiter.
So what is left for this file is genuinely small, and that is the point:
* WHICH HOUR IS IT NOW - is this instant the buy hour, the sell hour, or
neither.
* MAY THE BATTERY DO IT - state of charge, and §17's negative-price rule.
* HOW HARD - one number, filed as one arbiter claim.
Everything else is delegated, and delegated by CONSTRUCTION rather than by
convention: `TariffPlan.arbitrage()` will not hand over a spread at all unless
it is told whether the peak guard is holding, what a cycle costs, and what is
uncertain. There is no code path here that can reach a tradeable number without
having answered §10.1's priority ordering first.
AC3 - PEAK SHAVING WINS, AND IT WINS TWICE
==========================================
§9.1: "Give peak shaving priority over arbitrage whenever the two conflict."
That is honoured through the two mechanisms that already exist, and NEITHER of
them is re-implemented here:
1. THE INTERLOCK. `plan.arbitrage(peak_guard_active=...)` is keyword-only and
REQUIRED. When the guard is engaged the verdict is a refusal carrying the
§10.1 wording, so `decide()` below never sees a spread at all. It is not
possible to write a caller here that skips the question - the argument has
no default and the spread is unreachable without it.
2. THE ARBITER. Even if (1) were somehow bypassed, `claim()` files a SET at
P_ARBITRAGE (30) and peakguard files a LIMIT at P_PEAK (50). arbiter.py
clause 2 applies every limit whose priority is >= the winning set's, most
restrictive wins - so the peak floor CLAMPS an arbitrage charge whatever
this module thinks. 50 >= 30 is what makes that true, and it is why the
priority below sits under P_PEAK rather than beside it.
Two independent mechanisms for one rule is deliberate. The first is the one
that reports honestly; the second is the one that holds when the first is
wrong.
⚠️ AND THE GUARD MUST BE ABLE TO SEE. On `meter_source: off` there is no
telegram stream, so peakguard.decide() is never run and `peak_guard_active` is
not FALSE - it is UNKNOWN. Charging a battery at max_w creates offtake, offtake
is the billed quantity (§9.1), and doing it deliberately while nothing measures
the quarter is the exact shape of failure §9.1 exists to prevent. So the third
state is carried explicitly as `None` and it REFUSES. A failed read is not an
observation, and "the guard did not run" is not "the guard is clear".
§17's NEGATIVE-PRICE CASE, STATED ONCE
======================================
"Negative-price hours - arbitrage must not export." A negative price means the
market pays the site to consume. Discharging the battery into such an hour
gives away stored energy AND forgoes being paid to take more in; if the house
load is covered it becomes export, which at a negative price is paid-for
disposal. The buy side is untouched - a negative hour is the best possible hour
to charge in, and this file's guard is deliberately asymmetric for that reason.
⚠️ THE GUARD IS ON THE HOUR BEING SOLD INTO, NOT ON THE SPREAD. A series whose
prices are -0.30 and -0.05 has a 0.25 EUR/kWh spread that clears any shipped
threshold, and its DEAREST hour is still negative. A guard written as
"spread > 0" or "the prices are not all negative" passes that case and exports.
WHAT `cycle_cost_threshold_eur` IS, AND WHAT THIS FILE REFUSES TO DO WITH IT
===========================================================================
§10.1 gates arbitrage on "expected round-trip benefit exceeds
`cycle_cost_threshold_eur`". It ships at 0.10 EUR/kWh. On realistic Belgian
day/night numbers the static spread is about 0.08 EUR/kWh, and FCST-03's
widening factors (2.5 real-time, 1.5 no-weather) are honest policy defaults
with nothing to derive them from.
⚠️ SO A STATIC-TARIFF SITE DOES NOT TRADE AT SHIPPED DEFAULTS, AND THAT IS
REPORTED RATHER THAN TUNED AWAY. The threshold is a parameter here, the
behaviour is correct at any value of it, and no number in this file was chosen
to make a trade happen. Whether 0.10 is the right shipped default - and whether
2.5/1.5 are - is one product decision and it belongs to the owner, not to this
card. test_arbitrage.py section 7 reports what the shipped default does over a
replayed year rather than adjusting anything so that it looks better.
THE ACCOUNTING BASIS, AND THE TERM THAT IS DELIBERATELY ABSENT
=============================================================
The card's technical notes ask for `benefit = value_out(d) - cost_in(c)/η`, and
then say of the round-trip efficiency: "η is NOT a §14 key - it needs one, or
must be derived from measured charge/discharge energy in the state store. DO
NOT HARDCODE IT." Both halves are honoured by not having the term at all:
* THE SELL SIDE IS PRICED AS DISPLACED IMPORT, NOT AS EXPORT. The two hours
compared are both all-in CONSUMPTION prices, which is the basis the notes
require ("credit displacement at all-in import prices, not at export price,
or the number flatters itself"). `export_price_eur_kwh` is therefore not
read here at all - §9.3's near-zero Belgian injection price is precisely
why the economically sound trade on this market is charge-cheap-to-
displace-expensive-import, and §17's guard below is the same fact stated as
a rule.
* AND THERE IS NO η, BECAUSE THERE IS NOTHING HONEST TO PUT IN IT. No config
key exists, the state store carries no measured charge/discharge energy
pair to derive one from, and a hardcoded 0.85 is exactly what the notes
forbid - it would also DOUBLE-COUNT, because `cycle_cost_threshold_eur` is
already defined as what a round trip costs per kWh and conversion loss is
part of that cost. When a measured η exists it belongs beside the threshold
as a second term, and this file's arithmetic is one subtraction, in one
place, ready for it.
HYSTERESIS (§10.2)
==================
The switching decision is "which hour is it", and an hour is 60 minutes wide,
so the action cannot chatter within one. The other two terms are monotone
inside an hour in the only direction that matters: a charge raises SoC towards
the ceiling and a discharge lowers it towards the floor, so each can stop once
and cannot restart in the same hour. No timer is needed and none is invented.
"""
import logging
import math
from dataclasses import dataclass
from datetime import datetime
from .arbiter import P_ARBITRAGE, Claim
from .price import Absence, price_option
_LOG = logging.getLogger("goodwe.arbitrage")
SOURCE = "arbitrage"
# §10.2 reason codes. Written out because the action log is a user-facing
# artefact and a code assembled from an f-string cannot be grepped for.
CHARGE = "ARBITRAGE_CHARGE"
DISCHARGE = "ARBITRAGE_DISCHARGE"
# Why nothing was done. These are the reasons this MODULE owns; the five
# refusals that belong to the verdict itself are carried through verbatim from
# static_tariff.py rather than restated, so a refusal's wording has exactly one
# author.
IDLE_NO_PLAN = "no tariff plan has been resolved yet"
IDLE_NOT_COVERED = (
"NOT COVERED: meter_source is off, so the capacity-tariff peak guard never "
"runs and nothing measures the quarter this charge would land in. §9.1 puts "
"peak shaving above arbitrage, and an unmeasured peak cannot be shaved - "
"so the honest answer is that arbitrage is not covered on this install, not "
"that the guard is clear")
IDLE_NO_SOC = (
"state of charge is not being read, and a battery whose fill nobody knows "
"cannot be told to empty or to fill")
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
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 "
"negative price")
@dataclass(frozen=True)
class ArbitrageDecision:
"""What the strategy concluded this cycle, with what it concluded it from.
⚠️ NO FIELD HAS A DEFAULT, and that is TEST-14's rule rather than a style
choice: an outcome type's required field quietly gaining a default is a
mutation that survived review in three files in this repository. The
signature is scanned in test_price_forecast.py alongside `Gate`'s and
`Arbitrage`'s constructors, so a default added here later goes red without
anybody remembering to write a case for it.
`action` is None whenever nothing is to be done, and `power_w` is then None
too - not 0.0. Zero watts is a COMMAND (it parks the battery); "no claim"
is the absence of one, and the arbiter must be free to let the loop win.
"""
action: str | None # CHARGE | DISCHARGE | None
power_w: float | None # signed: negative charges, positive discharges
reason: str
spread_eur_kwh: float | None
required_eur_kwh: float | None
price_now_eur_kwh: float | None
@property
def trading(self) -> bool:
return self.action is not None
def explain(self) -> str:
if not self.trading:
return f"arbitrage idle - {self.reason}"
return self.reason
def _idle(reason, *, spread=None, required=None, price=None) -> ArbitrageDecision:
if not reason or not isinstance(reason, str):
raise ValueError(
"an idle arbitrage decision needs a reason a human can act on - "
"§10.2 requires every plan change to carry one, and 'arbitrage did "
"nothing' with no reason is indistinguishable from a bug.")
return ArbitrageDecision(action=None, power_w=None, reason=reason,
spread_eur_kwh=spread, required_eur_kwh=required,
price_now_eur_kwh=price)
def enabled(opts: dict) -> bool:
"""Is the strategy switched on at all? Off in the shipped configuration.
⚠️ OFF BY DEFAULT ON PURPOSE. Every other card in this repository ships its
machinery inert and lets an operator arm it; this one MOVES ENERGY against
the meter on a real battery, so it is the last one that should be an
exception to that.
"""
v = price_option(opts, "arbitrage_enabled")
return v is True or str(v).strip().lower() in ("true", "yes", "on", "1")
def threshold_eur_kwh(opts: dict) -> float:
"""§10.1's `cycle_cost_threshold_eur`, from the one place option defaults live."""
v = float(price_option(opts, "cycle_cost_threshold_eur"))
if not math.isfinite(v) or v < 0:
raise ValueError(
f"cycle_cost_threshold_eur is {v!r}. It is what a round trip COSTS, "
f"in EUR/kWh, so it is non-negative and finite - a negative "
f"threshold is one every spread clears, which is arbitrage with the "
f"brake removed.")
return v
def soc_bounds(opts: dict) -> tuple:
"""(floor, ceiling) in percent. The reserve arbitrage may not spend."""
return (float(price_option(opts, "arbitrage_soc_floor")),
float(price_option(opts, "arbitrage_soc_ceiling")))
def guard_state(peak_decision):
"""The peak guard's answer as the three-state `decide()` takes: T / F / None.
THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason
`peakguard.shaping_suspended()`, `watchdog.loop_is_following()` 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, and the copy that
drifts is always the grading one - this repository has three separate
records of exactly that, one of which left every suite green while a
default install parked at 0 W in the quarter that sets the month's bill.
⚠️ `None` IS NOT `False` AND COLLAPSING THE TWO IS THE WHOLE HAZARD. On
`meter_source: off` main.py never runs peakguard.decide() - there is no
telegram stream, so there is no open quarter to project - and the decision
it hands here is None. That is "the guard never ran", not "the guard is
clear", and answering False would arm a strategy whose charging creates the
billed quantity (§9.1) while nothing measures the quarter it lands in.
"""
if peak_decision is None:
return None
return bool(peak_decision.engaged)
def _point_at(plan, when: datetime):
"""The price point covering `when`, or None when the series does not cover it.
⚠️ None IS NOT A PRICE AND IS NOT ZERO. `TariffPlan` raises `Absence` here
rather than answering 0.0, for price.py's reason - "no data" becoming "free"
is the defect the whole outcome-type family exists to remove - and this
turns that into "do nothing", never into "it is cheap".
"""
try:
return plan.read.match(series=lambda r: r.at(when),
no_data=lambda _r: None)
except Absence:
return None
def decide(plan, now: datetime, *, peak_guard_active, 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.
⚠️ `peak_guard_active` IS A THREE-STATE ARGUMENT AND ALL THREE ARE REAL.
True (the guard is holding), False (it ran and is clear), and None (it never
ran, because nothing measures this install's quarter). See the module note:
None refuses, because the alternative is a strategy that creates the billed
quantity while the thing that would have limited it is switched off.
⚠️ 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
first time it is wrong the battery is at 0 %.
"""
if peak_guard_active is None:
return _idle(IDLE_NOT_COVERED)
if plan is None:
return _idle(IDLE_NO_PLAN)
verdict = plan.arbitrage(peak_guard_active=peak_guard_active,
min_spread_eur_kwh=min_spread_eur_kwh,
uncertainty=uncertainty)
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))
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
"""The permitted case: which hour is this, and may the pack do it."""
here = _point_at(plan, now)
if here is None:
return _idle(
f"the series carries a tradeable spread but no price for this "
f"instant ({now.isoformat()}), which is §11.4 for this hour rather "
f"than a reason to reach for a neighbouring one",
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh)
price = here.eur_per_kwh
buying = here.start == v.cheapest.start
selling = here.start == v.dearest.start
if not (buying or selling):
return _idle(IDLE_OFF_PEAK_HOUR, spread=v.spread_eur_kwh,
required=v.required_spread_eur_kwh, price=price)
# ⚠️ BELT AND BRACES ON §8.3, AND IT IS NOT DEAD CODE. `_firm_or_die` makes
# the two points on the verdict firm at construction, but the point matched
# HERE is whatever `at()` returned for this instant, and the identity of the
# two rests on the series being the one the verdict was built from. An hour
# that is provisional is one nobody published a price for, and no
# irreversible action may rest on it.
if here.provisional:
return _idle(
f"§8.3: the price for this hour ({here.start.isoformat()}) is "
f"CARRIED FORWARD, not published, and no irreversible action may be "
f"taken on a provisional price",
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
price=price)
if soc is None:
return _idle(IDLE_NO_SOC, spread=v.spread_eur_kwh,
required=v.required_spread_eur_kwh, price=price)
common = dict(spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
price=price)
if selling:
# §17. THE NAMED REGRESSION CASE. Checked BEFORE the SoC question, so a
# negative price reports the reason that actually forbids the trade
# rather than one that happens to fire first on some other site.
if price < 0:
return _idle(f"{NEGATIVE_PRICE} - this hour is "
f"{price:+.4f} EUR/kWh", **common)
if soc <= floor_pct:
return _idle(
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
f"arbitrage may not spend (§10.1 priority 4)", **common)
return ArbitrageDecision(
action=DISCHARGE, power_w=abs(float(discharge_w)),
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} above the "
f"cheapest and clear of the {v.required_spread_eur_kwh:.4f} "
f"this cycle had to cover; discharging "
f"{abs(float(discharge_w)):.0f} W from {soc:g} %"),
spread_eur_kwh=v.spread_eur_kwh,
required_eur_kwh=v.required_spread_eur_kwh,
price_now_eur_kwh=price)
if soc >= ceiling_pct:
return _idle(
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
f"nowhere to put the cheap energy", **common)
return ArbitrageDecision(
action=CHARGE, power_w=-abs(float(charge_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} %"),
spread_eur_kwh=v.spread_eur_kwh,
required_eur_kwh=v.required_spread_eur_kwh,
price_now_eur_kwh=price)
def claim(decision: ArbitrageDecision) -> Claim | None:
"""The decision as one arbiter claim, or None when nothing is to be traded.
A SET at P_ARBITRAGE, and the priority is the whole §10.1 ordering:
P_PEAK 50 > this, so peakguard's `lo` floor CLAMPS an arbitrage
charge (arbiter clause 2 applies every limit at or
above the winner's priority). AC3, mechanically.
P_MAINT_SHAPE 40 > this, so a maintenance charge window's `hi` shaping is
not overridden by a price opportunity. §12.4's "money
outranks the schedule" is about the PEAK guard, which
is measured and billed; a speculative spread is not
that, and letting it out-rank the pack's balancing
cycle would be this card quietly rewriting §12.
P_LOOP 10 < this, because trading against the meter is the point:
the grid-following loop would otherwise cancel every
charge the moment it created import.
⚠️ NONE MEANS NO CLAIM, NOT A 0 W CLAIM. Filing `Claim.set(..., 0.0)` here
would out-rank the loop with a park order every cycle the strategy had
nothing to say, i.e. it would silently disable self-consumption on any site
that armed arbitrage.
"""
if not decision.trading or decision.power_w is None:
return None
return Claim.set(SOURCE, P_ARBITRAGE, float(decision.power_w),
decision.action)
+277 -7
View File
@@ -28,10 +28,11 @@ import contextlib
import json
import logging
import signal
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
import aiohttp
from . import arbitrage
from .arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
)
@@ -48,7 +49,8 @@ from .maintenance import IDLE, MaintConfig, Maintenance
from .mqtt import MqttPublisher
from . import peakguard
from .p1 import P1Ingest, build_source, is_enabled
from .static_tariff import static_status
from .price import Uncertainty, price_option
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .validate import ConfigRefused, refuse, validate_startup_config
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
@@ -57,6 +59,34 @@ from . import web
OPTIONS_PATH = "/data/options.json"
_LOG = logging.getLogger("goodwe")
# --------------------------------------------------------------------------- #
# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL
#
# `static_tariff.resolve_tariff()` may perform an HTTPS fetch. The control tick
# is what feeds the ESP32 heartbeat, and layer 1 commands 0 W if that heartbeat
# stops for ~30 s - so a supplier's TLS timeout on the tick is a SUPPLIER able
# to push this add-on into the failsafe. Every module in the price stack says
# so in its own words (static_tariff.static_plan, resolve_tariff, main's
# read_inputs) and this is the task those notes were waiting for.
#
# The tick therefore never awaits a fetch. It reads `self.tariff_plan`, an
# attribute this task assigns, and nothing else.
#
# ⚠️ THE CADENCE IS NOT A GUESS AT HOW OFTEN PRICES CHANGE. A day-ahead feed
# publishes ONCE, at 14-16 h CET, and §8.3's carried hours become firm at that
# moment; 30 minutes is short enough that the switch is picked up inside the
# hour it happens and long enough to be a polite 48 fetches a day. Nothing in
# the control law depends on it.
TARIFF_REFRESH_S = 1800.0
# ⚠️ AND THE AGE BOUND IS NOT THE SAME QUESTION. A refresh that FAILS still
# stores a plan - resolve_tariff() degrades to the §11.4 static fallback, which
# is provisional and refuses arbitrage on its own. This bound covers the other
# case: the refresh task itself dying, leaving a plan that was true hours ago
# standing as if it were current. Three missed refreshes is the threshold, for
# `p1.max_age_s`'s reason - one missed poll is not a fault, a run of them is.
# A failed read is not an observation, and neither is a stale one.
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
def load_options() -> dict:
try:
@@ -199,14 +229,25 @@ class Controller:
# the action log and the notification fire on transitions rather than
# once per cycle for a quarter of an hour.
#
# ⚠️ NO CONFIG OF ITS OWN, for the reason the clamp watchdog gives ~90
# lines up and TARIFF-01 repeats: the ceiling is `peak_cap_w`, already
# shipped and already validated, and every other term is read off
# something the arbiter or the averager already owns. CFG-03 still
# tracks three places, not four.
# ⚠️ THE CEILING HAS NO CONFIG OF ITS OWN, for the reason the clamp
# watchdog gives ~90 lines up and TARIFF-01 repeats: it is `peak_cap_w`,
# already shipped and already validated, and every other term of the
# projection is read off something the arbiter or the averager already
# owns.
#
# ⚠️ SAFETY-24'S FLOOR DOES HAVE ONE, AND IT COULD NOT NOT HAVE. How far
# down the guard may spend the pack for money is a number only the site
# knows, and refusing one set below the pack's own cut-out means knowing
# that cut-out too. So this card adds exactly two keys and reads their
# defaults from `peakguard.PEAK_OPTION_DEFAULTS` - TARIFF-02's mirror
# pattern - rather than typing literals here. THAT is what keeps CFG-03
# at three places: the divergence validate.py's rule 6 documents
# (`maintenance_charge_w` 2000 there, 2500 here) is exactly what a
# literal in this file would repeat.
self.peak_guard = None
self._peak_engaged = False
self._peak_saturated = False
self._peak_floor_limited = False
self._maint_shape_suspended = False
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
@@ -232,6 +273,16 @@ class Controller:
# window we are in and why it is or is not cheap - so "Cheap window:
# no" is an evidenced answer rather than a bare no.
self.tariff_words = "no static profile"
# OPT-04. The tariff plan the BACKGROUND task last resolved, and when.
# cycle() reads these two attributes and never awaits a fetch - see
# TARIFF_REFRESH_S. `None` means no refresh has completed yet, which is
# not "no prices" and certainly not "free": arbitrage.decide() reports
# it as its own reason and does nothing.
self.tariff_plan = None
self.tariff_at = None
self.arb = None
self._arb_action = None
self.last_write = None
self.last_write_at = None
self.last_write_ok = None
@@ -494,6 +545,17 @@ class Controller:
cap_w=float(self.o.get("peak_cap_w", 3500)),
reading=self.peak.peak(now),
achievable_w=min(max_w, abs(float(dev_top))),
# ⚠️ SAFETY-24. `self.soc` IS THE MEASURED READING AND MAY BE None,
# and it is handed over as None rather than defaulted here. An early
# draft of the stage-5 loop read SoC as `| float(100)` and thereby
# invented a full battery (NOTES.md 4.4b); a full battery is
# precisely the value that disarms this floor. decide() treats None
# as floor-limited, which is the direction that costs a shave rather
# than the pack.
soc=self.soc,
soc_floor=float(self.o.get(
"peak_soc_floor",
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])),
max_hold_s=self.p1.averager.max_hold_s,
frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None),
standing, max_w, self.freeze_wd.cfg),
@@ -514,6 +576,64 @@ class Controller:
if self.peak_guard is not None:
self.log_event(self.peak_guard.explain())
# --- OPT-04: price arbitrage (§10.1 priority 3) ----------------------
# ONE `set` claim at P_ARBITRAGE, and app/arbitrage.py carries the whole
# argument. What is decided HERE and nowhere else is what the strategy
# is allowed to see:
#
# ⚠️ IT IS PLACED AFTER THE PEAK BLOCK BECAUSE IT READS ITS CONCLUSION.
# `engaged_now` is this cycle's peak verdict, computed ~20 lines up.
# Evaluated before it, this would hand the strategy the PREVIOUS cycle's
# answer - one cycle of lag on the interlock that implements §9.1's
# "peak shaving outranks arbitrage", inside a 900 s quarter.
#
# ⚠️ AND THE THIRD STATE IS NOT A TIDINESS. Without P1 the guard is not
# CLEAR, it never RAN, and `None` says so. arbitrage.decide() refuses on
# it, because charging at max_w creates the billed quantity while
# nothing is measuring the quarter it lands in. `False` here would be
# this line asserting a measurement that was never taken - the defect
# `PeakReading` and `quarter_ceiling_w()` both exist to remove.
#
# ⚠️ THE THREE-STATE COLLAPSE LIVES IN arbitrage.guard_state(), NOT
# HERE, and that is not tidiness. It is the same lesson
# `shaping_suspended()` records: inline, this expression was reachable
# by no check, and mutating it back to a bare `engaged_now` left every
# suite in this repository green while a `meter_source: off` install
# traded blind. Read guard_state() before touching this line.
#
# ⚠️ NO FETCH ON THIS PATH. `fresh_tariff_plan()` is an attribute read
# and an age comparison; the HTTPS call lives in run_tariff_refresh().
# See TARIFF_REFRESH_S: a supplier's TLS timeout here would be a
# supplier able to push this add-on towards layer 1's 30 s veto.
#
# ⚠️ AND THE UNCERTAINTY SET IS NOT EMPTY BY DEFAULT. §11.4's
# no-weather row widens the threshold, and this add-on carries no
# weather forecast at all - §8.1's PV forecast is not built. Passing
# `frozenset()` would be main.py asserting a forecast is healthy when
# there is not one, which is exactly what static_tariff's `uncertainty`
# argument has no default in order to prevent.
self.arb = None
if arbitrage.enabled(self.o):
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
self.arb = arbitrage.decide(
self.fresh_tariff_plan(now), now,
peak_guard_active=arbitrage.guard_state(self.peak_guard),
soc=self.soc,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
charge_w=max_w, discharge_w=max_w,
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
arb_claim = arbitrage.claim(self.arb)
if arb_claim is not None:
claims.append(arb_claim)
# Edge-logged, for the peak guard's reason: the action log holds 40
# lines and a trade lasts a whole hour, so a line per cycle would
# flush the context that explains it. The transition is what an
# operator needs - "it started buying at 03:00", not 240 copies.
if self.arb.action != self._arb_action:
self._arb_action = self.arb.action
self.log_event(self.arb.explain())
# --- maintenance -----------------------------------------------------
if result.owns_setpoint:
claims.append(Claim.set("maintenance", P_MAINTENANCE,
@@ -682,6 +802,16 @@ class Controller:
# method assigned above and nothing since has touched.
await self.check_peak_saturated()
# --- SAFETY-24's alarm, and it is the OTHER way the money gets spent --
# Same boundary argument as check_peak_saturated(): it grades
# `self.peak_guard`, which cycle() assigned above and nothing since has
# touched. Kept as its own method rather than folded into that one,
# because the two states are mutually exclusive and want OPPOSITE
# operator actions - saturation says "shed load, the inverter is already
# flat out"; this says "the pack is at its floor and this quarter is not
# being shaved at all".
await self.check_peak_floor_limited()
# --- TARIFF-01, and it is LAST on purpose -----------------------------
# Banking a quarter is bookkeeping about a moment that has already
# happened; a slow SD card must never sit between the meter and the
@@ -826,6 +956,70 @@ class Controller:
f"thing that changes the bill."),
})
async def check_peak_floor_limited(self) -> None:
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
⚠️ FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT.
The guard exists for one euro figure - the capaciteitstarief bills the
month's single worst quarter - so a cycle in which it CHOSE not to shave
is a cycle in which money is being spent on purpose. Under-shaving
quietly would leave the operator with a bill and a status page full of
green.
The distinction from check_peak_saturated() is the operator's action,
which is why these are two notifications and not one:
saturated the guard asked for everything the inverter has and it
was not enough. SHED LOAD - the controller has already
done all it can, and it is still discharging.
floor-limited the guard asked for NOTHING, because the pack is at or
under the EMS floor, or its SoC could not be read.
Nothing is being discharged for this quarter at all.
They cannot both be true: peakguard.decide()'s floor-limited branch
returns before the `achievable_w` clamp that sets `saturated`.
Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape,
for FreezeWatchdog's reason: one notification per episode, not one per
cycle for a quarter of an hour.
"""
g = self.peak_guard
limited = bool(g is not None and g.floor_limited)
was, self._peak_floor_limited = self._peak_floor_limited, limited
if not limited or was:
return
# No prefix: `reason` already opens with PEAK GUARD FLOOR-LIMITED, and
# a second copy is the kind of stutter that makes a 40-line action log
# read like boilerplate. The saturated path needs its prefix because its
# reason carries the word in the middle of a sentence, not at the front.
self.log_event(g.reason)
_LOG.error(
"SAFETY-24 - %s. The controller is STILL RUNNING and charging is "
"still blocked, but it is not discharging to hold this quarter, so "
"the quarter will set or raise the month's billed capacity peak. "
"Shed load, or lower peak_soc_floor if this pack can afford it.",
g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A battery at its
# energy floor is not a saturated inverter and is not a frozen meter,
# and an operator who has to dismiss three identical-looking cards
# learns to dismiss the one that mattered.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_peak_soc_floor",
"title": "GoodWe: the capacity-peak guard has run out of battery",
"message": (
f"{g.reason}.\n\nTHIS QUARTER WILL GO OVER THE "
f"{g.ceiling_w:.0f} W ceiling and will set or raise the "
f"month's billed capaciteitstarief peak, because the peak guard "
f"is not allowed to discharge any further: {g.soc_floor_reason}."
f" This is not a controller fault - it is the EMS-side energy "
f"floor (peak_soc_floor) doing what it is set to do, and it sits "
f"deliberately above the pack's own BMS cut-out. Charging stays "
f"blocked. Shedding load is the only thing that changes the bill "
f"now; if this pack can afford a deeper discharge, lower "
f"peak_soc_floor."),
})
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
@@ -866,6 +1060,57 @@ class Controller:
})
# -- tasks ---------------------------------------------------------------
# -- OPT-04: the background tariff refresh -------------------------------
def fresh_tariff_plan(self, now):
"""The stored plan if it is still current, else None. NEVER a stale one.
⚠️ THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
`loop_is_following()`'s reason: a check has to be able to assert on the
shipped expression rather than on a transcription that drifts. Both
terms are required - a plan with no timestamp is one nothing stored, and
a timestamped plan older than TARIFF_MAX_AGE_S is a reading from a task
that has stopped answering.
"""
if self.tariff_plan is None or self.tariff_at is None:
return None
if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S:
return None
return self.tariff_plan
async def run_tariff_refresh(self) -> None:
"""Resolve the tariff off the control path, forever. NEVER on the tick.
⚠️ IT CATCHES EVERYTHING AND KEEPS GOING, for MqttPublisher's reason:
a supplier's outage, a certificate expiry, a malformed feed - all of it
is pricing, and the battery does not care. What it must never do is
raise out of here and take the task down, because then the plan freezes
at its last value and looks current forever. That is what
`fresh_tariff_plan()`'s age bound is the backstop for, and this is the
half that keeps the task alive so the bound rarely matters.
⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN. `resolve_tariff()` already has
a §11.4 answer for a dead feed - degrade to the static profile, mark it
provisional, arbitrage refuses - and that answer is better than the
absence of one. Zeroing the plan here would replace a coarse tariff with
no tariff, which is the opposite of what §11.4 asks for.
"""
horizon = float(price_option(self.o, "price_horizon_h"))
while not self.stopping:
now = datetime.now(timezone.utc)
try:
plan = await resolve_tariff(
self.o, now, now + timedelta(hours=horizon), now=now)
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
except Exception as err: # noqa: BLE001
_LOG.warning(
"tariff refresh failed (%s); keeping the plan resolved at "
"%s. It retires on its own after %.0f s and arbitrage "
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.stop_event.wait(),
timeout=TARIFF_REFRESH_S)
async def run_control(self) -> None:
entity = self.o.get("setpoint_entity", "")
if entity:
@@ -1116,6 +1361,13 @@ class Controller:
elif _pg.saturated:
out.append({"ok": False, "warn": False,
"text": "PEAK GUARD SATURATED - " + _pg.reason})
elif _pg.floor_limited:
# SAFETY-24. NOT ok, like saturation: in both states the money
# is going to be spent and a human is the only actuator left. A
# separate row from saturation because the two ask for opposite
# things - see check_peak_floor_limited().
out.append({"ok": False, "warn": False,
"text": _pg.explain()})
elif _pg.blind:
out.append({"ok": False, "warn": True, "text": _pg.explain()})
else:
@@ -1243,6 +1495,11 @@ class Controller:
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
# OPT-04. The strategy's own sentence, not a re-derivation of it -
# every refusal it can make already carries a reason a human can
# act on, and "Arbitrage: no" would throw all of them away.
("Arbitrage", "off (arbitrage_enabled)" if self.arb is None
else self.arb.explain()),
]
return {
"banner": banner, "level": level, "rows": rows, "checks": checks,
@@ -1370,6 +1627,19 @@ async def amain() -> None:
tasks = [asyncio.create_task(controller.run_control())]
# OPT-04. The tariff refresh runs as its OWN long-lived task, for the
# reason P1 ingestion does and then some: `resolve_tariff()` may open an
# HTTPS connection to a supplier, and the control tick is what feeds the
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
#
# ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on
# reads the resolved plan - `cheap` still comes from static_status() on
# the tick, which is pure - so on a default install this task would be
# network traffic in aid of an attribute nobody reads.
if arbitrage.enabled(opts):
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
# off the control loop: telegrams arrive every ~5 s and the loop would
# decimate them, so the 15-minute average - the capacity-tariff billing
+222 -14
View File
@@ -246,19 +246,100 @@ validate.py are untouched by this ticket, exactly as TARIFF-01 left them, and
mismatch trap 14's own warning box describes.
------------------------------------------------------------------------------
⚠️ WHAT THIS GUARD DOES NOT HAVE: AN ENERGY BACKSTOP. SEE SAFETY-24.
SAFETY-24: THE ENERGY BACKSTOP THIS GUARD SHIPPED WITHOUT
The graded branch can FORCE DISCHARGE, and nothing in this file or in the loop
below it consults the state of charge - `soc_floor` belongs to the maintenance
cycle, and freeze.py already records that for the grid-following loop "the BMS
is the sole energy backstop". On the 2026-08-20 replay the guard asks for 878 Wh
in a day. Against a pack sitting near 9 % that is not nothing, and the guard will
keep asking as long as the meter says the quarter is over the ceiling.
SAFETY-07 left this file saying, in its own words, that the graded branch can
FORCE DISCHARGE and that nothing below it consults the state of charge - "the
BMS is the sole energy backstop" (freeze.py). On the 2026-08-20 replay the guard
asks for 878 Wh in a day, and it keeps asking for as long as the meter says the
quarter is over the ceiling. That is the behaviour working as designed, and it
is unbounded from below by anything in the EMS.
That is deliberate for THIS card and it is filed as SAFETY-24. It is also the
reason this must not go to a client install before SAFETY-24 lands: on the
reference site the BMS is a known quantity and an operator is present, and on a
client site neither is true.
⚠️ STATE THE RISK FAIRLY, because overstating it is how a real one gets ignored.
Relying on the BMS is not absurd: it is a genuine floor and it works. But it is
THE PACK's protection, not the OPERATOR's. A month of aggressive quarters can
leave the battery repeatedly parked at that floor with no EMS-side record of
why, and the first symptom - a pack that is never available when it is wanted -
is indistinguishable from the SoC drift MAINT-01 exists to correct.
So `decide()` now takes `soc` and `soc_floor`, and `discharge_permitted()` is
the one place the question is answered. Four things about the shape of it:
1. IT IS NOT A TRIP, AND IT MUST NOT BECOME ONE. SAFETY-07's claim is a LIMIT
and every stop in 11.2's table still outranks it. This floor is about
ENERGY - how far down the guard may spend the pack for money - not about
last-resort safety. The action when it binds is therefore to stop ASKING
for more discharge, not to command anything.
2. THE ACTION IS `floor_w = 0.0`, WHICH IS THE BLIND BRANCH'S ACTION FOR THE
BLIND BRANCH'S REASON. Zero is one-directional: it forbids CHARGING, which
can only ever lower delivered offtake, and it forbids nothing else. It
cannot pull the loop off a discharge it is already doing, because a `lo`
limit structurally cannot cap discharge - the property this module's whole
shape rests on, and one a NEGATIVE floor would destroy. Nothing here can
produce a negative floor: the only two values `floor_w` ever takes are 0.0
and `min(wanted, achievable)` with `wanted > 0` already established.
⚠️ AND IT DOES NOT BOUND THE LOOP. Self-consumption may still drain the
pack past this floor, exactly as SAFETY-18 recorded. That is not fixed
here and must not be claimed: this card bounds THE PEAK GUARD's OWN forced
discharge, which is the thing SAFETY-07 introduced. A loop-side SoC guard
is a different lever with a different right value.
3. IT IS GRADED ONLY WHERE IT IS NOT DEGENERATE. The SoC question is asked
AFTER `wanted > 0` is established - i.e. only where a forced discharge is
actually being asked for. Asked earlier it would grade a quantity that
governs nothing, and would report "floor-limited" on a quiet afternoon
where the guard was never going to discharge anyway.
4. AN UNREADABLE SoC IS NOT A FULL BATTERY. `soc=None` is floor-limited, not
permitted. This is `PeakReading`'s lesson in the one place it bites
hardest: a full battery is exactly the value that would disarm this floor,
so a failed read defaulting to one would silently restore the unbounded
discharge this card exists to remove. Both new arguments are REQUIRED
keyword-only and neither has a default, which is TEST-14 read as a rule
rather than as a story: a required field that gains a default is how three
separate files in this repo shipped a quiet fallback.
AND FAILING TO SHAVE MUST NOT BE SILENT. The whole point of the guard is the
euro figure, so a floor-limited cycle carries `floor_limited=True` and main.py
raises ONE notification on the edge, saying the quarter will be exceeded - the
same shape, and the same argument, as `saturated`. The two are mutually
exclusive by construction: a floor-limited decision asks for nothing, so it
cannot have run out of inverter.
------------------------------------------------------------------------------
CFG-03: TWO NEW OPTIONS, MIRRORED ONCE, AND WHY BOTH ARE NEEDED
SAFETY-07 added none and said so. This card cannot: an EMS-side floor is a
number only the site knows, and refusing one set below the pack's own cut-out
means knowing that cut-out too.
* `peak_soc_floor` - the EMS floor. ⚠️ NOT `maintenance_soc_floor`. That is
the maintenance cycle's DRAIN TARGET: the depth a monthly recalibration
deliberately goes to, once, under supervision. This is the depth a
money-saving discharge may never reach, on any day of the month. Sharing
one key would couple two features whose right values differ and would widen
CFG-03 rather than narrow it.
* `bms_soc_floor` - the pack's own cut-out (register 0x0560 on the reference
unit, 10 %). It is here so validate.py rule 8 can REFUSE a `peak_soc_floor`
at or below it. That configuration is CFG-01 rule (a): the operator states
a floor, the BMS enforces a different one, and the status page reports an
energy backstop that can never bind.
Both defaults are mirrored ONCE, in `PEAK_OPTION_DEFAULTS` below, which is
TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern - imported by validate.py and by
main.py, pinned to config.yaml by test_peakguard.py rather than transcribed.
This module still reads no option itself: `decide()` takes both as ARGUMENTS,
makes no `.get()` call, and the mirror is a default table, not a read.
⚠️ A DECISION RECORDED, NOT A RENAME DONE. Now that a second SoC floor exists,
`maintenance_soc_floor` is the ambiguous name of the two - it reads like "the
SoC floor" and is not. Renaming it is an option migration on a live install for
a clarity gain, so the decision recorded here is: KEEP IT, and carry the
distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears -
a loop-side guard would be the third - at which point the migration buys enough
to be worth its cost.
------------------------------------------------------------------------------
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
@@ -287,8 +368,44 @@ BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
CEILING_CAP = "peak_cap_w"
CEILING_MONTH_PEAK = "the month's banked peak"
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
# for BLIND_*'s reason: the status page, the action log and the operator
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
"cycle, and an unreadable SoC is not a full battery")
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
SOURCE = "peak"
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR THIS CARD'S OPTIONS, and
# it is TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern for TARIFF-02's reason: the
# add-on already carries option defaults in three places (config.yaml's
# `options:`, config.yaml's `schema:`, and main.py's literal `.get()`
# fallbacks), and a fourth is the open ticket. validate.py and main.py both
# import THIS dict rather than typing a literal, and test_peakguard.py pins it
# to config.yaml by READING the file - so a drift goes red instead of shipping.
#
# ⚠️ NOT A READ. `decide()` takes both values as arguments and this module makes
# no `.get()` call at all; the dict is a default table for its two callers.
PEAK_OPTION_DEFAULTS = {
# The EMS-side floor on SAFETY-07's forced discharge, in percent.
#
# 20 is DERIVED from the two numbers this repository already has, not
# chosen. The reference pack is 300 Ah at 51.2 V with DoD 90 - 13.8 kWh
# usable, the figure validate.py's rule 6 note already quotes - and the
# 2026-08-20 replay models 878 Wh of guard discharge in a day, which is
# 6.4 % of that. A floor 10 points above the pack's own 10 % cut-out
# therefore leaves the guard roughly 1.6 of its worst observed days of room
# before it stops, which is the smallest margin that lets the guard do its
# job on a bad day and still never reach the BMS.
"peak_soc_floor": 20,
# The pack's OWN cut-out, in percent: what the BMS enforces regardless of
# anything here. On the reference unit this is register 0x0560, read as 10,
# and it is consistent with DoD 90 on a full pack. It exists so validate.py
# rule 8 can refuse a `peak_soc_floor` at or below it - see that rule.
"bms_soc_floor": 10,
}
@dataclass(frozen=True)
class PeakDecision:
@@ -309,9 +426,19 @@ class PeakDecision:
excess_w: float = 0.0
wanted_w: float | None = None # the floor before `achievable_w` clamped it
saturated: bool = False
# SAFETY-24. True when a forced discharge WAS wanted and the EMS-side SoC
# floor refused to size it. Mutually exclusive with `saturated` by
# construction: this branch asks for nothing, so it cannot have run out of
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
floor_limited: bool = False
soc: float | None = None
soc_floor: float | None = None
soc_floor_reason: str | None = None
reason: str = ""
def explain(self) -> str:
if self.floor_limited:
return self.reason
if not self.engaged:
if self.projected_w is None:
return f"peak guard idle - {self.reason}"
@@ -383,7 +510,44 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return None
def decide(projection, *, batt_w, cap_w, reading, achievable_w,
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
Returns None when it may, and otherwise the REASON it may not - which the
decision, the status page, the action log and the operator notification all
carry, because "floor-limited" on its own tells nobody what to do.
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
`freeze.looks_frozen()`, `watchdog.loop_is_following()` and
`shaping_suspended()` are: a safety predicate transcribed into its own check
is one copy plus a future disagreement about what the check means, and the
copy that drifts is always the grading one.
⚠️ `soc is None` IS NOT PERMISSION. A failed read is not an observation, and
a FULL BATTERY is exactly the value that would disarm this floor - so a
`float(soc or 100)` here would restore the unbounded discharge this card
removes, silently, on the one install whose SoC sensor is misnamed. There is
deliberately no default argument on either parameter.
⚠️ AND `soc_floor is None` IS NOT PERMISSION EITHER, for the same reason one
step further out: a caller that has no floor configured has not thereby been
granted an unbounded one.
Boundary: `<=`, not `<`. A pack sitting exactly ON the floor has already
spent everything this guard was allowed to spend; permitting one more cycle
there would make the floor mean "one cycle below the floor".
"""
if soc is None:
return FLOOR_SOC_UNREADABLE
if soc_floor is None:
return ("no peak guard SoC floor is configured, so there is nothing to "
"bound a forced discharge with")
if float(soc) <= float(soc_floor):
return FLOOR_AT_SOC_FLOOR
return None
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
max_hold_s=None, frozen: bool = False) -> PeakDecision:
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
@@ -395,6 +559,13 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w,
claim can NEVER produce `lo > hi` in the resolver - a
contradiction there resolves to 0 W, which would park the
battery in the exact minute it should be discharging hardest.
`soc` SAFETY-24. MEASURED state of charge, percent. None when the
sensor for it did not answer - which is floor-limited, not
permission.
`soc_floor` SAFETY-24. The percent below which this guard may not spend
the pack for money. REQUIRED, and deliberately without a
default: a default here would be CFG-03's fourth defaults
site and TEST-14's quiet fallback in one line.
"""
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
ceil_top = abs(float(achievable_w))
@@ -454,12 +625,41 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w,
wanted_w=wanted,
reason="the load is under the level this quarter can afford")
# SAFETY-24 - AND ONLY HERE. `wanted > 0` is now established, so a forced
# discharge really is being asked for and the SoC is the quantity that
# decides whether it may be sized. Asked one line earlier it would grade a
# number that governs nothing on every quiet cycle of the day, and would
# report "floor-limited" over a guard that was never going to discharge.
denied = discharge_permitted(soc, soc_floor)
if denied is not None:
# 0.0, not `wanted`, and not a negative number. Zero forbids CHARGING -
# one-directional, cannot cost pack energy - and forbids nothing else;
# a `lo` limit structurally cannot cap the discharge the loop is already
# doing. See the module note: this bounds THE GUARD's forced discharge,
# not the grid-following loop, which still has no SoC guard.
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling,
ceiling_from=from_where, projected_w=projected, excess_w=ex,
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
soc_floor_reason=denied,
reason=(
f"PEAK GUARD FLOOR-LIMITED: the quarter projects "
f"{projected:.0f} W against a {ceiling:.0f} W ceiling "
f"({from_where}) and {wanted:+.0f} W of discharge would hold "
f"it, but {denied}"
+ ("" if soc is None else f" ({float(soc):.0f} %")
+ ("" if soc is None or soc_floor is None
else f" vs {float(soc_floor):.0f} %")
+ ("" if soc is None else ")")
+ " - commanding no forced discharge, so THIS QUARTER WILL GO "
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
floor = min(wanted, ceil_top)
saturated = floor < wanted
return PeakDecision(
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
saturated=saturated,
saturated=saturated, soc=soc, soc_floor=soc_floor,
reason=(
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
f"{ceiling:.0f} W ceiling ({from_where}) with "
@@ -513,5 +713,13 @@ def claim(decision: PeakDecision) -> Claim | None:
"""
if not decision.engaged or decision.floor_w is None:
return None
# Three reasons, not two. A `lo = 0 W` claim reaches the arbiter from THREE
# different states now - blind, floor-limited, and a genuine floor that
# happens to be zero - and the arbiter's explain() is what an operator reads
# when the battery stops charging. Spelling them the same way would make the
# SAFETY-24 case indistinguishable from the SAFETY-18 one on the one line
# that gets read.
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
reason=("blind" if decision.blind else "quarter peak"))
reason=("blind" if decision.blind
else "SoC floor" if decision.floor_limited
else "quarter peak"))
+101
View File
@@ -282,6 +282,87 @@ def volatility_regime(product: PriceProduct) -> str:
return "wide" if product in _WIDE else "normal"
# --------------------------------------------------------------------------- #
# FCST-03: ONE widening path, driven by a SET of active uncertainty sources.
#
# Two spec rows widen the same number by two different triggers:
#
# §9.2 / §8.3 real-time / imbalance product -> "arbitrage thresholds widen"
# §11.4 no weather data -> "widen arbitrage thresholds"
#
# They are one mechanism reached twice, not two mechanisms, so there is one
# function and a set of reasons. ⚠️ THE COMBINATION RULE IS `max`, NOT A
# PRODUCT: multiplying 2.5 by 1.5 would make two independent doubts compound
# into a threshold nothing on a Belgian tariff can ever clear, and an arbitrage
# strategy that can never fire is indistinguishable from one that is switched
# off. The widest single reason wins.
#
# ⚠️ HONEST ABOUT WHERE THE NUMBERS COME FROM. specs.md says thresholds widen
# and does not say by how much; there is no capture to derive a factor from,
# because no arbitrage strategy has ever run here. These are POLICY DEFAULTS,
# chosen so that (a) a real-time product needs a materially larger spread than
# a settled day-ahead one before a cycle is worth its wear, and (b) the two
# factors DIFFER, so `max`, `sum` and `product` are three distinguishable
# answers rather than the same number - a check that grades a combination rule
# where all the candidates agree grades nothing. When OPT-04 lands
# `cycle_cost_threshold_eur` (§10.1/§14) these become the natural second option
# beside it, in PRICE_OPTION_DEFAULTS and nowhere else (CFG-03).
class Uncertainty(Enum):
"""Why an arbitrage threshold is currently wider than the tariff alone says."""
REALTIME_PRODUCT = "realtime_product" # §9.2 row 2, §8.3
NO_WEATHER = "no_weather" # §11.4 "no weather data"
WIDEN_FACTOR = {
Uncertainty.REALTIME_PRODUCT: 2.5,
Uncertainty.NO_WEATHER: 1.5,
}
def uncertainty_of(product: PriceProduct) -> frozenset:
"""The uncertainty a PRODUCT contributes on its own (§9.2 row 2).
Derived from the declared product, never from a plugin id - the same rule
`volatility_regime()` exists for. A sixth supplier declaring
`PriceProduct.REALTIME` is widened without anyone editing this file.
"""
if not isinstance(product, PriceProduct):
raise TypeError(f"uncertainty_of() takes a PriceProduct, not "
f"{product!r} - a plugin name is what §9.3 forbids.")
return (frozenset({Uncertainty.REALTIME_PRODUCT})
if product in _WIDE else frozenset())
def widen_factor(sources) -> float:
"""The multiplier the widest active source asks for. 1.0 when there are none.
⚠️ A STRING IS REFUSED. `widen_factor({"realtime"})` would otherwise be a
silent 1.0 - a widening that reports itself as applied and is not, which is
the shape of defect this repo keeps finding.
"""
srcs = frozenset(sources)
bad = [s for s in srcs if not isinstance(s, Uncertainty)]
if bad:
raise TypeError(f"widen_factor() takes Uncertainty members, not {bad!r}. "
f"A string here would widen by 1.0 and say it widened.")
return max((WIDEN_FACTOR[s] for s in srcs), default=1.0)
def required_spread(base_eur_kwh: float, sources) -> float:
"""The spread an arbitrage cycle must clear, given what is uncertain (§8.3).
`base_eur_kwh` is the caller's own round-trip cost threshold - OPT-04 owns
that number (§10.1's `cycle_cost_threshold_eur`), this owns only how much
wider it gets and why.
"""
if isinstance(base_eur_kwh, bool) or not isinstance(base_eur_kwh, (int, float)) or not math.isfinite(base_eur_kwh) or base_eur_kwh < 0:
raise ValueError(
f"required_spread() takes a non-negative, finite base threshold in "
f"EUR/kWh, not {base_eur_kwh!r}. A negative or NaN base makes every "
f"spread clear it, which is arbitrage with the brake removed.")
return round(float(base_eur_kwh) * widen_factor(sources), 6)
@dataclass(frozen=True)
class PricePoint:
"""One canonical point. THREE FIELDS. §9.3 lists three; this has three."""
@@ -526,6 +607,26 @@ PRICE_OPTION_DEFAULTS = {
# test_price.py section 1. A second defaults dict in the new module would be
# a fourth place option defaults live, which is exactly the open ticket.
"static_profile": "",
# 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
# FCST-03's note above `WIDEN_FACTOR` names this as the place they would
# land. `arbitrage_soc_floor` / `arbitrage_soc_ceiling` are the reserve
# arbitrage may not spend and the headroom it may not overfill; they are
# NOT the maintenance SoC keys, which describe a balancing cycle rather
# than a trading reserve and would silently couple two unrelated policies.
#
# ⚠️ AND `cycle_cost_threshold_eur` IS 0.10 BECAUSE §10.1's NAME FOR IT SAYS
# SO, NOT BECAUSE IT MAKES ANYTHING TRADE. On realistic Belgian day/night
# numbers the static spread is ~0.08 EUR/kWh, so a static-tariff site does
# NOT trade at this default. That is a reported result (test_arbitrage.py
# section 7), and whether 0.10 - or FCST-03's 2.5/1.5 - is right is one
# product decision that belongs to the owner. No number here was chosen to
# make arbitrage happen.
"arbitrage_enabled": False,
"cycle_cost_threshold_eur": 0.10,
"arbitrage_soc_floor": 20,
"arbitrage_soc_ceiling": 95,
}
# ⚠️ The keys the FRAMEWORK owns. A plugin that reads one of these is a plugin
+465
View File
@@ -0,0 +1,465 @@
"""FCST-03 - the hours the feed has not published yet, and what may not rest on them.
WHERE THIS LIVES. specs.md §2.2 puts price ingest under `ems/ingest/prices/`.
That layout does not exist and is not being built; the product owner's decision
of 2026-08-30 is that everything ships in `goodwe_controller/app/`. The
BEHAVIOUR §8.3 describes is unchanged and still authoritative - only the path
moved. Read `ems/...` as `app/...`.
§8.3, in full, because every line below is one clause of it:
"Day-ahead prices arrive once daily. Until they do, unknown hours use
yesterday's profile and are flagged `provisional: true` - and NO
IRREVERSIBLE ACTION MAY BE TAKEN ON A PROVISIONAL PRICE. Real-time /
imbalance products are treated as high-volatility: arbitrage thresholds
widen."
Three separate things, and this module owns the first two. The third - the
widening arithmetic - lives in price.py beside the product enum it is derived
from (`Uncertainty`, `widen_factor`, `required_spread`), and is spent by
`static_tariff.TariffPlan.arbitrage()`, because that is where the verdict this
repo already refuses to hand out casually is produced. FCST-03 added a refusal
to that existing ladder rather than a second mechanism beside it.
THE DEFECT THIS MODULE IS SHAPED BY: A COPY THAT KEEPS THE WRONG FLAG
=====================================================================
A point filled from yesterday and a point published this morning are the SAME
SHAPE. Three floats and a bool; only the bool tells them apart. And the natural
way to write a carry-forward fill is:
PricePoint(start=t, eur_per_kwh=y.eur_per_kwh, provisional=y.provisional)
^^^^^^^^^^^^^
which copies yesterday's `False` onto today's guess and produces exactly the
thing the ticket exists to prevent: STALE DATA PRESENTED AS CURRENT. It reads
fine, it type-checks, it round-trips through §9.3's JSON, and every price on
the status page looks like a price. This is the meter-side SAFETY-18 defect one
layer up: the reading arrives on time, looks fresh, and is wrong - and a
freshness check that only asks "did something arrive" cannot see it.
So the flag is not passed through anywhere in this file:
* A CARRIED POINT IS BUILT BY `_carried()`, WHICH HAS NO PARAMETER FOR THE
FLAG. `provisional=True` is a literal in one place. There is no argument a
caller can pass, no keyword to forget, and no variable that could hold
yesterday's value at the moment the point is constructed.
* A PUBLISHED POINT IS NEVER COPIED AT ALL. It is passed through BY IDENTITY -
the object the provider produced is the object in the output series - so
there is no copy that could lose or gain a flag. `filled.points[i] is
fetched.points[j]` is a checkable property, and test_price_forecast.py
checks it.
* `_audit()` RE-DERIVES the expected flag for every output point from the set
of hours the feed actually published, and RAISES if any point disagrees.
It is a post-condition, not a filter: it never fixes a flag, because a
module that quietly corrects its own provenance is one whose provenance
cannot be trusted.
* and test_price_forecast.py walks THIS FILE'S AST and fails if any
`PricePoint(...)` in it passes anything but a literal for `provisional`, so
the pass-through idiom cannot come back in a later edit and pass review by
looking ordinary.
⚠️ "PUBLISHED" DOES NOT MEAN "FIRM". `eneco_eddy` is an imbalance product whose
current-quarter print is genuinely provisional and says so per point (§9.2,
TARIFF-02). Such a point is published AND provisional, and the audit's rule for
a published point is "the flag is whatever the feed said", never "False". A
provenance check that forced published points to False would be a bug that
looks like a guarantee.
THE FILL, AND WHY IT KEYS ON THE WALL CLOCK
===========================================
For every whole hour in the requested horizon that the feed has not published,
take yesterday's value for THE SAME BRUSSELS WALL-CLOCK HOUR - not `t - 86400
s`. On the two days a year that matter those are different instants, and the
difference is not academic: a 23-hour day has no 02:00 at all and a 25-hour day
has two, so a fill that subtracts a fixed 24 h shifts every carried hour by one
across a transition and misprices the whole day in a way nothing reports.
On the 25-hour day the two 02:00 hours - `02:00+02:00` and `02:00+01:00` - both
key to local hour 2 and are BOTH filled from yesterday's single 02:00 value.
That is deliberate: they are both guesses, they are both flagged, and nothing
irreversible may rest on either. If yesterday itself was a 25-hour day, the
first of its two 02:00 points is the one used, deterministically.
An hour with no published price AND no yesterday value is LEFT OUT of the
series. It is not filled with a neighbour, a mean, or a zero. §11.4 applies to
that hour - `_Series.at()` raises `Absence` for it - and the caller that wanted
a tariff for the whole horizon falls back to the static profile (TARIFF-03),
which is the row the spec actually writes for "no price data". If NOTHING can
be produced, the result is `PriceRead.no_data(reason)` and not an empty series,
because an empty series is not constructible on purpose (price.py trap 3).
AC2: THE IRREVERSIBLE-ACTION GATE
=================================
"The distinction should be an explicit property of the action, not a judgement
call at each call site" - so an irreversible action is a value, `Irreversible`,
carrying the number of hours it COMMITS. `price_permits()` looks at exactly
those hours and answers with a `Gate` variant that carries the evidence on the
permitting arm and a reason on the blocking one, the `_Outcome` shape price.py
borrowed from `sim/riglock.py`.
The shipped example is the maintenance drain (§12): starting one commits hours
of forced discharge that cannot be undone if the price turns out to have been
yesterday's. Its commitment window is DERIVED from `maintenance.DRAIN_TIMEOUT_MIN`
rather than typed here, so the gate and the phase machine cannot drift apart.
⚠️ WHAT DOES NOT GO THROUGH THIS GATE, AND WHY THAT MATTERS MORE THAN WHAT
DOES. The maintenance cycle is a CALENDAR obligation (§12.2: a cycle that
silently never fires is indistinguishable from one that is not due yet), and a
site with `price_provider: off` - the shipped default - has no price series at
all. Routing every maintenance start through a gate that blocks on `no_data`
would stop the monthly cycle on every default install, permanently and
silently: the exact failure §12 opens by naming. So `begin_phase()` is the
entry point for a start that is being JUSTIFIED BY A PRICE, main.py's existing
calendar path is untouched by this ticket, and OPT-04/MAINT-05 own the decision
of which one a site runs. Nothing here disables anything that runs today.
Reversible actions - ordinary self-consumption modulation, the loop that
actually holds the grid at zero - are not gated at all and cannot be: §6 says
absent machinery must not disable the loop.
"""
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
from .price import PricePoint, PriceRead, _Outcome, brussels
_LOG = logging.getLogger("goodwe.price.forecast")
class Provenance(Enum):
"""Where a point in a filled series came from. INTERNAL - §9.3's wire format
carries the boolean and nothing else, so this never leaves the module.
It exists so `_audit()` has a vocabulary to state its post-condition in, and
so the two cases have names in the log rather than being "the ones with the
flag" and "the others".
"""
PUBLISHED = "published" # the provider printed this hour. Flag: the
# feed's own - an imbalance print may be
# published AND provisional (§9.2).
CARRIED = "carried" # yesterday's same-wall-clock hour (§8.3).
# Flag: ALWAYS True, by construction.
# --------------------------------------------------------------------------- #
# The fill
# --------------------------------------------------------------------------- #
def _utc_hour(instant: datetime) -> datetime:
u = instant.astimezone(timezone.utc)
return u.replace(minute=0, second=0, microsecond=0)
def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
"""A point filled from yesterday's profile. §8.3: ALWAYS provisional.
⚠️ THERE IS NO `provisional` PARAMETER, AND THAT IS THE MECHANISM. This is
the only constructor of a carried point, the flag is a literal, and no
caller - including a future one editing in a hurry - has anything to pass,
default, or copy from the point being carried.
"""
return PricePoint(start=brussels(start_utc),
eur_per_kwh=round(float(eur_per_kwh), 6),
provisional=True)
def _audit(points, published) -> None:
"""Post-condition: every flag matches the point's TRUE provenance (AC1).
`published` maps a UTC hour to the point the provider produced for it.
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
provenance is one whose provenance means nothing; the only safe response to
"this point's flag disagrees with where it came from" is to refuse to emit
the series at all.
"""
seen = set()
for p in points:
u = _utc_hour(p.start)
if u in seen:
raise ValueError(f"the filled series repeats {u.isoformat()}; a "
f"duplicated hour is how a DST day gets mispriced")
seen.add(u)
src = published.get(u)
prov = Provenance.CARRIED if src is None else Provenance.PUBLISHED
if src is None:
if p.provisional is not True:
raise ValueError(
f"{u.isoformat()} is {prov.value} and is flagged "
f"provisional={p.provisional!r}. §8.3: an hour filled from "
f"yesterday's profile is provisional whether or not the "
f"number turns out to be right - the flag is a claim about "
f"PROVENANCE, not about accuracy.")
else:
if p is not src:
raise ValueError(
f"{u.isoformat()} is {prov.value} but the series carries a "
f"COPY of that point rather than the point itself. A copy "
f"is where the provisional flag gets lost; published points "
f"pass through by identity.")
def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
since: datetime, until: datetime,
stamp: datetime) -> PriceRead:
"""§8.3: fill the hours the feed has not published with yesterday's profile.
Every argument is required and keyword-only past the first. In particular
`yesterday` has NO DEFAULT: a fill with no yesterday to fill from is a
different situation from a fill that found nothing useful in one, and a
default `None` would make the two arrive looking identical.
Returns a `PriceRead`:
* a series, whose points are the provider's own objects for the hours it
published and `_carried()` points for the rest, ordered by instant;
* `no_data` when neither source can produce a single hour - never an
empty series, which is not constructible (price.py trap 3).
⚠️ IDEMPOTENT AND RE-RUNNABLE, WHICH IS THE §5.4 LATE-PUBLICATION CONTRACT.
Day-ahead publication at 14-16 h CET can be late or fail, so the fetcher
retries and this runs again against the fresher read. Published hours always
win over carried ones, so a re-run REPLACES a carried point with the real
print and the hour becomes firm - the flag and the number move together
because the whole series is rebuilt rather than patched in place.
"""
for name, value in (("since", since), ("until", until), ("stamp", stamp)):
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f"{name} must be tz-aware; a naive instant is "
f"ambiguous for one hour every October")
published = read.match(
series=lambda r: {_utc_hour(p.start): p for p in r.points},
no_data=lambda _r: {})
# Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins
# when yesterday itself was a 25-hour day and printed local hour 2 twice.
by_local_hour = {}
yesterday.match(
series=lambda r: [by_local_hour.setdefault(brussels(p.start).hour, p)
for p in r.points],
no_data=lambda _r: [])
out = []
u = _utc_hour(since)
end = until.astimezone(timezone.utc)
while u < end:
if u in published:
out.append(published[u]) # BY IDENTITY. No copy.
else:
src = by_local_hour.get(brussels(u).hour)
if src is not None:
out.append(_carried(u, src.eur_per_kwh))
u += timedelta(hours=1)
if not out:
reason = read.match(
series=lambda r: (f"the {len(r.points)} published hours all fall "
f"outside {since.isoformat()}..{until.isoformat()}"),
no_data=lambda r: r.reason)
return PriceRead.no_data(
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's "
f"profile has nothing to fill them with either. §11.4 applies: "
f"static profile, plan provisional, no arbitrage")
_audit(out, published)
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
if carried:
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
"profile and flagged provisional; %d published",
carried, len(out), len(out) - carried)
product = read.match(series=lambda r: r.product,
no_data=lambda _r: yesterday.product)
provider = read.match(series=lambda r: r.provider,
no_data=lambda r: yesterday.provider)
return PriceRead.series(out, product, provider, stamp)
# --------------------------------------------------------------------------- #
# AC2: irreversibility as a property of the ACTION, and the gate that reads it
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Irreversible:
"""An action that cannot be undone once started, and how far it commits.
`commits_h` is the horizon the action ties up - the hours whose prices must
therefore be firm before it may be justified by a price at all.
"""
name: str # the §10.2 reason code
commits_h: float
def __post_init__(self):
if not self.name or not isinstance(self.name, str):
raise ValueError("an irreversible action needs a reason code (§10.2)")
if isinstance(self.commits_h, bool) or \
not isinstance(self.commits_h, (int, float)) or \
not self.commits_h > 0:
raise ValueError(
f"commits_h must be a positive number of hours, not "
f"{self.commits_h!r}. An action that commits nothing is a "
f"reversible one and does not belong here.")
# ⚠️ DERIVED FROM THE PHASE MACHINE'S OWN BOUNDS, NOT TYPED HERE. maintenance.py
# already states how long each phase may run before it is a fault; a second copy
# of those numbers here would be a place for them to drift apart, and the drift
# would show up as a gate that checked the wrong hours.
MAINTENANCE_DRAIN = Irreversible("MAINTENANCE_DRAIN", DRAIN_TIMEOUT_MIN / 60.0)
MAINTENANCE_CHARGE = Irreversible("MAINTENANCE_CHARGE", CHARGE_TIMEOUT_MIN / 60.0)
class Gate(_Outcome):
"""May an irreversible action proceed on the price evidence available?
* `permitted` - carries `.action` and `.hours`, the firm points that were
actually checked. The evidence exists only on this arm.
* `blocked` - carries `.reason`. Nothing else; `.hours` raises `Absence`.
No `__bool__`, both `match()` arms required and keyword-only, for the reason
price.py's `_Outcome` gives: `if gate:` must not look like a guard while
being none.
"""
def match(self, *, permitted, blocked):
return {"permitted": permitted, "blocked": blocked}[self.kind](self)
@staticmethod
def permit(action, hours):
return _Permitted(action, hours)
@staticmethod
def block(reason):
if not reason or not isinstance(reason, str):
raise ValueError(
"Gate.block() takes a reason a human can act on. §6 requires "
"every decision to explain itself, and 'the maintenance cycle "
"did not start' with no reason is indistinguishable from a bug.")
return _Blocked(reason)
class _Permitted(Gate):
kind = "permitted"
def __init__(self, action, hours):
if not isinstance(action, Irreversible):
raise TypeError(f"action must be an Irreversible, not {action!r}")
pts = tuple(hours)
if not pts:
raise ValueError(
"a permission granted over ZERO checked hours is a permission "
"granted on no evidence - the shape this whole file exists to "
"refuse. Block instead.")
if any(p.provisional for p in pts):
raise ValueError(
"a permitted gate cannot carry a provisional hour: §8.3 is the "
"one rule this type has.")
self.action = action
self.hours = pts
self.words = (f"{action.name} permitted on {len(pts)} firm hours "
f"({pts[0].start.isoformat()} ..)")
class _Blocked(Gate):
kind = "blocked"
def __init__(self, reason):
self.reason = reason
self.words = f"blocked - {reason}"
def committed_hours(now: datetime, action: Irreversible) -> tuple:
"""The whole hours `action` started at `now` would run through.
Split out because the gate and its caller must agree on what "the hours it
commits" means, and because a window that quietly excluded the last hour
would be a gate that permits an action on an hour it never looked at.
"""
if not isinstance(action, Irreversible):
raise TypeError(f"action must be an Irreversible, not {action!r}")
if now.tzinfo is None or now.utcoffset() is None:
raise ValueError("now must be tz-aware")
out = []
u = _utc_hour(now)
end = now.astimezone(timezone.utc) + timedelta(hours=action.commits_h)
while u < end:
out.append(u)
u += timedelta(hours=1)
return tuple(out)
def price_permits(read: PriceRead, now: datetime,
action: Irreversible) -> Gate:
"""§8.3's rule, as a value: may this irreversible action rest on this price?
Blocks when the read observed nothing (§11.4 - an action justified by a
price nobody has is an action justified by nothing), when any hour it
commits has no price at all, and when any of those hours is provisional.
Names the offending hour, so an operator reading the log knows which one.
"""
hours = committed_hours(now, action)
return read.match(
series=lambda r: _walk(r, hours, action),
no_data=lambda r: Gate.block(
f"{action.name} is gated on a price and there is no price data: "
f"{r.reason}. §11.4 - and a missing price is not a cheap one"))
def _walk(series, hours, action) -> Gate:
pts = []
for u in hours:
if not series.covers(u):
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and no price covers "
f"{u.isoformat()}. An hour outside the series is an hour with "
f"no price (§11.4 for that hour), not a free one")
p = series.at(u)
if p.provisional:
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - "
f"carried from yesterday's profile or standing in for a dead "
f"feed. §8.3: no irreversible action may be taken on a "
f"provisional price. It becomes available when the day-ahead "
f"print lands (~14-16 h CET, §5.4)")
pts.append(p)
return Gate.permit(action, pts)
def begin_phase(maint, now: datetime, read: PriceRead,
action: Irreversible) -> Gate:
"""Start a maintenance phase BECAUSE OF A PRICE, or refuse and say why.
The AC2 example made real: `maint.force_start(now)` - the call that moves
the phase machine out of `idle` and commits the pack to hours of forced
discharge - happens on the permitting arm and cannot happen on the other
one, because the blocking arm has no code path to it.
⚠️ READ THE MODULE DOCSTRING'S LAST WARNING BEFORE WIRING THIS IN. This is
the price-justified entry point, not the calendar one; a site with no price
provider must keep starting its monthly cycle the way it does today, or
§12's opening sentence comes true - a cycle that silently never fires.
"""
gate = price_permits(read, now, action)
return gate.match(permitted=lambda g: _go(maint, now, g),
blocked=lambda g: _no(g))
def _go(maint, now, gate) -> Gate:
maint.force_start(now)
_LOG.info("%s: %s", gate.action.name, gate.words)
return gate
def _no(gate) -> Gate:
_LOG.warning("irreversible action refused - %s", gate.reason)
return gate
+192 -38
View File
@@ -77,25 +77,49 @@ price.py borrowed from `sim/riglock.py`. An optimiser cannot compute a trade
from a refusal, because it cannot get the inputs out of one.
And the verdict is only obtainable from `TariffPlan.arbitrage(...)`, which takes
`peak_guard_active` as a REQUIRED KEYWORD ARGUMENT. §9.1 and §10.1 put peak
`peak_guard_active`, `min_spread_eur_kwh` and `uncertainty` as REQUIRED KEYWORD
ARGUMENTS, none of them defaulted. §9.1 and §10.1 put peak
shaving (priority 2) above price arbitrage (priority 3) - the capacity tariff is
a network cost charged whatever the energy product is - and the way that
ordering is made to survive OPT-04 landing is that OPT-04 cannot obtain a spread
without first stating whether the peak guard is holding. It is not free to
forget the question; the call does not compile without an answer.
⚠️ FOUR REFUSALS, IN THIS ORDER, EACH DERIVED FROM SOMETHING MEASURED:
⚠️ FIVE REFUSALS, IN THIS ORDER, EACH DERIVED FROM SOMETHING MEASURED:
1. the peak guard is active §10.1: priority 2 outranks priority 3
2. no price data at all §11.4, and no static_profile to fall to
3. the plan is provisional §11.4 fallback / §8.3 unknown hours
4. the series is FLAT max - min == 0, so there is nothing to
arbitrage. THIS is AC2: a fully-fixed
tariff disables arbitrage by ARITHMETIC,
not by a special case keyed on
`PriceProduct.FIXED`. A dynamic feed
3. fewer than TWO FIRM HOURS §8.3. FCST-03 rewrote this one: it used
to be "any point in the series is
provisional", which would have switched
arbitrage off every morning on every
dynamic site once carry-forward filling
landed (a series is part-firm and
part-carried until the 14-16 h CET
publication). What §8.3 forbids is
acting ON a provisional price, so the
provisional hours are DROPPED and
everything below is computed over the
firm ones. A wholly provisional series -
the §11.4 fallback - still has no firm
pair and is still refused.
4. the FIRM series is FLAT max - min == 0, so there is nothing to
arbitrage. THIS is TARIFF-03's AC2: a
fully-fixed tariff disables arbitrage by
ARITHMETIC, not by a special case keyed
on `PriceProduct.FIXED`. A dynamic feed
that happens to print one price all day
gets refused for the same true reason.
5. the spread does not clear the §8.3/§9.2/§11.4, new in FCST-03. The
WIDENED threshold caller states its round-trip cost
threshold; this widens it by the widest
active uncertainty source - a real-time
/ imbalance PRODUCT (derived from the
declared `PriceProduct`, never from a
plugin id) or the caller's own
`NO_WEATHER`. See price.py's
`required_spread()`; the combination
rule is `max`, not a product.
Nothing here decides a trade. The spread is handed over; OPT-04 owns
`cycle_cost_threshold_eur` (§10.1/§14) and what to do about it. ⚠️ Worth knowing
@@ -182,7 +206,8 @@ from datetime import datetime, timedelta, timezone
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
_Outcome, brussels, load_prices, price_option,
Uncertainty, _Outcome, brussels, load_prices, price_option,
required_spread, uncertainty_of, widen_factor,
)
_LOG = logging.getLogger("goodwe.tariff.static")
@@ -619,8 +644,9 @@ class Arbitrage(_Outcome):
return {"allowed": allowed, "refused": refused}[self.kind](self)
@staticmethod
def allow(spread, cheapest, dearest, regime, product):
return _Allowed(spread, cheapest, dearest, regime, product)
def allow(spread, cheapest, dearest, regime, product, required, sources):
return _Allowed(spread, cheapest, dearest, regime, product, required,
sources)
@staticmethod
def refuse(reason):
@@ -632,17 +658,43 @@ class Arbitrage(_Outcome):
return _Refused(reason)
# ⚠️ FIRM BY CONSTRUCTION, ASSERTED RATHER THAN TRUSTED (FCST-03). §8.3 forbids
# an irreversible action on a provisional price, and the two points on the
# permitting variant ARE the action: they are the hours a strategy buys and
# sells in. A provisional point reaching this far is a defect in the refusal
# ladder above, and it fails loudly at the moment of construction rather than
# quietly at the meter a day later.
def _firm_or_die(cheapest, dearest):
for role, pt in (("cheapest", cheapest), ("dearest", dearest)):
if getattr(pt, "provisional", True):
raise ValueError(
f"the {role} hour handed to an arbitrage strategy is "
f"PROVISIONAL ({getattr(pt, 'start', '?')}). §8.3: no "
f"irreversible action may be taken on a provisional price - "
f"an ALLOWED verdict cannot be built out of one.")
class _Allowed(Arbitrage):
kind = "allowed"
def __init__(self, spread, cheapest, dearest, regime, product):
def __init__(self, spread, cheapest, dearest, regime, product, required,
sources):
_firm_or_die(cheapest, dearest)
self.spread_eur_kwh = spread
self.cheapest = cheapest # the PricePoint at the low end
self.dearest = dearest # the PricePoint at the high end
self.regime = regime # §8.3 "wide" | "normal"
self.product = product
# FCST-03. The threshold this spread actually cleared, and why it was
# the size it was. An optimiser that wants to know how much margin it
# has does not get to re-derive it from a product enum.
self.required_spread_eur_kwh = required
self.uncertainty = frozenset(sources)
self.words = (f"spread {spread:.4f} EUR/kWh over "
f"{product.value} ({regime} thresholds)")
f"{product.value} ({regime} thresholds, cleared "
f"{required:.4f} EUR/kWh)")
class _Refused(Arbitrage):
@@ -724,36 +776,100 @@ class TariffPlan:
no_data=lambda r: _absent(r.reason))
# -- the two things a consumer is allowed to act on ----------------------
def firm_points(self) -> tuple:
"""The points that carry a PUBLISHED price for their own hour (§8.3).
The complement of the carried-forward fill FCST-03 adds. Empty on a
§11.4 fallback plan and on a `no_data` read - and empty is honest here,
because it is a count of observations, not a value invented in place of
one. Never used to answer "what does this hour cost"; only to answer
"may something irreversible rest on it".
"""
return self.read.match(
series=lambda r: tuple(p for p in r.points if not p.provisional),
no_data=lambda _r: ())
def cheap_now(self, instant: datetime) -> bool:
"""Is now a cheap window? STRICTLY below the mean - see the docstring.
False on a provisional plan: §8.3 forbids an irreversible action on a
provisional price, and a grid top-up of a battery is about as
irreversible as this add-on gets. False when there is no price at all,
which is the §11.4 row and also main.py's long-standing "fixed-tariff
site: never force a paid grid top-up, wait for sun".
⚠️ FALSE ON A PROVISIONAL HOUR, AND IT IS THIS HOUR THAT DECIDES
(FCST-03). §8.3 forbids an irreversible action on a provisional price
and a paid grid top-up of a battery is about as irreversible as this
add-on gets - but the price that would be paid is THIS hour's, so a
firm hour does not stop being cheap because tomorrow's hours have not
been published yet. Before FCST-03 the whole plan had to be firm, which
was the same answer for the only two series that existed then (all firm,
or all provisional) and would have switched the cheap window off every
morning once carry-forward filling landed.
The mean is taken over the FIRM hours for the same reason: yesterday's
carried values are not observations of what today costs, and a
comparison basis half-built out of them grades this hour against a
number nobody published. On an all-firm series - every series that
existed before FCST-03 - it is the same mean to the last digit.
False when there is no price at all, which is the §11.4 row and also
main.py's long-standing "fixed-tariff site: never force a paid grid
top-up, wait for sun".
"""
if not self.has_prices or self.provisional:
firm = self.firm_points()
if not firm:
return False
try:
return self.price_at(instant) < self.mean_eur_kwh()
here = self.read.match(series=lambda r: r.at(instant),
no_data=lambda r: _absent(r.reason))
except Absence:
# An instant outside the series is an hour with no price (§11.4 for
# that hour), not a cheap one.
return False
if here.provisional:
return False
return here.eur_per_kwh < (sum(p.eur_per_kwh for p in firm) / len(firm))
def arbitrage(self, *, peak_guard_active: bool) -> Arbitrage:
def arbitrage(self, *, peak_guard_active: bool, min_spread_eur_kwh,
uncertainty) -> Arbitrage:
"""May OPT-04 trade, and on what numbers. See the module docstring.
⚠️ `peak_guard_active` IS REQUIRED AND KEYWORD-ONLY. §9.1/§10.1 put peak
shaving above arbitrage, and this is how that ordering survives a
strategy that has not been written yet: the spread is not obtainable
without answering the question.
⚠️ ALL THREE ARGUMENTS ARE REQUIRED AND KEYWORD-ONLY, for one reason
each, and none of them has a default:
`peak_guard_active` §9.1/§10.1 put peak shaving above arbitrage,
and this is how that ordering survives a
strategy that has not been written yet: the
spread is not obtainable without answering the
question.
`min_spread_eur_kwh` the caller's own round-trip cost threshold
(§10.1's `cycle_cost_threshold_eur`). A verdict
that says "allowed" without being told what
would make a cycle worth doing is a verdict
about arithmetic, not about money. Pass 0.0 to
mean "any positive spread", explicitly.
`uncertainty` the §11.4 doubts the CALLER can see and this
object cannot - today that is
`Uncertainty.NO_WEATHER`. Defaulting it to the
empty set would be this module asserting the
weather forecast is healthy, which is a fact it
has no way to observe. The product's own
real-time uncertainty (§9.2 row 2) is derived
here and unioned in, so AC3 cannot be forgotten
by a caller who passes an empty set.
"""
if not isinstance(peak_guard_active, bool):
raise TypeError(
f"peak_guard_active must be a bool, not {peak_guard_active!r}. "
f"A truthy 'unknown' is how §10.1's priority 2 gets skipped.")
if isinstance(min_spread_eur_kwh, bool) or not isinstance(min_spread_eur_kwh, (int, float)) or not math.isfinite(min_spread_eur_kwh) or min_spread_eur_kwh < 0:
raise TypeError(
f"min_spread_eur_kwh must be a finite, non-negative number of "
f"EUR/kWh, not {min_spread_eur_kwh!r}. A negative threshold is "
f"one every spread clears.")
caller_sources = frozenset(uncertainty)
if any(not isinstance(u, Uncertainty) for u in caller_sources):
raise TypeError(
f"uncertainty must be Uncertainty members, not "
f"{sorted(map(repr, caller_sources))} - a string widens by "
f"nothing while reporting that it widened.")
if peak_guard_active:
return Arbitrage.refuse(
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
@@ -765,26 +881,64 @@ class TariffPlan:
f"§11.4: {self.read.reason}. No series exists, so no spread "
f"exists; self-consumption, peak shaving and reserve management "
f"(§10.1 priorities 1, 2 and 4) are unaffected")
if self.provisional:
# ⚠️ REFUSAL 3, REWRITTEN BY FCST-03 FROM "ANY POINT IS PROVISIONAL" TO
# "THE FIRM POINTS ARE NOT ENOUGH TO TRADE ON", and the difference is
# the whole ticket. §8.3 fills the hours a day-ahead feed has not
# published yet with yesterday's profile and flags them - so from
# midnight until the 14-16 h CET publication, a normal healthy site has
# a series that is PART firm and PART carried. Refusing on "any point
# provisional" would switch arbitrage off every morning of every day on
# every dynamic site, which is not what §8.3 says and is a behaviour
# nothing would ever report. What §8.3 forbids is acting ON a
# provisional price - so the provisional hours are DROPPED from the
# trade set and everything below is computed over the firm ones only.
# A fully provisional series (the §11.4 fallback, or a pre-publication
# day with no fetch at all) has no firm pair left and is refused, which
# is the previous behaviour arrived at by a truer route.
pts = self.read.points
firm = tuple(p for p in pts if not p.provisional)
if len(firm) < 2:
return Arbitrage.refuse(
f"§11.4: the plan is provisional (origin {self.origin!r}), and "
f"§8.3 forbids an irreversible action on a provisional price. "
f"Load shifting and peak shaving continue")
spread = self.spread_eur_kwh()
f"§8.3: only {len(firm)} of {len(pts)} hours carry a published "
f"price - the rest are provisional (origin {self.origin!r}), "
f"and no irreversible action may be taken on a provisional "
f"price. A buy hour and a sell hour must BOTH be firm before a "
f"cycle can be committed. Load shifting, self-consumption and "
f"peak shaving continue")
spread = round(max(p.eur_per_kwh for p in firm)
- min(p.eur_per_kwh for p in firm), 6)
if spread == 0.0:
return Arbitrage.refuse(
f"the tariff is flat - every one of the "
f"{len(self.read.points)} hours costs "
f"{self.read.points[0].eur_per_kwh:.4f} EUR/kWh, so buying low "
f"and selling high is arithmetically impossible. §9.2: a fully "
f"the tariff is flat - every one of the {len(firm)} firm hours "
f"costs {firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
f"selling high is arithmetically impossible. §9.2: a fully "
f"fixed product is self-consumption only")
pts = self.read.points
# REFUSAL 5, NEW IN FCST-03 (AC3). One widening path, driven by the set
# of active uncertainty sources: the product's own (§9.2 row 2, derived
# from the DECLARED product and never from a plugin id) unioned with
# whatever the caller can see (§11.4's no-weather row).
sources = uncertainty_of(self.read.product) | caller_sources
need = required_spread(min_spread_eur_kwh, sources)
if spread < need:
return Arbitrage.refuse(
f"the firm spread is {spread:.4f} EUR/kWh and this cycle needs "
f"{need:.4f} - {min_spread_eur_kwh:.4f} widened by "
f"{widen_factor(sources):.2f}x because "
f"{', '.join(sorted(u.value for u in sources)) or 'nothing'} "
f"is uncertain (§8.3, §9.2, §11.4). A cycle whose spread does "
f"not cover its own round-trip cost costs money and pack life")
return Arbitrage.allow(
spread=spread,
cheapest=min(pts, key=lambda p: p.eur_per_kwh),
dearest=max(pts, key=lambda p: p.eur_per_kwh),
cheapest=min(firm, key=lambda p: p.eur_per_kwh),
dearest=max(firm, key=lambda p: p.eur_per_kwh),
regime=self.read.regime,
product=self.read.product)
product=self.read.product,
required=need,
sources=sources)
@property
def words(self) -> str:
+137 -12
View File
@@ -131,6 +131,25 @@ NOT READY banner already names the problem, (b) does not apply.
installed base is on a fixed or static tariff. Refusing to start without a
price feed would lock those sites out of the add-on entirely.
8. `peak_soc_floor` at or below `bms_soc_floor`. [(a)]
SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND
a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor`
is the EMS-side bound on how far down it may take the pack. The BMS has its
own cut-out and enforces it regardless. Set the EMS floor at or below that
cut-out and THE BMS WINS: the EMS bound can never be the thing that stops
the discharge, so the add-on carries an energy backstop that is incapable
of binding while its status page reports one - rule 5's "a disarmed guard
that advertises itself as armed", on the guard whose failure mode is a pack
that is quietly never available when it is wanted.
⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly
equal, the EMS floor and the pack's cut-out act at the same percent, so the
EMS floor buys ZERO margin - it can never prevent the pack reaching the BMS
limit, which is the entire thing this option exists to prevent. That is not
"close to the rail", it is the disarmed case with an extra step. Rule 6
permits equality because equality there leaves one reachable point that
works; here it leaves none.
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
@@ -151,12 +170,18 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
not refused.
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
every ordering of the two is a legitimate site configuration. Refusing on
an ordering would be inventing a relationship the system does not have.
"""
import logging
from .arbitrage import enabled as arbitrage_on
from .p1 import is_enabled, validate_meter_options
from .price import PROVIDERS
from .peakguard import PEAK_OPTION_DEFAULTS
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
from .static_tariff import ProfileInvalid, parse_static_profile
_LOG = logging.getLogger("goodwe.config")
@@ -369,6 +394,36 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# --- 7. two price sources on one site ------------------------------------
problems.extend(_price_problems(opts))
# --- 8. an EMS energy floor the BMS does not already enforce -------------
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
# that is CFG-03 read as a rule rather than as a complaint. Rule 6's own
# comment documents a LIVE divergence in this repo caused by typing 2000
# here and 2500 in main.py; this card refuses to add a second instance of
# it. The dict is the single in-code mirror, pinned to config.yaml by
# test_peakguard.py, and main.py reads the same one.
soc_floor = _number(opts, "peak_soc_floor",
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
bms_floor = _number(opts, "bms_soc_floor",
PEAK_OPTION_DEFAULTS["bms_soc_floor"])
if soc_floor is None or bms_floor is None:
problems.append(
"peak_soc_floor and bms_soc_floor must both be numbers (got "
f"{opts.get('peak_soc_floor')!r} / {opts.get('bms_soc_floor')!r})")
elif soc_floor <= bms_floor:
problems.append(
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
f"(bms_soc_floor) is {bms_floor:.0f} %, so SAFETY-07's forced "
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
"the pack's protection rather than yours. The guard would report a "
"SoC floor on its status page while being incapable of ever being "
"the thing that stops the discharge, and the symptom of that is a "
"battery repeatedly sitting at the BMS limit with no record of why "
f"- which looks exactly like the SoC drift the maintenance cycle "
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
f"(the shipped default is "
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
"bms_soc_floor if it does not match this pack.")
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
@@ -453,14 +508,21 @@ def _price_problems(opts: dict) -> list[str]:
# arbitrage verdict, so the seam is no longer inert.
# * main.py reads the STATIC side of that module every cycle
# (static_status()), which makes static_profile a real writer of `cheap`.
# * main.py does NOT yet call resolve_tariff() on the control tick, and
# that is deliberate: a provider fetch is network I/O and the tick is
# what feeds the ESP32 heartbeat. So a configured PROVIDER still shapes
# no energy decision on its own; a configured STATIC PROFILE does.
# * main.py does NOT call resolve_tariff() on the control tick, and that
# is still deliberate: a provider fetch is network I/O and the tick is
# what feeds the ESP32 heartbeat. OPT-04 put it on a BACKGROUND REFRESH
# instead (Controller.run_tariff_refresh), and the tick reads the plan
# that task last stored - an attribute read, with nothing to block on.
# * so a configured PROVIDER now DOES shape an energy decision, but only
# through app/arbitrage.py and only while `arbitrage_enabled` is on.
# With it off - the shipped configuration - the series is still fetched,
# validated and published, and still moves nothing.
#
# Hence two refusals below rather than one, and neither claims more than
# that list. When OPT-04 puts resolve_tariff() on a background refresh, the
# first message's middle clause is what changes - not the rule.
# Hence three refusals below rather than two, and none of them claims more
# than that list. ⚠️ THE MIDDLE CLAUSE OF THE FIRST MESSAGE IS WHAT OPT-04
# CHANGED, exactly as the note it replaced predicted - the RULE is
# untouched, because the reason it exists (main.py reads the Home Assistant
# pair FIRST and the other source is silent) is unchanged.
ha_price = [k for k in ("price_now_entity", "price_avg_entity")
if str(opts.get(k, "") or "").strip()]
if pid != "off" and ha_price:
@@ -469,10 +531,12 @@ def _price_problems(opts: dict) -> list[str]:
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
"what main.py reduces to the `cheap` flag the maintenance cycle "
"reads. The provider series IS consumed now - app/static_tariff.py "
"builds a tariff plan and an arbitrage verdict from it - but it is "
"not what main.py acts on, so the supplier you configured would "
"shape no energy decision while the status page named it. Clear "
"reads, and read_inputs() never looks at the other source once "
"they are set. The provider series is consumed - "
"app/static_tariff.py builds a tariff plan and an arbitrage "
"verdict, and app/arbitrage.py trades on it off a background "
"refresh - but the `cheap` flag those entities own is a separate "
"decision they would silently keep. Clear "
"price_now_entity and price_avg_entity to use the "
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
"Home Assistant sensors.")
@@ -528,6 +592,67 @@ def _price_problems(opts: dict) -> list[str]:
"with no tariff at all rather than with a coarse one. Not refused, "
"but filling it in is what makes that row work.", pid)
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
# rather than logged, because a strategy that is armed in the UI and cannot
# fire is indistinguishable from one that fires and finds no opportunity -
# which is the §14 warning box's case exactly. None of these can break an
# existing install: the option is new and ships `false`.
# ⚠️ CHECKED WHETHER OR NOT ARBITRAGE IS ARMED. A threshold that is not a
# non-negative number is a configuration error the moment it is typed, and
# deferring the complaint until somebody turns the strategy on would report
# it at the worst possible moment - the one where energy starts moving.
thresh = _number(opts, "cycle_cost_threshold_eur",
PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"])
if thresh is None or thresh < 0:
problems.append(
f"cycle_cost_threshold_eur is "
f"{opts.get('cycle_cost_threshold_eur')!r} and must be a "
f"non-negative number of EUR/kWh - it is what one round trip COSTS "
f"(§10.1). A negative threshold is one that every spread clears, "
f"including a spread of nothing, which is arbitrage with the brake "
f"removed rather than arbitrage tuned aggressively.")
if arbitrage_on(opts):
if pid == "off" and not str(opts.get("static_profile", "") or "").strip():
problems.append(
"arbitrage_enabled is on but price_provider is 'off' and "
"static_profile is empty, so this site has NO PRICES AT ALL. "
"§10.1's priority 3 is 'charge cheap, discharge expensive' and "
"there is nothing here to tell cheap from expensive, so the "
"strategy would refuse on every cycle forever while the "
"configuration said it was running. Configure a price_provider "
"or a static_profile, or turn arbitrage_enabled off.")
if not is_enabled(opts):
problems.append(
"arbitrage_enabled is on but meter_source is 'off', so there "
"is no P1 telegram stream, the capacity-tariff peak guard "
"never runs, and nothing measures the 15-minute average this "
"strategy's charging would land in. §9.1 gives peak shaving "
"priority over arbitrage; an unmeasured peak cannot be shaved, "
"so arbitrage would be deliberately creating the billed "
"quantity with the one guard that limits it switched off. "
"app/arbitrage.py refuses to trade in that state anyway - this "
"refuses the configuration that asks it to. Set meter_source, "
"or turn arbitrage_enabled off.")
floor = _number(opts, "arbitrage_soc_floor",
PRICE_OPTION_DEFAULTS["arbitrage_soc_floor"])
ceiling = _number(opts, "arbitrage_soc_ceiling",
PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])
if floor is None or ceiling is None:
problems.append(
f"arbitrage_soc_floor {opts.get('arbitrage_soc_floor')!r} and "
f"arbitrage_soc_ceiling {opts.get('arbitrage_soc_ceiling')!r} "
f"must both be numbers - they are percentages of pack charge.")
elif floor >= ceiling:
problems.append(
f"arbitrage_soc_floor is {floor:g} % and arbitrage_soc_ceiling "
f"is {ceiling:g} %, so there is no band left between them: "
f"every state of charge is either at or under the floor (no "
f"discharge) or at or over the ceiling (no charge), and the "
f"strategy can never act. The floor is the reserve it may not "
f"spend and the ceiling is the headroom it may not overfill, "
f"so the floor must be the SMALLER number.")
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
problems.append(
"price_provider is 'manual_csv' but price_csv_path is empty, so "
+79
View File
@@ -163,6 +163,37 @@ options:
peak_forecast_entity: ""
peak_cap_w: 3500
# --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge -----
# The peak guard can COMMAND a discharge to hold a billed quarter under
# peak_cap_w. Until this card it was bounded from below by nothing but the
# pack's own BMS cut-out - a real floor, but the PACK's protection rather than
# the OPERATOR's. On the 2026-08-20 replay the guard asks for 878 Wh in a day.
#
# peak_soc_floor is the percent below which that discharge is not sized. When
# it binds the guard reports FLOOR-LIMITED, keeps charging blocked (a 0 W
# floor), and the add-on raises ONE notification saying the quarter will be
# exceeded - failing to shave is never silent, because the euro figure is the
# whole point of the guard.
#
# ⚠️ THIS IS NOT maintenance_soc_floor AND MUST NOT BE MERGED WITH IT.
# maintenance_soc_floor is the DRAIN TARGET of the monthly recalibration: the
# depth the cycle deliberately goes to, once a month, on purpose. This is the
# depth a money-saving discharge may never reach on any day. The right values
# differ, and sharing one key would couple two features that want to move
# independently.
#
# ⚠️ 20 IS DERIVED, NOT PICKED. The reference pack is 13.8 kWh usable and the
# worst modelled guard day is 878 Wh, i.e. 6.4 % - so 10 points above the
# pack's own cut-out leaves the guard ~1.6 of its worst observed days of room
# and still never reaches the BMS.
peak_soc_floor: 20
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
# it: such a floor can never bind, the BMS wins, and the status page would
# report an energy backstop that does not exist.
bms_soc_floor: 10
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
# add-on delegating the whole price question to Home Assistant: main.py reads
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
@@ -240,6 +271,38 @@ options:
# a second series: no shipped supplier publishes a dynamic injection curve.
export_price_eur_kwh: 0.04
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
# when the spread covers what the round trip costs. Off in the shipped
# configuration: this is the first strategy in the add-on that moves energy
# against the meter on purpose, so it is armed deliberately or not at all.
#
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
# combinations that cannot work rather than letting them look armed. With
# `price_provider: off` and no `static_profile` there is no series to trade
# on; with `meter_source: off` the capacity-tariff peak guard never runs, and
# §9.1 puts peak shaving above arbitrage - a charge nothing measures is the
# exact failure that guard exists to prevent.
#
# ⚠️ AT THE SHIPPED THRESHOLD A STATIC DAY/NIGHT SITE DOES NOT TRADE. The
# realistic Belgian day/night spread is about 0.08 EUR/kWh and the threshold
# below is 0.10, so the verdict is "the firm spread does not cover this
# cycle's cost" and nothing happens - correctly. That is a product decision
# (this number, and app/price.py's widening factors) and it is open with the
# owner; the code is correct at any value and no number was picked to make a
# trade appear.
arbitrage_enabled: false
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
# - the wear on the pack plus the conversion losses. A spread below this is
# a cycle that loses money and pack life, so it is not taken.
cycle_cost_threshold_eur: 0.10
# The reserve arbitrage may not spend, and the headroom it may not overfill.
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
# balancing cycle, and pointing a trading strategy at them would couple two
# unrelated policies through one number.
arbitrage_soc_floor: 20
arbitrage_soc_ceiling: 95
# --- site ------------------------------------------------------------------
estop_fitted: false
log_level: info
@@ -311,6 +374,12 @@ schema:
peak_forecast_entity: str?
peak_cap_w: int(500,15000)
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
# a supported way to have no energy bound at all. The ranges OVERLAP on
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
# rule 8 is what refuses the pair, with a message naming both numbers.
peak_soc_floor: int(5,60)
bms_soc_floor: int(0,50)
price_now_entity: str?
price_avg_entity: str?
@@ -337,5 +406,15 @@ schema:
# bottoms out at 0 would make that site inexpressible.
export_price_eur_kwh: float(-1,1)
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
# and a negative one is a threshold every spread clears - arbitrage with the
# brake removed. app/arbitrage.py refuses it in code too, because
# validate_startup_config() is also sim/inject.py's entry point and can be
# handed any dict at all.
arbitrage_enabled: bool
cycle_cost_threshold_eur: float(0,1)
arbitrage_soc_floor: int(5,95)
arbitrage_soc_ceiling: int(10,100)
estop_fitted: bool
log_level: list(trace|debug|info|warning|error)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,551 @@
#!/usr/bin/env python3
"""DATA-02: fetch 12 months of EPEX BE day-ahead prices, in §9.3's shape.
python3 scenarios/fetch_epex_be.py --start 2025-09-01 --end 2026-09-01 \
--out scenarios/epex-be-dayahead-2025-09-01_2026-09-01.json
Run from `goodwe_controller/`. Rewrites the output file from scratch; the
committed dataset is exactly what this produces, and `--verify` re-derives every
claim the file makes about itself without going to the network.
WHAT THIS IS NOT
================
NOT wired into the add-on, and it must never be. `price.py`'s `GenericDayAhead`
is the live path and it has its own egress check; this is a one-shot archival
fetch that produces a file for REPLAY, run by hand by a developer. Nothing under
`app/` imports it.
THE SOURCE, AND WHY THIS ONE
============================
`https://api.energy-charts.info/price?bzn=BE` - Fraunhofer ISE's Energy-Charts
API. No key, no registration.
⚠️ THE LICENCE WAS CHECKED BEFORE ANY DATA WAS COMMITTED, because for this
source it is NOT uniform across bidding zones. The API's own OpenAPI document
(`https://api.energy-charts.info/openapi.json`, /price) splits the zones in two:
"The data for the following bidding zones is licensed as CC BY 4.0 from
Bundesnetzagentur | SMARD.de and is published without changes:
AT, BE (Belgium), CH, CZ, DE-LU, ..."
"The data for the other bidding zones is for private and internal use only.
The utilization of any data, whether in its raw or derived form, for
external or commercial purposes is expressly prohibited. Should you require
licensing for market-related data, please direct your inquiries to the
original data providers, including but not limited to EPEX SPOT SE."
BE is in the FIRST list, so redistribution is permitted with attribution and the
dataset is committed rather than left as a fetch script and a checksum. Every
response also carries the licence per-response in `license_info`, and this
script REFUSES to write a file if that field ever stops saying CC BY 4.0 - a
licence checked once by a human and assumed forever is a licence that changes
silently. Had BE been in the second list, the correct output of this card would
have been this script and a checksum and no data.
Attribution, carried in the dataset's own `provenance` block and reproduced
here: data CC BY 4.0, Bundesnetzagentur | SMARD.de, retrieved via Energy-Charts
(Fraunhofer ISE), https://energy-charts.info, published without changes.
Cross-checked against the upstream on 2026-08-31: SMARD's own chart_data series
`4996/DE` over 2026-04-10 matched this API's BE series on all 96 quarter-hours
with a maximum absolute difference of 0.00 EUR/MWh. "Published without changes"
is a claim worth checking once; `--verify` does not repeat it, because it is a
claim about the SOURCE and not about the file.
RAW WHOLESALE, AND THE ASSUMPTION THAT IS NOT BAKED IN
======================================================
⚠️ WHAT COMES BACK IS RAW WHOLESALE, EUR/MWh, DAY-AHEAD AUCTION CLEARING PRICE.
It is NOT all-in: no supplier markup, no distribution or transmission tariff, no
levies, no VAT. §9.3's canonical point is all-in, and TARIFF-02's whole trap is
a figure that already contains a component being handed to something that adds
it again.
So this file does NOT add one. `eur_per_kwh` in the output is the wholesale
figure converted from EUR/MWh, `provenance.all_in` is `false` beside it, and the
markup/tax assumption a consumer needs lives in `provenance.all_in_assumption`
as NUMBERS TO APPLY rather than numbers already applied. test_arbitrage.py
section 7 applies them where a reader can watch it happen.
The assumption's SHAPE is the part that is not arbitrary, and it decides which
conclusions depend on the guessed numbers at all:
all_in(h) = (wholesale(h) + markup + network_and_levies) * (1 + vat)
* The two ADDITIVE terms cancel out of a spread: max - min is unchanged by
them. So how often a day clears `cycle_cost_threshold_eur` does NOT depend
on the markup guess - only on the VAT multiplier.
* They do NOT cancel out of §17's negative-price guard, which compares ONE
hour's all-in price against zero. A wholesale hour at -0.02 EUR/kWh is
comfortably positive all-in once ~0.12 of fixed charges is added.
Both are asserted arithmetically in section 7 rather than believed, and the
trade count is reported on the raw spread as well, so a reader can see exactly
how much of the answer rests on the guess.
The numbers themselves (2026 Belgian residential dynamic contract, order of
magnitude) are a STATED GUESS, labelled as one everywhere they appear. They are
not a measurement and this card had nothing to measure them with.
HOURS, QUARTER-HOURS, AND THE SEAM IN THE MIDDLE OF THE YEAR
============================================================
⚠️ THE SOURCE CHANGES RESOLUTION PART-WAY THROUGH ANY RECENT 12 MONTHS. EPEX
day-ahead moved to a 15-minute market time unit in October 2025 and the feed
follows: 2025-09-10 comes back as 24 hourly points, 2025-12-10 as 96
quarter-hourly ones. An importer that assumed either one silently loses three
quarters of the year, or reads a quarter as an hour.
The canonical format is hourly, so quarter-hours are aggregated: an hour's price
is the unweighted mean of its four quarters. That is a STATED APPROXIMATION of
the hourly product, not the hourly product, and it errs in the direction that
matters here - averaging discards intra-hour variation, so the observed spread
is a LOWER bound on what a 15-minute strategy would see.
`provenance.resolution` records how many hours came from each source shape.
⚠️ AND A PARTIAL HOUR IS DROPPED, NOT AVERAGED OVER WHAT ARRIVED. An hour with
three of four quarters is an hour nobody published a complete price for; the
mean of three would be an invented number carrying no flag. Dropped hours are
listed in `provenance.gaps` with their timestamps, because a missing hour in a
feed is not a zero price, and a zero price is exactly the value that would make
everything look free.
SIZE: WHY THIS IS PLAIN JSON AND NOT A `.gz`, MEASURED
======================================================
The directory's existing captures are gzipped, so the default would have been to
gzip this too. Measured on the committed file instead of assumed:
working tree, plain JSON 893 171 bytes
what GIT actually stores for it (zlib, loose object) 74 590 bytes
what git would store for a committed `.json.gz` 56 504 bytes
⚠️ GIT ALREADY COMPRESSES BLOBS, so gzipping buys 18 KB - 24 % of an object
that is already 75 KB, in a repository whose three existing P1 captures cost
27 KB, 136 KB and 140 KB. Eighteen kilobytes is not worth making the provenance
block unreadable in a pull request, and the licence claim in that block is
precisely the thing a reviewer of this card has to read and check. Plain JSON
also greps, diffs line-by-line when a future refetch changes something, and is
what `--verify` opens. `load()` still handles `.gz` for a future dataset large
enough to want it; this one is not.
The P1 captures are gzipped for the opposite reason and it is not an
inconsistency: they are hundreds of thousands of telegram lines that no human
reads, and there is no metadata in them to review.
TIME
====
Every instant is carried as UTC and rendered with `app.price.brussels()`, the
module that already owns the EU DST rule without a tz database. There is no
second answer to that question here and there must not be: the point of
`brussels()` is that the 02:00-03:00 hour which happens twice in October is two
instants, and a 24-slot local-hour importer collapses them.
So a year is 8760 hours only if it contains no transition, or contains both and
they cancel - 25 in October, 23 in March. `--verify` therefore asserts the
PER-DAY counts, not the total, because the total is exactly the number a broken
importer also produces.
"""
import argparse
import gzip
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.price import brussels # noqa: E402
API = "https://api.energy-charts.info/price"
BZN = "BE"
LICENCE_MUST_CONTAIN = "CC BY 4.0"
UTC = timezone.utc
ATTRIBUTION = ("Data licensed CC BY 4.0 by Bundesnetzagentur | SMARD.de, "
"retrieved via the Energy-Charts API (Fraunhofer ISE), "
"https://energy-charts.info. Published without changes.")
# ⚠️ A GUESS, LABELLED AS ONE, AND APPLIED BY THE CONSUMER - NOT HERE.
# Order-of-magnitude 2026 Belgian residential dynamic contract. Nothing in this
# repository measured any part of it.
ALL_IN_ASSUMPTION = {
"form": "all_in = (wholesale + markup + network_and_levies) * (1 + vat)",
"markup_eur_per_kwh": 0.020,
"network_and_levies_eur_per_kwh": 0.100,
"vat_fraction": 0.06,
"status": "ASSUMPTION, NOT MEASURED. Stated so a consumer applies it "
"deliberately; the committed eur_per_kwh has none of it applied.",
"note": "The two additive terms cancel out of any spread, so an arbitrage "
"trade count depends on vat_fraction only. They do NOT cancel out "
"of the negative-price guard (17), which compares one hour's "
"all-in price against zero.",
}
PACE_S = 3.0 # between chunks, unconditionally
RETRIES = 6
def _get(url: str) -> dict:
"""One GET, backing off on 429 and 5xx.
⚠️ THIS IS A FREE PUBLIC SERVICE AND IT RATE-LIMITS. The first run of this
script was refused with 429 on its fifth chunk. Backing off is the polite
behaviour; it is also the correct one, because the alternative - catching
the error and carrying on - would produce a file that is silently missing a
month and says nothing about it. A refused request is not an empty month.
"""
req = urllib.request.Request(
url,
headers={"User-Agent": "goodwe-addon/DATA-02 (one-shot archival fetch)"})
wait = 5.0
for attempt in range(1, RETRIES + 1):
try:
with urllib.request.urlopen(req, timeout=90) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code not in (429, 500, 502, 503, 504) or attempt == RETRIES:
raise
print(f" HTTP {e.code}; backing off {wait:.0f}s "
f"(attempt {attempt}/{RETRIES})", file=sys.stderr)
time.sleep(wait)
wait *= 2
raise SystemExit(f"unreachable: {url}")
def local_midnight_utc(day: str) -> datetime:
"""The INSTANT at which Brussels midnight begins on `day` (YYYY-MM-DD).
⚠️ THE WINDOW IS LOCAL, NOT UTC, AND THE FIRST RUN OF THIS SCRIPT PROVED WHY.
Bounding it at 00:00 UTC starts the year at 02:00 CEST, which makes the first
Brussels day 22 hours long - and 22 is not one of the two lengths a
transition produces, so `report()` refused the file. A dataset whose first
day is short is a dataset whose "days per year" arithmetic is quietly wrong
everywhere downstream.
Derived from `brussels()` by trying the only two offsets Belgium has and
keeping the one that round-trips, rather than by restating the EU rule here.
"""
for hours in (1, 2):
guess = datetime.fromisoformat(f"{day}T00:00:00").replace(
tzinfo=timezone(timedelta(hours=hours))).astimezone(UTC)
if brussels(guess).isoformat() == f"{day}T00:00:00+0{hours}:00":
return guess
raise SystemExit(f"no Brussels midnight for {day!r} - brussels() disagrees "
f"with itself, which is a bug in price.py, not here.")
def _months(start: datetime, end: datetime):
"""[start, end) in month-sized chunks, so a bad chunk names its own window."""
a = start
while a < end:
b = min(end, (a.replace(day=1) + timedelta(days=32)).replace(day=1))
yield a, b
a = b
def fetch(start: datetime, end: datetime):
"""Raw {unix_second: EUR/MWh} over [start, end), deduplicated.
⚠️ A `null` price is DROPPED, never coerced. The API returns null for an
interval it holds no result for, and `or 0.0` is the version of this line
that ships a year of free electricity.
"""
seen, licences, nulls = {}, Counter(), 0
for a, b in _months(start, end):
# ⚠️ THE API'S `end` IS AN INCLUSIVE LOCAL DAY, AND THE WINDOW'S IS AN
# EXCLUSIVE INSTANT. Ask for the day containing the LAST hour of the
# chunk, derived from the instant rather than by subtracting a calendar
# day from the bound: `b - 1 day` drops the final local day whenever the
# window ends at local midnight, which is exactly how this window ends.
# The first run lost 2026-08-31 that way, silently - 8736 points instead
# of 8760 - and the points-plus-gaps accounting in report() is what
# noticed. A day nobody asked for is not a day with no prices.
first = brussels(a).date().isoformat()
last = brussels(b - timedelta(hours=1)).date().isoformat()
url = f"{API}?bzn={BZN}&start={first}&end={last}"
d = _get(url)
lic = str(d.get("license_info", ""))
licences[lic] += 1
if LICENCE_MUST_CONTAIN not in lic:
raise SystemExit(
f"REFUSING TO WRITE. {url} came back under licence {lic!r}, "
f"which does not say {LICENCE_MUST_CONTAIN!r}. The BE zone was "
f"redistributable when this card ran; if that has changed, the "
f"right output is this script and a checksum, not the data.")
unit = str(d.get("unit", ""))
if "MWh" not in unit:
raise SystemExit(
f"REFUSING TO WRITE. {url} reports unit {unit!r}, not EUR/MWh. "
f"A silent unit change is a factor of 1000 on every price.")
for t, p in zip(d["unix_seconds"], d["price"]):
if p is None:
nulls += 1
continue
if start.timestamp() <= t < end.timestamp():
seen[int(t)] = float(p)
print(f" {a.date()} .. {b.date()} {len(d['price']):5d} intervals",
file=sys.stderr)
time.sleep(PACE_S)
return seen, licences, nulls
def to_hourly(raw: dict):
"""Hourly means keyed by UTC hour. Returns (hours, gaps, shape_counts).
An hour is emitted only when every interval the source used for it arrived:
1 of 1 where the source was hourly, 4 of 4 where it was quarter-hourly.
Anything else is a gap and is named as one.
"""
buckets = defaultdict(dict)
for t, p in raw.items():
buckets[t - (t % 3600)][t % 3600] = p
hours, gaps, shapes = {}, [], Counter()
for h in sorted(buckets):
got = buckets[h]
if set(got) == {0}:
shapes["hourly"] += 1
hours[h] = got[0]
elif set(got) == {0, 900, 1800, 2700}:
shapes["quarter_hourly_mean"] += 1
hours[h] = sum(got.values()) / 4.0
else:
gaps.append({
"hour_utc": datetime.fromtimestamp(h, UTC).isoformat(),
"intervals_present": sorted(got),
"why": "incomplete hour - dropped rather than averaged over "
"what arrived; a partial read is not an observation",
})
return hours, gaps, shapes
def canonical(hours: dict) -> list:
"""9.3 points. THREE FIELDS, rendered at the Brussels offset for the instant."""
return [{"start": brussels(datetime.fromtimestamp(h, UTC)).isoformat(),
"eur_per_kwh": round(hours[h] / 1000.0, 6) + 0.0,
"provisional": False}
for h in sorted(hours)]
def expected_hours(start: datetime, end: datetime) -> int:
"""Whole hours in the window. Derived, rather than 8760 typed in."""
return int((end - start).total_seconds() // 3600)
def per_local_day(points) -> Counter:
"""Points per BRUSSELS calendar day - the count that exposes a DST bug."""
n = Counter()
for p in points:
n[p["start"][:10]] += 1
return n
def digest(points) -> str:
"""A checksum over the POINTS, so reformatting the envelope does not move it."""
h = hashlib.sha256()
for p in points:
h.update(f"{p['start']}|{p['eur_per_kwh']:.6f}|{int(p['provisional'])}\n"
.encode())
return h.hexdigest()
def load(path: str) -> dict:
op = gzip.open if path.endswith(".gz") else open
with op(path, "rt", encoding="utf-8") as f:
return json.load(f)
def report(doc, *, start=None, end=None) -> int:
"""Re-derive every claim the file makes about itself. Returns failures."""
pts = doc["prices"]
prov = doc["provenance"]
bad = 0
def ck(name, cond):
nonlocal bad
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
bad += 1
ck(f"the checksum over the {len(pts)} points matches the recorded one "
f"({prov['sha256_of_points'][:16]}...)",
digest(pts) == prov["sha256_of_points"])
ck("every point has exactly the three canonical fields and nothing else",
all(set(p) == {"start", "eur_per_kwh", "provisional"} for p in pts))
ck("every start carries a UTC offset, and it is +01:00 or +02:00 - a naive "
"timestamp here is a wall clock plus an assumption",
bool(pts) and all(p["start"][-6:] in ("+01:00", "+02:00") for p in pts))
ck("the series is strictly increasing in time, so no instant is duplicated",
all(datetime.fromisoformat(a["start"]) < datetime.fromisoformat(b["start"])
for a, b in zip(pts, pts[1:])))
ck("consecutive points are exactly one hour apart AS INSTANTS, so the two "
"02:00s of the October day are two hours rather than one",
all((datetime.fromisoformat(b["start"])
- datetime.fromisoformat(a["start"])) == timedelta(hours=1)
for a, b in zip(pts, pts[1:])))
days = per_local_day(pts)
odd = {d: n for d, n in days.items() if n != 24}
ck(f"exactly two Brussels days are not 24 hours long, and they are the "
f"transitions: {sorted(odd.items())}",
sorted(odd.values()) == [23, 25] and len(odd) == 2)
spring = [d for d, n in odd.items() if n == 23]
autumn = [d for d, n in odd.items() if n == 25]
ck(f"the 23-hour day {spring} is in March and the 25-hour day {autumn} is "
f"in October, which is the direction the EU rule actually runs",
len(spring) == 1 and len(autumn) == 1
and spring[0][5:7] == "03" and autumn[0][5:7] == "10")
if autumn:
twice = [p["start"] for p in pts
if p["start"].startswith(autumn[0] + "T02")]
ck(f"...and 02:00 on {autumn[0]} occurs TWICE at two different offsets "
f"{twice} - exactly the case a 24-slot local-hour array collapses",
len(twice) == 2 and twice[0][-6:] == "+02:00"
and twice[1][-6:] == "+01:00")
ck(f"...and the two carry DIFFERENT prices, so the 25th hour is a real "
f"observation and not the same one written down twice",
len({p["eur_per_kwh"] for p in pts
if p["start"].startswith(autumn[0] + "T02")}) == 2)
if spring:
ck(f"...and 02:00 on {spring[0]} does not occur at all, because that "
f"wall clock reading never happened in Belgium",
not any(p["start"].startswith(spring[0] + "T02") for p in pts))
if start and end:
want = expected_hours(start, end)
ck(f"{len(pts)} points for a window of {want} whole hours, and the "
f"{len(prov['gaps'])} recorded gaps account for the difference "
f"exactly", len(pts) + len(prov["gaps"]) == want)
# ⚠️ A ZERO IS NOT AUTOMATICALLY A DEFECT HERE, AND SAYING IT WAS WOULD BE
# THE WRONG LESSON. The Belgian market really does clear at 0.00 EUR/MWh in
# solar-glut hours; this window contains such hours. What must be impossible
# is a zero that arrived by COERCION - a dropped read turned into a free
# hour. That is excluded structurally rather than by outlawing the value:
# nulls are dropped, incomplete hours become gaps, and the accounting above
# shows points + gaps covering every hour of the window, so every emitted
# point stands for intervals the source actually published.
zeros = [p["start"] for p in pts if p["eur_per_kwh"] == 0.0]
ck(f"the {len(zeros)} hours priced at exactly zero match the count recorded "
f"at build time, so no zero appeared between the fetch and the file",
len(zeros) == prov["exact_zero_hours"])
ck("...and none of them is a coerced read: every emitted hour is backed by "
"published intervals, which is what the points-plus-gaps accounting "
"above establishes",
len(prov["gaps"]) == 0 or all("intervals_present" in g
for g in prov["gaps"]))
ck("the figures are declared RAW WHOLESALE, and the all-in assumption is "
"recorded as an assumption rather than applied",
prov["all_in"] is False
and "ASSUMPTION" in prov["all_in_assumption"]["status"])
return bad
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--start", default="2025-09-01")
ap.add_argument("--end", default="2026-09-01",
help="exclusive; both bounds are BRUSSELS-local midnights")
ap.add_argument("--out", default=None)
ap.add_argument("--verify", default=None,
help="re-derive the claims of an existing file; no network")
a = ap.parse_args()
if a.verify:
doc = load(a.verify)
print(f"verifying {a.verify}")
s = datetime.fromisoformat(doc["provenance"]["window_utc"]["start"])
e = datetime.fromisoformat(doc["provenance"]["window_utc"]["end"])
bad = report(doc, start=s, end=e)
print("OK" if not bad else f"{bad} FAILED")
return 1 if bad else 0
start = local_midnight_utc(a.start)
end = local_midnight_utc(a.end)
out = a.out or f"scenarios/epex-be-dayahead-{a.start}_{a.end}.json"
print(f"fetching {BZN} day-ahead {start.date()} .. {end.date()}",
file=sys.stderr)
raw, licences, nulls = fetch(start, end)
hours, gaps, shapes = to_hourly(raw)
pts = canonical(hours)
doc = {
"provenance": {
"card": "DATA-02",
"what": "EPEX BE day-ahead auction clearing prices, hourly",
"source": f"{API}?bzn={BZN} (Energy-Charts API, Fraunhofer ISE)",
"upstream": "Bundesnetzagentur | SMARD.de",
"licence": "CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)",
"licence_checked": "before committing anything; the API's OpenAPI "
"document lists BE among the CC BY 4.0 zones and "
"the remaining zones as private/internal use "
"only, so redistribution of THIS zone is "
"permitted with attribution",
"licence_as_returned": sorted(licences),
"attribution": ATTRIBUTION,
"cross_check": "SMARD chart_data 4996/DE over 2026-04-10 matched "
"this feed on all 96 quarter-hours, max abs "
"difference 0.00 EUR/MWh (2026-08-31)",
"retrieved_utc": datetime.now(UTC).replace(microsecond=0).isoformat(),
"fetched_by": "scenarios/fetch_epex_be.py",
"window_utc": {"start": start.isoformat(), "end": end.isoformat()},
"unit": "EUR per kWh (source publishes EUR/MWh; divided by 1000)",
"all_in": False,
"all_in_note": "RAW WHOLESALE. No supplier markup, no network or "
"transmission tariff, no levies, no VAT. 9.3's "
"canonical point is all-in and this is NOT; the "
"consumer applies the assumption below, visibly.",
"all_in_assumption": ALL_IN_ASSUMPTION,
"resolution": {
"note": "EPEX day-ahead moved to a 15-minute market time unit "
"in Oct 2025, part-way through this window.",
"hours_from_hourly_source": shapes["hourly"],
"hours_from_quarter_hourly_mean": shapes["quarter_hourly_mean"],
"aggregation": "unweighted mean of the four quarters; a stated "
"approximation of the hourly product, and a "
"LOWER bound on the spread a 15-minute strategy "
"would have seen",
},
"timezone": "starts rendered at the Brussels offset by "
"app.price.brussels(); instants carried in UTC, so the "
"transition days come out 23 and 25 points by "
"construction rather than by a special case",
"nulls_dropped": nulls,
"gaps": gaps,
"gaps_note": "hours the source did not publish completely. Dropped, "
"never zero-filled: a missing hour is not a free hour.",
"points": len(pts),
"exact_zero_hours": sum(1 for p in pts if p["eur_per_kwh"] == 0.0),
"negative_hours": sum(1 for p in pts if p["eur_per_kwh"] < 0),
"zero_note": "REAL CLEARINGS AT 0.00 EUR/MWh, not dropped reads. "
"Nulls are dropped and incomplete hours become gaps, "
"so points + gaps == hours in the window and no point "
"stands in for something that failed to arrive.",
"sha256_of_points": digest(pts),
},
"prices": pts,
}
with open(out, "w", encoding="utf-8", newline="\n") as f:
json.dump(doc, f, indent=1, ensure_ascii=False)
f.write("\n")
print(f"wrote {out} {len(pts)} points {os.path.getsize(out)} bytes",
file=sys.stderr)
print(f"verifying {out}")
return 1 if report(doc, start=start, end=end) else 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8
View File
@@ -212,6 +212,14 @@ def shipped(key):
if in_schema or not line.startswith(f" {key}:"):
continue
raw = line.split(":", 1)[1].split("#", 1)[0].strip().strip('"').strip("'")
# OPT-04 added the first BOOLEAN price option, and without this the
# mirror check would compare the string "false" against `False` and go
# red for a file that is perfectly in step. Parsing a bool as a bool is
# what the yaml module this image does not carry would have done; it
# widens nothing, because a key whose shipped value disagrees with the
# mirror still drifts.
if raw in ("true", "false"):
return raw == "true"
try:
return int(raw)
except ValueError:
+911
View File
@@ -0,0 +1,911 @@
"""Runnable check for FCST-03 - provisional prices. `python3 test_price_forecast.py`
WHAT THIS FILE IS DEFENDING, in the order the ticket's acceptance criteria name
it:
AC1 Every price point carries a `provisional` flag MATCHING ITS TRUE
PROVENANCE - a point filled from yesterday's profile is provisional even
when the number happens to be right, a point the feed published keeps
whatever flag the feed gave it, and an unmarked carry-forward is not
constructible rather than merely discouraged.
AC2 An irreversible action gated on a provisional price is REJECTED, with the
spec's own example - a maintenance-phase transition - actually refused:
the phase machine is still in `idle` afterwards.
AC3 Real-time / imbalance prices get MEASURABLY WIDER thresholds. Measured,
by finding the smallest spread each regime will accept and comparing the
two numbers - not by reading a constant back out of the module that
defines it.
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
* EVERY VERDICT IS DERIVED. The carried/published split is re-derived here
from the set of hours the fixture published, by different arithmetic than
app/price_forecast.py uses; the DST day lengths come from the offset
difference rather than from a 23 or a 25 typed into a check; the widening
factor is MEASURED by bisecting the accept/refuse boundary and then
compared with the declared one.
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE, and
this ticket is full of that trap:
- YESTERDAY AND TODAY MUST PRICE EVERY HOUR DIFFERENTLY, or a fill that
carried the wrong day forward is invisible. Asserted first, as a check.
- THE PROVISIONAL HOURS MUST CONTAIN THE CHEAPEST HOUR, or "arbitrage
ignores provisional hours" grades nothing: the firm-only spread and the
whole-series spread would be the same number. Asserted before it is
used, and it is the realistic shape of the danger - yesterday's cheap
hour luring a charge that today's prices do not justify.
- THE TWO WIDENING FACTORS MUST DIFFER, or `max`, `sum` and `product` are
indistinguishable combination rules. Asserted, then all three are
computed and only one matches what the code does.
- THE LOCAL-HOUR FILL AND A NAIVE `t - 24 h` FILL MUST DISAGREE on the
transition days, or the DST check passes for both implementations.
Asserted, then the correct one is graded.
- PRICES ARE WRITTEN TO SIX DECIMALS with distinct 3-, 4- and 6-dp
roundings, so an implementation that rounds differently is visible.
(TARIFF-03's fixtures were 3-dp, where those three are one number.)
* A FAILED READ IS NOT AN OBSERVATION. A missing price is not a zero price
and a stale one is not a fresh one: the gate blocks on `no_data`, blocks on
an hour the series does not cover, and blocks on a provisional hour - three
different reasons, none of them an answer.
"""
import ast
import inspect
import os
import sys
from datetime import datetime, timedelta, timezone
from app import price as _price
from app import price_forecast as _fc
from app import static_tariff as _st
from app.maintenance import (
CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN, DRAIN, IDLE, MaintConfig, Maintenance,
)
from app.price import (
Absence, PricePoint, PriceProduct, PriceRead, PROVIDERS, Uncertainty,
WIDEN_FACTOR, brussels, brussels_offset, plan_is_provisional,
required_spread, uncertainty_of, widen_factor,
)
from app.price_forecast import (
MAINTENANCE_CHARGE, MAINTENANCE_DRAIN, Gate, Irreversible, committed_hours,
fill_from_yesterday, price_permits, begin_phase,
)
from app.arbitrage import ArbitrageDecision
from app.static_tariff import Arbitrage, TariffPlan
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def raises(fn, exc):
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
'it was refused' and 'it broke on the way to being refused' must not grade
the same."""
try:
fn()
except exc:
return True
except Exception:
return False
return False
class _Raised:
def __init__(self, e):
self.e = e
def __eq__(self, other):
return False
def __hash__(self):
return id(self)
def __bool__(self):
return False
def __repr__(self):
return f"<raised {type(self.e).__name__}: {self.e}>"
def derived(fn):
"""A value a check grades. An exception becomes `_Raised`, which equals
nothing and is falsy - NOT a default."""
try:
return fn()
except Exception as e: # noqa: BLE001
return _Raised(e)
def near(a, b, tol=1e-9):
try:
return abs(float(a) - float(b)) <= tol
except (TypeError, ValueError):
return False
# --------------------------------------------------------------------------- #
# Fixtures.
#
# ⚠️ SIX-DECIMAL PRICES, DELIBERATELY. TARIFF-03's fixtures were 0.3410 /
# 0.2570, where rounding to 3, 4 and 6 decimals gives the same number - so an
# implementation that rounded differently from the one being graded survived
# every check. These are chosen so those three roundings differ.
# --------------------------------------------------------------------------- #
def y_price(h):
"""Yesterday's all-in EUR/kWh for local hour h."""
return round(0.123437 + 0.010700 * h, 6)
def t_price(h):
"""Today's PUBLISHED all-in EUR/kWh for local hour h. Different from
yesterday's at every hour, on purpose - see the degeneracy note above."""
return round(0.206562 + 0.009300 * h, 6)
def local_midnight(y, m, d) -> datetime:
"""The UTC instant of Brussels local midnight on that date, derived from
`brussels_offset` rather than from a table of dates."""
naive = datetime(y, m, d, 0, 0)
for hours in (1, 2):
guess = naive.replace(tzinfo=UTC) - timedelta(hours=hours)
if brussels_offset(guess).utcoffset(None) == timedelta(hours=hours):
return guess
raise AssertionError(f"no consistent Brussels offset for {naive}")
def hours_between(a, b):
out, u = [], a
while u < b:
out.append(u)
u += timedelta(hours=1)
return out
def series_of(instants, price_of, product, provider, *, provisional):
return PriceRead.series(
[PricePoint(start=brussels(u), eur_per_kwh=price_of(u),
provisional=provisional) for u in instants],
product, provider, instants[0])
# A plain winter day: local 2026-01-15, 24 hours, no transition in sight.
D0 = local_midnight(2026, 1, 15)
D1 = local_midnight(2026, 1, 16)
YEST0 = local_midnight(2026, 1, 14)
DAY = hours_between(D0, D1)
YDAY = hours_between(YEST0, D0)
YESTERDAY = series_of(YDAY, lambda u: y_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False)
# The feed has published the first 12 hours of today and nothing after - the
# ordinary state of a dynamic site before the 14-16 h CET print (§5.4).
PUBLISHED_N = 12
TODAY_PART = series_of(DAY[:PUBLISHED_N], lambda u: t_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False)
print()
print("0. the fixtures are not degenerate (checked before anything is graded)")
check("yesterday and today price EVERY shared hour differently, so a fill that "
"carried the wrong day forward would be visible in the value and not "
"only in the flag",
all(y_price(h) != t_price(h) for h in range(24)))
_roundings = derived(lambda: {round(y_price(3), n) for n in (3, 4, 6)})
check(f"...and a fixture price rounds to three DIFFERENT numbers at 3, 4 and 6 "
f"decimals {sorted(_roundings)}, so a divergent rounding cannot hide",
len(_roundings) == 3)
check("...and both widening factors differ, so max / sum / product are three "
"distinguishable answers",
len(set(WIDEN_FACTOR.values())) == len(WIDEN_FACTOR) >= 2)
# --------------------------------------------------------------------------- #
print()
print("1. AC1 - the flag matches PROVENANCE, and an unmarked fill is not "
"constructible")
FILLED = fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY,
since=D0, until=D1, stamp=D0)
_pub_starts = {u for u in DAY[:PUBLISHED_N]}
_expected_prov = {u: (u not in _pub_starts) for u in DAY}
_got_prov = derived(lambda: {p.start.astimezone(UTC): p.provisional
for p in FILLED.points})
check(f"the filled series covers every hour of the day "
f"{len(FILLED.points) if FILLED.kind == 'series' else FILLED}",
FILLED.kind == "series" and len(FILLED.points) == len(DAY))
check("...and EVERY point's flag equals what its provenance says it should be, "
"re-derived here from the set of hours the fixture published",
_got_prov == _expected_prov)
_carried = derived(lambda: [p for p in FILLED.points if p.provisional])
check(f"...which is {len(DAY) - PUBLISHED_N} carried hours and {PUBLISHED_N} "
f"published ones, counted rather than asserted",
len(_carried) == len(DAY) - PUBLISHED_N)
check("...and the carried hours carry YESTERDAY's number for the same wall "
"clock hour, not today's and not a neighbour's",
all(near(p.eur_per_kwh, y_price(brussels(p.start).hour))
for p in _carried))
check("...while the published hours carry TODAY's, so the two halves are not "
"the same data wearing two flags",
all(near(p.eur_per_kwh, t_price(brussels(p.start).hour))
for p in FILLED.points if not p.provisional))
# ⚠️ THE COPY IS THE DEFECT. A published point that survives as a COPY is one
# whose flag could have been rewritten on the way through; identity is the only
# property that rules that out for every field at once.
check("a published point is passed through BY IDENTITY - the object the "
"provider produced is the object in the filled series",
all(any(p is q for q in TODAY_PART.points)
for p in FILLED.points if not p.provisional))
check("...and the canonical §9.3 dict carries the flag out to the wire for "
"both kinds",
FILLED.canonical()["prices"][0]["provisional"] is False
and FILLED.canonical()["prices"][-1]["provisional"] is True)
check("...and plan_is_provisional() sees the series as provisional overall, "
"because at least one hour is",
plan_is_provisional(FILLED) is True)
# The audit is a post-condition that RAISES. Feed it the exact defect.
_stale_copy = PricePoint(start=FILLED.points[-1].start,
eur_per_kwh=FILLED.points[-1].eur_per_kwh,
provisional=False) # the copy-the-flag bug
check("the provenance audit REFUSES a carried hour flagged non-provisional - "
"the 'stale but plausible' point, which is SAFETY-18's frozen-but-fresh "
"meter one layer up",
raises(lambda: _fc._audit([_stale_copy], {}), ValueError))
check("...and refuses a published hour that arrived as a COPY rather than as "
"the published object, which is where a flag gets lost",
raises(lambda: _fc._audit(
[PricePoint(start=TODAY_PART.points[0].start,
eur_per_kwh=TODAY_PART.points[0].eur_per_kwh,
provisional=TODAY_PART.points[0].provisional)],
{D0: TODAY_PART.points[0]}), ValueError))
check("...and never repairs a flag: the audit's only outcomes are silence and "
"an exception, so a series it passed was already correct",
_fc._audit(list(FILLED.points), {u: TODAY_PART.points[i]
for i, u in enumerate(DAY[:PUBLISHED_N])})
is None)
# ⚠️ A PUBLISHED POINT MAY BE PROVISIONAL. eneco_eddy's current-quarter print
# is settled after the fact and says so (§9.2). "Published" is not "firm".
_eddy = series_of(DAY[:2], lambda u: t_price(brussels(u).hour),
PriceProduct.REALTIME, "eneco_eddy", provisional=True)
_eddy_filled = derived(lambda: fill_from_yesterday(
_eddy, yesterday=YESTERDAY, since=D0, until=D1, stamp=D0))
check("a PUBLISHED point that the feed itself flagged provisional keeps that "
"flag - the audit checks provenance, and does not force published hours "
"to False",
_eddy_filled.kind == "series"
and all(p.provisional for p in _eddy_filled.points[:2])
and _eddy_filled.points[0] is _eddy.points[0])
# The structural half: the constructor has nothing to pass, and the file is
# scanned so the pass-through idiom cannot come back looking ordinary.
check("_carried() has NO `provisional` parameter, so a caller has nothing to "
"forget, default, or copy from the point being carried",
"provisional" not in inspect.signature(_fc._carried).parameters)
_SRC = derived(lambda: open(os.path.join(HERE, "app", "price_forecast.py"),
encoding="utf-8").read())
def _nonliteral_provisional(src):
"""Every `PricePoint(...)` in the module whose `provisional` is not a
literal. AST, not a substring: the module docstring quotes the defect it
exists to prevent, and a text scan would match its own documentation."""
out = []
for node in ast.walk(ast.parse(src)):
if isinstance(node, ast.Call) and \
getattr(node.func, "id", None) == "PricePoint":
kw = {k.arg: k.value for k in node.keywords}
v = kw.get("provisional")
if not isinstance(v, ast.Constant):
out.append(ast.dump(node)[:80])
return out
_leaks = derived(lambda: _nonliteral_provisional(_SRC))
check(f"no PricePoint built in app/price_forecast.py takes its `provisional` "
f"from an expression - it is a literal at every construction site, so "
f"`provisional=y.provisional` cannot reappear in a later edit {_leaks}",
_leaks == [])
# The scan must be able to fail, or it grades nothing.
check("...and that scan FAILS on the defect it names, so it is a check and not "
"a decoration",
_nonliteral_provisional(
"PricePoint(start=t, eur_per_kwh=v, provisional=y.provisional)") != [])
# --------------------------------------------------------------------------- #
print()
print("2. the fill keys on the WALL CLOCK, so the 23- and 25-hour days are right")
for label, (y, m, d) in (("spring forward (23 h)", (2026, 3, 29)),
("fall back (25 h)", (2026, 10, 25))):
a = local_midnight(y, m, d)
b = local_midnight(y, m, d + 1)
yb = local_midnight(y, m, d - 1)
yesterday = series_of(hours_between(yb, a),
lambda u: y_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False)
# Nothing published at all yet: the whole day is carried forward.
nothing = PriceRead.no_data("the day-ahead print has not landed yet")
got = derived(lambda: fill_from_yesterday(nothing, yesterday=yesterday,
since=a, until=b, stamp=a))
# ⚠️ DERIVED FROM THE OFFSETS, not from a 23 or a 25 typed here.
off_a = brussels_offset(a).utcoffset(None).total_seconds() / 3600
off_b = brussels_offset(b - timedelta(seconds=1)).utcoffset(None) \
.total_seconds() / 3600
expect = int(24 - (off_b - off_a))
check(f"{label}: the day is {expect} hours long, derived from the offset "
f"change {off_a:+.0f} -> {off_b:+.0f}, and the fill emits exactly "
f"that many",
got.kind == "series" and len(got.points) == expect)
check(f"{label}: every hour of it is flagged provisional - nothing was "
f"published",
got.kind == "series" and all(p.provisional for p in got.points))
# The discriminating part: a naive `t - 24 h` fill would give a DIFFERENT
# answer here, so this check separates the two implementations.
y_by_utc = {u: y_price(brussels(u).hour) for u in hours_between(yb, a)}
naive = [y_by_utc.get(u - timedelta(hours=24)) for u in hours_between(a, b)]
wall = [y_price(brussels(u).hour) for u in hours_between(a, b)]
check(f"{label}: the wall-clock fill and a naive t-24h fill DISAGREE, so "
f"the check below can tell them apart",
naive != wall)
check(f"{label}: ...and the emitted values are the WALL-CLOCK ones",
got.kind == "series"
and [round(p.eur_per_kwh, 6) for p in got.points] == wall)
# The 25-hour day's two 02:00 hours are two distinct instants, both filled.
_oct = local_midnight(2026, 10, 25)
_oct_end = local_midnight(2026, 10, 26)
_oct_fill = fill_from_yesterday(
PriceRead.no_data("nothing yet"),
yesterday=series_of(hours_between(local_midnight(2026, 10, 24), _oct),
lambda u: y_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False),
since=_oct, until=_oct_end, stamp=_oct)
_twos = [p for p in _oct_fill.points if brussels(p.start).hour == 2]
check("the 25-hour day has TWO local 02:00 points, at different offsets, and "
"both are filled and both are flagged - a 24-slot implementation would "
"collide them into one",
len(_twos) == 2
and _twos[0].start.utcoffset() != _twos[1].start.utcoffset()
and all(p.provisional for p in _twos))
# --------------------------------------------------------------------------- #
print()
print("3. §5.4 - a late publication REPLACES a carried hour and re-flags it")
_late = fill_from_yesterday(
series_of(DAY, lambda u: t_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead", provisional=False),
yesterday=YESTERDAY, since=D0, until=D1, stamp=D1)
_h20 = DAY[20]
_before = derived(lambda: FILLED.at(_h20))
_after = derived(lambda: _late.at(_h20))
check("before the print, hour 20 is provisional and carries YESTERDAY's number",
_before.provisional is True and near(_before.eur_per_kwh, y_price(20)))
check("...after it, the same hour is FIRM and carries today's - the flag and "
"the number move together, because the series is rebuilt and not patched",
_after.provisional is False and near(_after.eur_per_kwh, t_price(20)))
check("...and the two numbers actually differ, so 'it was replaced' is a "
"measurement and not a restatement",
not near(_before.eur_per_kwh, _after.eur_per_kwh))
check("...and re-running the fill over an already-complete series changes "
"nothing: it is idempotent",
[p.canonical() for p in fill_from_yesterday(
_late, yesterday=YESTERDAY, since=D0, until=D1, stamp=D1).points]
== [p.canonical() for p in _late.points])
# Nothing to fill from, and nothing published: no_data, never an empty series.
_nothing = derived(lambda: fill_from_yesterday(
PriceRead.no_data("the feed timed out"),
yesterday=PriceRead.no_data("and there is no yesterday either"),
since=D0, until=D1, stamp=D0))
check("with neither a published hour nor a yesterday, the result is no_data - "
"not an empty series, and not a series of zeros",
_nothing.kind == "no_data")
check("...so it has no .points to iterate and no .get() to default out of",
raises(lambda: _nothing.points, Absence)
and raises(lambda: _nothing.get("points", []), Absence))
check("...and its reason names §11.4 for an operator",
"§11.4" in _nothing.reason)
check("a naive `since` is refused rather than silently interpreted as UTC",
raises(lambda: fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY,
since=datetime(2026, 1, 15),
until=D1, stamp=D0), ValueError))
check("...and `yesterday` has no default, so a fill with nothing to fill from "
"cannot be written by accident",
raises(lambda: fill_from_yesterday(TODAY_PART, since=D0, until=D1,
stamp=D0), TypeError))
# --------------------------------------------------------------------------- #
print()
print("4. AC2 - an irreversible action is REJECTED on a provisional price")
class FakeStore:
"""Same surface as app/store.Store, no disk. Copied from test_maintenance."""
def __init__(self):
self.data = {"phase": IDLE, "phase_started": None,
"last_completed": None, "last_start_attempt": None}
def set(self, k, v):
self.data[k] = v
def set_time(self, k, when):
self.data[k] = when
def get_time(self, k):
return self.data.get(k)
def save(self):
pass
def maint():
return Maintenance(MaintConfig(enabled=True), FakeStore())
check("the drain's commitment window is DERIVED from the phase machine's own "
"timeout, so the gate and maintenance.py cannot drift apart",
near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0)
and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0))
# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on
# schedule runs into the evening - hours a morning-only day-ahead print has not
# covered. That is the ticket's scenario and it is where the window is placed.
START = D0 + timedelta(hours=MaintConfig().start_hour)
_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN))
check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} "
f"whole hours, one per hour of the timeout",
len(_hours) == int(DRAIN_TIMEOUT_MIN / 60))
check("an action that commits nothing is not an irreversible one and cannot be "
"declared", raises(lambda: Irreversible("X", 0), ValueError)
and raises(lambda: Irreversible("", 4), ValueError))
# The stimulus: the first hours are firm, the drain's window reaches into the
# carried ones. THIS is the ticket's scenario - a cycle started at 10:00 on
# prices that were published for the morning and guessed for the evening.
_m = maint()
_gate_bad = derived(lambda: begin_phase(_m, START, FILLED, MAINTENANCE_DRAIN))
_first_prov = derived(lambda: next(u for u in _hours if FILLED.at(u).provisional))
check("the drain's window really does reach past the published hours while "
"staying INSIDE the series, so the refusal below is about provenance and "
"not about a short series",
FILLED.covers(_hours[-1])
and any(not FILLED.at(u).provisional for u in _hours)
and any(FILLED.at(u).provisional for u in _hours))
check("a maintenance DRAIN whose window includes a carried-forward hour is "
"BLOCKED", _gate_bad.kind == "blocked")
check("...and the phase machine did not move: it is still idle, so the "
"rejection is of the ACTION and not merely of a verdict object",
_m.phase == IDLE)
check("...and the reason names §8.3, the offending hour, and when the real "
"price is expected",
"§8.3" in _gate_bad.reason
and _first_prov.isoformat() in _gate_bad.reason
and "14-16" in _gate_bad.reason)
# The positive arm, so the block above is discriminating rather than universal.
_firm_long = series_of(hours_between(START,
START + timedelta(hours=len(_hours) + 2)),
lambda u: t_price(brussels(u).hour % 24),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False)
_m2 = maint()
_gate_ok = derived(lambda: begin_phase(_m2, START, _firm_long,
MAINTENANCE_DRAIN))
check("...while the SAME transition on a fully published series is PERMITTED, "
"and the phase machine actually moves to drain",
_gate_ok.kind == "permitted" and _m2.phase == DRAIN)
check("...and the permitting arm carries the evidence it checked - one firm "
"point per committed hour",
_gate_ok.kind == "permitted" and len(_gate_ok.hours) == len(_hours)
and not any(p.provisional for p in _gate_ok.hours))
# The other two ways a price can fail to be an observation.
_m3 = maint()
_gate_none = begin_phase(_m3, START, PriceRead.no_data("the feed timed out"),
MAINTENANCE_DRAIN)
check("no price data at all BLOCKS an irreversible action too - a missing "
"price is not a cheap one (§11.4)",
_gate_none.kind == "blocked" and "§11.4" in _gate_none.reason
and _m3.phase == IDLE)
_short = series_of(DAY[:4], lambda u: t_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead",
provisional=False)
_gate_short = price_permits(_short, D0, MAINTENANCE_DRAIN)
check("...and so does an hour the series does not cover, which is §11.4 for "
"that hour rather than a reason to reach for a neighbour",
_gate_short.kind == "blocked" and "no price covers" in _gate_short.reason)
# The variant type itself, held to the same shape as PriceRead and Arbitrage.
check("a BLOCKED gate carries a reason and nothing else: .hours and .action "
"raise Absence rather than answering with an empty tuple",
raises(lambda: _gate_bad.hours, Absence)
and raises(lambda: _gate_bad.action, Absence))
check("...it is not iterable and has no .get(), the other two routes to an "
"empty, harmless-looking loop",
raises(lambda: list(_gate_bad), Absence)
and raises(lambda: _gate_bad.get("hours", []), Absence))
check("...match() demands BOTH arms, so the block cannot be fallen through",
raises(lambda: _gate_bad.match(permitted=lambda g: 1), TypeError))
check("...and there is no falsy variant, so `if gate:` cannot look like a "
"guard while being none",
bool(_gate_bad) is True and bool(_gate_ok) is True)
# A genuinely carried point - the flagged one, not the mis-flagged copy above.
_prov_pt = FILLED.points[-1]
check("the point the two constructor checks below are handed really is "
"provisional, or they would be grading nothing",
_prov_pt.provisional is True)
check("a permitted gate cannot be built over a provisional hour or over no "
"hours at all - §8.3 is the one rule this type has",
raises(lambda: Gate.permit(MAINTENANCE_DRAIN, [_prov_pt]), ValueError)
and raises(lambda: Gate.permit(MAINTENANCE_DRAIN, []), ValueError))
# TEST-14, PORTED. A default on any of these constructors reopens the exact
# hole `_NoData.reason` had - and static_tariff's `_Refused.reason` still has,
# because nothing scanned it. Scanned by SIGNATURE, so a default added later is
# red without anyone remembering to write a new case.
def _defaulted(fn):
try:
ps = list(inspect.signature(fn).parameters.values())
except (TypeError, ValueError) as e: # pragma: no cover
return _Raised(e)
return [q.name for q in ps
if q.name != "self" and q.default is not inspect.Parameter.empty]
_ctors = {"Gate.permit": Gate.permit, "Gate.block": Gate.block,
"Gate.match": Gate.match,
"_Permitted.__init__": _fc._Permitted.__init__,
"_Blocked.__init__": _fc._Blocked.__init__,
# ⚠️ static_tariff's outcome type is scanned HERE because nothing
# scanned it before: TEST-14 records `_Refused.reason` gaining a
# default as a mutation that survived every check in that file.
"Arbitrage.allow": Arbitrage.allow, "Arbitrage.refuse": Arbitrage.refuse,
"Arbitrage.match": Arbitrage.match,
"_Allowed.__init__": _st._Allowed.__init__,
"_Refused.__init__": _st._Refused.__init__,
# ⚠️ OPT-04's outcome type is scanned HERE, in the same dict, rather
# than by a hand-written case in test_arbitrage.py - which is TEST-14's
# actual instruction. A new outcome type extends the SCAN; a
# hand-written case is the thing that gets forgotten, and the whole
# reason this dict exists instead of five separate checks.
"ArbitrageDecision.__init__": ArbitrageDecision.__init__}
_with_defaults = derived(lambda: {n: d for n, d in
((n, _defaulted(f)) for n, f in _ctors.items())
if d})
check(f"no constructor or handler of an outcome type - Gate's OR Arbitrage's - "
f"carries a DEFAULT, so a verdict can never be built out of nothing "
f"{_with_defaults}", _with_defaults == {})
check("...so a block with no reason is a TypeError at the call site, not a "
"blank status line",
raises(lambda: Gate.block(), TypeError)
and raises(lambda: Gate.block(""), ValueError))
check("...and the same holds for the refusal FCST-03 added to Arbitrage",
raises(lambda: Arbitrage.refuse(), TypeError))
# ⚠️ ONE CALL PER ROLE, AND THE FIRST DRAFT SHOWED WHY. Passing the provisional
# point as BOTH `cheapest` and `dearest` is a stimulus that cannot distinguish
# "the constructor guards the cheapest hour" from "the constructor guards both
# hours": a guard on either one alone raises, and the check goes green for an
# implementation that leaves the sell hour unguarded. That is the degeneracy
# rule this file states at the top, in a new dress - so the two roles are
# stimulated SEPARATELY, each with a firm point in the other seat.
_firm_pt = TODAY_PART.points[0]
check("the two points these constructor checks use really differ in provenance, "
"or neither of them grades anything",
_prov_pt.provisional is True and _firm_pt.provisional is False)
check("an ALLOWED arbitrage verdict cannot be constructed with a provisional "
"CHEAPEST hour - the buy hour - even directly: §8.3 is enforced at the "
"constructor, not only in the ladder above it",
raises(lambda: Arbitrage.allow(0.1, _prov_pt, _firm_pt, "normal",
PriceProduct.DAY_AHEAD, 0.0, frozenset()),
ValueError))
check("...nor with a provisional DEAREST hour - the sell hour - which a guard "
"written for `cheapest` alone would have let through",
raises(lambda: Arbitrage.allow(0.1, _firm_pt, _prov_pt, "normal",
PriceProduct.DAY_AHEAD, 0.0, frozenset()),
ValueError))
check("...while two firm points build the verdict perfectly well, so the two "
"refusals above are discriminating rather than universal",
Arbitrage.allow(0.1, _firm_pt, _firm_pt, "normal",
PriceProduct.DAY_AHEAD, 0.0, frozenset()).kind
== "allowed")
# --------------------------------------------------------------------------- #
print()
print("5. AC3 - real-time thresholds are MEASURABLY wider (measured, not read)")
def plan_with_spread(spread, product, provider):
"""A two-hour series with exactly `spread` between its two prices."""
lo, hi = 0.150000, round(0.150000 + spread, 6)
pts = [PricePoint(start=brussels(D0), eur_per_kwh=lo, provisional=False),
PricePoint(start=brussels(D0 + timedelta(hours=1)),
eur_per_kwh=hi, provisional=False)]
return TariffPlan(PriceRead.series(pts, product, provider, D0), None,
"provider")
def min_accepted_spread(product, sources, base, provider="generic_dayahead"):
"""The SMALLEST spread this configuration will trade on, found by bisecting
the accept/refuse boundary of the real verdict. Nothing here reads a
factor out of the module it is grading."""
lo, hi = 0.0, 4.0
for _ in range(60):
mid = (lo + hi) / 2
v = plan_with_spread(mid, product, provider).arbitrage(
peak_guard_active=False, min_spread_eur_kwh=base,
uncertainty=sources)
if v.kind == "allowed":
hi = mid
else:
lo = mid
return hi
BASE = 0.104000 # a caller's own round-trip cost threshold, EUR/kWh
_normal = derived(lambda: min_accepted_spread(PriceProduct.DAY_AHEAD,
frozenset(), BASE))
_wide = derived(lambda: min_accepted_spread(PriceProduct.REALTIME,
frozenset(), BASE))
_weather = derived(lambda: min_accepted_spread(
PriceProduct.DAY_AHEAD, frozenset({Uncertainty.NO_WEATHER}), BASE))
_both = derived(lambda: min_accepted_spread(
PriceProduct.REALTIME, frozenset({Uncertainty.NO_WEATHER}), BASE))
check(f"a day-ahead product trades on any spread above its own threshold "
f"{_normal:.6f}, which is the base {BASE} it was given",
near(_normal, BASE, 1e-6))
check(f"a REAL-TIME / imbalance product needs {_wide:.6f} - MEASURABLY wider, "
f"by a factor of {(_wide / _normal) if _normal else 0:.3f} measured from "
f"two verdict boundaries and not read from any constant",
_wide > _normal + 1e-6)
check(f"...and that measured factor equals the declared one for "
f"REALTIME_PRODUCT ({WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT]}), so "
f"the declaration and the behaviour are the same thing",
near(_wide / _normal, WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT], 1e-4))
check(f"§11.4's no-weather row reaches the SAME widening path by a different "
f"trigger, and lands at {_weather:.6f}",
near(_weather / _normal, WIDEN_FACTOR[Uncertainty.NO_WEATHER], 1e-4))
# ⚠️ THE COMBINATION RULE, ON A STIMULUS WHERE THE THREE CANDIDATES DIFFER.
_f1 = WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT]
_f2 = WIDEN_FACTOR[Uncertainty.NO_WEATHER]
check(f"with BOTH sources active the threshold is max(f1,f2)={max(_f1, _f2)} x "
f"base and NOT the product ({_f1 * _f2}) or the sum ({_f1 + _f2}) - two "
f"independent doubts must not compound into a threshold nothing can "
f"clear",
near(_both / _normal, max(_f1, _f2), 1e-4)
and not near(_both / _normal, _f1 * _f2, 1e-3)
and not near(_both / _normal, _f1 + _f2, 1e-3))
# The regime must follow the DECLARED product, never the plugin id.
_wide_named_dayahead = derived(lambda: min_accepted_spread(
PriceProduct.REALTIME, frozenset(), BASE, provider="generic_dayahead"))
_normal_named_eddy = derived(lambda: min_accepted_spread(
PriceProduct.DAY_AHEAD, frozenset(), BASE, provider="eneco_eddy"))
check("the widening follows the DECLARED PriceProduct even when the provider "
"is called 'generic_dayahead', and does NOT follow the name "
"'eneco_eddy' when the declared product is day-ahead - so a sixth "
"supplier is widened by its declaration and not by an id in a list",
near(_wide_named_dayahead, _wide, 1e-9)
and near(_normal_named_eddy, _normal, 1e-9))
check("...and the shipped plugin §9.2 names IS the one that declares REALTIME, "
"so the mechanism is pointed at the real product",
PROVIDERS["eneco_eddy"].capabilities().product is PriceProduct.REALTIME
and uncertainty_of(PriceProduct.REALTIME)
== frozenset({Uncertainty.REALTIME_PRODUCT})
and uncertainty_of(PriceProduct.DAY_AHEAD) == frozenset())
check("a string in the uncertainty set is REFUSED rather than widening by 1.0 "
"and reporting that it widened",
raises(lambda: widen_factor({"realtime"}), TypeError)
and raises(lambda: required_spread(0.1, {"realtime"}), TypeError))
check("...and a negative or non-finite base threshold is refused, since it is "
"one every spread clears",
raises(lambda: required_spread(-0.01, frozenset()), ValueError)
and raises(lambda: required_spread(float("nan"), frozenset()), ValueError))
check("required_spread() with nothing uncertain is the base itself, so the "
"widening is additive machinery and not a permanent surcharge",
near(required_spread(BASE, frozenset()), BASE))
# --------------------------------------------------------------------------- #
print()
print("6. the refusal ladder - provisional hours are DROPPED, not traded on")
# ⚠️ THE PROVISIONAL HOURS CARRY THE CHEAPEST PRICE. That is the realistic
# danger (yesterday's cheap night luring a charge) AND the non-degenerate
# stimulus: the firm-only spread and the whole-series spread differ, so a check
# on the spread can tell which set was used.
_cheap_stale = [PricePoint(start=brussels(DAY[i]),
eur_per_kwh=0.041250, provisional=True)
for i in range(3)]
_firm_rest = [PricePoint(start=brussels(DAY[i]),
eur_per_kwh=round(0.212500 + 0.013750 * i, 6),
provisional=False) for i in range(3, 12)]
_mixed = TariffPlan(PriceRead.series(_cheap_stale + _firm_rest,
PriceProduct.DAY_AHEAD,
"generic_dayahead", D0), None, "provider")
_all_spread = derived(lambda: _mixed.spread_eur_kwh())
_firm_spread = derived(lambda: round(
max(p.eur_per_kwh for p in _firm_rest)
- min(p.eur_per_kwh for p in _firm_rest), 6))
check(f"the fixture is non-degenerate: the whole-series spread "
f"{_all_spread:.6f} and the firm-only spread {_firm_spread:.6f} are "
f"different numbers, so a verdict can be traced to one of them",
not near(_all_spread, _firm_spread, 1e-6))
_v = derived(lambda: _mixed.arbitrage(peak_guard_active=False,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()))
check("a PART-published series still allows arbitrage - §8.3 forbids acting on "
"a provisional price, not trading at all until 16:00",
_v.kind == "allowed")
check(f"...on the FIRM spread {_firm_spread:.6f}, not the one yesterday's "
f"carried-forward cheap hour would have invented",
near(_v.spread_eur_kwh, _firm_spread, 1e-9))
check("...and the cheapest and dearest hours it hands an optimiser are both "
"PUBLISHED hours - the two points ARE the trade",
_v.cheapest.provisional is False and _v.dearest.provisional is False)
check("...and it reports the threshold it cleared and why, rather than leaving "
"OPT-04 to re-derive it from a product enum",
near(_v.required_spread_eur_kwh, 0.0) and _v.uncertainty == frozenset())
_all_prov = TariffPlan(PriceRead.series(_cheap_stale, PriceProduct.DAY_AHEAD,
"generic_dayahead", D0), None,
"fallback")
_vp = derived(lambda: _all_prov.arbitrage(peak_guard_active=False,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()))
check("a WHOLLY provisional series - the §11.4 fallback, or a day before any "
"print - is still refused, and the reason counts the firm hours",
_vp.kind == "refused" and "0 of 3" in _vp.reason and "§8.3" in _vp.reason)
check("...and the refusal carries no numbers to trade on",
raises(lambda: _vp.spread_eur_kwh, Absence)
and raises(lambda: _vp.cheapest, Absence))
_one_firm = TariffPlan(
PriceRead.series(_cheap_stale + _firm_rest[:1], PriceProduct.DAY_AHEAD,
"generic_dayahead", D0), None, "provider")
check("one firm hour is not enough either: a buy hour and a sell hour must "
"BOTH be published before a cycle can be committed",
_one_firm.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset()).kind == "refused")
check("§10.1 still holds above all of it: the peak guard refuses the same "
"series that is otherwise allowed",
_mixed.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0,
uncertainty=frozenset()).kind == "refused")
check("the three questions cannot be skipped: all of peak_guard_active, "
"min_spread_eur_kwh and uncertainty are required and keyword-only",
raises(lambda: _mixed.arbitrage(peak_guard_active=False), TypeError)
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
min_spread_eur_kwh=0.0), TypeError)
and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError))
check("...nor answered with a truthy 'unknown' or a negative threshold",
raises(lambda: _mixed.arbitrage(peak_guard_active=None,
min_spread_eur_kwh=0.0,
uncertainty=frozenset()), TypeError)
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
min_spread_eur_kwh=-0.1,
uncertainty=frozenset()), TypeError))
# The cheap-window flag is the other consumer of provisionality, and it is the
# one a live install's maintenance charge floor already reads.
_firm_hour = _firm_rest[0].start
_prov_hour = _cheap_stale[0].start
check("the cheap-window flag is decided per HOUR: a provisional hour is never "
"cheap, whatever its (stale) number says",
_mixed.cheap_now(_prov_hour) is False)
check("...while a firm hour below the firm mean still is, so carry-forward "
"filling does not switch the maintenance charge floor off every morning",
_mixed.cheap_now(_firm_hour) is True)
# ⚠️ GRADED DIRECTLY, BECAUSE ITS CONSUMERS CANNOT GRADE IT. The §11.4 refusal
# and `cheap_now` both guard provenance independently, so `firm_points()`
# returning the carried hours TOO still leaves both of them answering correctly
# - a stimulus that cannot distinguish an over-reporting accessor from a
# correct one. So the tuple itself is compared with the published points, by
# identity and in order, rather than only the mean it feeds.
check("firm_points() returns EXACTLY the published points, in order - not the "
"carried ones as well, which its two consumers would have absorbed "
"without either of them changing its answer",
list(_mixed.firm_points()) == _firm_rest
and all(a is b for a, b in zip(_mixed.firm_points(), _firm_rest)))
check("...and the firm mean it compares against is derived from published "
"hours only",
near(sum(p.eur_per_kwh for p in _mixed.firm_points())
/ len(_mixed.firm_points()),
sum(p.eur_per_kwh for p in _firm_rest) / len(_firm_rest)))
# --------------------------------------------------------------------------- #
print()
print("7. CFG-03 and validate.py rule 7 - what this ticket did NOT widen")
_defaults = derived(lambda: sorted(_price.PRICE_OPTION_DEFAULTS))
_cfg = derived(lambda: open(os.path.join(HERE, "config.yaml"),
encoding="utf-8").read())
# ⚠️ THIS LIST IS A DRIFT PIN, NOT A BAN ON EVER ADDING AN OPTION. FCST-03 added
# none, and said so here. OPT-04 added FOUR - and added them to this ONE dict
# and to config.yaml, which is precisely the behaviour CFG-03 asks for: the
# alternative is a fourth freehand copy in main.py's `o.get(k, X)` fallbacks.
# The pin is what makes such an addition a decision somebody signs rather than a
# thing that appears; whoever adds the fifth comes here and says which card and
# why, as this line does.
_OPT04_KEYS = ["arbitrage_enabled", "cycle_cost_threshold_eur",
"arbitrage_soc_floor", "arbitrage_soc_ceiling"]
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's and OPT-04's "
f"keys - FCST-03 still added 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))
# ...and the four 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 keys 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)) == 9)
check("...and every one of them is still in config.yaml, which stays the "
"source of truth",
isinstance(_cfg, str)
and all(f"\n {k}:" in _cfg for k in _price.PRICE_OPTION_DEFAULTS))
def _names(path):
try:
tree = ast.parse(open(path, encoding="utf-8").read())
except (OSError, SyntaxError): # pragma: no cover
return set()
out = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
out |= {a.name for a in node.names}
if isinstance(node, ast.Name):
out.add(node.id)
if isinstance(node, ast.Attribute):
out.add(node.attr)
return out
_FC = os.path.join(HERE, "app", "price_forecast.py")
_used = derived(lambda: _names(_FC))
check("app/price_forecast.py reads NO options at all - it takes a PriceRead "
"and instants, so there is no fourth place for an option default to live",
isinstance(_used, set)
and not ({"price_option", "PRICE_OPTION_DEFAULTS", "PluginOptions"}
& _used))
# ⚠️ RULE 7'S TRIPWIRE, RESPECTED RATHER THAN TRIPPED. test_price.py section 12
# pins the set of modules that consume `load_prices()` to exactly
# ["static_tariff.py"], and goes red when a second one appears. This module
# deliberately does NOT fetch: it transforms a read somebody else obtained, so
# rule 7's message about what a configured provider does or does not drive is
# unchanged by this ticket and needed no rewording.
check("...and it does not consume load_prices(), so validate.py rule 7's "
"pinned consumer list is still true and its wording still accurate",
"load_prices" not in _used)
print()
print("FAILED" if fails else "OK")
for f in fails:
print(" -", f)
sys.exit(1 if fails else 0)
+10 -10
View File
@@ -306,8 +306,8 @@ check(f"...while the two-period spread is the day/night difference "
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
_v_fixed = p_fixed.arbitrage(peak_guard_active=False)
_v_two = p_two.arbitrage(peak_guard_active=False)
_v_fixed = p_fixed.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
_v_two = p_two.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset())
check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused")
check("...for the true reason - the series is flat, so there is nothing to buy "
"low and sell high, not because a FIXED enum was matched on",
@@ -327,7 +327,7 @@ _flat_dayahead = TariffPlan(
None, "provider")
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
"is arithmetic and not a product label",
_flat_dayahead.arbitrage(peak_guard_active=False).kind == "refused")
_flat_dayahead.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
# The numbers an optimiser needs exist ONLY on the permitting variant.
check("the allowed verdict carries the spread, the cheapest hour and the "
@@ -352,9 +352,9 @@ check("...and there is no falsy variant, so `if verdict:` cannot look like a "
# §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it.
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
"the peak guard is holding",
p_two.arbitrage(peak_guard_active=True).kind == "refused")
p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused")
check("...and the reason names the ordering rather than the tariff",
"priority 2" in p_two.arbitrage(peak_guard_active=True).reason)
"priority 2" in p_two.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0, uncertainty=frozenset()).reason)
check("...and the question cannot be skipped: peak_guard_active is required "
"and keyword-only, so a spread is not obtainable without answering it",
raises(lambda: p_two.arbitrage(), TypeError)
@@ -497,7 +497,7 @@ check("...and its underlying read has no .points at all, so `min(read.points)` "
"cannot run against it",
raises(lambda: _none.read.points, Absence))
check("...and arbitrage is refused, per §11.4",
_none.arbitrage(peak_guard_active=False).kind == "refused"
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and arbitrage_allowed(_none.read) is False)
check("...and static_status() says so in words rather than reporting a price",
static_status(opts(None), THU) == (False, "no static profile"))
@@ -746,12 +746,12 @@ check("...marked PROVISIONAL, which is the one flag that differs",
_fb.provisional and plan_is_provisional(_fb.read))
check("...and arbitrage is refused on it, per §11.4, even though a full series "
"exists and has a real spread",
_fb.arbitrage(peak_guard_active=False).kind == "refused"
_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
check("...while the SAME profile as the configured product allows it - so the "
"refusal is about provisionality and not about the profile",
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
).arbitrage(peak_guard_active=False).kind == "allowed")
).arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed")
check("...and the provisional plan has no cheap window either: §8.3 forbids an "
"irreversible action on a provisional price, and a grid top-up is one",
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
@@ -779,8 +779,8 @@ check("a live feed is used in preference to the static profile",
and _live_plan.product is PriceProduct.DAY_AHEAD)
check("...and arbitrage is allowed on it, with the spread derived from the "
"feed rather than from the profile",
_live_plan.arbitrage(peak_guard_active=False).kind == "allowed"
and near(_live_plan.arbitrage(peak_guard_active=False).spread_eur_kwh,
_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "allowed"
and near(_live_plan.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).spread_eur_kwh,
0.023, 1e-9))