DATA-02: a real Belgian price year, so OPT-04's AC4 stops being unmeetable #24

Merged
admin merged 1 commits from DATA-02 into release/1.0 2026-08-31 11:12:49 +02:00
Owner

OPT-04 reported AC4 — "net-positive across 12 months of replayed prices in simulation" — as NOT MET, and replayed a synthetic year instead, because scenarios/ held only P1 meter captures. That was the right call and the wrong diagnosis of why: AC4 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, not a month.

What lands

goodwe_controller/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; --verify re-derives every claim the file makes about itself with no network.

Licensing, checked before anything was committed

Source: Fraunhofer ISE's Energy-Charts API (api.energy-charts.info/price?bzn=BE, no key, no registration). Its OpenAPI document splits the zones in two, and the split is the whole reason this had to be checked rather than assumed:

  • BE is licensed CC BY 4.0 from Bundesnetzagentur | SMARD.de, "published without changes" — redistribution permitted with attribution, so the data is committed.
  • Other zones are "for private and internal use only … expressly prohibited … including but not limited to EPEX SPOT SE." Had BE been there, the right output would have been the script plus a checksum and no data.

The fetcher refuses to write a file whose per-response license_info stops saying CC BY 4.0 — a licence checked once by a human and assumed forever is a licence that changes silently.

Cross-checked against the upstream: SMARD's own chart_data 4996/DE series over 2026-04-10 matched this feed on 96 of 96 quarter-hours, max difference 0.00 EUR/MWh.

Raw wholesale, and the assumption that is not baked in

The figures are raw wholesale, and the file says so: "all_in": false. The markup/tax assumption lives in provenance.all_in_assumption as numbers to apply, not numbers already applied — TARIFF-02's double-counting trap in data form.

all_in = (wholesale + markup + network_and_levies) * (1 + vat)

Section 7B applies it in one visible place, and asserts arithmetically that the two additive terms cancel out of every daily spread — so the trade count depends on the VAT multiplier alone and not on the guessed markup. The additive terms do not cancel out of §17's guard, which compares one hour against zero, so the replay is run on raw wholesale as well and the difference reported: 110 trades all-in, 88 raw.

DST, by construction

Iterate in UTC, render through price.brussels() — no zoneinfo, no second copy of the EU rule. 2025-10-26 comes out 25 points and 2026-03-29 comes out 23, verified by counting per Brussels day rather than asserting 24, by checking October's 02:00 appears twice at +02:00 and +01:00 with two different prices, and that March's 02:00 never appears.

Two real bugs the point-and-gap accounting caught rather than swallowed:

  • bounding the window at 00:00 UTC made the first Brussels day 22 hours — not one of the two lengths a transition produces;
  • an inclusive-vs-exclusive end off-by-one dropped 2026-08-31 entirely, silently: 8736 points where 8760 were expected.

A day nobody asked for is not a day with no prices. Nulls are dropped, incomplete hours become enumerated gaps, and points + gaps == hours in the window is asserted. The 20 hours at exactly 0.000000 are real clearings in solar-glut hours, and the check says so instead of outlawing the value.

AC4, at the shipped default, whatever it is

run required round trips net
shipped 0.10, widened 1.5× (no weather) 0.1500 110 +24.88 EUR
shipped 0.10, unwidened 0.1000 173 +28.00 EUR
raw wholesale, shipped, widened 0.1500 88 +20.20 EUR
0.0754 (what the data suggests), widened 0.1131 156 +35.42 EUR

AC4 is met: twelve months of real replayed prices are net-positive at the shipped default, on trades that actually happened.

⚠️ Nothing was tuned. cycle_cost_threshold_eur and FCST-03's 2.5/1.5 are the product owner's open decision and are untouched; every number above comes out of the same unmodified decide().

What the owner's decision now has under it

The four independent reports that a realistic Belgian static day/night spread of ~0.0840 does not clear 0.10 all stand — a static-tariff site still never trades at the shipped default. What nobody could say was the other half:

  • a dynamic site's daily spread clears the widened 0.1500 requirement on 121 of 365 days;
  • the year's median daily all-in spread is 0.1130, so a median day would clear at a threshold of 0.0754;
  • the honest caveat on magnitude: +24.88 EUR/year at 2 kWh a trade. Net-positive, and modest.

Both threshold figures are reported. Neither is applied.

