Compare commits

..
8 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 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
10 changed files with 46107 additions and 98 deletions
+120
View File
@@ -2,6 +2,126 @@
## 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
+39 -2
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
+108 -5
View File
@@ -229,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
@@ -534,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),
@@ -780,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
@@ -924,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.
@@ -1265,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:
+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"))
+54
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,17 @@ 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 .peakguard import PEAK_OPTION_DEFAULTS
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
from .static_tariff import ProfileInvalid, parse_static_profile
@@ -370,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)
+37
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
@@ -343,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?
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())
+402 -33
View File
@@ -20,12 +20,15 @@ WHAT THIS FILE IS DEFENDING, in the order the card's acceptance criteria name it
exist - static_tariff's required `peak_guard_active` interlock and
arbiter.py's clause 2 - both driven end to end, plus the third state
(the guard never ran) that neither of them can express on its own.
AC4 NET-POSITIVE ACROSS 12 MONTHS OF REPLAYED PRICES. ⚠️ SEE SECTION 7. THIS
REPOSITORY HOLDS NO PRICE HISTORY AT ALL - `scenarios/` carries two days
of real METER data and nothing else - so the criterion as written cannot
be met, and it is NOT redefined here into something that can be. What
section 7 does instead is stated there in full, and what it cannot say is
stated with it.
AC4 NET-POSITIVE ACROSS 12 MONTHS OF REPLAYED PRICES. ⚠️ MET AS OF DATA-02,
AND SECTIONS 7 AND 7B DO DIFFERENT HALVES OF IT. 7B replays twelve months
of REAL EPEX BE day-ahead prices - `scenarios/epex-be-dayahead-*.json`,
8760 hours, public data under CC BY 4.0 - through the shipped `decide()`,
and reports the euro figure at the shipped threshold. Section 7 keeps the
SYNTHETIC year, because the real one turns out to be unable to grade §17
at all: not one of its 365 days has a negative DEAREST hour, so a count
of zero negative-price discharges over it grades nothing. 7B asserts that
degeneracy rather than quietly reporting the zero.
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
@@ -378,7 +381,11 @@ check("BUYING in a negative-price hour is still permitted - §17 forbids "
"refusing to be paid to charge",
act(NEG_PLAN, NEG.index(min(NEG)), thresh=_shipped,
unc=NO_WEATHER).action == CHARGE)
# A zero price is the boundary and it is NOT negative.
# A zero price is the boundary and it is NOT negative. ⚠️ KEEP THIS PROBE. It
# pins the boundary from ABOVE - it is what catches a guard written `<= 0` - and
# the SAFETY-26 block below pins it from BELOW. Neither replaces the other, and
# the whole defect that card exists to close is that only one of the two sides
# was ever stated.
ZERO = list(POS)
ZERO[DEAR_H] = 0.0
check("a sell hour priced at exactly ZERO is not a negative one, so the guard "
@@ -387,6 +394,119 @@ check("a sell hour priced at exactly ZERO is not a negative one, so the guard "
act(plan_of(ZERO), DEAR_H, thresh=_shipped, unc=NO_WEATHER).action
== DISCHARGE)
# --------------------------------------------------------------------------- #
# SAFETY-26. THE OTHER SIDE OF THE SAME BOUNDARY, AND WHY IT HAD TO BE ADDED.
#
# OPT-04's reviewer mutated `if price < 0` to `if price < -0.001` and EVERY
# SUITE STAYED GREEN, with a -0.0005 EUR/kWh sell hour discharging. The shipped
# code was right the whole time; the CHECKS could not tell correct from
# nearly-correct, because the only probe near the boundary sat at 0.0000. A
# probe above the line catches `<=`. It says nothing at all about a tolerance
# invented below the line, and a tolerance below the line IS §17's regression
# case walking through with the suite passing.
#
# ⚠️ AND -0.0005 IS NOT AN EXOTIC INPUT. Belgian day-ahead prints small negative
# hours regularly; an all-in price a twentieth of a cent below zero is an
# ordinary day, not a fuzz value. That is what makes this a live regression
# rather than a theoretical one.
#
# ⚠️ SO THE PROBE IS A SWEEP, NOT A POINT, and the sweep is the discriminating
# part. One probe at -0.0005 closes `-0.001` and nothing else; a mutation
# reaching for `-0.01` would survive it, which is the identical defect one
# decimal place further out. The sweep spans four orders of magnitude down to
# the last place the canonical six-decimal format can even express, so there is
# no tolerance a guard can invent that some member of it does not straddle.
# `_ROUNDING` asserts the smallest member SURVIVES the format rather than being
# rounded to zero on the way in - a probe that silently became 0.0 would be the
# `0.0000` check again, wearing a different name.
TINY_NEG = (-0.05, -0.005, -0.0005, -0.00005, -0.000001)
_ROUNDING = [round(p, 6) for p in TINY_NEG]
check(f"every probe price survives the canonical 6-decimal rounding as a "
f"STRICTLY negative number {_ROUNDING} - one that rounded to 0.0 would "
f"be the check above again under another name",
all(r < 0 for r in _ROUNDING) and len(set(_ROUNDING)) == len(TINY_NEG))
check("...and they span four orders of magnitude, so no single invented "
"tolerance can sit below all of them and above none",
abs(min(TINY_NEG)) / abs(max(TINY_NEG)) > 10000)
def tiny_neg_plan(price):
"""The POS day with its DEAREST hour set to a small negative print."""
day = list(POS)
day[DEAR_H] = price
return plan_of(day), day
# The stimulus has to survive the rungs ABOVE §17 or it grades nothing: the
# hour must still be the dearest, and the spread must still clear the widened
# threshold, at every probe. Asserted first, for all of them, as its own check.
_wellformed = [
(p, day.index(max(day)) == DEAR_H,
round(max(day) - min(day), 6) > required_spread(_shipped, NO_WEATHER))
for p, (_pl, day) in ((p, tiny_neg_plan(p)) for p in TINY_NEG)]
check(f"at every probe the negative hour is still the DEAREST one and the "
f"spread still clears the threshold, so §17 is the only rung that can "
f"refuse {_wellformed}",
all(dearest and clears for _p, dearest, clears in _wellformed))
_swept = [(p, act(tiny_neg_plan(p)[0], DEAR_H, thresh=_shipped,
unc=NO_WEATHER).action) for p in TINY_NEG]
check(f"SAFETY-26: a sell hour that is negative BY ANY MARGIN AT ALL refuses - "
f"down to -0.000001 EUR/kWh, so the guard is `< 0` and not `< -epsilon` "
f"for any epsilon {_swept}",
all(a is None for _p, a in _swept))
check("...and each refusal is §17's, not some other rung that happens to fire "
"at small numbers",
all("§17" in act(tiny_neg_plan(p)[0], DEAR_H, thresh=_shipped,
unc=NO_WEATHER).reason for p in TINY_NEG))
# The mirror image, one place above zero. Without this the sweep above would
# pass for a guard that refused EVERYTHING, which grades §17 by nothing.
_tiny_pos = [(-p, act(tiny_neg_plan(-p)[0], DEAR_H, thresh=_shipped,
unc=NO_WEATHER).action) for p in TINY_NEG]
check(f"...while THE SAME MAGNITUDES with the sign flipped all DISCHARGE, so "
f"the sweep is a boundary at exactly zero rather than a refusal of small "
f"numbers - which a guard that refused everything would also have passed "
f"{_tiny_pos}",
all(a == DISCHARGE for _p, a in _tiny_pos))
# SAFETY-26 AC4 - THE LOOK AT THE OTHER BOUNDARIES, NAMED RATHER THAN FIXED.
#
# The card asks whether the same one-sided pinning exists elsewhere. Every
# price/quantity boundary in arbitrage.py and static_tariff.py was given the
# tolerance mutation that survived on `price < 0`, in both directions where
# both exist, under `-B` with a recursive purge. Two survived, BOTH in
# static_tariff.py - TARIFF-03's file, and deliberately NOT touched here:
#
# D `spread == 0.0` (the flat-tariff rung) -> `spread <= 0.001` SURVIVES.
# Measured, not reasoned: with it loosened, a series priced 0.20000 /
# 0.20050 - a REAL 0.0005 spread - flips from ALLOWED to REFUSED at
# min_spread_eur_kwh=0.0, which `arbitrage()`'s own docstring documents as
# a supported value ("pass 0.0 to mean any positive spread, explicitly").
# At a shipped threshold the OUTCOME is unchanged, but the operator's
# reason becomes the false sentence "the tariff is flat - every one of the
# 2 firm hours costs 0.2000 EUR/kWh" about a tariff that is not flat.
# Same shape as §17's: the rung is only ever probed with an EXACTLY flat
# series, so nothing pins the tiny-but-real side.
#
# F `len(firm) < 2` (§8.3's firm-pair rung) -> `< 1` SURVIVES, at +0 BYTES,
# which is the delta TEST-15 names as the risky one. It is not live on its
# own: a single firm hour has a spread of 0.0, so the flat rung refuses it
# anyway - but the reason degrades from §8.3's honest "only 1 of 3 hours
# carry a published price" to "the tariff is flat". THE FINDING THAT
# MATTERS IS THE INTERACTION: D and F MASK EACH OTHER. Neither is
# individually live because the other catches it, which is exactly the
# arrangement in which a later edit to either one opens a hole nothing
# reports.
#
# Everything else in arbitrage.py is pinned on BOTH sides and was verified so:
# `price < 0` (this card), `soc <= floor_pct` and `soc >= ceiling_pct` are each
# red under a +5 and a -5 tolerance; static_tariff's `spread < need` is red
# under +/-0.001 and additionally bisected to 1e-6 in section 1; `cheap_now`'s
# `price < mean` is red under a +0.001 tolerance in test_static_tariff.py.
#
# Not fixed here on purpose. D and F are TARIFF-03's rungs, they need probes in
# test_static_tariff.py rather than in this file, and the card is explicit that
# its fourth criterion is a look and not a refactor.
# --------------------------------------------------------------------------- #
print()
@@ -669,34 +789,39 @@ check("...and it is still ARMED when arbitrage is idle and the loop wins, so "
print()
print("7. AC4 - 12 months of replayed prices, and what could NOT be measured")
# ⚠️ READ THIS BEFORE READING THE NUMBERS BELOW.
# ⚠️ READ THIS BEFORE READING THE NUMBERS BELOW. THIS SECTION IS THE SYNTHETIC
# YEAR, AND IT IS NOT AC4's EVIDENCE ANY MORE - SECTION 7B IS.
#
# The acceptance criterion is "net-positive across 12 months of replayed prices
# in simulation". THIS REPOSITORY CONTAINS NO PRICE HISTORY. `scenarios/` holds
# three gzipped captures and all three are P1 METER data - two days of net power
# from the reference house plus one age capture. There is no EPEX archive, no
# supplier export, and no CSV of a past year anywhere in the tree.
# When OPT-04 shipped, this repository held no price history: `scenarios/`
# carried three gzipped P1 METER captures and nothing else. AC4 was therefore
# reported NOT MET rather than redefined into something passable, which is what
# TARIFF-01 and SAFETY-07 had both been upheld for doing.
#
# TARIFF-01 and SAFETY-07 both hit an unmeetable criterion and both DECLINED to
# redefine it into one they could pass, and both were upheld for it. So:
# DATA-02 pointed out that the missing ingredient was never a capture. EPEX BE
# day-ahead is PUBLIC DATA; the 30-day meter criterion it was being carried
# beside genuinely needs waiting, and this one only ever needed a fetch. Section
# 7B replays the real year and AC4 is met there.
#
# AC4 IS NOT MET, AND IT IS NOT RESTATED. What follows is not a replay of the
# Belgian market; it is a replay of a SYNTHETIC year, and a euro figure from a
# synthetic year grades the generator that produced it, not the strategy.
# ⚠️ SO WHY IS THE SYNTHETIC YEAR STILL HERE? Because the real one cannot grade
# §17, and 7B proves that rather than assuming it. Across all 365 real days, the
# number whose DEAREST hour is priced below zero is ZERO - raw wholesale and
# all-in alike - so "no discharges into a negative hour" over the real year is a
# count of something that never had the chance to happen. Deleting the guard
# would not change it. This section's generator drives a summer midday block
# negative ON PURPOSE, and it is the only thing in this file that puts a
# negative price where the strategy would otherwise sell.
#
# What the replay is FOR is the half of AC4 that does not need real prices. Net
# positivity here is not an empirical outcome to be hoped for - it is a property
# of the decision rule, and the replay's job is to demonstrate that the property
# holds on every trade the strategy actually chose, over a year of varied days
# including negative-price ones. Stated as an invariant:
# What this section establishes is the half of AC4 that does not need real
# prices, and it is a property of the decision rule rather than an outcome to be
# hoped for:
#
# every executed round trip has (sell - buy) >= required >= threshold,
# so its benefit net of the threshold is >= 0, on every day, always.
#
# That is asserted per trade below, over 365 days. What it does NOT establish -
# and no synthetic series could - is how OFTEN a real Belgian year clears the
# threshold, which is the actual open question and the reason the shipped
# default is a product decision rather than a number this card picked.
# That is asserted per trade below, over 365 varied days including negative ones.
# HOW OFTEN a real Belgian year clears the threshold is the other question, it
# is the one the product owner's open decision turns on, and section 7B is the
# only place in this file that answers it.
def synth_day(n):
"""A synthetic all-in day-ahead day. Deterministic, seeded by day number.
@@ -803,12 +928,256 @@ check("the shipped threshold is strictly more selective than the loose one on "
"the same year - the parameter does what it says, at whatever value the "
"owner sets it to",
len(_sh_trades) < len(_trades))
check("AC4 IS REPORTED AS NOT MET rather than redefined: this file says so in "
"section 7's preamble, and there is no price history in the repository "
"to meet it with",
not any(f.endswith((".csv", ".json.gz"))
and "price" in f.lower()
for f in os.listdir(os.path.join(HERE, "scenarios"))))
check("...and the shipped default was NOT moved to make any of this look "
"better: app/arbitrage.py reads the threshold through price_option() and "
"names no number of its own",
"0.10" not in open(os.path.join(HERE, "app", "arbitrage.py"),
encoding="utf-8").read().split('"""', 2)[2])
# --------------------------------------------------------------------------- #
print()
print("7B. AC4 - twelve months of REAL EPEX BE prices, replayed")
# THE DATASET, AND WHAT IT IS NOT.
#
# `scenarios/epex-be-dayahead-2025-09-01_2026-09-01.json` - 8760 hourly Belgian
# day-ahead auction prices, 2025-09-01 to 2026-09-01 Brussels-local, fetched by
# `scenarios/fetch_epex_be.py` from Fraunhofer ISE's Energy-Charts API, which
# publishes the BE zone under CC BY 4.0 from Bundesnetzagentur | SMARD.de. The
# licence was checked BEFORE the data was committed, because the same API serves
# other zones under private-use-only terms; the script refuses to write a file
# whose per-response `license_info` stops saying CC BY 4.0. Provenance,
# attribution and the DST accounting live in the file's own `provenance` block
# and `fetch_epex_be.py --verify` re-derives every claim in it.
#
# ⚠️ THE FIGURES IN THAT FILE ARE RAW WHOLESALE, NOT ALL-IN, AND THIS SECTION IS
# WHERE THE DIFFERENCE IS MADE VISIBLE. §9.3's canonical point is all-in; the
# committed file says `"all_in": false` and carries the markup/tax assumption as
# NUMBERS TO APPLY rather than numbers already applied - TARIFF-02's
# double-counting trap, avoided by never baking the guess in. `_all_in()` below
# applies it in one place where a reader can watch it happen, and both replays
# are run, so exactly how much of the answer rests on the guess is reported
# rather than hidden.
import json # noqa: E402
from collections import defaultdict # noqa: E402
_DS_PATH = os.path.join(HERE, "scenarios",
"epex-be-dayahead-2025-09-01_2026-09-01.json")
_DS = derived(lambda: json.load(open(_DS_PATH, encoding="utf-8")))
_ASSUME = _DS["provenance"]["all_in_assumption"]
def _all_in(wholesale):
"""The stated assumption, applied HERE and nowhere in the committed data.
all_in = (wholesale + markup + network_and_levies) * (1 + vat), with the
coefficients read from the dataset's own provenance block rather than
retyped - a second copy is a second thing to disagree with the file.
"""
return round((wholesale + _ASSUME["markup_eur_per_kwh"]
+ _ASSUME["network_and_levies_eur_per_kwh"])
* (1.0 + _ASSUME["vat_fraction"]), 6)
def _days(price_of):
"""The dataset as Brussels calendar days of `PricePoint`s, in order.
⚠️ GROUPED BY THE LOCAL DATE IN THE `start` STRING, NOT BY SLICING 24 AT A
TIME. Two of these days are not 24 hours long, which is the whole DST check,
and a fixed stride would put October's 25th hour into the next day and
misalign every day after it.
"""
out = defaultdict(list)
for p in _DS["prices"]:
out[p["start"][:10]].append(
PricePoint(start=datetime.fromisoformat(p["start"]),
eur_per_kwh=price_of(p["eur_per_kwh"]),
provisional=bool(p["provisional"])))
return [out[k] for k in sorted(out)]
_AI_DAYS = derived(lambda: _days(_all_in))
_RAW_DAYS = derived(lambda: _days(lambda w: w))
# ---- the dataset is not degenerate, asserted BEFORE anything is graded ------
_lens = [len(d) for d in _AI_DAYS]
check(f"the replay has {len(_AI_DAYS)} Brussels days and {sum(_lens)} hourly "
f"points, which is a full year rather than a fortnight repeated",
len(_AI_DAYS) == 365 and sum(_lens) == 8760)
check(f"...and the day lengths are {sorted(set(_lens))}, so BOTH DST days are "
f"in it: an importer that produced 24 everywhere would have lost an hour "
f"in October and invented one in March",
sorted(set(_lens)) == [23, 24, 25]
and _lens.count(23) == 1 and _lens.count(25) == 1)
_dst_day = [d for d in _AI_DAYS if len(d) == 25][0]
check("...and the 25-hour day's two 02:00 points are two different INSTANTS at "
"two different offsets, which is what `at()` has to tell apart",
len({p.start for p in _dst_day}) == 25
and {p.start.utcoffset() for p in _dst_day}
== {timedelta(hours=1), timedelta(hours=2)})
_ai_spreads = sorted(round(max(p.eur_per_kwh for p in d)
- min(p.eur_per_kwh for p in d), 6)
for d in _AI_DAYS)
_raw_spreads = sorted(round(max(p.eur_per_kwh for p in d)
- min(p.eur_per_kwh for p in d), 6)
for d in _RAW_DAYS)
_req_shipped = required_spread(_shipped, NO_WEATHER)
_clears = [s for s in _ai_spreads if s > _req_shipped]
check(f"...and the year's daily spreads STRADDLE the shipped requirement of "
f"{_req_shipped:.4f} EUR/kWh - {len(_clears)} of {len(_ai_spreads)} days "
f"clear it - so the threshold is grading something. A year in which every "
f"day cleared, or none did, would grade nothing at any value",
0 < len(_clears) < len(_ai_spreads))
check(f"...and the spreads are not one repeated number: median "
f"{_ai_spreads[len(_ai_spreads) // 2]:.4f}, min {_ai_spreads[0]:.4f}, "
f"max {_ai_spreads[-1]:.4f}",
len(set(_ai_spreads)) > 300 and _ai_spreads[-1] > 4 * _ai_spreads[0])
# ---- the assumption's arithmetic, asserted rather than asserted-about -------
check("the ADDITIVE half of the all-in assumption cancels out of every daily "
"spread, so the trade counts below do not depend on the markup guess at "
"all - only on the VAT multiplier",
all(near(a, r * (1.0 + _ASSUME["vat_fraction"]), 1e-6)
for a, r in zip(_ai_spreads, _raw_spreads)))
# ---- ⚠️ THE DEGENERACY THAT KEEPS SECTION 7 ALIVE --------------------------
_raw_neg_h = sum(1 for d in _RAW_DAYS for p in d if p.eur_per_kwh < 0)
_ai_neg_h = sum(1 for d in _AI_DAYS for p in d if p.eur_per_kwh < 0)
_raw_neg_dear = sum(1 for d in _RAW_DAYS if max(p.eur_per_kwh for p in d) < 0)
_ai_neg_dear = sum(1 for d in _AI_DAYS if max(p.eur_per_kwh for p in d) < 0)
check(f"the real year DOES contain negative hours - {_raw_neg_h} of them at "
f"wholesale, {_ai_neg_h} still negative once the assumed markup and "
f"charges are added - so §17 is a live case on this market and not a "
f"hypothetical", _raw_neg_h > 100 and _ai_neg_h > 0)
check(f"BUT NOT ONE OF THE 365 DAYS HAS A NEGATIVE DEAREST HOUR "
f"({_raw_neg_dear} raw, {_ai_neg_dear} all-in), and the guard is on the "
f"hour being SOLD INTO. So a count of zero negative-price discharges over "
f"this year would be a count of something that could not happen, and "
f"section 7's synthetic year stays because it is the only thing here that "
f"puts a negative price where the strategy would sell",
_raw_neg_dear == 0 and _ai_neg_dear == 0)
def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
"""The shipped `decide()` over the real year. Returns the ledger, nothing else.
One `TariffPlan` per Brussels day, so "the cheapest hour" means the cheapest
hour of that day - the same shape as section 7's replay, and the same shape
a site with a day-ahead feed actually sees.
"""
soc = soc0
trades, holdings, bad, neg_sells = [], [], [], 0
for day in days:
plan = TariffPlan(
PriceRead.series(day, PriceProduct.DAY_AHEAD, "generic_dayahead",
day[0].start), None, "provider")
open_buy = None
for p in day:
d = decide(plan, p.start + timedelta(minutes=30),
peak_guard_active=False, soc=soc,
min_spread_eur_kwh=threshold, uncertainty=unc,
charge_w=MAX_W, discharge_w=MAX_W,
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
if d.action is None:
continue
if d.spread_eur_kwh is None or d.required_eur_kwh is None:
bad.append(p.start)
if d.action == CHARGE:
soc = min(ceiling, soc + STEP_PCT)
open_buy = (p.eur_per_kwh, d)
else:
if p.eur_per_kwh < 0:
neg_sells += 1
soc = max(floor, soc - STEP_PCT)
if open_buy is not None:
trades.append((open_buy[0], p.eur_per_kwh, d))
open_buy = None
else:
holdings.append(p.start)
return trades, holdings, bad, neg_sells
# ⚠️ AT THE SHIPPED THRESHOLD, UNTOUCHED, AND WIDENED BY FCST-03's NO-WEATHER
# FACTOR - the harsher of the two operating states, so the figure reported is
# the conservative one.
_r_trades, _r_held, _r_bad, _r_neg = derived(
lambda: replay_real(_AI_DAYS, _shipped, NO_WEATHER)) or (None,) * 4
_r_kwh = MAX_W / 1000.0
_r_net = round(sum((s - b - _shipped) * _r_kwh for b, s, _ in _r_trades), 2)
check(f"the real year traded at the SHIPPED threshold: {len(_r_trades)} "
f"completed round trips over 365 days of Belgian day-ahead prices - so "
f"the criterion is being graded on trades that happened, not on an empty "
f"ledger that is net-positive by vacuity", len(_r_trades) > 0)
check("...and every one of them carries the spread and the widened requirement "
"it cleared, so the ledger is auditable rather than a count", _r_bad == [])
check(f"...and it did NOT trade every day ({len(_r_trades)} round trips over "
f"365 days), so the threshold selected rather than waved everything "
f"through", len(_r_trades) < 365)
_r_losses = [(b, s) for b, s, _ in _r_trades if s - b < _shipped]
check(f"AC4: every completed round trip on the REAL year cleared the shipped "
f"{_shipped} EUR/kWh - {len(_r_losses)} exceptions of {len(_r_trades)}",
_r_losses == [])
_r_under = [d.spread_eur_kwh for _b, _s, d in _r_trades
if d.spread_eur_kwh < d.required_eur_kwh]
check(f"...and each cleared its own WIDENED requirement of "
f"{_req_shipped:.4f} EUR/kWh, which is the stricter statement §8.3 asks "
f"for", _r_under == [])
check(f"AC4 IS MET: twelve months of REAL replayed EPEX BE prices are "
f"NET-POSITIVE at the shipped default - {_r_net:+.2f} EUR of benefit net "
f"of the cycle cost over {len(_r_trades)} round trips of {_r_kwh:.1f} kWh "
f"each, accumulated from the trades the strategy actually chose",
_r_net > 0)
# ---- what the owner's open decision actually turns on -----------------------
# ⚠️ REPORTED, NOT SETTLED. `cycle_cost_threshold_eur` and FCST-03's 2.5/1.5 are
# the product owner's decision. DATA-02 supplies the input and changes nothing:
# every number below comes out of the same untouched `decide()`.
_r_calm = derived(lambda: replay_real(_AI_DAYS, _shipped, frozenset())[0])
_r_raw = derived(lambda: replay_real(_RAW_DAYS, _shipped, NO_WEATHER)[0])
# The threshold at which the MEDIAN day of this year clears its widened
# requirement. Derived from the observed spreads, not chosen.
_median_spread = _ai_spreads[len(_ai_spreads) // 2]
_suggested = round(_median_spread / widen_factor(NO_WEATHER), 4)
_r_sugg, _, _, _ = derived(
lambda: replay_real(_AI_DAYS, _suggested, NO_WEATHER)) or (None,) * 4
_sugg_net = round(sum((s - b - _suggested) * _r_kwh for b, s, _ in _r_sugg), 2)
print(f" [report] SHIPPED {_shipped} EUR/kWh, widened {widen_factor(NO_WEATHER)}x "
f"to {_req_shipped:.4f}: {len(_r_trades)} round trips, {_r_net:+.2f} EUR "
f"over the year. Unwidened (weather present): {len(_r_calm)} trips. On "
f"RAW wholesale instead of the assumed all-in: {len(_r_raw)} trips, so "
f"the markup/VAT guess moves the count by "
f"{len(_r_trades) - len(_r_raw):+d}.")
print(f" [report] the year's MEDIAN daily all-in spread is "
f"{_median_spread:.4f} EUR/kWh, so the threshold at which a median day "
f"clears its widened requirement is {_suggested} - and at that value the "
f"same year completes {len(_r_sugg)} round trips for {_sugg_net:+.2f} "
f"EUR. THE SHIPPED DEFAULT IS NOT CHANGED BY THIS CARD; both numbers are "
f"reported so the owner can decide between them.")
print(f" [report] and the four-developer finding stands unchanged: a STATIC "
f"day/night spread of ~0.0840 EUR/kWh still does not clear "
f"{_req_shipped:.4f}, so a static-tariff site never trades at the shipped "
f"default. What this year adds is that a DYNAMIC site does - on "
f"{len(_clears)} of its 365 days the spread was there to be taken.")
check("the loose threshold is strictly less selective than the shipped one on "
"the SAME real year - the parameter does what it says at whatever value "
"the owner sets, and neither run tuned anything",
len(_r_sugg) > len(_r_trades) > 0)
check(f"§17 held over the real year too ({_r_neg} discharges into a negative "
f"hour) - but see the degeneracy check above: on this data that zero is "
f"structural, and section 7's synthetic year is what actually grades the "
f"guard", _r_neg == 0)
check("the dataset carries its own provenance, states it is NOT all-in, and "
"keeps the markup/tax figures as an assumption rather than applying "
"them - the TARIFF-02 trap, in data form",
_DS["provenance"]["all_in"] is False
and "ASSUMPTION" in _ASSUME["status"]
and _DS["provenance"]["licence"].startswith("CC BY 4.0"))
# --------------------------------------------------------------------------- #
File diff suppressed because it is too large Load Diff