Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5ea39784d | ||
|
|
559f0eb022 | ||
|
|
c81289527f | ||
|
|
e6c45a61e7 | ||
|
|
88c59a9918 | ||
|
|
17b7b447c4 | ||
|
|
4257e38e5a | ||
|
|
7a5281e97f | ||
|
|
6eba18968c | ||
|
|
958f5cd218 |
@@ -15,6 +15,249 @@ if it has, it needs 0.10.0. Whoever deploys decides.
|
||||
| _this branch_ | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
|
||||
| _this branch_ | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
|
||||
| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
|
||||
| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
|
||||
| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
|
||||
| _this branch_ | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
|
||||
| _this branch_ | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing |
|
||||
|
||||
### TARIFF-10 - a configured provider is fetched, whether or not you trade
|
||||
|
||||
**`amain()` started the background tariff refresh only when
|
||||
`arbitrage_enabled` was on, and that option ships `false`.**
|
||||
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
only caller of `price_history.carry()` - so on a shipped install with a price
|
||||
provider configured **nothing was ever fetched**: no plan resolved,
|
||||
`/data/price_history.json` never created, the *Price history* status row
|
||||
reading `empty` for the life of the install, and TARIFF-09's short-horizon
|
||||
warning unable to fire because there was no fetch to warn about.
|
||||
|
||||
Measured over a 40 h soak of two instances side by side on the same feed:
|
||||
|
||||
| | shipped default + provider | same, arbitrage armed |
|
||||
|---|---|---|
|
||||
| feed requests | **0** | fetching |
|
||||
| `/data/price_history.json` | `present: false` on **all 802 samples** | written |
|
||||
| resolved plan | none | 132 points |
|
||||
|
||||
⚠️ **`DOCS.md` has described the fixed behaviour since the commit that wrote
|
||||
the gate.** OPT-04 (`bdd7f18`) added the refresh task, the
|
||||
`arbitrage.enabled()` guard on it, and the sentence *"turning a provider on
|
||||
still fetches, validates and publishes prices and changes no charging
|
||||
decision"* — all in one change. `config.yaml` has said the same since
|
||||
TARIFF-02 (`852fc7c`). The documentation and the code have therefore disagreed
|
||||
for as long as both existed, and nothing compared them. `config.yaml` and both translations carried the same
|
||||
claim, plus a second one that had gone stale in the other direction (*"nothing
|
||||
consumes the price series yet"*, untrue since OPT-04). All four now say what
|
||||
the add-on does.
|
||||
|
||||
**The gate is `tariff_refresh_wanted()`, and it is a STRICT WIDENING.**
|
||||
|
||||
```
|
||||
provider_configured(opts) or arbitrage.enabled(opts)
|
||||
```
|
||||
|
||||
Every configuration that started the refresh before still starts it. The second
|
||||
term is not redundant and is not there for symmetry: `price_provider: off` with
|
||||
a `static_profile` and `arbitrage_enabled: true` is a configuration
|
||||
`validate.py` explicitly **accepts**, and it trades - measured, at
|
||||
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 EUR/kWh day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Gating the refresh on the provider alone would
|
||||
leave that site's `tariff_plan` at `None` forever and answer *"no tariff plan
|
||||
has been resolved yet"* on every cycle - silently disarming a strategy the UI
|
||||
says is running.
|
||||
|
||||
⚠️ **Nothing acquires a periodic HTTPS call from this.** With `price_provider`
|
||||
off, `resolve_tariff()` answers out of the static profile without opening a
|
||||
socket; and the true shipped default - no provider, no trading - starts **no
|
||||
task at all**. What decides whether a request leaves the machine is
|
||||
`price_provider`, and only that.
|
||||
|
||||
⚠️ **AND NOTHING TRADES THAT DID NOT TRADE BEFORE.** `cycle()`'s
|
||||
`if arbitrage.enabled(self.o):` is untouched. Graded directly rather than
|
||||
inferred from the gate having moved: `Controller.cycle()` is driven on a
|
||||
resolved provider plan whose cheapest firm hour is now, with the peak guard
|
||||
asserted clear, and `arbitrage_enabled` is the single option that moves between
|
||||
the rows.
|
||||
|
||||
| `arbitrage_enabled` | cheapest hour is now | commanded |
|
||||
|---|---|---|
|
||||
| `false` | yes | **+180 W** (`loop -> 180 W`) |
|
||||
| `false` | no | **+180 W** (`loop -> 180 W`) |
|
||||
| `true` | yes | -5000 W (`arbitrage -> -5000 W`) |
|
||||
| `true` | no | +5000 W (`arbitrage -> 5000 W`) |
|
||||
|
||||
**`TariffPlan.cheap_now()` is deliberately NOT wired, and that is now a
|
||||
decision rather than an inheritance.** A provider-backed plan exists on every
|
||||
configured site from this commit, so *Cheap window* - which feeds the
|
||||
maintenance cycle's grid top-up, i.e. the one place a price reaches something
|
||||
that **moves energy** without trading being armed - could now read it. It does
|
||||
not: `self.cheap` still comes from `static_status()`, and on a provider-only
|
||||
site (`static_profile` empty) that is `False` on every cycle, so no paid grid
|
||||
top-up is ever forced. Wiring it would take that from *never* to *sometimes*.
|
||||
`DECISIONS/TARIFF-10-fetching-is-not-trading.md` §4 carries the argument and
|
||||
the list of what adopting it would take.
|
||||
|
||||
**The check that would have caught this** is `test_price.py` section 13, and it
|
||||
grades **reachability from a configuration**, not the shape of an `if`. The
|
||||
guards in `amain()` are *evaluated* against real option dicts, whatever they are
|
||||
spelled as; the call graph under `app/` is walked to see whether the tasks those
|
||||
guards start can arrive at `resolve_tariff()`, wherever it has moved to; and
|
||||
then the fetch is **driven** with a counting transport, because a graph is an
|
||||
argument and a counted request is an observation. The shipped defect - the gate
|
||||
back at `arbitrage.enabled(opts)`, **-4 B** - is red on seven named checks.
|
||||
|
||||
### TARIFF-12 - a returned degradation is not a refresh
|
||||
|
||||
**`run_tariff_refresh()` promised in its own docstring that "a failure does not
|
||||
clear the plan", and enforced it with `except Exception` - a branch a supplier
|
||||
outage never reaches.** `load_prices()` states the reason in its own words: it
|
||||
"RETURNS, NEVER RAISES, for every failure a site can actually have", because a
|
||||
degradation arriving as a traceback into the planning tick would stop the
|
||||
controller regulating over an expired certificate. So a dead feed **returns** a
|
||||
§11.4 plan, the `try` succeeds, and the assignment below it overwrote the good
|
||||
plan unconditionally. With `static_profile` unset - the shipped default, and the
|
||||
normal state of a dynamic-tariff site - `_degrade()` returns
|
||||
`TariffPlan(no_data, None, "none")`: **a complete 48-hour plan replaced by
|
||||
nothing.**
|
||||
|
||||
Measured in a 40-hour soak, `static_profile` empty, a 90-minute outage driven:
|
||||
|
||||
| Brussels | refresh | plan |
|
||||
|---|---|---|
|
||||
| 17:26:12 | 55 | `dynamic_dayahead from generic_dayahead, 141 h, provisional` |
|
||||
| **17:56:12** | **56** | **`no tariff - generic_dayahead: ClientConnectorError: Cannot connect to host`** |
|
||||
| 19:26:12 | 59 | restored, one refresh after the feed returned |
|
||||
|
||||
`origin: none`, `n_points: 0` for all 30 outage samples. The plan was gone ~27
|
||||
minutes after the last good fetch, **at the first failure** - so
|
||||
`TARIFF_MAX_AGE_S` (5400 s) never governed anything, and there was no
|
||||
serve-stale window at all: a transient blip cost exactly what a long outage did.
|
||||
|
||||
**The question is now asked once, after the attempt, from the evidence that
|
||||
exists** - FCST-06's answer, one module over. The evidence is
|
||||
`TariffPlan.from_feed` (did this resolution observe the configured feed) and the
|
||||
shipped `fresh_tariff_plan()` (is an observation still current). A degradation
|
||||
replaces a standing observation only once that observation has **aged out**, so
|
||||
`TARIFF_MAX_AGE_S` is now the serve-stale window it reads like.
|
||||
|
||||
**A §11.4 static fallback does not replace a fresh provider plan either**,
|
||||
although it carries real prices. Every point `_degrade()` builds is
|
||||
`provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses and
|
||||
`cheap_now()` is False - everything a consumer may act on is already off - while
|
||||
a day-ahead price for a given hour does not change once published. The fallback
|
||||
is less actionable and no more current; it takes over when the observation
|
||||
retires, which is what §11.4 is for. The rule is **not** symmetrical: a standing
|
||||
plan that is itself a degradation is replaced by the next one, or an outage
|
||||
would freeze the operator's static profile at the window it was first generated
|
||||
for.
|
||||
|
||||
**`tariff_at` does not move on a kept plan.** It is the moment of the fetch that
|
||||
produced the plan standing, `fresh_tariff_plan()` judges the age from it, and an
|
||||
unmoved timestamp is the only thing that tells a kept plan from a freshly
|
||||
fetched one. Re-stamping it would be the refresh asserting the feed answered
|
||||
when it did not - the "freeze at its last value and look current forever" hazard
|
||||
the same docstring names.
|
||||
|
||||
**What did NOT change.** The persisted price history still does not back the
|
||||
plan during an outage: FCST-03 exists to stop stale prices being acted on, and
|
||||
"recent" is not "current". Nothing about the control loop, the arbiter, or the
|
||||
30 s tick - `cycle()` still reads two attributes and awaits no fetch.
|
||||
|
||||
`test_arbitrage.py` section 8b drives the whole chain with **nothing stubbed but
|
||||
the socket**: the transport raises the way aiohttp does, `load_prices()` returns
|
||||
`no_data`, `_degrade()` builds the §11.4 plan, and the shipped
|
||||
`run_tariff_refresh()` decides. A check that patched `resolve_tariff` to *raise*
|
||||
would have passed against the defect. Eleven mutations, eleven reds, no
|
||||
survivors - see `DECISIONS/TARIFF-12-a-degradation-is-not-a-refresh.md`.
|
||||
|
||||
**Merged after TARIFF-10, which widened *which installs run this task*, and the
|
||||
two configurations it newly keeps alive are graded rather than reasoned about.**
|
||||
A provider with trading disarmed is the case this guard was designed for and is
|
||||
covered by every driven check here, because nothing in `run_tariff_refresh()`
|
||||
reads a trading option at all - asserted on the AST symbols rather than on the
|
||||
source text, after two drafts of that check went red on a correct tree for
|
||||
saying "arbitrage" in a docstring and in a log line. `price_provider: off` + a
|
||||
`static_profile` resolves to the CONFIGURED product (`origin: "static"`), so
|
||||
`standing.from_feed` is false on every refresh and the guard never latches: the
|
||||
plan is regenerated for the current window exactly as it always was. Both are
|
||||
driven, and a mutation that makes the guard depend on `arbitrage.enabled()` is
|
||||
red on five named checks.
|
||||
|
||||
|
||||
### TARIFF-09 - the feed is asked for the range the caller wanted
|
||||
|
||||
**`generic_dayahead` took `since` and `until` and requested the bare feed URL,
|
||||
which answers one Brussels day.** Everything past today was then filled from
|
||||
yesterday's profile by FCST-04, flagged provisional, and §8.3 drops provisional
|
||||
hours from the trade set - so **the optimiser never saw tomorrow as firm, at any
|
||||
hour of any day.** Measured on `4257e38` at 2026-09-10 09:00 CEST over the
|
||||
shipped 48 h horizon, live feed, yesterday seeded:
|
||||
|
||||
| | 48 h horizon, one-day feed |
|
||||
|---|---|
|
||||
| firm | **15 whole hours** (60 quarter-hour points) |
|
||||
| carried, provisional | **33 hours** - 24 on D+1, 9 on D+2 |
|
||||
| permitted cycle | buy 11:45, sell 19:45 - **both on 2026-09-10** |
|
||||
| firm local days | `[2026-09-10]` |
|
||||
|
||||
The ceiling was structural, not incidental: with a one-day window, at hour *H*
|
||||
of the local day a 48 h horizon is at most **24−H hours firm and 24+H carried**.
|
||||
At 23:00 that is **1 firm hour and 47 carried**, and the remaining firm spread
|
||||
(0.0464 EUR/kWh) no longer clears the default 0.10 round-trip threshold at all -
|
||||
so arbitrage was refused outright. This is **not** what FCST-04's fill is for:
|
||||
that covers the window until the ~13:00-14:00 CET auction, after which a healthy
|
||||
feed publishes tomorrow in full.
|
||||
|
||||
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
|
||||
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns 192 quarter-hours across two
|
||||
local days, committed unmodified beside TARIFF-08's captures. The plugin simply
|
||||
never asked.
|
||||
|
||||
**The range is now derived from `since`/`until` on the BRUSSELS calendar**, as
|
||||
two inclusive dates. Not the UTC calendar: 22:30 UTC in summer is already
|
||||
tomorrow in Belgium, so a UTC-derived start asks for the day the controller has
|
||||
just left and drops the day it is standing in, for the last one or two hours of
|
||||
every day. That is `price_history._bdate()`'s lesson one layer up, with a worse
|
||||
consequence - `_bdate()` misfiles an hour, this never fetches it. `until` is
|
||||
exclusive, so a horizon ending exactly at local midnight asks for one day fewer
|
||||
than the naive reading gives, and an inverted window collapses to a single day
|
||||
rather than a backwards range.
|
||||
|
||||
Same code path, same window, a feed serving both days:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| firm hours of 48 | 15 | **39** |
|
||||
| carried, provisional | 33 | 9 |
|
||||
| firm local days | 1 | **2** |
|
||||
| permitted cycle | one calendar day | **buys on one day, sells on the next** |
|
||||
|
||||
The 9 remaining carried hours are the tail of the horizon that runs into a third
|
||||
Brussels day, which no day-ahead market publishes. FCST-04 still fills them and
|
||||
still flags them; what changed is what is firm, never what may be acted on.
|
||||
|
||||
**Before publication nothing regresses, and that is graded rather than
|
||||
assumed.** Asking for tomorrow before the auction returns fewer days than
|
||||
requested - measured, at 09:46 CEST the two-day request came back byte-identical
|
||||
to the one-day body - and that stays a `series` of what exists, with FCST-04
|
||||
filling the remainder. A `no_data` there would switch a working site's arbitrage
|
||||
off every morning.
|
||||
|
||||
⚠️ **`price_feed_url` is the EXACT URL and the plugin appends nothing to it, so
|
||||
an overriding site keeps the short horizon.** That is a decision, not an
|
||||
oversight: `start`/`end` are this feed's spelling, the provider declares a second
|
||||
host whose parameters are different, and rewriting a URL an operator typed is
|
||||
how a request nobody intended gets made. **It is not silent** - the plugin logs a
|
||||
`WARNING` at fetch time naming the consequence and the `start..end` it would
|
||||
otherwise have asked for. See
|
||||
`DECISIONS/TARIFF-09-the-range-and-the-override.md`.
|
||||
|
||||
The plugin contract - `fetch(self, opts, since, until, transport)` hands every
|
||||
plugin the range - is still **not** enforced, because three shipped plugins point
|
||||
at endpoints nobody has verified and a rule obliging them to pass a range would
|
||||
be satisfied by an invented parameter name. Which plugins consume the range is
|
||||
pinned as a literal in `test_price.py` instead, so the next supplier has to
|
||||
decide rather than inherit the silence.
|
||||
|
||||
### FCST-06 - the second way to be a store that cannot persist
|
||||
|
||||
@@ -72,6 +315,91 @@ EISDIR) and a volume that moves out from under an open store (`mkstemp`,
|
||||
ENOENT) - because the rule is enforced on the outcome and one cause would grade
|
||||
a coincidence. See `DECISIONS/FCST-06-cannot-persist-is-one-question.md`.
|
||||
|
||||
### TARIFF-08 - no network price provider had ever parsed a live response
|
||||
|
||||
**`generic_dayahead` could not read one byte of `api.energy-charts.info`, and
|
||||
had never been able to.** `_points_from_json()` asks for `doc["data"]`, a list
|
||||
of `{start, price_eur_mwh}` objects. That document does not exist. Fetched live
|
||||
on 2026-09-10, the feed returns **columnar** parallel arrays and no `data` key
|
||||
at all:
|
||||
|
||||
```
|
||||
top-level keys: ['deprecated', 'license_info', 'price', 'unit', 'unix_seconds']
|
||||
unix_seconds: 96 UTC epoch seconds, first 1788991200, spacing 900 s
|
||||
price: 96 floats, first 216.08
|
||||
unit: 'EUR / MWh'
|
||||
```
|
||||
|
||||
Driving the shipped parser against that body: `SourceUnavailable: the feed
|
||||
carried no 'data' list`. Since `generic_dayahead` is the only shipped provider
|
||||
whose endpoint anybody has reached, **dynamic pricing did not work at all**.
|
||||
|
||||
⚠️ **AND §11.4 MADE THAT SILENT, WHICH IS WHY IT LASTED.** A parse error becomes
|
||||
`no_data`, becomes the static profile, becomes arbitrage off - the *correct*
|
||||
degradation, and it says nothing whatsoever about the cause. A site would have
|
||||
sat on the fallback indefinitely while the status page reported only that the
|
||||
feed was down.
|
||||
|
||||
⚠️ **THE SUITE COULD NOT SEE IT BECAUSE EVERY FIXTURE WAS BUILT FROM THE
|
||||
PARSER'S OWN ASSUMPTION.** `test_price.py`, `test_price_history.py` and
|
||||
`test_static_tariff.py` each built `json.dumps({"data": [{"start": ...,
|
||||
"price_eur_mwh": ...}]})` - a shape invented here, published by nobody. The
|
||||
checks graded the parser against the parser's guess: a closed loop that cannot
|
||||
notice the guess is wrong, and no amount of coverage inside it would have. The
|
||||
loop is broken by **bytes nobody in this repository wrote**: two unmodified
|
||||
response bodies are committed under `scenarios/`, one quarter-hourly
|
||||
(2026-09-10, 96 points at 900 s) and one hourly (2025-09-10, 24 points at
|
||||
3600 s - the same endpoint before Belgium's 15-minute MTU), with URL, retrieval
|
||||
time, licence-as-returned and a sha256 recorded beside them in
|
||||
`energy-charts-be-price-captures.json` and **re-derived** by the replay, so a
|
||||
fixture edited to make a check pass stops being a capture. The remaining
|
||||
invented fixtures are renamed `synthetic_row_feed` at every call site.
|
||||
|
||||
**The columnar document is read by its own function, `_points_from_columnar_json()`.**
|
||||
Not by teaching the row parser to sniff for both: a parser that decides which
|
||||
document it received is a parser that can decide wrong, and a wrong-but-
|
||||
parseable response then becomes a price. Two shapes, two functions, and each
|
||||
plugin names the one its feed publishes. Feeding the row shape to
|
||||
`generic_dayahead` is now a graded `no_data`, so the sniff cannot come back
|
||||
quietly.
|
||||
|
||||
⚠️ **UNEQUAL ARRAYS ARE REFUSED RATHER THAN ZIPPED.** `zip()` stops at the
|
||||
shorter list and says nothing, so a truncated `price` array would have produced
|
||||
a **real price series with its tail missing** - and the tail of a Belgian day is
|
||||
the evening peak. That is worse than no series: `no_data` switches arbitrage
|
||||
off, whereas a short series is traded on as though complete, and nothing
|
||||
downstream can tell them apart. Refused in both directions, with the two lengths
|
||||
in the reason.
|
||||
|
||||
⚠️ **A UNIT CHANGE IS REFUSED FOR A REASON `_validate_raw()` CANNOT COVER.** The
|
||||
plugin converts with a fixed `0.001`; if the feed ever published EUR/kWh under
|
||||
the same keys, every price would come out a **thousand times too small** and
|
||||
`MAX_PLAUSIBLE_EUR_KWH` would not fire, because it only bounds prices that are
|
||||
too large. DATA-02's fetcher already refused on this field; the shipped path
|
||||
does now too.
|
||||
|
||||
**`unix_seconds` are UTC instants, said so explicitly**
|
||||
(`datetime.fromtimestamp(t, timezone.utc)`); §9.3's rendering at the Brussels
|
||||
offset stays where it already lived, in `assemble()`, rather than being copied
|
||||
into the plugin where a mutation could not tell it from a no-op. A published
|
||||
`null` is dropped and logged with a count - a missing quarter is not a free
|
||||
quarter - while an all-null body is `no_data` named by this parser rather than
|
||||
by the boundary validator.
|
||||
|
||||
**End to end against the live feed, 2026-09-10:** `resolve_tariff()` returns a
|
||||
`TariffPlan` with `origin='provider'`, **96 quarter-hourly points**, spacing
|
||||
`[900]` s and no decimation, first point `2026-09-10T00:00:00+02:00` at 0.30608
|
||||
EUR/kWh all-in. FCST-04's H1 fix was never theoretical - 900 s is the shape of
|
||||
the real feed.
|
||||
|
||||
⚠️ **WHAT THIS DOES NOT FIX.** `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy` are untouched: their URLs are unverified, no real body can be
|
||||
captured for them, and withdrawing them is TARIFF-07's open decision. Their
|
||||
fixtures are now named `synthetic_row_feed` so nothing reads them as evidence
|
||||
that those feeds parse. And `generic_dayahead` still ignores `since`/`until`
|
||||
and asks for no date range, so the default URL returns one published day - a
|
||||
full 48 h horizon is reached only through §8.3's carry-forward fill.
|
||||
|
||||
### FCST-05 - what FCST-04's own contract still did not hold
|
||||
|
||||
**A single non-UTF-8 byte in `/data/price_history.json` stopped the add-on from
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# TARIFF-09 — asking the feed for the horizon, and what `price_feed_url` means
|
||||
|
||||
Two decisions. The first had no defensible alternative once it was measured;
|
||||
the second did, and the alternative is argued below rather than dismissed.
|
||||
|
||||
---
|
||||
|
||||
## 0. The measurement this card exists for
|
||||
|
||||
`generic_dayahead.fetch()` accepted `since` and `until` and requested the bare
|
||||
`https://api.energy-charts.info/price?bzn=BE`. That URL names no range, and the
|
||||
feed answers **one Brussels day**.
|
||||
|
||||
Measured on the merged tree (`4257e38`) at 2026-09-10 09:00 CEST, 48 h horizon,
|
||||
live feed, yesterday seeded through the shipped read path:
|
||||
|
||||
```
|
||||
60 firm points (15 whole hours) + 33 carried hours of 48 requested
|
||||
carried: 24 h on 2026-09-11, 9 h on 2026-09-12
|
||||
arbitrage(0.10): allowed, spread 0.4645, buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
|
||||
firm local days = [2026-09-10]
|
||||
```
|
||||
|
||||
Everything past today was carry-forward, flagged provisional, and §8.3 drops
|
||||
provisional hours from the trade set. **The optimiser never saw tomorrow as
|
||||
firm, at any hour.** The ceiling was structural rather than incidental: with a
|
||||
one-day window, at hour *H* of the local day a 48 h horizon is at most
|
||||
**24−H hours firm and 24+H carried** — 15/33 at 09:00, and **1/47 at 23:00**,
|
||||
where the remaining firm spread (0.0464 EUR/kWh) no longer even clears the
|
||||
default 0.10 round-trip threshold and arbitrage is refused outright.
|
||||
|
||||
That is not what FCST-04's fill was built for. FCST-04 covers the
|
||||
pre-publication window until the ~13:00–14:00 CET auction, after which a
|
||||
healthy feed hands over tomorrow in full.
|
||||
|
||||
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
|
||||
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns **192 quarter-hours across
|
||||
two local days**, committed unmodified as
|
||||
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`. The
|
||||
plugin simply never asked.
|
||||
|
||||
After the change, same code path, same window, a feed serving both days:
|
||||
**39 firm hours and 9 carried**, firm across **two** Brussels days, and the
|
||||
permitted cycle buys on one day and sells on the other. Graded in
|
||||
`test_price_history.py` section 1c against both feed states.
|
||||
|
||||
---
|
||||
|
||||
## 1. The range is derived on the **Brussels** calendar, from `since`/`until`
|
||||
|
||||
`since` and `until` are **instants**; the API takes **dates**. Those are not
|
||||
the same question, and the conversion is where the day gets lost.
|
||||
|
||||
`brussels_date_range()` returns two inclusive `date`s:
|
||||
|
||||
* the first is `brussels(since).date()`. **Not `since.date()`, and not the UTC
|
||||
date.** 22:30 UTC in summer is already tomorrow in Belgium, so a UTC-derived
|
||||
start asks for the day the controller has just left and drops the day it is
|
||||
standing in — for the last one or two hours of *every* day. This is
|
||||
`price_history._bdate()`'s lesson one layer up, with a worse consequence:
|
||||
`_bdate()` misfiles an hour, this never fetches it.
|
||||
* the last is `brussels(until).date()`, **stepped back a day when `until`
|
||||
falls exactly on local midnight**, because `until` is exclusive and that
|
||||
instant is the first tick of a day nobody asked for. Without it every
|
||||
whole-hour horizon requests one extra day — harmless on this feed, but it
|
||||
makes the request a claim the caller never made.
|
||||
* an empty or inverted window collapses to a single day rather than producing
|
||||
a backwards range, which no feed has a defined answer for.
|
||||
|
||||
Both ends are graded, and both are driven red by mutation — including the two
|
||||
that swap `brussels()` for the UTC date (`test_price.py` section 2c).
|
||||
|
||||
---
|
||||
|
||||
## 2. `price_feed_url` is the **exact URL**, used as given. The plugin appends
|
||||
nothing.
|
||||
|
||||
The alternative — merge our `start`/`end` into whatever query string the
|
||||
operator supplied — was considered and rejected. Three reasons, in the order
|
||||
they decided it:
|
||||
|
||||
1. **`start`/`end` are energy-charts' spelling, and this provider declares a
|
||||
second host.** `generic_dayahead.capabilities().egress_hosts` is
|
||||
`("api.energy-charts.info", "web-api.tp.entsoe.eu")`. That second host's
|
||||
day-ahead date parameters have not been verified by this card and are not
|
||||
guessed at here — but they are not `start`/`end`. So an override to the
|
||||
*other declared host* — a case the declaration itself anticipates — is
|
||||
exactly where merging appends two parameters that host ignores, producing
|
||||
the short horizon anyway, with a URL nobody typed as the receipt.
|
||||
2. **Merging means re-serialising a URL somebody else wrote.** Parsing and
|
||||
rebuilding reorders parameters, collapses duplicates and re-escapes values.
|
||||
"Override the feed" is what the option already says it means, and an
|
||||
override that is silently rewritten is not one.
|
||||
3. **Blind appending is not on the table at all.** `dated_feed_url()` refuses
|
||||
to add a query string to a base that already has one, so
|
||||
`?bzn=BE?bzn=BE&start=…` cannot be produced by this module even by a later
|
||||
edit that folds the parameters back into the constant.
|
||||
|
||||
⚠️ **The cost is real and it is therefore NOT a doc footnote.** An overriding
|
||||
operator gets exactly the short horizon this card exists to fix. So the plugin
|
||||
emits a `WARNING` on the shipped `goodwe.price` logger at **fetch time**, naming
|
||||
the consequence — one Brussels day, the rest carried and flagged, §8.3 dropping
|
||||
them from the trade set — and naming the `start..end` it *would* have asked
|
||||
for. §11.4 is what makes a price defect quiet; an operator who has taken the
|
||||
short horizon has to be able to find out from the log that they did.
|
||||
|
||||
The warning is graded, with a control that the default path stays silent, and
|
||||
a mutation that downgrades it to `debug()` takes that check red.
|
||||
|
||||
**Fetch time rather than startup**, because that is where the URL actually in
|
||||
use is known. The trade-off is one line per refresh (the tariff task, not the
|
||||
30 s control tick). If the status page ever grows a price-source row, this is
|
||||
the sentence it should carry.
|
||||
|
||||
---
|
||||
|
||||
## 3. The plugin contract: `fetch(self, opts, since, until, transport)`
|
||||
|
||||
The signature promises every plugin the range, and until this card **none of
|
||||
the four networked plugins used it**. The obvious follow-up is to enforce the
|
||||
promise. It is **not** enforced, and the reason is TARIFF-08's:
|
||||
|
||||
Three of the five shipped plugins point at endpoints nobody has ever verified
|
||||
(TARIFF-07 is the open decision on withdrawing them). A rule obliging them to
|
||||
pass a date range would be satisfied by an **invented parameter name** — which
|
||||
is the parser-graded-against-its-own-guess defect moved from the response to
|
||||
the request, and it would ship looking like a guarantee.
|
||||
|
||||
What exists instead is a **roster pin** in `test_price.py` section 2c: which
|
||||
plugins reference their `since`/`until` is asserted as a literal, derived from
|
||||
the registry and the AST. A sixth supplier, or a change to any of the three,
|
||||
has to edit that line and make the decision rather than inherit the silence.
|
||||
It is named as a source-shape pin, not as evidence that any feed parses;
|
||||
`generic_dayahead`'s behaviour is graded separately by the URL it really
|
||||
requests.
|
||||
|
||||
**Recommendation for whoever picks up TARIFF-07:** enforce it at the moment
|
||||
those three endpoints are either verified or withdrawn, not before. If they are
|
||||
withdrawn, `generic_dayahead` and `manual_csv` are the whole roster and the
|
||||
rule is one line — `manual_csv` reads a whole file and is windowed downstream,
|
||||
so it would be the single documented exemption.
|
||||
@@ -0,0 +1,277 @@
|
||||
# TARIFF-10 — fetching a price and trading on one are two decisions
|
||||
|
||||
**Status: DECIDED 2026-09-12 — Option A. If a price provider is configured,
|
||||
prices are fetched. Trading stays a separate switch (`arbitrage_enabled`), and
|
||||
this card moves nothing that moves energy.**
|
||||
|
||||
Filed with TARIFF-10. Follows [[TARIFF-09]] and [[FCST-04]]; §4 settles the
|
||||
limitation FCST-04 recorded and did not decide.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, and why it was invisible
|
||||
|
||||
`app/main.py`'s `amain()` read:
|
||||
|
||||
```
|
||||
if arbitrage.enabled(opts):
|
||||
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
```
|
||||
|
||||
`run_tariff_refresh` is the **only** caller of `resolve_tariff()`, which is the
|
||||
**only** caller of `price_history.carry()`. `arbitrage_enabled` ships `false`.
|
||||
|
||||
So on a shipped install with a provider configured:
|
||||
|
||||
* no HTTPS request was ever made to the feed;
|
||||
* no plan was ever resolved, so `self.tariff_plan` stayed `None` forever;
|
||||
* `/data/price_history.json` was never created;
|
||||
* the *Price history* status row read `empty` for the life of the install;
|
||||
* TARIFF-09's short-horizon warning could not fire, because it is logged on a
|
||||
fetch and there was no fetch.
|
||||
|
||||
Measured, not inferred — a 40 h soak, two instances, one feed:
|
||||
|
||||
| | shipped default + provider | same, arbitrage armed |
|
||||
|---|---|---|
|
||||
| feed requests | **0** | fetching |
|
||||
| `/data/price_history.json` | `present: false`, **802/802 samples** | written |
|
||||
| resolved plan | none | 132 points |
|
||||
|
||||
⚠️ **AND FOUR SURFACES ALREADY DESCRIBED THE FIXED BEHAVIOUR.** `DOCS.md`
|
||||
§*Energy price providers*: *"With `arbitrage_enabled: false`, which is the
|
||||
shipped default, turning a provider on still fetches, validates and publishes
|
||||
prices and changes no charging decision."* `config.yaml` and both translations
|
||||
said the same, and additionally carried a claim that had gone stale in the other
|
||||
direction — *"nothing consumes the price series yet"*, untrue since OPT-04
|
||||
shipped the consumer. The code was the only surface that disagreed with all of
|
||||
them, and no check compared the two.
|
||||
|
||||
The dating is worth being exact about, because it says this was never a
|
||||
regression. `git log -S` puts the refresh task, its `arbitrage.enabled()` guard
|
||||
**and** the DOCS sentence in the *same commit* — OPT-04, `bdd7f18`;
|
||||
`config.yaml`'s version of the claim goes back further, to TARIFF-02,
|
||||
`852fc7c`. The prose and the code disagreed from the moment both existed.
|
||||
|
||||
## 2. Why a check on the `if` would not have caught it
|
||||
|
||||
The `if` was exactly what somebody meant to write, and its comment argued for
|
||||
it honestly:
|
||||
|
||||
> ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on reads the
|
||||
> resolved plan … so on a default install this task would be network traffic in
|
||||
> aid of an attribute nobody reads.
|
||||
|
||||
That was true when OPT-04 wrote it and stopped being true when FCST-04 gave the
|
||||
plan a *store* and a *status row*. A pin on the expression would have gone red
|
||||
on the fix and green on the defect. What was wrong is the **set of
|
||||
configurations from which a fetch is reachable**, so that is what
|
||||
`test_price.py` section 13 grades:
|
||||
|
||||
1. the guards on every `asyncio.create_task` in `amain()` are **evaluated**
|
||||
against real option dicts — whatever the guard is spelled as;
|
||||
2. the call graph under `app/` is walked to see whether the coroutines those
|
||||
guards start can arrive at `resolve_tariff()` — wherever it has moved to;
|
||||
3. and then the fetch is **driven**, with a counting transport, a real
|
||||
`Controller` and a real store path, because a reachability graph is an
|
||||
argument and a counted request is an observation.
|
||||
|
||||
⚠️ **Step 3 grades the gate only because it starts the task the way `amain()`
|
||||
would.** The driver asks `_fetchers(opts)` — the evaluated guard — before it
|
||||
calls the coroutine. Measured: with the shipped defect restored **and** that one
|
||||
line defeated (`if False and "run_tariff_refresh" not in names:`), the four
|
||||
driven rows go **green** while only the structural rows stay red. Without it
|
||||
they are decoration, which is MUTATION.md's habit 4 in a harness.
|
||||
|
||||
What the instrument **cannot** see is printed on every run: a coroutine started
|
||||
by anything but `create_task`; a guard that is not an enclosing `if` in
|
||||
`amain()`; anything outside `app/`; and — because the graph is keyed by **name**
|
||||
— two same-named functions as one node, which makes every *reachable* answer an
|
||||
**upper** bound and every *not reachable* answer the strong one.
|
||||
|
||||
## 3. The gate, and why it is an `or`
|
||||
|
||||
```
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
```
|
||||
|
||||
The first term is what this card adds. **The second term is the old gate, kept
|
||||
verbatim, and it is not there for symmetry.**
|
||||
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration `validate.py` explicitly **accepts** (rule 7 refuses only the
|
||||
no-prices-at-all case), and it **trades**. Measured on this branch:
|
||||
|
||||
```
|
||||
opts: price_provider off, arbitrage_enabled true,
|
||||
cycle_cost_threshold_eur 0.05,
|
||||
static_profile 0.20 night / 0.284 day
|
||||
plan : static_profile from static_profile, 48 h, 48 points
|
||||
WITH the plan -> ARBITRAGE_CHARGE -5000.0 W
|
||||
"cheapest firm hour at 0.2000 EUR/kWh, 0.0840 below the
|
||||
dearest and clear of the 0.0750 this cycle had to cover"
|
||||
WITHOUT the plan -> None "no tariff plan has been resolved yet"
|
||||
```
|
||||
|
||||
Gating the refresh on the provider **alone** — which is the literal reading of
|
||||
the ticket's second acceptance criterion — would leave that site's
|
||||
`tariff_plan` at `None` forever and answer *"no tariff plan has been resolved
|
||||
yet"* on every cycle. That is a strategy the UI says is running and that cannot
|
||||
fire: the exact state `validate.py`'s OPT-04 rules exist to refuse, arrived at
|
||||
from inside the code instead of from the configuration.
|
||||
|
||||
So the gate is a **strict widening**: every configuration that started the task
|
||||
before still starts it, and `test_price.py` grades that as its own row rather
|
||||
than leaving it to prose.
|
||||
|
||||
⚠️ **The AC's stated harm is still fully met, and it is worth being exact about
|
||||
this rather than quietly declaring victory.** The criterion reads *"with
|
||||
`price_provider: off`: no fetch task at all, whatever `arbitrage_enabled` says
|
||||
— an operator with no provider must not acquire a periodic HTTPS call."* The
|
||||
letter of it is not met in one configuration (provider off, trading armed),
|
||||
because that configuration keeps the task it already had. The harm it names is:
|
||||
|
||||
* with `price_provider` off, `resolve_tariff()` returns `static_plan()` — pure
|
||||
arithmetic, **no socket**. Driven and counted: **0 requests**.
|
||||
* the true shipped default — no provider, no trading — starts **no task at
|
||||
all**.
|
||||
|
||||
Nobody *acquires* a periodic HTTPS call from this card, and the only thing that
|
||||
decides whether a request leaves the machine is `price_provider`. **If the
|
||||
owner wants the literal reading instead, it is a one-token change** (drop the
|
||||
`or` term) **and it costs the site in the measurement above its trading.** That
|
||||
is the trade, stated rather than chosen silently.
|
||||
|
||||
## 4. `TariffPlan.cheap_now()` — available, and deliberately NOT consumed
|
||||
|
||||
**Decision: do not wire it in this card. `self.cheap` stays on
|
||||
`static_status()`.**
|
||||
|
||||
FCST-04 recorded that `cheap_now()` has no production caller on the provider
|
||||
plan: `main.read_inputs()` assigns `self.cheap, self.tariff_words =
|
||||
static_status(o, now)`, which builds its **own** static plan and calls
|
||||
`cheap_now()` on that. Under Option A a provider-backed plan now exists on every
|
||||
configured site, so the limitation stops being structural and becomes a choice.
|
||||
|
||||
**It is not cosmetic, and that is the whole reason for the decision.**
|
||||
`self.cheap` is read in `cycle()` by the maintenance cycle's cheap-window grid
|
||||
top-up — the add-on's one shiftable load, and the one place a *price* reaches
|
||||
something that **moves energy** on an install that has not armed trading.
|
||||
|
||||
⚠️ **And the direction it would move is not "a slightly different window".**
|
||||
`static_status()` returns `(False, "no static profile")` whenever
|
||||
`static_profile` is empty, so today a provider-only site — a plausible shape
|
||||
for a dynamic-tariff install, and the shape this card makes work — has
|
||||
`self.cheap = False` on **every** cycle and never forces a paid grid top-up at
|
||||
all. Wiring `cheap_now()` would take that from *never* to *sometimes*. That is
|
||||
a strictly larger behaviour change than this card's headline, and it belongs to
|
||||
whoever owns the maintenance cycle rather than to the ticket that made it
|
||||
reachable.
|
||||
|
||||
Three further reasons, in the order they would bite:
|
||||
|
||||
1. **Two sources, one attribute, and the loser is silent.** `validate.py`
|
||||
already refuses `static_profile` alongside the Home Assistant price
|
||||
entities *because* `read_inputs()` picks one and the other disappears. A
|
||||
provider plan would be a third writer of the same attribute with no rule
|
||||
saying which wins.
|
||||
2. **The freshness question is not answered for this consumer.** The plan
|
||||
retires after `TARIFF_MAX_AGE_S` and `fresh_tariff_plan()` returns `None`.
|
||||
Arbitrage has an answer for that (refuse). The maintenance top-up has none:
|
||||
"the plan expired" would have to mean *not cheap*, and *not cheap* on this
|
||||
path means **wait for sun**, which is a real decision nobody has taken.
|
||||
3. **The comparison basis changes, and so does the answer on a bad feed.**
|
||||
`cheap_now()` is strictly-below-the-mean-of-the-**firm**-hours, and it is
|
||||
`False` when THIS hour is provisional or is not in the series at all
|
||||
(FCST-03's rule: this hour decides, not the whole plan). So a feed that
|
||||
published nothing, or an hour the §8.3 fill could not cover, reads *not
|
||||
cheap* where a `static_profile` reads whatever the clock says. Defensible
|
||||
either way — and it is a change to the shipped maintenance cycle that needs
|
||||
its own measurement rather than a side effect of this one.
|
||||
|
||||
**What adopting it would take**, so the next card does not start from scratch:
|
||||
|
||||
* a rule for which source wins when both a provider plan and a
|
||||
`static_profile` are configured, enforced in `validate.py` the way the
|
||||
entity/profile conflict already is;
|
||||
* a decision on what an **expired** or **absent** plan means for the grid
|
||||
top-up, stated as a default rather than left to `None`;
|
||||
* `read_inputs()` splitting `self.cheap` from `self.tariff_words`, which are
|
||||
currently one assignment from one function;
|
||||
* a check that the maintenance top-up actually fires and does not fire on the
|
||||
provider plan — driven through `cycle()`, not asserted on the attribute;
|
||||
* and the *Tariff* status row, which FCST-04 pinned as a **pair** with
|
||||
`DOCS.md` in `test_price_history.py` §9: it does not report the provider
|
||||
plan, DOCS does not claim it does, and moving one without the other is
|
||||
already red.
|
||||
|
||||
## 5. The *Arbitrage* status row, re-read and left alone
|
||||
|
||||
`main.status_payload()` renders `("Arbitrage", "off (arbitrage_enabled)" if
|
||||
self.arb is None else self.arb.explain())`. `self.arb` is assigned only inside
|
||||
`if arbitrage.enabled(self.o):` and `arbitrage.decide()` never returns `None`,
|
||||
so `self.arb is None` **iff** the option is off. The string still names the
|
||||
option that is actually off, and under Option A it gains a job it did not have:
|
||||
on a site with a provider configured it is the row that tells an operator the
|
||||
prices arriving on the *Price history* row above are moving nothing. Unchanged,
|
||||
with a note at the call site saying it was checked.
|
||||
|
||||
## 6. What this card did not do
|
||||
|
||||
* It did not touch `run_tariff_refresh()`'s body (TARIFF-12 is there).
|
||||
* It did not change `cycle()`'s `if arbitrage.enabled(self.o):`, the sizing,
|
||||
the thresholds, the SoC bounds or the peak interlock.
|
||||
* It did not wire the provider plan into the *Tariff* status row or into
|
||||
`self.cheap` — §4.
|
||||
* It did not pick a version number (PKG-06).
|
||||
|
||||
## 7. Mutation ledger
|
||||
|
||||
Every mutation applied to a **fresh `cp -a`** of the clean worktree rather than
|
||||
reverted (habit 18: there is no revert to verify and no shared scratch path),
|
||||
`__pycache__` purged, tree mounted `:ro`, run under `python3 -B`.
|
||||
|
||||
| id | mutation | Δ bytes | result |
|
||||
|---|---|---|---|
|
||||
| M1 | the shipped defect: gate back to `arbitrage.enabled(opts)` | **-4 B** | **RED**, 7 named checks |
|
||||
| M2 | drop the `or arbitrage.enabled(opts)` term | -27 B | **RED**, 2 named checks (strict-widening + the static-profile row) |
|
||||
| M3 | `tariff_refresh_wanted` -> always `True` | +8 B | **RED**, the AC2 reachability row |
|
||||
| M4 | `provider_configured`: `"off"` counts as configured | -6 B | **RED**, the AC2 reachability row |
|
||||
| M5 | `cycle()`'s trading gate -> `if True or arbitrage.enabled(self.o)` | +8 B | **RED**, both AC3 rows (`-5000 W` with trading off) |
|
||||
| M6 | instrument: `_reaches()` always `True` | **+0 B** | **RED**, 4 checks incl. its own calibration row |
|
||||
| M7 | instrument: `_task_sites()` returns `[]` | **+0 B** | **RED**, 10 checks incl. "it found 0 sites" |
|
||||
| M8 | fixture: 5 minutes of telegrams instead of a whole quarter | **+0 B** | **RED**, the peak-guard precondition and both armed rows |
|
||||
| M9 | M1 **plus** the harness's guard-driven start defeated | -4 B | the four DRIVEN rows go **GREEN** — see §2 |
|
||||
| M10 | `provider_id()` drops `.strip()` | -8 B | **survivor**, then CLOSED — see below |
|
||||
| M11 | `provider_configured()` -> `return True` | -32 B | **RED**, 4 checks |
|
||||
| M12 | DOCS.md's sentence reverted to the old, vaguer claim | +0 B (docs) | **RED**, the AC4 pairing row (`fetches = True, DOCS says = False`) |
|
||||
| M13 | the Dutch translation left behind while English moved | +0 B (docs) | **RED**, the both-languages row |
|
||||
|
||||
⚠️ **M10 was a real survivor and is recorded as one.** Dropping `.strip()` left
|
||||
`test_price.py`, `test_price_history.py`, `test_static_tariff.py` and
|
||||
`test_validate.py` all green: nothing in the repository fed a `price_provider`
|
||||
with surrounding whitespace, and the `.strip()` predates this card — it was
|
||||
carried into `provider_id()` from the three call sites it replaced. The
|
||||
Supervisor's `list(off|…)` schema cannot produce `" off "`, but
|
||||
`validate_startup_config()` is also `sim/inject.py`'s entry point and takes any
|
||||
dict at all. Closed in the same commit by the padded-id pair in section 13;
|
||||
re-run as **M10b**, red on two named checks.
|
||||
|
||||
⚠️ **M8 is the fixture defect this card walked into and then fenced.** The
|
||||
first draft fed five minutes of telegrams. Whether that **covers** the current
|
||||
billing quarter depends on where the wall clock sits inside it, so the peak
|
||||
guard was blind and engaged on some runs and not others — and `decide()` refuses
|
||||
outright while the guard is holding (§9.1 over §10.1). On those runs the armed
|
||||
baseline did not trade, and "trading off never trades" would have been graded
|
||||
on a fixture in which nothing could trade either way. It is now 900 s of
|
||||
telegrams, and the guard's clear state is a **check** rather than an assumption.
|
||||
|
||||
## 8. What is still open
|
||||
|
||||
* The literal reading of AC2 (§3). One token, and it costs a measured trading
|
||||
configuration its plan. **Owner's call.**
|
||||
* `cheap_now()` (§4). Owner's call, with the adoption list above.
|
||||
* The *Tariff* status row still shows the static-profile sentence on a
|
||||
provider-backed site; the provider plan's own line goes to the log. Pinned as
|
||||
a pair with `DOCS.md` by FCST-04 and untouched here.
|
||||
@@ -0,0 +1,252 @@
|
||||
# TARIFF-12 — a returned degradation is not a refresh
|
||||
|
||||
Three decisions. The first is what the ticket put up for judgement; the second
|
||||
and third are what fall out of it and would otherwise be decided by accident.
|
||||
|
||||
---
|
||||
|
||||
## 0. What was actually wrong, stated once
|
||||
|
||||
`run_tariff_refresh()`'s docstring:
|
||||
|
||||
> ⚠️ **AND A FAILURE DOES NOT CLEAR THE PLAN.** … Zeroing the plan here would
|
||||
> replace a coarse tariff with no tariff, which is the opposite of what §11.4
|
||||
> asks for.
|
||||
|
||||
The guarantee was real. The condition it was enforced on was not:
|
||||
|
||||
```python
|
||||
try:
|
||||
plan = await resolve_tariff(...)
|
||||
self.tariff_plan, self.tariff_at = plan, now # unconditional
|
||||
except Exception as err:
|
||||
_LOG.warning("tariff refresh failed (%s); keeping the plan ...")
|
||||
```
|
||||
|
||||
`load_prices()` says in its own docstring that it **"RETURNS, NEVER RAISES, for
|
||||
every failure a site can actually have"**, because a degradation arriving as a
|
||||
traceback into the planning tick would stop the controller regulating over an
|
||||
expired certificate. So a supplier outage — the case the docstring names first —
|
||||
is a **return**: the `try` succeeds, `_degrade()` hands back a §11.4 plan, and
|
||||
the good plan is overwritten. The `except` is reachable only by something
|
||||
nobody expected, and nothing in the shipped path produces it.
|
||||
|
||||
This is the fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's
|
||||
HIGH (`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and now
|
||||
`except Exception` only, for a path that never raises. **A guarantee enforced on
|
||||
the condition someone thought of, rather than the condition that occurs.**
|
||||
|
||||
Reproduced before the fix, driving the shipped chain with nothing stubbed but
|
||||
the socket — 48 h horizon, `static_profile` empty, one good fetch then a
|
||||
`ClientConnectorError` 1800 s later, `TARIFF_MAX_AGE_S` = 5400 s:
|
||||
|
||||
```
|
||||
after a good fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
|
||||
after the failed fetch origin='none' n_points=0 'no tariff - generic_dayahead: ClientConnectorError: ...'
|
||||
tariff_at moved: True
|
||||
```
|
||||
|
||||
and after it:
|
||||
|
||||
```
|
||||
after the failed fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
|
||||
tariff_at moved: False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. The question is asked ONCE, after the attempt, from the evidence that exists
|
||||
|
||||
FCST-06's answer, one module over, and for the same reason: **the enumeration
|
||||
of failure conditions cannot be completed.** There is no pre-check that a feed
|
||||
will answer which is not itself a guess, and adding one would be a second
|
||||
symptom test beside the `except`. So the predicate is an **outcome**, read off
|
||||
the object the attempt produced:
|
||||
|
||||
```python
|
||||
standing = self.fresh_tariff_plan(now)
|
||||
keep = (not plan.from_feed
|
||||
and standing is not None and standing.from_feed)
|
||||
```
|
||||
|
||||
Two facts, both of which exist by the time the question is asked:
|
||||
|
||||
* **`plan.from_feed`** — did *this* resolution observe the configured provider?
|
||||
`TariffPlan.origin` already names the four ways a plan is obtained; this
|
||||
collapses them to the one question a caller holding a previous plan has to
|
||||
ask. It lives on `TariffPlan` rather than as an `origin == "provider"` at the
|
||||
call site for `fresh_tariff_plan()`'s own stated reason — *a check has to be
|
||||
able to assert on the shipped expression rather than on a transcription that
|
||||
drifts* — and because `origin`'s vocabulary belongs to `static_tariff.py`.
|
||||
* **`fresh_tariff_plan(now)`** — is a standing observation still current. It
|
||||
already existed, it is already the one place the age is judged, and it is
|
||||
**reused, not transcribed**. `test_arbitrage.py` 8b pins that reuse on the
|
||||
AST; a copy of the comparison behaves identically until the day the two
|
||||
drift, which is a claim about the code's shape, so a shape check is what
|
||||
grades it (FCST-06's precedent for the one source check among driven ones).
|
||||
|
||||
### What was NOT done
|
||||
|
||||
* **Not `except Exception` widened, or a second `if` beside it.** That is the
|
||||
defect's own shape — a place where the rule is spelt twice, so a fifth cause
|
||||
needs a fifth branch. The `except` stays, relabelled *"not the outage path"*,
|
||||
keeps the plan on the same terms, and is now honestly the
|
||||
something-nobody-expected branch.
|
||||
* **Not the persisted price store as a cache.** Out of scope by the product
|
||||
owner's call, and it is the right call: FCST-03 exists precisely to stop
|
||||
stale prices being acted on, and "recent" is not "current". Keeping the last
|
||||
**fetched** plan until it ages out is a different promise from serving the
|
||||
store, and that one needs its own ticket.
|
||||
* **Not a `no_data`-only guard.** `not plan.has_prices` is the tempting
|
||||
spelling and it is wrong — see decision 2. It leaves the §11.4 fallback free
|
||||
to replace a fresh observation, and it is a 1-byte edit from the shipped
|
||||
line, which is why it is in the mutation table rather than in a comment.
|
||||
|
||||
---
|
||||
|
||||
## 2. A §11.4 static fallback does NOT replace a fresh provider plan
|
||||
|
||||
The ticket asked this directly, because the fallback is *not* nothing: it is
|
||||
the operator's own configured profile, with real prices, for the requested
|
||||
window. The answer is still no, and the reason is not that it is worse data —
|
||||
it is that **nothing may be acted on it, and it is no more current.**
|
||||
|
||||
* Every point `_degrade()` builds carries `provisional=True`. So
|
||||
`firm_points()` is empty, `arbitrage()` finds fewer than two firm hours and
|
||||
refuses, and `cheap_now()` is False. Both of the two things `TariffPlan`
|
||||
exposes for a consumer to act on are already off.
|
||||
* A day-ahead price for a given hour **does not change once published**. A plan
|
||||
fetched 30 minutes ago is exactly as true about 19:00 as one fetched now; at
|
||||
most `TARIFF_MAX_AGE_S` of its horizon has elapsed. The observation is not
|
||||
stale in any sense the fallback is fresher than.
|
||||
|
||||
So replacing the observation with the fallback trades a plan with firm hours
|
||||
for a plan with none, and buys no currency with it. The fallback takes over
|
||||
when the observation **retires** — which is exactly the row §11.4 describes,
|
||||
arrived at when there is no longer an observation to prefer.
|
||||
|
||||
### And the rule is deliberately NOT symmetrical
|
||||
|
||||
`standing.from_feed` is a term in its own right. A standing plan that is itself
|
||||
a degradation **is** replaced by the next degradation, and its timestamp moves.
|
||||
Without that term:
|
||||
|
||||
* an outage on a static-profile site would freeze the operator's profile at the
|
||||
window it was first generated for, and the guard would be *"never overwrite"*
|
||||
wearing a bound's name;
|
||||
* and on a `price_provider: off` install — the shipped default — the plan would
|
||||
flip between kept and re-derived every `TARIFF_MAX_AGE_S`, for no behavioural
|
||||
gain, changing which reason the status page prints on a two-hour cycle.
|
||||
|
||||
Each of the three terms is the only false one in some check in 8b, which is
|
||||
MUTATION.md habit 17's test for whether a suite can tell an `AND` from an `OR`.
|
||||
|
||||
---
|
||||
|
||||
## 3. `tariff_at` does not move on a kept plan
|
||||
|
||||
The docstring's other half is that a plan must not *"freeze at its last value
|
||||
and look current forever"*. `tariff_at` is what makes that work: it is the
|
||||
moment of the fetch that produced the plan standing, and `fresh_tariff_plan()`
|
||||
judges the age from it.
|
||||
|
||||
Re-stamping it on a kept plan would be this task asserting the feed answered
|
||||
when it did not. The plan would be re-certified as current at every refresh, an
|
||||
outage of any length would never retire it, and `TARIFF_MAX_AGE_S` would be
|
||||
**exactly as dead as it was before this card** — reachable only by the refresh
|
||||
task itself dying. Leaving it alone is what makes the bound govern: the plan is
|
||||
served for the age bound after its own fetch, and no longer.
|
||||
|
||||
It is also the whole of **"distinguishable from a freshly-fetched one"**. A
|
||||
kept plan and a fetched plan are the same object shape; what separates them is
|
||||
that one's timestamp advanced and the other's did not, and every consumer that
|
||||
asks how old the plan is already reads that number. The kept case additionally
|
||||
logs at WARNING, naming the degradation's own reason, the fetch it is keeping,
|
||||
its age, and the deadline it retires at.
|
||||
|
||||
⚠️ **What this does not give the operator, and why it is not here.** The status
|
||||
page has no row for "the last refresh failed" — the banner/level not moving
|
||||
when the plan is lost is separately carded, and the ticket rules it out of
|
||||
scope. This card's change makes that surface *more* truthful, not less: during
|
||||
the first `TARIFF_MAX_AGE_S` of an outage there is now a real plan behind the
|
||||
Arbitrage row instead of a no-plan refusal, so the row says something true for
|
||||
longer. The no-plan state is entered later by up to 5400 s; it is not removed,
|
||||
and the 30-minute lag reported on that row is untouched by anything here.
|
||||
|
||||
---
|
||||
|
||||
## 4. Merged after TARIFF-10 — what the wider gate changed, and what it did not
|
||||
|
||||
TARIFF-10 landed on `release/1.0` while this card was open. It changed **which
|
||||
installs run this task** — `if arbitrage.enabled(opts)` became
|
||||
`tariff_refresh_wanted(opts)`, i.e. `provider_configured(opts) or
|
||||
arbitrage.enabled(opts)` — while this card changed **what the task does on a
|
||||
failed fetch**. Git auto-merged the two hunks, which is exactly when the
|
||||
interaction needs reading rather than trusting.
|
||||
|
||||
**Nothing in the decisions above changed.** Two configurations are newly
|
||||
reachable, and both are now driven in `test_arbitrage.py` 8b rather than
|
||||
reasoned about:
|
||||
|
||||
* **A provider configured with trading DISARMED** — TARIFF-10's headline case.
|
||||
This is precisely the configuration the guard was designed for: the plans are
|
||||
`origin == "provider"`, and a dead feed must not delete one that is still
|
||||
inside `TARIFF_MAX_AGE_S`. It needs no second fixture, because **nothing on
|
||||
this path reads `arbitrage_enabled` at all**, and that is asserted rather
|
||||
than assumed — over the AST's symbols and the non-docstring string constants,
|
||||
because two earlier drafts of that check were **red against a correct tree**:
|
||||
the first matched the docstring paragraph that *says* `arbitrage_enabled` is
|
||||
not read, the second matched the log line's "arbitrage, where it is armed".
|
||||
A substring test over a function grades its prose as much as its behaviour,
|
||||
which is habit 4 in a third costume. A fixture that set the option and
|
||||
asserted no difference would have been the other defect — comparing a thing
|
||||
against itself.
|
||||
* **`price_provider: off` + a `static_profile`** — the configuration TARIFF-10's
|
||||
`or` exists to protect, and the one where a latching guard would do real harm.
|
||||
`resolve_tariff()` returns `static_plan(provisional=False)`, whose origin is
|
||||
`"static"`: the CONFIGURED product, not a §11.4 row. So `standing.from_feed`
|
||||
is false on every refresh, `keep` is false on every refresh, and the plan is
|
||||
regenerated for the current window every 30 minutes exactly as before this
|
||||
card. **Driven, twice in a row**, because a guard that latched here would
|
||||
freeze a static site's plan at its first window for the life of the install —
|
||||
and mutation M3, which drops the `standing.from_feed` term, is red on that
|
||||
check as well as on the fallback one.
|
||||
|
||||
Two further facts settle the rest of the interaction:
|
||||
|
||||
* **This decision is strictly downstream of every side effect.** The §8.3 carry
|
||||
into `price_history` happens inside `resolve_tariff()`, on the `series`
|
||||
branch, before `keep` is computed — so which of two plans is kept can neither
|
||||
add nor lose a recorded day, and TARIFF-10's *Price history* row is
|
||||
unaffected either way.
|
||||
* **The kept-plan log line was reworded.** It said "arbitrage stops with it",
|
||||
which after TARIFF-10 is false on a site that fetches without trading; it now
|
||||
says "arbitrage, **where it is armed**, stops with it".
|
||||
|
||||
All eleven mutations were **re-driven on the merged tree** rather than carried
|
||||
over — the surrounding code changed, so a red against the old base is not
|
||||
evidence about the new one. Eleven red, no survivors, every revert
|
||||
fingerprinted. The added member is M11: making the guard conditional on
|
||||
`arbitrage.enabled(self.o)` (+86 B), red on five named checks.
|
||||
|
||||
⚠️ **And one process note that belongs in this file.** This card's mutation
|
||||
harness was written to a shared scratchpad path and was **overwritten mid-run
|
||||
by another agent's harness for a different card**, pointing at a different
|
||||
worktree. That is MUTATION.md habit 18's second bullet almost verbatim. It was
|
||||
caught because the harness fingerprints the tree after every revert and the
|
||||
runs are what proved this worktree was never left mutated; the harness now
|
||||
lives at a card-specific path. **A harness that edits source must not share a
|
||||
name, a directory or a backup path with another one.**
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
* **Nothing on the 30 s tick.** `cycle()` still reads two attributes and awaits
|
||||
no fetch; the AST pin in `test_arbitrage.py` section 8 is unchanged and green.
|
||||
* **Nothing about the prices themselves.** No series is invented, extended,
|
||||
re-stamped or carried. The only question this card answers is *which of two
|
||||
plans the controller holds*.
|
||||
* **A `price_provider: off` install is bit-for-bit unaffected.** Its standing
|
||||
plan is never `origin == "provider"`, so `keep` is false on every refresh it
|
||||
will ever run, and the assignment happens exactly as it did before.
|
||||
@@ -1346,12 +1346,26 @@ does, and only after you have read why.
|
||||
> release ships the five provider plugins, the canonical all-in format they
|
||||
> emit, the config that selects one, and — new in OPT-04 — a price-arbitrage
|
||||
> strategy that trades on them. With `arbitrage_enabled: false`, which is the
|
||||
> shipped default, turning a provider on still fetches, validates and publishes
|
||||
> prices and changes no charging decision.
|
||||
> shipped default, **selecting a provider is what starts the fetch**: the feed
|
||||
> is read every 30 minutes, validated, and the day recorded in
|
||||
> `/data/price_history.json`, which the *Price history* status row reports and
|
||||
> the add-on log names at every refresh. No charging decision changes.
|
||||
>
|
||||
> ⚠️ **That has been the documented behaviour since OPT-04 and was not the
|
||||
> actual behaviour until TARIFF-10.** The background refresh was started only
|
||||
> when `arbitrage_enabled` was on — and it ships `false` — so an install with a
|
||||
> provider configured fetched nothing at all, never created the store, and read
|
||||
> *Price history: empty* for as long as it ran. If you configured a provider and saw an empty
|
||||
> Price history row, that is what you were looking at. Trading is unchanged and
|
||||
> is still `arbitrage_enabled` alone.
|
||||
>
|
||||
> `static_profile` below needs no network at all, so its cheap window is
|
||||
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||||
> whether or not arbitrage is armed.
|
||||
> whether or not arbitrage is armed. ⚠️ **And it is still the only source of
|
||||
> that window.** A provider-backed plan now exists on every configured site,
|
||||
> but *Cheap window* and the maintenance cycle's grid top-up read
|
||||
> `static_profile` and nothing else — a deliberate limit, recorded with what it
|
||||
> would take to change in `DECISIONS/TARIFF-10-fetching-is-not-trading.md`.
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
@@ -1360,7 +1374,7 @@ does, and only after you have read why.
|
||||
| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule |
|
||||
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
|
||||
| `price_csv_path` | Where `manual_csv` reads its file |
|
||||
| `price_feed_url` | Override the plugin's default feed. Only a host the plugin itself declares is accepted |
|
||||
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
|
||||
| `price_horizon_h` | How far ahead to ask for, hours |
|
||||
| `price_timeout_s` | Per-request timeout |
|
||||
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
|
||||
@@ -1395,6 +1409,58 @@ flagged as high-volatility, which widens arbitrage thresholds once the
|
||||
optimiser lands. That comes from the plugin's own declaration, not from its
|
||||
name.
|
||||
|
||||
**`generic_dayahead` and the shape of the public feed.** It reads Fraunhofer
|
||||
ISE's Energy-Charts API (`https://api.energy-charts.info/price?bzn=BE`, no key
|
||||
and no registration), which publishes a *columnar* document — parallel
|
||||
`unix_seconds` and `price` arrays, in EUR/MWh, with the unit and the licence
|
||||
stated in the response. Two things follow that are worth knowing before you
|
||||
point `price_feed_url` somewhere else:
|
||||
|
||||
* The timestamps are **UTC instants**. They are rendered at the Brussels offset
|
||||
that applies to each one, so the hour that happens twice every October is two
|
||||
points rather than one overwritten one.
|
||||
* Belgium moved to a **15-minute market time unit** on 2025-10-01, and the feed
|
||||
followed. A published day is **96 points, not 24**. Nothing in the add-on
|
||||
assumes either spacing, and nothing aggregates the quarters away.
|
||||
* The request **names the days it wants**, derived from the horizon on the
|
||||
**Brussels** calendar — `?bzn=BE&start=2026-09-09&end=2026-09-10`, two
|
||||
inclusive local dates. Without that the feed answers a single day, and
|
||||
everything past today would be carried forward from yesterday's profile and
|
||||
flagged provisional — which §8.3 excludes from arbitrage, so tomorrow would
|
||||
never be tradeable at any hour. The calendar is Brussels rather than UTC
|
||||
because 22:30 UTC in summer is already tomorrow in Belgium.
|
||||
|
||||
**What you get before the auction publishes.** The day-ahead auction clears at
|
||||
about 13:00–14:00 CET. Ask for tomorrow before that and the feed returns fewer
|
||||
days than requested — that is normal, not a failure. The add-on keeps the
|
||||
series of what exists and fills the remaining hours from yesterday's profile,
|
||||
flagged provisional; arbitrage runs on the firm hours and ignores the flagged
|
||||
ones. So a dynamic site's plan is part firm and part carried every morning and
|
||||
fully firm once tomorrow is published, and neither state is an error.
|
||||
|
||||
⚠️ **If you set `price_feed_url`, you keep the short horizon.** The override is
|
||||
sent exactly as you wrote it and the add-on adds no `start`/`end` to it, because
|
||||
those are this feed's parameter names and an override may point somewhere with
|
||||
different ones — rewriting a URL you typed is how a request nobody intended gets
|
||||
made. The consequence is a **`WARNING` in the log on every price refresh**,
|
||||
naming the range that would otherwise have been requested. If you only wanted a
|
||||
different host and not a different horizon, the fix is to clear the option.
|
||||
|
||||
If the arrays ever come back different lengths, or the unit stops saying
|
||||
EUR/MWh, the fetch is refused outright rather than trusted in part: a price
|
||||
series missing its last hours is a series missing the evening peak, and it
|
||||
would be traded on as though it were complete. A refused fetch is the §11.4
|
||||
row — static profile, provisional plan, no arbitrage — and the reason is in the
|
||||
log.
|
||||
|
||||
**A refused fetch does not take the plan you already have.** The last plan that
|
||||
actually came from the feed keeps standing until it is 90 minutes old (three
|
||||
missed refreshes), and only then does the §11.4 row above take over. The
|
||||
add-on logs a `WARNING` naming the failure, the fetch it is keeping, its age
|
||||
and the moment it retires; the plan's own age is **not** reset by the failed
|
||||
refresh, so a kept plan still expires on schedule and is never presented as a
|
||||
current one. One failed fetch is a blip, not a lost tariff.
|
||||
|
||||
**`manual_csv` — the offline fallback.** It needs no internet, no supplier
|
||||
account and no HTTP stack at all, and it is the supported way to run this
|
||||
add-on price-aware on a site with no outbound connectivity. Write a file and
|
||||
@@ -1626,6 +1692,14 @@ towards the firmware's 30 s failsafe. The plan refreshes every 30 minutes and
|
||||
retires itself if the refresh stops answering, at which point arbitrage stops
|
||||
with it.
|
||||
|
||||
⚠️ **That refresh is not gated on this switch** (TARIFF-10). It runs when a
|
||||
`price_provider` is configured *or* when arbitrage is armed, so turning
|
||||
arbitrage off does not turn the price feed off — and a site with no provider
|
||||
that arms arbitrage on a `static_profile` keeps the plan it has always had. The
|
||||
only thing that decides whether a request leaves the machine is
|
||||
`price_provider`: with it `off`, the refresh resolves the static profile in
|
||||
memory and opens no connection.
|
||||
|
||||
### Site
|
||||
|
||||
| option | meaning |
|
||||
|
||||
@@ -695,6 +695,84 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
that two of it cannot silently overwrite each other's work. A fixed path
|
||||
in a tool that edits source is a shared mutable global with a filename.
|
||||
|
||||
19. ⚠️ **A FIXTURE IN A SHAPE NOBODY PUBLISHES GRADES THE PARSER AGAINST THE
|
||||
PARSER — FOURTEENTH INSTANCE OF HABIT 4, AND THE FIRST WHERE THE FIXTURE
|
||||
AND THE CODE UNDER TEST SHARED A *SHAPE* RATHER THAN A NUMBER.** TARIFF-08.
|
||||
`price.py`'s `_points_from_json()` read `doc["data"]` as a list of
|
||||
`{start, price_eur_mwh}` objects. `api.energy-charts.info` publishes
|
||||
parallel `unix_seconds` / `price` arrays and has no `data` key at all, so
|
||||
`generic_dayahead` raised `SourceUnavailable` on **every live fetch it ever
|
||||
made** — and the suite was green, because all three price suites built
|
||||
their bodies with `json.dumps({"data": [...]})`. The fixture was derived
|
||||
from the parser's guess, so no mutation of the parser and no amount of
|
||||
coverage inside that loop could ever have reached the failure. §11.4 then
|
||||
turned it into a *correct* silent degradation: parse error -> `no_data` ->
|
||||
static profile -> arbitrage off, with nothing naming the cause.
|
||||
|
||||
Habit 2 with a network on the end of it: **the reference has to be data the
|
||||
code under test did not shape.** For a parser that means BYTES, captured
|
||||
from the source and committed unmodified, with the URL, the retrieval time
|
||||
and a checksum beside them - `scenarios/energy-charts-be-price-*.json` and
|
||||
its `-captures.json` provenance, re-derived by the replay so an edited
|
||||
fixture stops being a capture. Two granularities, because the same endpoint
|
||||
answers hourly before 2025-10-01 and quarter-hourly after it and a fixture
|
||||
at one spacing cannot grade an assumption about the other.
|
||||
|
||||
⚠️ **AND THE SURVIVING SYNTHETIC FIXTURES ARE NAMED FOR WHAT THEY ARE.**
|
||||
Three shipped plugins point at endpoints nobody has verified (TARIFF-07),
|
||||
so no capture exists for them; their fixture builder is called
|
||||
`synthetic_row_feed` at every call site, because the next reader's default
|
||||
assumption will otherwise be that a green check means the feed parses.
|
||||
|
||||
⚠️ **ONE OF THIS CARD'S OWN NEW LINES WAS DECORATION AND THE BATTERY SAID
|
||||
SO.** The columnar parser first rendered its instants with `brussels()`
|
||||
before returning them. `assemble()` already renders every plugin's points,
|
||||
so removing the parser's call changed nothing observable - **M4 came back a
|
||||
survivor on a `:ro`, purged tree**, which is not the direction stale
|
||||
bytecode fabricates. The line was removed rather than fenced with a check:
|
||||
a second copy of a rule that already has an owner is indistinguishable from
|
||||
a no-op, and the mutation that proves the rendering is real belongs on
|
||||
`assemble()`, where it is now aimed.
|
||||
|
||||
20. ⚠️ **A FIXTURE GRADES THE RESPONSE. NOTHING ABOUT THE REQUEST IS REACHABLE
|
||||
FROM ONE, AND THE TRANSPORT HAD BEEN RECORDING IT ALL ALONG.** TARIFF-09,
|
||||
and it is habit 19's closed loop with the other half still shut.
|
||||
`generic_dayahead.fetch()` took `since` and `until` and asked for the bare
|
||||
`?bzn=BE`, which answers ONE Brussels day - so §8.3 dropped every hour past
|
||||
today from the trade set as provisional and the optimiser never saw
|
||||
tomorrow as firm, at any hour of any day. Measured at 09:00: **15 firm
|
||||
hours and 33 carried of 48**, both ends of the permitted cycle inside one
|
||||
calendar day; at 23:00, **1 and 47**, with the firm spread no longer
|
||||
clearing the round-trip cost.
|
||||
|
||||
TARIFF-08 had just fixed the RESPONSE side of this same plugin by
|
||||
committing real bytes. It could not have caught this: a captured body is
|
||||
the answer to a URL, and **no property of any body says which URL was
|
||||
asked for**. `canned()` in `test_price.py` had recorded `(url, timeout_s)`
|
||||
on every call since TARIFF-02 and not one check had ever asserted on the
|
||||
first element - so the harness *knew* the plugin was ignoring its arguments
|
||||
and nothing asked it. The fix is one line of habit: **where a plugin takes
|
||||
arguments that shape a request, assert on the REQUEST off the injected
|
||||
transport, not only on what comes back.** Section 2c does, and the
|
||||
shipped-defect mutation (restore the bare URL, `-173 B`) is red on six
|
||||
named checks in `test_price.py` and two in `test_price_history.py`.
|
||||
|
||||
⚠️ **AND A MUTATION THAT TURNS A READ INTO `no_data` CRASHES A SUITE
|
||||
INSTEAD OF NAMING A CHECK, UNLESS THE CHECK EXPECTS IT.** `_NoData.points`
|
||||
raises `Absence` by design, so `derived(...) < derived(...)` over two
|
||||
absent reads is a `TypeError` and `[p for p in plan.read.points]` is a
|
||||
traceback - habit 5's shape with an exception in place of a hang. The
|
||||
mutation that matters here is the "defensive" one somebody writes after
|
||||
reading this card: *refuse when the feed returned fewer days than we asked
|
||||
for*. It is exactly what a healthy feed does every morning before the
|
||||
~13:00-14:00 CET auction, and switching arbitrage off for it would be a
|
||||
worse defect than the one being fixed. It is red BY NAME in
|
||||
`test_price.py` (33 checks, the AC's own line among them) and takes
|
||||
`test_price_history.py` down at **FCST-04's section 1**, whose `_before`
|
||||
read is unguarded - a red, but an unnamed one. That is recorded rather than
|
||||
retrofitted: guarding another card's section is a change to what its
|
||||
checks report, and it belongs to whoever next touches it.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
@@ -752,6 +830,7 @@ commit and PR bodies.
|
||||
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
|
||||
| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. |
|
||||
| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. |
|
||||
| **TARIFF-09** 11 mutations over `app/price.py` and the two-day capture, all red, no survivors | **not at risk** | direction, and method: every mutation was applied to a FRESH `cp -a` of the clean worktree rather than reverted, so there is no revert to verify (habit 18) and no shared scratch path two runs could collide on. Each ran through `tools/mutation_run.sh`, which purges and fingerprints. Byte deltas recorded on the PR; one is **+0 B** - a single price digit edited inside the committed capture, the risky delta - and the re-derived sha256 catches it. |
|
||||
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
|
||||
|
||||
**Nothing here says a past result is wrong.** The two survivors re-run above
|
||||
|
||||
+155
-15
@@ -67,7 +67,7 @@ from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
from . import peakguard
|
||||
from . import packshare
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
from .price import Uncertainty, price_option, provider_configured
|
||||
from .price_history import PriceHistory
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
@@ -138,6 +138,60 @@ TARIFF_REFRESH_S = 1800.0
|
||||
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
"""TARIFF-10. Does this install need the background tariff refresh at all?
|
||||
|
||||
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
|
||||
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
|
||||
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
only caller of `price_history.carry()` - so on a shipped install with a
|
||||
provider configured NOTHING was ever fetched, no plan was ever resolved,
|
||||
`/data/price_history.json` was never created, the *Price history* row read
|
||||
`empty` forever, and TARIFF-09's short-horizon warning could not fire
|
||||
because there was no fetch to warn about. Measured over a 40 h soak of two
|
||||
instances side by side: zero feed requests and `present: false` across all
|
||||
802 samples on the shipped default, against a resolved plan from the same
|
||||
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
|
||||
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
|
||||
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
|
||||
it, and the sentence saying a configured provider still fetches, all in one
|
||||
change. config.yaml has said it since TARIFF-02 (852fc7c).
|
||||
|
||||
Two terms, and they answer two different questions:
|
||||
|
||||
provider_configured(opts) -> "the operator asked for a price feed", so
|
||||
fetch it, validate it and record the day
|
||||
(§8.3). This is the term TARIFF-10 ADDS.
|
||||
arbitrage.enabled(opts) -> "the operator armed trading". This term is
|
||||
what the gate USED to be, kept verbatim.
|
||||
|
||||
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
|
||||
that started this task before still starts it; none that did not start it
|
||||
stops anything. That matters because the second term is not redundant:
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
|
||||
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
|
||||
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
|
||||
that site forever and `decide()` would answer "no tariff plan has been
|
||||
resolved yet" on every cycle - silently disarming a strategy the UI says is
|
||||
running, which is the exact failure validate.py's OPT-04 rules exist to
|
||||
refuse.
|
||||
|
||||
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
|
||||
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
|
||||
arithmetic, no socket - so the second term starts a task that fetches
|
||||
nothing. The true shipped default (no provider, no arbitrage) starts no
|
||||
task at all. What is fetched is decided by the FIRST term only.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
|
||||
Whether they move energy is `arbitrage.enabled()` at the one call site in
|
||||
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
|
||||
means no trade, ever, on any configuration.
|
||||
"""
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
|
||||
|
||||
def load_options() -> dict:
|
||||
try:
|
||||
with open(OPTIONS_PATH, "r", encoding="utf-8") as fh:
|
||||
@@ -3480,13 +3534,64 @@ class Controller:
|
||||
raise out of here and take the task down, because then the plan freezes
|
||||
at its last value and looks current forever. That is what
|
||||
`fresh_tariff_plan()`'s age bound is the backstop for, and this is the
|
||||
half that keeps the task alive so the bound rarely matters.
|
||||
half that keeps the task alive. ⚠️ THE BOUND IS NO LONGER A BACKSTOP
|
||||
ONLY: since TARIFF-12 it is also the serve-stale window an outage runs
|
||||
on, and it is consulted below on every refresh.
|
||||
|
||||
⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN. `resolve_tariff()` already has
|
||||
a §11.4 answer for a dead feed - degrade to the static profile, mark it
|
||||
provisional, arbitrage refuses - and that answer is better than the
|
||||
absence of one. Zeroing the plan here would replace a coarse tariff with
|
||||
no tariff, which is the opposite of what §11.4 asks for.
|
||||
⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN - WHICH IS A CLAIM ABOUT WHAT
|
||||
`resolve_tariff()` RETURNS, NOT ABOUT WHAT IT RAISES (TARIFF-12). This
|
||||
paragraph was enforced by the `except` below alone, and the `except`
|
||||
below is the one path a supplier outage never takes. `load_prices()`
|
||||
says so in its own words - it "RETURNS, NEVER RAISES, for every failure
|
||||
a site can actually have" - because a degradation arriving as a
|
||||
traceback into the planning tick would stop the controller regulating
|
||||
over an expired certificate. So a dead feed is a RETURN: the `try`
|
||||
succeeds, `_degrade()` hands back a §11.4 plan, and the assignment used
|
||||
to overwrite the good one unconditionally. Measured in a 40 h soak with
|
||||
`static_profile` unset (the shipped default, and the normal state of a
|
||||
dynamic-tariff site): the FIRST failed fetch replaced a complete 48 h
|
||||
plan with `TariffPlan(no_data, None, "none")` - 0 points - 27 minutes
|
||||
after the last good one, and `TARIFF_MAX_AGE_S` governed nothing. A
|
||||
transient blip cost the same as a 90-minute outage.
|
||||
|
||||
⚠️ SO THE QUESTION IS ASKED ONCE, AFTER THE ATTEMPT, FROM THE EVIDENCE
|
||||
THAT EXISTS - FCST-06's answer, in this file. The evidence is
|
||||
`plan.from_feed` (did this attempt observe the feed) and
|
||||
`fresh_tariff_plan(now)` (is an observation still current). A
|
||||
degradation replaces a standing observation only once that observation
|
||||
has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it
|
||||
reads like rather than a bound nothing could reach.
|
||||
|
||||
⚠️ AND A §11.4 STATIC FALLBACK DOES NOT REPLACE A FRESH PROVIDER PLAN
|
||||
EITHER, although it carries real prices. Every point `_degrade()`
|
||||
builds is `provisional=True`, so `firm_points()` is empty,
|
||||
`arbitrage()` refuses and `cheap_now()` is False - everything a
|
||||
consumer may act on is already off. A day-ahead price for a given hour
|
||||
does not change once published, so a plan fetched half an hour ago is
|
||||
exactly as true about that hour as one fetched now. The fallback is
|
||||
less actionable and no more current; it takes over when the observation
|
||||
retires, which is what §11.4 is for. The reverse is not symmetrical: a
|
||||
standing plan that is ITSELF a degradation is replaced by a fresh one,
|
||||
or an outage would freeze the operator's static profile at the window
|
||||
it was generated for.
|
||||
|
||||
⚠️ AND NOTHING HERE READS `arbitrage_enabled`, WHICH TARIFF-10 MAKES
|
||||
WORTH SAYING. Since that card this task also runs on a site with a
|
||||
provider configured and trading DISARMED, so "keep the plan" is a
|
||||
decision about what the add-on has LOOKED UP, not about what it may
|
||||
trade on. It is also strictly downstream of `resolve_tariff()`: the
|
||||
§8.3 carry into `price_history` happens on the `series` branch inside
|
||||
that call, before this decision exists, so which of two plans is kept
|
||||
cannot add or lose a recorded day.
|
||||
|
||||
⚠️ AND `tariff_at` DOES NOT MOVE ON A KEPT PLAN. It is the moment the
|
||||
fetch that produced the standing plan happened, and
|
||||
`fresh_tariff_plan()` judges the age from it - re-stamping it here
|
||||
would be this task asserting the feed answered when it did not, and the
|
||||
plan would "freeze at its last value and look current forever", which
|
||||
is the hazard the paragraph above this one names. An unmoved
|
||||
`tariff_at` is also the only thing that tells a kept plan from a
|
||||
freshly fetched one, so it is what the check asserts.
|
||||
"""
|
||||
horizon = float(price_option(self.o, "price_horizon_h"))
|
||||
while not self.stopping:
|
||||
@@ -3495,11 +3600,33 @@ class Controller:
|
||||
plan = await resolve_tariff(
|
||||
self.o, now, now + timedelta(hours=horizon), now=now,
|
||||
history=self.price_history)
|
||||
self.tariff_plan, self.tariff_at = plan, now
|
||||
_LOG.info("tariff refreshed: %s", plan.words)
|
||||
standing = self.fresh_tariff_plan(now)
|
||||
# Three terms, and each of them is the only false one in some
|
||||
# check: the feed answered (replace), nothing current stands
|
||||
# (take the degradation - this is what makes the age bound
|
||||
# govern), or what stands is itself a degradation (replace).
|
||||
keep = (not plan.from_feed
|
||||
and standing is not None and standing.from_feed)
|
||||
if keep:
|
||||
_LOG.warning(
|
||||
"tariff refresh degraded (%s); KEEPING the plan "
|
||||
"resolved at %s, %.0f s ago. tariff_at is NOT moved, "
|
||||
"so it retires %.0f s after that fetch; arbitrage, "
|
||||
"where it is armed, stops with it. A kept plan is "
|
||||
"never presented as a current one.", plan.words,
|
||||
self.tariff_at,
|
||||
(now - self.tariff_at).total_seconds(),
|
||||
TARIFF_MAX_AGE_S)
|
||||
else:
|
||||
self.tariff_plan, self.tariff_at = plan, now
|
||||
_LOG.info("tariff refreshed: %s", plan.words)
|
||||
except Exception as err: # noqa: BLE001
|
||||
# ⚠️ NOT THE OUTAGE PATH - see the docstring. `load_prices()`
|
||||
# returns on every failure a site can have, so what reaches
|
||||
# here is something nobody expected, and the plan is kept for
|
||||
# the same reason and on the same terms.
|
||||
_LOG.warning(
|
||||
"tariff refresh failed (%s); keeping the plan resolved at "
|
||||
"tariff refresh raised (%s); keeping the plan resolved at "
|
||||
"%s. It retires on its own after %.0f s and arbitrage "
|
||||
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
@@ -4353,6 +4480,14 @@ class Controller:
|
||||
# OPT-04. The strategy's own sentence, not a re-derivation of it -
|
||||
# every refusal it can make already carries a reason a human can
|
||||
# act on, and "Arbitrage: no" would throw all of them away.
|
||||
#
|
||||
# ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE, which is worth
|
||||
# saying because that card is about the two switches being
|
||||
# different. `self.arb` is None if and only if
|
||||
# `arbitrage.enabled(self.o)` was False, so `off (arbitrage_enabled)`
|
||||
# still names the option that is actually off - and on a site with a
|
||||
# provider configured it is now the row that says the prices
|
||||
# arriving on the *Price history* row above are moving nothing.
|
||||
("Arbitrage", "off (arbitrage_enabled)" if self.arb is None
|
||||
else self.arb.explain()),
|
||||
]
|
||||
@@ -4605,11 +4740,16 @@ async def amain() -> None:
|
||||
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
|
||||
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
|
||||
#
|
||||
# ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on
|
||||
# reads the resolved plan - `cheap` still comes from static_status() on
|
||||
# the tick, which is pure - so on a default install this task would be
|
||||
# network traffic in aid of an attribute nobody reads.
|
||||
if arbitrage.enabled(opts):
|
||||
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
|
||||
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
|
||||
# enabled` ships `false`, so a site that had selected a price feed
|
||||
# fetched nothing at all; the whole argument, the measurement and what
|
||||
# the `or` is protecting are in `tariff_refresh_wanted()` above.
|
||||
#
|
||||
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
|
||||
# two decisions; this is the first, and `cycle()`'s
|
||||
# `if arbitrage.enabled(self.o):` is still the only gate on the second.
|
||||
if tariff_refresh_wanted(opts):
|
||||
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
|
||||
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
||||
|
||||
@@ -140,6 +140,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from textwrap import dedent
|
||||
from urllib.parse import urlencode
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price")
|
||||
|
||||
@@ -184,6 +185,87 @@ def brussels(instant: datetime) -> datetime:
|
||||
return instant.astimezone(brussels_offset(instant))
|
||||
|
||||
|
||||
def brussels_date_range(since: datetime, until: datetime) -> tuple:
|
||||
"""The Brussels calendar days a half-open window [since, until) touches.
|
||||
|
||||
Returns `(first, last)`, two `date`s, BOTH INCLUSIVE - which is the shape
|
||||
`api.energy-charts.info` takes: `?bzn=BE&start=2026-09-09&end=2026-09-10`
|
||||
answered 192 quarter-hours across exactly those two local days, measured
|
||||
2026-09-10T07:45:49Z and committed as
|
||||
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`.
|
||||
|
||||
THE DEFECT THIS EXISTS TO PREVENT (TARIFF-09). `generic_dayahead.fetch()`
|
||||
accepted `since` and `until` and requested the bare default URL, so the
|
||||
feed answered ONE Brussels day. Everything past today was then carried
|
||||
forward from yesterday's profile, flagged provisional, and §8.3 dropped all
|
||||
of it from the trade set - so the optimiser never saw tomorrow as firm, at
|
||||
any hour. Measured on the merged tree at 2026-09-10T09:00+02:00 over a 48 h
|
||||
horizon: 15 firm whole hours and 33 carried, and both ends of the only
|
||||
permitted cycle inside ONE calendar day. The ceiling was structural: at
|
||||
hour H of the local day a one-day window gives at most 24-H firm hours and
|
||||
24+H carried - 1/47 at 23:00, where the firm spread no longer even clears
|
||||
the round-trip cost. That is not what FCST-04's fill is for; it covers the
|
||||
pre-publication window until the ~13:00-14:00 CET auction, after which a
|
||||
healthy feed hands over tomorrow in full.
|
||||
|
||||
⚠️ THE CALENDAR IS BRUSSELS, NEVER UTC, AND THIS IS `_bdate()`'s LESSON ONE
|
||||
LAYER UP. `since`/`until` are INSTANTS; the API takes DATES, and the two are
|
||||
not the same question. 22:30 UTC in summer is already tomorrow in Belgium,
|
||||
so a range derived from `since.date()` asks for the wrong FIRST day for the
|
||||
last one or two hours of every single day - and the day it drops is the one
|
||||
the controller is standing in. price_history.py made this mistake at the
|
||||
cache layer and documents the same arithmetic; here it decides what is
|
||||
fetched at all, so getting it wrong loses the hours rather than misfiling
|
||||
them.
|
||||
|
||||
⚠️ `until` IS EXCLUSIVE, SO LOCAL MIDNIGHT BELONGS TO THE DAY BEFORE IT.
|
||||
A 48 h horizon from local midnight ends at local midnight two days later,
|
||||
and that instant is the first tick of a day the caller did not ask for.
|
||||
Taking `brussels(until).date()` unconditionally would request a THIRD day
|
||||
on every whole-hour horizon. This feed answers such a request without
|
||||
complaint, so nothing would report it - which is the reason to get it right
|
||||
here rather than to rely on the server's tolerance: the range is a claim
|
||||
about what the caller asked for, and it should be true.
|
||||
|
||||
⚠️ AND AN EMPTY OR INVERTED WINDOW COLLAPSES TO ONE DAY RATHER THAN TO A
|
||||
BACKWARDS RANGE. `start` after `end` is a request no feed has a defined
|
||||
answer for; some return everything, some return an error, and neither is a
|
||||
thing to hand a battery controller.
|
||||
"""
|
||||
first = brussels(since).date()
|
||||
edge = brussels(until)
|
||||
last = edge.date()
|
||||
if (edge.hour, edge.minute, edge.second, edge.microsecond) == (0, 0, 0, 0):
|
||||
last = last - timedelta(days=1)
|
||||
if last < first:
|
||||
last = first
|
||||
return first, last
|
||||
|
||||
|
||||
def dated_feed_url(base: str, **params) -> str:
|
||||
"""`base` plus a query string. Refuses a `base` that already carries one.
|
||||
|
||||
⚠️ THE REFUSAL IS THE POINT, NOT THE FORMATTING. Appending `&start=...` to
|
||||
a URL that may already hold its own query string is how a plugin produces a
|
||||
request nobody intended - `?bzn=BE?bzn=BE&start=...`, or a duplicated
|
||||
parameter whose winner is the server's choice rather than ours. So the base
|
||||
is required to be parameterless and the parameters are added exactly once,
|
||||
by `urlencode`, which is also what keeps a date out of the escaping
|
||||
question. A future edit that folds the query back into the constant is a
|
||||
refused fetch with a sentence, not a silently malformed URL.
|
||||
|
||||
⚠️ IT IS NOT REACHED BY AN OPERATOR'S `price_feed_url`. See
|
||||
`GenericDayAhead.fetch()`: an override is used exactly as given.
|
||||
"""
|
||||
if "?" in base or "#" in base:
|
||||
raise SourceUnavailable(
|
||||
f"the feed base {base!r} already carries a query string or "
|
||||
f"fragment, and this builder appends parameters. Adding a second "
|
||||
f"'?' produces a URL the server parses differently from the one "
|
||||
f"anybody intended; put the fixed parameters in the call instead")
|
||||
return f"{base}?{urlencode(params)}" if params else base
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Absence, borrowed wholesale from sim/riglock.py (TEST-04).
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -684,6 +766,47 @@ def price_option(opts: dict, key: str):
|
||||
return v
|
||||
|
||||
|
||||
def provider_id(opts: dict) -> str:
|
||||
"""The selected plugin id, normalised. `"off"` when none is selected.
|
||||
|
||||
⚠️ ONE SPELLING OF THIS EXPRESSION, AND TARIFF-10 IS WHY IT IS A FUNCTION.
|
||||
`str(price_option(opts, "price_provider") or "off").strip()` was written out
|
||||
twice - in `load_prices()` below and in `static_tariff.resolve_tariff()` -
|
||||
and main.py's startup gate would have been a third. "Is a provider
|
||||
configured" is now a DECISION the add-on takes (it is what starts the
|
||||
background refresh), and a decision spelled out at three call sites is three
|
||||
places to disagree about whether `" off "` or `None` counts.
|
||||
⚠️ `validate.py` deliberately keeps its own reader: it validates the RAW
|
||||
options blob, including one the Supervisor never saw, so it must not route
|
||||
through `price_option()`'s defaults. Note what this does NOT do: it does not
|
||||
decide
|
||||
whether the id names a shipped plugin - `load_prices()` and validate.py's
|
||||
rule 7 own that, and answering "unknown" here would turn a typo into
|
||||
"no provider" instead of into a startup refusal.
|
||||
"""
|
||||
return str(price_option(opts, "price_provider") or "off").strip()
|
||||
|
||||
|
||||
def provider_configured(opts: dict) -> bool:
|
||||
"""Has the operator selected a price feed at all? (TARIFF-10)
|
||||
|
||||
THE DEFECT THIS PREVENTS: the background tariff refresh used to start only
|
||||
when `arbitrage_enabled` was on, which ships `false` - so a site with a
|
||||
provider configured fetched NOTHING, ever. `/data/price_history.json` was
|
||||
never created, the *Price history* status row read `empty` forever, and
|
||||
TARIFF-09's short-horizon warning could not fire because no fetch happened
|
||||
to warn about. Measured over a 40 h two-instance soak: zero feed requests
|
||||
and `present: false` across all 802 samples on the shipped default, against
|
||||
a resolved plan on the same feed with arbitrage armed.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH AND MUST NEVER BECOME ONE. It answers
|
||||
"fetch prices?"; `arbitrage.enabled()` answers "move energy on them?", and
|
||||
§10.1's priority 3 is gated on the second alone. See
|
||||
`main.tariff_refresh_wanted()` for the one place the two meet.
|
||||
"""
|
||||
return provider_id(opts) not in ("", "off")
|
||||
|
||||
|
||||
def export_price_eur_kwh(opts: dict) -> float:
|
||||
"""AC4 / §9.3: the injection price is a SEPARATE SCALAR, not a second series.
|
||||
|
||||
@@ -731,6 +854,24 @@ class PriceProvider(ABC):
|
||||
`transport(url, timeout_s) -> bytes` is injected so the checks never
|
||||
touch a network and `manual_csv` never needs one. `opts` is a
|
||||
`PluginOptions`, so the markup and taxes are not reachable from here.
|
||||
|
||||
⚠️ `since`/`until` ARE THE RANGE THE CALLER WANTS, AND A PLUGIN THAT
|
||||
IGNORES THEM GETS WHATEVER ITS FEED DEFAULTS TO (TARIFF-09). That is
|
||||
not a small difference: `generic_dayahead` ignored both, the public
|
||||
EPEX feed answered ONE Brussels day, and §8.3 then dropped every hour
|
||||
past today from the trade set as provisional - so the optimiser never
|
||||
saw tomorrow as firm, at any hour of any day, and nothing reported it.
|
||||
|
||||
This promise is NOT mechanically enforced, and the honest reason is
|
||||
TARIFF-08's: three of the five shipped plugins point at endpoints
|
||||
nobody has verified (TARIFF-07 is the open decision on withdrawing
|
||||
them), so nobody knows how those feeds spell a date range. A rule
|
||||
obliging them to pass one would be satisfied by an INVENTED parameter
|
||||
name - the parser-graded-against-its-own-guess defect, moved from the
|
||||
response to the request. What exists instead is a roster pin in
|
||||
test_price.py: which plugins consume the range and which do not is
|
||||
asserted as a literal, so a sixth supplier, or a change to one of the
|
||||
three, forces the decision rather than inheriting the silence.
|
||||
"""
|
||||
|
||||
|
||||
@@ -979,9 +1120,166 @@ def _points_from_json(body, price_key, scale, provisional_default):
|
||||
return out
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TARIFF-08: the COLUMNAR document, which is what the public EPEX feed really
|
||||
# publishes.
|
||||
#
|
||||
# ⚠️ THIS IS A SECOND SHAPE, READ BY A SECOND FUNCTION ON PURPOSE.
|
||||
# `_points_from_json()` above reads a row-per-point document: `{"data": [{...},
|
||||
# {...}]}`. `https://api.energy-charts.info/price?bzn=BE` does not publish that
|
||||
# and never did - it publishes PARALLEL ARRAYS:
|
||||
#
|
||||
# {"unix_seconds": [1788991200, 1788992100, ...], <- UTC epoch seconds
|
||||
# "price": [216.08, 209.90, ...], <- one per instant
|
||||
# "unit": "EUR / MWh", "license_info": "...", "deprecated": false}
|
||||
#
|
||||
# The shipped parser was pointed at that URL and asked for `doc["data"]`, so
|
||||
# every fetch ended `SourceUnavailable: the feed carried no 'data' list` ->
|
||||
# §11.4 -> static profile, arbitrage off, and nothing in the status page said
|
||||
# why. Verified live 2026-09-10 against both fixtures under `scenarios/`.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT ONE FUNCTION THAT SNIFFS FOR BOTH. A parser that decides
|
||||
# which document it got is a parser that can decide wrong, and a wrong-but-
|
||||
# parseable response then becomes a price. Two shapes, two functions, and each
|
||||
# plugin names the one its own feed publishes.
|
||||
#
|
||||
# The columnar shape was already known in this repository: DATA-02's
|
||||
# `scenarios/fetch_epex_be.py` has read `zip(d["unix_seconds"], d["price"])`
|
||||
# since 2026-08-31. Only the SHIPPED path never learned it, because every
|
||||
# fixture in the suite was built from the invented row shape - the parser was
|
||||
# graded against its own assumption. See test_price.py section 2b.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
COLUMNAR_TIME_KEY = "unix_seconds"
|
||||
COLUMNAR_PRICE_KEY = "price"
|
||||
COLUMNAR_UNIT_KEY = "unit"
|
||||
|
||||
|
||||
def _columnar_unit(text) -> str:
|
||||
"""`"EUR / MWh"` -> `"eur/mwh"`. Spacing and case are not information."""
|
||||
return "".join(str(text).split()).lower()
|
||||
|
||||
|
||||
def _points_from_columnar_json(body, expect_unit, scale,
|
||||
provisional_default):
|
||||
"""Parse a COLUMNAR price document: parallel `unix_seconds` / `price`.
|
||||
|
||||
Defects this refuses, each of which has a way of becoming a number rather
|
||||
than an error:
|
||||
|
||||
* ⚠️ UNEQUAL ARRAYS ARE `SourceUnavailable`, NOT A SHORT SERIES. `zip()`
|
||||
stops at the shorter array and says nothing, so a `price` list that
|
||||
arrived truncated would produce a REAL price series with its tail
|
||||
missing - and the tail of a Belgian day is the evening peak. A series
|
||||
that is missing the expensive hours is worse than no series: §11.4's
|
||||
no-data row switches arbitrage OFF, whereas a truncated series is
|
||||
traded on. Nothing downstream can tell the two apart, so the refusal
|
||||
has to happen here.
|
||||
* ⚠️ A UNIT THAT IS NOT THE DECLARED ONE IS REFUSED. `scale` is the
|
||||
caller's standing assumption about the unit; if the feed ever
|
||||
publishes EUR/kWh under the same keys, every price silently comes out
|
||||
a THOUSAND times too small. `_validate_raw()` cannot catch that -
|
||||
`MAX_PLAUSIBLE_EUR_KWH` only bounds prices that are too LARGE.
|
||||
DATA-02's fetcher refuses on the same field for the same reason.
|
||||
* ⚠️ `unix_seconds` ARE UTC INSTANTS, SAID SO EXPLICITLY.
|
||||
`datetime.fromtimestamp(t, timezone.utc)`. Without that second
|
||||
argument CPython returns a NAIVE datetime in the HOST's zone, and a
|
||||
naive datetime is a wall clock reading plus an assumption about whose
|
||||
clock it was. `brussels()` and `_validate_raw()` both refuse one, so
|
||||
the mistake surfaces as a refused fetch rather than as a mispriced
|
||||
day - but they refuse ALL naive datetimes and can say nothing about
|
||||
whether this one was the right instant. That part is decided here.
|
||||
|
||||
§9.3's rendering AT THE BRUSSELS OFFSET is `assemble()`'s, once, for
|
||||
every plugin - `brussels(p.start)` there is the single line that does
|
||||
it, and calling it here as well would be a second copy of a rule that
|
||||
already has an owner, indistinguishable from a no-op under mutation.
|
||||
A plugin's job is the right INSTANT; the framework's is the rendering.
|
||||
|
||||
A `null` price is DROPPED and logged, not fatal, and that is a different
|
||||
judgement from the one above: an unequal pair of arrays means the pairing
|
||||
of instant to price is unreliable, whereas a null means the source stated
|
||||
it published no price for that one instant. A dropped instant leaves a
|
||||
hole, and `_Series.at()` already answers a hole with `Absence` - §11.4 for
|
||||
that hour. `scenarios/fetch_epex_be.py` dropped 0 nulls over the 12 months
|
||||
to 2026-08-31, so this is a shape the source allows rather than one it uses.
|
||||
"""
|
||||
try:
|
||||
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
|
||||
else str(body)
|
||||
doc = json.loads(text)
|
||||
except (ValueError, UnicodeDecodeError) as e:
|
||||
raise SourceUnavailable(f"the feed did not parse as JSON: {e}")
|
||||
if not isinstance(doc, dict):
|
||||
raise SourceUnavailable(
|
||||
f"the feed is a {type(doc).__name__}, not a columnar object with "
|
||||
f"{COLUMNAR_TIME_KEY!r} and {COLUMNAR_PRICE_KEY!r} arrays")
|
||||
times = doc.get(COLUMNAR_TIME_KEY)
|
||||
prices = doc.get(COLUMNAR_PRICE_KEY)
|
||||
for key, col in ((COLUMNAR_TIME_KEY, times), (COLUMNAR_PRICE_KEY, prices)):
|
||||
if not isinstance(col, list):
|
||||
raise SourceUnavailable(
|
||||
f"the feed carried no {key!r} array; this provider reads the "
|
||||
f"columnar shape (parallel {COLUMNAR_TIME_KEY}/"
|
||||
f"{COLUMNAR_PRICE_KEY} arrays), and a row-per-point document "
|
||||
f"is a different feed, not this one with a field missing")
|
||||
if len(times) != len(prices):
|
||||
raise SourceUnavailable(
|
||||
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
|
||||
f"{COLUMNAR_PRICE_KEY} has {len(prices)}; refusing to zip them. "
|
||||
f"The short array would truncate the series silently, and a price "
|
||||
f"series missing its last hours is one missing the evening peak - "
|
||||
f"traded on as if complete, where no data at all would have "
|
||||
f"switched arbitrage off (§11.4)")
|
||||
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
|
||||
if unit != _columnar_unit(expect_unit):
|
||||
raise SourceUnavailable(
|
||||
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
|
||||
f"{expect_unit!r}. This provider converts with a fixed factor of "
|
||||
f"{scale}, so a changed unit is a factor of 1000 on every price "
|
||||
f"with nothing downstream able to notice it")
|
||||
if doc.get("deprecated"):
|
||||
# Not a refusal: today's prices are still prices. But §11.4 makes a
|
||||
# dead feed look exactly like a quiet one, so the warning that the
|
||||
# source is going away has to be emitted while it still works.
|
||||
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
|
||||
"but this endpoint is scheduled to stop",
|
||||
doc.get("deprecated"))
|
||||
out = []
|
||||
dropped = []
|
||||
for i, (t, v) in enumerate(zip(times, prices)):
|
||||
if v is None:
|
||||
dropped.append(i)
|
||||
continue
|
||||
if isinstance(t, bool) or not isinstance(t, (int, float)) or \
|
||||
not math.isfinite(t):
|
||||
raise SourceUnavailable(
|
||||
f"{COLUMNAR_TIME_KEY}[{i}] is {t!r}, not epoch seconds")
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float)) or \
|
||||
not math.isfinite(v):
|
||||
raise SourceUnavailable(
|
||||
f"{COLUMNAR_PRICE_KEY}[{i}] is {v!r}, which is not a price")
|
||||
start = datetime.fromtimestamp(float(t), timezone.utc)
|
||||
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
|
||||
provisional=bool(provisional_default)))
|
||||
if dropped:
|
||||
_LOG.warning("the feed published no price for %d of %d instants "
|
||||
"(index %s); those instants are left OUT of the series, "
|
||||
"never zero-filled - a missing hour is not a free hour",
|
||||
len(dropped), len(times),
|
||||
", ".join(str(i) for i in dropped[:5])
|
||||
+ (", ..." if len(dropped) > 5 else ""))
|
||||
if not out:
|
||||
raise SourceUnavailable(
|
||||
f"the feed carried {len(times)} instants and a price for none of "
|
||||
f"them")
|
||||
return out
|
||||
|
||||
|
||||
@register
|
||||
class GenericDayAhead(PriceProvider):
|
||||
"""Public EPEX BE day-ahead feed, in EUR/MWh.
|
||||
"""Public EPEX BE day-ahead feed, COLUMNAR, in EUR/MWh.
|
||||
|
||||
SOURCES: energy
|
||||
The framework ADDS: markup, taxes
|
||||
@@ -990,6 +1288,19 @@ class GenericDayAhead(PriceProvider):
|
||||
is the wholesale clearing price and nothing else, so both user-entered
|
||||
components have to be added to it - and because this plugin cannot read
|
||||
either number, it cannot be the one that adds them twice.
|
||||
|
||||
⚠️ IT READS THE COLUMNAR SHAPE, AND SAYS SO IN ITS OWN `fetch()`. Until
|
||||
TARIFF-08 it called `_points_from_json()` and asked
|
||||
`api.energy-charts.info` for a `"data"` list it has never published, so
|
||||
every fetch this provider ever made ended in `SourceUnavailable` and §11.4
|
||||
turned that into a silent fall back to the static profile. See
|
||||
`_points_from_columnar_json()` for the shape and for why there are two
|
||||
parsers rather than one that guesses.
|
||||
|
||||
⚠️ THE FEED IS QUARTER-HOURLY SINCE BELGIUM'S 15-MINUTE MTU (2025-10-01):
|
||||
96 points a day at 900 s, not 24 at 3600 s. Nothing here assumes either -
|
||||
the instants come from the document - and both granularities are replayed
|
||||
from real captured bodies in test_price.py section 2b.
|
||||
"""
|
||||
|
||||
id = "generic_dayahead"
|
||||
@@ -1001,12 +1312,68 @@ class GenericDayAhead(PriceProvider):
|
||||
sources=frozenset({Component.ENERGY}),
|
||||
egress_hosts=("api.energy-charts.info", "web-api.tp.entsoe.eu"))
|
||||
|
||||
FEED_BASE = "https://api.energy-charts.info/price"
|
||||
BZN = "BE"
|
||||
|
||||
async def fetch(self, opts, since, until, transport):
|
||||
url = opts.get("price_feed_url") or \
|
||||
"https://api.energy-charts.info/price?bzn=BE"
|
||||
"""Request the range the CALLER asked for, on the Brussels calendar.
|
||||
|
||||
⚠️ THE ARGUMENTS ARE USED. Until TARIFF-09 this method took `since` and
|
||||
`until` and asked for the bare `?bzn=BE`, which answers ONE Brussels
|
||||
day - so a 48 h horizon was 15 firm hours and 33 carried at 09:00, and
|
||||
1 firm and 47 carried at 23:00, with the whole trade set inside a
|
||||
single calendar day at every hour of every day. See
|
||||
`brussels_date_range()` for the measurement and for why the calendar
|
||||
is Brussels rather than UTC.
|
||||
|
||||
⚠️ AN OVERRIDE IS THE EXACT URL AND THIS METHOD APPENDS NOTHING TO IT,
|
||||
WHICH COSTS THE OVERRIDING SITE THE RANGE. That is a decision, not an
|
||||
oversight, and the sentence below is why it is not a footnote:
|
||||
|
||||
* `price_feed_url` says "override the plugin's default feed", and an
|
||||
override that is silently rewritten is not one. Merging parameters
|
||||
into an operator's query string means parsing and re-serialising a
|
||||
URL they wrote - which reorders, de-duplicates and re-escapes it,
|
||||
and can hand the server a request nobody typed.
|
||||
* `start`/`end` are ENERGY-CHARTS' spelling, and this provider
|
||||
DECLARES A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has
|
||||
verified that host's day-ahead parameters and nothing here should
|
||||
guess them - but they are not `start`/`end`, so an override to the
|
||||
other DECLARED host is exactly the case where appending our
|
||||
vocabulary adds two parameters that host ignores and yields the
|
||||
short horizon anyway, with nothing saying so.
|
||||
* and blind appending is refused outright: `dated_feed_url()` will
|
||||
not add a query string to a base that has one.
|
||||
|
||||
So the cost is real and it is REPORTED, at fetch time, on the shipped
|
||||
logger, naming the horizon consequence rather than the substitution.
|
||||
§11.4 is what makes a price defect quiet; an operator who has taken the
|
||||
short horizon has to be able to find out from the log that they did.
|
||||
"""
|
||||
override = str(opts.get("price_feed_url") or "").strip()
|
||||
if override:
|
||||
url = override
|
||||
_LOG.warning(
|
||||
"price_feed_url is set, so %s is requesting %s exactly as "
|
||||
"given and is adding NO date range to it. The public feed "
|
||||
"answers a single Brussels day to a request that names no "
|
||||
"range, and every hour past that day is then carried forward "
|
||||
"from yesterday's profile and flagged provisional - §8.3 "
|
||||
"drops provisional hours from the trade set, so arbitrage "
|
||||
"would never see tomorrow as firm. Clear price_feed_url to "
|
||||
"let this plugin ask for the %s..%s it was given.",
|
||||
self.id, url, *(d.isoformat()
|
||||
for d in brussels_date_range(since, until)))
|
||||
else:
|
||||
first, last = brussels_date_range(since, until)
|
||||
url = dated_feed_url(self.FEED_BASE, bzn=self.BZN,
|
||||
start=first.isoformat(),
|
||||
end=last.isoformat())
|
||||
check_egress(url, self.capabilities())
|
||||
body = await transport(url, float(opts.get("price_timeout_s")))
|
||||
return _points_from_json(body, "price_eur_mwh", 0.001, False)
|
||||
return _points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||||
scale=0.001,
|
||||
provisional_default=False)
|
||||
|
||||
|
||||
@register
|
||||
@@ -1190,8 +1557,8 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
site condition, it is a plugin trying to read the markup, and turning it
|
||||
into "no price data" would hide the exact defect trap 1 exists to catch.
|
||||
"""
|
||||
pid = str(price_option(opts, "price_provider") or "off").strip()
|
||||
if pid in ("", "off"):
|
||||
pid = provider_id(opts)
|
||||
if not provider_configured(opts):
|
||||
return PriceRead.no_data(
|
||||
"no price provider configured (price_provider is 'off'); "
|
||||
"§11.4: static profile, plan provisional, no arbitrage")
|
||||
|
||||
@@ -207,7 +207,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
required_spread, uncertainty_of, widen_factor,
|
||||
provider_configured, required_spread, uncertainty_of, widen_factor,
|
||||
)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff.static")
|
||||
@@ -728,6 +728,33 @@ class TariffPlan:
|
||||
self.origin = origin
|
||||
|
||||
# -- derived facts -------------------------------------------------------
|
||||
@property
|
||||
def from_feed(self) -> bool:
|
||||
"""Is this series an OBSERVATION of the configured provider's feed?
|
||||
|
||||
TARIFF-12. `origin` names four ways a plan is obtained; this collapses
|
||||
them to the one question a caller holding a PREVIOUS plan has to ask
|
||||
about a new one - did this resolution see the feed, or is it standing
|
||||
in for one that did not? Only `"provider"` saw it. `"fallback"` and
|
||||
`"none"` are the two §11.4 rows, and `"static"` is a site with no
|
||||
feed to observe at all, where the question does not arise.
|
||||
|
||||
⚠️ IT IS NOT `has_prices`, AND THE DIFFERENCE IS THE WHOLE POINT. A
|
||||
§11.4 fallback HAS prices - the operator's own static profile - but
|
||||
every point `_degrade()` builds carries `provisional=True`, so
|
||||
`firm_points()` is empty, `arbitrage()` refuses and `cheap_now()` is
|
||||
False. It is a real tariff and it is not an observation, and a caller
|
||||
deciding whether to REPLACE an observation with it needs the second
|
||||
fact, not the first.
|
||||
|
||||
⚠️ AND IT IS A PROPERTY HERE RATHER THAN AN `origin == "provider"` AT
|
||||
THE CALL SITE, for `main.fresh_tariff_plan()`'s stated reason: a check
|
||||
has to be able to assert on the shipped expression rather than on a
|
||||
transcription that drifts, and `origin`'s vocabulary belongs to this
|
||||
module.
|
||||
"""
|
||||
return self.origin == "provider"
|
||||
|
||||
@property
|
||||
def has_prices(self) -> bool:
|
||||
return self.read.match(series=lambda _r: True, no_data=lambda _r: False)
|
||||
@@ -1071,8 +1098,7 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
missing and loses the elapsed ones it should never have had.
|
||||
"""
|
||||
stamp = now or datetime.now(timezone.utc)
|
||||
pid = str(price_option(opts, "price_provider") or "off").strip()
|
||||
if pid in ("", "off"):
|
||||
if not provider_configured(opts):
|
||||
return static_plan(opts, since, until, provisional=False, now=stamp)
|
||||
|
||||
read = await load_prices(opts, since, until, transport)
|
||||
|
||||
@@ -265,9 +265,17 @@ options:
|
||||
# TARIFF - what energy costs, and whether to trade on it
|
||||
# ==========================================================================
|
||||
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
||||
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
|
||||
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
|
||||
# validates and publishes prices and changes no energy decision.
|
||||
# static profile, plan provisional, NO ARBITRAGE.
|
||||
#
|
||||
# ⚠️ SELECTING A PROVIDER IS WHAT STARTS THE FETCH, AND IT IS NOT
|
||||
# `arbitrage_enabled` (TARIFF-10). With a provider set and trading off - the
|
||||
# shipped default - the add-on fetches the feed every 30 minutes, validates
|
||||
# it, records the day in /data/price_history.json and reports what it holds
|
||||
# on the *Price history* status row. It changes NO energy decision: the cheap
|
||||
# window the maintenance cycle reads still comes from `static_profile`, and
|
||||
# §10.1's arbitrage is gated on `arbitrage_enabled` alone. Until TARIFF-10
|
||||
# the fetch was gated on `arbitrage_enabled` too, so this option did nothing
|
||||
# whatsoever on a shipped install - the store was never even created.
|
||||
#
|
||||
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
|
||||
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
|
||||
@@ -318,6 +326,14 @@ options:
|
||||
# validate.py warns about exactly that.
|
||||
static_profile: ""
|
||||
price_csv_path: "/share/goodwe_prices.csv"
|
||||
# ⚠️ AN OVERRIDE IS THE EXACT URL (TARIFF-09). The plugin sends it verbatim
|
||||
# and appends nothing - including the `start`/`end` date range it derives from
|
||||
# the horizon for its own feed - so an override gets whatever range that URL
|
||||
# answers, which for a bare day-ahead endpoint is ONE Brussels day. Every hour
|
||||
# past it is then carried from yesterday and flagged provisional, and §8.3
|
||||
# keeps provisional hours out of the trade set. The plugin logs a WARNING
|
||||
# naming that consequence on every fetch that uses an override; clearing this
|
||||
# option is what restores the full horizon.
|
||||
price_feed_url: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
@@ -344,6 +360,12 @@ options:
|
||||
# configuration: this is the first strategy in the add-on that moves energy
|
||||
# against the meter on purpose, so it is armed deliberately or not at all.
|
||||
#
|
||||
# ⚠️ IT IS NOT WHAT TURNS THE PRICE FEED ON (TARIFF-10). `price_provider`
|
||||
# above decides whether prices are FETCHED; this decides whether they move
|
||||
# energy. Leaving this off on a site with a provider configured is a
|
||||
# supported, documented state: prices are fetched, validated and recorded,
|
||||
# and nothing trades.
|
||||
#
|
||||
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
|
||||
# combinations that cannot work rather than letting them look armed. With
|
||||
# `price_provider: off` and no `static_profile` there is no series to trade
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1757455200,1757458800,1757462400,1757466000,1757469600,1757473200,1757476800,1757480400,1757484000,1757487600,1757491200,1757494800,1757498400,1757502000,1757505600,1757509200,1757512800,1757516400,1757520000,1757523600,1757527200,1757530800,1757534400,1757538000],"price":[73.0,61.31,64.32,62.49,67.0,64.48,94.52,149.26,132.43,91.61,84.8,79.61,69.23,66.75,68.91,69.31,80.29,66.91,105.2,161.05,116.73,84.2,64.1,42.41],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788904800,1788905700,1788906600,1788907500,1788908400,1788909300,1788910200,1788911100,1788912000,1788912900,1788913800,1788914700,1788915600,1788916500,1788917400,1788918300,1788919200,1788920100,1788921000,1788921900,1788922800,1788923700,1788924600,1788925500,1788926400,1788927300,1788928200,1788929100,1788930000,1788930900,1788931800,1788932700,1788933600,1788934500,1788935400,1788936300,1788937200,1788938100,1788939000,1788939900,1788940800,1788941700,1788942600,1788943500,1788944400,1788945300,1788946200,1788947100,1788948000,1788948900,1788949800,1788950700,1788951600,1788952500,1788953400,1788954300,1788955200,1788956100,1788957000,1788957900,1788958800,1788959700,1788960600,1788961500,1788962400,1788963300,1788964200,1788965100,1788966000,1788966900,1788967800,1788968700,1788969600,1788970500,1788971400,1788972300,1788973200,1788974100,1788975000,1788975900,1788976800,1788977700,1788978600,1788979500,1788980400,1788981300,1788982200,1788983100,1788984000,1788984900,1788985800,1788986700,1788987600,1788988500,1788989400,1788990300,1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[150.93,151.41,150.99,147.59,153.25,147.26,144.04,141.53,147.79,146.53,143.55,139.93,145.58,139.56,139.11,138.06,138.92,138.52,138.04,138.65,137.29,139.03,144.59,150.34,154.12,174.5,178.43,186.62,199.35,200.21,200.11,197.43,220.07,202.0,196.01,174.03,198.32,167.49,152.54,142.05,161.5,145.45,128.61,100.85,139.18,115.83,94.41,80.39,111.25,89.61,84.99,74.38,80.53,70.58,62.27,57.1,62.24,64.95,68.99,71.19,66.9,87.32,109.0,123.12,68.48,125.53,155.99,161.9,138.77,160.57,167.35,173.0,142.2,172.83,196.19,211.26,205.85,218.77,217.08,222.0,210.98,208.46,208.88,210.02,209.77,202.61,200.27,191.49,194.39,195.04,197.26,190.92,188.39,198.05,203.05,191.84,216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"card": "TARIFF-08, extended by TARIFF-09",
|
||||
"what": "UNMODIFIED response bodies from the public EPEX BE day-ahead feed, captured so the SHIPPED parser can be replayed against what the source really publishes",
|
||||
"why": "every price fixture in the suite was built from a `{\"data\": [{\"start\": ..., \"price_eur_mwh\": ...}]}` document that this feed has never published. The suite therefore graded `_points_from_json` against its own assumption - a closed loop that could not notice the assumption was wrong - and §11.4 turned the resulting failure into a silent fall back to the static profile. A captured real body is what breaks that loop, so these files are bytes, not a reconstruction: nothing here is pretty-printed, re-keyed or trimmed. TARIFF-09 adds a body from a request that names a DATE RANGE, for the same reason one layer out: the shipped plugin took `since`/`until` and asked for the bare default URL, so the feed answered ONE Brussels day and every hour past today was carried forward and dropped from the trade set as provisional. A two-day body captured from the range request is what makes \"asking for the range gets the range\" checkable against something the plugin did not shape.",
|
||||
"source": "Energy-Charts API (Fraunhofer ISE), api.energy-charts.info",
|
||||
"upstream": "Bundesnetzagentur | SMARD.de",
|
||||
"licence": "CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)",
|
||||
"licence_note": "the BE bidding zone is in the API's CC BY 4.0 list, so redistribution is permitted with attribution. DATA-02 established this against the API's own OpenAPI document before committing `epex-be-dayahead-2025-09-01_2026-09-01.json`; both bodies below carry the same `license_info` string, recorded per file and asserted by the replay test.",
|
||||
"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.",
|
||||
"retrieved_utc": "2026-09-10T06:35:00+00:00 (TARIFF-08's two), 2026-09-10T07:45:49+00:00 (TARIFF-09's)",
|
||||
"retrieved_with": "curl, by hand; no code in `app/` fetches these",
|
||||
"shape": "COLUMNAR: parallel `unix_seconds` (UTC epoch seconds) and `price` arrays, plus `unit`, `license_info` and `deprecated`. There is no `data` key and no per-point object.",
|
||||
"replayed_by": "test_price.py sections 2b (TARIFF-08) and 2c (TARIFF-09)",
|
||||
"files": {
|
||||
"energy-charts-be-price-2026-09-10-quarterhourly.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"note": "the SHIPPED default URL of `generic_dayahead`, verbatim and with no parameters, so the replay drives the bytes the add-on's own request produces",
|
||||
"covers_local_day": "2026-09-10 (Europe/Brussels)",
|
||||
"granularity_s": 900,
|
||||
"intervals": 96,
|
||||
"bytes": 1877,
|
||||
"sha256": "176309d0020ad208320797385ef68b6a98ac248218539416cddf6579dd060487",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
],
|
||||
"also_returned_verbatim_by": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-10&end=2026-09-11",
|
||||
"retrieved_utc": "2026-09-10T07:46:55+00:00",
|
||||
"note": "TARIFF-09's PRE-PUBLICATION case, and it needed no second file because the answer was BYTE-IDENTICAL to the bare-URL capture above - the same 1877 bytes and the same sha256. Asked for two days at 09:46 CEST, before the ~13:00-14:00 CET auction publishes 2026-09-11, the feed returned the ONE day that exists. That is the short answer this card requires to stay a `series` of what exists rather than becoming `no_data`, and the replay re-derives the identity from the bytes rather than restating it."
|
||||
}
|
||||
},
|
||||
"energy-charts-be-price-2025-09-10-hourly.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE&start=2025-09-10&end=2025-09-10",
|
||||
"note": "a day BEFORE Belgium's 15-minute MTU (2025-10-01), so the same endpoint answers HOURLY. Kept because the parser must not assume either spacing, and a fixture at only one granularity cannot grade that",
|
||||
"covers_local_day": "2025-09-10 (Europe/Brussels)",
|
||||
"granularity_s": 3600,
|
||||
"intervals": 24,
|
||||
"bytes": 572,
|
||||
"sha256": "03fe41690810df224611f4016899f856457f67a3c12c7654da17b1331ff2eff4",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
]
|
||||
},
|
||||
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-09&end=2026-09-10",
|
||||
"retrieved_utc": "2026-09-10T07:45:49+00:00",
|
||||
"note": "TARIFF-09. A RANGE request, and the counterfactual the ticket is built on: `start`/`end` are INCLUSIVE Brussels calendar dates and this one answers 192 quarter-hours across TWO local days, where the bare `?bzn=BE` of the same endpoint answers 96 across one. The shipped `generic_dayahead` now derives these two dates from the `since`/`until` it is handed.",
|
||||
"covers_local_days": [
|
||||
"2026-09-09",
|
||||
"2026-09-10"
|
||||
],
|
||||
"granularity_s": 900,
|
||||
"intervals": 192,
|
||||
"bytes": 3579,
|
||||
"sha256": "4d83f5efb4294a4a682562eac619bf35e45c95edde957bfa94c402e1777cc9b3",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,9 @@ from app.price import (
|
||||
PRICE_OPTION_DEFAULTS, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, WIDEN_FACTOR, brussels, required_spread, widen_factor,
|
||||
)
|
||||
from app.static_tariff import TariffPlan, parse_static_profile, static_plan
|
||||
from app.static_tariff import (
|
||||
TariffPlan, parse_static_profile, resolve_tariff, static_plan,
|
||||
)
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
UTC = timezone.utc
|
||||
@@ -1284,6 +1286,323 @@ check("main.py reads every arbitrage option through app/arbitrage.py or "
|
||||
"arbitrage_soc_ceiling")))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("8b. TARIFF-12 - a RETURNED degradation must not clear a fresh plan")
|
||||
|
||||
# ⚠️ THE STIMULUS IS A RETURN, NOT A RAISE, AND THAT IS THE WHOLE CHECK.
|
||||
# `run_tariff_refresh()` guarded the plan with `except Exception` alone, and
|
||||
# `load_prices()` states in its own docstring that it "RETURNS, NEVER RAISES,
|
||||
# for every failure a site can actually have" - so the one case the guard was
|
||||
# written for, a supplier outage, never reached it. Everything below drives the
|
||||
# SHIPPED chain and stubs nothing but the socket: the transport raises the way
|
||||
# aiohttp does, `load_prices()` turns that into a `no_data` read, `_degrade()`
|
||||
# turns THAT into a §11.4 plan, and `run_tariff_refresh()` decides what to do
|
||||
# with it. A check that patched `resolve_tariff` to raise would have passed
|
||||
# against the defective code.
|
||||
import asyncio # noqa: E402
|
||||
from app import price as _price # noqa: E402
|
||||
from app.price_history import NoHistory # noqa: E402
|
||||
|
||||
|
||||
class ClientConnectorError(Exception):
|
||||
"""aiohttp's own class name, so the §11.4 reason this drives is the
|
||||
sentence the 40 h soak recorded at refresh 56, verbatim."""
|
||||
|
||||
|
||||
_T12_FEED_UP = {"up": True}
|
||||
|
||||
|
||||
async def _t12_transport(url, timeout_s):
|
||||
"""The socket, and nothing else, is what this replaces."""
|
||||
if not _T12_FEED_UP["up"]:
|
||||
raise ClientConnectorError(
|
||||
"Cannot connect to host api.energy-charts.info:443 ssl:default")
|
||||
t0 = datetime.now(UTC).replace(minute=0, second=0, microsecond=0)
|
||||
return json.dumps({
|
||||
"unix_seconds": [int((t0 + timedelta(hours=i)).timestamp())
|
||||
for i in range(48)],
|
||||
"price": [80.0 + 40.0 * (i % 6) for i in range(48)],
|
||||
"unit": "EUR / MWh"}).encode()
|
||||
|
||||
|
||||
class _T12Controller:
|
||||
"""Just enough Controller to run the SHIPPED refresh coroutine.
|
||||
|
||||
Both methods are bound off the shipped class rather than copied, for
|
||||
`fresh_tariff_plan()`'s own stated reason: a transcription drifts.
|
||||
"""
|
||||
run_tariff_refresh = _main.Controller.run_tariff_refresh
|
||||
fresh_tariff_plan = _main.Controller.fresh_tariff_plan
|
||||
|
||||
def __init__(self, opts):
|
||||
self.o = opts
|
||||
self.tariff_plan = None
|
||||
self.tariff_at = None
|
||||
self.price_history = NoHistory()
|
||||
self.stop_event = asyncio.Event()
|
||||
self.stop_event.set() # so the inter-refresh wait returns
|
||||
self._ticks = 0
|
||||
|
||||
@property
|
||||
def stopping(self):
|
||||
self._ticks -= 1
|
||||
return self._ticks < 0
|
||||
|
||||
def refresh_once(self):
|
||||
self._ticks = 1
|
||||
asyncio.run(self.run_tariff_refresh())
|
||||
return self
|
||||
|
||||
|
||||
def _t12_opts(profile=""):
|
||||
# `price_timeout_s` is read with a plain `.get()` by every plugin, so it
|
||||
# has to be present - an options dict that relies on price_option()'s
|
||||
# defaults for it fetches with `float(None)` and this section would grade
|
||||
# a TypeError instead of an outage.
|
||||
return {"price_provider": "generic_dayahead", "price_timeout_s": 10,
|
||||
"price_horizon_h": 48, "static_profile": profile}
|
||||
|
||||
|
||||
_T12_HORIZON_H = 48
|
||||
_T12_STALE_S = _main.TARIFF_MAX_AGE_S
|
||||
_t12_saved_transport = _price.https_get
|
||||
_price.https_get = _t12_transport
|
||||
|
||||
# -- the accepted baseline (habit 7): one good fetch, varied one thing at a
|
||||
# -- time from here.
|
||||
_T12_FEED_UP["up"] = True
|
||||
_t12_a = derived(lambda: _T12Controller(_t12_opts()).refresh_once())
|
||||
_t12_good = derived(lambda: _t12_a.tariff_plan)
|
||||
check(f"BASELINE: a fetch that succeeds stores an OBSERVATION - "
|
||||
f"{getattr(_t12_good, 'words', _t12_good)!r}",
|
||||
getattr(_t12_good, "origin", None) == "provider"
|
||||
and _t12_good.from_feed is True
|
||||
and len(_t12_good.read.points) == _T12_HORIZON_H)
|
||||
|
||||
# -- the stimulus, graded before it is used on anything (habit 6/7) ----------
|
||||
_T12_FEED_UP["up"] = False
|
||||
_t12_now = datetime.now(UTC)
|
||||
_t12_deg = derived(lambda: asyncio.run(resolve_tariff(
|
||||
_t12_opts(), _t12_now, _t12_now + timedelta(hours=_T12_HORIZON_H),
|
||||
now=_t12_now, history=NoHistory())))
|
||||
check("STIMULUS: a dead feed makes resolve_tariff() RETURN a degraded plan - "
|
||||
"it does not raise, so `except Exception` is not what was protecting "
|
||||
"the plan and the defect was never on that branch",
|
||||
isinstance(_t12_deg, TariffPlan))
|
||||
check(f"...and the returned plan is the §11.4 no-profile row the soak saw - "
|
||||
f"{getattr(_t12_deg, 'words', _t12_deg)!r}",
|
||||
isinstance(_t12_deg, TariffPlan) and _t12_deg.origin == "none"
|
||||
and _t12_deg.from_feed is False and _t12_deg.has_prices is False
|
||||
and "ClientConnectorError" in _t12_deg.words)
|
||||
|
||||
# -- AC1: the degradation must not replace a plan still inside the bound -----
|
||||
# ⚠️ 1800 s IS STATED HERE, NOT DERIVED FROM THE MODULE BEING GRADED - section
|
||||
# 8's own lesson. A probe at `TARIFF_MAX_AGE_S - 1` moves with a mutation of
|
||||
# the constant; half an hour is inside any reading of a 5400 s bound, and it is
|
||||
# the interval the soak actually ran at.
|
||||
_t12_b = derived(lambda: _T12Controller(_t12_opts()))
|
||||
_t12_b.tariff_plan, _t12_b.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
_t12_at_before = _t12_b.tariff_at
|
||||
check(f"PRECONDITION: the standing plan is 1800 s old and TARIFF_MAX_AGE_S is "
|
||||
f"{_T12_STALE_S:.0f} s, so it IS fresh when the failed refresh runs - "
|
||||
f"without this the next check grades an expired plan",
|
||||
1800 < _T12_STALE_S
|
||||
and _t12_b.fresh_tariff_plan(datetime.now(UTC)) is _t12_good)
|
||||
_t12_b.refresh_once()
|
||||
check("a RETURNED degradation does not replace a plan still inside "
|
||||
"TARIFF_MAX_AGE_S - the 48 h series is still there, by identity, and "
|
||||
"not a 0-point `origin: none` read",
|
||||
_t12_b.tariff_plan is _t12_good
|
||||
and _t12_b.tariff_plan.has_prices is True
|
||||
and len(_t12_b.tariff_plan.read.points) == _T12_HORIZON_H)
|
||||
check("...and `tariff_at` did NOT move, so the kept plan still ages out from "
|
||||
"the fetch that produced it and is distinguishable from a freshly "
|
||||
"fetched one. Re-stamping it here is how a plan freezes at its last "
|
||||
"value and looks current forever",
|
||||
_t12_b.tariff_at == _t12_at_before)
|
||||
check("...and the two timestamps a successful refresh and a kept plan produce "
|
||||
"really are different values, or the check above compares a number "
|
||||
"against itself",
|
||||
_t12_a.tariff_at != _t12_at_before)
|
||||
|
||||
# -- AC3: TARIFF_MAX_AGE_S governs - the bound is where the module says ------
|
||||
_t12_c = derived(lambda: _T12Controller(_t12_opts()))
|
||||
_t12_c.tariff_plan, _t12_c.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=_T12_STALE_S + 5)
|
||||
_t12_c.refresh_once()
|
||||
check(f"a plan that has aged PAST {_T12_STALE_S:.0f} s IS replaced by the "
|
||||
f"degradation - the plan is served stale for the age bound and no "
|
||||
f"longer, so TARIFF_MAX_AGE_S governs something rather than being "
|
||||
f"unreachable",
|
||||
_t12_c.tariff_plan is not _t12_good
|
||||
and _t12_c.tariff_plan.from_feed is False
|
||||
and _t12_c.tariff_plan.has_prices is False)
|
||||
_t12_d = derived(lambda: _T12Controller(_t12_opts()))
|
||||
_t12_d.tariff_plan, _t12_d.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=_T12_STALE_S - 60)
|
||||
_t12_d.refresh_once()
|
||||
check("...and one minute SHORT of the bound it is still kept, so the line "
|
||||
"above is a bound rather than 'the degradation always wins'",
|
||||
_t12_d.tariff_plan is _t12_good)
|
||||
_t12_e = derived(lambda: _T12Controller(_t12_opts()).refresh_once())
|
||||
check("a degradation with NO plan standing at all is stored - §11.4 still "
|
||||
"reaches an install that has never had a good fetch, and the guard is "
|
||||
"not 'never overwrite'",
|
||||
_t12_e.tariff_plan is not None
|
||||
and _t12_e.tariff_plan.from_feed is False
|
||||
and _t12_e.tariff_at is not None)
|
||||
|
||||
# -- the §11.4 STATIC FALLBACK, which is the judgement this card had to make -
|
||||
_t12_f = derived(lambda: _T12Controller(_t12_opts(STATIC_BE)))
|
||||
_t12_fb = derived(lambda: asyncio.run(resolve_tariff(
|
||||
_t12_opts(STATIC_BE), _t12_now,
|
||||
_t12_now + timedelta(hours=_T12_HORIZON_H), now=_t12_now,
|
||||
history=NoHistory())))
|
||||
check(f"PRECONDITION: with a static_profile configured the SAME dead feed "
|
||||
f"degrades to a §11.4 FALLBACK carrying real prices, not to the "
|
||||
f"no-profile row - {getattr(_t12_fb, 'words', _t12_fb)!r}",
|
||||
isinstance(_t12_fb, TariffPlan) and _t12_fb.origin == "fallback"
|
||||
and _t12_fb.has_prices is True and _t12_fb.from_feed is False)
|
||||
check("...and every one of its points is provisional, so firm_points() is "
|
||||
"empty, arbitrage() refuses and cheap_now() is False - it is a real "
|
||||
"tariff and nothing may be acted on it. That is why it does not replace "
|
||||
"a fresh observation",
|
||||
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
|
||||
and _t12_fb.firm_points() == ()
|
||||
and _t12_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=NO_WEATHER).kind == "refused")
|
||||
_t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
_t12_f_at = _t12_f.tariff_at
|
||||
_t12_f.refresh_once()
|
||||
check("a §11.4 static FALLBACK does not replace a fresh provider plan either, "
|
||||
"although it has prices - a day-ahead price for an hour does not change "
|
||||
"once published, so the 30-minute-old observation is no less true and "
|
||||
"is the only one of the two that carries a firm hour",
|
||||
_t12_f.tariff_plan is _t12_good and _t12_f.tariff_at == _t12_f_at)
|
||||
|
||||
# -- ...and the rule is NOT symmetrical: a degradation replaces a degradation.
|
||||
_t12_g = derived(lambda: _T12Controller(_t12_opts(STATIC_BE)))
|
||||
_t12_g.tariff_plan, _t12_g.tariff_at = _t12_fb, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
_t12_g_at = _t12_g.tariff_at
|
||||
_t12_g.refresh_once()
|
||||
check("a standing plan that is ITSELF a §11.4 fallback IS replaced by the "
|
||||
"next one, and its timestamp moves - otherwise an outage would freeze "
|
||||
"the operator's static profile at the window it was first generated "
|
||||
"for, and the guard would be 'never overwrite' wearing a bound's name",
|
||||
_t12_g.tariff_plan is not _t12_fb and _t12_g.tariff_plan.origin ==
|
||||
"fallback" and _t12_g.tariff_at != _t12_g_at)
|
||||
|
||||
# -- ...and a recovered feed replaces a kept plan, which is the third term ---
|
||||
_T12_FEED_UP["up"] = True
|
||||
_t12_h = derived(lambda: _T12Controller(_t12_opts()))
|
||||
_t12_h.tariff_plan, _t12_h.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
_t12_h_at = _t12_h.tariff_at
|
||||
_t12_h.refresh_once()
|
||||
check("when the feed answers again the observation replaces the kept plan and "
|
||||
"`tariff_at` moves - the guard keeps a plan against a DEGRADATION, not "
|
||||
"against a fetch that worked",
|
||||
_t12_h.tariff_plan is not _t12_good
|
||||
and _t12_h.tariff_plan.from_feed is True
|
||||
and _t12_h.tariff_at != _t12_h_at)
|
||||
|
||||
# -- TARIFF-10 widened WHICH INSTALLS RUN THIS TASK, so the configurations it
|
||||
# -- newly keeps alive are graded here rather than reasoned about.
|
||||
# ⚠️ `price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
# configuration validate.py ACCEPTS and TARIFF-10's `or` exists to protect. On
|
||||
# it `resolve_tariff()` returns `static_plan(provisional=False)` - the
|
||||
# CONFIGURED product, origin "static" - so `standing.from_feed` is false on
|
||||
# every refresh and this card's guard must never fire. Confirmed by driving it,
|
||||
# not by reading the origin table: a guard that quietly latched here would
|
||||
# freeze a static site's plan at its first window for the life of the install.
|
||||
_t12_i = derived(lambda: _T12Controller(
|
||||
{"price_provider": "off", "price_timeout_s": 10, "price_horizon_h": 48,
|
||||
"static_profile": STATIC_BE}).refresh_once())
|
||||
_t12_i_plan, _t12_i_at = _t12_i.tariff_plan, _t12_i.tariff_at
|
||||
_t12_i.refresh_once()
|
||||
check("PRECONDITION: a `price_provider: off` + static_profile site resolves to "
|
||||
"the CONFIGURED product, not to a §11.4 row - origin 'static', prices, "
|
||||
"and from_feed false because there is no feed to have observed",
|
||||
getattr(_t12_i_plan, "origin", None) == "static"
|
||||
and _t12_i_plan.has_prices is True and _t12_i_plan.from_feed is False)
|
||||
check("...and TARIFF-10 now keeps this task alive on exactly that site, where "
|
||||
"this card's guard must NEVER latch: the second refresh replaces the "
|
||||
"first and the timestamp moves, so the plan is regenerated for the "
|
||||
"current window every 30 minutes as it always was",
|
||||
_t12_i.tariff_plan is not _t12_i_plan
|
||||
and _t12_i.tariff_plan.origin == "static"
|
||||
and _t12_i.tariff_at != _t12_i_at)
|
||||
|
||||
# ⚠️ AND THE OTHER CONFIGURATION TARIFF-10 ADDED - a provider configured with
|
||||
# trading DISARMED - is graded by every driven check above without a second
|
||||
# fixture, because nothing on this path reads `arbitrage_enabled` at all. That
|
||||
# is a claim about the code rather than about a behaviour, so it is asserted as
|
||||
# one (habit 4's exception, FCST-06's precedent): a fixture that set the option
|
||||
# and asserted no difference would be comparing a thing against itself.
|
||||
_T12_TRADING_OPTS = ("arbitrage_enabled", "cycle_cost_threshold_eur",
|
||||
"arbitrage_soc_floor", "arbitrage_soc_ceiling")
|
||||
# ⚠️ AND IT IS WHAT THE FUNCTION READS THAT IS SCANNED, NOT ITS TEXT. Two
|
||||
# earlier drafts of this check were red against a CORRECT tree: the first
|
||||
# because the docstring paragraph that SAYS `arbitrage_enabled` is not read is
|
||||
# itself an occurrence of the string, the second because the log line says
|
||||
# "arbitrage, where it is armed, stops with it" - which is a sentence, not a
|
||||
# read. A substring test over a function grades its prose as much as its
|
||||
# behaviour (habit 4). So the symbols are taken from the AST, and the string
|
||||
# constants are taken separately and with the docstring dropped: naming
|
||||
# arbitrage in a message is fine, READING a trading option is the thing claimed
|
||||
# not to happen.
|
||||
_t12_syms = derived(lambda: {getattr(n, "id", None) or getattr(n, "attr", None)
|
||||
for n in ast.walk(_refresh)
|
||||
if isinstance(n, (ast.Name, ast.Attribute))})
|
||||
_t12_consts = derived(lambda: {c.value for b in _refresh.body[1:]
|
||||
for c in ast.walk(b)
|
||||
if isinstance(c, ast.Constant)
|
||||
and isinstance(c.value, str)})
|
||||
check("run_tariff_refresh() READS no trading option and calls nothing in "
|
||||
"app/arbitrage.py, so keeping a plan is a decision about what the add-on "
|
||||
"has LOOKED UP - TARIFF-10's own distinction, and why the checks above "
|
||||
"cover a provider-configured site whether or not arbitrage is armed",
|
||||
isinstance(_t12_syms, set)
|
||||
and not any("arbitrage" in (n or "") for n in _t12_syms)
|
||||
and not (_t12_consts & set(_T12_TRADING_OPTS))
|
||||
# ...and both scanners can see what IS there, or they grade nothing: the
|
||||
# one option this task does read, and a name it really does call.
|
||||
and "price_horizon_h" in _t12_consts
|
||||
and "fresh_tariff_plan" in _t12_syms
|
||||
# ...and the docstring really was dropped, or the first draft's defect is
|
||||
# back and the constant set is grading prose again.
|
||||
and not any("RETURNS, NEVER RAISES" in c for c in _t12_consts))
|
||||
|
||||
# -- the vocabulary, graded on its own so a mutation of it has a named home --
|
||||
check("TariffPlan.from_feed is true of exactly the observation - a §11.4 "
|
||||
"fallback, a no-data read and a CONFIGURED static profile are all false, "
|
||||
"and it is not a synonym for has_prices",
|
||||
_t12_good.from_feed is True and _t12_fb.from_feed is False
|
||||
and _t12_deg.from_feed is False
|
||||
and static_plan({"static_profile": STATIC_BE}, D0,
|
||||
D0 + timedelta(days=2), now=D0).from_feed is False
|
||||
and static_plan({"static_profile": STATIC_BE}, D0,
|
||||
D0 + timedelta(days=2), now=D0).has_prices is True)
|
||||
|
||||
# ⚠️ A SHAPE CLAIM, AND IT IS ALLOWED TO BE ONE (FCST-06's precedent). "The age
|
||||
# is judged in ONE place" is a claim about the code, not about a behaviour: a
|
||||
# transcribed copy of the same comparison behaves identically until the day the
|
||||
# two drift. Every other check in this section is driven.
|
||||
check("run_tariff_refresh() asks fresh_tariff_plan() rather than re-deriving "
|
||||
"the age bound - one place judges it, which is what that method's own "
|
||||
"docstring is for",
|
||||
_refresh is not None
|
||||
and {n.func.attr for n in ast.walk(_refresh)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)}
|
||||
>= {"fresh_tariff_plan"})
|
||||
|
||||
_price.https_get = _t12_saved_transport
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("9. the startup gate - the configurations that cannot work are refused")
|
||||
|
||||
+1283
-20
File diff suppressed because it is too large
Load Diff
@@ -288,9 +288,25 @@ def allin(mwh):
|
||||
|
||||
|
||||
def feed_body(instants, table):
|
||||
return json.dumps({"data": [
|
||||
{"start": u.isoformat(), "price_eur_mwh": table[brussels(u).hour]}
|
||||
for u in instants]}).encode()
|
||||
"""A `generic_dayahead` body in the COLUMNAR shape the real feed publishes.
|
||||
|
||||
⚠️ TARIFF-08. This used to build `{"data": [{"start": ...,
|
||||
"price_eur_mwh": ...}]}` - a shape `api.energy-charts.info` has never
|
||||
published and that the shipped parser was nevertheless written to expect.
|
||||
Every price fixture in the repository fed that invented shape, so the
|
||||
suite graded the parser against the parser's own assumption while the live
|
||||
plugin raised `SourceUnavailable` on every fetch. Real captured bodies are
|
||||
replayed in test_price.py section 2b; the fixtures here are SYNTHETIC
|
||||
prices in the REAL shape, because this file is grading the fill and the
|
||||
history, not the parse.
|
||||
"""
|
||||
return json.dumps({
|
||||
"unix_seconds": [int(u.timestamp()) for u in instants],
|
||||
"price": [table[brussels(u).hour] for u in instants],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
|
||||
|
||||
def transport_for(body):
|
||||
@@ -299,8 +315,12 @@ def transport_for(body):
|
||||
return _t
|
||||
|
||||
|
||||
# ⚠️ NO `price_feed_url` (TARIFF-09). An override is used VERBATIM and the
|
||||
# plugin appends no date range to it, so a CFG that sets one - even to the
|
||||
# feed's own address - would exercise the override path in every check in this
|
||||
# file and never the shipped default. The override has its own grading in
|
||||
# test_price.py section 2c.
|
||||
CFG = {"price_provider": "generic_dayahead",
|
||||
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"price_timeout_s": 1,
|
||||
"price_markup_eur_kwh": MARKUP,
|
||||
"price_taxes_eur_kwh": TAXES,
|
||||
@@ -501,6 +521,220 @@ check("...and the plan is still a provider plan, not a §11.4 fallback: the "
|
||||
_after.origin == "provider" and _after.product is PriceProduct.DAY_AHEAD)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("1c. TARIFF-09 - the feed is now ASKED for the horizon, and tomorrow "
|
||||
"comes back FIRM")
|
||||
#
|
||||
# ⚠️ WHAT SECTION 1 COULD NOT SEE, AND WHY. Everything above grades the FILL
|
||||
# against a feed that published one day, and it was right to: FCST-04 covers
|
||||
# the window between midnight and the ~13:00-14:00 CET auction. But
|
||||
# `generic_dayahead.fetch()` accepted `since`/`until` and requested the bare
|
||||
# `?bzn=BE`, which answers one Brussels day AT EVERY HOUR OF EVERY DAY - so the
|
||||
# one-day feed was not the pre-publication state, it was the ONLY state, and
|
||||
# §8.3 dropped every hour past today from the trade set as provisional. The
|
||||
# optimiser never saw tomorrow as firm.
|
||||
#
|
||||
# Measured on the merged tree (4257e38) at 2026-09-10T09:00+02:00 over a 48 h
|
||||
# horizon, live feed, yesterday seeded: 15 firm whole hours and 33 carried,
|
||||
# and BOTH ends of the permitted cycle inside 2026-09-10. At 23:00 it was 1
|
||||
# firm and 47 carried, and the firm spread no longer cleared the round-trip
|
||||
# cost at all. The ceiling was structural - at hour H a one-day window gives at
|
||||
# most 24-H firm hours - which is why no amount of carrying could fix it.
|
||||
#
|
||||
# The two plans below differ in ONE thing: what the feed answered. The code
|
||||
# path, the window, the store and the instant are identical, so the difference
|
||||
# is the horizon and nothing else (habit 7).
|
||||
|
||||
TOMORROW_D = "2026-01-16"
|
||||
TOMORROW_H = bday_hours(TOMORROW_D)
|
||||
|
||||
# ⚠️ TOMORROW'S PRICES MUST NOT OVERLAP TODAY'S OR YESTERDAY'S, or a point's
|
||||
# day of origin is not recoverable from its number and every claim below is
|
||||
# about a coincidence. Asserted, not assumed.
|
||||
TOMORROW_MWH = {h: 300.0 + h for h in range(24)}
|
||||
TOMORROW_MWH[18] = 900.0 # the dear evening TOMORROW: the global peak of
|
||||
# the 48 h window, and it is on the second day
|
||||
|
||||
PRICE_AT = {u: TODAY_MWH[brussels(u).hour] for u in TODAY_H}
|
||||
PRICE_AT.update({u: TOMORROW_MWH[brussels(u).hour] for u in TOMORROW_H})
|
||||
|
||||
|
||||
def body_at(instants):
|
||||
"""A columnar body priced PER INSTANT, so two days can differ at the same
|
||||
local hour. `feed_body()` keys on the local hour alone and cannot."""
|
||||
return json.dumps({
|
||||
"unix_seconds": [int(u.timestamp()) for u in instants],
|
||||
"price": [PRICE_AT[u] for u in instants],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
|
||||
|
||||
def recording(body):
|
||||
"""A transport that serves bytes and remembers the URL it was asked for."""
|
||||
seen = []
|
||||
|
||||
async def _t(url, timeout_s):
|
||||
seen.append(url)
|
||||
return body
|
||||
_t.seen = seen
|
||||
return _t
|
||||
|
||||
|
||||
_vals_today = {TODAY_MWH[h] for h in range(24)}
|
||||
_vals_tmrw = {TOMORROW_MWH[h] for h in range(24)}
|
||||
_vals_yday = {YESTERDAY_MWH[h] for h in range(24)}
|
||||
check(f"the three days' price tables share no value at all "
|
||||
f"({len(_vals_today)}/{len(_vals_tmrw)}/{len(_vals_yday)} distinct), so "
|
||||
f"which day a point came from is recoverable from the point",
|
||||
not (_vals_today & _vals_tmrw) and not (_vals_tmrw & _vals_yday)
|
||||
and not (_vals_today & _vals_yday))
|
||||
check(f"...and both fixture days are 24 Brussels hours long "
|
||||
f"({len(TODAY_H)}/{len(TOMORROW_H)}), found by search rather than by "
|
||||
f"adding 24 UTC hours",
|
||||
len(TODAY_H) == 24 and len(TOMORROW_H) == 24)
|
||||
|
||||
# THE TWO FEED STATES, one code path.
|
||||
_t_two = recording(body_at(TODAY_H + TOMORROW_H))
|
||||
_h_two = store()
|
||||
_ph.PriceHistory._remember(_h_two, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
_pub = run(resolve_tariff(CFG, NOW, _until, transport=_t_two, now=NOW,
|
||||
history=_h_two))
|
||||
|
||||
_t_one = recording(body_at(TODAY_H))
|
||||
_h_one = store()
|
||||
_ph.PriceHistory._remember(_h_one, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
_pre = run(resolve_tariff(CFG, NOW, _until, transport=_t_one, now=NOW,
|
||||
history=_h_one))
|
||||
|
||||
# ⚠️ THE REQUEST, WHICH IS THE THING THIS CARD CHANGED. Both calls ask for the
|
||||
# same three Brussels days; only the answer differs. The expected dates are
|
||||
# spelled out here rather than derived from the module that builds them.
|
||||
check(f"the shipped plugin asks the feed for the Brussels days the horizon "
|
||||
f"touches, on both calls -> {_t_two.seen}",
|
||||
_t_two.seen == _t_one.seen
|
||||
== ["https://api.energy-charts.info/price?bzn=BE"
|
||||
"&start=2026-01-15&end=2026-01-17"])
|
||||
check("...and it is no longer the bare URL that answers a single day, which "
|
||||
"is what made the one-day state permanent rather than pre-publication",
|
||||
"start=" in _t_two.seen[0] and "end=" in _t_two.seen[0])
|
||||
|
||||
|
||||
def split(plan):
|
||||
"""(firm, carried), and ([], []) for a plan that observed no series.
|
||||
|
||||
⚠️ THE EMPTY ANSWER IS DELIBERATE. A mutation that turns the short
|
||||
pre-publication answer into `no_data` makes `plan.read.points` raise
|
||||
`Absence` by design, and an unguarded read here would take this file down
|
||||
with a traceback rather than reporting a named FAIL - "the suite is red"
|
||||
and "the check that watches this is red" are different claims
|
||||
(MUTATION.md, reporting a mutation, point 3). With this guard the counts
|
||||
below go red BY NAME, which is what a mutation report can cite.
|
||||
"""
|
||||
if not plan.has_prices:
|
||||
return [], []
|
||||
firm = [p for p in plan.read.points if not p.provisional]
|
||||
carried = [p for p in plan.read.points if p.provisional]
|
||||
return firm, carried
|
||||
|
||||
|
||||
def local_days(points):
|
||||
return sorted({brussels(p.start).strftime("%Y-%m-%d") for p in points})
|
||||
|
||||
|
||||
_f2, _c2 = split(_pub)
|
||||
_f1, _c1 = split(_pre)
|
||||
|
||||
# The expected counts are re-derived from the fixture by set arithmetic: the
|
||||
# horizon hours the feed printed, versus the horizon hours it did not.
|
||||
_window = [SINCE_HOUR + timedelta(hours=n) for n in range(HORIZON_H)]
|
||||
_two_pub_utc = {u.astimezone(UTC) for u in TODAY_H + TOMORROW_H}
|
||||
_one_pub_utc = {u.astimezone(UTC) for u in TODAY_H}
|
||||
_want_f2 = len([u for u in _window if u.astimezone(UTC) in _two_pub_utc])
|
||||
_want_f1 = len([u for u in _window if u.astimezone(UTC) in _one_pub_utc])
|
||||
check(f"WITH TOMORROW PUBLISHED the 48 h plan is {len(_f2)} firm hours and "
|
||||
f"{len(_c2)} carried; with only today, {len(_f1)} firm and {len(_c1)} "
|
||||
f"carried. Re-derived from the fixture: {_want_f2} and {_want_f1}",
|
||||
len(_f2) == _want_f2 and len(_f1) == _want_f1
|
||||
and len(_f2) + len(_c2) == HORIZON_H
|
||||
and len(_f1) + len(_c1) == HORIZON_H
|
||||
and len(_f2) > len(_f1))
|
||||
check(f"...and the firm hours now span TWO Brussels local days "
|
||||
f"{local_days(_f2)}, where the one-day answer spans exactly one "
|
||||
f"{local_days(_f1)}. This is the acceptance criterion, and the ONE thing "
|
||||
f"no amount of carry-forward could ever produce: a carried hour is "
|
||||
f"provisional by construction",
|
||||
len(local_days(_f2)) == 2 and len(local_days(_f1)) == 1
|
||||
and local_days(_f2) == [TODAY_D, TOMORROW_D])
|
||||
|
||||
# ⚠️ THE TAIL IS STILL CARRIED, WHICH IS FCST-04 STILL DOING ITS JOB. A 48 h
|
||||
# horizon asked for at 09:17 runs 9 hours into a THIRD Brussels day, and no
|
||||
# day-ahead market publishes that far. The fix widens what is firm; it does not
|
||||
# claim the market publishes more than it does.
|
||||
check(f"FCST-04 still fills the remainder: the {len(_c2)} hours past the "
|
||||
f"market's own horizon are carried from yesterday and flagged, on a "
|
||||
f"third Brussels day {local_days(_c2)} - so the plan still covers all "
|
||||
f"{HORIZON_H} hours",
|
||||
len(_c2) > 0 and local_days(_c2) == ["2026-01-17"]
|
||||
and all(p.provisional for p in _c2))
|
||||
check("...and the plan is still a provider plan reporting itself provisional, "
|
||||
"not a §11.4 fallback",
|
||||
_pub.origin == "provider" and _pub.provisional)
|
||||
|
||||
# ⚠️ THE PRE-PUBLICATION ANSWER IS A SERIES OF WHAT EXISTS, NOT `no_data`.
|
||||
# Asking for tomorrow before the auction returns fewer days than requested.
|
||||
# A `no_data` there would switch a working site's arbitrage off every morning.
|
||||
check(f"the short answer stays a SERIES of what exists - {_pre.read.kind}, "
|
||||
f"{len(_pre.read.points)} hours, origin {_pre.origin!r} - and is never "
|
||||
f"no_data and never a truncated series presented as complete "
|
||||
f"({len(_c1)} of its hours are flagged provisional)",
|
||||
_pre.read.kind == "series" and _pre.origin == "provider"
|
||||
and len(_pre.read.points) == HORIZON_H and len(_c1) > 0)
|
||||
|
||||
# --- the trade set, which is where the whole thing lands ------------------- #
|
||||
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset())
|
||||
_v2 = _pub.arbitrage(**_A9)
|
||||
_v1 = _pre.arbitrage(**_A9)
|
||||
check("both plans still ALLOW a trade, so the two verdicts below are compared "
|
||||
"against each other and not against a refusal",
|
||||
_v2.kind == "allowed" and _v1.kind == "allowed")
|
||||
_v2 = _v2 if _v2.kind == "allowed" else None
|
||||
_v1 = _v1 if _v1.kind == "allowed" else None
|
||||
|
||||
# ⚠️ NON-DEGENERACY, DERIVED. The global cheapest firm hour must be on TODAY
|
||||
# and the global dearest on TOMORROW, or "the trade set spans two days" is a
|
||||
# property of the arithmetic rather than of the fix.
|
||||
def _day_of(v, end):
|
||||
"""The Brussels day of one end of a permitting verdict, or "" for a
|
||||
refusal - so a mutation that turns the verdict into a refusal is a named
|
||||
FAIL below rather than an `Absence` traceback."""
|
||||
return "" if v is None else brussels(getattr(v, end).start).strftime(
|
||||
"%Y-%m-%d")
|
||||
|
||||
|
||||
_lo2, _hi2 = _day_of(_v2, "cheapest"), _day_of(_v2, "dearest")
|
||||
_lo1, _hi1 = _day_of(_v1, "cheapest"), _day_of(_v1, "dearest")
|
||||
check(f"WITH TOMORROW FIRM the trade set contains hours from BOTH days: "
|
||||
f"buy on {_lo2}, sell on {_hi2}",
|
||||
sorted({_lo2, _hi2}) == [TODAY_D, TOMORROW_D])
|
||||
check(f"...where the one-day answer's buy AND sell are both inside {_lo1} - "
|
||||
f"a trade set that is a subset of ONE calendar day, structurally, which "
|
||||
f"is the defect this card is about",
|
||||
_lo1 == _hi1 == TODAY_D)
|
||||
_s2 = None if _v2 is None else _v2.spread_eur_kwh
|
||||
_s1 = None if _v1 is None else _v1.spread_eur_kwh
|
||||
check(f"...and the cross-day spread is strictly larger ({_s2} vs {_s1} "
|
||||
f"EUR/kWh), so the second day is not merely visible but PRICED",
|
||||
_s2 is not None and _s1 is not None and _s2 > _s1)
|
||||
check("...and both ends of the permitted cycle are FIRM, which "
|
||||
"`_firm_or_die()` asserts at construction and §8.3 requires",
|
||||
_v2 is not None and not _v2.cheapest.provisional
|
||||
and not _v2.dearest.provisional)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. AC2 - the spread stopped being taken over hours that have gone")
|
||||
@@ -559,14 +793,24 @@ for _n in range(len(TODAY_H) * (60 // _QSTEP_MIN)):
|
||||
_qu = TODAY_H[0] + timedelta(minutes=_QSTEP_MIN * _n)
|
||||
# A spiky within-the-hour profile, so decimation is visible in the SPREAD
|
||||
# and not only in a count.
|
||||
_qrows.append({"start": _qu.isoformat(),
|
||||
"price_eur_mwh": 100.0 + 40.0 * (_n % 4) + brussels(_qu).hour})
|
||||
_qbody = json.dumps({"data": _qrows}).encode()
|
||||
_qrows.append((_qu,
|
||||
100.0 + 40.0 * (_n % 4) + brussels(_qu).hour))
|
||||
# ⚠️ COLUMNAR, like the real feed - see feed_body(). And 900 s spacing is not a
|
||||
# hypothetical here: Belgium moved to a 15-minute MTU on 2025-10-01 and
|
||||
# `api.energy-charts.info` has answered 96 points a day ever since, which is
|
||||
# exactly what test_price.py section 2b replays from a captured body.
|
||||
_qbody = json.dumps({
|
||||
"unix_seconds": [int(_u.timestamp()) for _u, _v in _qrows],
|
||||
"price": [_v for _u, _v in _qrows],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
check(f"the fixture really is sub-hourly - {len(_qrows)} points across "
|
||||
f"{len(TODAY_H)} Brussels hours - or every check below passes for an "
|
||||
f"hourly feed too and grades nothing",
|
||||
len(_qrows) == 4 * len(TODAY_H)
|
||||
and len({_r["start"] for _r in _qrows}) == len(_qrows))
|
||||
and len({_r[0] for _r in _qrows}) == len(_qrows))
|
||||
|
||||
_q_before = run(resolve_tariff(CFG, NOW, _until, transport=transport_for(_qbody),
|
||||
now=NOW, history=NoHistory()))
|
||||
|
||||
@@ -743,8 +743,10 @@ def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ⚠️ NO `price_feed_url` (TARIFF-09): an override is used verbatim and the
|
||||
# plugin appends no date range to it, so setting one - even to the feed's own
|
||||
# address - exercises the override path rather than the shipped default.
|
||||
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
|
||||
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
|
||||
price_timeout_s=1, price_markup_eur_kwh=0.03,
|
||||
price_taxes_eur_kwh=0.06)
|
||||
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead,
|
||||
@@ -778,9 +780,20 @@ check("a dead feed with NO static_profile has nothing to fall back to, and "
|
||||
_fb_none.origin == "none" and not _fb_none.has_prices)
|
||||
|
||||
# A live feed still wins over the profile.
|
||||
_body = json.dumps({"data": [
|
||||
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
|
||||
for i in range(24)]}).encode()
|
||||
# ⚠️ COLUMNAR (TARIFF-08). `api.energy-charts.info` publishes parallel
|
||||
# `unix_seconds`/`price` arrays and no `data` key; the `{"data": [...]}` body
|
||||
# this used to build was invented by the parser it was grading, which is how a
|
||||
# provider that could never parse a live response shipped green. Real captured
|
||||
# bodies are replayed in test_price.py section 2b - the prices here are
|
||||
# synthetic, the SHAPE is not.
|
||||
_body = json.dumps({
|
||||
"unix_seconds": [int((THU + timedelta(hours=i)).timestamp())
|
||||
for i in range(24)],
|
||||
"price": [40 + i for i in range(24)],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
|
||||
|
||||
async def _live(url, timeout_s):
|
||||
|
||||
@@ -143,7 +143,7 @@ configuration:
|
||||
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
|
||||
price_provider:
|
||||
name: "Tariff · Dynamic price source"
|
||||
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ Switching it on today fetches, checks and publishes prices and changes no energy decision - nothing consumes the price series yet. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed."
|
||||
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH - not the trading switch further down. With a source selected the add-on fetches the feed every half hour, checks it, records the day and shows what it holds on the Price history row, whether or not price trading is armed. On its own it still moves no energy: trading is the separate switch, and the cheap window the monthly maintenance cycle uses comes from the fixed or day/night tariff below. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed."
|
||||
price_markup_eur_kwh:
|
||||
name: "Tariff · Supplier markup (EUR/kWh)"
|
||||
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under engie_dynamic, totalenergies_dynamic, eneco_eddy and manual_csv it is ignored, and logged as ignored, never applied twice."
|
||||
@@ -161,7 +161,7 @@ configuration:
|
||||
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
|
||||
price_feed_url:
|
||||
name: "Tariff · Price feed URL override"
|
||||
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere."
|
||||
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere. WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN: the add-on adds nothing to it, not even the date range it normally asks its own feed for, so an override gets whatever horizon that URL returns - often a single day, with the rest of the plan carried over from yesterday and excluded from price arbitrage. A warning in the log says so on every price refresh. Clear this option to get the full horizon back."
|
||||
price_horizon_h:
|
||||
name: "Tariff · How far ahead to fetch prices (hours)"
|
||||
description: "How many hours of prices to ask the source for."
|
||||
@@ -176,7 +176,7 @@ configuration:
|
||||
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
|
||||
arbitrage_enabled:
|
||||
name: "Tariff · Trade on the price spread"
|
||||
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
|
||||
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. ⚠️ LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF. A price source selected above is fetched, checked and recorded either way; this switch decides only whether the add-on ACTS on those prices. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
|
||||
cycle_cost_threshold_eur:
|
||||
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
|
||||
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
|
||||
|
||||
@@ -123,7 +123,7 @@ configuration:
|
||||
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
|
||||
price_provider:
|
||||
name: "Tarief · Bron van dynamische prijzen"
|
||||
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ Dit vandaag aanzetten haalt prijzen op, controleert en publiceert ze, en verandert geen enkele energiebeslissing - er is nog niets dat de prijsreeks gebruikt. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten."
|
||||
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ DIT AANZETTEN IS WAT HET OPHALEN VAN PRIJZEN START - niet de handelsschakelaar verderop. Met een bron ingesteld haalt de add-on de feed elk half uur op, controleert die, bewaart de dag en toont op de rij Prijsgeschiedenis wat er bewaard is, of prijshandel nu aan staat of niet. Op zichzelf verplaatst dat nog geen energie: handelen is de aparte schakelaar, en het goedkope venster dat de maandelijkse onderhoudscyclus gebruikt komt uit het vaste of dag/nacht-tarief hieronder. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten."
|
||||
price_markup_eur_kwh:
|
||||
name: "Tarief · Marge van de leverancier (EUR/kWh)"
|
||||
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij engie_dynamic, totalenergies_dynamic, eneco_eddy en manual_csv wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
|
||||
@@ -141,7 +141,7 @@ configuration:
|
||||
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
|
||||
price_feed_url:
|
||||
name: "Tarief · Afwijkende URL voor de prijsfeed"
|
||||
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen."
|
||||
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen. WAT U HIER TYPT WORDT LETTERLIJK VERSTUURD: de add-on voegt er niets aan toe, ook niet het datumbereik dat ze normaal aan haar eigen feed vraagt. Een afwijkende URL levert dus de horizon op die die URL zelf teruggeeft - vaak één dag, waarna de rest van het plan uit de prijzen van gisteren wordt overgenomen en buiten de prijsarbitrage valt. Bij elke prijsvernieuwing verschijnt daarover een waarschuwing in het logboek. Maak dit veld leeg om de volledige horizon terug te krijgen."
|
||||
price_horizon_h:
|
||||
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
|
||||
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."
|
||||
@@ -156,7 +156,7 @@ configuration:
|
||||
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen zinvol samen met de entiteit hierboven en, net als die, niet te combineren met een dynamische prijsbron."
|
||||
arbitrage_enabled:
|
||||
name: "Tarief · Handelen op het prijsverschil"
|
||||
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
|
||||
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. ⚠️ DIT UIT LATEN STAAN ZET DE PRIJSFEED NIET UIT. Een prijsbron die hierboven gekozen is, wordt hoe dan ook opgehaald, gecontroleerd en bewaard; deze schakelaar bepaalt alleen of de add-on op die prijzen HANDELT. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
|
||||
cycle_cost_threshold_eur:
|
||||
name: "Tarief · Kostprijs van één volledige laad/ontlaadcyclus (EUR/kWh)"
|
||||
description: "Wat één volledige cyclus per kWh kost: slijtage van de batterij plus omzettingsverliezen. Een prijsverschil dat hier onder ligt, kost geld en levensduur en wordt dus niet verhandeld. ⚠️ DIT GETAL IS NIET AFGELEID UIT EEN METING. 0,10 is een productbeslissing en staat nog open bij de eigenaar (OPT-08). Bij deze waarde handelt een installatie met een vast dag/nachttarief helemaal niet - het realistische Belgische dag/nachtverschil is ongeveer 0,08 EUR/kWh - en dat is bij dit getal het juiste antwoord, geen fout."
|
||||
|
||||
Reference in New Issue
Block a user