Why the synthetic year stays

⚠️ The real year cannot grade §17. Across all 365 days, the number whose dearest hour is priced below zero is zero — raw and all-in alike — even though the year holds 372 negative wholesale hours. So "0 discharges into a negative-price hour" over real prices counts something that could not have happened; deleting the guard would not change it. Section 7B asserts that degeneracy rather than quietly reporting the zero, and section 7's generator remains the only thing here that puts a negative price where the strategy would sell.

Size, measured

plain JSON, working tree 893 171 B
what git actually stores for it 74 590 B
what git would store for a .json.gz 56 504 B

Git already zlib-compresses blobs, so gzipping saves 18 KB — 24% of an object already smaller than two of the three P1 captures beside it — and costs a reviewer the ability to read the provenance block, including the licence claim they are meant to check, in the diff. Plain JSON. load() still accepts .gz for a dataset that earns it.

Scope

Nothing is wired into the add-on. generic_dayahead remains the live fetch path; nothing under app/ imports the script; the dataset is for replay. No energy moved, no add-on restart, no HA writes.

All 15 test files pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC

OPT-04 reported AC4 — *"net-positive across 12 months of replayed prices in simulation"* — as **NOT MET**, and replayed a synthetic year instead, because `scenarios/` held only P1 meter captures. That was the right call and the wrong diagnosis of why: AC4 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, not a month. ## What lands `goodwe_controller/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; `--verify` re-derives every claim the file makes about itself with no network. ## Licensing, checked before anything was committed Source: **Fraunhofer ISE's Energy-Charts API** (`api.energy-charts.info/price?bzn=BE`, no key, no registration). Its OpenAPI document splits the zones in two, and the split is the whole reason this had to be checked rather than assumed: - **BE is licensed CC BY 4.0 from Bundesnetzagentur | SMARD.de, "published without changes"** — redistribution permitted with attribution, so the data is **committed**. - Other zones are *"for private and internal use only … expressly prohibited … including but not limited to EPEX SPOT SE."* Had BE been there, the right output would have been the script plus a checksum and no data. The fetcher **refuses to write a file** whose per-response `license_info` stops saying CC BY 4.0 — a licence checked once by a human and assumed forever is a licence that changes silently. **Cross-checked against the upstream**: SMARD's own `chart_data 4996/DE` series over 2026-04-10 matched this feed on **96 of 96 quarter-hours, max difference 0.00 EUR/MWh**. ## Raw wholesale, and the assumption that is not baked in The figures are **raw wholesale**, and the file says so: `"all_in": false`. The markup/tax assumption lives in `provenance.all_in_assumption` as **numbers to apply**, not numbers already applied — TARIFF-02's double-counting trap in data form. all_in = (wholesale + markup + network_and_levies) * (1 + vat) Section 7B applies it in one visible place, and **asserts arithmetically** that the two additive terms cancel out of every daily spread — so the trade count depends on the VAT multiplier alone and not on the guessed markup. The additive terms do **not** cancel out of §17's guard, which compares one hour against zero, so the replay is run on **raw wholesale as well** and the difference reported: **110 trades all-in, 88 raw.** ## DST, by construction Iterate in UTC, render through `price.brussels()` — no `zoneinfo`, no second copy of the EU rule. **2025-10-26 comes out 25 points and 2026-03-29 comes out 23**, verified by counting per Brussels day rather than asserting 24, by checking October's 02:00 appears **twice** at `+02:00` and `+01:00` **with two different prices**, and that March's 02:00 never appears. Two real bugs the point-and-gap accounting caught rather than swallowed: - bounding the window at 00:00 **UTC** made the first Brussels day **22 hours** — not one of the two lengths a transition produces; - an inclusive-vs-exclusive `end` off-by-one dropped **2026-08-31 entirely**, silently: 8736 points where 8760 were expected. A day nobody asked for is not a day with no prices. Nulls are dropped, incomplete hours become enumerated `gaps`, and `points + gaps == hours in the window` is asserted. The 20 hours at exactly `0.000000` are **real clearings** in solar-glut hours, and the check says so instead of outlawing the value. ## AC4, at the shipped default, whatever it is | run | required | round trips | net | |---|---|---|---| | **shipped 0.10, widened 1.5× (no weather)** | 0.1500 | **110** | **+24.88 EUR** | | shipped 0.10, unwidened | 0.1000 | 173 | +28.00 EUR | | raw wholesale, shipped, widened | 0.1500 | 88 | +20.20 EUR | | 0.0754 (what the data suggests), widened | 0.1131 | 156 | +35.42 EUR | **AC4 is met**: twelve months of real replayed prices are net-positive at the shipped default, on trades that actually happened. ⚠️ **Nothing was tuned.** `cycle_cost_threshold_eur` and FCST-03's 2.5/1.5 are the product owner's open decision and are untouched; every number above comes out of the same unmodified `decide()`. ## What the owner's decision now has under it The **four independent reports** that a realistic Belgian static day/night spread of ~0.0840 does not clear 0.10 **all stand** — a static-tariff site still never trades at the shipped default. What nobody could say was the other half: - a **dynamic** site's daily spread clears the widened 0.1500 requirement on **121 of 365 days**; - the year's **median daily all-in spread is 0.1130**, so a median day would clear at a threshold of **0.0754**; - the honest caveat on magnitude: **+24.88 EUR/year** at 2 kWh a trade. Net-positive, and modest. Both threshold figures are reported. Neither is applied. ## Why the synthetic year stays ⚠️ **The real year cannot grade §17.** Across all 365 days, the number whose **dearest** hour is priced below zero is **zero** — raw and all-in alike — even though the year holds 372 negative wholesale hours. So "0 discharges into a negative-price hour" over real prices counts something that could not have happened; deleting the guard would not change it. Section 7B **asserts that degeneracy** rather than quietly reporting the zero, and section 7's generator remains the only thing here that puts a negative price where the strategy would sell. ## Size, measured | | | |---|---| | plain JSON, working tree | 893 171 B | | **what git actually stores for it** | **74 590 B** | | what git would store for a `.json.gz` | 56 504 B | Git already zlib-compresses blobs, so gzipping saves 18 KB — 24% of an object already smaller than two of the three P1 captures beside it — and costs a reviewer the ability to read the provenance block, *including the licence claim they are meant to check*, in the diff. Plain JSON. `load()` still accepts `.gz` for a dataset that earns it. ## Scope **Nothing is wired into the add-on.** `generic_dayahead` remains the live fetch path; nothing under `app/` imports the script; the dataset is for replay. No energy moved, no add-on restart, no HA writes. All 15 test files pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
admin added 1 commit 2026-08-31 11:02:26 +02:00
DATA-02: a real Belgian price year, so AC4 stops being unmeetable
checks / unit (pull_request) Successful in 3m36s
d57122ec74
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
admin merged commit 86c43eba24 into release/1.0 2026-08-31 11:12:49 +02:00
Author
Owner

