Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
028c3e53fb | ||
|
|
a6e64fb66e | ||
|
|
435e1b6f7c | ||
|
|
f5ea39784d | ||
|
|
7f2c4e20e1 | ||
|
|
559f0eb022 | ||
|
|
c81289527f | ||
|
|
e6c45a61e7 | ||
|
|
88c59a9918 | ||
|
|
17b7b447c4 | ||
|
|
4257e38e5a | ||
|
|
7a5281e97f | ||
|
|
6eba18968c | ||
|
|
958f5cd218 | ||
|
|
c45893ffad | ||
|
|
5059e28860 | ||
|
|
375a1e29d2 | ||
|
|
c2787da539 | ||
|
|
b59f5ce31b | ||
|
|
0618194d9c | ||
|
|
587751b115 | ||
|
|
3de4453956 | ||
|
|
f6f3f227c1 | ||
|
|
91b0d5e4dc | ||
|
|
806390d8b1 | ||
|
|
775f3a2e82 | ||
|
|
f109153216 | ||
|
|
f6d7bfcb04 | ||
|
|
d1b7e3f113 | ||
|
|
5e382f52f6 | ||
|
|
46c022c926 | ||
|
|
22ca341459 | ||
|
|
9c1a9e0f17 | ||
|
|
638c213944 | ||
|
|
1841416aa4 | ||
|
|
cb8f800180 | ||
|
|
018da00b74 | ||
|
|
e06906d65f | ||
|
|
37dd0f00cc | ||
|
|
1448bd909b | ||
|
|
a0d981731c | ||
|
|
b21e229adf | ||
|
|
2271e147c6 | ||
|
|
6719bd882f | ||
|
|
f76639c7b2 | ||
|
|
57775d4e06 | ||
|
|
9099ceb1af | ||
|
|
295a2aedb6 | ||
|
|
bb469163a6 |
@@ -1,5 +1,660 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
|
||||
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
|
||||
so `ha apps update` is a silent no-op on an unbumped change. This branch
|
||||
deliberately does **not** pick the number, because the right one depends on a
|
||||
fact the branch cannot observe - whether 0.9.0's image has already been built
|
||||
and pulled. If it has not, this belongs inside 0.9.0's section before it ships;
|
||||
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 |
|
||||
| _this branch_ | TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
|
||||
|
||||
### 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-14 - the trade was decided against the wrong price
|
||||
|
||||
**On the only price feed that works, `_Series.at()` returned the wrong point,
|
||||
and the error grew with depth into the series.** It asked
|
||||
`s <= u < s + 1 h` of every point **in list order** and returned the **first**
|
||||
match. On the hourly shape every fixture in this repository carried before
|
||||
TARIFF-08 that is right by accident; Belgium moved to a **15-minute MTU on
|
||||
2025-10-01** and the live day-ahead feed returns **96 points at 900 s**, where
|
||||
it is wrong twice over. Measured against the shipped code at `5059e28`:
|
||||
|
||||
```
|
||||
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
|
||||
|
||||
ask + 0min -> 02:00 0.10
|
||||
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
|
||||
ask +45min -> 02:00 0.10 <-- WRONG
|
||||
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
|
||||
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
|
||||
```
|
||||
|
||||
Inside the first hour the `:00` point wins because its hour has not run out.
|
||||
**From the second hour on the answer is not even the top of the hour** - the
|
||||
first point still inside its own hour wins - so the error compounds.
|
||||
|
||||
**What it cost is not "a slightly wrong price".** `arbitrage._act()` tests
|
||||
`here.start == v.cheapest.start` / `== v.dearest.start` to decide whether this
|
||||
interval is the buy one or the sell one. That identity almost never held, so
|
||||
**the strategy idled through exactly the interval it exists to trade**,
|
||||
reporting *"this hour is neither the buy hour nor the sell hour"* - and read
|
||||
§8.3's `here.provisional` and §17's `price < 0` off the same wrong point.
|
||||
`static_tariff.py` emits hourly points, so **static sites were never affected**;
|
||||
this was reachable only down the dynamic path TARIFF-08/09 made work.
|
||||
|
||||
**The rule now, in one sentence: a point covers from its own start until the
|
||||
next point's start, and never more than one hour.**
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| 900 s | `at(02:15)` -> the 02:00 point | -> the 02:15 point |
|
||||
| 3600 s | `at(02:30)` -> the 02:00 point | **unchanged** |
|
||||
| **mixed** `[900, 3600]` | each point one hour wide | **each point takes its own gap** |
|
||||
|
||||
The mixed case is not an edge case: FCST-04's carry-forward emits **hourly
|
||||
carried points beside quarter-hourly firm ones**, and the soak measured spacing
|
||||
`[900, 3600]` in **70 % of samples**. A window derived from one global spacing
|
||||
would be wrong in the normal steady state.
|
||||
|
||||
**The one-hour cap is load-bearing.** FCST-04 leaves an hour *out* of the
|
||||
series when nothing published it and yesterday has no value either, and
|
||||
`at()` raising `Absence` for that hour is what sends the caller to the static
|
||||
profile (§11.4). Uncapped, "until the next point's start" would stretch the
|
||||
point before a three-hour hole across the whole hole and answer every instant
|
||||
in it with a stale price - filling holes, introduced by the fix for a different
|
||||
defect.
|
||||
|
||||
**The last point's window is a decision and it is written down.**
|
||||
`DECISIONS/TARIFF-14-the-window-a-point-covers.md` §2 argues it: the last point
|
||||
is given **the same span as the point before it** (one hour when it is the only
|
||||
point). A flat one-hour tail would answer **00:30 tomorrow with today's 23:45
|
||||
price**, which is reaching for a neighbour across a day boundary. The cost of
|
||||
the choice - a mixed series ending at the firm/carried seam loses its last 45
|
||||
minutes to `Absence` - is stated there rather than buried, and the direction is
|
||||
the safe one.
|
||||
|
||||
**`at()` now reads a point's neighbours, so it would depend on the series being
|
||||
sorted - and it establishes that itself** rather than trusting the three
|
||||
producers that happen to guarantee it today. The lookup index is sorted by UTC
|
||||
start once, at construction; a series handed its 96 points in reverse order
|
||||
answers identically, which is a check. `covers()` shared the defect exactly
|
||||
(it has no rule of its own - it asks `at()`) and is fixed by the same change;
|
||||
`window()` did not (it grades a point's **start** against a range and asks
|
||||
nothing about duration), and its docstring now says so.
|
||||
|
||||
**Nothing about §9.3 or a point's `start` changed.** `PricePoint` still has
|
||||
three fields; the span is derived from the neighbours, not stored.
|
||||
|
||||
Graded by `test_price.py` section 14 - 900 s, 3600 s, **mixed**, holes, the
|
||||
tail, caller order, `covers()`/`window()` - and end to end by
|
||||
`test_arbitrage.py` section 10, which drives `decide()` over a 96-point
|
||||
quarter-hourly day whose cheapest and dearest quarters are deliberately **not**
|
||||
on a whole hour. Eight mutations, all red, including a **zero-byte** one on the
|
||||
tail rule; the table with byte deltas and the checks that were NOT reached is
|
||||
in the decision note §6.
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
**A price cache that could not write went on answering a yesterday the file did
|
||||
not contain.** `price_history.py`'s docstring states its rule unconditionally -
|
||||
*"A store that cannot persist now records nothing, and both of its answers say
|
||||
the same thing"* - and FCST-04 enforced it against `not self.ok`, which is one
|
||||
**symptom**: the durable volume is missing. `ok` is about the DIRECTORY, and a
|
||||
read-only remount and a full disk both leave it `True`. Reproduced on `5059e28`
|
||||
against two real volumes - a `/data` bind-mounted read-only and a full tmpfs:
|
||||
|
||||
| | with a `/data` that cannot be written |
|
||||
|---|---|
|
||||
| `last_error` | `could not write ([Errno 30] Read-only file system)` |
|
||||
| `words` | `2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over 24 h) [could not write (...)]` |
|
||||
| `yesterday()` | **series, 24 points** |
|
||||
| on disk | `{}` |
|
||||
|
||||
`words` and `yesterday()` were describing two different stores, and the 24
|
||||
points were gone on the next restart with nothing saying so - the
|
||||
silently-forgetting cache that rule exists to refuse, reached by the condition
|
||||
it did not name. **No shipped path reaches it**: `/data` is the Supervisor's
|
||||
durable volume and this module is its only writer. The defect is that a
|
||||
guarantee the module states about itself did not hold in one condition.
|
||||
|
||||
**"Cannot persist" is now ONE derived predicate, asked once, about the
|
||||
outcome.** It cannot be answered *before* the attempt - a directory that existed
|
||||
and was writable a moment ago is not a write that completed - so `_save()`
|
||||
returns whether the file now holds what memory holds: `False` with no durable
|
||||
volume, `False` on an `OSError` from the write, `True` otherwise. `_remember()`
|
||||
keeps only what that one answer says was kept, and **the `not self.ok` test in
|
||||
`_remember()` is gone** rather than joined by a second one. FCST-04's M4 and
|
||||
FCST-05's HIGH were the same shape - a rule enforced on the conditions somebody
|
||||
thought of - so a third cause now needs no third branch.
|
||||
|
||||
**What is undone is the MERGE, not the store.** The state restored is the last
|
||||
one that *is on disk*, so a site whose volume fills at 14:00 goes on answering
|
||||
the yesterday it wrote at 13:30 - that record survives a restart, so answering
|
||||
it is not a promise the store cannot keep. It simply stops adding to it. The
|
||||
snapshot is a `copy.deepcopy`: a one-level copy shares the day record the merge
|
||||
rebinds `points` on, and measured with one, a store holding 12 persisted points
|
||||
answered **24** after a failed merge.
|
||||
|
||||
⚠️ **A failed write is still not fatal, and that was a constraint rather than an
|
||||
oversight.** §6 and this module's opening: a broken cache must not be a broken
|
||||
controller. An add-on that will not start because `/data` filled up is a worse
|
||||
defect than the one being fixed, so `_save()` reports and returns - the failure
|
||||
stays in the log, in `last_error`, in `words` and in `yesterday()`'s reason -
|
||||
and the shipped `resolve_tariff()` path still returns a provider plan with no
|
||||
§11.4 degradation. What changed is that it is now *acted on*.
|
||||
|
||||
`test_price_history.py` section 5d drives it on two real failures with different
|
||||
syscalls and different errnos - a directory where the file goes (`os.replace`,
|
||||
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
|
||||
starting.** `_load()` caught `(json.JSONDecodeError, OSError)`.
|
||||
`UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError` -
|
||||
neither of those, and `issubclass(UnicodeDecodeError, OSError)` is **False** -
|
||||
so it escaped `_load()` -> `_open()` -> `PriceHistory.__init__`, which
|
||||
`Controller.__init__` does not guard. Reproduced: `UnicodeDecodeError: 'utf-8'
|
||||
codec can't decode byte 0xff`, and **the controller did not come up**. That is the same class as the day-record defect the store was reworked
|
||||
for, in the one byte class the rework left uncovered: valid-UTF-8-invalid-JSON,
|
||||
a UTF-8 BOM and a non-dict day record all degraded correctly. The suite could
|
||||
not see it because its "unreadable file" fixture writes `"{ this is not json"`,
|
||||
which is **valid UTF-8**. The handler now names the `ValueError` family - the
|
||||
file's CONTENT - beside `OSError`, the file's BYTES, and a fixture holding a
|
||||
genuinely undecodable byte is in `test_price_history.py` section 5.
|
||||
|
||||
**A malformed `start` under the day the feed is writing to killed the tariff
|
||||
plan permanently.** `_remember()` merged a day's points with
|
||||
`{q["start"]: q ...}` and sorted on `q["start"]`, trusting a value read off
|
||||
`/data` to be a hashable, sortable string. An `int`, `null`, a `dict` or a
|
||||
`list` there raised `TypeError`; `run_tariff_refresh` catches `Exception`, so
|
||||
the add-on stayed **up** while **every refresh raised, forever** - the plan
|
||||
retiring after `TARIFF_MAX_AGE_S` and arbitrage and the cheap window off for
|
||||
good, with no self-heal because `_remember()` raised before reaching `_save()`.
|
||||
The read path (`_parse_points()`) was properly defensive throughout, which is
|
||||
why `yesterday()` survived and the existing fixture passed: it files its
|
||||
malformed rows under a day the feed is **not** writing to, so `_remember()`
|
||||
never opened that record. **The two paths now share one validator**,
|
||||
`_point_of()`, and neither has a standard of its own. A row that is not a point
|
||||
is dropped and reported, and `_save()` rewrites the record clean.
|
||||
|
||||
⚠️ **AND THE STATUS ROW SAID THE STORE WAS HEALTHY WHILE THE PLAN WAS DEAD.**
|
||||
`words` read `'2026-01-15 (1 h)'` about the record that was killing every
|
||||
refresh. `_load()` now counts the rows inside a kept record as well as the
|
||||
record itself, so the store stops reporting plain health from the moment it is
|
||||
opened; the note names what was **found** at open and what was **dropped** by
|
||||
the refresh, and clears itself once the file has been rewritten.
|
||||
|
||||
⚠️ **AND THE ROW COUNTED POINTS AND CALLED THEM HOURS.** `f"{d} ({_n_points(d)}
|
||||
h)"` on the quarter-hourly feed this add-on already supports reads
|
||||
`'2026-01-13 (96 h)'` for a 24-hour day - the same points/hours conflation the
|
||||
sub-hourly defect above was about, on the row that defect's own card shipped.
|
||||
The cell is now `2026-01-13 (96 points over 24 h)`: two facts, each under its
|
||||
own unit, and the hour figure is the one that says whether tomorrow morning's
|
||||
fill has a whole day to carry.
|
||||
|
||||
⚠️ **AND THE MERGE KEY IS THE INSTANT RATHER THAN ITS SPELLING**, which is what
|
||||
rule 4 already said. Aware datetimes hash and compare by instant. The string
|
||||
key merged by spelling, and the lexicographic sort beside it put the second
|
||||
02:00 of the 25-hour October day (`+01:00`) **before** the first (`+02:00`);
|
||||
only the stored order was affected, because `_parse_points()` re-sorts by
|
||||
instant on the way out.
|
||||
|
||||
⚠️ **TWO STATED REASONS WERE FALSE, AND THIS REPOSITORY TREATS THAT AS A
|
||||
DEFECT.** `price_forecast._audit()`'s "WHY IT IS STRICTER" section claimed the
|
||||
rework refused two things FCST-03's audit did not. A differential over the
|
||||
whole domain the audit can express - every published mask over 3 hours crossed
|
||||
with every output series of length 0-3 - found **zero divergence in both
|
||||
directions**: the reworked audit is exactly **EQUAL** to FCST-03's, not
|
||||
stronger. The old `seen` count already refused the same published point emitted
|
||||
twice (one point twice is one hour twice), and the old **identity** rule
|
||||
already refused a carried point sharing a published hour. The only behavioural
|
||||
change is the intended **relaxation** - several distinct published points in
|
||||
one hour - and that stays. The docstring and the two check texts now say what
|
||||
is true, and the differential is committed as a check in
|
||||
`test_price_forecast.py` rather than left as prose.
|
||||
|
||||
⚠️ **AND `_audit` IS NOT WHAT STOPS THE DECIMATION DEFECT RECURRING.** It does
|
||||
not check **completeness**: fed the decimation directly - four published points
|
||||
in an hour, output emitting only the last - it **accepts**, and it accepts an
|
||||
empty output. That is a limit, not a regression, and the module docstring said
|
||||
otherwise by presenting the audit as the post-condition guarding provenance.
|
||||
What actually guards it is the point **count** and the pass-through **identity**
|
||||
checks in `test_price_forecast.py` and `test_price_history.py`, and the text now
|
||||
points at them. It is **not** made complete: `published` is the whole read while
|
||||
the fill emits only `[since, until)`, so "every published point appears" is not
|
||||
a true post-condition of those two arguments. Measured - the rule as stated
|
||||
takes the 1 h, 12 h and 48 h horizon checks red on legitimate series.
|
||||
|
||||
**No option was added, `config.yaml` is untouched, and nothing was deployed.**
|
||||
|
||||
|
||||
**FCST-03 shipped the fill and no caller.** `price_forecast.fill_from_yesterday()`
|
||||
was complete and checked, and so were the consumers written for it -
|
||||
`TariffPlan.firm_points()`, `cheap_now()`'s refusal of a provisional hour, and
|
||||
`arbitrage()`'s refusal 3, which drops provisional hours from the trade set
|
||||
rather than refusing the whole series. Nothing in `app/` called the fill, so no
|
||||
series ever contained a carried point and every one of those consumers was
|
||||
exercising its all-firm branch.
|
||||
|
||||
⚠️ **THE DEFECT THAT PRODUCED WAS NOT AN ERROR ANYWHERE.** `load_prices()`
|
||||
returns what the feed published and nothing widened it to the requested horizon,
|
||||
so on a dynamic site between midnight and the 14-16 h CET day-ahead publication
|
||||
the plan covered the rest of today and **nothing more** - typically 10-24 of the
|
||||
48 hours `price_horizon_h` asked for. `arbitrage()` then took `max - min` over
|
||||
that truncated window and reported a spread. The number was real, the window was
|
||||
half the one configured, and **nothing said so**: tomorrow evening's peak, the
|
||||
hour a morning charge would be sold into, was not in the series at all, so the
|
||||
spread it would have created was never compared against anything.
|
||||
|
||||
**What changes:**
|
||||
|
||||
* `app/price_history.py` (new) keeps the **published** prices of the last few
|
||||
Brussels days in `/data/price_history.json`, and `resolve_tariff()` now fills
|
||||
an unpublished hour of the horizon from yesterday's same-wall-clock hour,
|
||||
flagged provisional. ⚠️ **"An" unpublished hour, not "every" one:** the fill
|
||||
can carry only an hour yesterday actually published, so a partial yesterday
|
||||
leaves the local hours it never held **out of the series** - §11.4 for those
|
||||
hours, and asking for one raises rather than answering. Correct behaviour,
|
||||
and the wording is now the behaviour.
|
||||
* ⚠️ **A sub-hourly feed keeps every print.** `eneco_eddy` is the imbalance
|
||||
product and settles per **quarter**; the fill walks whole hours to decide
|
||||
what needs carrying, and that is not a rule about what a published hour may
|
||||
contain. Every published point inside the horizon passes through by identity,
|
||||
and a carried point is added only for a whole hour with no published coverage
|
||||
at all. The first draft keyed the published set by hour - last point wins -
|
||||
and a 96-point quarter-hourly feed reached the optimiser as 15 points with
|
||||
its spread down from 0.143 to 0.014 EUR/kWh, on an all-firm, fully-published
|
||||
series.
|
||||
* The horizon is **whole hours at both ends**: `since` is floored to the hour
|
||||
that contains it, and so is `until`. Asked for 48 hours at 08:17, the plan is
|
||||
48 hours long and not 49.
|
||||
* `resolve_tariff(history=...)` is **required and keyword-only**. A caller with
|
||||
no durable volume passes the explicit `NoHistory()`. A default of `None`
|
||||
meaning "skip the fill" would hand a caller who forgot the argument exactly
|
||||
the truncated horizon this card removes, silently.
|
||||
* A new status row, **Price history**, names the days held, how many points
|
||||
each holds and how many whole hours they cover (see FCST-05: it shipped
|
||||
counting points and printing them under the hour's name).
|
||||
|
||||
⚠️ **ONE CHANGE HERE MOVES ENERGY, AND IT IS NOT THE ONE THE CARD IS NAMED FOR.**
|
||||
Carried hours are provisional, so §8.3 keeps them out of the trade set: the fill
|
||||
widens what the optimiser can **see**, never what it may **do**. What does
|
||||
change a trade is that the filled series is windowed to `[since, until)`, so
|
||||
**hours that have already elapsed are no longer in the spread**. Before, a cheap
|
||||
night that had already gone sat in the series as the day's cheapest hour - and a
|
||||
cycle cannot buy in an hour that is past, so the trade was quietly never taken.
|
||||
`test_price_history.py` section 2 grades this by finding the cheapest firm hour
|
||||
with and without the fill and showing it **moves**.
|
||||
|
||||
⚠️ **A DEAD FEED IS STILL §11.4, NOT YESTERDAY.** The fill runs only on a
|
||||
`series` read; a feed that published nothing still degrades to the configured
|
||||
`static_profile`. The two are indistinguishable to the optimiser - both are
|
||||
100 % provisional, so arbitrage refuses on the firm-pair rung and `cheap_now()`
|
||||
is False - so preferring yesterday would change a documented degradation for a
|
||||
difference only the status page can see.
|
||||
`DECISIONS/FCST-04-the-day-that-is-carried.md` carries that and two more.
|
||||
|
||||
⚠️ **A BROKEN CACHE IS NOT A BROKEN CONTROLLER.** No `/data`, an unreadable
|
||||
file, a schema this build does not know (including a `version` of `1.0`, which
|
||||
`== 1`), a **day record that is not a record**, a malformed point - each its own
|
||||
state with its own reason, none of them a price and none of them an empty
|
||||
series. In every one the plan is still a real provider series and the controller
|
||||
still regulates; the horizon is just as short as it was before this card.
|
||||
`.words` never raises, because `Controller.__init__` reads it unguarded and a
|
||||
status line that throws is an add-on that does not start.
|
||||
|
||||
⚠️ **AND WHERE THERE IS NO `/data`, NOTHING IS RECORDED AT ALL** - not even in
|
||||
memory. An in-memory yesterday answers for one run and is gone on the next
|
||||
restart, which is a cache that forgets without saying so; the honest pair is
|
||||
`unavailable` on the status row and no prices carried.
|
||||
|
||||
⚠️ **"YESTERDAY" IS A LOCAL DATE, AND THE ARITHMETIC IS ON THE DATE.**
|
||||
`brussels()` renders at a fixed offset, so subtracting `timedelta(days=1)` from
|
||||
it subtracts 24 **absolute** hours - and the Brussels day before a
|
||||
spring-forward is 23 hours long. Between 00:00 and 00:59 on the morning after
|
||||
the March transition that lands on the day *before* yesterday. **No option was
|
||||
added and `config.yaml` is untouched.**
|
||||
|
||||
## 0.9.0
|
||||
|
||||
⚠️ **MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
|
||||
@@ -46,8 +701,10 @@ threshold. Measured: a wedged inverter goes from named on 199 of 200 cycles to
|
||||
**0 of 200** at a load its sibling can carry alone. The alarm returns once the
|
||||
healthy pack saturates - swept, that is a 5000 W load on the fixture, **below**
|
||||
the proportional-regime formula's 6500 W and not above it. ⚠️ **N=1 IS
|
||||
UNAFFECTED**: `allocator.plan()` returns before weights are read at all. The
|
||||
closure is card SAFETY-43.
|
||||
UNAFFECTED**: `allocator.plan()` returns before weights are read at all.
|
||||
⚠️ **CLOSED BY SAFETY-43 ON THIS BRANCH** - see its section below; the regression
|
||||
above is history, and the record of it is kept because the measurement that
|
||||
found it is what the fix is graded against.
|
||||
|
||||
**PKG-09** adds `tools/predeploy_check.py`. ⚠️ **RUN IT BEFORE DEPLOYING THIS
|
||||
RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits
|
||||
@@ -96,6 +753,106 @@ what happens when nobody does.
|
||||
|---|---|
|
||||
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
|
||||
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
|
||||
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
|
||||
| _this branch_ | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression |
|
||||
|
||||
⚠️ **SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A
|
||||
TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL.** `delivery.delinquent()` named a unit
|
||||
whose shortfall exceeded an **absolute** `min_w = saturation_w = 500 W`. OPT-09's
|
||||
weights shrink a low-headroom unit's share, and shrinking the share shrinks the
|
||||
gap - so a unit standing at +125 W and delivering **0 W**, 100 % delinquent, was
|
||||
never named. It now names a unit whose gap exceeds `min_w` **OR** exceeds
|
||||
`DELIVERY_SHARE_FRACTION` (0.85) of that unit's **own share**. Measured on the
|
||||
same closed-loop fixture, same weights, same detector:
|
||||
|
||||
load 1500 W OPT-09 weights the old rule named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped one named on 200/200 cycles
|
||||
|
||||
⚠️ **`saturation_w` WAS NOT LOWERED, AND THAT IS THE POINT.** Lowering it would
|
||||
report normal saturation as delinquency on every unit at once - SAFETY-41's
|
||||
four-attempt false-trip regime, *"i'm getting really annoyed by the notifications
|
||||
in my HA"*. This ADDS a second way to be named: all seven of `test_delivery.py`
|
||||
part 1's existing fixtures answer identically under the old rule and the new one,
|
||||
and the absolute arm's suppression window is still exactly 1000 W / 6500 W.
|
||||
|
||||
**0.85 is derived, not chosen.** `admin/goodwe:sim/delivery_window.py --campaign
|
||||
--fractions` grades it over the SAME 2640 no-fault runs that produced
|
||||
`delivery_hold_s`'s floor. The worst continuous legitimate episode is **35.0 s**
|
||||
under the absolute arm alone (SAFETY-42 §3.3, reproduced in the same run) and
|
||||
**40.0 s** at 0.85. ⚠️ **THE CURVE HAS NO CLIFF AND THAT IS THE FINDING**: swept
|
||||
at twenty fractions from 0.05 to 0.99, everything from **0.45 to 0.99** is 40.0
|
||||
or 45.0 s - within one control cycle of every other - and below 0.45 it rises
|
||||
gradually to 90.0 s at 0.05. ⚠️ **"Zero false namings" is not achievable at any
|
||||
fraction and the record says so**; what is zero is false ALARMS - the longest
|
||||
legitimate episode in the whole sweep is 90.0 s against a 185 s hold, so nothing
|
||||
comes within 95 s of notifying anybody at any fraction measured, and 145 s at the
|
||||
shipped one. The N=1 control arm is 0.0 s at every fraction in all 2640
|
||||
one-inverter runs, and the flapping family is 0.0 s in all 840.
|
||||
|
||||
⚠️ **[[CFG-07]] GETS ONE CONTROL CYCLE WORSE AND IS NOT FIXED HERE.**
|
||||
`delivery_hold_s` is declared `int(30,900)` while the measured legitimate worst
|
||||
was 35.0 s; it is now 40.0 s, so the band of schema-legal false-tripping values
|
||||
widens from `[30, 35]` to `[30, 40]`. CFG-07 should be re-derived against 40.0 s.
|
||||
⚠️ **IT MUST CLOSE BEFORE INVERTER B IS COMMISSIONED (~2026-09-12)**, not before
|
||||
the next deploy: the band is UNREACHABLE at N=1 - `delinquent()` answers `{}` for
|
||||
every input at a roster of one, so no `delivery_hold_s` can false-trip on the
|
||||
install running today - and commissioning B is the moment it becomes reachable.
|
||||
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE** - it exists as a card
|
||||
reference here and in SAFETY-43's record and nowhere else, so whoever takes it
|
||||
writes `DECISIONS/CFG-07-*.md` first.
|
||||
**SAFETY-43 CHANGES NO OPTION VALUE, NO SCHEMA BOUND AND NO DEFAULT** - the
|
||||
fraction is a derived constant with a committed harness behind it, deliberately
|
||||
not a knob, for the reason there is no `delivery_w`. The one edit to
|
||||
`config.yaml` is a COMMENT: the `delivery_hold_s` block described the check as
|
||||
"short by more than `saturation_w`", which is now only half of it.
|
||||
|
||||
**No behaviour change at one inverter, asserted rather than argued**: `plan()` is
|
||||
handed a `weights` mapping that raises if anything reads it and still answers
|
||||
identically at a roster of one (and the same mapping raises at a roster of two,
|
||||
so the tripwire is real), and `delinquent()` answers `{}` on all 84 single-unit
|
||||
(share, measured, rule) combinations including 0 of 125 W.
|
||||
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` carries the curve, the
|
||||
positive controls and the CFG-07 assessment.
|
||||
|
||||
⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
|
||||
CLAIMED.** `delivery_hold_s: 185` shipped resting on *"30.0 s, the worst
|
||||
continuous legitimate episode at the shipped threshold across all 2640 runs"* -
|
||||
from a harness `DECISIONS/SAFETY-42-unit-delivery.md` §1 deliberately did **not
|
||||
commit**, because it imports `plantsim` and `frame_check` out of `admin/goodwe`.
|
||||
A shipped safety constant the next reviewer cannot re-run is PROC-01's shape.
|
||||
|
||||
The harness is committed now, **in `admin/goodwe` as `sim/delivery_window.py`**,
|
||||
beside `sitesim.py`. It is there and not here on purpose: those two modules are
|
||||
that repo's, and restating them in this tree is the CI-08 coupling that has
|
||||
broken three times. The dependency runs one way instead - this repo's `control`,
|
||||
`arbiter` and the shipped `delivery.delinquent()` are imported live out of
|
||||
whichever checkout is under test. Re-run it with:
|
||||
|
||||
```
|
||||
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
python3 -B delivery_window.py --selftest --campaign --blag
|
||||
```
|
||||
|
||||
⚠️ **AND 30.0 s DID NOT REPRODUCE - IT IS 35.0 s.** Reported as measured rather
|
||||
than adjusted to agree. What DID reproduce is the part the compatibility promise
|
||||
rests on: the **N=1 control arm is 0.0 s at every threshold in every run**, and
|
||||
the **flapping family is 0.0 s everywhere** in all 840 runs. What did not: the
|
||||
headline (one control cycle worse), the reversal family (20.0 → 30.0 s), the
|
||||
non-monotone curve (this rebuild's is monotone, so that finding is now an
|
||||
argument and not a measurement), and the low-threshold rows, badly. The B-lag
|
||||
rows moved the other way - a hypothetical unit four times slower than A gives
|
||||
**75.0 s**, not 90.0 s - and they are relabelled throughout as a **sensitivity
|
||||
study on a hypothesis about inverter B, which does not physically exist yet**,
|
||||
rather than as a measurement of it.
|
||||
|
||||
**`delivery_hold_s` is NOT changed.** 185 s is 5.29× the re-measured worst
|
||||
(was claimed 6.2×), 2.47× the four-times-slower case, 6.2× the firmware's
|
||||
`LINK_STALE_S` veto and 4.9× inside the billing quarter: all four bounds still
|
||||
clear and none narrowly. ⚠️ One consequence is worth an operator's attention:
|
||||
at 35.0 s the rig's floor is now **above** the schema's own `int(30,900)`
|
||||
minimum, so `delivery_hold_s: 30` is below a measured legitimate episode and
|
||||
would report on ordinary saturation. The option is marked *derived, do not
|
||||
change*; raising the schema floor is left to its own card.
|
||||
|
||||
⚠️ **UX-02 ADDS A SECOND PLACE TO CONFIGURE THIS ADD-ON, AND NO NEW WAY TO
|
||||
COMMAND THE INVERTER.** The ingress panel gains a Configuration page beside
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# FCST-04 — what "yesterday" is, and what the fill is allowed to reach
|
||||
|
||||
Four decisions, recorded because each had a defensible alternative that a later
|
||||
reader would otherwise re-open from scratch.
|
||||
|
||||
---
|
||||
|
||||
## 1. The fill runs on a `series` read only. A dead feed still goes to §11.4.
|
||||
|
||||
`fill_from_yesterday()` accepts a `no_data` read and will happily build a
|
||||
100 %-carried series from yesterday alone. `resolve_tariff()` does not let it:
|
||||
a feed that published nothing still falls to `_degrade()` and §11.4's own row —
|
||||
*"No price data → fall back to the static profile, mark the plan provisional,
|
||||
no arbitrage."*
|
||||
|
||||
**Why not prefer yesterday's real prices to a coarse day/night profile?**
|
||||
Because it buys nothing the optimiser can use, and it silently replaces a
|
||||
fallback the operator configured.
|
||||
|
||||
Measured, not assumed — and stated at the reach it actually has:
|
||||
|
||||
- **This one reaches control.** A fully-carried series is 100 % provisional, so
|
||||
`TariffPlan.arbitrage()` finds fewer than two firm hours and refuses on
|
||||
§8.3's firm-pair rung. That is the verdict the optimiser reads: `arbitrage.py`
|
||||
calls `plan.arbitrage(...)` on `fresh_tariff_plan()`, and it is the only path
|
||||
by which a provider plan moves energy. The §11.4 static fallback refuses on
|
||||
the same rung, for the same reason.
|
||||
- **This one does not, and saying so is the point.** `TariffPlan.cheap_now()`
|
||||
also returns False on a fully-carried series, because `firm_points()` is
|
||||
empty — but it has **no production caller on the provider plan**.
|
||||
`self.cheap` comes from `static_status()`, which builds its *own* static plan
|
||||
and calls `cheap_now()` on that. So it is a property of the type, checked in
|
||||
`test_static_tariff.py`, and it is not evidence about the shipped control
|
||||
path. The conclusion below holds on the first bullet alone.
|
||||
|
||||
The two paths therefore differ only in which numbers the status page shows.
|
||||
|
||||
So the choice is between two identical control behaviours, and the tie-breaker
|
||||
is that `static_profile` is a documented option an operator fills in **for this
|
||||
case**. Overriding it for a cosmetic difference is a behaviour change nobody
|
||||
asked for.
|
||||
|
||||
⚠️ **The consequence, stated so it is not discovered later as a bug:**
|
||||
`fill_from_yesterday()`'s `no_data` branch is not reached from production.
|
||||
It stays checked in `test_price_forecast.py`. Turning it on is a product
|
||||
decision — it would want a rule about how old a carried profile may be before it
|
||||
stops being offered at all — and not a wiring one.
|
||||
|
||||
---
|
||||
|
||||
## 2. "Yesterday" is a date, not the newest record.
|
||||
|
||||
`newest()` is the obvious accessor and it is the one that decays. An add-on down
|
||||
from Friday to Monday would answer Friday's prices to a fill whose only
|
||||
vocabulary is `provisional: true` — a flag that says "this is a guess", never
|
||||
"this guess is three days old". Nothing downstream can tell the difference and
|
||||
nothing reports it.
|
||||
|
||||
So `yesterday(stamp)` computes the Brussels date before `stamp`'s Brussels date
|
||||
and looks up **that** record. Anything else is `no_data` with a reason naming
|
||||
both the date wanted and the dates held.
|
||||
|
||||
The cost is real and accepted: after an outage of more than a day, the first
|
||||
morning back has no fill. That is the honest state — there is no yesterday —
|
||||
and the horizon is short for one morning rather than wrong for several.
|
||||
|
||||
---
|
||||
|
||||
## 3. What is recorded is the provider's read, and the mechanism is ordering.
|
||||
|
||||
Feeding the filled series back into the store is how this rots: day 2 carries
|
||||
day 1's guesses, day 3 carries day 2's carry of them, and after a week of a dead
|
||||
feed the "yesterday" being carried is a fossil of the last real publication.
|
||||
Every point still carries `provisional=True`, so the **flag never lies** — what
|
||||
decays is the number, behind a flag that is telling the truth.
|
||||
|
||||
⚠️ **No value-level check can catch that**, and claiming one would be worse than
|
||||
claiming none. A carried point and a published-provisional point (`eneco_eddy`'s
|
||||
current quarter, §9.2) are the same three fields carrying the same flag. The
|
||||
difference is provenance, and provenance is not in the value.
|
||||
|
||||
The defence is therefore an **ordering**, not a validation:
|
||||
|
||||
- `carry()` is the only public entry point. It records its argument, then
|
||||
returns the fill.
|
||||
- The filled series exists only as that return value. The object never sees it
|
||||
again.
|
||||
- `_remember()` is private and called from exactly one place.
|
||||
- `test_price_history.py` parses every shipped module under `app/`, `tools/`,
|
||||
`scenarios/` and `estop/` and fails if any of them **uses the name**
|
||||
`_remember` — an attribute, a bare name, a `def`, or the string constant a
|
||||
`getattr` would need. By name rather than by substring, so a comment that
|
||||
merely mentions the rule does not fail it and a call in `tools/` cannot pass
|
||||
unseen.
|
||||
- and it pins the order two ways: the **line numbers** of the two calls in
|
||||
`carry()`, and — the one that matters — the **behaviour**. A feed carrying
|
||||
yesterday alongside today, into an empty store, must fill the whole horizon;
|
||||
reversed, the lookup finds nothing and the plan is only the hours the feed
|
||||
published. Driven, so a reversal is red.
|
||||
|
||||
⚠️ **The first version of that ordering check graded nothing, and this file
|
||||
said it did.** It compared positions in `ast.walk`, which is breadth-first:
|
||||
`self._remember(...)` is a statement and `self.yesterday(...)` is an *argument*
|
||||
of `fill_from_yesterday(...)`, one level deeper, so `_remember` came out first
|
||||
whichever statement was written first. Measured — shipped and reversed both
|
||||
scored the same. A decorative check that a document cites as a guarantee is
|
||||
worse than no check, which is why the correction is recorded here rather than
|
||||
quietly made.
|
||||
|
||||
That ordering has a second benefit worth keeping: a feed that returns yesterday
|
||||
alongside today refreshes yesterday's record from live data *before* it is
|
||||
consumed as the profile.
|
||||
|
||||
---
|
||||
|
||||
## What this ticket does not claim
|
||||
|
||||
It does not make a trade possible that yesterday's prices justify. Carried hours
|
||||
are dropped from the trade set by §8.3, and that is not a limitation to be fixed
|
||||
later — it is the rule.
|
||||
|
||||
The one change that **does** move energy came from the windowing, not the
|
||||
carrying: the filled series covers `[since, until)`, so hours that have already
|
||||
elapsed are no longer in the spread. A cheap night that has gone used to be the
|
||||
series' cheapest hour, and a cycle cannot buy in an hour that is past — so the
|
||||
trade was silently never taken. `test_price_history.py` section 2 grades that by
|
||||
finding the cheapest firm hour with and without the fill and showing it moves.
|
||||
|
||||
⚠️ **And section 1's identity check now grades identity.** It used to compare
|
||||
`eur_per_kwh` between `_before` and `_after`, which come from two *separate*
|
||||
`resolve_tariff()` calls — each parsing the feed body into its own objects, so
|
||||
`is` could never have held between them however the fill were written. FCST-03's
|
||||
whole anti-copy argument is `filled.points[i] is fetched.points[j]`, and floats
|
||||
cannot grade it. The value comparison is kept and now says what it is (the fill
|
||||
moved the window, it did not touch a price); identity is graded on a read the
|
||||
file constructs and still holds a reference to.
|
||||
|
||||
---
|
||||
|
||||
## 4. A sub-hourly feed passes through whole; the fill only decides which hours are missing.
|
||||
|
||||
`eneco_eddy` is the Belgian imbalance product and settles **per quarter**, and
|
||||
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing — so a
|
||||
published hour may legitimately hold four points.
|
||||
|
||||
FCST-04's first caller keyed the published set as `{_utc_hour(p.start): p for p
|
||||
in r.points}`. It reads perfectly ordinary and it keeps the **last** point of
|
||||
each hour. Measured on a 96-point quarter-hourly feed — all firm, fully
|
||||
published, nothing to carry: the plan reached the optimiser as **15 points**,
|
||||
the arbitrage spread fell from **0.143 to 0.014 EUR/kWh**, and the cheapest
|
||||
"hour" became a `:45` point whose `at()` window runs into the next hour. That is
|
||||
not the windowing effect above; it is a feed losing 84 % of its own prints, and
|
||||
no `provisional` flag would ever have reported it.
|
||||
|
||||
**The alternative was rejected:** refusing sub-hourly feeds at `_validate_raw()`
|
||||
would have made the count correct by breaking the one supported product that
|
||||
legitimately publishes per quarter.
|
||||
|
||||
**What was done instead** keeps FCST-03's existing contract rather than weakening
|
||||
it: *every* published point inside the window passes through **by identity**,
|
||||
and a carried point is added only for a whole hour with **no** published
|
||||
coverage at all. A carried hour is still a single hourly point whatever the
|
||||
feed's own spacing is, because yesterday's profile is only known per wall-clock
|
||||
hour — and carried hours are provisional, so they are out of the trade set
|
||||
anyway.
|
||||
|
||||
⚠️ **`_audit()` got stricter, not looser, and that was the condition on doing
|
||||
this at all.** The old post-condition refused any repeated `_utc_hour` — a count
|
||||
that cannot express four points in one hour. It is replaced by two rules that
|
||||
**subsume** it:
|
||||
|
||||
- a **published** point must *be* one of that hour's published points, by
|
||||
identity, and may be emitted only once;
|
||||
- a **carried** point may stand only in an hour that does not appear in the
|
||||
published set at all, must be flagged `True`, and may not repeat.
|
||||
|
||||
So a duplicated carried hour — the DST mispricing the count existed to catch —
|
||||
still raises; so does the same published point emitted twice; and a carried
|
||||
point sharing an hour with a real print now raises too, which the count never
|
||||
looked at. Each of those three is driven by its own mutation in
|
||||
`test_price_forecast.py`.
|
||||
@@ -0,0 +1,125 @@
|
||||
# FCST-06 — "cannot persist" is one question, and it is asked about the outcome
|
||||
|
||||
Two decisions. The first is the one the ticket put up for judgement; the second
|
||||
is the one that decides what a store answers *after* it has failed to write.
|
||||
|
||||
---
|
||||
|
||||
## 1. One derived predicate, not two places testing two symptoms — yes
|
||||
|
||||
`price_history.py`'s module docstring states, unconditionally:
|
||||
|
||||
> ⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. …
|
||||
> A store that cannot persist now records nothing, and both of its answers say
|
||||
> the same thing.
|
||||
|
||||
It was enforced against `not self.ok`, and `ok` is **about the directory**: it
|
||||
says `/data` was there when the store opened. A read-only remount and a full
|
||||
volume both leave it `True`. So the second way to be a store that cannot
|
||||
persist — `ok=True`, the directory present, `_save()` failing — was outside the
|
||||
one condition the rule named, and in that state `words` printed two full days
|
||||
beside the write error while `yesterday()` answered 24 real points, from memory,
|
||||
until a restart dropped them without a word.
|
||||
|
||||
**The ticket's question was whether "cannot persist" should be one derived
|
||||
predicate rather than two places each testing a different symptom. It should,
|
||||
and the reason is not tidiness — it is that the enumeration cannot be
|
||||
completed.** FCST-04's M4 enforced the rule on the symptom that was in front of
|
||||
it. FCST-05's HIGH was the same shape — an `except` clause naming the one
|
||||
content-failure member somebody had met (`json.JSONDecodeError`) instead of the
|
||||
`ValueError` family — and its fix was to name the family. This is the third instance in
|
||||
one module, and it is the last place in this file where the rule can be spelt
|
||||
twice. A fourth cause — an `OSError` subclass, a filesystem that accepts the
|
||||
`mkstemp` and refuses the `os.replace`, a container whose volume is unmounted
|
||||
mid-refresh — needs no fourth branch now, because none of them is asked about.
|
||||
|
||||
### Where the one question can be asked, which is the whole design
|
||||
|
||||
**"Can this store persist?" cannot be answered before the attempt.** A
|
||||
directory that exists and was writable a moment ago is not a write that
|
||||
completed; there is no pre-check that is not itself a guess, and adding one
|
||||
(`os.access`, a probe write) would be a *third* symptom test — precisely what
|
||||
the ticket rules out. So the predicate is an **outcome**:
|
||||
|
||||
_save() -> bool # True only if the file now holds what memory holds
|
||||
|
||||
Both ways of not persisting are the same answer from the same call: no durable
|
||||
volume returns `False` without touching the disk, an `OSError` out of the write
|
||||
returns `False` after recording why. `_remember()` reads that one answer and
|
||||
keeps only what it says was kept. **The `not self.ok` test in `_remember()` is
|
||||
gone** — not moved, gone — because there is nothing left for a second test to
|
||||
add, and a check in `test_price_history.py` 5d asserts its absence from the
|
||||
source.
|
||||
|
||||
⚠️ **That last check is deliberately a source check, and habit 4 is why it is
|
||||
allowed to be.** The claim "there are not two places testing two symptoms" is a
|
||||
claim about the *shape of the code*, not about a behaviour — the behaviour is
|
||||
identical whether the rule is enforced once or twice. Every other check in 5d
|
||||
is driven.
|
||||
|
||||
### What was NOT done, and why
|
||||
|
||||
- **Not a `can_persist()` helper.** It would have to answer before the attempt,
|
||||
which is the thing that cannot be done honestly, and it would be a second
|
||||
place to keep in step with the first.
|
||||
- **Not a probe write at `_open()`.** It would make `ok` mean something new, it
|
||||
would write to `/data` on every start, and a volume that is writable at 08:00
|
||||
and full at 14:00 would still defeat it. A predicate that can go stale is a
|
||||
symptom test wearing a predicate's name.
|
||||
- **Not making a failed write fatal.** §6, and this module's own opening: a
|
||||
broken cache must not be a broken controller. An add-on that will not start
|
||||
because `/data` filled up is a worse defect than the one being fixed. The
|
||||
store degrades; it does not raise. `_save()` reports and returns.
|
||||
|
||||
---
|
||||
|
||||
## 2. The MERGE is undone, not the store
|
||||
|
||||
When `_save()` answers no, `_remember()` restores the state the read started
|
||||
from — **not** an empty one.
|
||||
|
||||
The alternative considered was to clear `self._days` outright, on the argument
|
||||
that a store which cannot write is a store that should stop answering. It is
|
||||
wrong, and measurably so: the state being restored is the last one that **is on
|
||||
disk**, so answering it is not a promise the store cannot keep. A site whose
|
||||
volume fills at 14:00 goes on answering the yesterday it wrote at 13:30 —
|
||||
through the restart, because that record is in the file. What it stops doing is
|
||||
*adding* to it.
|
||||
|
||||
Clearing would throw away a good yesterday that survives a restart in order to
|
||||
punish a write that failed, and it would make the morning fill worse on exactly
|
||||
the day the operator is already dealing with a full disk.
|
||||
|
||||
That gives the file the invariant its rule was always describing, stated once
|
||||
in the class docstring:
|
||||
|
||||
> `self._days` **never holds a point the file does not.**
|
||||
|
||||
⚠️ Not *"mirrors the file"* — `_usable_days()` drops records the file still
|
||||
holds, and that asymmetry is deliberate. The only direction that can **invent a
|
||||
yesterday** is the one named.
|
||||
|
||||
⚠️ **And the snapshot is `copy.deepcopy`, not `dict(self._days)`.** A one-level
|
||||
copy shares the day *records*, and the merge rebinds `points` on the ones it
|
||||
touches — so the "restored" store would answer the merge that never reached the
|
||||
file. Measured: with a shallow copy, a store holding 12 persisted points
|
||||
answered **24** after a failed merge. It would also be correct only for as long
|
||||
as nobody makes the merge mutate a `points` list or a row in place, which is the
|
||||
same "correct for the conditions someone thought of" this ticket is about. The
|
||||
store holds at most `KEEP_DAYS + 2` records and writes 48 times a day; a copy
|
||||
that is right by construction is worth more than the allocations.
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
- **No shipped path reaches the defect.** `/data` is the Supervisor's durable
|
||||
volume and this module is its only writer, so on a real install `_save()`
|
||||
succeeds and nothing above is on the hot path. The fix is that a guarantee the
|
||||
module states about itself now holds in the condition it did not.
|
||||
- **The failure was already visible and still is** — `_LOG.error("could not
|
||||
persist price history: %s")` and `last_error`, reported by both `words` and
|
||||
`yesterday()`'s reason. What changed is that it is now *acted on*.
|
||||
- **Nothing about the prices.** The series served is the feed's own, every
|
||||
carried point is still flagged provisional, and a broken cache is still a
|
||||
short horizon rather than a §11.4 degradation.
|
||||
@@ -161,17 +161,18 @@ its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after
|
||||
this card that ask does not arrive in the first place, which removes the benign
|
||||
arm from the detector's input rather than teaching it a new discriminator.
|
||||
|
||||
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWS SAFETY-42's DETECTION WINDOW. THIS
|
||||
IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE.** An earlier draft of
|
||||
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW.
|
||||
THAT WAS A DETECTION REGRESSION AT N=2, IT WAS NOT FIXED HERE, AND SAFETY-43
|
||||
CLOSED IT - see the block at the end of this section.** An earlier draft of
|
||||
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
|
||||
at all". The second clause was **false**, and it was load-bearing for the claim
|
||||
that the two cards do not overlap. A weight is a function of the pack's ENERGY
|
||||
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
|
||||
its headroom differs from its sibling's - which is the ordinary case this card
|
||||
exists to exploit. And `delivery.delinquent()` (`app/delivery.py:484`)
|
||||
names a unit only while its `short_by()` gap exceeds `min_w =
|
||||
Tuning.saturation_w` - the comparison is `app/delivery.py:531`, and it is
|
||||
**500 W in absolute watts**. Shrinking a unit's share shrinks its gap. Shrink the
|
||||
exists to exploit. And `delivery.delinquent()`
|
||||
named a unit only while its `short_by()` gap exceeded `min_w =
|
||||
Tuning.saturation_w`, and that was **500 W in absolute watts** with nothing
|
||||
else beside it. Shrinking a unit's share shrinks its gap. Shrink the
|
||||
share far enough and the gap drops below 500 W while the unit is still delivering
|
||||
nothing, and the alarm stops firing.
|
||||
|
||||
@@ -200,20 +201,47 @@ a 9600 W site; above it - once the healthy pack is at its own rating and
|
||||
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
|
||||
returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
`test_packshare.py` part 8b grades all of it and **pins the current,
|
||||
unflattering behaviour**, so that the day it changes a named check moves. The
|
||||
closure - `delinquent()` measured against the ask rather than a flat 500 W - is
|
||||
card **SAFETY-43**.
|
||||
`test_packshare.py` part 8b graded all of it and **pinned the unflattering
|
||||
behaviour**, so that the day it changed a named check would move. It has moved.
|
||||
|
||||
So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's
|
||||
detection window for a low-headroom unit.** The two cards do not merely meet from
|
||||
opposite sides; this one takes something away from the other one. Closing the gap
|
||||
means making `delinquent()` relative to the ask rather than absolute, which is
|
||||
SAFETY-42's code and a design change - it is filed as its own card, deliberately
|
||||
not done here, and this paragraph is its input. Until then this is a **disclosed
|
||||
residual**, in the same voice as SAFETY-13's "2 of 272" and SAFETY-42's own "the
|
||||
discriminator does not exist": at two inverters, a wedged unit with materially
|
||||
less energy headroom than its sibling can go unreported at ordinary house loads.
|
||||
### ⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL
|
||||
|
||||
This section used to end *"until then this is a disclosed residual [...] at two
|
||||
inverters, a wedged unit with materially less energy headroom than its sibling
|
||||
can go unreported at ordinary house loads"*. That sentence is **retired, not
|
||||
softened**, and what replaces it is a measurement rather than an intention.
|
||||
|
||||
`delivery.delinquent()` is now relative to the ask **as well as** absolute:
|
||||
|
||||
named if short_by() > min_w OR short_by() > frac * share
|
||||
|
||||
with `frac = delivery.DELIVERY_SHARE_FRACTION`. A unit delivering 0 of 125 W is
|
||||
100 % delinquent and is named; a unit delivering 0 of 1500 W was already named
|
||||
and still is. On the **same fixture, under the same weights**, part 8b now
|
||||
grades both rules in one run - `share_frac=None` is SAFETY-42's absolute arm,
|
||||
exactly:
|
||||
|
||||
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
|
||||
|
||||
⚠️ **`min_w` WAS NOT LOWERED, AND THAT IS THE HALF THAT MATTERS HERE.** The
|
||||
absolute arm's suppression window is still exactly the arithmetic above - 1000 W
|
||||
at equal weights, 6500 W at these - and part 8b still asserts both numbers.
|
||||
Lowering `saturation_w` would have traded a missed alarm for a false one on
|
||||
every unit at once, which is SAFETY-41's four-attempt regime. SAFETY-43 added a
|
||||
**second** way to be named and left the first where it was.
|
||||
|
||||
⚠️ **AND WHAT OPT-09 STILL DOES NOT DO IS UNCHANGED.** This card stops the site
|
||||
ASKING a pack whose own SoC says it cannot deliver; it does not re-allocate a
|
||||
wedged unit's watts, and SAFETY-43 did not make it. The alarm got louder; the
|
||||
loop did not move. The derivation of `frac` - swept over the same 2640 no-fault
|
||||
runs that produced `delivery_hold_s`'s floor - is in
|
||||
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md`.
|
||||
|
||||
So the seam, stated accurately **as of SAFETY-43**: OPT-09 stops the ask, and it
|
||||
still narrows the ABSOLUTE arm's window for a low-headroom unit exactly as
|
||||
described above - but it no longer narrows the DETECTOR, because the detector no
|
||||
longer reads only that arm.
|
||||
|
||||
⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without
|
||||
connecting it.** That module's rejection of re-allocation rests on *"the only
|
||||
|
||||
@@ -149,8 +149,70 @@ each swept across the **whole range `config.yaml` accepts** for the two settings
|
||||
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
|
||||
`slew_w` `int(50,5000)`.
|
||||
|
||||
### ⚠️ THE HARNESS BEHIND EVERY FIGURE ABOVE IS NOT COMMITTED, AND UNTIL 2026-09-07 THAT MADE THEM UNCHECKABLE
|
||||
|
||||
**Read this before quoting any number in this section.** The paragraph below
|
||||
said the campaign harness "is not committed here" and treated that as a
|
||||
packaging detail. It is not: a figure produced by a script that exists in
|
||||
nobody's tree cannot be re-run, cannot be reviewed, and cannot be defended when
|
||||
it is contradicted. It has now been contradicted twice.
|
||||
|
||||
| sweep | worst peak | worst continuous | trips |
|
||||
|---|---:|---:|---:|
|
||||
| the 924-run campaign below — **harness uncommitted** | 1700 W | 505.0 s at 100 W; 5.0 s above 700 W | 0 / 924 |
|
||||
| independent reviewer sweep, 2026-09-07, 48 honest reversals | **2750 W** | **15.0 s above 700 W** | 0 / 48 |
|
||||
| `admin/goodwe` `two_master_circulation`, 840 runs — **committed** | 1048 W | 10.0 s at 100 W; 5.0 s above 700 W | **0 / 840** |
|
||||
|
||||
**This is not proof that anything was fabricated, and it should not be reported
|
||||
as one.** The three swept different grids. What the third one adds is a
|
||||
measurement of *why* the spread is that wide — on one grid, changing nothing but
|
||||
the model:
|
||||
|
||||
| plant / stagger | worst legitimate peak |
|
||||
|---|---:|
|
||||
| modelled plant (lag 10 s, ramp 800 W/s), staggers up to a whole cycle | 1048 W |
|
||||
| `INSTANT` (lag 0, unbounded ramp), staggers up to a whole cycle | 2250 W |
|
||||
| `INSTANT`, staggers strictly under a whole cycle | **0 W** |
|
||||
|
||||
A factor of 2.1×, and a factor of infinity, between three defensible
|
||||
descriptions of the same rig. **The plant model and the write-stagger set are
|
||||
terms of the number**, and a worst case quoted without them is not a
|
||||
measurement. That is enough to explain the whole spread above without anybody
|
||||
having invented anything — and it is exactly what cannot be established when the
|
||||
instrument is missing.
|
||||
|
||||
**What survives, and what does not.**
|
||||
|
||||
* **The window argument survives, and is now committed.** The claim that
|
||||
decides `circulation_hold_s: 75` is *"the worst continuous legitimate episode
|
||||
above 700 W is about one control cycle"*. All three sweeps agree on the shape
|
||||
of that (5.0 s, 15.0 s, 5.0 s — one to three cycles against a fifteen-cycle
|
||||
window), and **all three trip zero times**. `two_master_circulation` re-runs
|
||||
it on every pull request in `admin/goodwe`.
|
||||
* **The threshold curve does NOT survive as stated.** `505.0 s at 100 W` is the
|
||||
number the "knee at 400 W" argument for `circulation_w: 700` rests on, and the
|
||||
committed sweep measures **10.0 s** at 100 W on its grid — 50× smaller. The
|
||||
curve still *falls* with the threshold (126 of 840 runs cross 100 W against 1
|
||||
that crosses 700 W), so the knee is real in direction; its depth is not
|
||||
established by anything anybody can run. **Do not quote 505.0 s.**
|
||||
* **Nothing about the guard is compromised.** 0 trips in 924 + 48 + 840
|
||||
non-faulty runs, from three independent grids.
|
||||
|
||||
**What would settle it:** the original harness, committed. Until then the
|
||||
committed sweep is the only figure in this section anybody can check, and it is
|
||||
labelled as its own grid's rather than as a reproduction.
|
||||
|
||||
### Reproducing it
|
||||
|
||||
⚠️ **THE COMMITTED VERSION IS `admin/goodwe`'s `sim/inject.py
|
||||
two_master_circulation`** (`sim/sitesim.py:circulation_findings`, runnable on its
|
||||
own as `python3 sim/sitesim.py --circulation`, `norig`, ~36 s, graded by
|
||||
`checks.yml` on every pull request there). It produces the fault with
|
||||
`plantsim.Inverter.wedge()` — a unit answering its bus and no longer steering its
|
||||
power stage, which is HW-01 — and sweeps the legitimate family with the
|
||||
serialised write path staggered up to a whole control cycle. The paragraph below
|
||||
describes the ORIGINAL campaign, which is the one that is not committed.
|
||||
|
||||
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
|
||||
is not committed here because it imports that repo's `plantsim`/`frame_check`,
|
||||
which are not present in this one or in CI. What it does is small enough to
|
||||
@@ -206,7 +268,11 @@ margin above the knee is what absorbs that without re-opening this card.
|
||||
**Revisit when SPIKE-04 lands, not before.**
|
||||
|
||||
The **schema floor is the knee itself**, 400 W, so a legally configured install
|
||||
cannot land in the 445–505 s regime. What the threshold costs: a circulating
|
||||
cannot land in the 445–505 s regime — ⚠️ and those two figures are from the
|
||||
uncommitted campaign; the committed sweep measures 10.0 s at 100 W on its own
|
||||
grid. See the disclosure at the head of this section. The floor is still the
|
||||
right shape (fewer runs cross a higher threshold, measured 126/840 at 100 W
|
||||
against 1/840 at 700 W); its DEPTH is not established by anything re-runnable. What the threshold costs: a circulating
|
||||
loop smaller than 350 W per unit is not reported, which is deliberate — at that
|
||||
scale the round-trip loss is a few watts.
|
||||
|
||||
@@ -289,6 +355,32 @@ SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded;
|
||||
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
|
||||
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
|
||||
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
|
||||
| **M20** | `main.py` | **`pos, neg = opposing(...)` → `([], [])`** — the attribution clause deleted from the alarm | −102 B | **SURVIVED ALL 26 FILES on the shipped battery — GAP CLOSED**, then RED on §6a |
|
||||
| M21 | `main.py` | `pos` and `neg` exchanged — the alarm names both packs, both the wrong way round | **+0 B** | **RED** — §6a ×3 (the mirrored run, and "only its own sentence") |
|
||||
| M22 | `main.py` | `opposing(..., min_w=cfg.min_w / 2.0)` → `min_w=0.0` | −25 B | **RED** — §6a's resting-pack check |
|
||||
|
||||
**And four in `admin/goodwe`**, graded through `python3 sim/sitesim.py
|
||||
--circulation` rather than through the injector — deliberately, because
|
||||
`inject.py` is the file that takes the rig lock, and "run the injector to grade a
|
||||
mutation" is not a sentence anybody should have to type. Byte deltas over
|
||||
`plantsim.py` + `sitesim.py` together.
|
||||
|
||||
| # | file | mutation | Δ | result |
|
||||
|---|---|---|---|---|
|
||||
| S1 | `plantsim.py` | `wedge()` no longer stops the latch reaching the power stage | −20 B | **RED** — the opposing-signs precondition **and** "the watchdog did not trip" |
|
||||
| S2 | `sitesim.py` | the sensor computed over the **allocation record** instead of register 0x0518 | **+0 B** | **RED** ×3 — this is M10 restated on the rig |
|
||||
| S3 | `sitesim.py` | the campaign run on `plant=INSTANT` | −51 B | **RED** — the plant is a *term* of the figures; it was a SURVIVOR until that check was added, and finding out why produced §3's 1048/2250/0 W table |
|
||||
| S4 | `sitesim.py` | the write stagger removed from the legitimate campaign | −19 B | **RED** — "the legitimate campaign produced no opposition at all" |
|
||||
|
||||
**S3 is recorded as a survivor because it was one, and it is the most useful
|
||||
result in this table.** The scenario first shipped with a vacuity check whose
|
||||
text claimed `plant=INSTANT` would make the sweep measure 0 W. S3 was run and
|
||||
**passed**: `INSTANT` with a stagger of exactly one control cycle samples the
|
||||
plant before the second unit has stepped, so it produces opposition anyway — and
|
||||
a *larger* worst peak (2250 W) than the modelled plant does (1048 W). The claim
|
||||
in the check's own text was wrong. It was corrected against the measurement, the
|
||||
plant was pinned as an explicit term, and S3 is red now for a stated reason
|
||||
rather than by accident. §3's spread table is that measurement.
|
||||
|
||||
**M19 is recorded as a survivor because it was one.** The first battery found no
|
||||
check grading what `publish()` does when a per-unit reading is missing, so
|
||||
@@ -297,6 +389,30 @@ watches went unnoticed — the sensor's own `None` rule, defeated one layer out.
|
||||
A check was added and the mutation re-run; it is red now. The run that found it
|
||||
was purged, so it is a result and not an artefact.
|
||||
|
||||
**M20 IS RECORDED AS A SURVIVOR BECAUSE IT WAS ONE, AND IT IS THE MORE
|
||||
UNCOMFORTABLE OF THE TWO.** The commit message for this card claimed *"18
|
||||
mutations, all RED"*, and M1–M19 above have no entry for the notification's
|
||||
attribution clause. Verification on 2026-09-07 ran it and it **survived the
|
||||
whole 26-file suite**: the alarm still fires, still stops the site, still says
|
||||
the meter cannot see it — and no longer says *which two packs* or *which way
|
||||
round*. `circulation.py`'s own docstring calls that sentence the first thing
|
||||
somebody woken by this alarm needs and *"impossible to reconstruct from the
|
||||
number alone"*.
|
||||
|
||||
It survived for one reason, and it is not reachability: **M18 (the whole call
|
||||
removed) is red**, so `check_circulating()` is exercised. The three checks on
|
||||
that notification asserted `METER CANNOT SEE`, `CONTROL HAS BEEN STOPPED` and
|
||||
`isolator` — and **never an inverter name**. Nothing graded the attribution.
|
||||
|
||||
`test_watchdog.py` §6a closes it, and closes it on a fixture that can tell the
|
||||
three failures apart. It runs a roster of **`["garage", "shed", "cellar"]`** —
|
||||
operator names, none of them `UNIT_A`, for test_maintenance.py §11's reason:
|
||||
on a fixture whose names are the module's own constants a check cannot tell the
|
||||
roster from the constant. It runs the site **twice, mirrored**, so the sentence
|
||||
is not symmetric and a swap (M21, `+0 B`) is a different red from a deletion.
|
||||
And it carries a **third pack resting at 5 W**, so the floor that stops a pack
|
||||
in standby being named as a participant in a 6 kW loop (M22) is graded too.
|
||||
|
||||
## 7. What this card does NOT close
|
||||
|
||||
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
|
||||
@@ -305,7 +421,9 @@ was purged, so it is a result and not an artefact.
|
||||
margin. **Do not present any figure here as a measurement of inverter B.**
|
||||
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
|
||||
notification says so; there is no software fix and this card does not pretend
|
||||
to one.
|
||||
to one. It *can* now be produced on demand: `plantsim.Inverter.wedge()`.
|
||||
* **The depth of the threshold curve is not established by anything committed.**
|
||||
§3's disclosure. The window is; the knee's magnitude is not.
|
||||
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
|
||||
* **The saturation freeze fires under a wedged unit and leaves a standing grid
|
||||
error.** Observed on the rig while producing §2's numbers: with B wedged, the
|
||||
|
||||
@@ -68,22 +68,102 @@ deliberately *not* in the model). **Every two-inverter figure in this document
|
||||
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
|
||||
margin that costs.
|
||||
|
||||
### Reproducing it
|
||||
### Reproducing it — ⚠️ THE HARNESS IS COMMITTED NOW (TEST-21, 2026-09-07)
|
||||
|
||||
⚠️ **THIS SECTION USED TO SAY THE HARNESS WAS NOT COMMITTED, AND THAT WAS THE
|
||||
DEFECT TEST-21 FOUND.** A shipped safety constant whose floor the next reviewer
|
||||
cannot re-run is PROC-01's shape, and PROC-01 had eleven instances in three
|
||||
days. The paragraph this replaces was true — what the harness does *is* small
|
||||
enough to restate — and it was not enough: a restatement in prose leaves every
|
||||
axis it does not name to the next person's judgement, and §3.3 below now records
|
||||
how far that moves the number. **It moved. See §3.3's second table.**
|
||||
|
||||
It is committed in the **other** repository, `admin/goodwe`, as
|
||||
`sim/delivery_window.py`, beside `sitesim.py` — the container-free two-master
|
||||
harness it is built on. From clean checkouts of both repos:
|
||||
|
||||
```
|
||||
cd goodwe/sim
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --selftest
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --campaign --blag
|
||||
```
|
||||
|
||||
stdlib only. No container, no HA, no esphome, **no rig and no rig lock** — it is
|
||||
a `plantsim` campaign, not a rig job. About fifteen minutes for the whole thing.
|
||||
|
||||
⚠️ **AND IT IS IN `admin/goodwe` RATHER THAN HERE, WHICH IS CI-08's RULE
|
||||
APPLIED RATHER THAN IGNORED.** It imports `plantsim` (the plant, the per-unit
|
||||
wire, `Site`) and `frame_check` (the firmware's own encoder), both **owned by
|
||||
that repo** and neither present in this one's CI image. Committed here it had
|
||||
two possible fates and both are the coupling CI-08 has been broken by three
|
||||
times: skipped for want of its imports — a green that establishes nothing — or
|
||||
the plant and the encoder **restated in this tree**, which is a second copy of
|
||||
something the other repo owns, free to drift, and drift in a fixture is
|
||||
invisible until the number it defends is wrong. There the dependency runs one
|
||||
way and stays one-directional: **this** repo's `control`, `arbiter` and
|
||||
`delivery` are imported live out of whichever checkout is under test
|
||||
(`$GOODWE_ADDON_REPO`), never copied — so a change to `delinquent()` moves the
|
||||
number on the next run instead of silently disagreeing with a fixture. That is
|
||||
the seam `sitesim.py` already stands on, which is the other reason the campaign
|
||||
is its neighbour and not a third arrangement.
|
||||
|
||||
⚠️ **THE SELFTEST IS CI-GATED; THE CAMPAIGN IS NOT, AND THE SPLIT IS
|
||||
DELIBERATE.** `admin/goodwe`'s `.gitea/workflows/checks.yml` gained one step —
|
||||
`python3 -B delivery_window.py --selftest`, seconds, beside the existing
|
||||
`norig, no lock` TEST-19 and TEST-20 steps, in the workflow that **already**
|
||||
checks out this repo for `two_writers` and accepts that boundary in writing. So
|
||||
a change **here** that moved `saturation_w`, `max_w`, `delivery_hold_s` or the
|
||||
shape of `delinquent()` turns that step red rather than leaving a stale number
|
||||
in this record. The **campaign** (`--campaign --blag`, ~15 minutes) is not
|
||||
gated: it is a campaign, not a gate, and `failure-injection.yml`'s jobs run on
|
||||
the **rig** runner, which a container-free simulation has no business holding.
|
||||
What TEST-21 asked for is a command a reviewer can run from a clean checkout —
|
||||
that is the block above.
|
||||
|
||||
⚠️ **`--selftest` IS A FALSIFIABILITY ARM, NOT A SMOKE TEST, AND IT RUNS
|
||||
FIRST.** A campaign whose N=1 column and whose flapping column are 0.0 s
|
||||
everywhere is indistinguishable from a campaign whose detector was never wired
|
||||
up — `sitesim.broadcast_onset` exists for the same reason. So the selftest
|
||||
drives a genuinely **wedged** unit through the same loop and refuses to proceed
|
||||
unless the shipped `delinquent()` names it for longer than the shipped hold
|
||||
(it names it for 275.0 s). Every 0.0 s in §3.3 is only worth reading because
|
||||
that arm passes.
|
||||
|
||||
What it does, unchanged from the restatement it replaces: build two
|
||||
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`; close the loop with the
|
||||
add-on's own `compute()` on the SITE sum (`Controller.site_batt_w`) and
|
||||
`arbiter.allocate_record()` over per-unit envelopes of `±min(max_w, rating)`;
|
||||
put each share on its own wire through `Inverter.command()`, honouring a
|
||||
configurable **write stagger** so B's number can land up to four cycles after
|
||||
A's (the serialised write path); advance `Site.settle()` one control cycle; and
|
||||
record, per unit, the command standing *before* the write and that unit's 0x0518
|
||||
power. Then grade each cycle with the **shipped** `delivery.delinquent()` and
|
||||
take the **longest run of consecutive cycles** on which it names anybody — not a
|
||||
count of naming samples, because the window is cleared by any cycle that does
|
||||
not.
|
||||
|
||||
⚠️ **SINCE [[SAFETY-43]] THE ROWS OF §3.3's TABLE ARE GRADED WITH
|
||||
`share_frac=None`** — `delinquent()`'s ABSOLUTE arm alone, which is this card's
|
||||
rule exactly. Said here because it is the difference between §3.3 still meaning
|
||||
what it is labelled as and quietly becoming a table about a different predicate:
|
||||
SAFETY-43 added a SECOND way to be named, the harness grades it in its own rows
|
||||
(`--fractions`), and the numbers below are unchanged and were re-measured to
|
||||
confirm it. `DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` §3 carries
|
||||
those rows and what they cost — the worst legitimate episode goes from 35.0 s to
|
||||
40.0 s at the shipped fraction, which does not move `delivery_hold_s`.
|
||||
|
||||
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
|
||||
command standing before the write" is the **controller's** standing command —
|
||||
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
|
||||
wire. A write that has been issued but has not landed yet is standing as far as
|
||||
this add-on is concerned, which is the whole of the write-stagger axis. Grading
|
||||
against the plant's latch instead reads a number no add-on has, and it makes
|
||||
both the stagger *and a wedged unit* invisible: a unit whose writes never arrive
|
||||
shows a standing command of 0 W, is never `asked`, and is never named. That is
|
||||
not a conservative harness, it is a harness that cannot fail — and the rebuild
|
||||
did exactly this on its first run, which is how the falsifiability arm earned
|
||||
its place above.
|
||||
|
||||
The harness is **not committed here**, for the reason SAFETY-30's and
|
||||
SAFETY-31's were not: it imports `plantsim` and `frame_check`, which live in the
|
||||
other repository and are not present in this one's CI image. What it does is
|
||||
small enough to restate: build two `plantsim.Inverter`s from `INVERTER_A` /
|
||||
`INVERTER_B`; close the loop with the add-on's own `compute()` on the SITE sum
|
||||
(`Controller.site_batt_w`) and `arbiter.allocate_record()` over per-unit
|
||||
envelopes of `±min(max_w, rating)`; put each share on its own wire through
|
||||
`Inverter.command()`, honouring a configurable **write stagger** so B's number
|
||||
can land up to four cycles after A's (the serialised write path); advance
|
||||
`Site.settle()` one control cycle; and record, per unit, the command standing
|
||||
*before* the write and that unit's 0x0518 power. Then grade each cycle with the
|
||||
**shipped** `delivery.delinquent()` and take the **longest run of consecutive
|
||||
cycles** on which it names anybody — not a count of naming samples, because the
|
||||
window is cleared by any cycle that does not.
|
||||
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
|
||||
trees. Run it with `python3 -B` and purge.
|
||||
|
||||
@@ -213,7 +293,9 @@ Every run is executed a second time at a **roster of one** on the same stimulus.
|
||||
check"* from *"a property of the second inverter"*.
|
||||
|
||||
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
|
||||
threshold:
|
||||
threshold. **ORIGINAL CAMPAIGN, 2026-09-06, from the harness that was not
|
||||
committed — SUPERSEDED, and kept here because a record that quietly replaced its
|
||||
own numbers would be worth less than one that shows both:**
|
||||
|
||||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
@@ -225,7 +307,100 @@ threshold:
|
||||
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
|
||||
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
|
||||
|
||||
Three findings worth stating rather than leaving in the table:
|
||||
#### ⚠️ TEST-21, 2026-09-07 — THE RE-RUN, AND 30.0 s DID NOT REPRODUCE
|
||||
|
||||
`admin/goodwe:sim/delivery_window.py --campaign`, same three families, same
|
||||
counts, same thresholds, the shipped `delinquent()`:
|
||||
|
||||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640 runs)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 100 W | 120.0 s (770) | 0.0 s (0) | 165.0 s (685) | **0.0 s (0)** |
|
||||
| 250 W | 65.0 s (687) | 0.0 s (0) | 100.0 s (590) | **0.0 s (0)** |
|
||||
| **500 W** | **30.0 s (460)** | **0.0 s (0)** | **35.0 s (403)** | **0.0 s (0)** |
|
||||
| 700 W | 25.0 s (342) | 0.0 s (0) | 30.0 s (318) | **0.0 s (0)** |
|
||||
| 1000 W | 15.0 s (4) | 0.0 s (0) | 25.0 s (131) | **0.0 s (0)** |
|
||||
| 1500 W | 0.0 s (0) | 0.0 s (0) | 20.0 s (43) | **0.0 s (0)** |
|
||||
| 2000 W | 0.0 s (0) | 0.0 s (0) | 0.0 s (0) | **0.0 s (0)** |
|
||||
|
||||
**The floor is 35.0 s, not 30.0 s** — weighted family, and the worst single run
|
||||
is `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W reversal. The
|
||||
argmax is printed with the table on purpose (see below): a worst case sitting on
|
||||
an edge of the grid would be a number about the grid.
|
||||
|
||||
⚠️ **AND BY THAT STANDARD, TWO OF ITS FOUR AXES ARE ON AN EDGE. Measured, at the
|
||||
argmax's other axes held fixed:**
|
||||
|
||||
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
|
||||
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
|
||||
0.10 -> 35.0 s
|
||||
|
||||
Strictly increasing in stagger and maximal at 4, the largest modelled stagger;
|
||||
and maximal at the most asymmetric split. It is interior only in `gain` (2nd of
|
||||
6) and `slew_w` (3rd of 5).
|
||||
|
||||
⚠️ **SO THE HEADLINE IS 0.0 s AT stagger=0, AND 35.0 s IS ENTIRELY A PRODUCT OF
|
||||
THE MODELLED SERIALISED-WRITE STAGGER.** That stagger is declared MODELLED at
|
||||
`sim/delivery_window.py:60-64` — it is a term of this figure, not a detail of
|
||||
it. A site whose two writes land in the same cycle does not produce this episode
|
||||
at all.
|
||||
|
||||
This does not move `delivery_hold_s`: 185 s must cover the worst case the
|
||||
modelled site can produce, and a stagger of four cycles is inside what a real
|
||||
two-inverter write path can do. But a reader entitled to ask "is 35.0 s about
|
||||
the plant or about the grid?" deserves the answer, and the answer is: **partly
|
||||
about the grid, and the grid's shape is the record's own, fixed by §3.3 before
|
||||
this rebuild ran.** Found in review of TEST-21; the PR body had claimed the
|
||||
argmax was interior, and it is not.
|
||||
|
||||
**WHAT REPRODUCED, EXACTLY:**
|
||||
|
||||
* the **N=1 control column is 0.0 s at every threshold**, in every run. That is
|
||||
§3.2's algebra measured, and it is the claim the compatibility promise rests
|
||||
on. (This rebuild ran the control arm over all three families — 2640 runs —
|
||||
where the original ran 1890, i.e. the two families whose stimulus is not
|
||||
weighted. The weighted stimulus at a roster of one is the same stimulus with
|
||||
inert weights, and it too reports nothing at any threshold.)
|
||||
* the **flapping family is 0.0 s at every threshold**, in all 840 runs. The
|
||||
proper-subset rule really does defeat the family that defeated SAFETY-30's
|
||||
site window.
|
||||
* **45.0 s at twice A's lag**, to the cycle. See the B-lag table below.
|
||||
|
||||
**WHAT DID NOT:**
|
||||
|
||||
* the headline. **35.0 s against a recorded 30.0 s** — one control cycle worse,
|
||||
and 20.0 s → 30.0 s on the reversal family, two cycles worse.
|
||||
* **the non-monotonicity.** §3.3 finding 3 read the original 55.0 s at 250 W
|
||||
against 40.0 s at 100 W as a property of the reduction. **This rebuild's
|
||||
curve is monotone decreasing on both N=2 families.** The *mechanism* finding 3
|
||||
describes is real and is still the reason not to read either table as a
|
||||
sensitivity curve — lowering the threshold really can turn a proper subset
|
||||
into the whole roster and break the run — but **this grid does not exhibit
|
||||
it**, so the claim now rests on the mechanism alone and no longer on a
|
||||
measurement. That is a weaker claim than the one this record made, and it is
|
||||
written down as weaker.
|
||||
* the low-threshold rows, badly: 120.0 s / 165.0 s at 100 W against 40.0 s /
|
||||
75.0 s. Nothing ships at 100 W, but the size of that gap is the honest measure
|
||||
of how much of the original table was a property of axes nobody wrote down.
|
||||
|
||||
⚠️ **WHY THE NUMBERS MOVED, AS FAR AS IT CAN BE ESTABLISHED.** §3.3 fixes the
|
||||
*counts* of every axis (6 gains × 5 slews; 7 reversal sizes; 7 flap periods × 4
|
||||
amplitudes; 5 staggers; and the 5 splits, which it lists) and the *ranges*
|
||||
(`config.yaml`'s `gain` `float(0.05,1.0)` and `slew_w` `int(0,5000)`). It does
|
||||
**not** fix the individual values, the run length, or the per-unit cap. The
|
||||
rebuild spread them over the admissible ranges and **prints every one of them
|
||||
above its own table**, so the caveat travels with the number instead of living
|
||||
in somebody's memory. The residual disagreement — one to two control cycles at
|
||||
the shipped threshold — is inside the sensitivity to that choice; the
|
||||
low-threshold rows are not, and are reported as not.
|
||||
|
||||
⚠️ **AND THE HARNESS WAS NOT ADJUSTED UNTIL IT AGREED.** The one substantive
|
||||
correction made during the rebuild was made *against* the doc's number, not
|
||||
towards it, and it is recorded in §1: grading on the plant's latch rather than
|
||||
the controller's standing command produced a harness that could not name even a
|
||||
wedged unit. Fixing that made the numbers **larger**. Nothing else was tuned.
|
||||
|
||||
Findings 1-3 below are the original campaign's, kept with finding 3 now
|
||||
qualified by the re-run above:
|
||||
|
||||
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
|
||||
threshold.** That is the compatibility promise measured, not argued.
|
||||
@@ -268,37 +443,77 @@ would be a preference, and all the margin this check needs is in the window.
|
||||
|
||||
Four bounds, and the value clears all four.
|
||||
|
||||
* **lower, from the rig — 30.0 s.** The worst continuous legitimate episode at
|
||||
the shipped threshold across all 2640 runs (weighted family; 20.0 s on
|
||||
reversals, 0.0 s on flapping).
|
||||
* **lower, from the rig — 35.0 s** (TEST-21, 2026-09-07; **the original record
|
||||
said 30.0 s and it did not reproduce**). The worst continuous legitimate
|
||||
episode at the shipped threshold across all 2640 runs — weighted family, worst
|
||||
single run `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W
|
||||
reversal; 30.0 s on reversals, 0.0 s on flapping. Re-run it with
|
||||
`admin/goodwe:sim/delivery_window.py --campaign` (§1).
|
||||
⚠️ **THIS IS NOW STRICTLY THE BINDING LOWER BOUND, WHICH IT WAS NOT BEFORE.**
|
||||
At 30.0 s it coincided with the firmware's veto below and a reader could not
|
||||
tell which of the two was doing the work. At 35.0 s the rig's number is on its
|
||||
own, above the firmware's — so **the rig is what stops `delivery_hold_s` going
|
||||
low**, and the schema's `int(30,900)` floor no longer sits above the worst
|
||||
legitimate episode. An operator who sets it to the schema minimum of 30 s is
|
||||
now **below** a measured legitimate episode and will get false reports; the
|
||||
option is marked *derived, do not change* and this is what that marking is
|
||||
for. Raising the schema floor to 35 is a change to a shipped option's
|
||||
contract and is left to its own card rather than smuggled in here.
|
||||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
|
||||
executing its share for less than the firmware's own veto window is a *link*
|
||||
fault the add-on already answers by dropping it from the split. Reporting
|
||||
inside that window would be a second, louder alarm about a state already
|
||||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||||
* **margin, from inverter B not existing — and it is MEASURED, not felt.** The
|
||||
campaign's B is A copied, so 30.0 s is two identical machines. Sweeping a
|
||||
*different* second unit through the weighted family (750 runs each):
|
||||
* **margin, from inverter B not existing.** ⚠️ **THIS ROW IS A SENSITIVITY
|
||||
STUDY ON A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, AND THE ORIGINAL
|
||||
WORDING ("MEASURED, not felt") OVERSTATED IT.** Inverter B is not installed
|
||||
(~2026-09-12; NOTES.md §1.1b). The campaign's B is **A copied**, `measured=
|
||||
False`, so the 35.0 s above is **two identical machines** and every row below
|
||||
is a *what-if*: what a slower second unit would cost this window. **No row
|
||||
here is B's lag.** What it is genuinely evidence for is the SHAPE of the
|
||||
margin — which term the window has to be sized against — and that is what it
|
||||
is used for. Sweeping a *different* second unit through the weighted family
|
||||
(750 runs each). Second column is the original record, third is TEST-21's
|
||||
re-run with the committed harness (`--blag`):
|
||||
|
||||
| B lag | B ramp | worst legitimate | runs naming |
|
||||
|---:|---:|---:|---:|
|
||||
| 10.0 s (A) | 800 W/s (A) | **30.0 s** | 368/750 |
|
||||
| 20.0 s | 800 W/s | 45.0 s | 469/750 |
|
||||
| 10.0 s | 400 W/s | **30.0 s** | 370/750 |
|
||||
| 20.0 s | 400 W/s | 45.0 s | 469/750 |
|
||||
| 40.0 s | 200 W/s | **90.0 s** | 500/750 |
|
||||
| B lag | B ramp | 2026-09-06 (uncommitted) | **TEST-21 re-run** | runs naming |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 10.0 s (A) | 800 W/s (A) | 30.0 s | **35.0 s** | 403/750 |
|
||||
| 20.0 s | 800 W/s | 45.0 s | **45.0 s** | 454/750 |
|
||||
| 10.0 s | 400 W/s | 30.0 s | **35.0 s** | 411/750 |
|
||||
| 20.0 s | 400 W/s | 45.0 s | **50.0 s** | 454/750 |
|
||||
| 40.0 s | 200 W/s | 90.0 s | **75.0 s** | 478/750 |
|
||||
|
||||
**The RAMP does not move it at all and the LAG moves it roughly linearly.**
|
||||
The shortfall lasts as long as the unit's first-order lag holds it more than
|
||||
`saturation_w` below its share; at these step sizes the ramp is never the
|
||||
binding term. So the number to carry margin against is B's lag, and a unit
|
||||
**four times slower than A** produces 90.0 s.
|
||||
**The LAG moves it roughly linearly and the RAMP barely moves it**, which is
|
||||
the original finding and it survives — with one correction: *"the RAMP does
|
||||
not move it at all"* is too strong. Halving the ramp is worth 0 s at A's lag
|
||||
and **+5 s at twice it** (45.0 → 50.0), so the ramp is a second-order term
|
||||
rather than no term. The shortfall lasts as long as the unit's first-order lag
|
||||
holds it more than `saturation_w` below its share; at these step sizes the
|
||||
ramp is rarely the binding one. So the number to carry margin against is still
|
||||
B's lag — and a hypothetical unit **four times slower than A** produces
|
||||
**75.0 s**, where the original record said 90.0 s. ⚠️ Note the direction: this
|
||||
row moved **in the margin's favour**, while the floor above moved against it.
|
||||
Neither was adjusted to suit the other.
|
||||
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
|
||||
|
||||
**185 s** is thirty-seven control cycles: **6.2×** the measured worst, **2.06×**
|
||||
the four-times-slower case, **6.2×** the firmware veto and **4.9×** inside the
|
||||
quarter. ⚠️ Deliberately **not 180** — that is SAFETY-02's site hold, and a
|
||||
reader must not be able to think this was carried across — and not 150 or 200.
|
||||
**185 s** is thirty-seven control cycles. Against the TEST-21 re-run it is
|
||||
**5.29×** the measured worst legitimate episode (35.0 s), **2.47×** the
|
||||
hypothetical four-times-slower second unit (75.0 s), **6.2×** the firmware veto
|
||||
(30 s) and **4.9×** inside the quarter (900 s). ⚠️ Deliberately **not 180** —
|
||||
that is SAFETY-02's site hold, and a reader must not be able to think this was
|
||||
carried across — and not 150 or 200.
|
||||
|
||||
⚠️ **185 STANDS, AND HERE IS THE ARITHMETIC RATHER THAN THE ASSURANCE.** The
|
||||
record claimed 6.2× the measured worst; the re-run makes that **5.29×**. All
|
||||
four bounds still clear, none of them narrowly, and the *ordering* of the
|
||||
argument is unchanged: the rig's floor is the binding one, the quarter is the
|
||||
ceiling, and 185 sits between them with margin at both ends. So the value is
|
||||
**not changed** — a shipped safety constant is not moved because its margin
|
||||
went from 6.2× to 5.3× while every bound still holds, and moving it would
|
||||
invalidate SAFETY-41's four-attempt false-trip regime for nothing. What IS
|
||||
changed is the claim: this record no longer says 6.2×, and no longer says 30.0
|
||||
s, because neither is what the harness produces.
|
||||
|
||||
**What it costs, stated rather than hidden:** up to 185 s of a site running
|
||||
short by one unit's share before anybody is told. Against a fault that ran
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
# SAFETY-43 — delinquency measured against the ask, not against a flat 500 W
|
||||
|
||||
**Status: closed.** `delivery.delinquent()` names a unit whose `short_by()` gap
|
||||
exceeds `min_w` **or** exceeds a fraction of that unit's own share. The fraction
|
||||
is `delivery.DELIVERY_SHARE_FRACTION = 0.85`, derived on the campaign in §3.
|
||||
|
||||
⚠️ **INVERTER B DOES NOT PHYSICALLY EXIST** (expected ~2026-09-12; NOTES.md
|
||||
§1.1b). `plantsim.INVERTER_B` is inverter A **copied**, `measured=False`, and
|
||||
`test_packshare.py`'s plant is modelled in that file. **Every two-inverter
|
||||
figure in this document is two copies of A.** Nothing here is a measurement of
|
||||
inverter B.
|
||||
|
||||
## 0. The defect, and why it is the quiet regime that matters
|
||||
|
||||
[[OPT-09]] passes SoC- and capacity-aware weights to the allocator. A weight is
|
||||
a function of a pack's **energy headroom**, not of what the pack delivers, so a
|
||||
**wedged** unit — one that accepts its share and produces nothing — is weighted
|
||||
down exactly like a legitimately low-headroom one. `delinquent()` read an
|
||||
**absolute** gap, `min_w = Tuning.saturation_w = 500 W`. Shrinking the share
|
||||
shrinks the gap. Measured end to end on the real loop (`test_packshare.py`
|
||||
part 8b: real `compute()`, real `plan()`, real `packshare.weights()`, real
|
||||
`delivery.account()`, real `delinquent()`; B wedged at a healthy 15 % against its
|
||||
own 10 % cut-out, so no envelope closes and `account()` explains nothing — a
|
||||
SAFETY-42 **true positive** by that card's own definition):
|
||||
|
||||
load 1500 W equal weights shares {a:1500, b:1500} named on 199/200
|
||||
load 1500 W OPT-09 weights shares {a:1505, b: 125} named on 0/200
|
||||
|
||||
A unit delivering **0 of 125 W** is 100 % delinquent and 125 W short. Only the
|
||||
absolute threshold disagreed.
|
||||
|
||||
⚠️ **THE SUPPRESSED REGIME IS THE DANGEROUS ONE.** At a load the healthy sibling
|
||||
covers, the meter is satisfied (grid within 5 W of zero on that fixture),
|
||||
`unmet_w` is 0 because both units *accepted* their shares, no envelope closes and
|
||||
no action-log line fires. The delinquency alarm is the **only** instrument that
|
||||
would have told the operator the site was quietly running one inverter.
|
||||
|
||||
## 1. What was NOT done, and it is the larger half of this card
|
||||
|
||||
⚠️ **`min_w` WAS NOT LOWERED.** It is `Tuning.saturation_w` — the gap at which
|
||||
the control law itself concludes the inverter is not following — and it exists to
|
||||
stop **normal saturation** being reported as delinquency. Lowering it trades a
|
||||
missed alarm for a false one on every unit at once. [[SAFETY-41]] spent **four
|
||||
attempts** on notification cadence in this system and the owner's words were
|
||||
*"i'm getting really annoyed by the notifications in my HA"*.
|
||||
|
||||
So the change is an **`or`**, never a looser first arm:
|
||||
|
||||
named if short_by() > min_w OR short_by() > frac * share
|
||||
|
||||
Everything `min_w` named before is still named. Nothing `min_w` refused becomes
|
||||
noisy **unless the unit is delivering almost none of its ask**, which is not
|
||||
saturation-shaped at all. `test_delivery.py` part 1 runs all seven of its
|
||||
existing `delinquent()` fixtures under **both** rules — `share_frac=None` is
|
||||
SAFETY-42's rule exactly — and requires identical answers on every one.
|
||||
|
||||
The absolute arm's suppression window is therefore **unchanged**: still
|
||||
`site target < 2·min_w` = 1000 W at equal weights, still
|
||||
`site target < min_w·(w_A+w_B)/w_B` = 6500 W under OPT-09's weights on part 8b's
|
||||
fixture. Part 8b still asserts both numbers, now as evidence that the first arm
|
||||
did not move.
|
||||
|
||||
## 2. The evidence is a diff between two rules on ONE run
|
||||
|
||||
`delinquent()` takes `share_frac`, and **`None` means the absolute arm alone**.
|
||||
That parameter exists for one reason: so the red and the green come out of one
|
||||
run over one tree instead of out of two commits that a reader has to trust.
|
||||
`test_packshare.py` part 8b grades every cycle twice:
|
||||
|
||||
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
|
||||
|
||||
load 1500 W equal weights absolute arm alone named on 199/200 cycles
|
||||
load 1500 W equal weights the shipped rule named on 200/200 cycles
|
||||
|
||||
The equal-weights arm gains **exactly one cycle** — the first, where the slew
|
||||
limit holds the split at 500/500, a 500 W gap is not *over* a 500 W threshold
|
||||
(the absolute bound is strict) and the relative arm names a box delivering none
|
||||
of its 500 W. That single cycle is the whole of what this card costs an arm that
|
||||
was already firing.
|
||||
|
||||
⚠️ **THE PIN THAT INVERTED.** Part 8b was written by OPT-09 to pin the
|
||||
**suppressed** behaviour, and its comment said: *"if it ever goes red because the
|
||||
alarm fired, read the card that made it fire before 'fixing' this line."* This is
|
||||
that card. The pin now asserts the opposite answer and keeps the old one beside
|
||||
it, measured rather than remembered.
|
||||
|
||||
## 3. ⭐ Deriving the fraction
|
||||
|
||||
⚠️ **DERIVED ON THE SAME CAMPAIGN AS `delivery_hold_s`'s FLOOR**, not chosen.
|
||||
`admin/goodwe:sim/delivery_window.py` — the harness [[TEST-21]] committed —
|
||||
gained a second grading axis: the same **2640 no-fault runs at N=2** (three
|
||||
families × 6 gains × 5 slews × write staggers; every unit executes the share the
|
||||
allocator gave it) and the same **2640 N=1 control runs**, graded at the shipped
|
||||
`min_w = 500 W` for each candidate fraction, on every cycle of every run — so a
|
||||
pass grades every fraction it is given, and the absolute arm beside them, over
|
||||
one tree and one set of runs. Every row in a pass is comparable to every other
|
||||
row in it, run for run.
|
||||
|
||||
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
python3 -B delivery_window.py --selftest --campaign --fractions
|
||||
|
||||
⚠️ **THE TABLE BELOW WAS PRODUCED BY TWO PASSES; `FRACTIONS` NOW HOLDS ALL
|
||||
TWENTY SO IT TAKES ONE.** The derivation ran the shipped ten
|
||||
`(0.25 … 0.99)` and then the ten that resolve the low end
|
||||
`(0.05 … 0.65)` with only that tuple edited between them. **The absolute-arm
|
||||
control row came out identical in both** (30.0 / 0.0 / 35.0 / 0.0, same argmax),
|
||||
which is what says the two passes are one campaign. A table a reviewer cannot
|
||||
reproduce without knowing to edit a line nobody wrote down is PROC-01's shape in
|
||||
miniature, so the committed `FRACTIONS` was widened to all twenty in review: the
|
||||
command above now reproduces every row below.
|
||||
|
||||
### 3.1 The curve
|
||||
|
||||
Worst **continuous** legitimate episode (the measure `DeliveryWatchdog` actually
|
||||
sees — its window is cleared by any cycle that does not name), runs naming in
|
||||
brackets:
|
||||
|
||||
| `share_frac` | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| absolute only | 30.0 s (460) | 0.0 s (0) | 35.0 s (403) | **0.0 s (0)** |
|
||||
| 0.05 | 85.0 s (826) | 0.0 s (0) | 90.0 s (616) | **0.0 s (0)** |
|
||||
| 0.10 | 75.0 s (824) | 0.0 s (0) | 90.0 s (607) | **0.0 s (0)** |
|
||||
| 0.15 | 70.0 s (836) | 0.0 s (0) | 70.0 s (609) | **0.0 s (0)** |
|
||||
| 0.20 | 70.0 s (839) | 0.0 s (0) | 60.0 s (629) | **0.0 s (0)** |
|
||||
| 0.25 | 55.0 s (832) | 0.0 s (0) | 60.0 s (611) | **0.0 s (0)** |
|
||||
| 0.30 | 55.0 s (840) | 0.0 s (0) | 50.0 s (614) | **0.0 s (0)** |
|
||||
| 0.35 | 55.0 s (840) | 0.0 s (0) | 55.0 s (618) | **0.0 s (0)** |
|
||||
| 0.40 | 50.0 s (840) | 0.0 s (0) | 60.0 s (658) | **0.0 s (0)** |
|
||||
| 0.45 | 45.0 s (840) | 0.0 s (0) | 45.0 s (627) | **0.0 s (0)** |
|
||||
| 0.50 | 45.0 s (840) | 0.0 s (0) | 45.0 s (636) | **0.0 s (0)** |
|
||||
| 0.55 | 40.0 s (840) | 0.0 s (0) | 40.0 s (636) | **0.0 s (0)** |
|
||||
| 0.60 | 40.0 s (840) | 0.0 s (0) | 40.0 s (645) | **0.0 s (0)** |
|
||||
| 0.65 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.70 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.75 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.80 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| **0.85** | **35.0 s (840)** | **0.0 s (0)** | **40.0 s (635)** | **0.0 s (0)** |
|
||||
| 0.90 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
| 0.95 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
| 0.99 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
|
||||
Worst of the three families, which is what `delivery_hold_s` has to cover:
|
||||
|
||||
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
|
||||
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
|
||||
|
||||
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
|
||||
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
|
||||
|
||||
⚠️ **THE FIRST ROW IS THE CONTROL AND IT REPRODUCED SAFETY-42 §3.3 EXACTLY** —
|
||||
30.0 s / 0.0 s / 35.0 s, and the same argmax (`gain=0.2 slew_w=1000 stagger=4
|
||||
split=0.10/0.90`, ±2200 W reversal). It is measured **in the same run** as every
|
||||
row below it, which is what makes those rows a comparison rather than a claim.
|
||||
|
||||
### 3.2 What the curve says
|
||||
|
||||
* ⚠️ **THERE IS NO CLIFF ANYWHERE IN THE CURVE, AND THAT IS THE FINDING.** From
|
||||
**0.45 to 0.99** every figure is 40.0 or 45.0 s — within **one control cycle
|
||||
(5.0 s)** of every other — and below 0.45 it rises **gradually**: 60.0 s at
|
||||
0.25, 70.0 s at 0.15, 90.0 s at 0.05. A reviewer asking "why 0.85 and not
|
||||
0.90?" is asking about a difference this campaign cannot resolve, and the
|
||||
honest answer is that it cannot.
|
||||
* ⚠️ **IT IS NOT MONOTONE** — 0.40 reports 60.0 s where 0.35 reports 55.0, and
|
||||
0.65–0.80 report 45.0 s where 0.55/0.60 and 0.85–0.99 report 40.0 — and that is
|
||||
SAFETY-42 §3.3's own mechanism (c) rather than noise in the plant: **a more
|
||||
sensitive rule puts MORE units into the short set, which can turn a proper
|
||||
subset into the WHOLE roster and END the episode.** The reduction is not
|
||||
monotone in its own threshold. Do not read this column as a sensitivity curve
|
||||
in the ordinary sense.
|
||||
* **The shoulder is at about 0.45**, and it is a shoulder rather than an edge:
|
||||
the reversal family's naming count saturates at 840 of 1050 runs from 0.30
|
||||
upward, so below the shoulder what grows is the LENGTH of an episode, not the
|
||||
number of runs that have one.
|
||||
* **The N=1 column is 0.0 s at every fraction, in all 2640 one-inverter runs.**
|
||||
The compatibility promise is measured for the new arm, not argued.
|
||||
* **The flapping family is 0.0 s at every fraction, in all 840 runs.** A common
|
||||
stimulus makes both units short at once and the subset stops being proper —
|
||||
the property that defeated SAFETY-30's site window still holds.
|
||||
|
||||
### 3.3 ⚠️ "Zero false namings" is not available at ANY fraction
|
||||
|
||||
The card asked for *the largest fraction that yields zero false namings across
|
||||
the whole no-fault campaign*. **There is none.** Every fraction adds naming
|
||||
cycles: the reversal family goes from 460 runs naming to 840, the weighted family
|
||||
from 403 to 635. A relative arm that never fired on a legitimate transient would
|
||||
be a relative arm that never fired at all — the write stagger lands B's number up
|
||||
to four cycles late, and a unit whose new command has not arrived is, for those
|
||||
cycles, delivering a small fraction of what is standing on its wire.
|
||||
|
||||
What **is** zero at every fraction is **false alarms**:
|
||||
|
||||
> the longest continuous legitimate episode anywhere in the 20-point sweep is
|
||||
> **90.0 s** — at 0.05, the most sensitive fraction measured — against a
|
||||
> `delivery_hold_s` of **185 s**. Nothing in 2640 no-fault runs comes within
|
||||
> **95 s** of notifying an operator, **at any fraction measured**. At the
|
||||
> shipped 0.85 it is 40.0 s, i.e. 145 s of slack.
|
||||
|
||||
That is the quantity SAFETY-41 cares about and it is the one this card had to
|
||||
protect. Stated as margin, at the shipped fraction: **185 s is 4.62×** the worst
|
||||
legitimate episode, where the absolute arm alone gave 5.29×.
|
||||
|
||||
### 3.4 The bounds 0.85 answers to
|
||||
|
||||
* **LOWER, from the campaign** — the shoulder at about **0.45**, below which the
|
||||
added episode starts to grow (60.0 s at 0.25, 90.0 s at 0.05). 0.85 is 0.40
|
||||
above it.
|
||||
* **UPPER, from the card's own case** — strictly **below 1.0**. At 1.0 the arm
|
||||
can only fire on a unit that is *importing* while asked to discharge, which is
|
||||
SAFETY-30's fault and not this one, so a unit delivering **none** of its share
|
||||
(gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
|
||||
wedged box reporting a small standby figure instead of a clean zero.
|
||||
* **UPPER, from the hold** — every fraction in the flat range leaves
|
||||
`delivery_hold_s` between **4.11×** (45.0 s) and **4.62×** (40.0 s) the worst
|
||||
legitimate episode, well inside the four bounds SAFETY-42 §3.3 requires of it.
|
||||
**`delivery_hold_s` IS NOT CHANGED BY THIS CARD.**
|
||||
* ⚠️ **NOT DERIVED, AND SAID SO: the exact value inside [0.45, 1.0).** The
|
||||
campaign cannot separate them. 0.85 is the midpoint of the argument above —
|
||||
0.40 of margin above the shoulder, 0.15 below the ceiling — and a
|
||||
re-derivation that moved it to 0.90 would be moving a number this campaign
|
||||
never resolved. It is not moved for that reason.
|
||||
|
||||
## 4. The positive control — what the relative arm must still NOT say
|
||||
|
||||
A card that adds a way to raise an alarm has to show what it did **not** add.
|
||||
`test_packshare.py` part 8b runs the same fixture, the same weights and the same
|
||||
125 W share with the one fact that makes the shortfall legitimate changed:
|
||||
|
||||
| fixture | B's share | named | why it is silent |
|
||||
|---|---|---|---|
|
||||
| B wedged at a healthy 15 % (the fault) | 77–154 W | **200 / 200** | — it is the fault |
|
||||
| B at its own 10 % cut-out | **0 W, all 200 cycles** | **0 / 200** | it is never **asked** |
|
||||
| B delivering **half** its share | 120 W | **0 / 200** | short by 50 %, inside both arms |
|
||||
|
||||
### ⚠️ The cut-out row does NOT grade `account()`, and this record said it did
|
||||
|
||||
**Corrected in review.** The first draft of this section claimed the wedged and
|
||||
cut-out arms had *"identical power traces — 0 W against a positive share, 100 %
|
||||
of the ask missing"* and that *"what separates them is `account()`"*. **Every
|
||||
part of that was false**, and the check that carried it was **vacuous**:
|
||||
|
||||
* at its own cut-out the pack's usable energy **above that cut-out is zero**, so
|
||||
`packshare.weights()` answers **0.0** for it, `_fill()`'s preferred pass gives
|
||||
it nothing, the healthy sibling covers the load alone so the last-resort pass
|
||||
never runs, and its share is **0 W on all 200 cycles**;
|
||||
* a unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns `{}`
|
||||
**before either arm is evaluated** and `account()` is **never consulted**;
|
||||
* the traces are not identical — the cut-out arm's share is **0 W on every
|
||||
cycle** (asserted in part 8b and printed on its line) while the wedged arm's
|
||||
is **125 W** at the last cycle (asserted in the GREEN check) and takes four
|
||||
distinct values 77/125/133/154 W across the 200, measured by re-running the
|
||||
same loop with per-cycle recording during review — and "100 % of the ask" is
|
||||
0/0.
|
||||
|
||||
Measured, by deleting the gate the claim rested on:
|
||||
|
||||
cut-out arm, account() live named 0/200, distinct B shares: [0]
|
||||
cut-out arm, account() DELETED named 0/200, distinct B shares: [0]
|
||||
wedged arm, account() live named 200/200, shares [77,125,133,154]
|
||||
|
||||
⚠️ **The fact that replaces it is better, not weaker.** At its own cut-out
|
||||
**OPT-09 stops asking the pack at all**, so SAFETY-43's relative arm is never
|
||||
reached — a **second, independent protection sitting upstream of `account()`**:
|
||||
the allocator declines to ask before the detector has to decline to alarm. Part
|
||||
8b now asserts the 0 W share and prints it, and runs the arm a second time with
|
||||
`account()` deleted to show which mechanism is doing the work.
|
||||
|
||||
⚠️ **It was invisible for one reason worth keeping**: that line was the only one
|
||||
in part 8b that did not print its shares. `inverter-b+0` would have been on
|
||||
screen from the first run. Every line in that part prints them now, and the
|
||||
derated control's vacuity guard now asserts **its own** unit is asked, rather
|
||||
than re-asserting a different run's 200/200 — which is exactly why the old guard
|
||||
could not see this.
|
||||
|
||||
**The account gate is graded where it is real**, on a positive 125 W share, in
|
||||
`test_delivery.py` part 1 (*"THE ACCOUNT GATE IS UNTOUCHED BY IT"* — the same
|
||||
0 of 125 W with the shortfall explained names nobody). That part also carries
|
||||
that the relative bound is **strict** (a gap of exactly `frac · share` is not
|
||||
over it) and that the proper-subset rule still refuses a site-wide cause (both
|
||||
units delivering none of a 125 W share names nobody).
|
||||
|
||||
## 5. N=1 is byte-identical, and it is asserted rather than argued
|
||||
|
||||
The live install is **N=1**. Two independent statements, each graded against the
|
||||
thing that would break it (`test_packshare.py` part 8b):
|
||||
|
||||
1. **`allocator.plan()` returns before `weights` is read at all.** Graded by
|
||||
handing it a `weights` mapping that **raises on any read**: at a roster of one
|
||||
it answers identically to the same call with no weights; at a roster of two
|
||||
the same mapping is read and raises, so the tripwire is a real one.
|
||||
2. **`delinquent()` answers `{}` at a roster of one for every input** — 84
|
||||
combinations of (share, measured, rule), including **0 of 125 W**, the exact
|
||||
input this card added an arm for. `len(asked) < 2` returns before either
|
||||
threshold is compared, so the fraction is never even multiplied.
|
||||
|
||||
Nothing in this card is reachable at one inverter. It must land before inverter B
|
||||
is commissioned (~2026-09-12), not before the next deploy.
|
||||
|
||||
## 5b. Mutation — the fraction is pinned from BOTH directions
|
||||
|
||||
⚠️ **RUN, NOT REASONED ABOUT.** Each mutation was applied to a tree **copied
|
||||
fresh** from this branch and mounted `:ro` inside the checks image, so no `.pyc`
|
||||
could go stale — MUTATION.md's defect, and all three of these are **reds**, the
|
||||
direction that defect cannot fabricate. Recorded in `test_packshare.py` part 11
|
||||
as M12–M14.
|
||||
|
||||
| mutation | named checks | where |
|
||||
|---|---:|---|
|
||||
| **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | **7** | `test_delivery.py` part 1 (3: the 0-of-125 W naming, the proportion in the sentence, the strict relative bound) + `test_packshare.py` 8b (4: GREEN back to 0/200, the equal arm's 200 back to 199, the 6000 W arm back to 195/199, the derated control's guard). `test_alloc.py` / `test_control.py` stay green — nothing outside this check moves. |
|
||||
| **M13** `DELIVERY_SHARE_FRACTION` → **1.0** — *too loose* | **7** | part 1 (2) + 8b (5), including the PRECONDITION that the fraction is strictly below 1.0. At 1.0 the card is a no-op: gap/share of exactly 1.0 is not *over* 1.0. |
|
||||
| **M14** `DELIVERY_SHARE_FRACTION` → **0.05** — *too sensitive* | **6** | part 1 (2: the strict bound, and *"IT TOOK NOTHING AWAY"* stops being true) + part **4b** (3: the `saturation_w` pin goes **vacuous**, the relative arm naming B at every threshold) + 8b's derated control, where a unit delivering half its share starts being named. |
|
||||
|
||||
M13 and M14 are the pair that matters: the constant cannot be moved in **either**
|
||||
direction without a named check moving with it.
|
||||
|
||||
### ⚠️ Two of these counts were wrong, and the reason is worth keeping
|
||||
|
||||
**Corrected in review.**
|
||||
|
||||
* **M12 and M13 read 1 and 1 in `test_delivery.py`, not 3 and 2.** Part 1 printed
|
||||
the alarm sentence with a bare subscript, `delinquent(_tiny, 500)["b"]`. On any
|
||||
tree where the relative arm is broken or absent that raises `KeyError` and
|
||||
**aborts the file at part 1**, leaving parts 2–8 ungraded — so the recorded
|
||||
coverage of a mutation run read *smaller* than the truth. The direction is safe
|
||||
(an abort is a red, and MUTATION.md's defect can only fabricate a green), but a
|
||||
figure that does not reproduce is a figure that does not reproduce. It is a
|
||||
`.get` now, with the reason written beside it, and the counts above are from
|
||||
re-runs with the guard in place.
|
||||
* **M14 was recorded as 4 named checks in `test_delivery.py` and it is 5** — part
|
||||
1's pre-existing *"a shortfall AT the threshold is not one"* also goes red. That
|
||||
one under-claimed, so it never inflated the evidence; it is still a recorded
|
||||
number that did not reproduce.
|
||||
|
||||
## 6. ⚠️ [[CFG-07]] is adjacent and is NOT absorbed here
|
||||
|
||||
CFG-07 is that `delivery_hold_s` is declared `int(30,900)` in `config.yaml`'s
|
||||
schema while the measured worst legitimate episode at the shipped threshold is
|
||||
**35.0 s** — so the schema offers an operator a value (30) that is **below a
|
||||
measured legitimate episode** and would report on ordinary saturation.
|
||||
|
||||
**This card makes it worse, and by exactly one control cycle.** The relative arm
|
||||
takes the worst legitimate episode from **35.0 s to 40.0 s** at the shipped
|
||||
fraction, so the band of schema-legal but false-tripping values widens from
|
||||
`[30, 35]` to `[30, 40]`. The floor the schema should have is now **40 s**, not
|
||||
35 s. It is not fixed here: raising a schema floor is an option-surface change
|
||||
with its own compatibility argument (an existing config carrying `35` would start
|
||||
being refused), and folding it into a safety fix would hide it. **CFG-07 should
|
||||
be re-derived against 40.0 s, not 35.0 s.**
|
||||
|
||||
⚠️ **WHEN IT HAS TO CLOSE: BEFORE INVERTER B IS COMMISSIONED (~2026-09-12).**
|
||||
Not before the next deploy. The band is **unreachable at N=1** — `delinquent()`
|
||||
returns `{}` for every input at a roster of one, so no value of
|
||||
`delivery_hold_s` can false-trip on the install that is running today — and the
|
||||
live install is N=1 until B exists. Commissioning B is the moment the band
|
||||
becomes reachable, and that is the deadline this card hands it.
|
||||
|
||||
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE.** It exists as a
|
||||
`[[CFG-07]]` mention in `CHANGELOG.md` and in this section, and nowhere else —
|
||||
so there is no file for the 40.0 s to be recorded in, and a reader who goes
|
||||
looking will find a card reference with no record behind it. Whoever takes
|
||||
CFG-07 writes `DECISIONS/CFG-07-*.md` first; the number it has to answer to is
|
||||
**40.0 s**, measured here, and the harness that produces it is
|
||||
`admin/goodwe:sim/delivery_window.py --campaign --fractions`.
|
||||
|
||||
## 7. Provenance of every number in this document
|
||||
|
||||
| number | where it came from |
|
||||
|---|---|
|
||||
| 199/200, 0/200, 200/200, shares 1505/125 | `test_packshare.py` part 8b, run |
|
||||
| 35.0 s absolute-arm floor, and its argmax | `delivery_window.py --campaign`, re-run here, reproduces SAFETY-42 §3.3 |
|
||||
| every row of §3.1 | `delivery_window.py --campaign --fractions`, two passes over the same campaign with `FRACTIONS` changed between them |
|
||||
| 1000 W / 6500 W suppression windows | part 8b's arithmetic, asserted there |
|
||||
| 185 s, 5.29× | SAFETY-42 §3.3 / TEST-21, unchanged by this card |
|
||||
| 4.62× | 185 / 40.0, printed by the campaign |
|
||||
| 0/200 on both positive controls, and B's 0 W share at the cut-out | part 8b, run and asserted |
|
||||
| 0 W cut-out share / 125 W wedged share | part 8b, asserted and printed |
|
||||
| 77/125/133/154 W (the wedged arm's four distinct shares) | the same loop re-run in review with per-cycle recording — NOT part 8b's printed line, which shows the last cycle only |
|
||||
| M12/M13/M14 named-check counts (7 / 7 / 6) | re-run in review on fresh `:ro` trees, after the `KeyError` abort in part 1 was guarded |
|
||||
| 84 single-unit rosters | part 8b, run |
|
||||
|
||||
⚠️ Every two-inverter figure above is **two copies of inverter A**.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,349 @@
|
||||
# TARIFF-14 — how long one price point covers, and what the last one does
|
||||
|
||||
One defect and one genuinely open decision. The defect had no defensible
|
||||
alternative once it was measured; the decision — the **last** point's window —
|
||||
did, and the alternative is argued below rather than dismissed.
|
||||
|
||||
---
|
||||
|
||||
## 0. The measurement this card exists for
|
||||
|
||||
`_Series.at()` asked `s <= u < s + 1 h` of every point **in list order** and
|
||||
returned the **first** match. Run against the shipped code at `5059e28` on a
|
||||
quarter-hourly series:
|
||||
|
||||
```
|
||||
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
|
||||
|
||||
ask + 0min -> 02:00 0.10
|
||||
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
|
||||
ask +30min -> 02:00 0.10 <-- WRONG
|
||||
ask +45min -> 02:00 0.10 <-- WRONG
|
||||
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
|
||||
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
|
||||
```
|
||||
|
||||
**Two failure modes, not one.** Inside the first hour the `:00` point wins
|
||||
because its hour has not run out. From the second hour on the answer is not
|
||||
even the top of the hour — the first point still inside its own hour wins — so
|
||||
**the error compounds with depth into the series**.
|
||||
|
||||
Same code, same run, after the fix:
|
||||
|
||||
```
|
||||
ask + 0min -> 02:00 0.10 ask +60min -> 03:00 0.14
|
||||
ask +15min -> 02:15 0.11 ask +75min -> 03:15 0.15
|
||||
ask +30min -> 02:30 0.12 ask +90min -> 03:30 0.16
|
||||
ask +45min -> 02:45 0.13 ask+120min -> 04:00 0.18
|
||||
```
|
||||
|
||||
An hourly series is **unchanged at every instant**, before and after — which is
|
||||
the whole reason nothing caught this. Every price fixture in this repository
|
||||
before TARIFF-08 was the invented hourly row shape, `static_tariff.py` emits
|
||||
hourly points, and the DST checks count points on the 23- and 25-hour days
|
||||
without ever asking `at()` for a quarter-hour. **The suite had been exercising
|
||||
the one resolution on which the broken rule and the right one agree.**
|
||||
|
||||
Belgium moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
|
||||
returns **96 points at 900 s**, so this is reachable only down the dynamic path
|
||||
TARIFF-08/09 just made work. Static sites are immune.
|
||||
|
||||
## 0.1 What it cost, which is not "a slightly wrong price"
|
||||
|
||||
`arbitrage._act()` calls `_point_at(plan, now)` → `series.at(now)` and then
|
||||
tests `here.start == v.cheapest.start` / `== v.dearest.start` to decide whether
|
||||
this interval is the buy one or the sell one. That is an **identity test
|
||||
between the matched point and the point the verdict was built from**, and on a
|
||||
quarter-hourly series it almost never matched — so the strategy idled through
|
||||
exactly the interval it exists to trade, reporting *"this hour is neither the
|
||||
buy hour nor the sell hour"*. It also read `here.provisional` (§8.3) and §17's
|
||||
`price < 0` off that same wrong point.
|
||||
|
||||
---
|
||||
|
||||
## 1. The rule
|
||||
|
||||
> **A point covers from its own start until the next point's start, and never
|
||||
> more than one hour.**
|
||||
|
||||
* **900 s** → each point covers its own quarter.
|
||||
* **3600 s** → each point covers its own hour. Identical to what shipped.
|
||||
* **MIXED** → **each point takes its own gap.** FCST-04's carry-forward emits
|
||||
hourly carried points beside quarter-hourly firm ones, and the soak measured
|
||||
spacing `[900, 3600]` in **70 % of samples** — so a window derived from one
|
||||
global spacing would be wrong in the **normal steady state**, not in an edge
|
||||
case.
|
||||
|
||||
### 1.1 Why the one-hour cap is not decoration
|
||||
|
||||
FCST-04 **leaves holes on purpose**: an hour with no published price and no
|
||||
yesterday value is left *out* of the series, and `price_forecast.py`'s module
|
||||
note names `at()` raising `Absence` for it as the behaviour that sends the
|
||||
caller to the static profile (§11.4 for that hour).
|
||||
|
||||
Uncapped, "until the next point's start" stretches the point **before** a
|
||||
three-hour hole across the whole hole and answers every instant in it with a
|
||||
stale price. Filling holes is the defect this module exists to refuse, and it
|
||||
would have been introduced **by the fix for a different one**. Graded in
|
||||
`test_price.py` 14d; the mutation that removes the cap is red there.
|
||||
|
||||
---
|
||||
|
||||
## 2. The decision: what the **last** point covers
|
||||
|
||||
There is no next start, and §9.3 says nothing about how wide the final interval
|
||||
is — a `PricePoint` has **three fields** and must not learn a fourth, because
|
||||
that is the wire format every store, replay, fixture and captured body in this
|
||||
repository is written against. So the tail is a **choice**, and both candidates
|
||||
are defensible.
|
||||
|
||||
**Chosen: the last point is given the same span as the point before it** (one
|
||||
hour when it is the only point).
|
||||
|
||||
| | flat one hour | the span in front of it (**chosen**) |
|
||||
|---|---|---|
|
||||
| 96-point 900 s day, `at(23:50)` | answers 23:45 | answers 23:45 |
|
||||
| the same day, `at(00:30)` **tomorrow** | **answers with today's 23:45 price** | `Absence` |
|
||||
| 24-point hourly day, `at(23:50)` | answers 23:00 | answers 23:00 |
|
||||
| mixed series ending at the firm/carried seam | answers for an hour | `Absence` after 15 min |
|
||||
|
||||
**The second row is why.** A flat one-hour tail reaches across a day boundary
|
||||
and prices tomorrow morning with yesterday's last print — which is
|
||||
"reaching for a neighbouring one", the single thing the `Absence` message has
|
||||
always refused to do. The chosen rule never extends a point past a span the
|
||||
series itself demonstrates.
|
||||
|
||||
### 2.1 The cost of the choice, stated rather than buried
|
||||
|
||||
On a **mixed** series whose last point is a carried **hourly** one sitting
|
||||
behind quarter-hourly firm ones, the tail is **900 s**, and the last 45 minutes
|
||||
of the horizon raise `Absence` for an hour something did carry a value for.
|
||||
|
||||
That is accepted, not tolerated, and the direction is the safe one:
|
||||
|
||||
* the caller falls back to the static profile (§11.4) instead of trading on a
|
||||
span the series never demonstrated;
|
||||
* the point is **carried**, so §8.3 forbids any irreversible action resting on
|
||||
it regardless;
|
||||
* it is at the far end of a 48 h horizon, where nothing is decided at 30 s.
|
||||
|
||||
Under-covering costs a missed decision. Over-covering prices a real interval
|
||||
with a number that does not describe it. Those are not symmetric.
|
||||
|
||||
### 2.2 The degenerate case
|
||||
|
||||
A series of **one** point has no gap in front of it to copy and gets **one
|
||||
hour** — the shipped behaviour, kept deliberately. `test_price.py` 14e states
|
||||
it as its own check so it cannot decay into an accident.
|
||||
|
||||
---
|
||||
|
||||
## 3. Does `at()` now depend on sortedness? Yes — and it establishes it itself
|
||||
|
||||
The old rule read one point at a time and did not care about order. The new one
|
||||
reads a point's **neighbours**, so it *would* depend on the series being
|
||||
ascending.
|
||||
|
||||
It does not, because **the lookup index is sorted by UTC start at construction**
|
||||
(`_cover_table()`, called once from `_Series.__init__`). No caller's list order
|
||||
can reach `at()`.
|
||||
|
||||
That matters because the three things that "guarantee" order today each
|
||||
guarantee it somewhere else:
|
||||
|
||||
| | what it guarantees | where |
|
||||
|---|---|---|
|
||||
| `_validate_raw()` | strictly increasing starts | the `load_prices()` path only |
|
||||
| `price_history._parse_points()` | re-sorts by instant | the store read path only |
|
||||
| FCST-04's fill | emits ascending | that one producer only |
|
||||
| `PriceRead.series()` | **nothing** | every other caller, including tests |
|
||||
|
||||
Three producers agreeing today is not an invariant; it is a coincidence with
|
||||
good references. `test_price.py` 14f drives a series handed its 96 points in
|
||||
**reverse** order and asserts `at()` answers identically at every one of them —
|
||||
red the moment the sort is removed.
|
||||
|
||||
**Duplicated starts** (refused by `_validate_raw()`, constructible by hand) give
|
||||
the earlier list entry a zero-width window and the later one the real span. One
|
||||
of the pair becomes unreachable through `at()`; **nothing raises**, at
|
||||
construction or on the 30 s control path. Stated as a check rather than left to
|
||||
be discovered.
|
||||
|
||||
---
|
||||
|
||||
## 4. `covers()` shared it. `window()` did not
|
||||
|
||||
* **`covers()`** has no window rule of its own — it calls `at()` — so it shared
|
||||
the defect exactly and the same change fixes it. It is kept as a delegation
|
||||
for that reason: a second copy of the rule is a second place for it to be
|
||||
wrong.
|
||||
* **`window(since, until)`** grades a point's **start** against a half-open
|
||||
range and never asks how long a point lasts, so it carries no resolution
|
||||
assumption at all. It is **not** a coverage query — a point that starts before
|
||||
`since` and runs into it is absent from its result by design. That is now
|
||||
said in its docstring and pinned by a check, because the next reader will ask.
|
||||
It has **no callers** in `app/`.
|
||||
|
||||
---
|
||||
|
||||
## 5. What this card does NOT fix, with its shape
|
||||
|
||||
`price_forecast._walk()` — §8.3's irreversible-action gate — iterates
|
||||
`committed_hours()`, which steps in **whole hours**, and asks `series.at(u)` at
|
||||
each. On a quarter-hourly series that now reliably returns the hour's `:00`
|
||||
point (it previously returned something earlier and arbitrary), so the gate
|
||||
reads **one point per hour** and never looks at the `:15`/`:30`/`:45` flags.
|
||||
|
||||
A partially-provisional hour would therefore be gated on its first quarter
|
||||
alone. **Two things keep it off every shipped install, and both are
|
||||
circumstantial rather than structural:** `price_permits()`/`begin_phase()` have
|
||||
**no caller in `app/main.py`** — the §12 price-justified entry point is built
|
||||
and not yet wired — and FCST-04 carries **whole** hours while
|
||||
`generic_dayahead` publishes whole days, so the four quarters of an hour are
|
||||
firm or carried together anyway. Both are facts about the *callers and
|
||||
producers*, not properties of the *gate*, so this is written down here rather
|
||||
than left for the first sub-hourly feed or the first wiring-up to find. Fixing it means teaching
|
||||
`committed_hours()` about resolution, which is a data-model question this card
|
||||
deliberately does not open.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mutations
|
||||
|
||||
Eight, each purged before the run (TEST-15), each reverted with the tree
|
||||
fingerprint checked afterwards (MUTATION.md habit 18), backup at a `mktemp`
|
||||
path per invocation.
|
||||
|
||||
⚠️ **Re-driven in full on the MERGED tree** after TARIFF-10 landed on
|
||||
`release/1.0` (base `88c59a9` -> `559f0eb`). All eight give the same verdicts
|
||||
and the same red-check counts as before the merge, which is the answer that
|
||||
matters: TARIFF-10 added 650 lines of checks to `test_price.py` and none of
|
||||
them moves under any of these edits, because they grade `resolve_tariff()`'s
|
||||
*reachability from a configuration* and not the lookup. Clean `app/price.py` on
|
||||
the merged tree = **`90d8ca31f7c0109d`, 85953 B** (pre-merge it was
|
||||
`30d1a56f0117e571`, 83742 B; the difference is TARIFF-10's `provider_id()` /
|
||||
`provider_configured()`, which this card does not touch).
|
||||
|
||||
| | mutation | Δ | red in |
|
||||
|---|---|---|---|
|
||||
| M1 | `at()` back to the shipped fixed one-hour scan over `self.points` | +68 B | **24 named checks**: `test_price.py` 14b ×4, 14c ×3, 14d, 14e ×3, 14f, 14g; `test_arbitrage.py` 10b ×6, 10c ×3, 10d ×2 |
|
||||
| M2 | drop the one-hour cap | −68 B | `test_price.py` 14d |
|
||||
| M3 | flat one-hour tail instead of the span in front | **+0 B** | `test_price.py` 14e ×3, 14g — 4 checks |
|
||||
| M4 | trust the caller's list order — drop the sort | −61 B | `test_price.py` 14f |
|
||||
| M5 | `covers()` grows its own hour-wide rule | +95 B | `test_price.py` 14e, 14g |
|
||||
| M6 | `window()` becomes a coverage query | −37 B | `test_price.py` 14g |
|
||||
| M7 | the window's end becomes inclusive | +1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14e ×4, 14f ×2, 14g — 16 checks |
|
||||
| M8 | the window's start becomes exclusive | −1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14f ×2, 14g, 14h, plus the pre-existing section 6 check — 14 checks |
|
||||
|
||||
**M3 is the zero-byte one and it is the tail decision itself** — the case
|
||||
MUTATION.md names as the risky size, caught by the check written for it.
|
||||
|
||||
**What the arbitrage file does NOT see, with sizes.** M2, M4, M5, M6 and M8
|
||||
leave `test_arbitrage.py` green, and that is honest rather than a gap to round
|
||||
off: they are `at()`-level properties (holes, caller order, `covers()`,
|
||||
`window()`, the start boundary) that no arbitrage fixture reaches, because the
|
||||
probes are seven minutes into a slot and the plans have no holes. The one that
|
||||
matters end to end — M1, the shipped rule — is red in **both** files, on the
|
||||
named check `THE PROPERTY THE DEFECT BROKE`.
|
||||
|
||||
---
|
||||
|
||||
## 7. TARIFF-10 landed on the base while this was open — what it changes here
|
||||
|
||||
Base moved `88c59a9` → `559f0eb` mid-card. The question is not whether the
|
||||
branches merge; it is whether TARIFF-10 makes any sentence above **false**.
|
||||
|
||||
**In `app/price.py` the two do not meet.** TARIFF-10 added `provider_id()` and
|
||||
`provider_configured()` beside `price_option()` and routed `load_prices()`
|
||||
through them. That is the option-reading layer; it touches nothing in
|
||||
`_Series`, `at()`, `covers()`, `window()` or `_cover_table()`. Git merged the
|
||||
file without a conflict, and the merged `at()` is byte-identical to the one the
|
||||
mutations above were driven against.
|
||||
|
||||
**Behaviourally it widens who has a series at all, and that cuts one way only.**
|
||||
`run_tariff_refresh()` now runs on installs with `arbitrage_enabled` **off**, so
|
||||
a provider-configured site has a standing plan — and therefore a `_Series` —
|
||||
where before it had none. **It does not add a new `at()` consumer to the 30 s
|
||||
tick**, which is the claim worth checking rather than assuming:
|
||||
|
||||
| caller of `at()`/`covers()` | reached by a non-trading install? |
|
||||
|---|---|
|
||||
| `arbitrage._point_at()` (`arbitrage.py:300`) | no — trading only |
|
||||
| `static_tariff.TariffPlan.price_at()` / `.words` `here` (`:750`, `:819`) | on the tick these are reached through `static_status()`, which builds a **`static_plan`** — hourly points, immune. `main.py` calls only `plan.words` on the fetched plan, which counts points and never looks one up |
|
||||
| `price_forecast._walk()` (`:562`, `:568`) | **`price_permits()` and `begin_phase()` have no caller in `app/main.py` at all** — §12's price-justified entry point is built and not yet wired |
|
||||
|
||||
So TARIFF-10 neither creates nor closes any exposure this card is about. What it
|
||||
does do is make the defect's reach larger the moment such a site arms trading,
|
||||
because the series it would trade against now exists on every configured
|
||||
install rather than only on armed ones. That strengthens the case for the fix
|
||||
and changes nothing in its reasoning.
|
||||
|
||||
**In `test_price.py` both cards appended a section 13.** Nothing is dropped:
|
||||
TARIFF-10's stays **13**, this card's is renumbered **14** (which is also this
|
||||
card's number), and the temp-directory teardown TARIFF-10 put at the end of the
|
||||
file stays at the end of the file. The two sections share no fixture, no option
|
||||
dict and no helper name; TARIFF-10's grades `resolve_tariff()`'s *reachability
|
||||
from a configuration* and drives real fetches with a counting transport, and
|
||||
**none of its checks moves under any of the eight mutations above** — measured,
|
||||
not assumed.
|
||||
|
||||
---
|
||||
|
||||
## 8. TARIFF-12 landed too — a plan may now be 90 minutes old when it is asked
|
||||
|
||||
Base moved `559f0eb` → `a6e64fb` while this was open. TARIFF-12 stops a
|
||||
returned §11.4 degradation from overwriting a standing plan that is still
|
||||
inside `TARIFF_MAX_AGE_S`, so **a served plan can now be up to
|
||||
`3 × TARIFF_REFRESH_S = 5400 s` old** where before it was at most one refresh
|
||||
interval. `at()` is what answers *"what does it cost right now"* out of that
|
||||
older plan, so the question is fair and the answer is written out rather than
|
||||
assumed.
|
||||
|
||||
**Per-point span: unaffected, structurally.** Every window is derived from the
|
||||
neighbouring points' **absolute `start` instants**. Those are `datetime`s inside
|
||||
a frozen dataclass inside a tuple — they cannot move, and `_cover_table()` runs
|
||||
once at construction over data that cannot change afterwards. A plan's *age* is
|
||||
a property of `fetched_at` / `tariff_at`, never of a window. **The index cannot
|
||||
go stale**, which is a small positive under TARIFF-12 rather than a risk: a kept
|
||||
plan now lives longer, and there is nothing in it that needed recomputing.
|
||||
|
||||
**The one-hour cap: unaffected.** It bites only where a gap exceeds an hour,
|
||||
i.e. at a hole FCST-04 left. Walking further into a series crosses more windows;
|
||||
each is still resolved on its own neighbours.
|
||||
|
||||
**The tail: reachable for the first time, and the choice made in §2 is the one
|
||||
that survives it.** This is the half worth spelling out.
|
||||
|
||||
* At the **shipped 48 h horizon** the tail is unreachable by staleness. A plan
|
||||
is served for at most 90 minutes, so `now` is at most 90 minutes into a
|
||||
~48 h series — the last point is still about 46½ hours away.
|
||||
* But `price_horizon_h` is `int(1,72)` in `config.yaml`, so an operator **can**
|
||||
configure a **1 h** horizon. A 90-minute serve-stale window against a 1 h
|
||||
series puts `now` genuinely past the end. That is not hypothetical; it is a
|
||||
supported configuration meeting a supported outage.
|
||||
* Under the **chosen** rule a quarter-hourly 1 h series ends at `:45` and that
|
||||
point's window closes at the top of the next hour: `at()` raises `Absence`,
|
||||
`_point_at()` returns None, `_act()` idles with its "no price for this
|
||||
instant" reason, and §11.4 sends the site to the static profile.
|
||||
* Under the **rejected** flat one-hour tail the same stale plan would have gone
|
||||
on trading against the `:45` print for **up to 45 minutes after the interval
|
||||
it describes had ended** — pricing an outage with a number for a quarter that
|
||||
is over.
|
||||
|
||||
So TARIFF-12 does not weaken the tail decision. It is the first thing that makes
|
||||
it reachable on a shipped configuration, and it independently selects the same
|
||||
answer. ⚠️ **Note which direction that runs**: the more stale a plan is allowed
|
||||
to be, the more the tail rule is doing real work, so it should be re-read if
|
||||
`TARIFF_MAX_AGE_S` is ever widened again.
|
||||
|
||||
**`TariffPlan.from_feed` cannot produce a series this code has not seen.** It is
|
||||
a pure predicate on `origin` (`== "provider"`), and TARIFF-12's decision keeps or
|
||||
replaces a **whole plan object**. It never splices two reads into one series, so
|
||||
it cannot hand `at()` an unsorted or duplicated point list — the case §3 covers
|
||||
defensively and that nothing shipped produces.
|
||||
|
||||
**Re-graded on this base.** All 29 files green. `app/price.py` is byte-identical
|
||||
to the TARIFF-10 merge (`90d8ca31f7c0109d`, 85953 B) because TARIFF-12 does not
|
||||
touch it. **M1** re-driven: +68 B, red on the same **24 named checks** across
|
||||
both files. **M3** re-driven: **+0 B**, red on the same 4. This branch
|
||||
contributes **zero diff** to `app/main.py` and `app/static_tariff.py`.
|
||||
+336
-4
@@ -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
|
||||
@@ -1424,6 +1490,85 @@ distinct state from "prices, all of them zero": the plan is marked provisional
|
||||
and arbitrage is disabled, per the degradation matrix. It never falls back to a
|
||||
price of zero, which would read as free electricity.
|
||||
|
||||
#### The hours the feed has not published yet (FCST-04)
|
||||
|
||||
A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**.
|
||||
Before that, the feed is serving today and nothing else — so a `price_horizon_h`
|
||||
of 48 asks for 48 hours and gets back the ten or fifteen that are left in the
|
||||
day. Until FCST-04 those other hours were simply **absent** from the plan: not
|
||||
priced badly, not flagged, just missing, and nothing said so.
|
||||
|
||||
Now the add-on keeps the **published prices of the last few days** in
|
||||
`/data/price_history.json` and fills an unpublished hour of the horizon with
|
||||
yesterday's price for the same Brussels wall-clock hour, flagged
|
||||
`provisional: true`. Two things follow, and the second one matters more:
|
||||
|
||||
- **The plan says it is guessing** — `provisional: true` on every carried hour,
|
||||
which is what the optimiser reads. Before, a half-length plan looked
|
||||
completely settled.
|
||||
- **Nothing may trade on a carried hour.** §8.3 forbids acting on a provisional
|
||||
price, so arbitrage drops those hours from the trade set entirely — it needs a
|
||||
buy hour and a sell hour that were *both* published. Carrying a price forward
|
||||
widens what the optimiser can **see**; it never widens what it may **do**.
|
||||
|
||||
**"An unpublished hour", not "every unpublished hour"** — the fill can only
|
||||
carry an hour yesterday actually published. If yesterday's record is itself
|
||||
partial (a first day, an outage, a feed that only ever serves the rest of the
|
||||
day), the local hours it never held are **left out of the series**: §11.4
|
||||
applies to those hours, and asking the plan for one raises rather than
|
||||
answering. An hour with no price is not a free hour, and the fill does not
|
||||
invent a neighbour, a mean or a zero for it.
|
||||
|
||||
**A quarter-hourly feed keeps all four of its prints.** `eneco_eddy` settles
|
||||
per quarter, and the fill decides *which whole hours need carrying* — never what
|
||||
a published hour may contain. Every point the provider published inside the
|
||||
horizon is passed through untouched; a carried point is added only for a whole
|
||||
hour the feed did not print at all.
|
||||
|
||||
**What you can actually see on the status page.** *Price history* (below) says
|
||||
what is held. *Arbitrage* carries the strategy's own sentence, which names the
|
||||
firm/provisional split whenever that split is what refused a trade. *Tariff*
|
||||
does **not** report the fill: it is the static-profile or Home Assistant
|
||||
price-entity sentence, regenerated on the 30 s tick. The provider plan's own
|
||||
line — `dynamic_dayahead from generic_dayahead, 48 h, provisional` — goes to the
|
||||
**add-on log** at every refresh, alongside `§8.3: 33 of 48 hours carried forward
|
||||
from yesterday's profile and flagged provisional; 15 published`.
|
||||
|
||||
The plan is also now cut to the horizon you asked for, which means **hours that
|
||||
have already elapsed are no longer in it**. That is a real change to arbitrage:
|
||||
a cheap night that has already gone used to sit in the series as the cheapest
|
||||
hour of the day, and a cycle can never buy in an hour that is past — so the
|
||||
trade was quietly never taken. The spread is now taken over hours still ahead.
|
||||
|
||||
A new status row, **Price history**, says which days are held, how many price
|
||||
points each holds and how many whole hours those cover — `2026-01-13 (96 points
|
||||
over 24 h)`. ⚠️ **Those are two different numbers and the row used to print one
|
||||
of them under the other's name**: it counted points and labelled them `h`, so a
|
||||
quarter-hourly feed read `96 h` for a 24-hour day. The figure that says whether
|
||||
tomorrow morning's fill has a whole day to carry is the **hour** one. If it
|
||||
reads `empty` or `unavailable`, nothing is carried, the horizon
|
||||
is as short as the feed makes it, and every other guarantee is unchanged — a
|
||||
cache that cannot answer is not allowed to be a controller that cannot regulate.
|
||||
The store is rebuilt by the next successful fetch; on a brand-new install the
|
||||
first fill is possible the day after the first refresh.
|
||||
|
||||
The same applies to a file that is *partly* broken: a day record this build
|
||||
cannot read is dropped, the row says how many were dropped and why, and the rest
|
||||
of the file still loads. And a store that **cannot persist records nothing** —
|
||||
not even in memory. That covers a missing `/data`, and it also covers a `/data`
|
||||
that is there and cannot be **written**: a full volume, a read-only remount. An
|
||||
in-memory yesterday would answer for one run and vanish on the next restart,
|
||||
which is a cache that forgets without saying so; the row naming the reason and
|
||||
*no prices carried* are the honest pair.
|
||||
|
||||
⚠️ **What a failed write does not do is throw away what is already in the
|
||||
file.** A volume that fills at 14:00 leaves the yesterday written at 13:30 still
|
||||
answering, because that record survives a restart — the store stops *adding* to
|
||||
it, nothing more. And it never stops the add-on: a write that cannot complete is
|
||||
logged and reported on the status row, never raised. A cache that cannot answer
|
||||
is not allowed to be a controller that cannot regulate, and that applies to the
|
||||
volume being full exactly as it applies to the volume being absent.
|
||||
|
||||
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
|
||||
often near zero, which by itself strongly favours self-consumption over export.
|
||||
It is a single number rather than a curve because no supported supplier
|
||||
@@ -1547,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 |
|
||||
@@ -1574,6 +1727,185 @@ battery power, circulating power, state of charge, maintenance phase and
|
||||
controller status by MQTT discovery. This is observability only — the controller works fine without a
|
||||
broker, and MQTT problems can never affect control.
|
||||
|
||||
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
|
||||
|
||||
The day the `inverters:` roster is declared, two things change that are
|
||||
**one-way on a live box and invisible until after the restart that applies
|
||||
them**: the store migration renames the keys that hold the SAFETY-02 latch and
|
||||
the maintenance history, and the entity ids Home Assistant composes change
|
||||
shape. This tool runs the **real `Controller`** through that transition against
|
||||
a **copy** of the state file and reports the difference.
|
||||
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v <repo>:/w:ro \
|
||||
goodwe-addon-checks:1 sh -c 'cd /w/goodwe_controller && \
|
||||
python3 tools/rehearse_roster.py \
|
||||
--state /w/state-copy.json \
|
||||
--before /w/live-options.json \
|
||||
--after /w/roster-options.json \
|
||||
--live-entities /w/live-entities.txt \
|
||||
--reverse --round-trip'
|
||||
|
||||
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
|
||||
pass.**
|
||||
|
||||
### The owner steps, because this session could not take them
|
||||
|
||||
⚠️ **The rehearsal is only as good as the state it is pointed at, and nothing in
|
||||
this repository can fetch that.** SSH to the live box is blocked by policy and
|
||||
that is correct — `/data/state.json` is the file the running add-on is writing.
|
||||
So three things have to be exported by hand, onto the machine that runs the
|
||||
check, **before** the roster is declared:
|
||||
|
||||
1. **The state file.** From the Supervisor host:
|
||||
|
||||
ha addons info local_goodwe_controller # confirm the slug first
|
||||
docker cp addon_local_goodwe_controller:/data/state.json ./state-copy.json
|
||||
|
||||
or take it out of a full add-on backup, which is the same bytes. ⚠️ **Copy
|
||||
it, never move it, and never write to the original.** The tool opens the path
|
||||
it is given exactly once and does all of its writing on a scratch copy — but
|
||||
the safe habit is to hand it a file the add-on has never heard of.
|
||||
|
||||
2. **The options blob**, as `--before`. It is not reachable through the Home
|
||||
Assistant REST API and no entity carries it; export it from the Supervisor,
|
||||
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
|
||||
to deploy into a second file and pass it as `--after`.
|
||||
|
||||
3. **The entity list**, as `--live-entities` — one entity id per line:
|
||||
|
||||
Developer tools -> Template:
|
||||
{{ states | map(attribute='entity_id') | select('search','goodwe') | join('\n') }}
|
||||
|
||||
⚠️ **Omitting it is a REFUSAL (exit 2), not a quieter run.** The orphan check
|
||||
is graded against the ids the install actually has, and without that file it
|
||||
is not graded against reality at all — so the tool will not run rather than
|
||||
hand back a clean bill of health for a half of the transition it did not
|
||||
check. If you genuinely have to run without it, pass `--no-live-entities`:
|
||||
the run then carries a standing banner in the report header **and** on the
|
||||
exit line saying the orphan check was not graded. The asymmetry used to be
|
||||
the other way round — an unreadable file refused, a missing flag passed with
|
||||
exit 0 and no disclosure at all — and the owner runs this once, at
|
||||
commissioning, under time pressure.
|
||||
|
||||
The install carries roughly 49 `goodwe*` entities and this add-on announces
|
||||
eight of them. The tool separates the ids it owns from the ids it does not,
|
||||
rather than claiming orphans it cannot cause.
|
||||
|
||||
### The reference, which is loaded on every run
|
||||
|
||||
`--reference` defaults to `tools/rehearsal/announced-reference.txt` and is read
|
||||
**whether or not** you pass an entity list. It records what the build is
|
||||
supposed to announce, three columns per row:
|
||||
|
||||
<entity_id> <unique_id> <device identifier>
|
||||
|
||||
⚠️ **All three, and the second and third are the ones an entity_id-only list
|
||||
cannot see.** The tool's own before/after diff is composed twice by the same
|
||||
build, so a change to the composition moves both sides equally and the diff
|
||||
reports nothing. Worse, two changes orphan an install *without moving a single
|
||||
composed entity_id*: renaming `mqtt.BASE` (every `unique_id` changes) and
|
||||
changing `DEVICE["identifiers"]` (HA sees a new device). Home Assistant keys its
|
||||
entity registry on `unique_id` and its device registry on the identifier, so
|
||||
either one creates a **new** entity beside the old — and because discovery is
|
||||
published `retain=True` with no removal payload, the old config stays on the
|
||||
broker and the old entity stays in the registry, still named by every automation
|
||||
that referenced it, never updating again. Both were measured coming back
|
||||
**exit 0** before the reference carried three columns.
|
||||
|
||||
`object_id` is deliberately **not** a column: `app/mqtt.py` records that HA
|
||||
2026.8 does not honour it in a discovery payload, so renaming one is a true
|
||||
no-op, and `test_rehearse.py` requires that mutation to **survive** — otherwise
|
||||
"the reference notices any edit to `mqtt.py`" would be indistinguishable from
|
||||
"the reference notices what HA keys on".
|
||||
|
||||
⚠️ **No result in this repository was produced against real state.** Everything
|
||||
recorded here and in `test_rehearse.py` was measured on the synthetic fixtures
|
||||
in `tools/rehearsal/`.
|
||||
|
||||
### What it confirms on the synthetic fixtures
|
||||
|
||||
⚠️ **This is a SECOND INSTRUMENT reaching a conclusion this repository already
|
||||
holds, not a discovery.** `test_maintenance.py` section 13 — *"RECORDED, NOT
|
||||
FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a
|
||||
LATCH"* — already states it, already names `namespaced_latched`, and already
|
||||
carries the sentence *"the guard is defeated by the very key the defect wrote"*.
|
||||
That file is on `release/1.0` and this card does not touch it. What is new here
|
||||
is that the finding is now reachable **from a state file the owner supplies**
|
||||
rather than only from a fixture built in a test, and that it is reached by a
|
||||
different route: a full `Controller` start diffed against its predecessor,
|
||||
rather than assertions over a `MemStore`. Two instruments, one answer.
|
||||
|
||||
**The finding.** A pre-DEV-02 flat store that declares a multi-inverter roster
|
||||
comes up with its SAFETY-02 latch **DISARMED**, unless the first roster entry
|
||||
happens to be named `inverter-a`. `Controller.__init__` calls
|
||||
`migrate_unit_keys(store, UNIT_A)` with a hardcoded constant, so the latch lands
|
||||
in `unit/inverter-a/sign_tripped`; a roster of `["garage", "shed"]` reads
|
||||
`unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()` —
|
||||
the guard written for exactly this — sees the migration's own misdirected key
|
||||
satisfy its `namespaced_latched` test and returns without re-arming. The file
|
||||
says `sign_tripped: true`, the banner is green, and nothing is held at 0 W.
|
||||
|
||||
**Both routes reach it, and they are NOT the same defect.** The rehearsal
|
||||
reports them as separate legs because they need separate fixes:
|
||||
|
||||
| leg | how it gets there |
|
||||
|---|---|
|
||||
| `SAME-STEP` | the roster is declared in the same step as the upgrade. The migration runs, and is handed `UNIT_A`. |
|
||||
| `STAGED` | the flat config runs once under 0.7.0+ first. That start stamps `schema_version = 1`, so the roster start's migration takes its `from_version >= SCHEMA_VERSION` early return and **the latch never moves at all**. |
|
||||
|
||||
⚠️ **So "hand `migrate_unit_keys()` the roster's own first name" is NOT the
|
||||
whole fix, and the rehearsal measures that rather than asserting it.**
|
||||
`test_rehearse.py` part 5b builds exactly that change and reports:
|
||||
|
||||
LEG STAGED: DISARMED
|
||||
LEG SAME-STEP: clean
|
||||
|
||||
The staged path never reaches the line that was changed. A fix that stops there
|
||||
closes the half an operator is least likely to take and leaves the half that
|
||||
"upgrade first, declare the roster later" produces. The guard side —
|
||||
`namespaced_latched` counting a namespace that is not on the roster — is what
|
||||
covers both, and a change there turns every SAFETY-44 check in part 5 red, which
|
||||
is what a fix should do to a pin that specifies a defect.
|
||||
|
||||
The fix belongs to its own card, shown red first. Part 5 pins the current
|
||||
behaviour as a requirement so it cannot drift green by accident.
|
||||
|
||||
### The reverse direction: the retreat is supported, the round trip is not
|
||||
|
||||
If commissioning B goes wrong, **backing out to the flat single-inverter config
|
||||
is supported and does not corrupt state.** Measured on the fixtures:
|
||||
|
||||
* the SAFETY-02 latch and the first pack's maintenance history both survive,
|
||||
because `store.py` rule 2 keeps the flat spellings **live** rather than merely
|
||||
present — `maintenance.UnitHistory.mirror_flat` writes them on every change,
|
||||
so a single-FSM add-on finds a current history rather than one dated the day
|
||||
the roster was declared;
|
||||
* **it orphans eight Home Assistant entities** — the four per-unit rows for each
|
||||
of the two units. **Measured:** the add-on stops announcing them and stops
|
||||
publishing their state, so their history gaps and any automation whose
|
||||
trigger names them stops firing, silently and without an error.
|
||||
|
||||
⚠️ **What is NOT measured, and was previously overstated here:** whether
|
||||
advancing again restores them. An earlier draft of this section said the
|
||||
orphaning was "not recoverable by advancing again: HA keeps the id it composed
|
||||
at first discovery" — that reasoning cuts the other way, since keeping the
|
||||
composed id is precisely what would let a re-advertised entity land back on
|
||||
it, and discovery is published `retain=True` with no removal payload. Nothing
|
||||
in this repository has tested a re-advance against a real Home Assistant, so
|
||||
the claim is withdrawn rather than restated in the other direction. Plan the
|
||||
retreat around the measured part: **the eight rows stop updating for as long
|
||||
as you are retreated.**
|
||||
|
||||
⚠️ **What is NOT supported is the round trip.** A maintenance cycle that
|
||||
completes *while the install is rolled back* is **discarded** when it
|
||||
re-advances: `maint_schema_version` is already stamped, so `migrate_maint_keys()`
|
||||
takes its early return and the pack reads the namespaced history from before the
|
||||
retreat. The pack is then recalibrated on a schedule that does not know it
|
||||
already was. **So: retreat freely, but treat a retreat as ending the current
|
||||
maintenance bookkeeping — and if a cycle ran during it, correct
|
||||
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
|
||||
decision, not a discovery made at the inverter.
|
||||
|
||||
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
|
||||
|
||||
Two ways a deploy has gone wrong on this system, both of which the Supervisor
|
||||
|
||||
@@ -127,6 +127,43 @@ Two rules come out of it, and both are cheap:
|
||||
function, that pair is the first thing to try — TEST-16's two survivors were
|
||||
adjacent lines in one function and neither was individually live.
|
||||
|
||||
⚠️ **AND FCST-05 WALKED INTO IT AGAIN, IN ITS OWN NEW CHECK, WHICH IS WHY THE
|
||||
RULE IS “mutate every check you add”.** `price_history.py` reports a malformed
|
||||
point row from **two** places into **one** field: `_usable_days()` records what
|
||||
it FOUND when the file was opened, and `_remember()` records what it DROPPED on
|
||||
the next refresh. Both land in `last_error`, and `words` prints `last_error`.
|
||||
The first draft of the new check asked `"malformed point row" in words` — which
|
||||
whichever rung is still standing satisfies — so deleting `_remember()`'s note
|
||||
entirely was a **−1 B SURVIVOR of the whole suite**, on the half of the fix that
|
||||
tells an operator the cache **self-healed**. It is graded on the TENSE now
|
||||
(`… in <day>` at open, `… dropped from <day>` after the refresh), the two are
|
||||
asserted to DIFFER, and the paired mutation — both notes deleted, −91 B — is
|
||||
red on 15 named checks. Same shape as TEST-16 and the same fix: **grade the
|
||||
reason, not the verdict**, and when two rungs answer one stimulus, mutate them
|
||||
together.
|
||||
|
||||
⚠️ **AND FCST-06 FOUND THE COUNT ITSELF LYING — LOW, NOT SILENT.** A mutation
|
||||
whose red is a **traceback** does not report one red; it reports however many
|
||||
checks ran before the traceback, and grades **none** of the ones after it. The
|
||||
new section drove M4 (`self._days = before` → `self._days = {}`) and the run
|
||||
printed **1 FAIL**. The true figure is **3**: an f-string in the next check read
|
||||
`len(read.points)`, and `.points` on a `no_data` **raises `Absence` by design**,
|
||||
so the harness died mid-section with the remaining checks ungraded. That is
|
||||
`why()`'s and `derived()`'s rule — *"it answered nothing" and "it broke on the
|
||||
way to answering" must not grade the same* — arriving in the **printed operand**
|
||||
of a check rather than in its condition, which is the half that is easy to miss
|
||||
because it looks like formatting. Two things follow:
|
||||
|
||||
* **a mutation report's red COUNT is only a lower bound until the run is seen to
|
||||
have reached the end of its section.** Read the output, not the count;
|
||||
* **the total accessor has to cover what a check PRINTS, not only what it
|
||||
asserts.** `test_price_history.py` 5d added `npts()` beside `why()` for
|
||||
exactly this, and re-driving M4 against it moved 1 FAIL to 3.
|
||||
|
||||
Worse, had the traceback landed one check earlier the mutation would have
|
||||
produced **no named red at all** — a result that reads like a broken harness and
|
||||
gets retried rather than read.
|
||||
|
||||
## The third way a check lies: its DATA cannot reach the failure it names
|
||||
|
||||
Stale bytecode manufactures survivors from a mutation that never ran. Masking
|
||||
@@ -225,6 +262,78 @@ same place twice, the check is decoration.
|
||||
forever, *including after the call site is fixed*. The check was true and
|
||||
passed for the wrong reason. Habit **9** below is what falls out of it.
|
||||
|
||||
* ⚠️ **ARCH-02's OWN REHEARSAL HARNESS, CAUGHT BY ITS OWN MUTATION BATTERY —
|
||||
the THIRTEENTH instance, and the third in an INSTRUMENT rather than in a
|
||||
check.** `tools/rehearse_roster.py` reports orphaned Home Assistant entities
|
||||
by composing the ids the add-on announces **before** the roster switch and
|
||||
**after** it, and diffing. Both sides are composed by the same build. So the
|
||||
two mutations that change the id composition itself — renaming the MQTT device
|
||||
(`"GoodWe"` -> `"GoodWe Controller"`), renaming one site sensor (`"Grid power"`
|
||||
-> `"Grid power W"`) — moved **every id on both sides equally**, and the
|
||||
harness reported *"no orphans"* about a change that orphans the entire live
|
||||
entity list. It read exactly like a clean transition. The first draft's
|
||||
M5/M6 came back `exit 0` and `exit 2`, and neither was the failure they name.
|
||||
|
||||
The fix is habit 2 with a filename: **the reference has to be data that does
|
||||
not move with the code.** `--live-entities` takes a list of the ids the
|
||||
install actually has, `tools/rehearsal/live-entities-recorded.txt` is the
|
||||
recorded stand-in that keeps it live in CI, and a build that announces an id
|
||||
the list does not carry is now a `COMPOSITION DRIFT` finding rather than a
|
||||
silence. ⚠️ **The anchor moved for the same reason**: it had been
|
||||
`compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age"`,
|
||||
which any renaming of the device satisfies by moving both operands, and it is
|
||||
now composed from two recorded **literals**.
|
||||
|
||||
Ask of an instrument what habit 4 asks of a check: *where did each side of
|
||||
this comparison come from* — and if a mutation of the subject moves both
|
||||
sides, the instrument is decoration.
|
||||
|
||||
⚠️ **AND REVIEW FOUND TWO MORE IN THE SAME CARD, ONE OF THEM INSIDE THE FIX
|
||||
FOR THE OTHER — the FOURTEENTH and FIFTEENTH.** Both are the pin, not the
|
||||
tool, and both are the same sentence in a new costume: *a check whose data
|
||||
cannot reach the failure it names.*
|
||||
|
||||
* **Fourteenth — a COUNT cannot tell one red leg from two.** The rehearsal
|
||||
reports two independent routes to the disarmed latch (`SAME-STEP` and
|
||||
`STAGED`) and prints every finding **twice per red leg**: once in that
|
||||
leg's own block and once in the closing summary. The check named *"NOT THE
|
||||
SAME-STEP PATH ALONE"* was written `out.count("LATCH CAME UP DISARMED")
|
||||
>= 2`, so **one** red leg always satisfied it. Measured on three builds:
|
||||
shipped code (both legs red) → 4; `migrate_unit_keys()` handed the roster
|
||||
name (SAME-STEP fixed) → 2, **passes**; a guard-side fix (both fixed) → 0.
|
||||
The check reported `ok` on the build whose only remaining failure *is* the
|
||||
path it says is not the only one. The fix is to parse the legs and require
|
||||
the finding in **each**, which is habit 2 again: grade the thing, not a
|
||||
total over the thing.
|
||||
|
||||
* **Fifteenth — and it is INSIDE THAT FIX, which is the third time this file
|
||||
has had to write that sentence.** The first leg-splitter opened on a
|
||||
`LEG <id>` marker and never closed, so every line after the *last* marker
|
||||
was attributed to that leg — including the closing summary, which repeats
|
||||
every finding from every leg. The last leg therefore read red whenever
|
||||
**any** leg was red, and the new per-leg check was the old count with extra
|
||||
steps. It was caught by building the one fixture that separates them: a
|
||||
tree in which `SAME-STEP` is genuinely clean and `STAGED` is not. The tool
|
||||
now prints `END LEG <id>` and the splitter closes on it.
|
||||
|
||||
The rule that falls out, and it is the cheap one: **a per-thing check that
|
||||
has never seen the things DISAGREE is a total.** Part 5b exists to make
|
||||
them disagree on every run.
|
||||
|
||||
⚠️ **AND ONE THAT HAS ONLY SEEN THEM DISAGREE IN ONE ARRANGEMENT IS A TOTAL
|
||||
OVER THAT ARRANGEMENT.** Review found this by attacking the fix itself. Part
|
||||
5b does make the legs disagree, on every run - and was still blind to a build
|
||||
that merely REORDERS them, because it had only ever seen the clean leg
|
||||
printed LAST. Three edits, two of them ordinary refactors (reorder the report
|
||||
legs, reword a summary line), produced `all checks passed` from a splitter
|
||||
that closed ZERO legs.
|
||||
|
||||
A fixture cannot enumerate the arrangements, so the STRUCTURE is asserted
|
||||
instead: a leg may not open while one is open, and every leg that opens must
|
||||
be explicitly closed. That fires on the FIRST edit of all three attacks,
|
||||
whatever the order or the wording. **When a fixture cannot enumerate the
|
||||
arrangements, assert the invariant they share.**
|
||||
|
||||
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
|
||||
reaching outside the suite. The probe is the committed evidence that
|
||||
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
|
||||
@@ -586,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
|
||||
@@ -643,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
|
||||
|
||||
@@ -264,36 +264,66 @@ DELIVERY_OPTION_DEFAULTS = {
|
||||
# such family, so reading its numbers across would have understated this.
|
||||
#
|
||||
# Every run was executed a second time at a roster of ONE, on the same
|
||||
# stimulus, as the control. Worst CONTINUOUS legitimate episode:
|
||||
# stimulus, as the control.
|
||||
#
|
||||
# ⚠️ RE-RUN BY TEST-21 (2026-09-07) AND THE HEADLINE DID NOT REPRODUCE. The
|
||||
# harness that produced the original table was never committed, which is
|
||||
# PROC-01's shape; it is committed now, in the OTHER repository, as
|
||||
# `admin/goodwe:sim/delivery_window.py` (it imports `plantsim` and
|
||||
# `frame_check`, which that repo owns - restating them here would be CI-08's
|
||||
# coupling, and CI-08 has broken three times). Re-derive it with:
|
||||
#
|
||||
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
# python3 -B delivery_window.py --selftest --campaign --blag
|
||||
#
|
||||
# Worst CONTINUOUS legitimate episode. Second block is the re-run:
|
||||
#
|
||||
# threshold reversals flapping weighted N=1 control (1890 runs)
|
||||
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s
|
||||
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s
|
||||
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s
|
||||
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s
|
||||
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s
|
||||
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s
|
||||
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s } 2026-09-06
|
||||
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s } harness
|
||||
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s } NOT
|
||||
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s } COMMITTED
|
||||
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s } - see
|
||||
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s } DECISIONS
|
||||
#
|
||||
# threshold reversals flapping weighted N=1 control (2640 runs)
|
||||
# 100 W 120.0 s 0.0 s 165.0 s 0.0 s } TEST-21
|
||||
# 250 W 65.0 s 0.0 s 100.0 s 0.0 s } 2026-09-07
|
||||
# * 500 W 30.0 s 0.0 s 35.0 s 0.0 s } committed
|
||||
# 700 W 25.0 s 0.0 s 30.0 s 0.0 s } harness,
|
||||
# 1000 W 15.0 s 0.0 s 25.0 s 0.0 s } re-runnable
|
||||
# 1500 W 0.0 s 0.0 s 20.0 s 0.0 s }
|
||||
# 2000 W 0.0 s 0.0 s 0.0 s 0.0 s }
|
||||
#
|
||||
# * is the shipped threshold, `Tuning.saturation_w`.
|
||||
#
|
||||
# ⚠️ THREE THINGS IN THAT TABLE ARE WORTH MORE THAN THE ROW THEY SIT ON.
|
||||
# (a) The N=1 column is ZERO EVERYWHERE, in all 1890 runs at every
|
||||
# threshold. The compatibility promise is measured, not argued.
|
||||
# (b) The FLAPPING column is zero everywhere too - and that is the family
|
||||
# that defeated SAFETY-30's site window (980 s at N=2, 1000 s at N=1, no
|
||||
# window inside the billing quarter surviving it). It cannot touch this
|
||||
# check, because a common stimulus makes BOTH units short at once and
|
||||
# the subset stops being proper.
|
||||
# (c) THE CURVE IS NOT MONOTONE - 55.0 s at 250 W against 40.0 s at 100 W -
|
||||
# and that is a property of the reduction, not noise: a lower threshold
|
||||
# puts MORE units into the short set, which can turn a proper subset
|
||||
# into the whole roster and BREAK the run. Do not read it as a
|
||||
# sensitivity curve.
|
||||
# (a) The N=1 column is ZERO EVERYWHERE, at every threshold, IN BOTH
|
||||
# CAMPAIGNS. The compatibility promise is measured, not argued, and it
|
||||
# is the part that reproduced most cleanly.
|
||||
# (b) The FLAPPING column is zero everywhere too, in both - and that is the
|
||||
# family that defeated SAFETY-30's site window (980 s at N=2, 1000 s at
|
||||
# N=1, no window inside the billing quarter surviving it). It cannot
|
||||
# touch this check, because a common stimulus makes BOTH units short at
|
||||
# once and the subset stops being proper.
|
||||
# (c) THE ORIGINAL CURVE WAS NOT MONOTONE - 55.0 s at 250 W against 40.0 s
|
||||
# at 100 W - and that was read as a property of the reduction: a lower
|
||||
# threshold puts MORE units into the short set, which can turn a proper
|
||||
# subset into the whole roster and BREAK the run. ⚠️ THE RE-RUN'S CURVE
|
||||
# IS MONOTONE. The mechanism is real and is still the reason not to read
|
||||
# either table as a sensitivity curve, but it is no longer something
|
||||
# these numbers demonstrate - it is an argument now, not a measurement.
|
||||
#
|
||||
# THE BOUNDS THE VALUE ANSWERS TO:
|
||||
#
|
||||
# LOWER, from the rig - 30.0 s, the worst continuous legitimate episode at
|
||||
# the shipped threshold across all 2640 runs.
|
||||
# LOWER, from the rig - 35.0 s (TEST-21; the original record said 30.0 s
|
||||
# and it did not reproduce), the worst continuous legitimate episode at
|
||||
# the shipped threshold across all 2640 runs. ⚠️ THIS IS NOW STRICTLY THE
|
||||
# BINDING LOWER BOUND: at 30.0 s it coincided with the firmware's veto
|
||||
# below and a reader could not tell which was doing the work; at 35.0 s it
|
||||
# is ABOVE it, and it is above the schema's own `int(30,900)` floor too -
|
||||
# so an operator who sets `delivery_hold_s` to 30 is below a MEASURED
|
||||
# legitimate episode. That is what "derived, do not change" is for.
|
||||
#
|
||||
# LOWER, from the firmware - `inverter.LINK_STALE_S`, 30 s. A unit not
|
||||
# executing its share for less than the firmware's own veto window is a
|
||||
@@ -302,26 +332,47 @@ DELIVERY_OPTION_DEFAULTS = {
|
||||
# second, louder alarm about a state already being handled - the
|
||||
# SAFETY-15/SAFETY-19 failure exactly.
|
||||
#
|
||||
# MARGIN, from INVERTER B NOT EXISTING, AND IT IS MEASURED RATHER THAN
|
||||
# GUESSED. The campaign's B is A COPIED (`plantsim.INVERTER_B`,
|
||||
# `measured=False`), so the 30.0 s above is two identical machines.
|
||||
# Sweeping a DIFFERENT second unit through the weighted family: the worst
|
||||
# legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and
|
||||
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 90.0 s at
|
||||
# four times it. That is the sensitivity the margin has to carry until
|
||||
# SPIKE-04 measures the real unit.
|
||||
# MARGIN, from INVERTER B NOT EXISTING. ⚠️ THIS IS A SENSITIVITY STUDY ON
|
||||
# A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, and the wording here used
|
||||
# to say "MEASURED rather than GUESSED", which overstated it. B is not
|
||||
# installed (~2026-09-12). The campaign's B is A COPIED
|
||||
# (`plantsim.INVERTER_B`, `measured=False`), so the 35.0 s above is TWO
|
||||
# IDENTICAL MACHINES and every row below is a what-if. NO ROW HERE IS B's
|
||||
# LAG. Sweeping a DIFFERENT second unit through the weighted family
|
||||
# (2026-09-06 -> TEST-21 re-run):
|
||||
#
|
||||
# B lag / ramp original re-run
|
||||
# 10.0 s / 800 W/s 30.0 s 35.0 s (= A, both copies)
|
||||
# 20.0 s / 800 W/s 45.0 s 45.0 s
|
||||
# 10.0 s / 400 W/s 30.0 s 35.0 s
|
||||
# 20.0 s / 400 W/s 45.0 s 50.0 s
|
||||
# 40.0 s / 200 W/s 90.0 s 75.0 s (4x A's lag)
|
||||
#
|
||||
# The LAG moves it roughly linearly; the RAMP barely does. "The ramp does
|
||||
# not move it AT ALL" was too strong - halving it is worth 0 s at A's lag
|
||||
# and +5 s at twice it - so the ramp is second-order, not absent. That is
|
||||
# the sensitivity the margin has to carry until SPIKE-04 measures the real
|
||||
# unit.
|
||||
#
|
||||
# UPPER, from the bill - 900 s, the quarter that sets the capacity tariff.
|
||||
# A site running short of one unit's share must be reported well inside
|
||||
# one.
|
||||
#
|
||||
# 185 s: thirty-seven control cycles. 6.2x the worst legitimate episode this
|
||||
# rig produces, 2.06x the worst a second unit FOUR TIMES slower than A
|
||||
# produces, 6.2x the firmware's own veto window, and 4.9x inside the
|
||||
# quarter. ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
|
||||
# 185 s: thirty-seven control cycles. Against the TEST-21 re-run: 5.29x the
|
||||
# worst legitimate episode this rig produces (35.0 s), 2.47x the worst a
|
||||
# hypothetical second unit FOUR TIMES slower than A produces (75.0 s), 6.2x
|
||||
# the firmware's own veto window (30 s), and 4.9x inside the quarter
|
||||
# (900 s). ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
|
||||
# is a different check on a different quantity, so a reader must not be
|
||||
# able to think it was carried across - and not 150 or 200.
|
||||
#
|
||||
# ⚠️ 185 STANDS AND IS NOT CHANGED. The record claimed 6.2x the measured
|
||||
# worst; the re-run makes it 5.29x. All four bounds still clear and none of
|
||||
# them narrowly, and the ordering of the argument is unchanged. A shipped
|
||||
# safety constant is not moved because a margin went from 6.2x to 5.3x while
|
||||
# every bound holds - moving it would re-open SAFETY-41's four-attempt
|
||||
# false-trip regime for nothing. What changed is the CLAIM, not the value.
|
||||
#
|
||||
# WHAT IT COSTS, stated rather than hidden: up to 185 s of a site running
|
||||
# short by one unit's share before anybody is told. Against a fault that ran
|
||||
# 6 h 25 m unnoticed, and against a guard that TAKES NO ACTION, this window
|
||||
@@ -363,6 +414,94 @@ DELIVERY_OPTION_DEFAULTS = {
|
||||
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-43. THE SECOND WAY TO BE NAMED, AND WHY IT IS A FRACTION OF THE ASK.
|
||||
#
|
||||
# `min_w` above is an ABSOLUTE gap, and OPT-09 made that a hole. `weights()`
|
||||
# shrinks a low-headroom unit's share, and shrinking the share shrinks the gap:
|
||||
# a unit standing at +125 W and delivering 0 W is 100 % delinquent and 125 W
|
||||
# short, so an absolute 500 W never names it. Measured end to end
|
||||
# (`test_packshare.py` part 8b, real `compute()`, real `plan()`, real
|
||||
# `delinquent()`, B wedged at a healthy 15 % against its own 10 % cut-out):
|
||||
#
|
||||
# load 1500 W, equal weights the wedged unit named on 199/200 cycles
|
||||
# load 1500 W, OPT-09 weights named on 0/200 cycles
|
||||
#
|
||||
# ⚠️ AND THE SUPPRESSED REGIME IS THE QUIET ONE. At a load the healthy sibling
|
||||
# covers, the meter is satisfied, `unmet_w` is 0 and no action-log line fires,
|
||||
# so this alarm is the ONLY instrument that would have told the operator.
|
||||
#
|
||||
# ⚠️ THE FIX IS NOT A SMALLER `min_w`, AND THAT IS THE WHOLE SHAPE OF THIS
|
||||
# CARD. `min_w` is `Tuning.saturation_w` and it exists to stop NORMAL
|
||||
# SATURATION being reported as delinquency - the crying-wolf failure the whole
|
||||
# module is written against, and the one SAFETY-41 spent four attempts on
|
||||
# ("i'm getting really annoyed by the notifications in my HA"). Lowering it
|
||||
# trades a missed alarm for a false one on every unit at once. A fraction of
|
||||
# the unit's OWN SHARE is a SECOND way to be named that leaves the first
|
||||
# exactly where it was: `gap > min_w OR gap > frac * share`. Nothing that was
|
||||
# silent before this card becomes noisy unless the unit is delivering almost
|
||||
# none of what it was asked for, which is not saturation-shaped at all.
|
||||
#
|
||||
# WHERE 0.85 COMES FROM - MEASURED ON THE SAME CAMPAIGN AS `delivery_hold_s`.
|
||||
# `admin/goodwe:sim/delivery_window.py --campaign --fractions` grades this
|
||||
# constant over the SAME 2640 no-fault runs (three families x 6 gains x 5 slews
|
||||
# x the write staggers, every unit executing the share it was given) that
|
||||
# produced the 35.0 s worst legitimate episode, at the shipped `min_w`, and
|
||||
# reports what the relative arm ADDS. Worst continuous legitimate episode,
|
||||
# worst of the three families:
|
||||
#
|
||||
# frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
|
||||
# worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
|
||||
#
|
||||
# frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
|
||||
# worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
|
||||
#
|
||||
# absolute arm alone: 35.0 s - SAFETY-42 §3.3's figure, re-measured in the
|
||||
# SAME runs, which is what makes every row above a comparison and not a claim.
|
||||
# N=1 control: 0.0 s at EVERY fraction, in all 2640 one-inverter runs.
|
||||
# Flapping family: 0.0 s at EVERY fraction, in all 840 runs.
|
||||
#
|
||||
# ⚠️ READ THE CURVE, NOT THE ROW. THERE IS NO CLIFF ANYWHERE IN IT. From 0.45
|
||||
# to 0.99 every figure is 40.0 or 45.0 s - within ONE CONTROL CYCLE (5.0 s) of
|
||||
# every other - and below 0.45 it rises gradually to 90.0 s at 0.05. It is not
|
||||
# monotone either (0.40 reports 60.0 s where 0.35 reports 55.0), which is
|
||||
# §3.3's own mechanism (c) rather than noise in the plant: a more sensitive rule
|
||||
# puts MORE units into the short set, which can turn a proper subset into the
|
||||
# WHOLE roster and END the episode. So this constant is INSENSITIVE over the
|
||||
# whole usable range, and that is the finding rather than a caveat on it. 0.85
|
||||
# sits in the flat part, 0.40 above the shoulder at ~0.45 and 0.15 below 1.0.
|
||||
#
|
||||
# ⚠️ AND "ZERO FALSE NAMINGS" IS NOT AVAILABLE AT ANY FRACTION - said here
|
||||
# because the card asked for exactly that. Every fraction adds naming CYCLES to
|
||||
# the no-fault campaign; a relative arm that never fired on a legitimate
|
||||
# transient would be one that never fired at all, because the modelled write
|
||||
# stagger lands B's number up to four cycles late and a unit whose new command
|
||||
# has not arrived IS delivering a small fraction of what is standing on its
|
||||
# wire. What is zero at every fraction is false ALARMS: the longest legitimate
|
||||
# episode anywhere in the 20-point sweep is 90.0 s - at 0.05, the most sensitive
|
||||
# fraction measured and far below anything this card would ship - against a
|
||||
# `delivery_hold_s` of 185 s, so NOTHING in 2640 no-fault runs comes within 95 s
|
||||
# of notifying an operator at any fraction measured. At 0.85 the hold is 4.62x
|
||||
# the worst legitimate episode where the absolute arm alone gave 5.29x. That is
|
||||
# the price, in full.
|
||||
#
|
||||
# ⚠️ THE DIRECTION IS THE OPPOSITE OF `min_w`'s AND IT IS EASY TO GET BACKWARDS.
|
||||
# A SMALLER fraction names MORE units - it is the more sensitive setting and the
|
||||
# more dangerous one - so the campaign puts a LOWER bound on this number, not an
|
||||
# upper one. It is bounded ABOVE by the card's own case: at 1.0 the arm can only
|
||||
# fire on a unit that is IMPORTING while asked to discharge, which is
|
||||
# SAFETY-30's fault and not this one, so a unit delivering NONE of its share
|
||||
# (gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
|
||||
# wedged box reporting a small standby figure rather than a clean zero.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT AN OPTION, FOR `delivery_w`'s REASON ONE PARAGRAPH UP. An
|
||||
# operator has no instrument to derive it with, a knob here is a knob on how
|
||||
# loud this add-on is, and SAFETY-41 is the record of what that costs. It is a
|
||||
# derived constant with a committed harness behind it, and the way to move it
|
||||
# is to re-run the campaign.
|
||||
DELIVERY_SHARE_FRACTION = 0.85
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryConfig:
|
||||
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
||||
@@ -481,12 +620,30 @@ def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def delinquent(units, min_w: float) -> dict:
|
||||
def delinquent(units, min_w: float,
|
||||
share_frac: float = DELIVERY_SHARE_FRACTION) -> dict:
|
||||
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
|
||||
|
||||
`units` one `UnitDelivery` per inverter, in roster order.
|
||||
`min_w` the shortfall that counts - `control.Tuning.saturation_w`. See
|
||||
`MIN_W_IS_SATURATION_W`.
|
||||
`units` one `UnitDelivery` per inverter, in roster order.
|
||||
`min_w` the ABSOLUTE shortfall that counts - `control.Tuning.
|
||||
saturation_w`. See `MIN_W_IS_SATURATION_W`.
|
||||
`share_frac` SAFETY-43. The RELATIVE shortfall that counts, as a fraction
|
||||
of that unit's own share. See `DELIVERY_SHARE_FRACTION`; it
|
||||
is a parameter rather than a read of the constant so the
|
||||
campaign that derived it can sweep it over the shipped code.
|
||||
⚠️ `None` MEANS THE ABSOLUTE ARM ALONE - SAFETY-42's rule,
|
||||
exactly - and it exists for ONE caller: the campaign in
|
||||
`admin/goodwe:sim/delivery_window.py`, which has to be able
|
||||
to measure what this card ADDS against what it replaced, run
|
||||
for run, over ONE tree. Nothing in the add-on passes it, and
|
||||
`main.py` passes nothing at all.
|
||||
|
||||
⚠️ THE TWO ARMS ARE AN `or` AND THE ORDER OF THAT SENTENCE MATTERS. The
|
||||
absolute arm is untouched by SAFETY-43: everything `min_w` named before it
|
||||
is still named, and nothing `min_w` refused becomes noisy unless the unit
|
||||
is delivering almost NONE of its ask. Lowering `min_w` would have been the
|
||||
other change - the one that reports normal saturation on every unit at once
|
||||
- and `DELIVERY_SHARE_FRACTION` says at length why it was refused.
|
||||
|
||||
Returns `{name: sentence}`, empty when there is nothing to say. The sentence
|
||||
is the operator's first line and it carries the peer clause, because "B is
|
||||
@@ -528,7 +685,13 @@ def delinquent(units, min_w: float) -> dict:
|
||||
gaps = {}
|
||||
for u in asked:
|
||||
gap = short_by(u.share_w, u.measured_w)
|
||||
if gap is not None and gap > float(min_w):
|
||||
if gap is None:
|
||||
continue
|
||||
# SAFETY-43: absolute OR relative. `share_w` is > 0 for every member
|
||||
# of `asked`, so the second comparison is always well formed.
|
||||
if gap > float(min_w) or (share_frac is not None
|
||||
and gap > float(share_frac)
|
||||
* float(u.share_w)):
|
||||
gaps[u.name] = gap
|
||||
|
||||
unaccounted = {u.name: gaps[u.name] for u in asked
|
||||
@@ -544,7 +707,13 @@ def delinquent(units, min_w: float) -> dict:
|
||||
out[u.name] = (
|
||||
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
|
||||
f"and delivering {float(u.measured_w):+.0f} W - "
|
||||
f"{unaccounted[u.name]:.0f} W of its share is not arriving - while "
|
||||
f"{unaccounted[u.name]:.0f} W of its share "
|
||||
# SAFETY-43. The PROPORTION is in the sentence because the watts
|
||||
# alone stopped being the whole story the moment a unit could be
|
||||
# named on the relative arm: "125 W is not arriving" reads like a
|
||||
# rounding error, and "125 W (100 % of its share)" is the fault.
|
||||
f"({100.0 * unaccounted[u.name] / float(u.share_w):.0f} % of it) "
|
||||
f"is not arriving - while "
|
||||
f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
|
||||
f"that state. Nothing this add-on reads explains it")
|
||||
return out
|
||||
|
||||
+362
-34
@@ -67,7 +67,8 @@ 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
|
||||
from .store import migrate_maint_keys, migrate_unit_keys
|
||||
@@ -137,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:
|
||||
@@ -165,7 +220,8 @@ def pack_energy_of(inv):
|
||||
|
||||
class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||
peak_db_path: str = "/data/tariff.db"):
|
||||
peak_db_path: str = "/data/tariff.db",
|
||||
price_history_path: str = "/data/price_history.json"):
|
||||
self.o = opts
|
||||
self.hass = hass
|
||||
self.store = store
|
||||
@@ -247,7 +303,40 @@ class Controller:
|
||||
hold_s=float(opts.get("delivery_hold_s",
|
||||
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
|
||||
)
|
||||
self.store_migration = migrate_unit_keys(store, UNIT_A)
|
||||
# SAFETY-44. THE NAMES THE MACHINES READ, HANDED TO THE MIGRATION THAT
|
||||
# WRITES FOR THEM. This line used to read `migrate_unit_keys(store,
|
||||
# UNIT_A)`, and the constant was wrong in exactly the way the
|
||||
# `maint_migration` call below says the SAME constant would be wrong -
|
||||
# ⚠️ this comment used to cite "line 315" and "two hundred lines
|
||||
# below", and both were wrong. The correction I wrote for them was
|
||||
# wrong too: it said "~54 lines down", which was review's distance to
|
||||
# the WRONG line, carried to a new referent without re-measuring -
|
||||
# inside the sentence apologising for a wrong number. There is no
|
||||
# distance here now, because a distance is a coordinate and the
|
||||
# sentence above already names the thing. On a declared roster the flat
|
||||
# latch landed under
|
||||
# `unit/inverter-a/`, which is on no roster, was read back under the
|
||||
# name it was handed, and stamped the marker. `roster_names()` is the
|
||||
# naming rule itself and returns `[UNIT_A]` on the flat shape, so the
|
||||
# upgrade path every install runs today is byte-identical.
|
||||
#
|
||||
# ⚠️ AND `misdirected` IS UNREACHABLE FROM **THIS** CALL SITE, WHICH THE
|
||||
# COMMIT MESSAGE ORIGINALLY CLAIMED AS A BENEFIT. `unit` is
|
||||
# `roster_names(opts)[0]` and `build_roster()` names its entries from
|
||||
# the same `roster_names()` list, so `unit` is a member of `roster` by
|
||||
# construction and the refusal can never fire here. Review measured it:
|
||||
# dropping the `roster` argument from the `_verify_moves()` call inside
|
||||
# `migrate_unit_keys()` survives 28/28.
|
||||
#
|
||||
# THE ARGUMENT STAYS ANYWAY, and the reason is the one `_verify_moves()`
|
||||
# already gives for keeping its own `roster=None` branch: it is what a
|
||||
# NEW caller gets, and the grading it enables is measured at the
|
||||
# function's own level in test_maintenance.py rather than through here.
|
||||
# What is NOT true is that this call site buys the refusal, so that
|
||||
# claim is gone rather than restated.
|
||||
_roster_names = roster_names(opts)
|
||||
self.store_migration = migrate_unit_keys(
|
||||
store, _roster_names[0], _roster_names)
|
||||
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
|
||||
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
|
||||
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
|
||||
@@ -549,6 +638,42 @@ class Controller:
|
||||
self.peak.restore(self.p1.averager)
|
||||
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
|
||||
|
||||
# FCST-04. The §8.3 carry-forward store: the published prices of the
|
||||
# last few Brussels days, so the hours a day-ahead feed has not printed
|
||||
# yet can be filled from yesterday's profile and FLAGGED, instead of
|
||||
# being absent from the series entirely.
|
||||
#
|
||||
# ⚠️ IT IS AN INSTRUMENT'S BACKING FILE, NOT A STRATEGY. It commands
|
||||
# nothing and claims nothing; what it changes is how many hours the
|
||||
# plan covers. Before this, a dynamic site between midnight and the
|
||||
# 14-16 h CET publication had a series covering only the rest of today
|
||||
# - typically 10-24 of the 48 hours `price_horizon_h` asked for - and
|
||||
# `TariffPlan.arbitrage()` took its spread over that truncated window
|
||||
# without anything reporting that it had. Every carried hour is
|
||||
# provisional, so refusal 3 drops it from the trade set: CARRYING a
|
||||
# price widens what the optimiser can SEE, never what it may act on.
|
||||
#
|
||||
# ⚠️ AND THE CAVEAT THAT BELONGS WITH THAT SENTENCE, because this is
|
||||
# the one place a maintainer reads it on its own. THE OTHER HALF OF
|
||||
# THIS CHANGE DOES MOVE ENERGY, and it is the windowing rather than the
|
||||
# carrying: the filled series covers `[since, until)`, so hours that
|
||||
# have ALREADY ELAPSED are no longer in the spread. A cheap night that
|
||||
# has gone used to sit in the series as the day's cheapest hour, and no
|
||||
# cycle can buy in an hour that is past - so the trade was quietly
|
||||
# never taken. `resolve_tariff()`'s docstring, DOCS.md and the CHANGELOG
|
||||
# all carry this beside the guarantee; it may not appear here without it.
|
||||
#
|
||||
# ⚠️ A BROKEN OR ABSENT STORE IS NOT AN ERROR HERE. It answers `no_data`,
|
||||
# the fill carries nothing, and the plan is the pre-FCST-04 one minus
|
||||
# the elapsed hours - short, but honest and still refusing on the same
|
||||
# rules. It must not be an error in the other sense either: a day
|
||||
# record this build cannot read is DROPPED by `PriceHistory._load()`
|
||||
# and reported in `.words`, because both the constructor above and the
|
||||
# `.words` read below are unguarded, and a cache that raises here is an
|
||||
# add-on that does not start.
|
||||
self.price_history = PriceHistory(price_history_path)
|
||||
_LOG.info("§8.3 price history: %s", self.price_history.words)
|
||||
|
||||
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
|
||||
# design (peakguard.py holds no history between calls), so the only
|
||||
# state here is what an EDGE needs: what the last cycle concluded, so
|
||||
@@ -751,23 +876,132 @@ class Controller:
|
||||
legacy_tripped = bool(data.get("sign_tripped", False))
|
||||
if not legacy_tripped or not self.inverters:
|
||||
return
|
||||
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET", AND IT IS ASKED OF
|
||||
# THE STORE RATHER THAN OF THE ROSTER. At a roster of two a trip on B
|
||||
# leaves A untripped, so re-arming A off a flat key that B's own trip
|
||||
# mirrored would latch a healthy inverter on every restart - the
|
||||
# flat-key defect this card removed, coming back through its own guard.
|
||||
# And the STORE is the right place to ask: it holds every
|
||||
# `unit/*/sign_tripped` this install has ever written, including one
|
||||
# belonging to a unit that is not in TODAY's roster (a roster is
|
||||
# configuration and it changes), whereas the roster only knows what is
|
||||
# configured right now. The flat key carries new information only when
|
||||
# nothing namespaced is latched at all.
|
||||
namespaced_latched = any(
|
||||
bool(v) for k, v in data.items()
|
||||
if k.startswith("unit/") and k.endswith("/sign_tripped"))
|
||||
if namespaced_latched or any(inv.sign_wd.tripped
|
||||
for inv in self.inverters):
|
||||
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET **THAT A MACHINE
|
||||
# READS**", AND THE LAST CLAUSE IS SAFETY-44's SECOND HALF. At a roster
|
||||
# of two a trip on B leaves A untripped, so re-arming A off a flat key
|
||||
# that B's own trip mirrored would latch a healthy inverter on every
|
||||
# restart - the flat-key defect DEV-02 removed, coming back through its
|
||||
# own guard. That much stands, and it is why the question is asked of
|
||||
# the STORE's keys rather than of the roster's objects.
|
||||
#
|
||||
# ⚠️ BUT IT USED TO ACCEPT **ANY** `unit/*/sign_tripped`, and the
|
||||
# paragraph here defended that: the store "holds every one this install
|
||||
# has ever written, including one belonging to a unit that is not in
|
||||
# TODAY's roster ... whereas the roster only knows what is configured
|
||||
# right now". THE PREMISE IS TRUE AND IT ARGUES THE WRONG WAY. A latch
|
||||
# under a name no unit answers to is not protection an operator has - it
|
||||
# is a key nothing reads, which is the definition of the state this
|
||||
# method exists to repair. Counting it as protection made the guard
|
||||
# return early on precisely the install that needed it, and that is not
|
||||
# hypothetical: it is where the migration above USED to put the latch
|
||||
# on every declared roster, so the guard was blinded by its own upgrade.
|
||||
#
|
||||
# THE MIGRATION FIX DOES NOT REACH THOSE INSTALLS. A store stamped by
|
||||
# the shipped version already has the marker, so `migrate_unit_keys()`
|
||||
# returns at the version check and never runs again. This guard is the
|
||||
# ONLY repair path for a site that upgraded before this card, and it
|
||||
# re-arms onto `inverters[0]` - which lands the latch back under a name
|
||||
# the roster reads.
|
||||
# ⚠️ AND "OFF THE ROSTER" IS NOT ENOUGH ON ITS OWN - IT HAS TO BE THE
|
||||
# MIGRATION'S OWN FINGERPRINT, WHICH IS `UNIT_A`. The first draft of
|
||||
# this treated EVERY off-roster latch as litter, and test_inverter.py's
|
||||
# DEV-02 restart case caught it: there the store carries
|
||||
# `unit/inverter-b/sign_tripped = True` from B's own trip while the
|
||||
# roster being graded holds only A, and the blunt rule re-armed a
|
||||
# HEALTHY A off the flat key that B's trip had mirrored - DEV-02's
|
||||
# defect rebuilt inside its own guard, which is exactly what the
|
||||
# paragraph above was right to be afraid of.
|
||||
#
|
||||
# `UNIT_A` is the discriminator because it is the ONLY name the broken
|
||||
# migration ever wrote. `roster_names()` falls back to a POSITIONAL
|
||||
# `inverter-N`, so `inverter-a` is not a name this code hands to anyone
|
||||
# else. A latch stranded under any OTHER name is a real unit that is
|
||||
# out of today's config - a roster is configuration and it changes -
|
||||
# and it still counts as protection.
|
||||
#
|
||||
# ⚠️ THE RESIDUAL, STATED RATHER THAN LEFT TO BE FOUND: a site whose
|
||||
# own first unit is NAMED `inverter-a`, latched, and then taken out of
|
||||
# the roster gets re-armed on `inverters[0]`. That is a healthy unit
|
||||
# held at 0 W until an operator clears it, which is the conservative
|
||||
# direction; SAFETY-44's is a site DRIVING on a state file that says it
|
||||
# is latched.
|
||||
#
|
||||
# ⚠️ AND THE SECOND HALF OF THAT RESIDUAL IS THE **SENTENCE**, NOT THE
|
||||
# BOOLEAN. The reason re-armed from is the FLAT mirror, which carries
|
||||
# whatever tripped LAST - so on a multi-unit site this path can print
|
||||
# one inverter's evidence under another inverter's name. The latch is
|
||||
# right and the explanation may not be. It is disclosed here because the
|
||||
# banner is what an operator acts on, and `test_maintenance.py`'s check
|
||||
# that the reason is not the removed unit's is guaranteed by that
|
||||
# fixture's choice of flat string, NOT by this code.
|
||||
# ⚠️ AND THE THREE CASES ARE THREE VARIABLES, NOT ONE SHARED FLAG.
|
||||
# THIS IS WHERE THE FIRST VERSION OF THIS FIX WAS DEFEATED, BY ONE KEY.
|
||||
# It wrote `namespaced_latched = True` for BOTH "on the roster" and "off
|
||||
# the roster but not UNIT_A", so a single latched key belonging to a
|
||||
# unit the operator had removed from `inverters:` vetoed the repair of
|
||||
# the misdirected `inverter-a` latch sitting beside it. Measured on the
|
||||
# shipped build, roster ['garage', 'shed'], store stamped so the
|
||||
# migration cannot reach it:
|
||||
#
|
||||
# sign_tripped + unit/inverter-a/sign_tripped
|
||||
# -> [('garage', True), ('shed', False)] repaired
|
||||
# ...AND unit/old-shed/sign_tripped
|
||||
# -> [('garage', False), ('shed', False)] ⚠️ DRIVING
|
||||
#
|
||||
# A site DRIVING on a state file that reads `sign_tripped: true`, which
|
||||
# is the sentence this whole card exists to make false - and
|
||||
# `tools/rehearse_roster.py` said so, on the unmodified tree, exit 1.
|
||||
#
|
||||
# ⚠️ IT IS ALSO INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md
|
||||
# ADDED TWO DAYS AGO. The two fixtures written for this card each hold
|
||||
# ONE arrangement - `_staged_store` has only `inverter-a`, `_other_store`
|
||||
# has only `inverter-b` - and the flag is written twice with opposite
|
||||
# intents only when BOTH are present. A per-key check whose keys have
|
||||
# never disagreed WITHIN ONE STORE is a check over whichever key came
|
||||
# last. The fixture below now holds both.
|
||||
#
|
||||
# THE THREE CASES, AND WHY THE ANSWER DIFFERS FOR EACH:
|
||||
# on the roster a machine reads it: real protection, stop.
|
||||
# off-roster, `UNIT_A` the broken migration's fingerprint - this
|
||||
# IS the flat latch, misdirected. Repair.
|
||||
# off-roster, other name a real unit out of today's config. Its
|
||||
# latch is unread too, but it is NOT the flat
|
||||
# key's misdirection, and re-arming a healthy
|
||||
# sibling off it is DEV-02's defect. Stop -
|
||||
# UNLESS a stranded `UNIT_A` latch is also
|
||||
# present, because then the flat key IS
|
||||
# accounted for and the repair is the point.
|
||||
on_roster = {inv.name for inv in self.inverters}
|
||||
stranded, on_roster_latched, other_off_roster = [], False, False
|
||||
for k, v in data.items():
|
||||
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
|
||||
continue
|
||||
if not bool(v):
|
||||
continue
|
||||
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1 -
|
||||
# split through the writer's own spelling rather than re-deriving.
|
||||
unit = k.split("/")[1]
|
||||
if unit in on_roster:
|
||||
on_roster_latched = True
|
||||
elif unit == UNIT_A:
|
||||
stranded.append(k)
|
||||
else:
|
||||
other_off_roster = True
|
||||
if on_roster_latched or any(inv.sign_wd.tripped
|
||||
for inv in self.inverters):
|
||||
return
|
||||
if other_off_roster and not stranded:
|
||||
# A removed unit's own latch, and no misdirected one. The flat key
|
||||
# is that unit's mirror, so it carries no new information and
|
||||
# re-arming `inverters[0]` from it would latch a healthy pack.
|
||||
return
|
||||
if stranded:
|
||||
_LOG.error(
|
||||
"SAFETY-44: this store carries a LATCHED sign-disagreement "
|
||||
"failsafe under %s, and that unit is NOT on today's roster "
|
||||
"%s - so no machine reads it and the site came up DRIVING on "
|
||||
"a state file that says it is latched. Re-arming below.",
|
||||
", ".join(sorted(stranded)), sorted(on_roster))
|
||||
first = self.inverters[0]
|
||||
# The one state this method exists for.
|
||||
reason = str(data.get("sign_reason", "") or "")
|
||||
@@ -3300,25 +3534,99 @@ 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:
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
plan = await resolve_tariff(
|
||||
self.o, now, now + timedelta(hours=horizon), now=now)
|
||||
self.tariff_plan, self.tariff_at = plan, now
|
||||
_LOG.info("tariff refreshed: %s", plan.words)
|
||||
self.o, now, now + timedelta(hours=horizon), now=now,
|
||||
history=self.price_history)
|
||||
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):
|
||||
@@ -4162,9 +4470,24 @@ class Controller:
|
||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||
("Cheap window", "yes" if self.cheap else "no"),
|
||||
("Tariff", self.tariff_words),
|
||||
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
|
||||
# between a 48-hour plan and a 12-hour one on a weekday morning.
|
||||
# It is its own row rather than a clause of "Tariff" because it
|
||||
# answers a different question: "Tariff" says what the plan IS, and
|
||||
# this says what the plan could still be filled from if the feed is
|
||||
# late again tomorrow.
|
||||
("Price history", self.price_history.words),
|
||||
# OPT-04. The strategy's own sentence, not a re-derivation of it -
|
||||
# every refusal it can make already carries a reason a human can
|
||||
# act on, and "Arbitrage: no" would throw all of them away.
|
||||
#
|
||||
# ⚠️ 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()),
|
||||
]
|
||||
@@ -4417,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
|
||||
|
||||
@@ -57,8 +57,8 @@ deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
|
||||
never rises. That module DETECTS it; this one cannot fix it, and saying so
|
||||
is worth more than a weighting scheme that looks like it might.
|
||||
|
||||
⚠️ AND IT IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's
|
||||
DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2
|
||||
⚠️ AND IT WAS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROWED SAFETY-42's
|
||||
DETECTION WINDOW - A DETECTION REGRESSION AT N=2, NOW CLOSED BY SAFETY-43
|
||||
=======================================================================
|
||||
This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said
|
||||
with it - that a wedged unit's weight does not move, so OPT-09 cannot see the
|
||||
@@ -68,27 +68,40 @@ pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight
|
||||
moves whenever its headroom differs from its sibling's - the ordinary case this
|
||||
module exists to exploit.
|
||||
|
||||
`delivery.delinquent()` names a unit only while its `short_by()` gap exceeds
|
||||
`delivery.delinquent()` named a unit only while its `short_by()` gap exceeded
|
||||
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
|
||||
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
|
||||
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
|
||||
own 10 % floor, so `envelope()` does not close and `account()` explains
|
||||
nothing): at a 1500 W load the healthy pack can carry alone, equal weights name
|
||||
the wedged unit on 199 of 200 cycles and these weights name it on NONE. The
|
||||
suppression window widens from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it has
|
||||
own 10 % cut-out, so `envelope()` does not close and `account()` explains
|
||||
nothing): at a 1500 W load the healthy pack can carry alone, equal weights named
|
||||
the wedged unit on 199 of 200 cycles and these weights named it on NONE. The
|
||||
suppression window widened from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it had
|
||||
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
|
||||
hands the wedged unit a big share again and the alarm returns.
|
||||
hands the wedged unit a big share again and the absolute arm names it.
|
||||
|
||||
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE - KEPT HERE BECAUSE IT IS A MEASUREMENT, EVEN THOUGH SAFETY-43 HAS SINCE MADE THE WINDOW IRRELEVANT TO THE DETECTOR. Swept on this fixture with the ABSOLUTE arm alone, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returned BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm named the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK
|
||||
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
|
||||
is a disclosed residual, not a fixed thing. Closing it means making
|
||||
`delinquent()` relative to the ask rather than absolute - SAFETY-42's code
|
||||
and a design change, filed as its own card and deliberately NOT done here.
|
||||
Do not "fix" part 8b by making it assert the alarm still fires; it does not,
|
||||
and a green check that says otherwise would be worse than the gap.
|
||||
⚠️ THAT IS NO LONGER A RESIDUAL. SAFETY-43 made `delinquent()` relative to
|
||||
the ask AS WELL AS absolute - `gap > min_w OR gap > frac * share`, and a
|
||||
unit delivering 0 of 125 W is as delinquent as one delivering 0 of 1500 W.
|
||||
On the SAME fixture, under the SAME weights, the wedged unit is now named
|
||||
on 200 of 200 cycles. Part 8b grades both rules on one run:
|
||||
`share_frac=None` is SAFETY-42's absolute arm alone and still says nothing,
|
||||
which is what keeps the paragraph above honest rather than nostalgic.
|
||||
|
||||
⚠️ AND `min_w` WAS NOT LOWERED. The absolute arm's suppression window is
|
||||
still exactly the arithmetic above - 1000 W at equal weights, 6500 W at
|
||||
these - because a lower `saturation_w` would report NORMAL SATURATION as
|
||||
delinquency on every unit at once, which is the false-trip regime SAFETY-41
|
||||
spent four attempts in. What changed is that there is a SECOND way to be
|
||||
named; nothing that was quiet before became noisy.
|
||||
|
||||
⚠️ WHAT THIS MODULE STILL CANNOT DO IS UNCHANGED. OPT-09 stops the site
|
||||
ASKING a pack whose own SoC says it cannot deliver; it does not, and will
|
||||
not, re-allocate a wedged unit's watts to its sibling - `delivery.py`'s
|
||||
"IT REPORTS. IT DOES NOT ACT" block is the measurement behind that, and
|
||||
SAFETY-43 did not touch it. The alarm is louder; the loop is identical.
|
||||
|
||||
WHY ENERGY AND NOT POWER
|
||||
========================
|
||||
|
||||
+533
-15
@@ -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).
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -459,6 +541,121 @@ class PriceRead(_Outcome):
|
||||
return _NoData(reason)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# How long ONE point covers - TARIFF-14
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# ⚠️ A POINT DOES NOT CARRY ITS OWN DURATION AND MUST NOT LEARN TO. §9.3 lists
|
||||
# THREE fields and `PricePoint` has three; a fourth would change the wire
|
||||
# format every store, replay, fixture and captured body in this repository is
|
||||
# written against. The span is DERIVED from the neighbours instead - here,
|
||||
# once, at construction.
|
||||
MAX_POINT_SPAN = timedelta(hours=1)
|
||||
|
||||
|
||||
def _cover_table(points) -> tuple:
|
||||
"""`(start_utc, end_utc, point)` per point, ascending. The `at()` index.
|
||||
|
||||
THE DEFECT THIS PREVENTS (TARIFF-14, HIGH). `at()` asked
|
||||
`s <= u < s + 1 h` of every point IN LIST ORDER and returned the FIRST
|
||||
match. On an hourly series - the only resolution any fixture carried
|
||||
before TARIFF-08 - that is right by accident. Belgium moved to a
|
||||
15-minute MTU on 2025-10-01 and the live day-ahead feed returns 96 points
|
||||
at 900 s, where it is wrong twice over. Measured on shipped `5059e28`:
|
||||
|
||||
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14
|
||||
|
||||
at(02:15) -> 02:00 the :00 point's HOUR has not run out yet
|
||||
at(03:15) -> 02:30 and from the second hour on it is not even the
|
||||
top of the hour: the first point still inside
|
||||
its own hour wins, so the error COMPOUNDS with
|
||||
depth into the series
|
||||
|
||||
`arbitrage._act()` compares `here.start` against `v.cheapest.start` and
|
||||
`v.dearest.start` to decide whether this interval is the buy or the sell
|
||||
one. That identity test almost never matched the extremum, so the
|
||||
strategy idled through exactly the interval it exists to trade - and read
|
||||
`here.provisional` and §17's `price < 0` guard off the wrong point while
|
||||
doing it. `static_tariff.py` emits hourly points, so static sites never
|
||||
saw it; this is reachable only down the dynamic path TARIFF-08/09 made
|
||||
work.
|
||||
|
||||
THE RULE, in one sentence:
|
||||
|
||||
A POINT COVERS FROM ITS OWN START UNTIL THE NEXT POINT'S START, AND
|
||||
NEVER MORE THAN ONE HOUR.
|
||||
|
||||
* 900 s -> each point covers its own quarter, so `at(02:15)` is the
|
||||
02:15 point;
|
||||
* 3600 s -> each point covers its own hour, unchanged from shipped;
|
||||
* MIXED -> EACH POINT TAKES ITS OWN GAP. FCST-04's carry-forward emits
|
||||
HOURLY carried points beside QUARTER-HOURLY firm ones, and the soak
|
||||
measured spacing `[900, 3600]` in 70 % of samples - so mixed is the
|
||||
normal steady state, and a window derived from one global spacing
|
||||
would be wrong THERE rather than in an edge case.
|
||||
|
||||
⚠️ WHY THE ONE-HOUR CAP, WHICH IS NOT DECORATION. FCST-04 LEAVES HOLES ON
|
||||
PURPOSE: an hour with no published price AND no yesterday value is left
|
||||
OUT of the series, and `price_forecast.py`'s module note names `at()`
|
||||
raising `Absence` for it as the behaviour that sends the caller to the
|
||||
static profile (§11.4 for that hour). Uncapped, "until the next point's
|
||||
start" would stretch the point BEFORE a three-hour hole across the whole
|
||||
hole and answer every instant in it with a stale price. Filling holes is
|
||||
the defect this module exists to refuse, and it would have been
|
||||
introduced by the fix for a different one.
|
||||
|
||||
⚠️ THE LAST POINT'S WINDOW IS DECIDED, NOT INHERITED. There is no next
|
||||
start and nothing in §9.3 says how wide the final interval is, so THE
|
||||
LAST POINT IS GIVEN THE SAME SPAN AS THE POINT BEFORE IT - one hour when
|
||||
it is the only point. Three consequences, all intended:
|
||||
|
||||
* on a 96-point 900 s day the 23:45 point covers 23:45 .. 00:00, so
|
||||
`at(23:50)` ANSWERS and `at(00:00)` raises `Absence`. A flat one-hour
|
||||
tail would have answered 00:30 TOMORROW with today's last price,
|
||||
which is reaching for a neighbour across a day boundary;
|
||||
* on an hourly series the tail is an hour, exactly as it shipped, so no
|
||||
static or hourly site changes behaviour;
|
||||
* on a MIXED series whose last point is a carried HOURLY one behind
|
||||
quarter-hourly firm ones, the tail is 900 s and the last 45 minutes
|
||||
of the horizon raise `Absence`. That is the SAFE direction and it is
|
||||
accepted rather than tolerated: the caller falls back to the static
|
||||
profile (§11.4) instead of trading on a span the series never
|
||||
demonstrated, and a carried point is provisional, so §8.3 forbids any
|
||||
irreversible action resting on it regardless.
|
||||
|
||||
⚠️ ORDER IS ESTABLISHED HERE, NOT ASSUMED - and that is the difference
|
||||
between this and the rule it replaces. `at()` now reads neighbours, so it
|
||||
WOULD depend on sortedness; the table is therefore sorted by UTC start at
|
||||
construction, and no caller's list order can reach it. `_validate_raw()`
|
||||
rejects out-of-order and duplicate starts on the `load_prices()` path,
|
||||
`price_history._parse_points()` re-sorts what it reads off disk, and
|
||||
FCST-04's fill emits ascending - but `PriceRead.series()` itself has
|
||||
never enforced any of that, and an assumption that holds today and is
|
||||
unstated is how this class of defect recurs.
|
||||
|
||||
⚠️ NOTHING HERE RAISES ON A MALFORMED SERIES. A duplicated start - not
|
||||
reachable through `load_prices()`, which refuses it - gives the earlier
|
||||
list entry a ZERO-width window and the later one the real span, so one of
|
||||
the pair becomes unreachable through `at()` and neither the 30 s control
|
||||
path nor construction raises.
|
||||
"""
|
||||
rows = sorted(((p.start.astimezone(timezone.utc), i, p)
|
||||
for i, p in enumerate(points)),
|
||||
key=lambda r: (r[0], r[1]))
|
||||
out = []
|
||||
for n, (start, _i, point) in enumerate(rows):
|
||||
if n + 1 < len(rows):
|
||||
span = rows[n + 1][0] - start # -> the next point's start
|
||||
elif n:
|
||||
span = start - rows[n - 1][0] # -> the tail, see above
|
||||
else:
|
||||
span = MAX_POINT_SPAN # -> a series of one point
|
||||
if span > MAX_POINT_SPAN:
|
||||
span = MAX_POINT_SPAN
|
||||
out.append((start, start + span, point))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
class _Series(PriceRead):
|
||||
kind = "series"
|
||||
|
||||
@@ -482,6 +679,10 @@ class _Series(PriceRead):
|
||||
self.provider = provider
|
||||
self.fetched_at = fetched_at
|
||||
self.regime = volatility_regime(product)
|
||||
# ⚠️ TARIFF-14. Built ONCE, here, so that `at()` scans an index whose
|
||||
# order it established itself rather than one it hopes it was handed.
|
||||
# See `_cover_table()` for the window rule and for what the tail does.
|
||||
self._cover = _cover_table(self.points)
|
||||
self.words = (f"{len(self.points)} points from {provider} "
|
||||
f"({product.value}, {self.regime} thresholds)")
|
||||
|
||||
@@ -490,25 +691,47 @@ class _Series(PriceRead):
|
||||
return {"prices": [p.canonical() for p in self.points]}
|
||||
|
||||
def at(self, instant: datetime) -> PricePoint:
|
||||
"""The point covering `instant`. Raises `Absence` if the series does not.
|
||||
"""The point whose OWN interval covers `instant`, at ANY resolution.
|
||||
|
||||
⚠️ RETURNS A POINT OR RAISES; it never returns None. `series.at(t) or
|
||||
fallback` is the same defaulting bug one level down, so the falsy
|
||||
answer does not exist. Ask `covers()` first if you mean to branch.
|
||||
|
||||
⚠️ TARIFF-14: THE WINDOW IS NOT ONE HOUR WIDE ANY MORE, AND WAS NEVER
|
||||
ENTITLED TO BE. It runs to the next point's start, capped at an hour -
|
||||
`_cover_table()` above holds the rule, the tail decision and the
|
||||
reproduction on a quarter-hourly series. A fixed `s + 1 h` here
|
||||
returned the FIRST point still inside its own hour, which on the live
|
||||
900 s feed is neither the covering point nor, past the first hour,
|
||||
even the top of the hour.
|
||||
|
||||
A linear scan over the index, which is 192 entries on the shipped 48 h
|
||||
horizon. This runs on the 30 s control path through
|
||||
`arbitrage._point_at()`; a bisect would be correct and is not worth
|
||||
the second way to be wrong at this size.
|
||||
"""
|
||||
u = instant.astimezone(timezone.utc)
|
||||
for p in self.points:
|
||||
s = p.start.astimezone(timezone.utc)
|
||||
if s <= u < s + timedelta(hours=1):
|
||||
return p
|
||||
for start, end, point in self._cover:
|
||||
if start <= u < end:
|
||||
return point
|
||||
raise Absence(
|
||||
f"no price point covers {u.isoformat()}; the series runs "
|
||||
f"{self.points[0].start.isoformat()} .. "
|
||||
f"{self.points[-1].start.isoformat()}. An hour outside the series "
|
||||
f"is an hour with no price, which is §11.4 for that hour - not a "
|
||||
f"reason to reach for a neighbouring one.")
|
||||
f"{self.points[-1].start.isoformat()}. An instant outside the "
|
||||
f"series - or inside a hole FCST-04 left because nothing "
|
||||
f"published that hour - is an instant with no price, which is "
|
||||
f"§11.4 for it, not a reason to reach for a neighbouring one.")
|
||||
|
||||
def covers(self, instant: datetime) -> bool:
|
||||
"""Whether `at(instant)` would answer.
|
||||
|
||||
⚠️ SHARED TARIFF-14 EXACTLY, AND IS FIXED BY THE SAME CHANGE. It has
|
||||
no window rule of its own - it asks `at()` - so on the 900 s feed it
|
||||
answered True for instants no point covered and False for none of
|
||||
them, because the hour-wide window is never SHORTER than the truth.
|
||||
It is kept as a delegation for that reason: a second copy of the rule
|
||||
is a second place for it to be wrong.
|
||||
"""
|
||||
try:
|
||||
self.at(instant)
|
||||
return True
|
||||
@@ -516,7 +739,17 @@ class _Series(PriceRead):
|
||||
return False
|
||||
|
||||
def window(self, since: datetime, until: datetime) -> tuple:
|
||||
"""Points whose start falls in [since, until). May legitimately be empty."""
|
||||
"""Points whose START falls in [since, until). May legitimately be empty.
|
||||
|
||||
⚠️ DID NOT SHARE TARIFF-14, AND THE REASON IS WORTH STATING RATHER
|
||||
THAN REDISCOVERING. This grades a point's `start` against a half-open
|
||||
range and never asks how long a point lasts, so it carries no
|
||||
resolution assumption to be wrong about - the same call answers the
|
||||
same set at 900 s, at 3600 s and mixed. It is therefore NOT a
|
||||
coverage query: a point that starts before `since` and runs into it
|
||||
is absent from this tuple by design. `at()`/`covers()` are the
|
||||
coverage questions.
|
||||
"""
|
||||
a = since.astimezone(timezone.utc)
|
||||
b = until.astimezone(timezone.utc)
|
||||
return tuple(p for p in self.points
|
||||
@@ -684,6 +917,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 +1005,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 +1271,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 +1439,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 +1463,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 +1708,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")
|
||||
|
||||
@@ -48,12 +48,24 @@ So the flag is not passed through anywhere in this file:
|
||||
the object the provider produced is the object in the output series - so
|
||||
there is no copy that could lose or gain a flag. `filled.points[i] is
|
||||
fetched.points[j]` is a checkable property, and test_price_forecast.py
|
||||
checks it.
|
||||
checks it. EVERY published point in the window is passed through, not one
|
||||
per hour: see the sub-hourly note below.
|
||||
* `_audit()` RE-DERIVES the expected flag for every output point from the set
|
||||
of hours the feed actually published, and RAISES if any point disagrees.
|
||||
It is a post-condition, not a filter: it never fixes a flag, because a
|
||||
module that quietly corrects its own provenance is one whose provenance
|
||||
cannot be trusted.
|
||||
|
||||
⚠️ IT GRADES THE POINTS THAT ARE EMITTED AND NOTHING ABOUT THE ONES THAT
|
||||
ARE NOT (FCST-05). `_audit()` is a provenance check, NOT a completeness
|
||||
check: fed FCST-04's own decimation defect - four published points in an
|
||||
hour, only the last emitted - it ACCEPTS, and it accepts an empty output
|
||||
too. Measured, and the FCST-03 audit it replaced was equally blind, so
|
||||
this is a limit rather than a regression. What actually guards against a
|
||||
decimated series is the point COUNT and the pass-through IDENTITY checks
|
||||
in `test_price_forecast.py` (section on the quarter-hourly feed) and
|
||||
`test_price_history.py`; see `_audit()`'s own docstring for why the
|
||||
completeness rule is not expressible from its two arguments.
|
||||
* and test_price_forecast.py walks THIS FILE'S AST and fails if any
|
||||
`PricePoint(...)` in it passes anything but a literal for `provisional`, so
|
||||
the pass-through idiom cannot come back in a later edit and pass review by
|
||||
@@ -90,6 +102,24 @@ which is the row the spec actually writes for "no price data". If NOTHING can
|
||||
be produced, the result is `PriceRead.no_data(reason)` and not an empty series,
|
||||
because an empty series is not constructible on purpose (price.py trap 3).
|
||||
|
||||
⚠️ A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR, and the hourly walk
|
||||
below is about WHICH HOURS NEED FILLING - never about what a published hour is
|
||||
allowed to contain. `eneco_eddy` is the Belgian imbalance product and settles
|
||||
per QUARTER, `_validate_raw()` accepts any strictly-increasing spacing, and a
|
||||
published hour may therefore hold four points. So EVERY published point inside
|
||||
the window is passed through, in order, and a carried point is added only for a
|
||||
whole hour with NO published coverage at all.
|
||||
|
||||
The alternative was measured rather than reasoned about, because FCST-04's
|
||||
first caller shipped it: keying the published set as `{_utc_hour(p.start): p
|
||||
for p in r.points}` reads perfectly ordinary and keeps the LAST point of each
|
||||
hour, dropping the other three. On a 96-point quarter-hourly feed - all firm,
|
||||
fully published, nothing to carry - the series came out 15 points long, the
|
||||
arbitrage spread collapsed from 0.143 to 0.014 EUR/kWh, and the cheapest
|
||||
"hour" became a `:45` point whose `at()` window runs into the next hour. A feed
|
||||
losing 84 % of its own prints is not a windowing effect and is not something a
|
||||
provisional flag would ever have reported.
|
||||
|
||||
|
||||
AC2: THE IRREVERSIBLE-ACTION GATE
|
||||
=================================
|
||||
@@ -156,6 +186,25 @@ def _utc_hour(instant: datetime) -> datetime:
|
||||
return u.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _published_by_hour(read: PriceRead) -> dict:
|
||||
"""UTC hour -> the provider's OWN points that start inside it, in order.
|
||||
|
||||
⚠️ A TUPLE PER HOUR, NOT A POINT, AND THAT IS THE WHOLE OF THE SUB-HOURLY
|
||||
RULE. `{_utc_hour(p.start): p for p in r.points}` reads identically, is one
|
||||
character shorter, and silently keeps one point per hour - the last one
|
||||
written. The mapping answers two DIFFERENT questions here: "does this hour
|
||||
need filling at all?" is `u in published`, and "what does the output carry
|
||||
for it?" is all of `published[u]`. Collapsing them is how a quarter-hourly
|
||||
feed loses three quarters of its prints.
|
||||
"""
|
||||
out: dict = {}
|
||||
read.match(
|
||||
series=lambda r: [out.setdefault(_utc_hour(p.start), []).append(p)
|
||||
for p in r.points],
|
||||
no_data=lambda _r: [])
|
||||
return {u: tuple(ps) for u, ps in out.items()}
|
||||
|
||||
|
||||
def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
|
||||
"""A point filled from yesterday's profile. §8.3: ALWAYS provisional.
|
||||
|
||||
@@ -172,37 +221,122 @@ def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
|
||||
def _audit(points, published) -> None:
|
||||
"""Post-condition: every flag matches the point's TRUE provenance (AC1).
|
||||
|
||||
`published` maps a UTC hour to the point the provider produced for it.
|
||||
`published` maps a UTC hour to the TUPLE of points the provider produced
|
||||
for it (`_published_by_hour()`). An hour ABSENT from that mapping is an hour
|
||||
the feed did not print, and a carried point is the only thing that may
|
||||
stand in it.
|
||||
|
||||
The rule, in the two forms an output point can take:
|
||||
|
||||
* PUBLISHED - the point must BE one of `published[u]`, by identity, and
|
||||
may be emitted only once. Its flag is whatever the feed said, never
|
||||
forced to False: an imbalance print is published AND provisional (§9.2,
|
||||
TARIFF-02), and a check that "corrected" it would be a bug wearing a
|
||||
guarantee.
|
||||
* CARRIED - the hour must not appear in `published` AT ALL, the flag must
|
||||
be exactly True, and that hour may hold no second point.
|
||||
|
||||
⚠️ WHAT REPLACED "AT MOST ONE POINT PER HOUR", AND WHAT THAT DID AND DID
|
||||
NOT CHANGE. This audit began as FCST-03's `seen` set: any repeated
|
||||
`_utc_hour` raised, beside an identity rule `p is not src`. A bare hour
|
||||
count cannot express a quarter-hourly feed, where four distinct published
|
||||
points share one hour legitimately - so the count was replaced by the two
|
||||
rules above, which subsume it.
|
||||
|
||||
⚠️ AND IT IS NOT STRICTER. FCST-04's version of this paragraph claimed two
|
||||
strengthenings and BOTH WERE FALSE; FCST-05 measured it. A differential
|
||||
over the whole domain this audit can express - every published mask over 3
|
||||
hours crossed with every output series of length 0-3 drawn from
|
||||
{published, published-provisional, carried, carried-unflagged,
|
||||
copy-of-published} - found ZERO divergence in EITHER direction:
|
||||
|
||||
OLD raised, NEW accepted (WEAKER): 0
|
||||
OLD accepted, NEW raised (STRONGER): 0
|
||||
|
||||
The two specific claims, one at a time:
|
||||
|
||||
* "so does the same published point emitted twice" - the OLD `seen`
|
||||
count already raised on that, because emitting one point twice is one
|
||||
HOUR twice. Preserved by the identity/`emitted` rules, not added.
|
||||
* "a carried point may no longer share an hour with a published one,
|
||||
which the count never checked at all" - true of the COUNT, and the old
|
||||
IDENTITY rule raised on it anyway: a carried point in a published hour
|
||||
took the `else` branch and failed `p is not src`. The message was about
|
||||
a copy rather than about a carry, which is the only thing that moved.
|
||||
|
||||
The one real behavioural change is the intended RELAXATION - several
|
||||
distinct published points in one hour, which the old audit could not
|
||||
express at all and refused. That relaxation is why this was rewritten, and
|
||||
it stays. A right decision with a fabricated reason is still a defect here.
|
||||
|
||||
⚠️ AND THE REAL LIMIT, WHICH IS NOT A REGRESSION AND IS NOT SMALL: THIS
|
||||
AUDIT DOES NOT CHECK COMPLETENESS. It grades every point that IS emitted
|
||||
and says nothing about the ones that are not. Fed the H1 decimation
|
||||
directly - four published points in an hour, output emitting only the last
|
||||
- it ACCEPTS; so does two of four, and so does an EMPTY output. So `_audit`
|
||||
is NOT what stops H1 recurring. What stops it is the point COUNT and the
|
||||
IDENTITY checks in `test_price_history.py` and `test_price_forecast.py`,
|
||||
which compare the emitted series against the feed's own prints.
|
||||
|
||||
It is not made complete here, and the reason is structural rather than
|
||||
effort: `published` is `_published_by_hour(read)` over the WHOLE read,
|
||||
unwindowed, while `fill_from_yesterday()` emits only [since, until). A
|
||||
published point outside the window is legitimately absent, so "every
|
||||
published point appears" is not a true post-condition of these two
|
||||
arguments - it would need the window passed in, which is a different
|
||||
signature and a different check.
|
||||
|
||||
⚠️ AND THAT IS MEASURED, NOT ASSUMED. FCST-05 built the rule - raise unless
|
||||
every point of every `published[u]` is emitted, +162 B - and ran it. It
|
||||
takes the 1 h, 12 h and 48 h HORIZON checks in test_price_forecast.py RED,
|
||||
on the shipped fill and a healthy feed: at an off-the-hour instant the
|
||||
feed's already-elapsed prints are outside [since, until) and are correctly
|
||||
dropped, and the rule calls that a provenance failure. (The differential
|
||||
beside those checks scores the same rule as 49 STRONGER cases, so it is a
|
||||
real change of behaviour and not a no-op.) The rule as stated is therefore
|
||||
not merely unimplemented, it is WRONG - and stating a false post-condition
|
||||
is MUTATION.md habit 16's defect: an instrument that asserts a completeness
|
||||
it does not have is worse than no instrument.
|
||||
|
||||
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
|
||||
provenance is one whose provenance means nothing; the only safe response to
|
||||
"this point's flag disagrees with where it came from" is to refuse to emit
|
||||
the series at all.
|
||||
"""
|
||||
seen = set()
|
||||
carried_hours = set()
|
||||
emitted = set()
|
||||
for p in points:
|
||||
u = _utc_hour(p.start)
|
||||
if u in seen:
|
||||
raise ValueError(f"the filled series repeats {u.isoformat()}; a "
|
||||
f"duplicated hour is how a DST day gets mispriced")
|
||||
seen.add(u)
|
||||
src = published.get(u)
|
||||
prov = Provenance.CARRIED if src is None else Provenance.PUBLISHED
|
||||
if src is None:
|
||||
if u in carried_hours:
|
||||
raise ValueError(
|
||||
f"the filled series repeats the carried hour "
|
||||
f"{u.isoformat()}; a duplicated hour is how a DST day gets "
|
||||
f"mispriced")
|
||||
carried_hours.add(u)
|
||||
if p.provisional is not True:
|
||||
raise ValueError(
|
||||
f"{u.isoformat()} is {prov.value} and is flagged "
|
||||
f"provisional={p.provisional!r}. §8.3: an hour filled from "
|
||||
f"yesterday's profile is provisional whether or not the "
|
||||
f"number turns out to be right - the flag is a claim about "
|
||||
f"PROVENANCE, not about accuracy.")
|
||||
f"{u.isoformat()} is {Provenance.CARRIED.value} and is "
|
||||
f"flagged provisional={p.provisional!r}. §8.3: an hour "
|
||||
f"filled from yesterday's profile is provisional whether "
|
||||
f"or not the number turns out to be right - the flag is a "
|
||||
f"claim about PROVENANCE, not about accuracy.")
|
||||
else:
|
||||
if p is not src:
|
||||
if not any(p is q for q in src):
|
||||
raise ValueError(
|
||||
f"{u.isoformat()} is {prov.value} but the series carries a "
|
||||
f"COPY of that point rather than the point itself. A copy "
|
||||
f"is where the provisional flag gets lost; published points "
|
||||
f"pass through by identity.")
|
||||
f"{u.isoformat()} is {Provenance.PUBLISHED.value} but the "
|
||||
f"series carries a COPY of that point rather than the "
|
||||
f"point itself. A copy is where the provisional flag gets "
|
||||
f"lost; published points pass through by identity.")
|
||||
if id(p) in emitted:
|
||||
raise ValueError(
|
||||
f"the filled series emits the published point at "
|
||||
f"{p.start.isoformat()} TWICE. The provider's own points "
|
||||
f"are strictly increasing (`_validate_raw`), so a repeat "
|
||||
f"is never a second print - it is a duplicated hour, which "
|
||||
f"is how a DST day gets mispriced")
|
||||
emitted.add(id(p))
|
||||
|
||||
|
||||
def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
@@ -217,11 +351,21 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
|
||||
Returns a `PriceRead`:
|
||||
|
||||
* a series, whose points are the provider's own objects for the hours it
|
||||
published and `_carried()` points for the rest, ordered by instant;
|
||||
* a series, carrying the provider's own objects - ALL of them, which on
|
||||
a quarter-hourly feed is four inside one hour - for the hours it
|
||||
published, and one `_carried()` point for each remaining whole hour,
|
||||
ordered by instant;
|
||||
* `no_data` when neither source can produce a single hour - never an
|
||||
empty series, which is not constructible (price.py trap 3).
|
||||
|
||||
⚠️ THE WINDOW IS WHOLE HOURS AT BOTH ENDS. `since` is floored to the hour
|
||||
that CONTAINS it, because the current hour is still a tradeable hour; and
|
||||
so is `until`, which is the half FCST-04's first draft left out. With
|
||||
`since` floored and `until` compared raw, a 48 h horizon asked for at 08:17
|
||||
emitted 49 hours - the hour of `until` was still `< until` - and the count
|
||||
was one too many for every `now` that is not exactly on the hour, which is
|
||||
every `now` a running controller has.
|
||||
|
||||
⚠️ IDEMPOTENT AND RE-RUNNABLE, WHICH IS THE §5.4 LATE-PUBLICATION CONTRACT.
|
||||
Day-ahead publication at 14-16 h CET can be late or fail, so the fetcher
|
||||
retries and this runs again against the fresher read. Published hours always
|
||||
@@ -234,9 +378,7 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
raise ValueError(f"{name} must be tz-aware; a naive instant is "
|
||||
f"ambiguous for one hour every October")
|
||||
|
||||
published = read.match(
|
||||
series=lambda r: {_utc_hour(p.start): p for p in r.points},
|
||||
no_data=lambda _r: {})
|
||||
published = _published_by_hour(read)
|
||||
|
||||
# Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins
|
||||
# when yesterday itself was a 25-hour day and printed local hour 2 twice.
|
||||
@@ -248,11 +390,12 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
|
||||
out = []
|
||||
u = _utc_hour(since)
|
||||
end = until.astimezone(timezone.utc)
|
||||
end = _utc_hour(until)
|
||||
while u < end:
|
||||
if u in published:
|
||||
out.append(published[u]) # BY IDENTITY. No copy.
|
||||
else:
|
||||
got = published.get(u)
|
||||
if got:
|
||||
out.extend(got) # BY IDENTITY, and ALL of them. No copy,
|
||||
else: # and no decimation to one an hour.
|
||||
src = by_local_hour.get(brussels(u).hour)
|
||||
if src is not None:
|
||||
out.append(_carried(u, src.eur_per_kwh))
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
"""FCST-04 - the day the carry-forward carries, kept somewhere a restart cannot reach.
|
||||
|
||||
WHAT WAS MISSING, AND WHY IT WAS INVISIBLE. FCST-03 shipped
|
||||
`price_forecast.fill_from_yesterday()` complete and checked, and shipped the
|
||||
consumers that were written to expect it - `TariffPlan.firm_points()`,
|
||||
`cheap_now()`'s refusal of a provisional hour, and `arbitrage()`'s refusal 3,
|
||||
which drops provisional hours from the trade set instead of refusing the whole
|
||||
series. Every one of those was live. The FILL ITSELF HAD NO CALLER: nothing in
|
||||
`app/` referenced `fill_from_yesterday`, so no series ever contained a carried
|
||||
point and every one of those consumers was exercising its all-firm branch.
|
||||
|
||||
The defect that produced is not an error anywhere. `load_prices()` returns
|
||||
whatever the feed published and nothing widens it to the requested horizon, so
|
||||
on a dynamic site between midnight and the 14-16 h CET day-ahead publication
|
||||
the series covers the rest of TODAY and nothing more - typically 10-24 of the
|
||||
48 hours `price_horizon_h` asked for. `TariffPlan.arbitrage()` then computes
|
||||
`max - min` over that truncated window and reports a spread. The number is
|
||||
real, the window is half the one the operator configured, and NOTHING SAYS SO:
|
||||
tomorrow evening's peak - the hour a morning charge would be sold into - is
|
||||
simply not in the series, so the spread it would have created cannot clear a
|
||||
threshold it was never compared against. A missing hour is not a wrong price;
|
||||
it is an hour the optimiser never learns exists.
|
||||
|
||||
So this module is the half FCST-03 left out, and it is deliberately the SMALL
|
||||
half: the fill's arithmetic, its DST rule and its provenance audit all stay in
|
||||
`price_forecast.py`, untouched. What lives here is the one thing that
|
||||
arithmetic needs and could not have: A YESTERDAY THAT SURVIVES A RESTART.
|
||||
|
||||
|
||||
WHY PERSISTENCE, AND NOT A VARIABLE ON THE REFRESH TASK
|
||||
======================================================
|
||||
An in-memory "the last full day I saw" costs nothing and is wrong exactly when
|
||||
it matters. The window the fill exists for is midnight -> publication; an
|
||||
add-on restarted at 08:00 - a config change, an HA update, a power cut - would
|
||||
come up with no yesterday at all and spend that whole window on the truncated
|
||||
series, which is the pre-FCST-03 behaviour arriving silently on the mornings
|
||||
after an operator touched something. The fill's own value is a function of
|
||||
surviving a restart, so the day it carries has to.
|
||||
|
||||
⚠️ AND NOT IN `/data/state.json`. That file holds the maintenance history and
|
||||
SAFETY-02's sign latch, and `Store.set()` rewrites the whole of it on every
|
||||
write. The tariff refresh runs every `TARIFF_REFRESH_S` (1800 s), so folding a
|
||||
price cache into it would put 48 extra whole-file rewrites a day through the
|
||||
file whose corruption reads as "no maintenance ever ran" and "never tripped".
|
||||
The cache is worth having; it is not worth putting anywhere near that. Separate
|
||||
file, separate blast radius.
|
||||
|
||||
⚠️ AND THE DIRECTORY IS NOT CREATED, which is `tariff.PeakStore`'s rule and its
|
||||
reason applies unchanged: `/data` is mounted by the Supervisor and always
|
||||
exists on a real install, so `makedirs` here could only ever succeed where the
|
||||
durable volume is MISSING - manufacturing a cache on the container's ephemeral
|
||||
layer that writes fine, reads fine, and is thrown away on the next restart.
|
||||
That is a yesterday that silently forgets, which is worse than one that says it
|
||||
cannot answer.
|
||||
|
||||
⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. FCST-04
|
||||
first shipped it half-done: `_save()` short-circuited on `not self.ok` while
|
||||
`_remember()` filled `self._days` anyway, so a store with no durable volume
|
||||
answered `words` = "unavailable" and `yesterday()` = A SERIES - an in-memory
|
||||
yesterday that works all day and is gone on the next restart, which is exactly
|
||||
the silently-forgetting cache this rule exists to refuse. A store that cannot
|
||||
persist now records nothing, and both of its answers say the same thing.
|
||||
|
||||
⚠️ AND "CANNOT PERSIST" IS ONE QUESTION, ASKED ONCE, ABOUT THE OUTCOME -
|
||||
FCST-06. The paragraph above enforced that rule against `not self.ok`, which is
|
||||
one SYMPTOM of not persisting: the durable volume is missing. There is a second,
|
||||
and FCST-06 measured it. With `ok=True` and the directory present, `_save()` can
|
||||
still fail - a read-only remount (`[Errno 30] Read-only file system`), a full
|
||||
volume (`[Errno 28] No space left on device`) - and it logs, records
|
||||
`last_error` and returns. `_remember()` had already populated `self._days`, so
|
||||
`words` read `'2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over
|
||||
24 h) [could not write (...)]'` while the file on disk held `{}`, and
|
||||
`yesterday()` answered 24 REAL POINTS for the rest of the process's life. Gone
|
||||
on the next restart, and nothing said it would be - the same silently-forgetting
|
||||
cache, reached by the condition the rule did not name.
|
||||
|
||||
⚠️ SO IT IS NOT TWO RULES, AND IT IS DELIBERATELY NOT TWO TESTS. "Can this
|
||||
store persist?" CANNOT BE ANSWERED BEFORE THE ATTEMPT - a directory that existed
|
||||
and was writable a moment ago is not a write that completed - so it is answered
|
||||
AFTER it, once, by the only thing holding the evidence: `_save()` returns
|
||||
whether the file now holds what memory holds. A missing volume is one of its
|
||||
False cases and an `OSError` from the write is another, and `_remember()` KEEPS
|
||||
ONLY WHAT THAT ONE ANSWER SAYS WAS KEPT. There is no `not self.ok` test in
|
||||
`_remember()` any more, because there is nothing left for a second test to add -
|
||||
and a third symptom nobody has thought of is how this defect arrived both times.
|
||||
|
||||
⚠️ AND WHAT IS KEPT IS THE LAST STATE THAT IS ON DISK, NOT AN EMPTY ONE. The
|
||||
MERGE is undone, not the store. A site whose volume fills at 14:00 goes on
|
||||
answering the yesterday it wrote at 13:30 - that record survives a restart, so
|
||||
answering it is not a promise the store cannot keep. What it stops doing is
|
||||
adding to it. The invariant the rule was always describing, stated once:
|
||||
`self._days` NEVER HOLDS A POINT THE FILE DOES NOT. (Not "mirrors the file" -
|
||||
`_usable_days()` drops records the file still holds. The only direction that can
|
||||
invent a yesterday is the one named.)
|
||||
|
||||
|
||||
THE FOUR RULES THIS FILE IS SHAPED BY
|
||||
=====================================
|
||||
|
||||
1. IT RECORDS WHAT THE PROVIDER PUBLISHED, NEVER WHAT THE FILL PRODUCED.
|
||||
Feeding a filled series back in is the defect that makes the whole mechanism
|
||||
rot: day 2 carries day 1's guesses, day 3 carries day 2's carry of them, and
|
||||
after a week of a dead feed the "yesterday" being carried forward is a
|
||||
fossil of the last real publication with nothing anywhere reporting its age.
|
||||
Every point still carries `provisional=True`, so the flag never lies - what
|
||||
decays is the NUMBER, silently, behind a flag that is telling the truth.
|
||||
|
||||
⚠️ NO VALUE-LEVEL CHECK CAN CATCH THAT, and saying so is more useful than a
|
||||
guarantee that would not hold. A carried point and a published-provisional
|
||||
point (`eneco_eddy`'s current quarter, §9.2) are the same three fields with
|
||||
the same flag; the difference is provenance, and provenance is not in the
|
||||
value. So the mechanism is ORDERING, not validation: `carry()` is the only
|
||||
public entry point, it remembers the argument and returns the fill, and the
|
||||
filled series exists only as a return value that `carry()` itself never
|
||||
sees again. `_remember()` is private, is called from exactly one place, and
|
||||
`test_price_history.py` PARSES every shipped module under `app/`, `tools/`,
|
||||
`scenarios/` and `estop/` and fails if any of them uses the NAME - an
|
||||
attribute, a bare name, a def, or the string constant a `getattr` needs. By
|
||||
name rather than by substring, so a comment about the rule does not fail it
|
||||
and a call from `tools/` cannot pass unseen.
|
||||
|
||||
⚠️ AND THE ORDER IS GRADED BY DRIVING IT, NOT BY READING THE SOURCE. The
|
||||
first version of that check compared positions in `ast.walk`, which is
|
||||
breadth-first: `self._remember(...)` is a statement and `self.yesterday(...)`
|
||||
is an ARGUMENT of the fill, one level deeper, so `_remember` came out first
|
||||
whichever statement was written first - shipped and reversed scored the
|
||||
same. What grades it now is the behaviour: a feed carrying yesterday
|
||||
alongside today, into an EMPTY store, must fill the whole horizon, and
|
||||
reversed it fills nothing.
|
||||
|
||||
2. "YESTERDAY" MEANS YESTERDAY, NOT "THE NEWEST DAY I HAVE".
|
||||
`newest()` is the natural accessor and it is the one that decays: an add-on
|
||||
down from Friday to Monday would answer Friday's prices to a fill that flags
|
||||
them as one day old. The lookup is BY DATE - the Brussels day before the
|
||||
Brussels day of the instant asked about - and a record for any other date is
|
||||
NO_RECORD with a reason naming both dates. A three-day-old profile is not a
|
||||
worse yesterday; it is not a yesterday.
|
||||
|
||||
3. A STORE THAT CANNOT ANSWER SAYS SO, IN THE TYPE.
|
||||
`yesterday()` returns a `PriceRead`: a `series` when a full record exists,
|
||||
`no_data(reason)` for every other outcome - no file, no record for that
|
||||
date, an unreadable file, a record that parses to nothing. There is no
|
||||
empty-series answer and no `.get(..., [])`-shaped accessor, for
|
||||
`price.py`'s reason: an empty series is how "the cache is broken" gets to
|
||||
wear the shape of "yesterday was free". `fill_from_yesterday()` takes a
|
||||
`PriceRead` and handles `no_data` on its own, so the degraded case needs no
|
||||
branch at the call site - it fills nothing and the horizon stays short,
|
||||
which is exactly the behaviour that shipped before this ticket.
|
||||
|
||||
4. HOURS ACCUMULATE WITHIN A DAY; THEY DO NOT REPLACE IT.
|
||||
A feed that serves only the REMAINING hours of the day (a legitimate shape -
|
||||
nothing in the plugin contract promises a whole day) would, under
|
||||
replace-the-record, overwrite a complete 24-hour profile with the two points
|
||||
left at 22:00 and hand the next morning a yesterday with two hours in it. So
|
||||
a record is MERGED by instant: a later read updates the hours it carries and
|
||||
leaves the rest, and a revised price for an hour already held wins because
|
||||
it is the newer statement about the same hour.
|
||||
|
||||
⚠️ KEYED BY INSTANT, NOT BY LOCAL HOUR, AND THAT IS THE DST DECISION. Storing
|
||||
`hour -> price` would collapse the two 02:00s of a 25-hour October day into
|
||||
one, inventing a second and different collapsing rule beside the one
|
||||
`fill_from_yesterday()` already documents ("the first of its two 02:00 points
|
||||
is the one used, deterministically"). Storing the instants keeps both, in
|
||||
order, and lets the fill's own rule be the only one in the system.
|
||||
|
||||
WHAT THIS DOES NOT DO, ON PURPOSE
|
||||
=================================
|
||||
It does not extend the fill to a feed that published NOTHING. `resolve_tariff()`
|
||||
applies `carry()` only on a `series` read; a `no_data` read still falls to
|
||||
`_degrade()` and §11.4's row - "No price data -> fall back to the static
|
||||
profile, mark the plan provisional, no arbitrage" - which is the fallback the
|
||||
operator configured `static_profile` FOR. Preferring yesterday's feed over it
|
||||
would change a documented degradation without being asked, and it would buy
|
||||
nothing measurable: a fully-carried series is 100 % provisional, so
|
||||
`arbitrage()` finds fewer than two firm hours and refuses, and `cheap_now()` is
|
||||
False, exactly as on the §11.4 fallback. The two differ only in which numbers
|
||||
the status page shows. `fill_from_yesterday()` handles a `no_data` read anyway,
|
||||
and that branch is therefore not reached from production - it stays checked,
|
||||
and turning it on is a product decision, not a wiring one. See
|
||||
DECISIONS/FCST-04-the-day-that-is-carried.md.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .price import PricePoint, PriceProduct, PriceRead, brussels
|
||||
from .price_forecast import fill_from_yesterday
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price.history")
|
||||
|
||||
# How many Brussels days of published prices the SORT-ORDER cap keeps. The fill
|
||||
# needs exactly one - yesterday - and rule 2 refuses anything older, so this is
|
||||
# not a horizon: it is the number of days a record may sit unused before
|
||||
# pruning removes it, and it is small because a bigger cache would only hold
|
||||
# days no lookup can ask for. Three covers today, yesterday, and the day a
|
||||
# fetch spanning midnight writes ahead of itself.
|
||||
#
|
||||
# ⚠️ IT IS A CAP, NOT THE RETENTION RULE, AND THREE HAS NO SLACK. The steady
|
||||
# state is already {yesterday, today, tomorrow}; a feed that publishes to D+2,
|
||||
# or ONE stray point in the wrong year - which sorts LAST and is therefore
|
||||
# immortal under a newest-N cap - pushes yesterday out of a three-slot store
|
||||
# and keeps it out, permanently and silently. So `_prune()` PINS the two days a
|
||||
# lookup can actually ask for on top of the cap; see its docstring. The store
|
||||
# is then bounded at KEEP_DAYS + 2 records rather than at KEEP_DAYS.
|
||||
KEEP_DAYS = 3
|
||||
|
||||
SCHEMA = 1
|
||||
|
||||
|
||||
def _bdate(instant: datetime) -> str:
|
||||
"""The Brussels calendar date of an instant, as `YYYY-MM-DD`.
|
||||
|
||||
⚠️ BRUSSELS, NOT UTC. 23:30 UTC in July is already tomorrow in Belgium, and
|
||||
a cache keyed on the UTC date would file the last 90-120 minutes of every
|
||||
day under the wrong one - so a lookup on the following morning would find a
|
||||
"yesterday" missing its own evening hours, which are the expensive ones.
|
||||
"""
|
||||
return brussels(instant).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _yesterday_of(instant: datetime) -> str:
|
||||
"""The Brussels date of the day BEFORE `instant`'s Brussels day.
|
||||
|
||||
⚠️ THE ARITHMETIC IS ON THE LOCAL DATE, NOT ON THE INSTANT, AND THAT IS A
|
||||
DST DEFECT THIS FILE SHIPPED. `brussels()` renders at the FIXED offset in
|
||||
force at that instant, so `brussels(stamp) - timedelta(days=1)` subtracts 24
|
||||
ABSOLUTE hours from a wall clock - and the Brussels day before a
|
||||
spring-forward is 23 hours long. Measured at 2026-03-30T00:30+02:00, the
|
||||
first hour of the morning after the March transition: it returned
|
||||
2026-03-28, so the fill carried a TWO-DAY-OLD profile flagged as yesterday's
|
||||
(or, with only the last three days held, answered `no_data` naming a date
|
||||
that is not yesterday).
|
||||
|
||||
A calendar day minus one day is `date` arithmetic, and a `date` has no
|
||||
offset to lose. The 25-hour October day is right for the same reason.
|
||||
"""
|
||||
return (brussels(instant).date() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _point_of(row):
|
||||
"""One stored row -> a `PricePoint`, or `None` if the row is not one.
|
||||
|
||||
THE ONE VALIDATOR, AND THE ASYMMETRY IT CLOSES (FCST-05's MEDIUM). This
|
||||
file used to hold TWO standards for what a stored point is. The READ path
|
||||
(`_parse_points()`) was properly defensive - every field checked, a
|
||||
malformed row dropped and reported. The WRITE path was
|
||||
`{q["start"]: q for q in raw if isinstance(q, dict) and "start" in q}`
|
||||
followed by `sorted(..., key=lambda q: q["start"])`, which trusts
|
||||
`q["start"]` to be a hashable, sortable string it never checked. Measured,
|
||||
with the malformed row under the day the feed is currently writing to:
|
||||
|
||||
start is an int -> TypeError: '<' not supported between instances
|
||||
of 'str' and 'int'
|
||||
start is a dict -> TypeError: cannot use 'dict' as a dict key
|
||||
start is null -> TypeError: '<' not supported between 'str' and
|
||||
'NoneType'
|
||||
start is a list -> TypeError: cannot use 'list' as a dict key
|
||||
|
||||
⚠️ AND THE BLAST RADIUS IS PERMANENT, WHICH IS WHY IT IS NOT MERELY UNTIDY.
|
||||
`_remember()` is reached through `carry()` -> `resolve_tariff()` ->
|
||||
`run_tariff_refresh`, which catches `Exception` - so the add-on stays UP
|
||||
and EVERY refresh raises forever. The plan retires after
|
||||
`TARIFF_MAX_AGE_S`, and arbitrage and the cheap window are off from then
|
||||
on. There is no self-heal, because `_remember()` raises before it reaches
|
||||
`_save()`. `_usable_days()` only required `rec["points"]` to be a LIST; the
|
||||
elements were unchecked on the write path, and the existing fixture put its
|
||||
malformed row under a day the feed was NOT writing to, so `_remember()`
|
||||
never touched it.
|
||||
|
||||
So both paths call this, and neither of them has a standard of its own.
|
||||
Returning `None` rather than raising is the contract: a caller decides
|
||||
whether a bad row is dropped (both of them do) and how it is reported.
|
||||
"""
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
try:
|
||||
start = datetime.fromisoformat(row["start"])
|
||||
value = float(row["eur_per_kwh"])
|
||||
flag = row["provisional"]
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if start.tzinfo is None or start.utcoffset() is None \
|
||||
or not isinstance(flag, bool):
|
||||
return None
|
||||
return PricePoint(start=start, eur_per_kwh=value, provisional=flag)
|
||||
|
||||
|
||||
def _held(rec) -> tuple:
|
||||
"""`(points, whole hours they cover)` for a day record. `(0, 0)` otherwise.
|
||||
|
||||
⚠️ POINTS AND HOURS ARE TWO NUMBERS, AND FCST-04's STATUS ROW PRINTED THE
|
||||
FIRST UNDER THE SECOND'S NAME. The row was `f"{d} ({_n_points(rec)} h)"`,
|
||||
which counts POINTS: on the quarter-hourly feed this add-on already
|
||||
supports it read `'2026-01-13 (96 h)'` - 96 "h" for a 24-hour day. That is
|
||||
the points/hours conflation H1 was raised for, on the row H1's own ticket
|
||||
shipped. Both numbers are reported now, so an operator can see a day that
|
||||
is 24 points over 24 h and a day that is 96 points over 24 h and tell them
|
||||
apart, and neither number is wearing the other's unit.
|
||||
|
||||
⚠️ TOLERANT, AND IT IS THE SECOND LINE OF DEFENCE, NOT THE FIRST. `_load()`
|
||||
drops a malformed day record and says so, so this should never see one. It
|
||||
is written this way because `words` is read from `Controller.__init__` and
|
||||
from every status render: a status LINE that raises takes the add-on down,
|
||||
and "the cache is broken" must never be able to mean "the controller does
|
||||
not start". That is exactly what FCST-04 shipped - `{"days": {"2026-01-14":
|
||||
"oops"}}` gave `ok=True`, `last_error=None`, and an `AttributeError` out of
|
||||
`words`, `yesterday()` and `resolve_tariff()` alike, with no self-heal
|
||||
because `_remember()` raised before it could rewrite the file.
|
||||
|
||||
⚠️ AND IT COUNTS ONLY ROWS THAT ARE POINTS. A row `_point_of()` refuses is
|
||||
not held prices, and a row count that included it would report a store as
|
||||
healthier than it is - which is the second half of FCST-05's MEDIUM: the
|
||||
status row said `'2026-01-15 (1 h)'` about a record whose single row was
|
||||
killing every tariff refresh. What is dropped is named in `last_error`, and
|
||||
`words` prints that beside the counts.
|
||||
"""
|
||||
if not isinstance(rec, dict):
|
||||
return (0, 0)
|
||||
raw = rec.get("points")
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return (0, 0)
|
||||
n, hours = 0, set()
|
||||
for row in raw:
|
||||
pt = _point_of(row)
|
||||
if pt is None:
|
||||
continue
|
||||
n += 1
|
||||
hours.add(pt.start.astimezone(timezone.utc)
|
||||
.replace(minute=0, second=0, microsecond=0))
|
||||
return (n, len(hours))
|
||||
|
||||
|
||||
def _row_of(day: str, points: int, hours: int) -> str:
|
||||
"""One day's cell of the status row: what is held, in BOTH of its units.
|
||||
|
||||
⚠️ THE UNIT IS SPELT OUT ON EACH NUMBER. FCST-04's cell was
|
||||
`f"{day} ({points} h)"` - the point count wearing the hour's name, which on
|
||||
a quarter-hourly feed reads `'2026-01-13 (96 h)'` for a 24-hour day. What
|
||||
an operator needs from this row is whether tomorrow morning's fill has a
|
||||
whole day to carry, and that is the HOUR figure; the point count says how
|
||||
finely it is priced. Neither answers for the other.
|
||||
"""
|
||||
return (f"{day} ({points} point{'' if points == 1 else 's'} "
|
||||
f"over {hours} h)")
|
||||
|
||||
|
||||
def _bad_rows(rec) -> int:
|
||||
"""How many rows of a day record are not points. Total; never raises."""
|
||||
if not isinstance(rec, dict):
|
||||
return 0
|
||||
raw = rec.get("points")
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return 0
|
||||
return sum(1 for row in raw if _point_of(row) is None)
|
||||
|
||||
|
||||
class PriceHistory:
|
||||
"""The published price series of the last few Brussels days, in /data.
|
||||
|
||||
⚠️ `ok` IS ABOUT THE FILE, NOT ABOUT THE DATA. A healthy store with no
|
||||
record for yesterday is `ok` and answers `no_data` - a normal state on a
|
||||
site's first day, and a different thing from a store that could not be
|
||||
opened. Both are visible: `ok`/`last_error` describe the file, and
|
||||
`yesterday()`'s reason describes the answer.
|
||||
|
||||
⚠️ AND `ok` IS NOT "CAN PERSIST" (FCST-06). It says the directory was
|
||||
there when the store opened, which a full volume and a read-only remount
|
||||
both leave true. The one thing that answers "can this store persist" is
|
||||
`_save()`'s return value, and `self._days` NEVER HOLDS A POINT THE FILE DOES
|
||||
NOT, because `_remember()` undoes a merge that answer refused. That is what
|
||||
makes `words` and `yesterday()` describe the same store in EVERY degraded
|
||||
state rather than only in the ones somebody enumerated.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str = "/data/price_history.json"):
|
||||
self.path = path
|
||||
self.ok = False
|
||||
self.last_error: str | None = None
|
||||
self._days: dict = {}
|
||||
self._open()
|
||||
|
||||
# -- the file ------------------------------------------------------------
|
||||
def _open(self) -> None:
|
||||
parent = os.path.dirname(self.path)
|
||||
# See the module note: NOT created. A cache on the ephemeral layer is
|
||||
# one that forgets without saying so.
|
||||
if parent and not os.path.isdir(parent):
|
||||
self.last_error = f"no durable volume at {parent}"
|
||||
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||
# /data and nothing is wrong.
|
||||
_LOG.info("no price history: %s - §8.3 carry-forward has nothing "
|
||||
"to carry and the horizon stays as short as the feed",
|
||||
self.last_error)
|
||||
return
|
||||
self.ok = True
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
with open(self.path, "r", encoding="utf-8") as fh:
|
||||
blob = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
_LOG.info("no price history yet at %s - the first refresh writes "
|
||||
"one, and the fill has nothing to carry until the day "
|
||||
"after that", self.path)
|
||||
return
|
||||
except (ValueError, OSError) as err:
|
||||
# A corrupt cache must not stop the controller, and must not be
|
||||
# silently re-created either: the record is discarded, the reason
|
||||
# is kept, and `yesterday()` reports it rather than answering
|
||||
# "no record", which would read as a normal first day.
|
||||
#
|
||||
# ⚠️ `ValueError`, NOT `json.JSONDecodeError`, AND THAT IS
|
||||
# FCST-05's HIGH. Reading this file has two failure families: the
|
||||
# BYTES (`OSError`) and the CONTENT - and the content family has at
|
||||
# least two members, because `open(..., encoding="utf-8")` decodes
|
||||
# before `json` ever sees a character. `UnicodeDecodeError` is
|
||||
# `ValueError -> UnicodeError -> UnicodeDecodeError`; it is NOT a
|
||||
# `json.JSONDecodeError` and it is NOT an `OSError`
|
||||
# (`issubclass(UnicodeDecodeError, OSError)` is False). So ONE
|
||||
# non-UTF-8 byte in /data/price_history.json escaped `_load()` ->
|
||||
# `_open()` -> `PriceHistory.__init__`, which is unguarded at
|
||||
# main.py's `Controller.__init__`, AND THE ADD-ON DID NOT START.
|
||||
# Measured: `UnicodeDecodeError: 'utf-8' codec can't decode byte
|
||||
# 0xff in position 39`. Valid-UTF-8-invalid-JSON, a UTF-8 BOM and a
|
||||
# non-dict day record all degraded correctly - it was the one byte
|
||||
# class left uncovered, which is the same class as the defect the
|
||||
# `_usable_days()` rework was raised for.
|
||||
#
|
||||
# The tuple names the FAMILY rather than the two members known
|
||||
# today, because "the file's content is not readable as JSON" is
|
||||
# exactly what this handler says, and a third `ValueError` subclass
|
||||
# nobody has thought of is how this defect arrived the first time.
|
||||
self.last_error = f"unreadable ({err})"
|
||||
_LOG.warning("price history at %s is %s - starting from empty. "
|
||||
"§8.3 carry-forward has nothing to carry until the "
|
||||
"next refresh has written a full day",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
found = blob.get("version") if isinstance(blob, dict) else None
|
||||
# ⚠️ THE TYPE IS PART OF THE VERSION. `1.0 == 1` in Python, so a float
|
||||
# written by some other tool would pass a bare `!=` and be read as
|
||||
# schema 1 - a version check that cannot tell 1 from 1.0 is one that
|
||||
# would not notice a writer this build has never met.
|
||||
if not isinstance(blob, dict) or isinstance(found, bool) \
|
||||
or not isinstance(found, int) or found != SCHEMA:
|
||||
self.last_error = f"schema {found!r}, not {SCHEMA}"
|
||||
_LOG.warning("price history at %s has %s - discarding it rather "
|
||||
"than guessing what its points mean",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
days = blob.get("days")
|
||||
if not isinstance(days, dict):
|
||||
self.last_error = "no `days` object"
|
||||
_LOG.warning("price history at %s has %s", self.path, self.last_error)
|
||||
return
|
||||
self._days = self._usable_days(days)
|
||||
|
||||
def _usable_days(self, days: dict) -> dict:
|
||||
"""The day records that are shaped like day records. The rest are dropped.
|
||||
|
||||
⚠️ THE ASYMMETRY THAT PRODUCED THE DEFECT: `_parse_points()` below is
|
||||
carefully defensive about every field of a POINT, and `_load()` was not
|
||||
defensive about the record holding them at all. `{"version": 1, "days":
|
||||
{"2026-01-14": "oops"}}` therefore loaded as `ok=True`,
|
||||
`last_error=None` - and then `rec.get(...)` raised `AttributeError` out
|
||||
of `words`, `yesterday()` and `resolve_tariff()`. `PriceHistory(...)`
|
||||
and `.words` are both unguarded in `Controller.__init__`, so THE ADD-ON
|
||||
DID NOT START; and it never self-healed, because `_remember()` raised on
|
||||
the same record before it could reach `_save()`.
|
||||
|
||||
A broken cache is a short horizon. It is not a controller that will not
|
||||
regulate a battery, and §6's rule - absent machinery must not disable
|
||||
the loop - does not stop applying because the machinery is a JSON file.
|
||||
|
||||
Dropped, not repaired: an unusable record has nothing in it to keep, and
|
||||
the next `_remember()` writes a fresh one under that date.
|
||||
|
||||
⚠️ AND THE ROWS INSIDE A KEPT RECORD ARE COUNTED, NOT ONLY THE RECORD
|
||||
(FCST-05). This checked that `rec["points"]` is a LIST and stopped
|
||||
there, so a record holding one row with a `start` of `7` loaded as
|
||||
healthy: `last_error=None`, and `words` said `'2026-01-15 (1 h)'` about
|
||||
a store whose next `_remember()` would raise on that row and go on
|
||||
raising forever. The rows are NOT dropped here - `_parse_points()` is
|
||||
the read path's one filter and stays so - but they are COUNTED, so the
|
||||
store stops reporting health it does not have from the moment it is
|
||||
opened. `_remember()` drops them on the next refresh and `_save()`
|
||||
rewrites the file, after which a restart clears the note.
|
||||
"""
|
||||
kept, dropped, malformed = {}, [], {}
|
||||
for key, rec in days.items():
|
||||
if not isinstance(key, str) or not isinstance(rec, dict) \
|
||||
or not isinstance(rec.get("points"), list):
|
||||
dropped.append(str(key))
|
||||
continue
|
||||
bad = _bad_rows(rec)
|
||||
if bad:
|
||||
malformed[key] = bad
|
||||
kept[key] = rec
|
||||
notes = []
|
||||
if dropped:
|
||||
notes.append(f"{len(dropped)} unusable day record(s) dropped "
|
||||
f"({', '.join(sorted(dropped))})")
|
||||
if malformed:
|
||||
notes.append(
|
||||
f"{sum(malformed.values())} malformed point row(s) in "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items())))
|
||||
if notes:
|
||||
# Recorded, not silent: `yesterday()` and `words` both report
|
||||
# `last_error`, and a cache that quietly discards half a file reads
|
||||
# to an operator exactly like a normal first day.
|
||||
self.last_error = "; ".join(notes)
|
||||
_LOG.warning("price history at %s: %s - the controller starts "
|
||||
"anyway and the horizon is as short as the feed makes "
|
||||
"it", self.path, self.last_error)
|
||||
return kept
|
||||
|
||||
def _save(self) -> bool:
|
||||
"""Write `self._days` out. True only if the file now holds it.
|
||||
|
||||
⚠️ THE RETURN VALUE IS THE "CAN THIS STORE PERSIST?" PREDICATE, AND
|
||||
IT IS THE ONLY ONE (FCST-06). Both ways of not persisting end here and
|
||||
are the same answer: no durable volume returns False without touching
|
||||
the disk, an `OSError` out of the write returns False after recording
|
||||
why. `_remember()` acts on that one answer, so the second place that
|
||||
used to test the first symptom on its own - and missed the second - has
|
||||
nothing left to test. See the module docstring's fifth warning.
|
||||
|
||||
⚠️ AND IT REPORTS, IT DOES NOT RAISE. §6 and this module's opening: a
|
||||
broken cache must not be a broken controller. An add-on that will not
|
||||
start because /data filled up is a worse defect than the one FCST-06
|
||||
fixed, so a failed write is logged, kept in `last_error` - where `words`
|
||||
and `yesterday()` both read it - and RETURNED, never thrown.
|
||||
|
||||
⚠️ THE TEMP FILE IS REMOVED ON EVERY FAILING PATH. A write that dies
|
||||
between `mkstemp` and `os.replace` used to leave its temp behind, and
|
||||
the refresh task runs 48 times a day: a persistent write failure - a
|
||||
full volume being the likeliest cause - leaked 48 inodes a day onto the
|
||||
volume that was already the problem.
|
||||
"""
|
||||
if not self.ok:
|
||||
return False
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump({"version": SCHEMA, "days": self._days}, fh, indent=1)
|
||||
# Atomic: a power cut mid-write must not leave a truncated file
|
||||
# that parses as an empty day.
|
||||
os.replace(tmp, self.path)
|
||||
tmp = None # it IS the file now; do not unlink
|
||||
except OSError as err:
|
||||
self.last_error = f"could not write ({err})"
|
||||
_LOG.error("could not persist price history: %s", err)
|
||||
return False
|
||||
finally:
|
||||
if tmp is not None:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError: # nothing further to try, and a
|
||||
pass # failed cleanup is not a fault
|
||||
return True
|
||||
|
||||
# -- the one public entry point -----------------------------------------
|
||||
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
|
||||
stamp: datetime) -> PriceRead:
|
||||
"""Remember what this read published, then §8.3-fill the missing hours.
|
||||
|
||||
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
|
||||
recorded FIRST, so a feed that hands back yesterday alongside today
|
||||
refreshes yesterday's record from live data before it is consumed as
|
||||
the profile; and the filled series - the one thing that must never be
|
||||
recorded - exists only as this method's return value, which this object
|
||||
never sees again. See rule 1, including how that order is graded.
|
||||
|
||||
Returns whatever `fill_from_yesterday()` returns: a series over the
|
||||
whole hours of [since, until), carrying EVERY point the provider
|
||||
published inside it - by identity, and four of them in one hour on a
|
||||
quarter-hourly feed - plus one carried point for each remaining whole
|
||||
hour FOR WHICH YESTERDAY HELD THAT WALL-CLOCK HOUR. An hour neither
|
||||
source can price is left out (§11.4 for that hour), and `no_data` comes
|
||||
back when that is every hour.
|
||||
"""
|
||||
self._remember(read, stamp)
|
||||
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
|
||||
since=since, until=until, stamp=stamp)
|
||||
|
||||
# -- the halves, private because rule 1 is an ordering ------------------
|
||||
def _remember(self, read: PriceRead, stamp: datetime) -> None:
|
||||
"""File a provider read's points under their own Brussels days.
|
||||
|
||||
⚠️ PRIVATE, AND CALLED FROM EXACTLY ONE PLACE. A caller that can reach
|
||||
this can record a filled series, which is the decay in rule 1. There is
|
||||
no value-level defence against that - a carried point and a published
|
||||
provisional point are the same three fields - so the defence is that
|
||||
nothing outside this file names it, and `test_price_history.py` checks
|
||||
the tree for that rather than trusting the underscore.
|
||||
"""
|
||||
points = read.match(series=lambda r: r.points, no_data=lambda _r: ())
|
||||
if not points:
|
||||
return
|
||||
product = read.match(series=lambda r: r.product.value,
|
||||
no_data=lambda _r: None)
|
||||
provider = read.match(series=lambda r: r.provider,
|
||||
no_data=lambda _r: None)
|
||||
|
||||
# ⚠️ THE STATE THIS READ STARTED FROM, KEPT SO IT CAN BE PUT BACK
|
||||
# (FCST-06). Everything below merges into `self._days` IN PLACE, and
|
||||
# whether any of it may be KEPT is not knowable until `_save()` has
|
||||
# tried - so the merge runs, and it is undone if the file did not take
|
||||
# it. There is no `not self.ok` branch above any more: a store with no
|
||||
# durable volume is one of the answers `_save()` gives, not a separate
|
||||
# symptom with a test of its own. Module docstring, fifth warning.
|
||||
#
|
||||
# ⚠️ DEEP, NOT `dict(self._days)`. A one-level copy shares the day
|
||||
# RECORDS, and the loop below rebinds keys on the ones it touches - so
|
||||
# the "previous" state would hand back records the merge had already
|
||||
# edited, and a store with a good yesterday on disk would go on
|
||||
# answering the merge of it that never reached the file. It would also
|
||||
# be right only for as long as nobody makes this loop mutate a `points`
|
||||
# list or a row in place. A copy that is right by construction outlives
|
||||
# that, and the store holds at most KEEP_DAYS + 2 records, 48 times a
|
||||
# day.
|
||||
before = copy.deepcopy(self._days)
|
||||
|
||||
touched = set()
|
||||
junked = {}
|
||||
for p in points:
|
||||
day = _bdate(p.start)
|
||||
rec = self._days.setdefault(
|
||||
day, {"product": product, "provider": provider, "points": []})
|
||||
# Rule 4: MERGE by instant. A later read updates the hours it
|
||||
# carries and leaves the rest; a revised price for an hour already
|
||||
# held wins, because it is the newer statement about that hour.
|
||||
#
|
||||
# ⚠️ EVERY HELD ROW GOES THROUGH `_point_of()` (FCST-05's MEDIUM).
|
||||
# This was `{q["start"]: q for q in raw if isinstance(q, dict) and
|
||||
# "start" in q}` sorted by `q["start"]` - a key and a sort key that
|
||||
# trusted a value read off disk to be a hashable, sortable string.
|
||||
# An `int`, `null`, a `dict` or a `list` there raised `TypeError`
|
||||
# out of `carry()` on the day the feed was writing to, and
|
||||
# `run_tariff_refresh` caught it, so the add-on stayed up while
|
||||
# EVERY refresh failed for good. A row that is not a point is now
|
||||
# dropped and reported here, exactly as the read path drops it, and
|
||||
# `_save()` below writes the record back clean - which is the
|
||||
# self-heal the raising version could never reach.
|
||||
#
|
||||
# ⚠️ AND THE MERGE KEY IS THE VALIDATED INSTANT, NOT ITS SPELLING.
|
||||
# Aware datetimes hash and compare by instant, so this is rule 4 as
|
||||
# rule 4 is written. The old string key merged by SPELLING, and the
|
||||
# old lexicographic sort put the second 02:00 of the 25-hour
|
||||
# October day (`+01:00`) BEFORE the first (`+02:00`). Only the
|
||||
# stored order was affected - `_parse_points()` re-sorts by instant
|
||||
# on the way out - but the store now holds what it says it holds.
|
||||
raw = rec.get("points")
|
||||
held = {}
|
||||
for q in (raw if isinstance(raw, list) else ()):
|
||||
kept = _point_of(q)
|
||||
if kept is None:
|
||||
junked[day] = junked.get(day, 0) + 1
|
||||
continue
|
||||
held[kept.start] = q
|
||||
held[p.start] = p.canonical()
|
||||
order = sorted(held, key=lambda t: t.astimezone(timezone.utc))
|
||||
rec["points"] = [held[t] for t in order]
|
||||
rec["product"], rec["provider"] = product, provider
|
||||
touched.add(day)
|
||||
|
||||
if junked:
|
||||
# Said, not swallowed. `words` prints `last_error`, so a store that
|
||||
# has just discarded rows off disk stops reporting plain health -
|
||||
# and after `_save()` below the file is clean, so a restart clears
|
||||
# this. `_save()` overwrites it if the write itself fails, which is
|
||||
# the more urgent fact of the two - and on that path the drop is
|
||||
# undone with the rest of the merge, so those rows are still in the
|
||||
# file and the next write that completes drops them again.
|
||||
self.last_error = (
|
||||
f"{sum(junked.values())} malformed point row(s) dropped from "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
|
||||
_LOG.warning("price history at %s: %s - the record is rewritten "
|
||||
"without them and the refresh continues",
|
||||
self.path, self.last_error)
|
||||
self._prune(stamp)
|
||||
if not self._save():
|
||||
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING - the rule, asked
|
||||
# ONCE as a question about the outcome instead of tested twice
|
||||
# against two symptoms of it. Keeping the merge here is exactly the
|
||||
# cache the module docstring refuses: `yesterday()` would answer a
|
||||
# series the file does not contain, all day, until a restart
|
||||
# dropped it without a word - and `words` and `yesterday()` would
|
||||
# be describing two different stores. The reason is already in
|
||||
# `last_error` (from `_save()`, or from `_open()` when there is no
|
||||
# volume at all), so both answers now describe what is on disk.
|
||||
#
|
||||
# It does NOT raise, and §6 is why: a broken cache must not be a
|
||||
# broken controller. `carry()` still returns the fill, the horizon
|
||||
# is as short as the feed makes it, and the add-on regulates.
|
||||
self._days = before
|
||||
return
|
||||
_LOG.debug("price history: %d point(s) filed under %s; %d day(s) held",
|
||||
len(points), ", ".join(sorted(touched)), len(self._days))
|
||||
|
||||
def _prune(self, stamp: datetime) -> None:
|
||||
"""Drop records no lookup can reach. A sort-order cap, plus two pins.
|
||||
|
||||
⚠️ NOTHING IS DELETED BY ARITHMETIC AGAINST THE CLOCK. A clock that
|
||||
jumped - the failure mode WITNESS-03 spent a session on - would
|
||||
otherwise delete every record in one pass and leave the fill with
|
||||
nothing on the morning after, which is the one morning it is for.
|
||||
Deleting on a bad clock is unrecoverable; holding a few days too many
|
||||
is not, so the cap is `sorted(...)[-KEEP_DAYS:]` and stays that way.
|
||||
|
||||
⚠️ AND A CAP ALONE IS DEFEATED BY ONE POINT IN THE WRONG YEAR. Sorting
|
||||
is lexicographic on the date string, so a stray far-future record sorts
|
||||
LAST and is immortal - it consumes a slot for good, and with the steady
|
||||
state already {yesterday, today, tomorrow} filling all three, yesterday
|
||||
is what falls out. A feed that publishes to D+2 does the same thing
|
||||
without any stray at all. So the two days `yesterday()` can ACTUALLY be
|
||||
asked for are pinned on top of the cap.
|
||||
|
||||
`stamp` is READ here, which it was not before: the pin is the only use
|
||||
of it, and it is additive. Pinning can only RETAIN a record, never
|
||||
delete one, so a jumped `stamp` pins two dates that are not held and
|
||||
changes nothing - the WITNESS-03 property is preserved by construction
|
||||
rather than by remembering to check it.
|
||||
"""
|
||||
if len(self._days) <= KEEP_DAYS:
|
||||
return
|
||||
keep = set(sorted(self._days)[-KEEP_DAYS:])
|
||||
keep |= {_bdate(stamp), _yesterday_of(stamp)}
|
||||
for day in [d for d in self._days if d not in keep]:
|
||||
del self._days[day]
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
"""The published series of the Brussels day BEFORE `stamp`'s, or no_data.
|
||||
|
||||
⚠️ THE DATE IS COMPUTED, NOT LOOKED UP. See rule 2: `newest()` would
|
||||
answer Friday's prices to a Monday fill, and the fill would flag them as
|
||||
one day old because that is the only thing its flag can say.
|
||||
"""
|
||||
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
||||
raise ValueError(
|
||||
f"yesterday({stamp!r}): a naive datetime is not an instant, "
|
||||
f"and 'which Brussels day was yesterday' is exactly the "
|
||||
f"question a wall-clock reading cannot answer.")
|
||||
want = _yesterday_of(stamp)
|
||||
if self.last_error and not self._days:
|
||||
return PriceRead.no_data(
|
||||
f"the price history at {self.path} is {self.last_error}, so "
|
||||
f"§8.3 has no profile to carry forward for {want}; the plan "
|
||||
f"covers only the hours the feed published")
|
||||
rec = self._days.get(want)
|
||||
if rec is None:
|
||||
have = ", ".join(sorted(self._days)) or "no days at all"
|
||||
return PriceRead.no_data(
|
||||
f"no published prices recorded for {want} (held: {have}), so "
|
||||
f"§8.3 has no profile to carry forward. A record for another "
|
||||
f"day is not a yesterday")
|
||||
points, bad = _parse_points(rec.get("points"))
|
||||
if not points:
|
||||
why = bad or "it is empty"
|
||||
return PriceRead.no_data(
|
||||
f"the {want} record holds no usable point ({why}), and an "
|
||||
f"empty series is not a free day")
|
||||
try:
|
||||
product = PriceProduct(rec.get("product"))
|
||||
except ValueError:
|
||||
return PriceRead.no_data(
|
||||
f"the {want} record names product {rec.get('product')!r}, "
|
||||
f"which is not a PriceProduct. The optimiser switches on that "
|
||||
f"enum, so a record that cannot name one cannot be a series")
|
||||
if bad:
|
||||
_LOG.warning("price history: %s in the %s record were dropped; "
|
||||
"%d point(s) carried forward", bad, want, len(points))
|
||||
return PriceRead.series(points, product,
|
||||
str(rec.get("provider") or "price_history"),
|
||||
stamp)
|
||||
|
||||
# -- observability -------------------------------------------------------
|
||||
@property
|
||||
def words(self) -> str:
|
||||
"""One line for the status page. Says what is held, or why nothing is.
|
||||
|
||||
⚠️ THIS MUST NOT RAISE, EVER. It is read once from `Controller.__init__`
|
||||
and again on every status render, and neither call site guards it - so
|
||||
an exception here is an add-on that does not start. It went out that
|
||||
way: a day record that was a string instead of a dict raised
|
||||
`AttributeError` from the `len(...)` below. `_load()` now drops such a
|
||||
record and `_held()` is total, so this has two independent reasons not
|
||||
to fail on a broken file.
|
||||
|
||||
⚠️ AND IT COUNTED POINTS AND CALLED THEM HOURS (FCST-05's LOW). The row
|
||||
was `f"{d} ({_n_points(rec)} h)"`; on the quarter-hourly feed this
|
||||
add-on already supports that reads `'2026-01-13 (96 h)'` for a 24-hour
|
||||
day. Both numbers are printed now - see `_held()` - because they are
|
||||
two different facts and only one of them was ever being shown.
|
||||
"""
|
||||
if not self.ok:
|
||||
return f"unavailable - {self.last_error}"
|
||||
if not self._days:
|
||||
why = f" - {self.last_error}" if self.last_error else ""
|
||||
return f"empty{why} - nothing to carry forward yet"
|
||||
held = "; ".join(_row_of(d, *_held(self._days[d]))
|
||||
for d in sorted(self._days, key=str))
|
||||
# A file that PARTLY loaded says so, or the dropped days read as days
|
||||
# that were simply never written.
|
||||
return held + (f" [{self.last_error}]" if self.last_error else "")
|
||||
|
||||
|
||||
def _parse_points(raw) -> tuple:
|
||||
"""Canonical dicts -> PricePoints, and a phrase naming what was dropped.
|
||||
|
||||
⚠️ A MALFORMED POINT IS DROPPED, NOT DEFAULTED, and the drop is reported.
|
||||
`float(row.get("eur_per_kwh", 0))` is the shape of the whole family of
|
||||
defects this repo has been removing: a cache that lost a field would answer
|
||||
"that hour was free" to a fill that flags it as an ordinary guess.
|
||||
"""
|
||||
if not isinstance(raw, list):
|
||||
return (), "the `points` field is not a list"
|
||||
out, dropped = [], 0
|
||||
for row in raw:
|
||||
# ⚠️ `_point_of()` IS THE ONE VALIDATOR, and this loop used to BE it.
|
||||
# The write path had its own, weaker standard; see `_point_of()` for
|
||||
# what that cost. One standard, two callers, no asymmetry to re-open.
|
||||
pt = _point_of(row)
|
||||
if pt is None:
|
||||
dropped += 1
|
||||
continue
|
||||
out.append(pt)
|
||||
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
|
||||
|
||||
|
||||
class NoHistory:
|
||||
"""The explicit "this caller keeps no history" - a NAME, not a `None`.
|
||||
|
||||
⚠️ IT EXISTS SO THE PARAMETER CAN BE REQUIRED. `history=None` meaning "skip
|
||||
the fill" is the shape this repo keeps removing: a caller who forgets the
|
||||
argument gets the truncated-horizon behaviour of the bug this ticket is
|
||||
about, silently, and the code reads as though the fill is happening. With
|
||||
no default there is nothing to forget, and a caller with no durable volume
|
||||
- a check, a replay harness - has to say `NoHistory()` and be visible in
|
||||
the diff.
|
||||
|
||||
It carries out the ordering faithfully: it records nothing and carries
|
||||
nothing, so `carry()` returns the provider's read unchanged and the plan is
|
||||
exactly what it was before FCST-04.
|
||||
"""
|
||||
|
||||
ok = False
|
||||
last_error = "no history is kept by this caller"
|
||||
words = "not kept"
|
||||
|
||||
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
|
||||
return read
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data(
|
||||
"this caller keeps no price history (NoHistory), so §8.3 has no "
|
||||
"profile to carry forward")
|
||||
@@ -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)
|
||||
@@ -1019,7 +1046,7 @@ def static_status(opts: dict, now: datetime):
|
||||
|
||||
|
||||
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
transport=None, now=None) -> TariffPlan:
|
||||
transport=None, now=None, *, history) -> TariffPlan:
|
||||
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
|
||||
|
||||
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
|
||||
@@ -1033,17 +1060,63 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
price_provider off + profile -> the CONFIGURED product (§9.2 rows 3-4)
|
||||
price_provider off + none -> no prices at all (§11.4, nothing to
|
||||
fall back to)
|
||||
provider series -> the feed (§9.2 rows 1-2)
|
||||
provider series -> the feed, §8.3-FILLED (§9.2 rows 1-2)
|
||||
provider no_data + profile -> §11.4 FALLBACK, provisional, no arbitrage
|
||||
provider no_data + none -> no prices at all
|
||||
|
||||
⚠️ `history` IS REQUIRED, KEYWORD-ONLY, AND HAS NO DEFAULT (FCST-04). It is
|
||||
the §8.3 carry-forward store - `price_history.PriceHistory`, or the explicit
|
||||
`price_history.NoHistory()` for a caller that keeps none. A default of
|
||||
`None` meaning "skip the fill" would give a caller who forgot the argument
|
||||
the TRUNCATED HORIZON this ticket exists to remove, silently, while the code
|
||||
read as though the fill were happening. There is nothing to forget now.
|
||||
|
||||
⚠️ AND THE FILL RUNS ONLY ON A `series` READ. A feed that published nothing
|
||||
still falls to `_degrade()` and §11.4's own row - the static profile the
|
||||
operator configured FOR that case - rather than to yesterday's feed. The two
|
||||
are indistinguishable to the optimiser (both are 100 % provisional, so
|
||||
`arbitrage()` finds fewer than two firm hours and refuses, and `cheap_now()`
|
||||
is False), so preferring yesterday would change a documented degradation for
|
||||
a difference only the status page can see. See price_history.py's closing
|
||||
note and DECISIONS/FCST-04-the-day-that-is-carried.md.
|
||||
|
||||
⚠️ THE FILL IS PER WHOLE HOUR AND ITS COVERAGE IS CONDITIONAL. Every point
|
||||
the provider published inside the window passes through BY IDENTITY - four
|
||||
of them in one hour on a quarter-hourly product like `eneco_eddy` - and a
|
||||
carried point is added only for a whole hour the feed did not print at all
|
||||
AND yesterday did. An hour neither source can price is left out, which is
|
||||
§11.4 for that hour: `plan.price_at()` raises rather than answering, and an
|
||||
hour with no price is not a free one.
|
||||
|
||||
⚠️ THE FILLED SERIES IS WINDOWED TO [since, until), AND THAT IS A CHANGE.
|
||||
`load_prices()` returns whatever the feed sent, past hours included, and
|
||||
`TariffPlan.arbitrage()` took its spread over all of it - so an hour that
|
||||
had already gone could be the cheapest hour of a cycle nothing can now buy
|
||||
in. `fill_from_yesterday()` walks the requested horizon and emits exactly
|
||||
that, so the spread is over hours that are still ahead. Both directions of
|
||||
the difference are real: the window gains the unpublished hours it was
|
||||
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)
|
||||
return read.match(
|
||||
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
|
||||
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
|
||||
"""§8.3: record what the feed published, carry yesterday into the gaps.
|
||||
|
||||
The fill can itself return `no_data` - a feed whose points all fall outside
|
||||
the horizon, with nothing in the history to fill it from - and that is the
|
||||
§11.4 row arrived at one step later, so it degrades exactly as a failed
|
||||
fetch does rather than being special-cased here.
|
||||
"""
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
||||
return filled.match(
|
||||
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ class MigrationReport:
|
||||
return out
|
||||
|
||||
|
||||
def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
|
||||
"""Move the pre-DEV-02 flat per-inverter keys into `unit`'s namespace.
|
||||
|
||||
`unit` is the FIRST inverter in the roster, which on every install that can
|
||||
@@ -261,6 +261,24 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
version that knew about more than one inverter is already at
|
||||
SCHEMA_VERSION and is not touched.
|
||||
|
||||
⚠️ `roster` IS THE NAMES THE MACHINES READ, AND IT IS SAFETY-44. This
|
||||
function shipped WITHOUT it, taking a hardcoded `inverter.UNIT_A` at its one
|
||||
call site - which is, exactly, the MAINT-05 defect `_verify_moves()` spends
|
||||
a paragraph describing, still live in the sibling that paragraph was written
|
||||
beside. On a store that ran pre-DEV-02 flat and declares
|
||||
`inverters: [garage, shed]`, the latch moved to `unit/inverter-a/
|
||||
sign_tripped`, was read back under that same name, was SATISFIED, and
|
||||
stamped the marker - so the next start skipped the migration too and NO
|
||||
inverter came up latched on an install whose state file says `sign_tripped:
|
||||
true`. A disarmed SAFETY-02 guard, every symptom of a clean migration.
|
||||
|
||||
`roster_names(opts)` answers this BEFORE any `Inverter` exists - it is
|
||||
defined to, for `MqttPublisher`, which is constructed before the roster for
|
||||
the same reason - so the "there is no roster yet" that kept this argument
|
||||
out was never true. On the flat shape it returns `[UNIT_A]`, so the upgrade
|
||||
every install runs today passes `unit == roster[0] == UNIT_A` and is
|
||||
byte-identical.
|
||||
|
||||
THE THREE RULES, each one a case that would otherwise be a wrong answer:
|
||||
|
||||
1. IT NEVER OVERWRITES AN EXISTING NAMESPACED VALUE. A store that has both
|
||||
@@ -302,7 +320,7 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
|
||||
rep.ran = True
|
||||
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
|
||||
_verify_moves(store, rep)
|
||||
_verify_moves(store, rep, roster)
|
||||
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
@@ -383,12 +401,26 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
||||
itself. `test_maintenance.py` section 9c calls this twice with the SAME
|
||||
wrong `unit` over one store, which is the shape nothing in the suite had.
|
||||
|
||||
`roster=None` means "not graded", and the one caller that passes it is
|
||||
`migrate_unit_keys()`, which runs BEFORE `build_roster()` for the reason its
|
||||
own docstring gives - the `Inverter` objects restore their latch in their
|
||||
constructors, so the keys have to be in place before there is a roster to
|
||||
check against. That gap is real and is recorded as a running check rather
|
||||
than left to a comment: see test_maintenance.py section 13.
|
||||
`roster=None` still means "not graded", and SAFETY-44 is what it cost. This
|
||||
paragraph used to end here, saying that the one caller passing it was
|
||||
`migrate_unit_keys()`, "which runs BEFORE `build_roster()` ... so the keys
|
||||
have to be in place before there is a roster to check against", and calling
|
||||
that gap real. THE FIRST HALF IS TRUE AND THE CONCLUSION IS NOT. The keys do
|
||||
have to be written before the `Inverter`s restore from them - but the NAMES
|
||||
do not come from the `Inverter`s. `roster_names(opts)` reads them straight
|
||||
off the options, before anything is constructed, which is the whole reason
|
||||
it exists (`MqttPublisher` is built before the roster too). So the argument
|
||||
that could have graded that write was available at the call site the entire
|
||||
time, and the sibling function three screens down had already been fixed by
|
||||
passing it.
|
||||
|
||||
⚠️ SO NO CALLER PASSES `None` TODAY, and this branch is kept for one reason:
|
||||
it is what a NEW caller gets by DEFAULT. A future migration that forgets the
|
||||
roster gets the ungraded read-back and stamps its marker over a namespace no
|
||||
machine reads - silently, exactly as this one did. That is the shape of both
|
||||
defects, and the gap is recorded as a running check rather than left to a
|
||||
comment: test_maintenance.py section 13, and test_rehearse.py part 5, which
|
||||
drives it end-to-end through the harness an operator actually runs.
|
||||
"""
|
||||
for target, value in rep.moved.items():
|
||||
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
||||
|
||||
@@ -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
|
||||
@@ -548,8 +570,10 @@ options:
|
||||
# only ever ran at 500 could not tell this wiring from a hard-coded number.
|
||||
#
|
||||
# How long ONE inverter may stand at a discharge share it is not producing -
|
||||
# short by more than `saturation_w` - while at least one sibling is NOT in
|
||||
# that state, before the add-on names it. It REPORTS: nothing is commanded
|
||||
# short by more than `saturation_w`, OR short by more than a fixed FRACTION
|
||||
# of its own share (SAFETY-43; the fraction is derived and is not an option,
|
||||
# see app/delivery.py) - while at least one sibling is NOT in that state,
|
||||
# before the add-on names it. It REPORTS: nothing is commanded
|
||||
# differently, the loop is untouched, and the site goes on running short by
|
||||
# that unit's share until somebody looks. app/delivery.py carries why, with
|
||||
# the rig measurements on both sides of the decision.
|
||||
@@ -560,17 +584,31 @@ options:
|
||||
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 2640 closed-loop runs
|
||||
# at a roster of two in which nothing was faulty, over 6 gains x 5 slews - the
|
||||
# whole range this schema accepts - across load reversals, a flapping load and
|
||||
# ASYMMETRIC SPLITS, plus 1890 runs at a roster of ONE as the control. At the
|
||||
# shipped threshold the worst CONTINUOUS legitimate episode is 30.0 s (the
|
||||
# weighted family; 20.0 s on reversals, 0.0 s on flapping, and 0.0 s at every
|
||||
# threshold in all 1890 one-inverter runs). 185 s is 6.2x that.
|
||||
# ASYMMETRIC SPLITS, plus a roster-of-ONE arm as the control. At the shipped
|
||||
# threshold the worst CONTINUOUS legitimate episode is 35.0 s (the weighted
|
||||
# family; 30.0 s on reversals, 0.0 s on flapping, and 0.0 s at every threshold
|
||||
# in every one-inverter run). 185 s is 5.29x that.
|
||||
#
|
||||
# ⚠️ AND THE MARGIN IS SIZED ON A MEASUREMENT, NOT ON A FEELING, BECAUSE
|
||||
# INVERTER B DOES NOT PHYSICALLY EXIST. The rig's B is A copied. Sweeping a
|
||||
# DIFFERENT second unit through that campaign: the worst legitimate episode is
|
||||
# insensitive to B's ramp (30.0 s at half A's ramp) and scales with its LAG -
|
||||
# 45.0 s at twice A's measured 10 s lag, 90.0 s at four times it. 185 s is
|
||||
# 2.06x even the four-times-slower case.
|
||||
# ⚠️ AND THE CAMPAIGN IS RE-RUNNABLE NOW, WHICH IT WAS NOT WHEN THIS NUMBER
|
||||
# SHIPPED. TEST-21 (2026-09-07) committed the harness to the OTHER repository,
|
||||
# `admin/goodwe:sim/delivery_window.py` - it imports plantsim and frame_check,
|
||||
# which that repo owns. From clean checkouts of both, side by side:
|
||||
#
|
||||
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
# python3 -B delivery_window.py --selftest --campaign --blag
|
||||
#
|
||||
# ⚠️ THE ORIGINAL RECORD SAID 30.0 s AND IT DID NOT REPRODUCE - the re-run
|
||||
# says 35.0 s. 185 s still clears all four bounds and is unchanged; what
|
||||
# changed is the claim. DECISIONS/SAFETY-42-unit-delivery.md §3.3 carries both
|
||||
# tables and what did and did not reproduce.
|
||||
#
|
||||
# ⚠️ AND THE MARGIN IS A SENSITIVITY STUDY ON A HYPOTHESIS, NOT A MEASUREMENT,
|
||||
# BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). The rig's B is A
|
||||
# COPIED. Sweeping a DIFFERENT second unit through that campaign: the worst
|
||||
# legitimate episode barely moves with B's ramp (35.0 s at half A's ramp) and
|
||||
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 75.0 s at four
|
||||
# times it. 185 s is 2.47x even the four-times-slower case. NO NUMBER HERE IS
|
||||
# A MEASUREMENT OF INVERTER B.
|
||||
#
|
||||
# DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it -
|
||||
# and not 150 or 200. 37 control cycles.
|
||||
|
||||
@@ -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")
|
||||
@@ -1376,6 +1695,312 @@ check("soc_bounds() returns the shipped pair, floor first",
|
||||
float(PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("10. TARIFF-14 - the QUARTER-HOURLY day, end to end through decide()")
|
||||
# ⚠️ THIS IS THE PROPERTY THE DEFECT ACTUALLY BROKE, WHICH IS WHY IT IS GRADED
|
||||
# HERE AND NOT ONLY ON `at()`. `_act()` decides whether this interval is the
|
||||
# buy one or the sell one with `here.start == v.cheapest.start` - an IDENTITY
|
||||
# test between the point `_point_at()` matched and the point the verdict was
|
||||
# built from. `_Series.at()` used to return the first point whose start was
|
||||
# within one hour BEFORE the instant, in list order, so on a 900 s series that
|
||||
# identity almost never held: the strategy idled through exactly the interval
|
||||
# it exists to trade, and read `here.provisional` and §17's `price < 0` off a
|
||||
# point that was not the one being traded.
|
||||
#
|
||||
# Belgium moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
|
||||
# returns 96 points at 900 s. `static_tariff.py` emits HOURLY points, so every
|
||||
# fixture above this line - and every static site - is immune; this is
|
||||
# reachable only down the dynamic path TARIFF-08/09 made work.
|
||||
|
||||
QD0 = datetime(2026, 1, 15, 0, 0, tzinfo=timezone(timedelta(hours=1)))
|
||||
QUARTER = timedelta(minutes=15)
|
||||
|
||||
|
||||
def qplan_of(prices, *, provisional=False, start=QD0, gaps=None):
|
||||
"""A TariffPlan over `prices` spaced by `gaps` seconds (900 s by default).
|
||||
|
||||
`gaps` is what makes the MIXED fixture below expressible: FCST-04's
|
||||
carry-forward emits HOURLY carried points beside QUARTER-HOURLY firm ones,
|
||||
and the soak measured spacing [900, 3600] in 70 % of samples.
|
||||
"""
|
||||
g = [900] * (len(prices) - 1) if gaps is None else list(gaps)
|
||||
flags = ([bool(provisional)] * len(prices)
|
||||
if isinstance(provisional, bool) else list(provisional))
|
||||
pts, t = [], start
|
||||
for i, (p, f) in enumerate(zip(prices, flags)):
|
||||
pts.append(PricePoint(start=brussels(t), eur_per_kwh=round(float(p), 6),
|
||||
provisional=bool(f)))
|
||||
if i < len(g):
|
||||
t += timedelta(seconds=g[i])
|
||||
return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", start),
|
||||
None, "provider")
|
||||
|
||||
|
||||
def qprobe(i, *, start=QD0, gaps=None):
|
||||
"""An instant 7 minutes into slot `i` - inside every window, never on a
|
||||
boundary, and NOT the midpoint of an hour either, so an implementation that
|
||||
answered per hour and one that answers per quarter differ here."""
|
||||
g = [900] * i if gaps is None else list(gaps)[:i]
|
||||
return start + timedelta(seconds=sum(g)) + timedelta(minutes=7)
|
||||
|
||||
|
||||
def shipped_at(series, u):
|
||||
"""THE RULE TARIFF-14 REPLACED, kept here as the COUNTERFACTUAL ONLY.
|
||||
|
||||
The first point whose start is within one hour before `u`, in list order.
|
||||
Nothing below is graded against it; it exists so that the fixtures can be
|
||||
shown to SEPARATE the two rules. A check whose data cannot reach the
|
||||
failure it names is decoration (MUTATION.md), and on an hourly series the
|
||||
two rules agree at every instant - which is why 29 files stayed green.
|
||||
"""
|
||||
uu = u.astimezone(UTC)
|
||||
for p in series.points:
|
||||
s = p.start.astimezone(UTC)
|
||||
if s <= uu < s + timedelta(hours=1):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
# The hourly day interpolated across its quarters, with the two extremes
|
||||
# planted OFF the hour boundary on purpose.
|
||||
QBASE = []
|
||||
for _i in range(24):
|
||||
_a, _b = DAY[_i], DAY[(_i + 1) % 24]
|
||||
for _q in range(4):
|
||||
QBASE.append(round(_a + (_b - _a) * _q / 4 + 0.000001 * (_i * 4 + _q),
|
||||
6))
|
||||
Q_CHEAP_I = 4 * 5 + 3 # 05:45 - a :45 quarter, not a whole hour
|
||||
Q_DEAR_I = 4 * 19 + 1 # 19:15 - a :15 quarter, not a whole hour
|
||||
QDAY = list(QBASE)
|
||||
QDAY[Q_CHEAP_I] = round(min(QBASE) - 0.030, 6)
|
||||
QDAY[Q_DEAR_I] = round(max(QBASE) + 0.030, 6)
|
||||
QPLAN = qplan_of(QDAY)
|
||||
QPTS = QPLAN.read.points
|
||||
|
||||
print("10a. the fixture is not degenerate, and it SEPARATES the two rules")
|
||||
_qgaps = sorted({int((b.start - a.start).total_seconds())
|
||||
for a, b in zip(QPTS, QPTS[1:])})
|
||||
check(f"the day is 96 points at {_qgaps} s - the shape the live feed has "
|
||||
f"returned since Belgium's 2025-10-01 move to a 15-minute MTU",
|
||||
len(QPTS) == 96 and _qgaps == [900])
|
||||
check(f"the cheapest quarter is slot {Q_CHEAP_I} and the dearest slot "
|
||||
f"{Q_DEAR_I} - neither first, neither last, {abs(Q_CHEAP_I - Q_DEAR_I)} "
|
||||
f"slots apart, and NEITHER ON A WHOLE HOUR, without which this section "
|
||||
f"would grade nothing an hourly fixture does not already grade",
|
||||
QDAY.index(min(QDAY)) == Q_CHEAP_I and QDAY.index(max(QDAY)) == Q_DEAR_I
|
||||
and Q_CHEAP_I % 4 != 0 and Q_DEAR_I % 4 != 0
|
||||
and Q_CHEAP_I not in (0, 95) and Q_DEAR_I not in (0, 95)
|
||||
and abs(Q_CHEAP_I - Q_DEAR_I) > 1)
|
||||
check("...and all 96 prices are distinct, so 'the cheapest quarter' names one "
|
||||
"quarter and no tie-break is what any check below is really grading",
|
||||
len(set(QDAY)) == 96)
|
||||
check(f"...and its spread is {round(max(QDAY) - min(QDAY), 6):.4f} EUR/kWh, "
|
||||
f"clear of the widened threshold, so what follows grades the strategy "
|
||||
f"and not the spread rung",
|
||||
round(max(QDAY) - min(QDAY), 6) > required_spread(_shipped, NO_WEATHER))
|
||||
# ⚠️ HABIT 4, AND THE ONLY REASON THIS SECTION IS WORTH ITS LINES. State, as a
|
||||
# check, that the shipped rule and the fixed one give DIFFERENT points at the
|
||||
# two probes that matter. Without it, every check below could be passing on a
|
||||
# fixture the defect could not have changed.
|
||||
_cf_buy = derived(lambda: shipped_at(QPLAN.read, qprobe(Q_CHEAP_I)))
|
||||
_cf_sell = derived(lambda: shipped_at(QPLAN.read, qprobe(Q_DEAR_I)))
|
||||
check(f"the shipped rule and this one give DIFFERENT points at both probes - "
|
||||
f"buy: {derived(lambda: _cf_buy.eur_per_kwh)} instead of "
|
||||
f"{QDAY[Q_CHEAP_I]}, sell: {derived(lambda: _cf_sell.eur_per_kwh)} "
|
||||
f"instead of {QDAY[Q_DEAR_I]} - so the fixture can reach the failure it "
|
||||
f"names",
|
||||
_cf_buy is not QPTS[Q_CHEAP_I] and _cf_sell is not QPTS[Q_DEAR_I]
|
||||
and _cf_buy is not None and _cf_sell is not None)
|
||||
|
||||
print()
|
||||
print("10b. the buy/sell identity test matches the actual extremum interval")
|
||||
_qv = QPLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER)
|
||||
check("the verdict's cheapest and dearest are the two QUARTERS, not the hours "
|
||||
"containing them - `static_tariff` reduces over every firm point and "
|
||||
"carries no resolution assumption of its own",
|
||||
derived(lambda: _qv.cheapest) is QPTS[Q_CHEAP_I]
|
||||
and derived(lambda: _qv.dearest) is QPTS[Q_DEAR_I])
|
||||
_pa_buy = derived(lambda: _arb._point_at(QPLAN, qprobe(Q_CHEAP_I)))
|
||||
_pa_sell = derived(lambda: _arb._point_at(QPLAN, qprobe(Q_DEAR_I)))
|
||||
check("THE PROPERTY THE DEFECT BROKE: at the buy probe `_point_at()` returns "
|
||||
"the verdict's OWN cheapest point, by identity - which is what makes "
|
||||
"`_act()`'s `here.start == v.cheapest.start` true, and what made it "
|
||||
"false on every quarter-hourly series the add-on has ever fetched",
|
||||
_pa_buy is derived(lambda: _qv.cheapest))
|
||||
check("...and the same at the sell probe, against the verdict's dearest",
|
||||
_pa_sell is derived(lambda: _qv.dearest))
|
||||
_qbuy = decide(QPLAN, qprobe(Q_CHEAP_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
_qsell = decide(QPLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
check("...so decide() CHARGES in the cheapest quarter and DISCHARGES in the "
|
||||
"dearest one, which is the trade the strategy exists to make and the "
|
||||
"one it slept through",
|
||||
_qbuy.action == CHARGE and _qsell.action == DISCHARGE)
|
||||
check(f"...at THAT QUARTER's price and not at the price of the hour "
|
||||
f"containing it (buy {_qbuy.price_now_eur_kwh}, sell "
|
||||
f"{_qsell.price_now_eur_kwh})",
|
||||
near(_qbuy.price_now_eur_kwh, QDAY[Q_CHEAP_I])
|
||||
and near(_qsell.price_now_eur_kwh, QDAY[Q_DEAR_I]))
|
||||
check("...and both reasons quote that quarter's price, so the operator's log "
|
||||
"names the interval that was actually traded",
|
||||
f"{QDAY[Q_CHEAP_I]:.4f}" in _qbuy.reason
|
||||
and f"{QDAY[Q_DEAR_I]:.4f}" in _qsell.reason)
|
||||
# The negative arm. Without it "it acted" could be "it acts everywhere".
|
||||
_QOFF_I = Q_CHEAP_I + 2
|
||||
check(f"the off-peak probe's own price differs from its hour's :00 price "
|
||||
f"({QDAY[_QOFF_I]} vs {QDAY[4 * (_QOFF_I // 4)]}), so the check below "
|
||||
f"can tell which point the decision read",
|
||||
QDAY[_QOFF_I] != QDAY[4 * (_QOFF_I // 4)]
|
||||
and _QOFF_I not in (Q_CHEAP_I, Q_DEAR_I))
|
||||
_qoff = decide(QPLAN, qprobe(_QOFF_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
check("a quarter that is neither extremum still idles, for the reason that "
|
||||
"names the right rule, and reports ITS OWN price - not the price of the "
|
||||
"quarter the shipped scan would have matched",
|
||||
_qoff.action is None and _qoff.reason == _arb.IDLE_OFF_PEAK_HOUR
|
||||
and near(_qoff.price_now_eur_kwh, QDAY[_QOFF_I]))
|
||||
|
||||
print()
|
||||
print("10c. §17 and §8.3 are evaluated on the QUARTER being traded")
|
||||
# ⚠️ §17 AT 900 s. The whole day is below zero and the dearest QUARTER is the
|
||||
# least negative one - section 2's shape, at the resolution the live feed
|
||||
# actually publishes. Under the shipped scan this probe never reached the
|
||||
# guard at all: the identity test failed first and the decision idled as
|
||||
# "neither the buy hour nor the sell hour", so §17 was not so much wrong as
|
||||
# never consulted.
|
||||
_shift = round(max(QDAY) + 0.020, 6)
|
||||
QNEG = [round(p - _shift, 6) for p in QDAY]
|
||||
QNEG_PLAN = qplan_of(QNEG)
|
||||
check(f"the negative day's dearest quarter is slot {Q_DEAR_I} and it is BELOW "
|
||||
f"ZERO ({max(QNEG):+.4f}), and its spread still clears the widened "
|
||||
f"threshold - so the refusal below is §17's and not the spread rung's",
|
||||
QNEG.index(max(QNEG)) == Q_DEAR_I and max(QNEG) < 0
|
||||
and round(max(QNEG) - min(QNEG), 6) > required_spread(_shipped,
|
||||
NO_WEATHER))
|
||||
_qneg_sell = decide(QNEG_PLAN, qprobe(Q_DEAR_I), peak_guard_active=False,
|
||||
soc=50.0, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER, charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
check("selling into a negative-price QUARTER is refused, and the reason names "
|
||||
"§17 and THAT QUARTER's price",
|
||||
_qneg_sell.action is None and "§17" in _qneg_sell.reason
|
||||
and f"{max(QNEG):+.4f}" in _qneg_sell.reason)
|
||||
# ...and the other side, so the refusal is about the sign and nothing else.
|
||||
QPOS = list(QNEG)
|
||||
QPOS[Q_DEAR_I] = round(-max(QNEG), 6)
|
||||
check("...while the SAME quarter-hourly series with that ONE price's SIGN "
|
||||
"FLIPPED discharges instead",
|
||||
decide(qplan_of(QPOS), qprobe(Q_DEAR_I), peak_guard_active=False,
|
||||
soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == DISCHARGE
|
||||
and sum(1 for a, b in zip(QNEG, QPOS) if a != b) == 1)
|
||||
# §8.3's flag, read off the covering point. `_act`'s own provisional guard
|
||||
# stays unreachable here for section 4's reason - the extrema are firm by
|
||||
# `_firm_or_die` and starts are unique - and THAT ARGUMENT IS EXACTLY WHAT THE
|
||||
# DEFECT BROKE at 900 s, because the point `at()` returned was not the
|
||||
# verdict's point at all. It is restated here at the resolution that matters.
|
||||
check("the point matched for the buy quarter IS the verdict's own cheapest "
|
||||
"point and is FIRM, so `_act`'s §8.3 guard is unreachable at 900 s for "
|
||||
"the same construction section 4 states at 3600 s",
|
||||
_pa_buy is derived(lambda: _qv.cheapest)
|
||||
and derived(lambda: _pa_buy.provisional) is False
|
||||
and len({p.start for p in QPTS}) == len(QPTS))
|
||||
|
||||
print()
|
||||
print("10d. the MIXED series FCST-04 actually emits, traded end to end")
|
||||
# ⚠️ 70 % OF SOAK SAMPLES, NOT AN EDGE CASE. The carry-forward emits HOURLY
|
||||
# carried points beside QUARTER-HOURLY firm ones, so a window derived from one
|
||||
# global spacing would be wrong in the NORMAL steady state. Here: 12 carried
|
||||
# hourly points for the morning, then firm quarters for the rest of the day.
|
||||
MIX_N_H, MIX_N_Q = 12, 48
|
||||
MIX_GAPS = [3600] * MIX_N_H + [900] * (MIX_N_Q - 1)
|
||||
MIX_PRICES = [QDAY[4 * i] for i in range(MIX_N_H)] + QDAY[4 * MIX_N_H:]
|
||||
MIX_FLAGS = [True] * MIX_N_H + [False] * MIX_N_Q
|
||||
MIX_PLAN = qplan_of(MIX_PRICES, provisional=MIX_FLAGS, gaps=MIX_GAPS)
|
||||
MIX_PTS = MIX_PLAN.read.points
|
||||
_mix_gaps = sorted({int((b.start - a.start).total_seconds())
|
||||
for a, b in zip(MIX_PTS, MIX_PTS[1:])})
|
||||
check(f"the mixed plan really is mixed - spacing {_mix_gaps} s, "
|
||||
f"{MIX_N_H} carried and {MIX_N_Q} firm points, so neither half is what "
|
||||
f"the other half's window rule would give",
|
||||
_mix_gaps == [900, 3600]
|
||||
and sum(1 for p in MIX_PTS if p.provisional) == MIX_N_H
|
||||
and sum(1 for p in MIX_PTS if not p.provisional) == MIX_N_Q)
|
||||
_mix_cheap_i = MIX_N_H + (MIX_PRICES[MIX_N_H:].index(min(MIX_PRICES[MIX_N_H:])))
|
||||
_mix_dear_i = MIX_N_H + (MIX_PRICES[MIX_N_H:].index(max(MIX_PRICES[MIX_N_H:])))
|
||||
check(f"...and its firm half's extremes are quarters {_mix_cheap_i} and "
|
||||
f"{_mix_dear_i}, neither on a whole hour, so the identity test below is "
|
||||
f"about the quarter and not about the hour",
|
||||
(_mix_cheap_i - MIX_N_H) % 4 != 0 and (_mix_dear_i - MIX_N_H) % 4 != 0)
|
||||
_mv = MIX_PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER)
|
||||
_mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS)
|
||||
_mix_sell_t = qprobe(_mix_dear_i, gaps=MIX_GAPS)
|
||||
check("the verdict over a MIXED series still names firm quarters, and "
|
||||
"`_point_at()` matches them by identity at their own probes",
|
||||
derived(lambda: _mv.cheapest) is MIX_PTS[_mix_cheap_i]
|
||||
and derived(lambda: _mv.dearest) is MIX_PTS[_mix_dear_i]
|
||||
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_buy_t))
|
||||
is MIX_PTS[_mix_cheap_i]
|
||||
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t))
|
||||
is MIX_PTS[_mix_dear_i])
|
||||
check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
|
||||
"their own prices",
|
||||
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == CHARGE
|
||||
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == DISCHARGE)
|
||||
# An instant inside a CARRIED hour of the same series. The covering point is
|
||||
# the carried one; the shipped scan answered a point from the FIRM block
|
||||
# earlier in the list, which is §8.3's flag read off the wrong point.
|
||||
_mix_carried_t = QD0 + timedelta(hours=MIX_N_H - 1, minutes=37)
|
||||
check("an instant inside a CARRIED hour of the mixed series matches the "
|
||||
"CARRIED point, whose provisional flag is what §8.3 reads - and the "
|
||||
"strategy does nothing there, because a carried point is not in the "
|
||||
"firm trade set",
|
||||
derived(lambda: _arb._point_at(MIX_PLAN, _mix_carried_t))
|
||||
is MIX_PTS[MIX_N_H - 1]
|
||||
and MIX_PTS[MIX_N_H - 1].provisional is True
|
||||
and decide(MIX_PLAN, _mix_carried_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action is None)
|
||||
|
||||
print()
|
||||
print("10e. the HOURLY day is unchanged, which is the regression half")
|
||||
# ⚠️ A REGRESSION GUARD, NOT A WITNESS. On an hourly series the shipped rule
|
||||
# and this one agree at every instant, which is precisely why 29 files stayed
|
||||
# green through the defect. It is here so that a fix which got 900 s right by
|
||||
# breaking 3600 s cannot pass.
|
||||
_h_buy = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
|
||||
_h_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER)
|
||||
check("the hourly fixture still charges in its cheapest hour and discharges "
|
||||
"in its dearest, at those hours' prices",
|
||||
_h_buy.action == CHARGE and _h_sell.action == DISCHARGE
|
||||
and near(_h_buy.price_now_eur_kwh, DAY[CHEAP_H])
|
||||
and near(_h_sell.price_now_eur_kwh, DAY[DEAR_H]))
|
||||
check("...and a probe 59 minutes into an hourly slot still matches that slot, "
|
||||
"so the hourly window did not shrink under the fix",
|
||||
derived(lambda: _arb._point_at(
|
||||
PLAN, D0 + timedelta(hours=CHEAP_H, minutes=59)))
|
||||
is derived(lambda: PLAN.read.points[CHEAP_H]))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -33,8 +33,9 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.control import Tuning
|
||||
from app.delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, REASON_CODE, DeliveryConfig, DeliveryWatchdog,
|
||||
UnitDelivery, account, delinquent, short_by,
|
||||
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
|
||||
DeliveryConfig, DeliveryWatchdog, UnitDelivery, account, delinquent,
|
||||
short_by,
|
||||
)
|
||||
from app.inverter import LINK_STALE_S
|
||||
from app.main import Controller
|
||||
@@ -151,6 +152,77 @@ check("a unit given no share is not asked and cannot be named, and it is not "
|
||||
"a peer either - with only one unit asked there is nothing to compare",
|
||||
delinquent([U("a", 0, 0), U("b", 2000, 0)], 500) == {})
|
||||
|
||||
print()
|
||||
print(" SAFETY-43: THE RELATIVE ARM - short OF ITS OWN ASK, not just short")
|
||||
|
||||
# ⚠️ `share_frac=None` IS SAFETY-42's RULE EXACTLY, and every check in this part
|
||||
# above this line is run under BOTH rules below to show the card took nothing
|
||||
# away. What it ADDS is one thing: a unit can now be named for delivering almost
|
||||
# none of a SMALL share. OPT-09's weights make small shares ordinary - a
|
||||
# low-headroom unit is weighted down to 125 W of a 1630 W site target - so
|
||||
# before this card a wedged unit could be 100 % delinquent and 125 W short, and
|
||||
# 125 W is under a 500 W threshold. test_packshare.py part 8b measures that end
|
||||
# to end on the real loop; what is graded here is the predicate.
|
||||
_ABS = None # SAFETY-42's rule
|
||||
|
||||
_tiny = [U("a", 1505, 1505), U("b", 125, 0)]
|
||||
check("A UNIT DELIVERING 0 OF 125 W IS NAMED, AND UNDER THE OLD RULE IT WAS "
|
||||
"NOT. Same three units, same `min_w`, one input changed: the absolute arm "
|
||||
"alone answers {} because 125 W < 500 W, and the shipped rule names it "
|
||||
"because 100 % of its ask is not arriving",
|
||||
delinquent(_tiny, 500, _ABS) == {}
|
||||
and list(delinquent(_tiny, 500)) == ["b"])
|
||||
# ⚠️ `.get`, NOT `["b"]`, AND THAT IS NOT STYLE. A bare subscript here raises
|
||||
# KeyError on any tree where the relative arm is broken or absent, which ABORTS
|
||||
# THE FILE at part 1 and leaves parts 2-8 UNGRADED. The direction is safe - an
|
||||
# abort is a red, and MUTATION.md's defect can only fabricate a green - but the
|
||||
# recorded coverage of a mutation run then reads larger than it was, which is
|
||||
# the shape M11's entry in test_packshare.py part 11 already discloses. Found
|
||||
# in review of this card, on M12 and M13.
|
||||
_tiny_said = delinquent(_tiny, 500).get("b", "(not named - see the check above)")
|
||||
print(f" {_tiny_said}")
|
||||
check("...and the sentence carries the PROPORTION as well as the watts, because "
|
||||
"'125 W is not arriving' reads like a rounding error and '125 W (100 % of "
|
||||
"it)' is the fault",
|
||||
"125 W of its share (100 % of it) is not arriving" in _tiny_said)
|
||||
|
||||
check("THE ACCOUNT GATE IS UNTOUCHED BY IT - the same 0 of 125 W with the "
|
||||
"shortfall EXPLAINED names nobody. A relative criterion that could talk "
|
||||
"its way past `account()` would report every pack sitting at its own "
|
||||
"cut-out on a small share, which is the crying-wolf failure this module "
|
||||
"exists to avoid",
|
||||
delinquent([U("a", 1505, 1505), U("b", 125, 0, "its pack is empty")],
|
||||
500) == {})
|
||||
check("...and so is the PROPER-SUBSET rule: both units delivering none of a "
|
||||
"small share is a SITE condition and is still nobody's fault to name",
|
||||
delinquent([U("a", 125, 0), U("b", 125, 0)], 500) == {})
|
||||
check("...and the relative bound is STRICT, like the absolute one. A gap of "
|
||||
"EXACTLY the fraction of the share is not over it; one watt more is",
|
||||
delinquent([U("a", 2000, 2000),
|
||||
U("b", 1000, 1000 * (1.0 - DELIVERY_SHARE_FRACTION))],
|
||||
5000) == {}
|
||||
and list(delinquent([U("a", 2000, 2000),
|
||||
U("b", 1000,
|
||||
1000 * (1.0 - DELIVERY_SHARE_FRACTION) - 1)],
|
||||
5000)) == ["b"])
|
||||
|
||||
_both_rules = [(delinquent(u, w, _ABS), delinquent(u, w)) for u, w in (
|
||||
([U("a", 2000, 2000), U("b", 2000, 0)], 500),
|
||||
([U("a", 2000, 2000), U("b", 2000, 0, "its pack is empty")], 500),
|
||||
([U("a", 2000, 0), U("b", 2000, 0)], 500),
|
||||
([U("a", 2000, 2000), U("b", 2000, 0), U("c", 2000, 0)], 500),
|
||||
([U("a", 2000, 0, "at its floor"), U("b", 2000, 0)], 500),
|
||||
([U("a", 2000, 2000), U("b", 2000, 1500)], 500),
|
||||
([U("a", 0, 0), U("b", 2000, 0)], 500),
|
||||
)]
|
||||
check(f"⚠️ AND IT TOOK NOTHING AWAY: all {len(_both_rules)} fixtures above this "
|
||||
f"block answer IDENTICALLY under SAFETY-42's rule and the shipped one. "
|
||||
f"The relative arm is an `or`, so it can only ever ADD a naming - and on "
|
||||
f"every case this part already graded it adds none. That is the "
|
||||
f"difference between adding a second way to be named and lowering "
|
||||
f"`min_w`, which would have moved all seven",
|
||||
all(a == b for a, b in _both_rules))
|
||||
|
||||
print()
|
||||
print(" AC 5: AT ONE INVERTER IT IS INERT, AND BY ALGEBRA RATHER THAN BY A")
|
||||
print(" ROSTER GUARD - `len(asked) < 2` is unreachable-by-construction there")
|
||||
@@ -323,9 +395,14 @@ class _DeliveryHass:
|
||||
same in both.
|
||||
"""
|
||||
|
||||
def __init__(self, load_w=_LOAD_W, b_soc=50.0):
|
||||
def __init__(self, load_w=_LOAD_W, b_soc=50.0, b_frac=0.0):
|
||||
self.load_w = float(load_w)
|
||||
self.b_soc = float(b_soc)
|
||||
# SAFETY-43. What fraction of its command B actually delivers. 0.0 is a
|
||||
# WEDGED unit and is every arm in this file but part 4b(a) - see there
|
||||
# for why that one arm needs a unit that is short WITHOUT being 100 %
|
||||
# short.
|
||||
self.b_frac = float(b_frac)
|
||||
self.writes = []
|
||||
self.calls = []
|
||||
self.ctrl = None
|
||||
@@ -343,8 +420,11 @@ class _DeliveryHass:
|
||||
# A obeys. B produces NOTHING, whatever it is told - and whether that is
|
||||
# a wedged power stage or an empty pack is not a question its power
|
||||
# register can answer.
|
||||
b = self.ctrl.inverters[1]
|
||||
b_cmd = float((b.last_write if b.last_write is not None else b.target)
|
||||
or 0.0)
|
||||
return [float((a.last_write if a.last_write is not None else a.target)
|
||||
or 0.0), 0.0]
|
||||
or 0.0), self.b_frac * b_cmd]
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
m = self.measured()
|
||||
@@ -630,15 +710,27 @@ print(" schema. Same load, same wedged unit, two configured thresholds.")
|
||||
# load is what puts B's shortfall BETWEEN the two: above the shipped threshold,
|
||||
# below the raised one. The gap itself is printed by the run that produces it
|
||||
# and is never written down here; what is asserted is the ORDERING.
|
||||
#
|
||||
# ⚠️ AND B IS DERATED HERE RATHER THAN WEDGED, WHICH SAFETY-43 MADE NECESSARY.
|
||||
# `delinquent()` now names a unit whose gap exceeds `min_w` OR a fraction of its
|
||||
# own share, so a unit delivering NONE of its ask is named at every `min_w`
|
||||
# there is - which is the whole point of that card, and which would make the
|
||||
# raised arm below fire for a reason that has nothing to do with the watts. A
|
||||
# unit delivering HALF of what it is asked for is short in absolute watts and
|
||||
# comfortably inside the fraction, so it is the fixture that still isolates
|
||||
# `saturation_w`. `_SAT_B_FRAC` is asserted below to be inside the relative
|
||||
# arm's silence, so a future change to the fraction turns this red rather than
|
||||
# quietly turning the pin vacuous.
|
||||
_SAT_RAISED = 1800
|
||||
_SAT_LOAD_W = 1600.0
|
||||
_SAT_B_FRAC = 0.5
|
||||
_SAT_SHIPPED = int(read_option("saturation_w"))
|
||||
_sat_lo, _sat_hi = (int(x) for x in re.match(
|
||||
r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups())
|
||||
|
||||
|
||||
async def _at_saturation_w(sat):
|
||||
hass = _DeliveryHass(load_w=_SAT_LOAD_W)
|
||||
async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC):
|
||||
hass = _DeliveryHass(load_w=_SAT_LOAD_W, b_frac=b_frac)
|
||||
c = _ctrl(hass, saturation_w=sat)
|
||||
return c, await _drive(c, watch=_sample)
|
||||
|
||||
@@ -674,6 +766,17 @@ check("the raised arm REACHED the comparison: B was asked for a discharge "
|
||||
and not _b_up.sign_wd.tripped
|
||||
and account(live=True, parked=False, soc=_b_up.soc,
|
||||
bms_floor=_b_up.bms_soc_floor) is None)
|
||||
check("...and SAFETY-43's RELATIVE arm is silent on both arms of this fixture, "
|
||||
"which is what leaves the watts as the only moving part. B delivers half "
|
||||
"of what it is asked for, so its gap is half its share and that is "
|
||||
"inside `DELIVERY_SHARE_FRACTION`. ⚠️ A FIXTURE WHERE THE RELATIVE ARM "
|
||||
"FIRED WOULD NAME B AT EVERY `saturation_w` AND THIS PIN WOULD BE "
|
||||
"VACUOUS, so it is asserted rather than assumed",
|
||||
_gap_up / float(_b_up.standing_w()) <= DELIVERY_SHARE_FRACTION
|
||||
and _gap_ship / float(_b_ship.standing_w()) <= DELIVERY_SHARE_FRACTION
|
||||
and delinquent([U("a", 1000, 1000),
|
||||
U("b", float(_b_up.standing_w()), _b_up.batt)],
|
||||
_SAT_RAISED) == {})
|
||||
check("...and its shortfall lands STRICTLY BETWEEN the two configured "
|
||||
"thresholds, which is the one place the two answers can differ. Part 4's "
|
||||
"fixtures are all above both, which is exactly why they cannot see this",
|
||||
|
||||
@@ -21,6 +21,7 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -34,7 +35,8 @@ from app.inverter import UNIT_A
|
||||
from app.main import Controller
|
||||
from app.store import (
|
||||
DEFAULTS, MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION, MAINT_UNIT_KEYS,
|
||||
migrate_maint_keys, unit_key,
|
||||
SCHEMA_KEY, SCHEMA_VERSION, migrate_maint_keys, migrate_unit_keys,
|
||||
unit_key,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -1411,45 +1413,39 @@ check("...while the SAME site with the house importing more than the drain "
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("13. RECORDED, NOT FIXED: SAFETY-02's migration has the same "
|
||||
"wrong-namespace hole, and it is a LATCH")
|
||||
print("13. FIXED (SAFETY-44): SAFETY-02's migration takes the roster too, and "
|
||||
"the guard is no longer defeated by the key the defect wrote")
|
||||
|
||||
# ⚠️ FOUND WHILE FIXING THIS CARD'S OWN INSTANCE, AND WRITTEN DOWN AS A RUNNING
|
||||
# CHECK RATHER THAN A COMMENT SO IT CANNOT BE FORGOTTEN OR QUIETLY "FIXED" BY AN
|
||||
# UNRELATED EDIT. It is the SAME defect one module over, and it costs more.
|
||||
# ⚠️ THIS SECTION USED TO BE THE SPECIFICATION OF A DEFECT. It was written while
|
||||
# fixing MAINT-05's own instance, as a RUNNING check rather than a comment, with
|
||||
# the instruction that "the card that fixes it should expect them to go red and
|
||||
# should replace them". SAFETY-44 is that card and this is that replacement.
|
||||
#
|
||||
# WHAT IT IS. `Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)` -
|
||||
# a hardcoded constant, where the maintenance migration now passes the roster's
|
||||
# own first name. It has to run BEFORE `build_roster()`, because each
|
||||
# `Inverter` restores its sign latch in its constructor, so there is no roster
|
||||
# to name yet and `_verify_moves()` is passed `roster=None`. On a DECLARED
|
||||
# roster the pre-DEV-02 flat `sign_tripped: true` therefore lands under
|
||||
# `unit/inverter-a/sign_tripped`, which no unit on this site reads.
|
||||
# WHAT IT WAS. `Controller.__init__` called `migrate_unit_keys(store, UNIT_A)` -
|
||||
# a hardcoded constant, where the maintenance migration already passed the
|
||||
# roster's own first name. On a DECLARED roster the pre-DEV-02 flat
|
||||
# `sign_tripped: true` landed under `unit/inverter-a/sign_tripped`, which no
|
||||
# unit on the site reads, and `_verify_moves()` read it back under the name it
|
||||
# had been handed and was satisfied.
|
||||
#
|
||||
# WHY THE EXISTING GUARD DOES NOT CATCH IT, WHICH IS THE PART WORTH PINNING.
|
||||
# `_grade_store_migration()` re-arms the latch when the flat key says tripped
|
||||
# and NOTHING NAMESPACED IS LATCHED. The misdirected write IS namespaced and IS
|
||||
# latched, so `namespaced_latched` is True, the guard returns, and no inverter
|
||||
# on the roster comes up tripped. The guard is defeated by the very key the
|
||||
# defect wrote.
|
||||
# AND THE GUARD DID NOT CATCH IT, WHICH WAS THE PART WORTH PINNING.
|
||||
# `_grade_store_migration()` re-arms when the flat key says tripped and NOTHING
|
||||
# NAMESPACED IS LATCHED - and the misdirected write IS namespaced and IS
|
||||
# latched. The guard was defeated by the very key the defect wrote.
|
||||
#
|
||||
# WHY IT IS NOT FIXED HERE. The remedy is to name the migration's target from
|
||||
# the config before any `Inverter` exists, which means factoring
|
||||
# `build_roster()`'s naming rule out of `build_roster()` so there is still only
|
||||
# ONE spelling of it - inverter.py and SAFETY-02's latch semantics, with its own
|
||||
# mutation battery over test_inverter.py and test_watchdog.py. MAINT-05 moves
|
||||
# the maintenance history and nothing about the sign latch.
|
||||
#
|
||||
# ⚠️ AND IT IS A DISARMED SAFETY GUARD, NOT LOST HISTORY. Read the checks below
|
||||
# as the SPECIFICATION OF A DEFECT: every one of them is asserting the WRONG
|
||||
# behaviour, on purpose, and the card that fixes it should expect them to go
|
||||
# red and should replace them.
|
||||
# ⚠️ THE REASON IT WAS LEFT - "there is no roster to name yet, because each
|
||||
# `Inverter` restores its latch in its constructor" - WAS THE HALF-TRUTH THAT
|
||||
# KEPT IT ALIVE. The keys must be written before the `Inverter`s read them; the
|
||||
# NAMES do not come from the `Inverter`s. `roster_names(opts)` reads them off
|
||||
# the options before anything is constructed, which is what it already existed
|
||||
# to do for `MqttPublisher`. The factoring this section said the fix would need
|
||||
# had been done a card earlier.
|
||||
#
|
||||
# ⚠️ NO `schema_version` IN THIS STORE, AND THAT IS THE PRE-DEV-02 SHAPE
|
||||
# ITSELF: an install that has never run a version with namespaced keys, which is
|
||||
# what makes `migrate_unit_keys()` run at all.
|
||||
_lat_store = MemStore({"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02"})
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
|
||||
_lat, _ = controller(NAMED_OPTS, store=_lat_store)
|
||||
print(f" pre-DEV-02 flat latch + declared roster {_C2_NAMES} -> "
|
||||
f"{_lat.store_migration.explain()}")
|
||||
@@ -1457,29 +1453,179 @@ print(f" latch keys in the store: "
|
||||
f"{sorted(k for k in _lat_store.data if k.endswith('sign_tripped'))}")
|
||||
print(f" roster came up tripped: "
|
||||
f"{[(i.name, i.sign_wd.tripped) for i in _lat.inverters]}")
|
||||
check("the flat pre-DEV-02 latch is migrated into `unit/inverter-a/` - a name "
|
||||
"that is NOT on this roster, so no unit reads it. This is the same "
|
||||
"wrong-namespace write `migrate_maint_keys()` now refuses, in the "
|
||||
"migration that runs before there is a roster to grade against",
|
||||
_lat_store.data.get(unit_key(UNIT_A, "sign_tripped")) is True
|
||||
and unit_key(_C2_NAMES[0], "sign_tripped") not in _lat_store.data)
|
||||
check("⚠️ AND NO INVERTER ON THE ROSTER COMES UP LATCHED, on a store whose file "
|
||||
"still reads `sign_tripped: true`. That is DEV-02's own sentence for the "
|
||||
"worst case - a disarmed safety guard on an install that believes it is "
|
||||
"protected",
|
||||
not any(i.sign_wd.tripped for i in _lat.inverters)
|
||||
check("⭐ the flat pre-DEV-02 latch is migrated into the ROSTER'S OWN first "
|
||||
"namespace and NOT into `unit/inverter-a/` - the same wrong-namespace "
|
||||
"write `migrate_maint_keys()` refuses, now refused in the migration that "
|
||||
"runs before the `Inverter` objects exist",
|
||||
_lat_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True
|
||||
and unit_key(UNIT_A, "sign_tripped") not in _lat_store.data)
|
||||
check("⭐ AND AN INVERTER ON THE ROSTER COMES UP LATCHED, which is the whole "
|
||||
"card: a store whose file reads `sign_tripped: true` no longer produces "
|
||||
"a site that drives",
|
||||
_lat.inverters[0].sign_wd.tripped
|
||||
and _lat_store.data["sign_tripped"] is True)
|
||||
check("...and `_grade_store_migration()` is DEFEATED BY THE KEY THE DEFECT "
|
||||
"WROTE, which is why this needs a card rather than a tweak: its "
|
||||
"'is anything namespaced latched' test is satisfied by the misdirected "
|
||||
"value itself, so it returns instead of re-arming",
|
||||
any(k.startswith("unit/") and k.endswith("/sign_tripped") and v
|
||||
for k, v in _lat_store.data.items())
|
||||
and not any(i.sign_wd.tripped for i in _lat.inverters))
|
||||
check("...and the latch that came back carries the REASON, not just the "
|
||||
"boolean - an operator meeting a FAILSAFE banner with no sentence under "
|
||||
"it has been told the site stopped and not why",
|
||||
"SIGN_DISAGREEMENT" in _lat.inverters[0].sign_wd.reason)
|
||||
check("⚠️ ...and the OTHER units on the roster are NOT latched by it. The flat "
|
||||
"key is one site-level boolean and re-arming every unit off it would be "
|
||||
"DEV-02's defect in the opposite direction - a healthy pack parked "
|
||||
"because its sibling once disagreed",
|
||||
not any(i.sign_wd.tripped for i in _lat.inverters[1:]))
|
||||
|
||||
# ⚠️ AND THE OTHER PATH, WHICH THE MIGRATION FIX ALONE DOES NOT REACH: a store
|
||||
# ALREADY STAMPED by the shipped version. `migrate_unit_keys()` returns at its
|
||||
# `from_version >= SCHEMA_VERSION` check and never moves anything, so the
|
||||
# stranded `unit/inverter-a/sign_tripped` just sits there. This is the STAGED
|
||||
# shape - "upgrade now, declare the roster later" - and it is the one an
|
||||
# operator actually produces. Only the GUARD repairs it.
|
||||
_staged_store = MemStore({
|
||||
"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
unit_key(UNIT_A, "sign_tripped"): True,
|
||||
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
SCHEMA_KEY: SCHEMA_VERSION})
|
||||
_staged, _ = controller(NAMED_OPTS, store=_staged_store)
|
||||
print(f" already-stamped store + declared roster -> ran="
|
||||
f"{_staged.store_migration.ran}, came up tripped: "
|
||||
f"{[(i.name, i.sign_wd.tripped) for i in _staged.inverters]}")
|
||||
check("⚠️ PRECONDITION: the migration really does NOT run on this store - the "
|
||||
"marker is already there - so anything below is the guard's doing and "
|
||||
"not the migration's",
|
||||
not _staged.store_migration.ran)
|
||||
check("⭐ and the roster's first unit STILL comes up latched: the guard no "
|
||||
"longer counts a latch stranded under `UNIT_A`, which is not on this "
|
||||
"roster, as protection an operator has",
|
||||
_staged.inverters[0].sign_wd.tripped)
|
||||
check("...and the re-armed latch is written where a machine reads it, so the "
|
||||
"NEXT start finds it without needing the guard again",
|
||||
_staged_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True)
|
||||
|
||||
# ⚠️ THE NEGATIVE CONTROL FOR THAT NARROWING, because "off the roster" on its
|
||||
# own was too blunt and test_inverter.py's DEV-02 restart case caught it: a
|
||||
# latch stranded under a name that is NOT `UNIT_A` is a real unit out of
|
||||
# today's config, and it MUST still count as protection.
|
||||
_other_store = MemStore({
|
||||
"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: B disagreed, mirrored flat",
|
||||
unit_key("inverter-b", "sign_tripped"): True,
|
||||
unit_key("inverter-b", "sign_reason"): "SIGN_DISAGREEMENT: B disagreed",
|
||||
SCHEMA_KEY: SCHEMA_VERSION})
|
||||
_other, _ = controller(NAMED_OPTS, store=_other_store)
|
||||
print(f" stranded latch under `inverter-b` -> came up tripped: "
|
||||
f"{[(i.name, i.sign_wd.tripped) for i in _other.inverters]}")
|
||||
check("⚠️ a latch stranded under a name that is not `UNIT_A` is NOT re-armed "
|
||||
"onto the roster: it belongs to a unit the operator has taken out of "
|
||||
"the config, the flat key is its own mirror, and re-arming a healthy "
|
||||
"sibling off it is the defect DEV-02 removed",
|
||||
not any(i.sign_wd.tripped for i in _other.inverters))
|
||||
|
||||
# ⭐ AND THE ARRANGEMENT NEITHER FIXTURE ABOVE HOLDS: **BOTH** KEYS AT ONCE.
|
||||
# ⚠️ THIS IS INSTANCE #17 OF HABIT 4 AND IT WAS FOUND IN THIS CARD'S OWN FIX.
|
||||
# The first version of the guard classified each key and then wrote ONE shared
|
||||
# `namespaced_latched` flag for both "on the roster" and "off-roster but not
|
||||
# UNIT_A". `_staged_store` above holds only an `inverter-a` latch and
|
||||
# `_other_store` below holds only an `inverter-b` one, so the flag was never
|
||||
# written twice with opposite intents in a single store - and a per-key check
|
||||
# whose keys have never disagreed WITHIN ONE STORE is a check over whichever
|
||||
# key happened to come last. Measured on that build, one added key:
|
||||
#
|
||||
# sign_tripped + unit/inverter-a/sign_tripped -> garage latched, repaired
|
||||
# ...AND unit/old-shed/sign_tripped -> ⚠️ NOTHING latched
|
||||
#
|
||||
# A site DRIVING on a state file that reads `sign_tripped: true` - the sentence
|
||||
# this card exists to make false - reached by an operator taking one unit out of
|
||||
# `inverters:` after it had tripped. "A roster is configuration and it changes"
|
||||
# is this module's own phrase, and the second inverter goes in this month.
|
||||
_both_store = MemStore({
|
||||
"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
unit_key(UNIT_A, "sign_tripped"): True,
|
||||
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
unit_key("old-shed", "sign_tripped"): True,
|
||||
unit_key("old-shed", "sign_reason"): "SIGN_DISAGREEMENT: old-shed, since removed",
|
||||
SCHEMA_KEY: SCHEMA_VERSION})
|
||||
_both, _ = controller(NAMED_OPTS, store=_both_store)
|
||||
print(f" stranded `inverter-a` AND an unrelated off-roster `old-shed` -> "
|
||||
f"{[(i.name, i.sign_wd.tripped) for i in _both.inverters]}")
|
||||
check("⭐ A STRANDED `inverter-a` LATCH IS STILL REPAIRED WHEN AN UNRELATED "
|
||||
"OFF-ROSTER LATCH SITS BESIDE IT. One shared flag let `old-shed` veto the "
|
||||
"repair, and the site came up driving on a file that says it is latched",
|
||||
_both.inverters[0].sign_wd.tripped)
|
||||
check("...and the repair still lands on the roster's first unit, not on the "
|
||||
"removed one",
|
||||
_both_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True)
|
||||
check("⚠️ ...and the reason carried across is the FLAT one - the misdirected "
|
||||
"latch's own - and not `old-shed`'s, which belongs to a different "
|
||||
"physical inverter",
|
||||
"old-shed" not in _both.inverters[0].sign_wd.reason)
|
||||
|
||||
# ⚠️ AND THE OPERATOR MESSAGE IS ASSERTED, because review deleted it whole
|
||||
# (`if stranded:` -> `if False:`) and 28/28 stayed green. `stranded` has no
|
||||
# other consumer, so the list existed only to print a line nothing checked -
|
||||
# and the line is the entire operator-facing half of this repair: it names the
|
||||
# key that was stranded and the roster it was measured against. A repair an
|
||||
# operator cannot see is a repair they cannot confirm.
|
||||
_log_store = MemStore({
|
||||
"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
unit_key(UNIT_A, "sign_tripped"): True,
|
||||
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
SCHEMA_KEY: SCHEMA_VERSION})
|
||||
_records = []
|
||||
_h = logging.Handler()
|
||||
_h.emit = lambda r: _records.append(r.getMessage())
|
||||
logging.getLogger("goodwe").addHandler(_h)
|
||||
try:
|
||||
_logged, _ = controller(NAMED_OPTS, store=_log_store)
|
||||
finally:
|
||||
logging.getLogger("goodwe").removeHandler(_h)
|
||||
_s44_lines = [m for m in _records if "SAFETY-44" in m]
|
||||
print(f" SAFETY-44 operator lines logged: {len(_s44_lines)}")
|
||||
check("⭐ the repair SAYS SO, at ERROR, naming SAFETY-44 - deleting this line "
|
||||
"left the whole suite green, and it is how an operator learns their "
|
||||
"latch was re-armed rather than never lost",
|
||||
len(_s44_lines) == 1)
|
||||
check("...and it names the STRANDED KEY, so the operator can find it in the "
|
||||
"state file rather than being told something happened",
|
||||
any(unit_key(UNIT_A, "sign_tripped") in m for m in _s44_lines))
|
||||
check("...and the ROSTER it was measured against, which is the half that says "
|
||||
"WHY no machine read it",
|
||||
any("garage" in m and "shed" in m for m in _s44_lines))
|
||||
|
||||
# ⭐ AND `misdirected` IS MEASURED AT THE FUNCTION'S OWN LEVEL, BECAUSE IT IS
|
||||
# UNREACHABLE FROM THE CALL SITE. `Controller.__init__` passes
|
||||
# `roster_names(opts)[0]`, which `build_roster()` also names its first entry
|
||||
# from, so `unit` is a member of `roster` by construction and the refusal can
|
||||
# never fire there. Review measured that: dropping the `roster` argument from
|
||||
# `_verify_moves()` inside `migrate_unit_keys()` survives 28/28. The argument is
|
||||
# kept for the reason `_verify_moves()` gives for its own `roster=None` branch -
|
||||
# it is what a NEW caller gets by default - and a guard nothing exercises is a
|
||||
# guard nobody knows works, so it is exercised HERE rather than claimed.
|
||||
_mis_store = MemStore({"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
|
||||
_mis = migrate_unit_keys(_mis_store, UNIT_A, ["garage", "shed"])
|
||||
print(f" migrate_unit_keys(store, 'inverter-a', ['garage','shed']) -> "
|
||||
f"ok={_mis.ok} misdirected={sorted(_mis.misdirected)}")
|
||||
check("⭐ a WRONG `unit` - one that is not on the roster it was handed - comes "
|
||||
"back `misdirected`, which is the refusal `migrate_maint_keys()` already "
|
||||
"had and this one did not",
|
||||
bool(_mis.misdirected) and not _mis.ok)
|
||||
check("...and the schema marker is NOT stamped, so the migration runs again on "
|
||||
"the next start rather than being recorded as finished - the one thing "
|
||||
"store.py's header says must never happen",
|
||||
_mis_store.data.get(SCHEMA_KEY) != SCHEMA_VERSION)
|
||||
check("⚠️ ...while the SAME call with a unit that IS on the roster stamps the "
|
||||
"marker - so the refusal is caused by the name and not by the fixture",
|
||||
migrate_unit_keys(
|
||||
MemStore({"sign_tripped": True, "sign_reason": "x"}),
|
||||
"garage", ["garage", "shed"]).ok)
|
||||
|
||||
# The counterfactual, and it is the accepted baseline habit 7 asks for: the
|
||||
# same store, the same migration, one thing different - the roster shape.
|
||||
_flat_lat_store = MemStore({"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02"})
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
|
||||
_flat_lat, _ = controller(FLAT_OPTS, store=_flat_lat_store)
|
||||
print(f" the same store on the FLAT config -> roster "
|
||||
f"{[(i.name, i.sign_wd.tripped) for i in _flat_lat.inverters]}")
|
||||
|
||||
@@ -41,7 +41,9 @@ from app.arbiter import (
|
||||
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve,
|
||||
)
|
||||
from app.control import Tuning, compute
|
||||
from app.delivery import UnitDelivery, account, delinquent
|
||||
from app.delivery import (
|
||||
DELIVERY_SHARE_FRACTION, UnitDelivery, account, delinquent,
|
||||
)
|
||||
from app.peakguard import discharge_permitted
|
||||
|
||||
fails = []
|
||||
@@ -605,67 +607,87 @@ check("...and SAFETY-42 is where the case this card CANNOT reach is handled. A "
|
||||
"site ASKING a pack whose own SoC says it cannot deliver; SAFETY-42 "
|
||||
"alarms when a pack that said it could does not. ⚠️ THE WEIGHT, HOWEVER, "
|
||||
"DOES MOVE - this check used to say it did not - and part 8b grades what "
|
||||
"that costs the alarm",
|
||||
"that cost the alarm, and what SAFETY-43 did about it",
|
||||
not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("8b. ⚠️ AND WHAT THE WEIGHTS DO TO SAFETY-42's DETECTION WINDOW, WHICH IS "
|
||||
"A REGRESSION AND IS PINNED HERE AS ONE")
|
||||
print("8b. ⚠️ WHAT THE WEIGHTS DID TO SAFETY-42's DETECTION WINDOW, AND WHAT "
|
||||
"SAFETY-43 DID ABOUT IT - GRADED UNDER BOTH RULES, ON ONE FIXTURE")
|
||||
|
||||
# ⚠️ READ THIS BEFORE READING THE CHECKS: THEY ASSERT THE UNFLATTERING ANSWER ON
|
||||
# PURPOSE. An earlier draft of §5 of the decision said a wedged unit's weight
|
||||
# does not move, so OPT-09 "cannot see it at all". The first clause is about the
|
||||
# ENVELOPE and is true; the second is FALSE. A wedged unit's weight moves
|
||||
# whenever its energy headroom differs from its sibling's - it is a pack fact,
|
||||
# not a delivery fact - and `delivery.delinquent()` names a unit only while its
|
||||
# `short_by()` gap exceeds `min_w` (`Tuning.saturation_w`, 500 W) IN ABSOLUTE
|
||||
# WATTS. Shrinking a unit's share shrinks its gap. Shrink it below 500 W and the
|
||||
# alarm stops firing on a unit that is still delivering nothing.
|
||||
# ⚠️ THIS PART USED TO PIN THE UNFLATTERING ANSWER ON PURPOSE, AND SAFETY-43 IS
|
||||
# THE CARD THAT INVERTED IT. What it said - and it was true when it was written
|
||||
# - was: a wedged unit's weight MOVES whenever its energy headroom differs from
|
||||
# its sibling's (it is a pack fact, not a delivery fact), `delivery.delinquent()`
|
||||
# named a unit only while its `short_by()` gap exceeded `min_w`
|
||||
# (`Tuning.saturation_w`, 500 W) IN ABSOLUTE WATTS, and shrinking a unit's share
|
||||
# shrinks its gap. Shrink it below 500 W and the alarm stopped firing on a unit
|
||||
# that was still delivering nothing. The old text ended "if it ever goes red
|
||||
# because the alarm fired, read the card that made it fire before 'fixing' this
|
||||
# line". SAFETY-43 is that card, this is that red, and the checks below are the
|
||||
# same three measurements with their answers the other way round.
|
||||
#
|
||||
# So the seam is not "OPT-09 cannot see it". It is: OPT-09 stops the ask AND
|
||||
# narrows SAFETY-42's detection window on the same unit. At equal weights the
|
||||
# window is `site target < 2 * min_w` = 1000 W; at OPT-09's weights it is
|
||||
# `site target < min_w * (w_A + w_B) / w_B`, which on this fixture is 6500 W.
|
||||
# ⚠️ AND THE EVIDENCE IS THE DIFF BETWEEN TWO RULES ON ONE RUN, NOT A NUMBER
|
||||
# THAT CHANGED IN A COMMIT. `delinquent()` takes `share_frac`, and
|
||||
# `share_frac=None` is SAFETY-42's rule EXACTLY - the absolute arm alone. So
|
||||
# `wedged_loop` grades every cycle twice, once under each rule, and the red and
|
||||
# the green sit side by side in one output:
|
||||
#
|
||||
# ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
# load 1500 W, OPT-09 weights, absolute arm only named on 0/200 cycles
|
||||
# load 1500 W, OPT-09 weights, the shipped rule named on 200/200 cycles
|
||||
#
|
||||
# ⚠️ THIS IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE. Making
|
||||
# `delinquent()` relative to the ask rather than absolute is SAFETY-42's code and
|
||||
# a design change; it is filed as card SAFETY-43. What this part does is GRADE THE
|
||||
# CURRENT BEHAVIOUR so that the day it changes, a named check moves.
|
||||
# A future reader who wants to know what this card did runs this file.
|
||||
#
|
||||
# ⚠️ WHAT SAFETY-43 DID NOT DO IS LOWER `min_w`, and the check on the window
|
||||
# arithmetic below is what holds that open: the ABSOLUTE arm's suppression
|
||||
# window is still 500 W wide and still widens to 6500 W under these weights.
|
||||
# Nothing that was quiet became noisy; a SECOND way to be named was added, and
|
||||
# it is a fraction of the unit's OWN ask, so it fires on a unit delivering none
|
||||
# of 125 W exactly as it fires on one delivering none of 1500 W.
|
||||
#
|
||||
# ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION,
|
||||
# checked below before anything is concluded from it: B is WEDGED - it accepts
|
||||
# its share and produces nothing - at a HEALTHY 15 % against its own 10 % floor,
|
||||
# so `envelope()` does not close and `account()` explains nothing. This is
|
||||
# exactly the case §5 says the envelope cannot reach, which is why it is the
|
||||
# case where the weights' side effect matters.
|
||||
# case where the weights' side effect mattered.
|
||||
#
|
||||
# ⚠️ TWO COPIES OF A. Inverter B does not exist; the plant below is modelled in
|
||||
# this file the way part 8's is, and B's ratings are datasheet.
|
||||
|
||||
WEDGED_A_SOC, WEDGED_B_SOC = 90.0, 15.0 # both healthy, both above their floor
|
||||
MIN_W = Tuning().saturation_w # what `delinquent()` is handed
|
||||
ABSOLUTE_ONLY = None # `share_frac=None` - SAFETY-42's rule
|
||||
|
||||
|
||||
def wedged_loop(load_w, weighted, cycles=200):
|
||||
def wedged_loop(load_w, weighted, cycles=200, share_frac=DELIVERY_SHARE_FRACTION,
|
||||
b_delivers=0.0, b_soc=WEDGED_B_SOC, no_account=False):
|
||||
"""The closed loop with unit B WEDGED at a healthy SoC. Real everything.
|
||||
|
||||
Real `compute()`, real `plan()`, real `packshare.weights()`, real
|
||||
`delivery.account()` and real `delivery.delinquent()`. The only model is the
|
||||
plant: A obeys within one cycle inside its rating, B accepts whatever it is
|
||||
told and delivers 0 W - which is the wedged box, and is what makes this a
|
||||
plant: A obeys within one cycle inside its rating, B delivers `b_delivers`
|
||||
of whatever it is told - 0.0 is the wedged box, and is what makes this a
|
||||
SAFETY-42 case rather than an OPT-09 one.
|
||||
|
||||
`weighted=False` is equal weights - what shipped. Envelopes are left OPEN in
|
||||
both arms deliberately: neither pack's SoC closes one here, so filing them
|
||||
would change nothing and would blur which half of the card is being graded.
|
||||
|
||||
`share_frac` is SAFETY-43's relative arm. `ABSOLUTE_ONLY` grades the same
|
||||
cycles under SAFETY-42's rule, which is how the red and the green below come
|
||||
out of ONE run over ONE tree instead of out of two commits.
|
||||
|
||||
`no_account=True` DELETES the account gate - every unit is handed
|
||||
`accounted=None` - and it exists to answer "would this arm still be silent
|
||||
if `account()` did nothing at all?". A control that answers the same way
|
||||
with the gate removed is not grading the gate, and the cut-out arm below is
|
||||
exactly that case. See it there; it is the point of that block.
|
||||
|
||||
Returns `(cycles on which delinquent() named somebody, last cycle)`.
|
||||
"""
|
||||
packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, WEDGED_B_SOC, B_KWH)]
|
||||
packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, b_soc, B_KWH)]
|
||||
tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=A_W + B_W)
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
power = [0.0, 0.0]
|
||||
@@ -678,13 +700,15 @@ def wedged_loop(load_w, weighted, cycles=200):
|
||||
r = plan(d.target_w, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=w)
|
||||
assert r.ok, r.refused
|
||||
# THE MODELLED PLANT: A within one cycle inside its rating; B WEDGED.
|
||||
power = [max(-A_W, min(A_W, float(r.shares[A]))), 0.0]
|
||||
power = [max(-A_W, min(A_W, float(r.shares[A]))),
|
||||
b_delivers * max(-B_W, min(B_W, float(r.shares[Bee])))]
|
||||
grid = load_w - sum(power)
|
||||
who = delinquent(
|
||||
[UnitDelivery(p.name, r.shares[p.name], m,
|
||||
None if no_account else
|
||||
account(live=True, parked=False, soc=p.soc,
|
||||
bms_floor=p.bms_floor))
|
||||
for p, m in zip(packs, power)], MIN_W)
|
||||
for p, m in zip(packs, power)], MIN_W, share_frac)
|
||||
named += bool(who)
|
||||
last = (d.target_w, dict(r.shares), grid, who)
|
||||
return named, last
|
||||
@@ -698,75 +722,271 @@ check("PRECONDITION: the wedged unit is a SAFETY-42 TRUE POSITIVE and NOT an "
|
||||
not packshare.envelope(_WEDGED_PACK).binding
|
||||
and account(live=True, parked=False, soc=WEDGED_B_SOC,
|
||||
bms_floor=FLOOR) is None)
|
||||
check("PRECONDITION: the shipped fraction is a fraction, and it is STRICTLY "
|
||||
"below 1.0 - at 1.0 the relative arm could only fire on a unit IMPORTING "
|
||||
"while asked to discharge, which is SAFETY-30's fault and not this one, "
|
||||
"and this card's own case (a unit delivering NONE of its share, gap over "
|
||||
"share exactly 1.0) would be missed",
|
||||
0.0 < DELIVERY_SHARE_FRACTION < 1.0)
|
||||
|
||||
_wedge = {}
|
||||
for _load in (1500.0, 6000.0):
|
||||
for _weighted in (False, True):
|
||||
_n, _t = wedged_loop(_load, _weighted)
|
||||
_wedge[(_load, _weighted)] = (_n, _t)
|
||||
print(f" load {_load:.0f} W, B wedged at 15 %, "
|
||||
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
|
||||
f"named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W, shares "
|
||||
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items()))
|
||||
+ f", grid {_t[2]:+.0f} W, named="
|
||||
+ (str(sorted(_t[3])) if _t[3] else "(nobody)"))
|
||||
for _frac in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION):
|
||||
_n, _t = wedged_loop(_load, _weighted, share_frac=_frac)
|
||||
_wedge[(_load, _weighted, _frac)] = (_n, _t)
|
||||
print(f" load {_load:.0f} W, B wedged at 15 %, "
|
||||
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
|
||||
+ ("absolute arm only" if _frac is ABSOLUTE_ONLY
|
||||
else f"frac {_frac:.2f} ")
|
||||
+ f", named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W,"
|
||||
+ " shares "
|
||||
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items()))
|
||||
+ f", grid {_t[2]:+.0f} W, named="
|
||||
+ (str(sorted(_t[3])) if _t[3] else "(nobody)"))
|
||||
|
||||
check("AT EQUAL WEIGHTS - WHAT SHIPPED - the wedged unit is named on every "
|
||||
"cycle but the first, at a load its sibling can carry on its own. The "
|
||||
"loop winds the site target to twice the load, the 50/50 split stands "
|
||||
"+1500 W on a box producing nothing, and 1500 W > 500 W is what "
|
||||
"`delinquent()` reads",
|
||||
_wedge[(1500.0, False)][0] == 199
|
||||
and sorted(_wedge[(1500.0, False)][1][3]) == [Bee]
|
||||
and _wedge[(1500.0, False)][1][1] == {A: 1500, Bee: 1500})
|
||||
check("⚠️ THE CARD, RED: UNDER SAFETY-42's RULE AND OPT-09's WEIGHTS THE WEDGED "
|
||||
"UNIT IS NAMED ON NONE OF THE 200. Same wedged box, same healthy SoC, "
|
||||
"same load, same detector: the split moves to 1505/125, the gap on the "
|
||||
"wedged unit falls to 125 W, and 125 W < 500 W so the absolute arm "
|
||||
"returns {}. This is the defect SAFETY-43 was filed for, and it is "
|
||||
"measured here rather than remembered",
|
||||
_wedge[(1500.0, True, ABSOLUTE_ONLY)][0] == 0
|
||||
and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][3] == {}
|
||||
and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][1] == {A: 1505, Bee: 125})
|
||||
|
||||
check("⚠️ AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200. Same wedged "
|
||||
"box, same healthy SoC, same load, same detector: the split moves to "
|
||||
"1505/125, the gap on the wedged unit falls to 125 W, and 125 W < 500 W "
|
||||
"so `delinquent()` returns {}. THIS CHECK PINS A REGRESSION - it asserts "
|
||||
"what the code DOES, not what anyone wants, and if it ever goes red "
|
||||
"because the alarm fired, read the card that made it fire before "
|
||||
"'fixing' this line",
|
||||
_wedge[(1500.0, True)][0] == 0
|
||||
and _wedge[(1500.0, True)][1][3] == {}
|
||||
and _wedge[(1500.0, True)][1][1] == {A: 1505, Bee: 125})
|
||||
check("⚠️ THE CARD, GREEN: THE SHIPPED RULE NAMES IT ON ALL 200, ON THE SAME "
|
||||
"RUN. The unit is standing at +125 W and delivering 0 W - 100 % of its "
|
||||
"ask is not arriving - and `gap > frac * share` does not care that 125 W "
|
||||
"is a small number. The ONLY input that moved between this check and the "
|
||||
"one above is `share_frac`",
|
||||
_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||
and sorted(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee]
|
||||
and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][1] \
|
||||
== {A: 1505, Bee: 125})
|
||||
|
||||
check("AT EQUAL WEIGHTS - WHAT SHIPPED BEFORE OPT-09 - the wedged unit was "
|
||||
"already named under the absolute rule on every cycle but the first, at "
|
||||
"a load its sibling can carry on its own. The loop winds the site target "
|
||||
"to twice the load, the 50/50 split stands +1500 W on a box producing "
|
||||
"nothing, and 1500 W > 500 W is what the absolute arm reads",
|
||||
_wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 199
|
||||
and sorted(_wedge[(1500.0, False, ABSOLUTE_ONLY)][1][3]) == [Bee]
|
||||
and _wedge[(1500.0, False, ABSOLUTE_ONLY)][1][1] == {A: 1500, Bee: 1500})
|
||||
|
||||
check("...and the shipped rule adds exactly the FIRST cycle to that arm and "
|
||||
"nothing else - 199 becomes 200. On cycle one the slew limit holds the "
|
||||
"split at 500/500, a 500 W gap is NOT over a 500 W threshold (the "
|
||||
"absolute bound is strict), and the relative arm names a box delivering "
|
||||
"none of its 500 W. That one cycle is the whole of what this card costs "
|
||||
"an arm that was already firing",
|
||||
_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||
and _wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0]
|
||||
- _wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 1)
|
||||
|
||||
check("...and this is the regime where NOTHING ELSE TELLS THE OPERATOR "
|
||||
"ANYTHING: the meter is satisfied (grid within 5 W of zero), `unmet_w` is "
|
||||
"0 because both units accepted their shares, no envelope closed and no "
|
||||
"action-log line fires. The site is quietly running one inverter and the "
|
||||
"one instrument built to say so has gone quiet with it",
|
||||
abs(_wedge[(1500.0, True)][1][2]) <= 5.0
|
||||
and abs(_wedge[(1500.0, False)][1][2]) <= 5.0)
|
||||
"action-log line fires. The site is quietly running one inverter, and "
|
||||
"before this card the one instrument built to say so had gone quiet with "
|
||||
"it",
|
||||
abs(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0
|
||||
and abs(_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0)
|
||||
|
||||
_w = packshare.weights([PE(A, WEDGED_A_SOC, A_KWH), _WEDGED_PACK], 1000.0)
|
||||
_window_equal = MIN_W * 2.0
|
||||
_window_opt09 = MIN_W * sum(_w.values()) / _w[Bee]
|
||||
print(f" suppression window on this fixture: equal weights, site target "
|
||||
f"< {_window_equal:.0f} W; OPT-09 weights, site target "
|
||||
f"< {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
|
||||
check("...and the arithmetic behind it, stated as a window rather than as one "
|
||||
"fixture: `delinquent()` reads an ABSOLUTE gap, so a unit weighted to a "
|
||||
"fraction f of the site target is invisible while `f * target < min_w`. "
|
||||
"Equal weights suppress below 1000 W; these weights suppress below "
|
||||
"6500 W of a 9600 W site. ⚠️ BOTH ARE PROPORTIONAL-REGIME BOUNDS AND "
|
||||
"NEITHER IS REACHABLE: the healthy pack's own rating binds first, so the "
|
||||
"alarm returns BELOW 6500 W (swept: 0/200 to a 3000 W load, 41/200 at "
|
||||
"4900 W, 195/200 at 5000 W) and the equal arm never suppresses at all. "
|
||||
"That is the regression, and it is a property "
|
||||
"of the threshold being absolute rather than of any number chosen here",
|
||||
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0)
|
||||
print(f" the ABSOLUTE arm's suppression window is UNCHANGED by SAFETY-43: "
|
||||
f"equal weights, site target < {_window_equal:.0f} W; OPT-09 weights, "
|
||||
f"site target < {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
|
||||
check("⚠️ AND `min_w` WAS NOT LOWERED, WHICH IS THE HALF OF THIS CARD THAT IS "
|
||||
"EASIEST TO GET WRONG. The absolute arm still suppresses below 1000 W at "
|
||||
"equal weights and below 6500 W at these ones - the same arithmetic this "
|
||||
"part has always printed - because SAFETY-43 ADDED a second way to be "
|
||||
"named rather than loosening the first. Lowering `saturation_w` would "
|
||||
"have reported normal saturation on every unit at once, which is the "
|
||||
"false-trip regime SAFETY-41 spent four attempts in",
|
||||
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0
|
||||
and MIN_W == Tuning().saturation_w == 500.0)
|
||||
|
||||
check("⚠️ AND THE ABSOLUTE ARM WAS NEVER SILENT EVERYWHERE - which is what a "
|
||||
"check that only ever ran the quiet load would be pinning. Push the load "
|
||||
"past the healthy pack's OWN RATING and `_fill()`'s last-resort pass "
|
||||
"hands the wedged unit 3000 W anyway, the gap goes back over 500 W, and "
|
||||
"both arms named it under SAFETY-42's rule too. The window was real and "
|
||||
"it had an upper edge; the hole was the quiet regime below that edge, "
|
||||
"and the shipped rule now names the unit on every cycle of all four runs",
|
||||
_wedge[(6000.0, True, ABSOLUTE_ONLY)][0] == 195
|
||||
and _wedge[(6000.0, False, ABSOLUTE_ONLY)][0] == 199
|
||||
and _wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||
and _wedge[(6000.0, False, DELIVERY_SHARE_FRACTION)][0] == 200
|
||||
and sorted(_wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee])
|
||||
|
||||
print()
|
||||
print(" THE POSITIVE CONTROL: what the relative arm must STILL NOT say")
|
||||
|
||||
# ⚠️ A CARD THAT ADDS A WAY TO RAISE AN ALARM HAS TO SHOW WHAT IT DID NOT ADD,
|
||||
# or it has traded a missed alarm for a false one - which in this system is not
|
||||
# a rhetorical risk. SAFETY-41 took four attempts on notification cadence and
|
||||
# the owner's words were "i'm getting really annoyed by the notifications in my
|
||||
# HA". So: the same fixture, the same weights, the same tiny 125 W share, with
|
||||
# the ONE fact that makes the shortfall legitimate changed.
|
||||
|
||||
# ⚠️ AND THE FIRST DRAFT OF THIS BLOCK WAS VACUOUS, WHICH IS WHY IT NOW PRINTS
|
||||
# ITS SHARES. It ran the cut-out arm, saw 0/200, and concluded "`account()`
|
||||
# explains it" and "identical trace to the wedged arm". BOTH WERE FALSE. At its
|
||||
# own cut-out the pack's usable energy ABOVE that cut-out is zero, so
|
||||
# `weights()` gives it 0.0, `_fill()`'s preferred pass gives it nothing, the
|
||||
# healthy sibling covers the load alone so the LAST-RESORT pass never runs, and
|
||||
# its share is 0 W on ALL 200 CYCLES. A unit with a 0 W share is not in `asked`,
|
||||
# so `len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()`
|
||||
# is never consulted - measured below by deleting the gate and getting the same
|
||||
# answer. The wedged arm's share is the 125 W printed on its own line above;
|
||||
# this one's is 0, printed and asserted below. Nothing was identical and nothing
|
||||
# reached the comparison.
|
||||
#
|
||||
# ⚠️ THE FACT THAT REPLACES IT IS BETTER, NOT WEAKER: at its own cut-out OPT-09
|
||||
# STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached.
|
||||
# That is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the
|
||||
# allocator declines to ask before the detector has to decline to alarm. The
|
||||
# account gate itself is graded where it is REAL, on a positive 125 W share, in
|
||||
# `test_delivery.py` part 1 ("THE ACCOUNT GATE IS UNTOUCHED BY IT").
|
||||
#
|
||||
# ⚠️ AND IT WAS INVISIBLE FOR ONE REASON: this was the only line in part 8b that
|
||||
# did not print its shares. `inverter-b+0` would have been on screen from the
|
||||
# first run. Every line in this part prints them now.
|
||||
_sat_named, _sat_last = wedged_loop(1500.0, True, b_soc=FLOOR)
|
||||
_sat_nogate, _sat_nogate_last = wedged_loop(1500.0, True, b_soc=FLOOR,
|
||||
no_account=True)
|
||||
print(f" load 1500 W, B AT ITS OWN {FLOOR:.0f} % CUT-OUT, OPT-09 weights, "
|
||||
f"frac {DELIVERY_SHARE_FRACTION:.2f}: named on {_sat_named:3d}/200 "
|
||||
f"cycles, shares "
|
||||
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_sat_last[1].items()))
|
||||
+ ", named="
|
||||
+ (str(sorted(_sat_last[3])) if _sat_last[3] else "(nobody)")
|
||||
+ f"; with account() DELETED: {_sat_nogate:3d}/200")
|
||||
# ⚠️ AND THE `no_account=True` SWITCH ITSELF WAS GRADED BY NOTHING. It is used
|
||||
# in exactly one place - the fixture above - where it PROVABLY cannot change the
|
||||
# answer, because a cut-out pack is never asked and the gate is never reached.
|
||||
# Review neutered the switch so it removed nothing: the file stayed GREEN and
|
||||
# printed "with account() DELETED: 0/200" while `account()` had not been
|
||||
# deleted. A line asserting a mechanism that did not happen - B1's shape, one
|
||||
# level down, in the fix for B1.
|
||||
#
|
||||
# The switch DOES bite, on a fixture already reachable from this helper: at the
|
||||
# cut-out with EQUAL weights, `plan()` hands B a 50/50 share, so the gate is the
|
||||
# only thing keeping it silent. Measured: gate on 0/200, gate deleted 200/200.
|
||||
# That proves the switch removes the gate, AND gives this part the end-to-end
|
||||
# account-gate control on a POSITIVE share that it otherwise has to outsource to
|
||||
# test_delivery.py part 1.
|
||||
_eq_gate, _eq_last = wedged_loop(1500.0, False, b_soc=FLOOR)
|
||||
_eq_nogate, _ = wedged_loop(1500.0, False, b_soc=FLOOR, no_account=True)
|
||||
print(f" ...and the SWITCH is real: same cut-out, EQUAL weights so B IS "
|
||||
f"asked ({_eq_last[1][Bee]:+d} W last cycle) - gate on {_eq_gate:3d}/200, "
|
||||
f"gate DELETED {_eq_nogate:3d}/200")
|
||||
check("⭐ THE `no_account` SWITCH ACTUALLY REMOVES THE GATE, on a fixture where "
|
||||
"the gate is the only thing keeping the pack silent. Without this the "
|
||||
"line above prints 'with account() DELETED' about a run in which nothing "
|
||||
"was deleted - which is exactly the defect this block was rewritten for",
|
||||
_eq_gate == 0 and _eq_nogate == 200 and _eq_last[1][Bee] > 0)
|
||||
|
||||
check("A PACK AT ITS OWN CUT-OUT IS NEVER ASKED, SO THE RELATIVE ARM IS NEVER "
|
||||
"REACHED. Its usable energy above its own cut-out is zero, `weights()` "
|
||||
"answers 0.0 for it, the healthy sibling covers the load so `_fill()`'s "
|
||||
"last-resort pass never runs, and its share is 0 W on every one of the "
|
||||
"200 cycles - so it is not in `asked` and `len(asked) < 2` returns {} "
|
||||
"before either arm is compared. ⚠️ THE SHARE IS ASSERTED, not inferred "
|
||||
"from the silence: a control that cannot say WHY it is quiet is a control "
|
||||
"that cannot fail",
|
||||
_sat_named == 0 and _sat_last[3] == {} and _sat_last[1][Bee] == 0)
|
||||
check("⚠️ AND THAT IS A SECOND, INDEPENDENT PROTECTION - NOT THE ACCOUNT GATE, "
|
||||
"WHICH THIS ARM DOES NOT TOUCH. Delete `account()` entirely, hand every "
|
||||
"unit `accounted=None`, and this arm is STILL silent on all 200 cycles, "
|
||||
"because the protection is UPSTREAM of it: the allocator declines to ask "
|
||||
"before the detector has to decline to alarm. ⚠️ SO DO NOT READ THIS "
|
||||
"BLOCK AS EVIDENCE ABOUT `account()` - it is evidence about the ask. The "
|
||||
"account gate on a POSITIVE share is graded in test_delivery.py part 1",
|
||||
_sat_nogate == 0 and _sat_nogate_last[3] == {})
|
||||
|
||||
_derate_named, _derate_last = wedged_loop(1500.0, True, b_delivers=0.5)
|
||||
print(f" load 1500 W, B delivering HALF its share, OPT-09 weights, frac "
|
||||
f"{DELIVERY_SHARE_FRACTION:.2f}: named on {_derate_named:3d}/200 cycles, "
|
||||
f"shares "
|
||||
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_derate_last[1].items()))
|
||||
+ ", named="
|
||||
+ (str(sorted(_derate_last[3])) if _derate_last[3] else "(nobody)"))
|
||||
check("...and ORDINARY TRACKING ON A SMALL SHARE IS NOT DELINQUENCY EITHER. "
|
||||
"The same unit at the same weights delivering HALF of what it is asked "
|
||||
"for is short by 50 % - well inside the shipped fraction - and its gap "
|
||||
"in watts is far under `min_w`, so NEITHER arm names it. A relative "
|
||||
"criterion that fired here would be the saturation-shaped alarm this "
|
||||
"whole module is written against",
|
||||
_derate_named == 0 and _derate_last[3] == {})
|
||||
|
||||
check("...and the DERATED control is not vacuous, which is asserted on the ONE "
|
||||
"thing that could make it so: unlike the cut-out arm above, its unit IS "
|
||||
"asked - a positive share on every cycle - so it really does reach both "
|
||||
"arms and is turned away by the size of its shortfall. ⚠️ THIS GUARD USED "
|
||||
"TO RE-ASSERT THE WEDGED ARM's 200/200 INSTEAD, WHICH IS WHY IT DID NOT "
|
||||
"CATCH THE CUT-OUT ARM BEING VACUOUS: a check on a DIFFERENT run cannot "
|
||||
"say anything about whether THIS one reached the comparison",
|
||||
_derate_last[1][Bee] > 0
|
||||
and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200)
|
||||
|
||||
print()
|
||||
print(" N=1 IS BYTE-IDENTICAL, AND THAT IS ASSERTED RATHER THAN ARGUED")
|
||||
|
||||
# ⚠️ THE LIVE INSTALL IS N=1 AND THIS CARD MUST NOT REACH IT. Two independent
|
||||
# statements, and each is checked against the thing that would break it:
|
||||
# 1. `plan()` RETURNS BEFORE `weights` IS READ AT ALL, so no weighting - and
|
||||
# no change to how one is derived - can move a one-inverter share. Graded
|
||||
# with a `weights` object that RAISES if anything reads it.
|
||||
# 2. `delinquent()` returns {} at a roster of one for every input, including
|
||||
# the input the RELATIVE arm was built for. The proper-subset algebra runs
|
||||
# before either arm, so the fraction is never even multiplied.
|
||||
|
||||
|
||||
class _Poison(dict):
|
||||
"""A `weights` that cannot be read without saying so. Not a mock: a tripwire."""
|
||||
|
||||
def get(self, *a, **k):
|
||||
raise AssertionError("plan() read weights at a roster of one")
|
||||
|
||||
def __getitem__(self, k):
|
||||
raise AssertionError("plan() read weights at a roster of one")
|
||||
|
||||
|
||||
_n1_plain = plan(1234.5, {A: _B(-A_W, A_W)})
|
||||
_n1_poison = plan(1234.5, {A: _B(-A_W, A_W)}, weights=_Poison())
|
||||
check("`plan()` at a roster of ONE returns the site target unread, and it "
|
||||
"returns it WITHOUT TOUCHING `weights` - handed a mapping that raises on "
|
||||
"any read, it answers identically to the same call with no weights at "
|
||||
"all. The N=1 pass-through is above the weighting, so OPT-09 could not "
|
||||
"move it and SAFETY-43 does not either",
|
||||
_n1_poison.shares == _n1_plain.shares == {A: 1234.5}
|
||||
and _n1_poison.unmet_w == _n1_plain.unmet_w)
|
||||
try:
|
||||
plan(1234.5, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=_Poison())
|
||||
except AssertionError:
|
||||
_poison_bites = True
|
||||
else:
|
||||
_poison_bites = False
|
||||
check("...and the tripwire is a real one: the SAME poisoned mapping at a roster "
|
||||
"of TWO is read and raises. A check that could not fail would be "
|
||||
"measuring nothing",
|
||||
_poison_bites)
|
||||
|
||||
_n1_rel = [delinquent([UnitDelivery(A, s, m)], MIN_W, f)
|
||||
for s in (0, 125, 500, 2000, 5000, -2000, None)
|
||||
for m in (0, 60, 125, 2000, -2000, None)
|
||||
for f in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION)]
|
||||
check(f"...and `delinquent()` answers {{}} at a roster of one on all "
|
||||
f"{len(_n1_rel)} of (share, measured, rule) - INCLUDING 0 of 125 W, the "
|
||||
f"exact input this card added an arm for. `len(asked) < 2` returns "
|
||||
f"before either threshold is compared, so the one-inverter install this "
|
||||
f"add-on is running today cannot tell that SAFETY-43 happened",
|
||||
all(r == {} for r in _n1_rel))
|
||||
|
||||
check("⚠️ AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY - which is "
|
||||
"what a check that only ever ran the quiet load would be pinning. Push "
|
||||
"the load past the healthy pack's OWN RATING and `_fill()`'s last-resort "
|
||||
"pass hands the wedged unit 3000 W anyway, the gap goes back over 500 W, "
|
||||
"and BOTH arms name it. The window is real and it has an upper edge; the "
|
||||
"hole is the quiet regime below that edge",
|
||||
_wedge[(6000.0, True)][0] == 195
|
||||
and sorted(_wedge[(6000.0, True)][1][3]) == [Bee]
|
||||
and _wedge[(6000.0, False)][0] == 199
|
||||
and sorted(_wedge[(6000.0, False)][1][3]) == [Bee])
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
@@ -978,9 +1198,12 @@ print(" a preference and are honest about being one.")
|
||||
print(" * A WEDGED UNIT IS NOT REACHABLE BY THE ENVELOPE. Its SoC reads")
|
||||
print(" healthy, so nothing closes, and SAFETY-42 owns the detection.")
|
||||
print(" ⚠️ BUT IT IS REACHABLE BY THE WEIGHTS, WHICH MOVE ON ENERGY")
|
||||
print(" HEADROOM AND NOT ON DELIVERY - part 8b grades what that does")
|
||||
print(" to SAFETY-42's window, and the answer is a REGRESSION that is")
|
||||
print(" pinned rather than fixed.")
|
||||
print(" HEADROOM AND NOT ON DELIVERY. That narrowed SAFETY-42's")
|
||||
print(" detection window - a regression this file PINNED rather than")
|
||||
print(" fixed - and SAFETY-43 closed it by making delinquency relative")
|
||||
print(" to the ask as well as absolute. Part 8b now grades both rules")
|
||||
print(" on one fixture: the absolute arm still says nothing, and the")
|
||||
print(" shipped one names the unit on all 200 cycles.")
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
@@ -1023,13 +1246,41 @@ MUT = [
|
||||
"mutation took it down with a TypeError three lines into part 1"),
|
||||
("M11 weights(): keep the shape, force every weight to 1.0 <- THE PROOF "
|
||||
"THAT PART 8b CAN GO RED",
|
||||
"test_packshare 8b, three named checks: the wedged unit goes back to being "
|
||||
"named on 199/200 at 1500 W, the split reverts to 1500/1500 and the "
|
||||
"window collapses from 6500 W to 1000 W. ⚠️ RUN, NOT REASONED ABOUT - a "
|
||||
"check that pins an UNFLATTERING behaviour has to be shown capable of "
|
||||
"failing, or it is indistinguishable from a check that pins nothing. "
|
||||
"test_packshare 8b, FIVE named checks - re-run for SAFETY-43 rather than "
|
||||
"carried across, because the part it grades was rewritten: the split "
|
||||
"reverts to 1500/1500 so the absolute arm names the unit on 199/200 "
|
||||
"(taking the card's RED check down), the shipped rule's 200/200 comes "
|
||||
"from the wrong shares (taking the GREEN one down), the OPT-09 window "
|
||||
"collapses from 6500 W to 1000 W, the 6000 W arm's 195 becomes 199, and "
|
||||
"the derated positive control moves from 0/200 to 3/200. ⚠️ RUN, NOT "
|
||||
"REASONED ABOUT - a check that pins a behaviour has to be shown capable "
|
||||
"of failing, or it is indistinguishable from a check that pins nothing. "
|
||||
"(It also takes parts 1, 3, 7 and 8 down, which is M3's territory; 8b is "
|
||||
"the part that names the SAFETY-42 interaction)"),
|
||||
# --- SAFETY-43's three, and they pin the new arm from BOTH sides --------
|
||||
("M12 delinquent(): drop the relative arm, keep `gap > min_w` <- THE "
|
||||
"CARD ITSELF REVERTED",
|
||||
"SEVEN named checks: test_delivery 1 (THREE - the 0-of-125 W naming, the "
|
||||
"proportion in the sentence, and the strict relative bound) and "
|
||||
"test_packshare 8b (FOUR - the GREEN check goes back to 0/200 under "
|
||||
"OPT-09's weights, the equal arm's 200 back to 199, the 6000 W arm back "
|
||||
"to 195/199, and the derated control's vacuity guard falls with them). "
|
||||
"test_alloc and test_control stay green, which is the point - nothing "
|
||||
"outside this check moves"),
|
||||
("M13 DELIVERY_SHARE_FRACTION 0.85 -> 1.0 <- THE TOO-LOOSE DIRECTION",
|
||||
"SEVEN named checks: test_delivery 1 (TWO) and test_packshare 8b (FIVE), "
|
||||
"including the PRECONDITION that the fraction is strictly below 1.0. At "
|
||||
"1.0 the arm can only fire on a unit IMPORTING while asked to discharge, "
|
||||
"so the card's own case - 0 of 125 W, gap over share exactly 1.0 - is "
|
||||
"missed and the whole card is a no-op"),
|
||||
("M14 DELIVERY_SHARE_FRACTION 0.85 -> 0.05 <- THE TOO-SENSITIVE ONE",
|
||||
"SIX named checks: test_delivery 1 (TWO - the strict bound, and 'IT TOOK "
|
||||
"NOTHING AWAY' stops being true) and 4b (THREE - the `saturation_w` pin "
|
||||
"goes VACUOUS, because the relative arm names B at every threshold), plus "
|
||||
"test_packshare 8b's DERATED positive control, where a unit delivering "
|
||||
"half its share starts being named. ⚠️ THIS IS THE ROW THAT MATTERS - the "
|
||||
"fraction is pinned from BOTH directions, so it is a derived number with "
|
||||
"checks around it rather than a literal nobody can move"),
|
||||
]
|
||||
for _n, _w in MUT:
|
||||
print(f" caught {_n}")
|
||||
@@ -1039,8 +1290,17 @@ check("every mutation in the table above was RUN and every one of them is "
|
||||
"M6: the whole defect this card fixes - a parameter nothing passes - is "
|
||||
"invisible to every behavioural fixture in this repository, because they "
|
||||
"all build their own claim list and call `resolve()` themselves. That is "
|
||||
"why the call site is pinned statically as well",
|
||||
len(MUT) == 11)
|
||||
"why the call site is pinned statically as well. ⚠️ M12-M14 ARE "
|
||||
"SAFETY-43's and were run the same way, on trees copied fresh and "
|
||||
"mounted :ro so no `.pyc` could go stale - MUTATION.md's defect. All "
|
||||
"three are REDS, which is the direction that defect cannot fabricate. "
|
||||
"⚠️ AND THEIR COUNTS WERE RE-RUN IN REVIEW AND TWO OF THEM MOVED: "
|
||||
"test_delivery.py part 1 used to subscript `delinquent(...)['b']` for a "
|
||||
"print, which raised KeyError under M12 and M13 and ABORTED THE FILE at "
|
||||
"part 1, leaving parts 2-8 ungraded and the recorded coverage smaller "
|
||||
"than the truth. It is a `.get` now. M14's count was simply wrong: five "
|
||||
"named checks in test_delivery, not four",
|
||||
len(MUT) == 14)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
+1640
-20
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,7 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import itertools
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -272,16 +273,19 @@ check("the provenance audit REFUSES a carried hour flagged non-provisional - "
|
||||
"the 'stale but plausible' point, which is SAFETY-18's frozen-but-fresh "
|
||||
"meter one layer up",
|
||||
raises(lambda: _fc._audit([_stale_copy], {}), ValueError))
|
||||
# ⚠️ `published` MAPS AN HOUR TO A TUPLE, not to a point (FCST-04 H1). An hour
|
||||
# may legitimately hold four published points - see the quarter-hourly block
|
||||
# below - so the audit takes the whole set and checks membership by identity.
|
||||
check("...and refuses a published hour that arrived as a COPY rather than as "
|
||||
"the published object, which is where a flag gets lost",
|
||||
raises(lambda: _fc._audit(
|
||||
[PricePoint(start=TODAY_PART.points[0].start,
|
||||
eur_per_kwh=TODAY_PART.points[0].eur_per_kwh,
|
||||
provisional=TODAY_PART.points[0].provisional)],
|
||||
{D0: TODAY_PART.points[0]}), ValueError))
|
||||
{D0: (TODAY_PART.points[0],)}), ValueError))
|
||||
check("...and never repairs a flag: the audit's only outcomes are silence and "
|
||||
"an exception, so a series it passed was already correct",
|
||||
_fc._audit(list(FILLED.points), {u: TODAY_PART.points[i]
|
||||
_fc._audit(list(FILLED.points), {u: (TODAY_PART.points[i],)
|
||||
for i, u in enumerate(DAY[:PUBLISHED_N])})
|
||||
is None)
|
||||
|
||||
@@ -298,6 +302,231 @@ check("a PUBLISHED point that the feed itself flagged provisional keeps that "
|
||||
and all(p.provisional for p in _eddy_filled.points[:2])
|
||||
and _eddy_filled.points[0] is _eddy.points[0])
|
||||
|
||||
# ⚠️ AND A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR (FCST-04 H1).
|
||||
# `eneco_eddy` settles per QUARTER and `_validate_raw()` accepts any
|
||||
# strictly-increasing spacing, so a published hour can hold four points. The
|
||||
# fill walks WHOLE HOURS to decide what needs carrying; that must not become a
|
||||
# rule about what a published hour may contain. Measured on FCST-04's first
|
||||
# draft, which keyed the published set `{_utc_hour(p.start): p ...}`: a
|
||||
# 96-point feed came out 15 points long and its arbitrage spread fell from
|
||||
# 0.143 to 0.014 EUR/kWh, on an ALL-FIRM, fully-published series.
|
||||
_QN = 16 # four hours of quarters
|
||||
_QUARTERS = PriceRead.series(
|
||||
[PricePoint(start=brussels(D0 + timedelta(minutes=15 * n)),
|
||||
eur_per_kwh=round(0.100000 + 0.011300 * n, 6),
|
||||
provisional=False)
|
||||
for n in range(_QN)],
|
||||
PriceProduct.REALTIME, "eneco_eddy", D0)
|
||||
_QHOURS = {_fc._utc_hour(p.start) for p in _QUARTERS.points}
|
||||
check(f"the quarter-hourly fixture really is sub-hourly - {_QN} points across "
|
||||
f"{len(_QHOURS)} hours - or every check below passes for an hourly feed "
|
||||
f"too and grades nothing",
|
||||
len(_QUARTERS.points) == _QN and len(_QHOURS) == _QN // 4)
|
||||
_qfill = derived(lambda: fill_from_yesterday(_QUARTERS, yesterday=YESTERDAY,
|
||||
since=D0, until=D1, stamp=D0))
|
||||
_qfirm = derived(lambda: [p for p in _qfill.points if not p.provisional])
|
||||
check(f"every one of the {_QN} published points survives the fill BY IDENTITY "
|
||||
f"({len(_qfirm)} kept). One-per-hour keying would leave {_QN // 4}, and "
|
||||
f"the spread over the hours the feed actually published would collapse "
|
||||
f"with them",
|
||||
len(_qfirm) == _QN
|
||||
and all(any(p is q for q in _QUARTERS.points) for p in _qfirm))
|
||||
check(f"...and the {len(DAY) - len(_QHOURS)} whole hours it published NOTHING "
|
||||
f"for are carried, ONE point each: a carried hour is hourly whatever the "
|
||||
f"feed's spacing is, because yesterday's profile is only known per "
|
||||
f"wall-clock hour",
|
||||
_qfill.kind == "series"
|
||||
and len([p for p in _qfill.points if p.provisional])
|
||||
== len(DAY) - len(_QHOURS))
|
||||
check("...and the filled series is still ordered by instant across the two "
|
||||
"kinds, so `at()` and `window()` are not reading a shuffled list",
|
||||
_qfill.kind == "series"
|
||||
and [p.start.astimezone(UTC) for p in _qfill.points]
|
||||
== sorted(p.start.astimezone(UTC) for p in _qfill.points))
|
||||
|
||||
_PUB_Q = _fc._published_by_hour(_QUARTERS)
|
||||
# ⚠️ `derived`, NOT A RAW CALL. Under the decimating mutation this check
|
||||
# exists for, `_audit` RAISES here - and a harness that dies on the mutation it
|
||||
# is grading reports a traceback instead of a FAIL, which MUTATION.md forbids.
|
||||
check("the audit ACCEPTS four published points inside one hour, which is the "
|
||||
"case the repeated-hour COUNT it replaced could not express",
|
||||
derived(lambda: _fc._audit(list(_QUARTERS.points), _PUB_Q)) is None)
|
||||
check("...and still REFUSES a repeated CARRIED hour, which is the DST "
|
||||
"mispricing that count existed to catch",
|
||||
raises(lambda: _fc._audit([_fc._carried(D0, 0.11),
|
||||
_fc._carried(D0, 0.12)], {}), ValueError))
|
||||
check("...and STILL refuses the SAME published point emitted twice - the "
|
||||
"provider's points are strictly increasing, so a repeat is never a "
|
||||
"second print. The old hour COUNT already refused this (one point twice "
|
||||
"is one hour twice); what is graded here is that the relaxation did not "
|
||||
"lose it, not that it added it",
|
||||
raises(lambda: _fc._audit([_QUARTERS.points[0], _QUARTERS.points[0]],
|
||||
_PUB_Q), ValueError))
|
||||
check("...and STILL refuses a CARRIED point standing in an hour the feed DID "
|
||||
"publish. The old COUNT never looked at it; the old IDENTITY rule did, "
|
||||
"and raised - so this is preserved behaviour, not new strictness. "
|
||||
"FCST-04's text called it new here and in `_audit`'s docstring, and a "
|
||||
"measured differential says otherwise",
|
||||
raises(lambda: _fc._audit([_fc._carried(D0, 0.11)], _PUB_Q), ValueError))
|
||||
|
||||
# ⚠️ AND WHAT THE AUDIT DOES NOT DO, STATED WHERE A READER OF THESE THREE
|
||||
# CHECKS WOULD OTHERWISE ASSUME IT (FCST-05, MUTATION.md habit 16). `_audit` is
|
||||
# a PROVENANCE post-condition, not a COMPLETENESS one: it grades every point
|
||||
# that IS emitted and says nothing about points that are not. So it accepts the
|
||||
# very decimation FCST-04 was raised for. That is not a regression - FCST-03's
|
||||
# audit was equally blind - but it means the audit is NOT the thing that stops
|
||||
# H1 coming back. The count and identity checks above this line are.
|
||||
check("the audit ACCEPTS the H1 decimation itself - four published points in "
|
||||
"one hour, only the last emitted - and accepts an EMPTY output. It is a "
|
||||
"provenance check, not a completeness one, and the checks that guard "
|
||||
"provenance completeness are the COUNT and IDENTITY ones above",
|
||||
derived(lambda: _fc._audit([_QUARTERS.points[3]], _PUB_Q)) is None
|
||||
and derived(lambda: _fc._audit([], _PUB_Q)) is None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ AND THE CLAIM "STRICTER" IS ITSELF A CHECK NOW (FCST-05).
|
||||
#
|
||||
# FCST-04 rewrote `_audit` and wrote, in the module and in the three checks
|
||||
# above, that the result was STRICTER than FCST-03's. It is not. Two of the
|
||||
# stated reasons were false - the old `seen` count already refused the same
|
||||
# point emitted twice, and the old IDENTITY rule already refused a carried
|
||||
# point in a published hour - and a right decision with a fabricated reason is
|
||||
# a defect in this repository (MUTATION.md, and the reason DECISIONS files
|
||||
# exist). So the claim is measured here rather than asserted in prose:
|
||||
# FCST-03's audit is reproduced below from dd40c56 - its RULES verbatim, its
|
||||
# message TEXT shortened, and nothing here reads a message - and the two are
|
||||
# run over the whole domain `_audit` can express, in BOTH directions.
|
||||
#
|
||||
# THE DOMAIN, AND ITS SIZE. Every published mask over 3 hours (2**3) crossed
|
||||
# with every output series of length 0-3 drawn from 3 hours x 5 point kinds.
|
||||
# The nominal cross-product is 28 928; three of those kinds - published,
|
||||
# published-provisional, copy-of-published - DO NOT EXIST for an hour the feed
|
||||
# did not publish, and those combinations are SKIPPED rather than faked, which
|
||||
# leaves 11 990 realisable comparisons. A fabricated "published point" for an
|
||||
# unpublished hour is not in either audit's domain, and inventing one would
|
||||
# grade the fixture rather than the code.
|
||||
#
|
||||
# ⚠️ THE OLD COPY IS A LITERAL, NOT A CALL. MUTATION.md habit 2: a differential
|
||||
# whose two sides both come from the shipped module compares a thing against
|
||||
# itself and moves with every edit. This side is frozen text.
|
||||
def _audit_fcst03(points, published):
|
||||
"""FCST-03's `_audit`, verbatim from dd40c56. Its `published` maps a UTC
|
||||
hour to ONE point, which is exactly the expressiveness this ticket relaxed.
|
||||
"""
|
||||
seen = set()
|
||||
for p in points:
|
||||
u = _fc._utc_hour(p.start)
|
||||
if u in seen:
|
||||
raise ValueError("the filled series repeats " + u.isoformat())
|
||||
seen.add(u)
|
||||
src = published.get(u)
|
||||
if src is None:
|
||||
if p.provisional is not True:
|
||||
raise ValueError(u.isoformat() + " is carried and is flagged "
|
||||
"provisional=" + repr(p.provisional))
|
||||
else:
|
||||
if p is not src:
|
||||
raise ValueError(u.isoformat() + " is published but the series "
|
||||
"carries a COPY of that point")
|
||||
|
||||
|
||||
def _verdict(fn, pts, pub):
|
||||
try:
|
||||
fn(list(pts), pub)
|
||||
return "accept"
|
||||
except ValueError:
|
||||
return "raise"
|
||||
|
||||
|
||||
_DH = [D0 + timedelta(hours=6 + i) for i in range(3)]
|
||||
_weaker, _stronger, _compared = [], [], 0
|
||||
for _mask in itertools.product((0, 1), repeat=3):
|
||||
_pub = {u: PricePoint(start=u, eur_per_kwh=0.10 + i, provisional=False)
|
||||
for i, (u, _on) in enumerate(zip(_DH, _mask)) if _on}
|
||||
_old_pub = dict(_pub)
|
||||
_new_pub = {u: (q,) for u, q in _pub.items()}
|
||||
# The five shapes an output point can take at a given hour. Three of them
|
||||
# do not exist for an hour the feed did not publish, and those combinations
|
||||
# are skipped rather than faked - a fabricated "published point" for an
|
||||
# unpublished hour is not in either audit's domain.
|
||||
_kinds = []
|
||||
for _u in _DH:
|
||||
_src = _pub.get(_u)
|
||||
_kinds.append(("published", _src))
|
||||
_kinds.append(("published-provisional",
|
||||
None if _src is None else
|
||||
PricePoint(start=_src.start,
|
||||
eur_per_kwh=_src.eur_per_kwh,
|
||||
provisional=True)))
|
||||
_kinds.append(("carried", _fc._carried(_u, 0.5)))
|
||||
_kinds.append(("carried-unflagged",
|
||||
PricePoint(start=_u, eur_per_kwh=0.5,
|
||||
provisional=False)))
|
||||
_kinds.append(("copy-of-published",
|
||||
None if _src is None else
|
||||
PricePoint(start=_src.start,
|
||||
eur_per_kwh=_src.eur_per_kwh,
|
||||
provisional=_src.provisional)))
|
||||
for _n in range(4):
|
||||
for _combo in itertools.product(_kinds, repeat=_n):
|
||||
if any(k[1] is None for k in _combo):
|
||||
continue
|
||||
_pts = [k[1] for k in _combo]
|
||||
_compared += 1
|
||||
_o = _verdict(_audit_fcst03, _pts, _old_pub)
|
||||
_nw = _verdict(_fc._audit, _pts, _new_pub)
|
||||
if _o == "raise" and _nw == "accept":
|
||||
_weaker.append((_mask, [k[0] for k in _combo]))
|
||||
if _o == "accept" and _nw == "raise":
|
||||
_stronger.append((_mask, [k[0] for k in _combo]))
|
||||
|
||||
# ⚠️ THE PRECONDITION FIRST (MUTATION.md habit 1). A differential over a
|
||||
# domain that cannot reach the two disputed cases would score 0-0 for the wrong
|
||||
# reason and read exactly like this one, so the two cases are driven against
|
||||
# the OLD audit by name before the totals are graded.
|
||||
_probe = PricePoint(start=_DH[0], eur_per_kwh=0.1, provisional=False)
|
||||
check(f"the differential really covers the two claims it is here to settle, "
|
||||
f"or it is {_compared} runs of nothing: FCST-03's own audit raises on "
|
||||
f"BOTH 'the same published point twice' and 'a carried point in a "
|
||||
f"published hour', which is what FCST-04 said it did not",
|
||||
_compared > 3000
|
||||
and _verdict(_audit_fcst03, [_probe, _probe], {_DH[0]: _probe}) == "raise"
|
||||
and _verdict(_audit_fcst03, [_fc._carried(_DH[0], 0.5)],
|
||||
{_DH[0]: _probe}) == "raise")
|
||||
check(f"...and over all {_compared} of them the reworked audit is NOT WEAKER "
|
||||
f"than FCST-03's: {len(_weaker)} case(s) the old one raised and this one "
|
||||
f"accepts {_weaker[:2]}",
|
||||
_weaker == [])
|
||||
check(f"...and NOT STRONGER either: {len(_stronger)} case(s) the old one "
|
||||
f"accepted and this one raises {_stronger[:2]}. FCST-04's docstring and "
|
||||
f"two of these checks said it was stricter and named two reasons, and "
|
||||
f"BOTH were already refused by the old count and the old identity rule. "
|
||||
f"The only behavioural change is the RELAXATION below, which is what the "
|
||||
f"rework was for",
|
||||
_stronger == [])
|
||||
_QP = [PricePoint(start=_DH[0] + timedelta(minutes=15 * i),
|
||||
eur_per_kwh=0.1 + i, provisional=False) for i in range(4)]
|
||||
check("...and the relaxation is real and is NOT expressible to the old audit: "
|
||||
"four distinct published points inside one hour, which FCST-03's count "
|
||||
"refused as a repeated hour and this one accepts",
|
||||
_verdict(_fc._audit, _QP, {_DH[0]: tuple(_QP)}) == "accept"
|
||||
and _verdict(_audit_fcst03, _QP, {_DH[0]: _QP[0]}) == "raise")
|
||||
|
||||
# ⚠️ THE HORIZON IS WHOLE HOURS AT BOTH ENDS (FCST-04 L8). `since` is floored
|
||||
# and `until` was not, so a 48 h request made at any instant off the hour came
|
||||
# back 49 hours long. Graded at an OFF-THE-HOUR instant, because on the hour
|
||||
# the two implementations agree and the check would grade nothing.
|
||||
_off = D0 + timedelta(hours=8, minutes=17, seconds=43)
|
||||
for _h in (1, 12, 48):
|
||||
_n = derived(lambda h=_h: len(fill_from_yesterday(
|
||||
TODAY_PART, yesterday=YESTERDAY, since=_off,
|
||||
until=_off + timedelta(hours=h), stamp=_off).points))
|
||||
check(f"a {_h} h horizon asked for at {_off.isoformat()} - 17 min 43 s "
|
||||
f"past the hour, the shape a running controller always has - is "
|
||||
f"{_h} points long, not {_h + 1}",
|
||||
_n == _h)
|
||||
|
||||
# The structural half: the constructor has nothing to pass, and the file is
|
||||
# scanned so the pass-through idiom cannot come back looking ordinary.
|
||||
check("_carried() has NO `provisional` parameter, so a caller has nothing to "
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,7 @@ from app.price import (
|
||||
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
|
||||
plan_is_provisional, required_spread, uncertainty_of,
|
||||
)
|
||||
from app.price_history import NoHistory
|
||||
from app.static_tariff import (
|
||||
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
|
||||
parse_static_profile, resolve_tariff, static_plan, static_status,
|
||||
@@ -742,11 +743,14 @@ 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))
|
||||
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead,
|
||||
history=NoHistory()))
|
||||
check("a dead feed falls back to the static profile rather than to nothing",
|
||||
_fb.origin == "fallback" and _fb.has_prices)
|
||||
check("...with the SAME prices the configured-product path produces, because "
|
||||
@@ -769,15 +773,27 @@ check("...and the provisional plan has no cheap window either: §8.3 forbids an
|
||||
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
|
||||
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
|
||||
price_timeout_s=1),
|
||||
THU, THU + timedelta(days=1), transport=_dead))
|
||||
THU, THU + timedelta(days=1), transport=_dead,
|
||||
history=NoHistory()))
|
||||
check("a dead feed with NO static_profile has nothing to fall back to, and "
|
||||
"says that rather than inventing a tariff",
|
||||
_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):
|
||||
@@ -785,7 +801,7 @@ async def _live(url, timeout_s):
|
||||
|
||||
|
||||
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
|
||||
transport=_live))
|
||||
transport=_live, history=NoHistory()))
|
||||
check("a live feed is used in preference to the static profile",
|
||||
_live_plan.origin == "provider"
|
||||
and _live_plan.product is PriceProduct.DAY_AHEAD)
|
||||
|
||||
@@ -722,18 +722,41 @@ print(f" e.g. {_sustained.reason!r}")
|
||||
|
||||
print()
|
||||
print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE")
|
||||
# ⚠️ THE SHAPES BELOW ARE THE RIG'S, NOT INVENTED. Measured on TEST-19 over 924
|
||||
# closed-loop runs in which every unit executed the share it was given: the
|
||||
# worst continuous opposition above 700 W is 5.0 s - ONE control cycle - at a
|
||||
# peak of 1700 W. That is the serialised write path: on a reversing cycle B's
|
||||
# new number lands up to a whole cycle after A's, both slew through zero at
|
||||
# 800 W/s behind a 10 s lag, and between the two crossings they genuinely
|
||||
# oppose. A guard that trips on that is a false-alarm generator, and this
|
||||
# project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19).
|
||||
# THE SHAPE BELOW IS THE RIG'S: the serialised write path means that on a
|
||||
# reversing cycle B's new number lands up to a whole control cycle after A's,
|
||||
# both slew through zero behind a real ramp and a real lag, and between the two
|
||||
# crossings they genuinely oppose. A guard that trips on that is a false-alarm
|
||||
# generator, and this project's doctrine on crying wolf is explicit (SAFETY-15,
|
||||
# SAFETY-19).
|
||||
#
|
||||
# ⚠️ THE MAGNITUDE IS A FIXTURE INPUT, NOT A MEASUREMENT, AND THIS COMMENT USED
|
||||
# TO SAY OTHERWISE. It read "the worst single transient the rig can produce -
|
||||
# 1700 W", sourced to a 924-run campaign whose harness IS NOT COMMITTED IN
|
||||
# EITHER REPOSITORY (DECISIONS/SAFETY-31-circulating-power.md §3 says so in its
|
||||
# own words). Three sweeps have now been run against that claim and no two agree:
|
||||
#
|
||||
# 924-run campaign, harness uncommitted peak 1700 W, 505.0 s at 100 W
|
||||
# independent reviewer sweep, 2026-09-07 peak 2750 W, 15.0 s above 700 W
|
||||
# goodwe `two_master_circulation`, 840 runs peak 1048 W, 5.0 s above 700 W
|
||||
#
|
||||
# ⚠️ THAT IS NOT EVIDENCE THAT ANY OF THEM IS WRONG. The three swept different
|
||||
# grids, and the last one measured WHY the spread is so wide: on its own grid the
|
||||
# worst legitimate peak is 1048 W on the modelled plant, 2250 W on `INSTANT`, and
|
||||
# 0 W on `INSTANT` with no write stagger reaching a whole cycle. The plant model
|
||||
# and the stagger set are TERMS of the number, and a worst case quoted without
|
||||
# them is not a measurement. It is evidence that the claim cannot be adjudicated
|
||||
# while the instrument is missing.
|
||||
#
|
||||
# So what this check now says is what it can support: 1700 W is LARGER than
|
||||
# anything any of the three sweeps produced legitimately, and one control cycle
|
||||
# of it does not trip. The committed, re-runnable version of the claim is
|
||||
# `admin/goodwe`'s `sim/inject.py two_master_circulation`, which runs on every
|
||||
# pull request there and requires ZERO of ~840 non-faulty runs to trip.
|
||||
_one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30
|
||||
_t1, _ = circ_drive(_one_cycle)
|
||||
check("the worst single transient the rig can produce - 1700 W for ONE control "
|
||||
"cycle - does not trip", not _t1.tripped)
|
||||
check("a single transient of 1700 W - larger than the worst peak any committed "
|
||||
"sweep of the legitimate family has produced - lasting ONE control cycle "
|
||||
"does not trip", not _t1.tripped)
|
||||
_repeated = ([0.0] * 3 + [1700.0]) * 20
|
||||
_t2, _ = circ_drive(_repeated)
|
||||
check("...and neither does it REPEATED every fourth cycle for 400 s, which is "
|
||||
@@ -1091,6 +1114,158 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
|
||||
"this' a different question from 'is this site tripped'",
|
||||
len(_notes2) == 1)
|
||||
|
||||
|
||||
print()
|
||||
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
|
||||
# ⚠️ THIS BLOCK EXISTS BECAUSE A MUTATION SURVIVED ALL 26 FILES. Replacing
|
||||
#
|
||||
# pos, neg = opposing([inv.batt for inv in self.inverters],
|
||||
# min_w=self.circ_wd.cfg.min_w / 2.0)
|
||||
#
|
||||
# in `check_circulating()` with `pos, neg = ([], [])` (-102 B) silently deletes
|
||||
# the "Right now X is discharging while Y charges" clause from the notification,
|
||||
# and the whole suite stayed GREEN. The block above asserts three tokens -
|
||||
# METER CANNOT SEE, CONTROL HAS BEEN STOPPED, isolator - and NEVER an inverter
|
||||
# name, so the one sentence `circulation.py` calls "the first thing somebody
|
||||
# woken by this alarm needs" and "impossible to reconstruct from the number
|
||||
# alone" was graded by nothing at all.
|
||||
#
|
||||
# ⚠️ IT IS NOT DEAD CODE, WHICH IS THE PART THAT MAKES IT A CHECK GAP RATHER
|
||||
# THAN A REACHABILITY ONE. Ledger M18 - the `await self.check_circulating()`
|
||||
# call removed - goes red on four checks above, so this function runs. What
|
||||
# nothing asserted was the ATTRIBUTION.
|
||||
#
|
||||
# ⚠️ AND THE ROSTER IS ["garage", "shed", "cellar"], WHICH IS THE OTHER HALF OF
|
||||
# THE FIX. Every fixture above runs on `inverter-a`/`inverter-b`;
|
||||
# test_maintenance.py section 11 records what that costs - on a fixture whose
|
||||
# names are the module's own constants, a check cannot tell the roster from the
|
||||
# constant, and `UNIT_A` is not even what a nameless entry falls back to. Here
|
||||
# the names are an operator's, the two participants pull OPPOSITE ways so the
|
||||
# sentence is not symmetric, and a third pack rests at 5 W. That makes three
|
||||
# DIFFERENT mutations - the clause dropped, the two directions swapped, and the
|
||||
# `min_w` floor dropped - into three DIFFERENT red checks. A fixture naming both
|
||||
# units the same, or one in which the direction did not matter, would see the
|
||||
# first and neither of the others.
|
||||
|
||||
_CIRC_ROSTER = [
|
||||
dict(name="garage", setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
batt_entity="sensor.batt_a", max_w=5000),
|
||||
# ⚠️ B_RATING_HYPOTHESIS, and part 4's note above says why it is not 4600:
|
||||
# inverter B does not physically exist and a datasheet is not a measurement.
|
||||
dict(name="shed", setpoint_entity="number.sp_b", soc_entity="sensor.soc_b",
|
||||
batt_entity="sensor.batt_b", max_w=B_RATING_HYPOTHESIS),
|
||||
# THE BYSTANDER. A third pack resting at standby draw, on the roster, read
|
||||
# every cycle, and NOT a participant in anybody's loop.
|
||||
dict(name="cellar", setpoint_entity="number.sp_c", soc_entity="sensor.soc_c",
|
||||
batt_entity="sensor.batt_c", max_w=B_RATING_HYPOTHESIS),
|
||||
]
|
||||
_CIRC_NAMES = [e["name"] for e in _CIRC_ROSTER]
|
||||
_REST_W = 5.0
|
||||
|
||||
|
||||
class _NamedCircHass(_Hass):
|
||||
"""Three named packs; the meter still reads exactly zero.
|
||||
|
||||
Same house as `_CircHass` - grid = load - sum(B) with two units cancelling -
|
||||
with one extra pack idling at standby draw so that "who is named" and "who
|
||||
is merely on the roster" are different questions.
|
||||
"""
|
||||
|
||||
def __init__(self, a_w, b_w, c_w=_REST_W):
|
||||
super().__init__()
|
||||
self.vals = {"sensor.batt_a": a_w, "sensor.batt_b": b_w,
|
||||
"sensor.batt_c": c_w}
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
return {"sensor.grid": 0.0, "sensor.soc_a": 50.0,
|
||||
"sensor.soc_b": 50.0, "sensor.soc_c": 50.0,
|
||||
**self.vals}.get(entity)
|
||||
|
||||
|
||||
def _named_trio(hass, store, *, hold_s=1, min_w=700):
|
||||
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
|
||||
"max_w": 5000, "gain": 0.6, "auto_start": True,
|
||||
"circulation_w": min_w, "circulation_hold_s": hold_s,
|
||||
"inverters": _CIRC_ROSTER}
|
||||
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
return c
|
||||
|
||||
|
||||
async def _circ_named(a_w, b_w):
|
||||
hass = _NamedCircHass(a_w, b_w)
|
||||
c = _named_trio(hass, _MemStore())
|
||||
for _ in range(14):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await asyncio.sleep(0.25)
|
||||
notes = [x for x in hass.calls
|
||||
if x[0] == "persistent_notification"
|
||||
and x[2].get("notification_id") == "goodwe_circulating_power"]
|
||||
return c, notes
|
||||
|
||||
|
||||
_cg, _notes_g = asyncio.run(_circ_named(+3000.0, -3000.0))
|
||||
_cs, _notes_s = asyncio.run(_circ_named(-3000.0, +3000.0))
|
||||
_msg_g = _notes_g[0][2]["message"] if _notes_g else ""
|
||||
_msg_s = _notes_s[0][2]["message"] if _notes_s else ""
|
||||
print(f" roster : {_cg.units}")
|
||||
print(f" garage discharging, measured: "
|
||||
f"{dict(zip(_cg.units, [i.batt for i in _cg.inverters]))}")
|
||||
print(f" -> {_msg_g.split(chr(10))[2] if _msg_g.count(chr(10)) > 2 else _msg_g!r}")
|
||||
print(f" shed discharging, measured: "
|
||||
f"{dict(zip(_cs.units, [i.batt for i in _cs.inverters]))}")
|
||||
print(f" -> {_msg_s.split(chr(10))[2] if _msg_s.count(chr(10)) > 2 else _msg_s!r}")
|
||||
|
||||
# --- THE PRECONDITIONS, AS THEIR OWN CHECKS (MUTATION.md rule 1) ------------
|
||||
check("PRECONDITION: the roster carries three DISTINCT operator-chosen names, "
|
||||
f"none of them `UNIT_A` ({UNIT_A!r}) - so every claim below is a claim "
|
||||
"about the roster and not about a constant that happens to equal it",
|
||||
_cg.units == tuple(_CIRC_NAMES) and len(set(_CIRC_NAMES)) == 3
|
||||
and UNIT_A not in _CIRC_NAMES)
|
||||
check("PRECONDITION: the two runs are MIRROR IMAGES - same house, same meter at "
|
||||
"0 W, same magnitudes, and the two participants swapped. Without this the "
|
||||
"direction claim below could be satisfied by a sentence that ignores the "
|
||||
"measurement entirely",
|
||||
[i.batt for i in _cg.inverters] == [+3000.0, -3000.0, _REST_W]
|
||||
and [i.batt for i in _cs.inverters] == [-3000.0, +3000.0, _REST_W]
|
||||
and _cg.grid == 0.0 and _cs.grid == 0.0)
|
||||
check("PRECONDITION: both runs actually tripped and actually raised the card, "
|
||||
"so an absent sentence below is an absent sentence and not an absent alarm",
|
||||
_cg.circ_wd.tripped and _cs.circ_wd.tripped
|
||||
and len(_notes_g) == 1 and len(_notes_s) == 1)
|
||||
|
||||
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
|
||||
_WHO_G = "Right now garage is discharging while shed charges."
|
||||
_WHO_S = "Right now shed is discharging while garage charges."
|
||||
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
|
||||
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
|
||||
"needs and impossible to reconstruct from the watts alone, and until this "
|
||||
"line the sentence could be deleted with all 26 files green",
|
||||
_WHO_G in _msg_g)
|
||||
check("⚠️ AND WITH THE TWO SWAPPED, THE SENTENCE SWAPS. This is the half a "
|
||||
"single fixture cannot have: `pos` and `neg` exchanged, or a hard-coded "
|
||||
"sentence, satisfies the check above and fails this one. The operator is "
|
||||
"being told which pack to walk to",
|
||||
_WHO_S in _msg_s)
|
||||
check("...and each run carries ONLY its own sentence - the mirrored alarm does "
|
||||
"not also contain the other direction, so the clause is derived from this "
|
||||
"cycle's measurement rather than assembled from the roster",
|
||||
_WHO_S not in _msg_g and _WHO_G not in _msg_s)
|
||||
check("...and the RESTING pack is not named. `cellar` is on the roster, is read "
|
||||
"every cycle and is measured at 5 W of standby draw - `opposing()` is "
|
||||
"called with a floor of half the trip threshold precisely so a pack idling "
|
||||
"beside a 6 kW loop is not reported as one of its two participants",
|
||||
"cellar" not in _msg_g and "cellar" not in _msg_s
|
||||
and _cg.inverters[2].batt == _REST_W)
|
||||
check("...and the sentence sits with the figure, not instead of it: the same "
|
||||
"card still carries the watts, the window and the three things only this "
|
||||
"alarm can say",
|
||||
all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m
|
||||
and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m
|
||||
for m in (_msg_g, _msg_s)))
|
||||
print()
|
||||
print(" the operator's press is the only way out, and it clears BOTH halves")
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# WHAT THIS BUILD IS SUPPOSED TO ANNOUNCE, recorded so that a change to the
|
||||
# composition has something to disagree with. THREE COLUMNS:
|
||||
#
|
||||
# <entity_id> <unique_id> <device identifier>
|
||||
#
|
||||
# ⚠️ ALL THREE, AND THE SECOND AND THIRD ARE THE HALF THE FIRST DRAFT MISSED.
|
||||
# A reference that carries only entity_ids cannot see the two changes that
|
||||
# orphan an install without changing a single composed id:
|
||||
#
|
||||
# `BASE` renamed (`goodwe_ctl` -> anything) every unique_id changes
|
||||
# `DEVICE["identifiers"]` changed HA sees a NEW device
|
||||
#
|
||||
# Home Assistant's MQTT integration keys its entity registry on `unique_id` and
|
||||
# its device registry on the device identifier. Change either and HA creates a
|
||||
# NEW entity beside the old one - and because discovery is published with
|
||||
# `retain=True` and nothing publishes a removal payload, the OLD config stays on
|
||||
# the broker and the dead entity stays in the registry, still named by every
|
||||
# automation that referenced it, never updating again. The composed entity_id is
|
||||
# identical on both, so an entity_id-only comparison reports a clean transition.
|
||||
# That is the same shape as the habit-4 this file's own card already caught
|
||||
# once: a reference that does not carry the thing that moved.
|
||||
#
|
||||
# ⚠️ AND `object_id` IS DELIBERATELY NOT A COLUMN. `app/mqtt.py` lines 36-42 and
|
||||
# 61-76 record that HA 2026.8 does NOT honour `object_id` in a discovery
|
||||
# payload - the id is composed from device name plus entity name - so renaming
|
||||
# one is a true no-op and a column for it would make this file refuse a change
|
||||
# that costs nothing. The check is graded on what HA actually keys on.
|
||||
#
|
||||
# PROVENANCE, stated exactly:
|
||||
# - recorded 2026-09-08 from `release/1.0` at `meter_source: firmware`,
|
||||
# roster of one, by reading `mqtt.discovery_payloads(units=[])`. It is what
|
||||
# the code announces.
|
||||
# - `sensor.goodwe_p1_sample_age` is additionally recorded in `app/mqtt.py` as
|
||||
# VERIFIED AGAINST THE LIVE SYSTEM on 2026-08-26, and the ESP32's ingest
|
||||
# guard subscribes to that exact string.
|
||||
# - ⚠️ IT IS NOT A DUMP OF THE LIVE INSTALL'S ENTITY REGISTRY. That is what
|
||||
# `--live-entities` is for. This file is about THE BUILD.
|
||||
#
|
||||
# Editing this file is how you say "the announcement changed on purpose". Do not
|
||||
# edit it to make a check pass.
|
||||
sensor.goodwe_setpoint goodwe_ctl_setpoint goodwe_rs485_controller
|
||||
sensor.goodwe_grid_power goodwe_ctl_grid goodwe_rs485_controller
|
||||
sensor.goodwe_battery_power goodwe_ctl_battery goodwe_rs485_controller
|
||||
sensor.goodwe_battery_soc goodwe_ctl_soc goodwe_rs485_controller
|
||||
sensor.goodwe_circulating_power goodwe_ctl_circulation goodwe_rs485_controller
|
||||
sensor.goodwe_maintenance_phase goodwe_ctl_phase goodwe_rs485_controller
|
||||
sensor.goodwe_controller_status goodwe_ctl_status goodwe_rs485_controller
|
||||
sensor.goodwe_p1_sample_age goodwe_ctl_p1_age goodwe_rs485_controller
|
||||
@@ -0,0 +1,22 @@
|
||||
# ARCH-02 fixture: THE SHAPE THE LIVE BOX IS IN TODAY (a flat, single-inverter
|
||||
# config). ⚠️ ILLUSTRATIVE ENTITY NAMES, NOT THE LIVE ONES - the owner runs the
|
||||
# rehearsal against a copy of the real options blob, and this file exists so
|
||||
# that the harness has a synthetic transition it can be graded on.
|
||||
setpoint_entity: number.goodwe_setpoint
|
||||
soc_entity: sensor.goodwe_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_battery_power_w
|
||||
batt_invert: false
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
meter_source: 'off'
|
||||
meter_entity: sensor.p1_active_power
|
||||
inverters: []
|
||||
gain: 0.6
|
||||
slew_w: 0
|
||||
deadband_w: 15
|
||||
target_grid_w: 0
|
||||
step_w: 10
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
@@ -0,0 +1,39 @@
|
||||
# THE ENTITY IDS THE INSTALL HAS — a stand-in for the owner's real export.
|
||||
#
|
||||
# ⚠️ THIS FILE AND `announced-reference.txt` ANSWER DIFFERENT QUESTIONS AND ARE
|
||||
# NOT INTERCHANGEABLE:
|
||||
#
|
||||
# announced-reference.txt what the BUILD announces. Loaded on EVERY run.
|
||||
# Three columns, because HA keys on `unique_id` and
|
||||
# on the device identifier as well as on the
|
||||
# composed entity_id.
|
||||
# this file (--live-entities)
|
||||
# what the INSTALL has. One id per line. Orphans
|
||||
# are graded against it: an id the install has and
|
||||
# the AFTER shape does not announce is an orphan
|
||||
# whatever the build's own history says.
|
||||
#
|
||||
# ⚠️ OMITTING `--live-entities` IS A REFUSAL (exit 2), NOT A QUIETER RUN. It
|
||||
# used to be the other way round - an unreadable file refused, a missing flag
|
||||
# passed with exit 0 and said nothing about the half it had not checked - and
|
||||
# the owner runs this ONCE, at commissioning, under time pressure.
|
||||
# `--no-live-entities` is the deliberate opt-out and it prints a standing
|
||||
# banner in the report header and again on the exit line.
|
||||
#
|
||||
# PROVENANCE: these eight are what `release/1.0` announces at
|
||||
# `meter_source: firmware`, roster of one, recorded 2026-09-08. They stand in
|
||||
# for the export so the checks run in CI. ⚠️ THE REAL INSTALL CARRIES ROUGHLY 49
|
||||
# `goodwe*` ENTITIES (card ARCH-02, read 2026-09-06); the other ~41 belong to
|
||||
# the ESPHome firmware, to template sensors and to helpers, and this add-on
|
||||
# announces none of them. The tool separates the ids it owns from the ids it
|
||||
# does not rather than claiming orphans it cannot cause - which is the half of
|
||||
# this file the eight-line stand-in cannot exercise, and the reason DOCS.md's
|
||||
# owner step asks for the real export.
|
||||
sensor.goodwe_battery_power
|
||||
sensor.goodwe_battery_soc
|
||||
sensor.goodwe_circulating_power
|
||||
sensor.goodwe_controller_status
|
||||
sensor.goodwe_grid_power
|
||||
sensor.goodwe_maintenance_phase
|
||||
sensor.goodwe_p1_sample_age
|
||||
sensor.goodwe_setpoint
|
||||
@@ -0,0 +1,42 @@
|
||||
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
|
||||
#
|
||||
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
|
||||
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
|
||||
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
|
||||
# every two-pack fixture in the suite named its first entry `inverter-a`, so
|
||||
# `UNIT_A` and `inverters[0].name` were the same string in every check that
|
||||
# existed and the defect was invisible. Names that are not `inverter-a` are what
|
||||
# make a misdirected namespace something a rehearsal can SEE.
|
||||
#
|
||||
# `roster-inverter-a.yaml` beside this file is the same roster with the first
|
||||
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
|
||||
setpoint_entity: number.goodwe_setpoint
|
||||
soc_entity: sensor.goodwe_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_battery_power_w
|
||||
batt_invert: false
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
meter_source: 'off'
|
||||
meter_entity: sensor.p1_active_power
|
||||
gain: 0.6
|
||||
slew_w: 0
|
||||
deadband_w: 15
|
||||
target_grid_w: 0
|
||||
step_w: 10
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
inverters:
|
||||
- name: inverter-a
|
||||
setpoint_entity: number.goodwe_setpoint
|
||||
soc_entity: sensor.goodwe_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_battery_power_w
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
- name: inverter-b
|
||||
setpoint_entity: number.goodwe_b_setpoint
|
||||
soc_entity: sensor.goodwe_b_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_b_battery_power_w
|
||||
max_w: 4600
|
||||
bms_soc_floor: 10
|
||||
@@ -0,0 +1,42 @@
|
||||
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
|
||||
#
|
||||
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
|
||||
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
|
||||
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
|
||||
# every two-pack fixture in the suite named its first entry `inverter-a`, so
|
||||
# `UNIT_A` and `inverters[0].name` were the same string in every check that
|
||||
# existed and the defect was invisible. Names that are not `inverter-a` are what
|
||||
# make a misdirected namespace something a rehearsal can SEE.
|
||||
#
|
||||
# `roster-inverter-a.yaml` beside this file is the same roster with the first
|
||||
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
|
||||
setpoint_entity: number.goodwe_setpoint
|
||||
soc_entity: sensor.goodwe_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_battery_power_w
|
||||
batt_invert: false
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
meter_source: 'off'
|
||||
meter_entity: sensor.p1_active_power
|
||||
gain: 0.6
|
||||
slew_w: 0
|
||||
deadband_w: 15
|
||||
target_grid_w: 0
|
||||
step_w: 10
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
inverters:
|
||||
- name: garage
|
||||
setpoint_entity: number.goodwe_setpoint
|
||||
soc_entity: sensor.goodwe_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_battery_power_w
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
- name: shed
|
||||
setpoint_entity: number.goodwe_b_setpoint
|
||||
soc_entity: sensor.goodwe_b_battery_soc_pct
|
||||
batt_entity: sensor.goodwe_b_battery_power_w
|
||||
max_w: 4600
|
||||
bms_soc_floor: 10
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a store that has ALREADY run the flat config under 0.7.0+, so DEV-02's `schema_version` is stamped and the SAFETY-02 latch is not tripped. NOT a copy of the live box's /data/state.json. This is the CLEAN CONTROL for the MAINT-05 half of the rehearsal: the flat->roster transition over it must come out with NO findings, which is what makes each deliberate mutation's RED mean something. A harness that is red on every input has not measured anything.",
|
||||
"schema_version": 1,
|
||||
"phase": "idle",
|
||||
"phase_started": "2026-08-07T10:00:00+00:00",
|
||||
"last_completed": "2026-08-07T18:42:00+00:00",
|
||||
"last_start_attempt": "2026-08-07T10:00:00+00:00",
|
||||
"sign_tripped": false,
|
||||
"sign_reason": "",
|
||||
"auto": true
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a FRESH INSTALL. No maintenance history, no latch, no schema markers: what /data/state.json looks like before the add-on has ever completed a cycle. It is a clean control, and it earned its place: the first draft of the harness reported this transition RED, because the DEV-02 migration writes both SAFETY-02 defaults under `inverter-a` before a roster exists and the tool called them stranded. Losing a default loses nothing."
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"_comment": "INSTR-01 SYNTHETIC FIXTURE - `state-legacy.json` with a SECOND roster unit already latched. Not a copy of any live state file. It exists to restore M12 END-TO-END on the STAGED leg, which is the arrangement SAFETY-44 and INSTR-01's first draft both missed: `snapshot_over()` starts the after-leg over the file the BEFORE-leg wrote, so a transition-made destination is available there and the two questions the provenance clause and the narrowing ask are NOT incompatible. Every same-step arrangement makes them incompatible; that is what was measured and over-generalised to 'no fixture can'.",
|
||||
"phase": "idle",
|
||||
"phase_started": "2026-08-07T10:00:00+00:00",
|
||||
"last_completed": "2026-08-07T18:42:00+00:00",
|
||||
"last_start_attempt": "2026-08-07T10:00:00+00:00",
|
||||
"sign_tripped": true,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
|
||||
"auto": true,
|
||||
"unit/shed/sign_tripped": true,
|
||||
"unit/shed/sign_reason": "SIGN_DISAGREEMENT: shed, latched before the switch"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a PRE-DEV-02 flat state file. NOT a copy of the live box's /data/state.json; this session cannot read that one and did not try. It is built to carry, deliberately, the two shapes that state built by the happy path does not have: a LATCHED SAFETY-02 failsafe under the flat spelling, and a real maintenance history under the flat spelling. Both are LEGACY-SHAPED: there is no schema_version and no maint_schema_version, so this file was last written by a controller that had never heard of unit namespaces. Losing either one is silent.",
|
||||
"phase": "idle",
|
||||
"phase_started": "2026-08-07T10:00:00+00:00",
|
||||
"last_completed": "2026-08-07T18:42:00+00:00",
|
||||
"last_start_attempt": "2026-08-07T10:00:00+00:00",
|
||||
"sign_tripped": true,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
|
||||
"auto": true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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."
|
||||
@@ -239,7 +239,7 @@ configuration:
|
||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
|
||||
delivery_hold_s:
|
||||
name: "Safety · Not-delivering report after (s) — derived, do not change"
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus 1890 runs at one inverter as the control, which report nothing at any threshold): the worst legitimate episode those runs produce is 30.0 s, the floor of 30 s is the firmware's own link veto below which a unit is already being dropped from the split, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 6.2 times that, and the margin is sized on a measurement rather than a feeling: the second inverter in those tests is a copy of the first, and a second unit four times slower produces 90.0 s. Do not change it without re-deriving it. A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
|
||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
|
||||
auto_start:
|
||||
name: "Advanced · Start controlling automatically on boot"
|
||||
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
|
||||
|
||||
@@ -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."
|
||||
@@ -219,7 +219,7 @@ configuration:
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
|
||||
delivery_hold_s:
|
||||
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus 1890 tests met een omvormer als controlegroep, die bij geen enkele drempel iets melden): de langste terechte episode in die tests duurt 30.0 s, de ondergrens van 30 s is het eigen veto van de firmware waaronder een omvormer al uit de verdeling wordt gehaald, en de bovengrens is het factuurkwartier van 900 s. 185 s is 6,2 keer zoveel, en die marge is op een meting gebaseerd en niet op een gevoel: de tweede omvormer in die tests is een kopie van de eerste, en een tweede eenheid die vier keer trager is levert 90,0 s op. Verander dit niet zonder de afleiding opnieuw te maken. Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
|
||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
|
||||
auto_start:
|
||||
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
|
||||
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."
|
||||
|
||||
Reference in New Issue
Block a user