Merged as 86c43eb. I checked the licence against the upstream itself rather than against your description of it, and it holds. The spread-cancellation argument is exact. The app/ diff is genuinely zero. And I found one thing your cross-check did not rule out, which turns out to strengthen it rather than weaken it.

Read-back

release/1.0  86c43eb   parents 4601682 + d57122e
tree         175a2a5d  - byte-identical to the branch tree I reviewed
app/ diff, merged release/1.0 vs its base:  0 lines
15 suites from merged release/1.0, -B with recursive __pycache__ purge: all pass
checks / unit  success, "Successful in 3m36s", verified before merging

Licensing — verified against the source, not the claim

I queried the live API and the OpenAPI document myself:

GET /price?bzn=BE   license_info = "CC BY 4.0 (creativecommons.org/licenses/by/4.0)
                                    from Bundesnetzagentur | SMARD.de"

OpenAPI: "...is CC BY 4.0 from Bundesnetzagentur | SMARD.de and is published
          without changes: AT, BE, CH, CZ, ... PL, SE4, SI"
         "The data for the other bidding zones is for private and internal use
          only... expressly prohibited... including but not limited to EPEX SPOT SE."

BE is explicitly on the CC BY 4.0 list. The split is real and the data may be committed with attribution, which the provenance block carries. Your reading is correct.

One caution for whoever revisits this. I first tried to establish the split by querying zones and comparing license_info. FR, AT and CH came back as errors — which look like a licensing difference and are not: they were rate limits. AT and CH are on the CC BY list. Enumerating the split from per-response fields would have produced a confidently wrong answer; the OpenAPI document is the authority, and your provenance block cites it rather than the field. Right choice, and worth the sentence.

The fetcher's guard is real and I drove it. LICENCE_MUST_CONTAIN = "CC BY 4.0", checked per response, SystemExit on mismatch with the correct remedy named — "the right output is this script and a checksum, not the data." The sibling unit guard ("MWh" not in unit) is the same instinct against a silent factor of 1000, and TARIFF-02 already has a MAX_PLAUSIBLE_EUR_KWH for exactly that failure. Good.

The cross-check — reproduced, and I closed a hole in it

I reproduced your result exactly: 96 of 96 quarter-hours of 2026-04-10, max difference 0.00 EUR/MWh against SMARD chart_data/4996/DE.

Then I checked something the provenance block does not address. 4996/DE is a German URL path, and SMARD serves no BE region for that filter (4996/BE → 404). So a 96/96 match could have meant BE and DE-LU were simply coupled that day, which would make the control confounded — it would pass equally well if Energy-Charts were serving DE data under the BE label. So I compared the two zones:

energy-charts BE vs energy-charts DE-LU, 2026-04-10
  overlapping intervals : 96
  max |BE - DE-LU|      : 77.47 EUR/MWh
  intervals differing   : 96 of 96

The zones were far apart all day, and SMARD's series matched BE and not DE-LU. So the comparison is zone-correct and the control is not confounded. Your cross-check is stronger than you claimed for it.

What it establishes is that Energy-Charts republishes SMARD unchanged — which is exactly what the licence text asserts. It cannot validate SMARD's own numbers, and nothing available to us could. Worth stating in the provenance block so nobody later reads it as market validation.

The spread-cancellation argument — exact, verified on the data

Algebraically all_in = (w + m + n)(1 + v) makes any same-day difference (w₁ − w₂)(1 + v), so the additive terms vanish. Rather than take the algebra, I computed both spreads for all 365 days:

max | all_in_spread − raw_spread × (1 + VAT) |  over 365 days :  9.7e-17

Float noise. The markup and levy guesses cannot move a single trade count — only the VAT multiplier scales the spread, and that is a legislated number rather than an assumption. That is the right argument and it is a genuinely important one: it converts the weakest part of the dataset (two guessed constants) into something that provably does not matter for AC4. They still matter for §17, which is why running the raw replay too (110 all-in vs 88 raw) is the correct completion.

DST — both bugs fixed, and the count check is not vacuous

points 8760, distinct Brussels days 365
day-length histogram: {23: 1, 24: 363, 25: 1}
2025-10-26 -> 25 points      2026-03-29 -> 23 points
local 02:00 on 2025-10-26 appears twice:
   2025-10-26T02:00:00+02:00   0.018507 EUR/kWh
   2025-10-26T02:00:00+01:00   0.005770 EUR/kWh   (different prices)
2026-08-31 present, 24 points   (the inclusive-end off-by-one)
no 22-hour day                  (the UTC-bounded-window bug)

The histogram is what makes this non-vacuous: exactly one 23 and one 25 in the right places, derived by counting per Brussels day rather than asserting 24. And the two 02:00 points carry different prices, so they are two instants and not one duplicated.

§17 degeneracy — the assertion is sound, and I would have insisted on it

negative wholesale hours in the year      : 372
days whose DEAREST hour is negative (raw) :   0
days whose DEAREST hour is negative (all-in): 0
all-in hours below zero                   :  12

Confirmed independently, and under both framings. A "0 discharges into negative hours" result on this year would count something that could not have happened, and section 7B says so in those terms rather than banking the pass. Keeping section 7's synthetic year as the only thing that puts a negative price where the strategy would sell is exactly right — real data arriving is not a reason to retire the only stimulus that reaches a guard. This is the degeneracy discipline applied to a dataset, and it is the best thing in the PR.

AC4 — numbers reproduce

110 round trips, +24.88 EUR, 0 exceptions of 110 clearing the shipped 0.10, and it did not trade every day (110 of 365), so the threshold selected rather than waved everything through. git diff over app/ is 0 lines — I verified it on the branch and again on merged release/1.0. Nothing was tuned to reach this, which is the property the owner's pending decision depends on.

The two judgement calls

Plain JSON over gzip — agree. My measurements: 893,171 bytes plain, 62,224 gzipped. Git compresses in the pack regardless, so gzip buys a small one-off and costs the reviewable provenance block permanently — and a gzipped blob is opaque to git diff forever, on a file whose entire value is that a reader can see where it came from. The 18 KB is not worth it.

indent=1 → one object per line: agree it is better, disagree it is worth a round. 43,849 lines against ~8,800. But this is a frozen dataset carrying its own sha256_of_points; it will be regenerated rather than diffed. Against that, a re-push costs a CI round on a single-slot runner under the push-once constraint. Do it if the file is ever regenerated; do not spend a round on it now.

Coordination

DATA-01 conflicts with this on CHANGELOG.md only — verified. Everything else is disjoint: DATA-01 writes into scenarios/p1-net-30d-2026-08/ plus tools/, test_peakguard.py and test_tariff.py; DATA-02 writes one JSON at the scenarios/ root plus fetch_epex_be.py and test_arbitrage.py. A one-file textual resolution.

What I could not verify

That SMARD's own BE numbers are correct — nothing reachable from here can, and you do not claim it. And the markup/levy constants remain assumptions; the cancellation argument is what makes that acceptable for AC4, not any evidence about the constants themselves.

No surviving mutation to report: app/ is untouched by this PR, so there was no shipped logic to mutate. Good work — the licence check and the 7B degeneracy assertion are both the kind of thing that is easy to skip and expensive to skip.

**Merged as `86c43eb`.** I checked the licence against the upstream itself rather than against your description of it, and it holds. The spread-cancellation argument is exact. The `app/` diff is genuinely zero. And I found one thing your cross-check did not rule out, which turns out to strengthen it rather than weaken it. ## Read-back ``` release/1.0 86c43eb parents 4601682 + d57122e tree 175a2a5d - byte-identical to the branch tree I reviewed app/ diff, merged release/1.0 vs its base: 0 lines 15 suites from merged release/1.0, -B with recursive __pycache__ purge: all pass checks / unit success, "Successful in 3m36s", verified before merging ``` ## Licensing — verified against the source, not the claim I queried the live API and the OpenAPI document myself: ``` GET /price?bzn=BE license_info = "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de" OpenAPI: "...is CC BY 4.0 from Bundesnetzagentur | SMARD.de and is published without changes: AT, BE, CH, CZ, ... PL, SE4, SI" "The data for the other bidding zones is for private and internal use only... expressly prohibited... including but not limited to EPEX SPOT SE." ``` **BE is explicitly on the CC BY 4.0 list.** The split is real and the data may be committed with attribution, which the provenance block carries. Your reading is correct. **One caution for whoever revisits this.** I first tried to establish the split by querying zones and comparing `license_info`. FR, AT and CH came back as errors — which look like a licensing difference and are not: they were **rate limits**. AT and CH are on the CC BY list. Enumerating the split from per-response fields would have produced a confidently wrong answer; the OpenAPI document is the authority, and your provenance block cites it rather than the field. Right choice, and worth the sentence. **The fetcher's guard is real and I drove it.** `LICENCE_MUST_CONTAIN = "CC BY 4.0"`, checked per response, `SystemExit` on mismatch with the correct remedy named — *"the right output is this script and a checksum, not the data."* The sibling unit guard (`"MWh" not in unit`) is the same instinct against a silent factor of 1000, and TARIFF-02 already has a `MAX_PLAUSIBLE_EUR_KWH` for exactly that failure. Good. ## The cross-check — reproduced, and I closed a hole in it I reproduced your result exactly: **96 of 96 quarter-hours of 2026-04-10, max difference 0.00 EUR/MWh** against SMARD `chart_data/4996/DE`. Then I checked something the provenance block does not address. `4996/DE` is a *German* URL path, and SMARD serves no BE region for that filter (`4996/BE` → **404**). So a 96/96 match could have meant BE and DE-LU were simply coupled that day, which would make the control confounded — it would pass equally well if Energy-Charts were serving DE data under the BE label. So I compared the two zones: ``` energy-charts BE vs energy-charts DE-LU, 2026-04-10 overlapping intervals : 96 max |BE - DE-LU| : 77.47 EUR/MWh intervals differing : 96 of 96 ``` The zones were **far apart all day**, and SMARD's series matched **BE** and not DE-LU. So the comparison is zone-correct and the control is not confounded. Your cross-check is stronger than you claimed for it. What it establishes is that Energy-Charts republishes SMARD unchanged — which is exactly what the licence text asserts. It cannot validate SMARD's own numbers, and nothing available to us could. Worth stating in the provenance block so nobody later reads it as market validation. ## The spread-cancellation argument — exact, verified on the data Algebraically `all_in = (w + m + n)(1 + v)` makes any same-day difference `(w₁ − w₂)(1 + v)`, so the additive terms vanish. Rather than take the algebra, I computed both spreads for all 365 days: ``` max | all_in_spread − raw_spread × (1 + VAT) | over 365 days : 9.7e-17 ``` Float noise. **The markup and levy guesses cannot move a single trade count** — only the VAT multiplier scales the spread, and that is a legislated number rather than an assumption. That is the right argument and it is a genuinely important one: it converts the weakest part of the dataset (two guessed constants) into something that provably does not matter for AC4. They still matter for §17, which is why running the raw replay too (110 all-in vs 88 raw) is the correct completion. ## DST — both bugs fixed, and the count check is not vacuous ``` points 8760, distinct Brussels days 365 day-length histogram: {23: 1, 24: 363, 25: 1} 2025-10-26 -> 25 points 2026-03-29 -> 23 points local 02:00 on 2025-10-26 appears twice: 2025-10-26T02:00:00+02:00 0.018507 EUR/kWh 2025-10-26T02:00:00+01:00 0.005770 EUR/kWh (different prices) 2026-08-31 present, 24 points (the inclusive-end off-by-one) no 22-hour day (the UTC-bounded-window bug) ``` The histogram is what makes this non-vacuous: exactly one 23 and one 25 in the right places, derived by counting per Brussels day rather than asserting 24. And the two 02:00 points carry **different prices**, so they are two instants and not one duplicated. ## §17 degeneracy — the assertion is sound, and I would have insisted on it ``` negative wholesale hours in the year : 372 days whose DEAREST hour is negative (raw) : 0 days whose DEAREST hour is negative (all-in): 0 all-in hours below zero : 12 ``` Confirmed independently, and under **both** framings. A "0 discharges into negative hours" result on this year would count something that could not have happened, and section 7B says so in those terms rather than banking the pass. Keeping section 7's synthetic year as the only thing that puts a negative price where the strategy would sell is exactly right — real data arriving is not a reason to retire the only stimulus that reaches a guard. This is the degeneracy discipline applied to a dataset, and it is the best thing in the PR. ## AC4 — numbers reproduce `110` round trips, `+24.88 EUR`, `0 exceptions of 110` clearing the shipped `0.10`, and it did not trade every day (110 of 365), so the threshold selected rather than waved everything through. **`git diff` over `app/` is 0 lines** — I verified it on the branch and again on merged `release/1.0`. Nothing was tuned to reach this, which is the property the owner's pending decision depends on. ## The two judgement calls **Plain JSON over gzip — agree.** My measurements: 893,171 bytes plain, 62,224 gzipped. Git compresses in the pack regardless, so gzip buys a small one-off and costs the reviewable provenance block permanently — and a gzipped blob is opaque to `git diff` forever, on a file whose entire value is that a reader can see where it came from. The 18 KB is not worth it. **`indent=1` → one object per line: agree it is better, disagree it is worth a round.** 43,849 lines against ~8,800. But this is a frozen dataset carrying its own `sha256_of_points`; it will be regenerated rather than diffed. Against that, a re-push costs a CI round on a single-slot runner under the push-once constraint. Do it if the file is ever regenerated; do not spend a round on it now. ## Coordination DATA-01 conflicts with this on **`CHANGELOG.md` only** — verified. Everything else is disjoint: DATA-01 writes into `scenarios/p1-net-30d-2026-08/` plus `tools/`, `test_peakguard.py` and `test_tariff.py`; DATA-02 writes one JSON at the `scenarios/` root plus `fetch_epex_be.py` and `test_arbitrage.py`. A one-file textual resolution. ## What I could not verify That SMARD's own BE numbers are correct — nothing reachable from here can, and you do not claim it. And the markup/levy constants remain assumptions; the cancellation argument is what makes that acceptable for AC4, not any evidence about the constants themselves. No surviving mutation to report: `app/` is untouched by this PR, so there was no shipped logic to mutate. Good work — the licence check and the 7B degeneracy assertion are both the kind of thing that is easy to skip and expensive to skip.
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#24