Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
477911afa1 | ||
|
|
21f22b393a | ||
|
|
6e7b23388c | ||
|
|
1ba35f9c99 | ||
|
|
0b653c35a6 | ||
|
|
d0633665c9 | ||
|
|
8e3734f5ea | ||
|
|
86bdf49332 | ||
|
|
fcaa72418d | ||
|
|
04b3330def | ||
|
|
fdb3b69397 | ||
|
|
cab267c4f3 | ||
|
|
8d83ef0dfb | ||
|
|
f8e501393f | ||
|
|
3d2999337d |
@@ -2,6 +2,579 @@
|
||||
|
||||
## 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 |
|
||||
|
||||
### 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-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
|
||||
POINT.** 0.8.0 was minor because a config that started on 0.7.0 could be
|
||||
REFUSED on it (rule 10 against 0.7.0's own default `slew_w: 1000`). Nothing of
|
||||
that kind is true here, and it was checked rather than assumed:
|
||||
|
||||
- `options:` and `schema:` both hold **71 top-level keys**, the same 71 as
|
||||
0.8.0. The one schema addition is a per-entry key, `capacity_kwh:
|
||||
float(0.1,200)?` - **note the trailing `?`, it is OPTIONAL**.
|
||||
- An unmodified 0.8.0-era config validates clean on 0.9.0. Measured on both the
|
||||
flat single-inverter shape the reference install runs and an N=2 roster with
|
||||
no `capacity_kwh` on any entry: **0 problems each**.
|
||||
- The two refusals [[OPT-09]] adds fire only on configs that USE the new key -
|
||||
an unparseable value, or the key stated on one roster entry and left empty on
|
||||
another. Neither can reach a config written before the key existed.
|
||||
|
||||
So: existing installs upgrade untouched, and the bump is minor because the
|
||||
option surface and the behaviour grew.
|
||||
|
||||
Contents, in merge order since 0.8.0 (`06661c5`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3d29993` | UX-02 - the validator's refusals, next to the field, before the save |
|
||||
| `fdb3b69` | OPT-09 - the weights parameter nothing ever passed, and what it cannot do |
|
||||
| `fcaa724` | PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject |
|
||||
|
||||
**UX-02** builds the real configuration surface in the ingress panel, beside the
|
||||
Supervisor Configuration tab that cannot be removed. The prize is not styling:
|
||||
`validate.py` writes long cross-field refusals the Supervisor UI *structurally
|
||||
cannot show*, and until now an operator met them as **exit 78 in a log**, after
|
||||
saving and restarting. ⚠️ Two config surfaces now exist and the DECISIONS record
|
||||
names the **stored options blob** as authoritative, neither UI.
|
||||
|
||||
**OPT-09** makes the architecture's one free parameter real. `main.py` called
|
||||
`resolve()` without weights, so the split was a fixed 50/50 regardless of what
|
||||
either pack could deliver; it now passes SoC- and capacity-aware weights.
|
||||
|
||||
⚠️ **OPT-09 SHIPS A DISCLOSED DETECTION REGRESSION AT N=2.** `delivery.
|
||||
delinquent()` reads an ABSOLUTE 500 W gap, so weighting a low-headroom unit down
|
||||
to a small share puts a unit delivering **none of what it was asked** under the
|
||||
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.
|
||||
⚠️ **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
|
||||
behind it, which is why this section exists:
|
||||
|
||||
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
--deployed-version 0.8.0 --options /w/live-options.json
|
||||
|
||||
Exit `0` cleared, `1` REFUSED, `2` NOT CLEARED. ⚠️ **2 is not a pass.**
|
||||
|
||||
✅ **RUN AGAINST THIS RELEASE, 2026-09-07: CLEARED, exit 0.** The reference
|
||||
install's options were exported from the Supervisor (70 keys) and checked twice
|
||||
- the stored blob as-is, and the **effective post-upgrade config**, being those
|
||||
70 keys merged over 0.9.0's 71 defaults. Both pass. `max_w` 5000, `slew_w` 400
|
||||
against a rule-10 ceiling of 555.6 W.
|
||||
|
||||
⚠️ **THE STORED BLOB HAS 70 KEYS, NOT 71, AND THAT IS THE INTERESTING PART.**
|
||||
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running 0.7.0
|
||||
has never stored it - and it is REQUIRED in the schema, no trailing `?`, which
|
||||
is the shape that becomes exit 78. It resolves to its `options:` default of
|
||||
**185**, because the Supervisor sets `App.options = deepmerge(config.yaml
|
||||
defaults, stored options)`; `tools/supervisor_selfapi_probe.py` §D pins that
|
||||
against a real Supervisor image rather than assuming it. The second check above
|
||||
exists because "your saved settings are valid" and "what the box will actually
|
||||
run is valid" are different questions, and only the second one matters.
|
||||
|
||||
⚠️ **THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS** - PKG-06 six
|
||||
merges, PKG-07 six, this one five - and this time it was caught by PKG-09's own
|
||||
tool during PKG-09's review rather than by a person. `ha apps update` against an
|
||||
unchanged version installs nothing and reports success. PKG-10 carries the
|
||||
post-merge alarm so that noticing stops being a human's job.
|
||||
|
||||
## 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
|
||||
@@ -14,6 +587,107 @@ 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
|
||||
@@ -50,6 +724,47 @@ already declared, and the panel was already live.
|
||||
the Configuration tab **can** be removed - at the cost of all 71 options, which
|
||||
is why it stays. `DECISIONS/UX-02-two-config-surfaces.md` carries both.
|
||||
|
||||
⚠️ **UX-04 CHANGES NO BEHAVIOUR, NO SCHEMA AND NO DEFAULT.** It adds one check
|
||||
file, three assertions to a probe, and a decisions record; `config.yaml`,
|
||||
`translations/` and every module under `app/` are byte-identical to `6e7b233`.
|
||||
It closes out [[UX-01]], which shipped in **0.7.0 and is live**, with three
|
||||
criteria that were never met:
|
||||
|
||||
* **AC 3 - "the visible count drops to ~15" - is WITHDRAWN as unsatisfiable.**
|
||||
`options:` and `schema:` declare the same 71 keys, so the set HA's *show
|
||||
unused optional configuration options* toggle can hide is **empty**, and
|
||||
Supervisor's own `UiOptions` marks **59 of the 71 required** - the frontend
|
||||
shows those unconditionally, so 59 is the floor even with `options:` emptied
|
||||
altogether. The target was ~4x below what any manifest can reach. ⚠️ And the
|
||||
12 hides that *are* expressible would be bought with the deep-merge: a key
|
||||
absent from `options:` has no default for the Supervisor to merge into a
|
||||
0.7.0 box's stored blob, which is exactly the `delivery_hold_s` mechanism
|
||||
that makes this release's upgrade safe rather than exit 78. The count was
|
||||
**printed** by `tools/supervisor_shape_probe.py` §F from the day it was
|
||||
written and never graded, which is how this passed review; it is now three
|
||||
assertions, and removing one key from `options:` turns two of them red.
|
||||
* **AC 4's third clause - the AST check for stray in-code literal fallbacks -
|
||||
is BUILT**, as `test_defaults_pin.py`. It discovers the six
|
||||
`*_OPTION_DEFAULTS` mirrors from the tree rather than naming them, grades all
|
||||
30 mirrored keys against `config.yaml`, and freezes the CFG-03 backlog at
|
||||
exactly today's **27 (module, key) pairs** so it can only shrink. It found
|
||||
one previously unreported divergence: `main.py` answers a missing
|
||||
`target_grid_w` with **0** where `config.yaml` ships **-10** - unreachable on
|
||||
a Supervisor install, recorded rather than corrected. Its red direction runs
|
||||
on every invocation, and a literal planted in the real tree was shown red.
|
||||
* **AC 5 / the CFG-04 double-literal `max_w` hole is closed, by
|
||||
[[UX-02]]'s `bcee235` and not by UX-01's `fef6ef2`.** Re-measured on this
|
||||
branch: the `2000 -> 2500` triple mutation is **RED at `test_selfconfig.py`**,
|
||||
where it survived 21/21 green on 0.7.0. The attribution is corrected in
|
||||
`DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md`; nothing was
|
||||
re-done.
|
||||
|
||||
The 8 per-entry `inverters:` translations **stay**. They are shipped, validated
|
||||
and measured *not* to reach the Supervisor tab - `ha-selector-object` labels
|
||||
those fields `fields[name].label || name` and the converter sets no `label` -
|
||||
and UX-02's ingress panel is the surface that can render them. A written
|
||||
decision, not a change.
|
||||
|
||||
## 0.8.0
|
||||
|
||||
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
|
||||
@@ -206,6 +921,100 @@ the bound from 10 to 13. That ceiling change also removes a degenerate setting:
|
||||
the release bound, so **no accepted configuration can stop the block engaging**.
|
||||
At the old bound of 9 a setting of 9 or 10 deleted it silently.
|
||||
|
||||
⚠️ **THE SITE TARGET IS NO LONGER SPLIT 50/50 BETWEEN TWO PACKS ([[OPT-09]]) -
|
||||
AND THIS ADDS A PER-ENTRY OPTION, SO IT IS A SCHEMA CHANGE.**
|
||||
`capacity_kwh: float(0.1,200)?` joins the `inverters` roster as an **optional**
|
||||
per-entry key. Optional, where SAFETY-32 made `bms_soc_floor` required, and for
|
||||
that card's own reason: a missing cut-out would have to be *defaulted* and one
|
||||
default for two batteries protects the shallower pack with the deeper pack's
|
||||
number, whereas a missing capacity has an honest answer - no opinion about the
|
||||
split, every unit weighs 1.0, which is exactly what shipped. It is **all or
|
||||
nothing across the roster** and rule 9 refuses a partial one. There is no flat
|
||||
`capacity_kwh`: at one inverter there is no split to weight.
|
||||
|
||||
The allocator has had a `weights` argument since [[ALLOC-01]] and **nothing had
|
||||
ever passed one** - `main.py` called `resolve()` without it and `arbiter.py`
|
||||
defaulted it to 1.0 per unit - so the split was fixed 50/50 regardless of what
|
||||
either pack could do. Two things now feed it, and they are not the same kind of
|
||||
thing:
|
||||
|
||||
- **an ENVELOPE, which is a permission.** A new `pack` UNIT claim at `P_SAFETY`
|
||||
closes a unit's `hi` at 0 W when its own SoC is at or under its own
|
||||
`bms_soc_floor`, and its `lo` at 0 W at 100 %. The allocator obeys it
|
||||
absolutely, so an empty pack is not asked to discharge and its share moves to
|
||||
the sibling. ⚠️ **Not filed at all at a roster of one** - every source routes
|
||||
to the site there, so it would bound the SITE TARGET, and [[SAFETY-25]]'s
|
||||
Option A (no loop-side SoC floor) stands untouched on the shipped install.
|
||||
- **WEIGHTS, which are a preference.** Between those limits each pack is asked
|
||||
for the fraction matching its **energy** headroom in kWh - not its power
|
||||
rating. The two packs are asymmetric in opposite directions (5000 W /
|
||||
15.36 kWh against 4600 W / 20.48 kWh **from datasheet**), so the two ways of
|
||||
dividing hand the work to different packs.
|
||||
|
||||
⚠️ **AND WEIGHTS ARE A COMFORT CHOICE FOR STABILITY, NOT FOR DELIVERY.**
|
||||
[[ARCH-01]]'s convex combination guarantees a bad split cannot destabilise the
|
||||
site; it says nothing about whether the site can regulate. Measured (two 2000 W
|
||||
units, one at its floor, modelled plant, real `compute()`): at a 3000 W load the
|
||||
weighted arm and the shipped arm command the **identical** +2000 W into the empty
|
||||
pack and deliver the **identical** 2000 W, because `_fill()`'s last-resort pass
|
||||
hands the remainder to a zero-weighted unit rather than leave watts unplaced.
|
||||
Closing the envelope does not recover those watts either - it reports them:
|
||||
`unmet_w` becomes +2000 W and the action log says so, where before the record
|
||||
read impeccable. A unit that cannot deliver still parks the site short by its
|
||||
share, and that is now visible instead of silent.
|
||||
|
||||
Also: `arbitrage.decide()` is sized per pack rather than at the site rating on
|
||||
both sides, so a two-pack site cannot ask to buy 9600 W with one pack able to
|
||||
absorb it; and a 0 W sizing is refused by name, because a 0 W arbitrage claim is
|
||||
a park order that outranks the loop. ⚠️ The sell side is a **no-op on every
|
||||
roster today** - the sell gate reads the emptiest pack, so it refuses outright in
|
||||
exactly the states where the sum could be smaller - and moving that gate is
|
||||
deliberately not this card's; `DECISIONS/OPT-09-soc-aware-weights.md` §6 says
|
||||
why.
|
||||
|
||||
⚠️ **[[SAFETY-42]]'s end-to-end fixture moved, and its finding did not.** That
|
||||
card's evidence was that a wedged unit and a pack at its floor are byte-identical
|
||||
in the power domain. After this card the cold pack is no longer *asked*, so the
|
||||
shares differ - but the two quantities SAFETY-42's rejected discriminator reads
|
||||
(saturation persistence and |grid error|) are still inseparable on both arms, so
|
||||
the conclusion is unchanged and is now stated on the predicate's own inputs.
|
||||
`test_delivery.py` part 4 carries the whole argument. The two cards meet at one
|
||||
hazard from opposite sides: **OPT-09 stops the site ASKING a pack whose SoC says
|
||||
it cannot deliver; SAFETY-42 alarms when a pack that said it could does not.**
|
||||
|
||||
⚠️ **AND THIS CARD NARROWS SAFETY-42's DETECTION WINDOW. IT IS A DETECTION
|
||||
REGRESSION AT TWO INVERTERS AND IT IS DISCLOSED RATHER THAN FIXED.** An earlier
|
||||
draft of this entry and of `DECISIONS/OPT-09-soc-aware-weights.md` §5 said a
|
||||
wedged unit's weight does not move, so OPT-09 could not see the case at all. The
|
||||
ENVELOPE half is true - a wedged box reads a healthy SoC and nothing closes. The
|
||||
WEIGHT half was **false**: a weight is a function of a 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. And `delivery.delinquent()` names a unit
|
||||
only while its shortfall exceeds `saturation_w` - **500 W in absolute watts** -
|
||||
so shrinking the share shrinks the gap below the threshold and the alarm stops
|
||||
firing. Measured end to end with the real loop, the real allocator and the real
|
||||
detector, on a unit wedged at a healthy 15 % against its own 10 % floor (a
|
||||
SAFETY-42 true positive by that card's own definition): at a 1500 W load the
|
||||
healthy pack can carry alone, equal weights name it on **199 of 200** cycles and
|
||||
these weights name it on **none of the 200** - shares 1505/125, gap 125 W, and
|
||||
125 W is under the threshold. The suppression window widens from a site target
|
||||
below 1000 W to a site target below `saturation_w * (w_A + w_B) / w_B`, which on
|
||||
that fixture is **6500 W of a 9600 W site**; above it the healthy pack hits its
|
||||
own rating, the allocator's last-resort pass hands the wedged unit a big share
|
||||
again and the alarm returns. So in the quiet regime - where `unmet_w` is 0, the
|
||||
meter is satisfied and no action-log line fires - **a wedged inverter with less
|
||||
energy headroom than its sibling can now go unreported at ordinary house
|
||||
loads.**
|
||||
|
||||
⚠️ 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 it and pins the CURRENT behaviour, so
|
||||
the day it changes a named check moves. Closing it means making `delinquent()`
|
||||
relative to the ask rather than absolute, which is SAFETY-42's code and a design
|
||||
change; that is filed as its own card and is deliberately not done here.
|
||||
⚠️ Every two-unit figure above is **two copies of inverter A** through a modelled
|
||||
plant - inverter B does not exist yet - and B's ratings are datasheet and serial.
|
||||
|
||||
⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME
|
||||
([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.**
|
||||
`delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,334 @@
|
||||
# OPT-09 - the weights parameter, and what it is honestly for
|
||||
|
||||
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3d29993` (rebased onto the UX-02 merge; it read `3368231`, and before that `464d398` - each rebase moved it and review caught it each time, which is why the counts in `test_translations.py` are now computed rather than typed)
|
||||
(`git merge-base HEAD 3368231`; the branch was rebased and this line used to name
|
||||
the pre-rebase base, `464d398`).
|
||||
|
||||
## 0. What was actually wrong
|
||||
|
||||
`allocator.plan()` has taken a `weights` argument since ALLOC-01, `_fill()`
|
||||
consumes it in two passes, and **nothing had ever passed one**. Confirmed on the
|
||||
base commit:
|
||||
|
||||
arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...)
|
||||
main.py:2422 resolve(claims, units=self.units, live=self.live_units(), ...)
|
||||
|
||||
So `weights` was None on every tick of every install, `plan()` turned that into
|
||||
1.0 per unit, and the site target was split **50/50 regardless of what either
|
||||
pack could do**. The card was filed as "per-pack arbitrage sizing"; the architect
|
||||
pass found the sharper content, and it is the sharper content that this branch is
|
||||
mostly about.
|
||||
|
||||
## 1. Two instruments, and they are not interchangeable
|
||||
|
||||
`allocator.py` states the rule this card is built on, and it is mechanical rather
|
||||
than advisory:
|
||||
|
||||
> WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
|
||||
> "please do not use this unit", and it is honoured while any other unit has
|
||||
> headroom - but it is NOT a constraint.
|
||||
|
||||
`_fill()` runs a deliberate **last-resort pass** over the zero-weight units when
|
||||
the preferred ones saturate, so that a preference can never manufacture a
|
||||
standing error at the meter. A pack at its own cut-out that is only *weighted*
|
||||
to zero is therefore still commanded to discharge the moment its sibling fills
|
||||
up. So this branch ships both:
|
||||
|
||||
| | question | mechanism |
|
||||
|---|---|---|
|
||||
| `packshare.envelope()` | MAY this pack be asked, at all, in this direction? | a `pack` UNIT claim at `P_SAFETY`, `hi = 0` at the cut-out, `lo = 0` at 100 % |
|
||||
| `packshare.weights()` | GIVEN that both may, which should carry more? | energy headroom in kWh, handed to `resolve()` |
|
||||
|
||||
## 2. ⚠️ What the weights CANNOT do
|
||||
|
||||
ARCH-01's convex-combination argument - the gain at the meter is
|
||||
`sum(a_i * g_i)`, so it lies in `[min g_i, max g_i]` for **any** split -
|
||||
guarantees that a bad split cannot **destabilise** the site. It says nothing
|
||||
about whether the site can **regulate**. A unit at its energy limit has
|
||||
`g_i = 0`; the combination is still inside the interval and the load it was
|
||||
asked to carry simply does not move.
|
||||
|
||||
Measured, `test_packshare.py` part 8, two units rated 2000 W each, B sitting on
|
||||
its own floor and therefore answering 0 W to any discharge command. The plant is
|
||||
modelled in that file (`lag_s = 0`, obeys within one cycle, bounded by its
|
||||
rating) and the loop is the real `compute()`:
|
||||
|
||||
| load | arm | site target | shares | grid | unmet |
|
||||
|---|---|---|---|---|---|
|
||||
| 1500 W | shipped | +3000 W | A +1500, B +1500 | 0 W | 0 W |
|
||||
| 1500 W | weights | +1500 W | A +1500, B +0 | 0 W | 0 W |
|
||||
| 1500 W | envelope | +1500 W | A +1500, B +0 | 0 W | 0 W |
|
||||
| 3000 W | shipped | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
|
||||
| 3000 W | weights | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
|
||||
| 3000 W | envelope | +4000 W | A +2000, B **+0** | +1000 W | **+2000 W** |
|
||||
|
||||
Three things are worth reading off that table, and the second and third are the
|
||||
uncomfortable ones:
|
||||
|
||||
1. At a load the healthy pack can cover, the weights alone deliver the whole
|
||||
improvement. The shipped arm reaches the same meter reading only by winding
|
||||
the site target to **twice the load** and standing a +1500 W command on a
|
||||
battery that answers nothing, permanently saturation-frozen. That is not a
|
||||
working site, it is a site with no headroom left for the next load step.
|
||||
2. At a load it cannot cover, **the weights stop working entirely**. The
|
||||
weighted arm and the shipped arm command the identical +2000 W into the empty
|
||||
pack, because `_fill()`'s last-resort pass hands the remainder to the
|
||||
zero-weighted unit. Same shares, same watts, same error.
|
||||
3. **And the envelope does not rescue the delivery either - it names the
|
||||
shortfall.** Same 2000 W delivered, same 1000 W standing at the meter. What
|
||||
changes is that the empty pack is commanded 0 W instead of 2000 W, and 2000 W
|
||||
of site target is *reported* as unplaceable instead of appearing to have been
|
||||
placed.
|
||||
|
||||
> **Weights are a comfort choice for STABILITY and NOT for DELIVERY.** Nothing in
|
||||
> this add-on shifts a unit's load to its sibling once the sibling is at its
|
||||
> rating, and weighting a failing unit down is not a remedy for it.
|
||||
|
||||
## 2b. ⚠️ The card's own premise is only half true
|
||||
|
||||
The card opens with *"a unit that stops delivering its share (empty, full,
|
||||
curtailed, wedged) parks the site with a standing grid error and no alarm."*
|
||||
After this branch: the **ask** stops, and while the sibling has headroom the site
|
||||
regulates to zero error instead of winding to twice the load. The **standing grid
|
||||
error does not stop** once the sibling is at its rating - §2's table measures
|
||||
1000 W standing in all three arms - and there is **still no alarm** for it, only
|
||||
a report. SAFETY-42 will not alarm on a pack at its own floor; that is one of its
|
||||
acceptance criteria and its evidence is that the benign and the wedged case are
|
||||
indistinguishable in the power domain.
|
||||
|
||||
So the two cards together mean: an empty pack limiting the site is now REPORTED
|
||||
(`unmet_w`, an action-log line) and still, deliberately, NOT ALARMED. Whether it
|
||||
should be alarmed is a decision - and the alarm it would need is about a site's
|
||||
stored ENERGY, not about an inverter's delivery, so it is not this card's and not
|
||||
SAFETY-42's. Recorded rather than quietly closed against a premise this card only
|
||||
half meets.
|
||||
|
||||
## 3. Which floor, and which ceiling - the two places this could have become policy
|
||||
|
||||
**The floor is `bms_soc_floor` alone, never `peakguard.pack_floor_pct()`.** That
|
||||
function's conjunction with `peak_soc_floor` is right for the peak guard and
|
||||
wrong here: SAFETY-25 decided (2026-09-02, Option A) that `peak_soc_floor` bounds
|
||||
the peak guard **only**, ordinary self-consumption discharges straight past it,
|
||||
and closing an envelope at it would turn an operator preference into a hard stop
|
||||
on the grid-following loop - the memo's **deferred Option B**, built by accident.
|
||||
`delivery.py`'s `account()` made the same choice one step out, for the same
|
||||
reason. Graded: a pack at 15 % with a 10 % cut-out is not closed, though a 20 %
|
||||
EMS floor would have closed it.
|
||||
|
||||
**The ceiling is 100 %, and the coarseness is the honest answer.** There is no
|
||||
per-pack charge ceiling anywhere in this add-on - `delivery.py` says so at length
|
||||
and calls it a card, and it is why SAFETY-42 is armed on discharge shares only.
|
||||
The only other number available is `arbitrage_soc_ceiling`, which is one
|
||||
site-wide **trade policy**; filing it as a `P_SAFETY` envelope on the loop would
|
||||
be the mistake above in the charge direction. So the envelope closes only where
|
||||
the ask is physically impossible, and everything between is left to the weights -
|
||||
which is the right instrument for a graded preference and is honest about being
|
||||
only a preference.
|
||||
|
||||
## 4. N=1, and the tripwire this had to argue past
|
||||
|
||||
`actuator_for()` routes **every** source to the site at one inverter, so a `pack`
|
||||
interval filed there would bound the SITE TARGET rather than a share. `main.py`
|
||||
therefore does not file it at all below a roster of two - the same guard, for the
|
||||
same reason, that SAFETY-30's `sign_site` carries. AC 4 ("at N=1 allocation is
|
||||
byte-identical") is that `if`.
|
||||
|
||||
`test_peakguard.py` part 10f pins the claim-source set against `main.py`'s own
|
||||
AST precisely so that a state-of-charge-conditioned claim cannot arrive quietly.
|
||||
`pack` is state-of-charge-conditioned, so the argument is written at that set
|
||||
rather than the count edited: the number is not an option, the claim is
|
||||
unit-scoped so it cannot bound the site target, it is not filed at N=1, and part
|
||||
10f's own note names this as the revisit trigger - *"under ALLOC-01 the reserve
|
||||
becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a ninth claim
|
||||
here"*. Stage 1 is how an interval reaches the allocator and stage 1's input is
|
||||
claims, so a unit-scoped claim is the shape that sentence asks for.
|
||||
|
||||
**What N=1 still does not get, said plainly:** nothing stops the loop discharging
|
||||
into the BMS cut-out at one inverter. That is unchanged, it is SAFETY-25's
|
||||
deferred option, and the BMS is what stops it.
|
||||
|
||||
## 5. SAFETY-42 - the same hazard from the opposite side
|
||||
|
||||
The seam is **the state of charge** - and they overlap in one direction, which
|
||||
is what the rest of this section is about:
|
||||
|
||||
* **OPT-09 stops the site ASKING** a pack whose own SoC says it cannot deliver.
|
||||
* **SAFETY-42 alarms** when a pack that said it could does not.
|
||||
|
||||
A wedged unit reads a healthy SoC, so **no envelope closes** - that half is
|
||||
absolute, and it is why this card cannot stop the ask on a wedged box. A pack at
|
||||
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 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()`
|
||||
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.
|
||||
|
||||
Measured on the closed loop - real `compute()`, real `plan()`, real
|
||||
`delinquent()`, modelled plant, B wedged at 15 % against its own 10 % floor so
|
||||
nothing closes and `account()` explains nothing, i.e. a SAFETY-42 **true
|
||||
positive** by that card's own definition:
|
||||
|
||||
load 1500 W equal weights (shipped) named on 199/200 cycles
|
||||
shares {a:1500, b:1500} grid +0 W named=['inverter-b']
|
||||
load 1500 W OPT-09 weights named on 0/200 cycles
|
||||
shares {a:1505, b: 125} grid -5 W named=(nobody)
|
||||
|
||||
⚠️ That plant is modelled in `test_packshare.py`, and **inverter B does not
|
||||
exist** (expected ~2026-09-12): it is two copies of A driven with different
|
||||
numbers, and B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the
|
||||
serial. The claim is about the arithmetic of the split and the threshold, which
|
||||
needs no hardware, and not about a GW5000S-BP.
|
||||
|
||||
At a load the healthy pack can cover - the regime where nothing else tells the
|
||||
operator anything, `unmet_w` is 0, the meter is satisfied and no action-log line
|
||||
fires - a wedged inverter goes from named on essentially every cycle to **never
|
||||
named**. The suppression window widens from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B`, which on that fixture is **6500 W** of
|
||||
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 graded all of it and **pinned the unflattering
|
||||
behaviour**, so that the day it changed a named check would move. It has moved.
|
||||
|
||||
### ⚠️ 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
|
||||
re-allocation this add-on can express is ALL OR NOTHING [...] a partial
|
||||
re-allocation is what would make acting safe, and the allocator has no way to
|
||||
express it today"*. Passing weights **is** that expression. Wiring delinquency
|
||||
into them is deliberately **not** done here: it would be acting on the alarm,
|
||||
which SAFETY-42 refused with rig evidence (a unit derated to 50 % is 400 W worse
|
||||
dropped than kept, and no instrument this add-on has can tell it from a wedged
|
||||
one), and - see §2 - a weight cannot stop a unit being commanded anyway. That is
|
||||
a card, and this paragraph is its input.
|
||||
|
||||
## 6. Per-pack arbitrage sizing, and the half that is deliberately not moved
|
||||
|
||||
`arbitrage.decide()` was handed `site_max_w` on **both** sides, so a two-pack
|
||||
site would buy at the sum of the ratings whatever either pack's state of charge
|
||||
was. It is now sized to the packs whose own SoC permits that direction
|
||||
(`Controller.trade_power_w()`), using **arbitrage's own** bounds rather than the
|
||||
BMS cut-out - sizing a trade is the trade's business; permitting a command is the
|
||||
pack's.
|
||||
|
||||
Two honest limits:
|
||||
|
||||
* **The sell side is a no-op on every roster today.** `decide()` is handed
|
||||
`pack_floor_soc` - the emptiest pack - so a sell is refused outright whenever
|
||||
*any* pack is at or under the floor, i.e. in exactly the states where the sum
|
||||
could be smaller than the site rating. The arithmetic is graded directly
|
||||
(`test_packshare.py` part 9); it becomes reachable the day that gate is
|
||||
per-pack.
|
||||
* **Moving that gate is not this card's.** Sizing a sell off the *fullest* pack
|
||||
would let the trade proceed while the emptiest pack was below
|
||||
`arbitrage_soc_floor`, and the only per-pack instrument that could keep it out
|
||||
of that pack is the weights - which are a preference, and `_fill()` will hand
|
||||
it watts anyway. Doing it safely needs an envelope that depends on WHICH CLAIM
|
||||
WON the tick, which the arbiter's partition does not express. §2 is why that is
|
||||
a real objection and not a hedge.
|
||||
|
||||
`arbitrage.decide()` also now refuses a **0 W sizing** by name (`IDLE_NO_SIZE`),
|
||||
because a `Claim.set(..., 0.0)` at `P_ARBITRAGE` is a park order that outranks
|
||||
the loop - the failure the note on `claim()` is already written against. The
|
||||
sizing can never be zero on a tick that trades today; that is a coincidence of
|
||||
the existing gate, and this makes it structural instead.
|
||||
|
||||
## 7. The fixture, which is the hard part
|
||||
|
||||
Two packs that differ in only ONE of SoC and capacity cannot separate a
|
||||
SoC-aware weighting from a capacity-aware one; two identical packs cannot
|
||||
separate ANY weighting from any other. `test_packshare.py` part 1 grades the
|
||||
fixture itself before anything is asserted with it - four candidate schemes
|
||||
(equal, power rating, SoC only, energy), four distinct splits of the same
|
||||
1000 W:
|
||||
|
||||
equal A +500, B +500
|
||||
power A +521, B +479
|
||||
soc only A +125, B +875
|
||||
energy A +97, B +903
|
||||
|
||||
and the counterfactuals: on two identical packs all four collapse to one answer;
|
||||
at the same SoC, `soc only` collapses onto `equal`; at the same capacity,
|
||||
`energy` collapses onto `soc only`.
|
||||
|
||||
## 8. Provenance of every number
|
||||
|
||||
* A: **5000 W, 15.36 kWh, 10 % floor** - measured (GW5000S-BP, register 0x0560).
|
||||
* B: **4600 W, 20.48 kWh, 10 % floor** - **FROM DATASHEET AND SERIAL**
|
||||
(`goodwe/NOTES.md` §1.1b, GW5048D-ES `55048ESU22AW0992`). ⚠️ **Inverter B does
|
||||
not exist** (expected ~2026-09-12).
|
||||
* Every two-unit run in `test_packshare.py` is arithmetic over intervals and
|
||||
weights, or a plant modelled in that file. `sim/plantsim.py:216`'s `INVERTER_B`
|
||||
is **inverter A copied** with `measured=False`, so any two-inverter simulation
|
||||
result anywhere in this project is **two copies of A**.
|
||||
* No rig was taken, no hardware was touched, and nothing here is evidence about
|
||||
a second GoodWe inverter.
|
||||
|
||||
## 9. Config
|
||||
|
||||
`capacity_kwh` joins the `inverters` roster as an **optional** per-entry key
|
||||
(`float(0.1,200)?`) - the opposite choice to SAFETY-32's required
|
||||
`bms_soc_floor`, and for that card's own reason: a missing cut-out would have to
|
||||
be *defaulted*, and one default for two batteries protects the shallower pack
|
||||
with the deeper pack's number, whereas a missing capacity has an honest answer -
|
||||
no opinion about the split, every unit weighs 1.0, which is exactly what shipped.
|
||||
|
||||
It is **all or nothing across the roster** and rule 9 refuses a partial one: a
|
||||
pack measured in kWh beside a sibling weighing a dimensionless 1.0 is not a
|
||||
comparison, `weights()` answers "no opinion" for the whole roster in that case,
|
||||
and a key that silently changes nothing is worse than one that refuses to load.
|
||||
|
||||
There is deliberately **no flat `capacity_kwh`**: at one inverter there is no
|
||||
split to weight, so it would be an option an operator can set and nothing reads.
|
||||
@@ -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,379 @@
|
||||
# UX-04 — what the Configuration tab renders, and the pin that was never built
|
||||
|
||||
**Status: DECIDED 2026-09-07.** Three decisions and one confirmation, in the
|
||||
order [[UX-01]]'s unmet acceptance criteria are numbered:
|
||||
|
||||
1. **UX-01 AC 3 (hide the optional fields behind HA's toggle) is WITHDRAWN as
|
||||
unsatisfiable**, not deferred. The target was arithmetically unreachable, and
|
||||
the part of it that *was* reachable would be bought with the upgrade path.
|
||||
The measurement is now **graded** in `tools/supervisor_shape_probe.py` §F
|
||||
rather than printed, so the withdrawal cannot be quietly reversed.
|
||||
2. **UX-01 AC 4's third clause is now BUILT**: `test_defaults_pin.py`, an AST
|
||||
check, with its red direction executed on every run.
|
||||
3. **UX-01 AC 5 / the CFG-04 double-literal hole is CONFIRMED CLOSED on
|
||||
`release/1.0`, by [[UX-02]]'s `bcee235`, not by UX-01's `fef6ef2`.** The
|
||||
attribution is corrected here; no work was re-done.
|
||||
4. **The 8 per-entry `inverters:` translations STAY.** They do not reach the
|
||||
Supervisor tab and cannot be made to; [[UX-02]]'s ingress panel is where they
|
||||
become visible.
|
||||
|
||||
Filed with UX-04. Card `6a9ec94617e9c48ed74ca5dd`. Every number below was
|
||||
measured on `release/1.0` at `6e7b233` on 2026-09-07, against
|
||||
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` — the version
|
||||
`ha info` reports on the live install — and against the 26-file check suite in
|
||||
`goodwe-addon-checks:1`. Nothing here contacted the live Supervisor at
|
||||
192.168.2.158, posted options, or restarted anything.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hiding: withdrawn, and why that is the *safe* answer
|
||||
|
||||
### 1.1 What AC 3 claimed
|
||||
|
||||
> "The visible count drops to ~15, with the rest behind HA's *show unused
|
||||
> optional configuration options* toggle."
|
||||
|
||||
### 1.2 What the tab actually renders
|
||||
|
||||
`config.yaml` declares **71 keys under `options:` and the same 71 under
|
||||
`schema:`**. The set that HA's toggle is *able* to hide — schema-only keys — is
|
||||
therefore **empty**:
|
||||
|
||||
```
|
||||
hideable = set(schema) - set(options) -> [] (0 of 71)
|
||||
```
|
||||
|
||||
On the deployed 0.7.0 tree the same measurement is 70/70. Nothing was hidden
|
||||
before UX-01, nothing is hidden after it, and no line of `fef6ef2` moved a key
|
||||
either way. AC 3 is not partially met; it is unstarted.
|
||||
|
||||
### 1.3 The rule that decides it, replayed rather than paraphrased
|
||||
|
||||
From the frontend bundle shipped **in this image**,
|
||||
`api/panel/frontend_latest/5680.*.js` (frontend 20250925.1):
|
||||
|
||||
```js
|
||||
_filteredSchema = (opts, schema) => schema.filter(e => e.name in opts || e.required)
|
||||
```
|
||||
|
||||
`opts` is the add-on's options **as Supervisor serves them** — the operator's
|
||||
stored blob after `options:` has been deep-merged into it. So a field is hidden
|
||||
only when it is **both** absent from `options:` **and** not `required`. The
|
||||
probe now runs that filter on this manifest rather than quoting it.
|
||||
|
||||
### 1.4 Reason one: the target was never reachable
|
||||
|
||||
Supervisor's own `UiOptions` marks `required` on **59** of the 71 fields; 12 are
|
||||
optional. The filter above shows a required field unconditionally. So the
|
||||
**floor** — what the tab renders with `options:` emptied *altogether*, which is
|
||||
the most hiding that is even expressible — is 59 fields.
|
||||
|
||||
| `options:` block | fields rendered |
|
||||
|---|---|
|
||||
| as shipped (71 keys) | **71** |
|
||||
| emptied entirely (0 keys) | **59** |
|
||||
| AC 3's target | ~15 |
|
||||
|
||||
AC 3 asked for a number roughly four times below the floor. No arrangement of
|
||||
`options:` reaches it. The criterion could not have been met by any
|
||||
implementation; it was mis-specified, and the honest disposal of a
|
||||
mis-specified criterion is withdrawal, not a partial gesture toward it.
|
||||
|
||||
### 1.5 Reason two: the 12 hides that *are* expressible cost the deep-merge
|
||||
|
||||
Moving a key out of `options:` is not free. `App.options` is
|
||||
`deepmerge(config.yaml defaults, the stored user options)`, so **a key absent
|
||||
from `options:` has no default for the Supervisor to merge**.
|
||||
|
||||
That is exactly the mechanism the 0.9.0 upgrade rests on. From `CHANGELOG.md`,
|
||||
the pre-deploy check run against this release on 2026-09-07:
|
||||
|
||||
> The reference install's stored blob has **70 keys, not 71**.
|
||||
> `delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running
|
||||
> 0.7.0 has never stored it — and it is REQUIRED in the schema, no trailing
|
||||
> `?`, which is the shape that becomes **exit 78**. It resolves to its
|
||||
> `options:` default of **185** because the Supervisor deep-merges.
|
||||
|
||||
Hiding a key is done by taking it *out* of `options:` and marking it `?`. That
|
||||
is, precisely, the edit that removes a key's merge default — the thing standing
|
||||
between a 0.7.0 box and exit 78 on upgrade. It would be traded for at most 12
|
||||
fewer rows on a form, on a site where the add-on is holding a real house's grid
|
||||
exchange at zero.
|
||||
|
||||
### 1.6 Reason three: it would promote 27 unpinned literals to load-bearing
|
||||
|
||||
And the cost compounds with §2 of this document. `test_defaults_pin.py` part 4
|
||||
ledgers **27 (module, key) pairs** in `app/` that still answer a missing option
|
||||
with an in-code literal. Today those literals are unreachable on a Supervisor
|
||||
install, because the deep-merge guarantees every `options:` key is present in
|
||||
the served blob. Remove keys from `options:` to hide them and those literals
|
||||
stop being dead code: they become **the actual source of the defaults on a live
|
||||
install** — the CFG-03 defect the same card's AC 4 exists to police, made
|
||||
load-bearing by the card's own AC 3.
|
||||
|
||||
### 1.7 What now grades this
|
||||
|
||||
`tools/supervisor_shape_probe.py` §F. The count was printed there from the day
|
||||
it was written — *"the Configuration tab renders 71 fields"* — and the probe
|
||||
exited 0 through UX-01's whole review while displaying the number that
|
||||
contradicted the criterion it was evidence for. **A measurement that is
|
||||
displayed but not asserted is how this passed review.** Three assertions
|
||||
replace the bare print:
|
||||
|
||||
```
|
||||
PASS every one of the 71 `schema:` keys is ALSO in `options:`, so schema-only
|
||||
- the only set HA's toggle can hide - is EMPTY. Hideable: []
|
||||
PASS ...so replaying the frontend's own _filteredSchema on the served options
|
||||
hides NOTHING: 71 of 71 fields render
|
||||
PASS ...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is
|
||||
expressible - the tab still renders 59 required fields. THAT is the
|
||||
floor, and AC 3 asked for ~15.
|
||||
```
|
||||
|
||||
Every number in them is computed from the manifest and from Supervisor's own
|
||||
`required` flag; none is typed. **The red direction was run**: deleting one key
|
||||
(`price_feed_url`) from `options:` and nothing else turns the first two to FAIL
|
||||
— `Hideable: ['price_feed_url']`, `70 of 71 fields render` — and the probe
|
||||
exits 1. The counterfactual is isolated: one key varied, nothing else.
|
||||
|
||||
So if a future editor empties `options:` to shorten the form, this goes red and
|
||||
somebody has to read §1.5 before merging.
|
||||
|
||||
---
|
||||
|
||||
## 2. The defaults pin: `test_defaults_pin.py`
|
||||
|
||||
### 2.1 What AC 4 asked for and what shipped
|
||||
|
||||
AC 4 has three clauses.
|
||||
|
||||
| clause | state before UX-04 |
|
||||
|---|---|
|
||||
| "every `schema:` key absent from `options:` has exactly one defaults-table entry" | **vacuous** — that set is empty (§1.2), so the clause is true of any tree, including one with no defaults at all |
|
||||
| "the table changed → red" | **works**, six times over, once per mirror in its own suite |
|
||||
| "no module carries its own literal fallback for a schema default — an AST check, not a grep" | **never built** |
|
||||
|
||||
The only literal scan in the repository before this card is
|
||||
`test_validate.py:1025`: a `re.findall` restricted to eight `maintenance_*`
|
||||
keys across three named files. It is a grep, it predates UX-01, and it was not
|
||||
written for this.
|
||||
|
||||
### 2.2 What an "in-code literal fallback" is, structurally
|
||||
|
||||
A call in `app/` taking a string literal that names a `schema:` key, with a
|
||||
constant in the next positional slot:
|
||||
|
||||
```python
|
||||
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
|
||||
```
|
||||
|
||||
Found by walking `ast.Call` nodes — so a multi-line call and a renamed `opts`
|
||||
are found, and the same text inside a comment or docstring is not. Two
|
||||
exclusions, both structural and both argued at the site:
|
||||
|
||||
* **`getattr(obj, "max_w", 0.0)`** reads an attribute off an object, not an
|
||||
option off a blob. Excluded by callee name.
|
||||
* **the empty string.** `opts.get("static_profile", "")` is Python's "not
|
||||
stated", and all six such sites in `validate.py` normalise it immediately —
|
||||
`str(opts.get(k, "") or "").strip()`. It cannot be a stale copy of a *number*,
|
||||
which is the whole of the CFG-04 defect class. Excluded by value. **`0` is
|
||||
not excluded**: `slew_w: 0` and `integrator_max_w: 0` are real shipped
|
||||
defaults with real meanings.
|
||||
|
||||
### 2.3 What it grades, measured on `release/1.0` @ `6e7b233`
|
||||
|
||||
```
|
||||
2. the mirrors, DISCOVERED from the tree rather than listed here
|
||||
6 table(s): circulation.py:CIRC_OPTION_DEFAULTS, delivery.py:DELIVERY_OPTION_DEFAULTS,
|
||||
maintenance.py:MAINT_OPTION_DEFAULTS, peakguard.py:PEAK_OPTION_DEFAULTS,
|
||||
price.py:PRICE_OPTION_DEFAULTS, watchdog.py:SIGN_OPTION_DEFAULTS
|
||||
30 mirrored key(s) of 71 in `schema:`
|
||||
3. the AST scan
|
||||
scanned 29 modules, 34 literal fallback site(s): 0 over mirrored keys,
|
||||
34 over unmirrored ones
|
||||
4. the CFG-03 ledger
|
||||
27 (module, key) pair(s) carry a literal for a key no mirror answers for
|
||||
```
|
||||
|
||||
Four properties, none of them vacuous:
|
||||
|
||||
1. **The mirrors are discovered, not listed.** Six suites already pin their own
|
||||
table to `config.yaml` — and every one of those pins names its table by
|
||||
hand, so a *seventh* table added tomorrow would be pinned by nobody and
|
||||
nothing would say so. This walks `app/` for module-level `*_OPTION_DEFAULTS`
|
||||
assignments and grades whatever it finds against `config.yaml`. That is the
|
||||
property the six hand-written pins cannot have.
|
||||
2. **No literal survives for a key a mirror already answers for.** 30 keys,
|
||||
0 violations.
|
||||
3. **The CFG-03 ledger is FROZEN, by (module, key) pair, not by count.** A
|
||||
count goes green again the moment somebody deletes one site and adds
|
||||
another. Line numbers are deliberately *not* in the key: a ledger that goes
|
||||
red on an unrelated reformat is a ledger people learn to re-bless without
|
||||
reading. The 27 pairs are the open CFG-03 backlog; they are **not** removed
|
||||
here, because that is behaviour change across `main.py`, `p1.py`,
|
||||
`inverter.py` and `validate.py` that no single card should make in passing.
|
||||
The set can now only shrink.
|
||||
4. **Every remaining literal is graded against `config.yaml`** — the half that
|
||||
catches CFG-04's actual failure mode, two copies of one number and one moves.
|
||||
|
||||
### 2.4 One divergence found, and it is new
|
||||
|
||||
```
|
||||
main.py target_grid_w: code 0 vs config.yaml -10
|
||||
```
|
||||
|
||||
`main.py:290` reads `target_grid_w=float(opts.get("target_grid_w", 0))` while
|
||||
`config.yaml` ships **-10**. A dict handed to `Controller` without the key holds
|
||||
net exchange at 0 W instead of the shipped -10 W bias into import. **It cannot
|
||||
reach an operator**: on a Supervisor install the deep-merge guarantees the key
|
||||
is present, and the only paths that hand over a partial dict are
|
||||
`sim/inject.py` and this repository's own fixtures. It is recorded in the check
|
||||
as the single permitted divergence, with the reason, rather than corrected —
|
||||
changing it alters the control loop's target and belongs to a card that grades
|
||||
the loop. A *second* divergence goes red.
|
||||
|
||||
### 2.5 ⭐ The red direction, shown rather than claimed
|
||||
|
||||
A check that has never been shown failing is not a check — which is exactly
|
||||
what clause one of this AC was. Two things follow from that:
|
||||
|
||||
**(a) The red direction runs on every invocation.** Part 5 feeds the scanner a
|
||||
synthetic module containing a deliberate violation of each rule and *requires*
|
||||
it to be flagged, in-process, through the same `literal_fallbacks()` the graded
|
||||
parts call. A scanner that goes blind — a renamed helper, an `ast` change, a
|
||||
`SCHEMA` that comes back empty — stops working here too, and the total site
|
||||
count is additionally graded against a floor.
|
||||
|
||||
**(b) A literal was planted in the real tree and the check went red.** Planted
|
||||
at `main.py:290`, `float(opts.get("maintenance_charge_w", 2000))` — a mirrored
|
||||
key:
|
||||
|
||||
```
|
||||
3. the AST scan: no module carries its own literal fallback for a MIRRORED schema default
|
||||
scanned 29 modules, 35 literal fallback site(s): 1 over mirrored keys, 34 over unmirrored ones
|
||||
ok the scanner is not blind: it found 35 sites (floor 20)
|
||||
main.py:290 get('maintenance_charge_w', 2000)
|
||||
FAIL no literal fallback survives for any of the 30 keys a mirror already
|
||||
answers for - those sites must index the mirror
|
||||
|
||||
1 FAILED (exit 1)
|
||||
```
|
||||
|
||||
And part 4's two directions, planted the same way with
|
||||
`opts.get("p1_host", "x")` and `opts.get("slew_w", 999)`:
|
||||
|
||||
```
|
||||
4. the CFG-03 ledger
|
||||
28 (module, key) pair(s) carry a literal for a key no mirror answers for
|
||||
FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')]
|
||||
main.py p1_host: code 'x' vs config.yaml ''
|
||||
main.py slew_w: code 999 vs config.yaml 0
|
||||
main.py target_grid_w: code 0 vs config.yaml -10
|
||||
FAIL every remaining literal still AGREES with config.yaml, except the 1
|
||||
divergence(s) recorded above it
|
||||
|
||||
(exit 1)
|
||||
```
|
||||
|
||||
The second of those is the CFG-04 defect class itself: a literal that has
|
||||
drifted away from `config.yaml` while the file still says something else. Both
|
||||
plants were removed; the tree is byte-identical to `6e7b233` apart from this
|
||||
card's own files.
|
||||
|
||||
---
|
||||
|
||||
## 3. CFG-04: closed on `release/1.0`, by UX-02, not by UX-01
|
||||
|
||||
UX-01's AC 5 claimed the double-literal `max_w` hole. It did not close it.
|
||||
`fef6ef2` touched `config.yaml`, `translations/`, `test_translations.py` and the
|
||||
probe — **no defaults code at all** — and on the 0.7.0 tree the triple mutation
|
||||
survives: 21 files, 0 RED.
|
||||
|
||||
**Confirmed on current `release/1.0` @ `6e7b233`, 2026-09-07.** The same triple
|
||||
mutation, `2000 -> 2500` at the three descendants of 0.7.0's
|
||||
`inverter.py:438` / `validate.py:383` / `validate.py:684`:
|
||||
|
||||
| 0.7.0 | `release/1.0` @ `6e7b233` |
|
||||
|---|---|
|
||||
| `inverter.py:438` `max_w=float(opts.get("max_w", 2000))` | `inverter.py:640` `raw = opts.get("max_w", 2000)` |
|
||||
| `validate.py:383` `cap = _number(opts, "max_w", 2000.0)` | `validate.py:466`, same line |
|
||||
| `validate.py:684` `max_w = _number(opts, "max_w", 2000.0)` | `validate.py:836`, same line |
|
||||
|
||||
Result — 26 files, **RED at `test_selfconfig.py`**, exit 1:
|
||||
|
||||
```
|
||||
1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the
|
||||
rail collapsing to the 2000 default under an explicit 400 W slew, so the box
|
||||
cannot be emptied into a quietly halved rail
|
||||
```
|
||||
|
||||
`git log -L 310,325:goodwe_controller/test_selfconfig.py` attributes that check
|
||||
to **`bcee235`, "UX-02: pin both halves of the max_w fix, because neither was
|
||||
graded", glenn schrooyen, 2026-09-06** — not to UX-01. The hole ships closed
|
||||
with the pending 0.9.0 deploy. **The AC is met by UX-02.** No work was re-done
|
||||
here; the mutation was run once, on a throwaway copy, to confirm it.
|
||||
|
||||
---
|
||||
|
||||
## 4. The 8 per-entry translations: they stay
|
||||
|
||||
### 4.1 The measurement, and it is negative
|
||||
|
||||
`translations/{en,nl}.yaml` carry a name and a description for all 8 per-entry
|
||||
keys of the repeatable `inverters:` section. They are loaded by Supervisor's own
|
||||
loader, they pass `SCHEMA_APP_TRANSLATIONS`, and `test_translations.py` grades
|
||||
their coverage. **They do not reach the screen**, and this is not a defect in
|
||||
them. `UiOptions` gives `inverters` `type: "schema"` with `multiple: True`, and
|
||||
the frontend's `_convertSchemaElement` sends exactly that case to a selector
|
||||
rather than to a nested `ha-form`:
|
||||
|
||||
```js
|
||||
if ("schema" === t.type && !t.multiple) return {...type:"expandable"...} // not us
|
||||
if ("schema" === t.type) { const o = {}; for (const a of t.schema)
|
||||
o[a.name] = {required: a.required, selector: ...}; return {object:{multiple:t.multiple, fields:o}} }
|
||||
```
|
||||
|
||||
and `ha-selector-object` labels those fields `fields[name].label || name`, while
|
||||
`_convertSchemaElementToSelector` sets **neither `label` nor `translation_key`**.
|
||||
So an operator sees raw `name`, `setpoint_entity`, `max_w`, `bms_soc_floor`. The
|
||||
probe already asserts this, negatively and by name.
|
||||
|
||||
### 4.2 Decision: keep them. Four reasons.
|
||||
|
||||
1. **The Supervisor tab cannot be made to render them.** The gap is in
|
||||
somebody else's converter, not in this repository. There is no change here
|
||||
that fixes it; deleting the translations does not fix it either.
|
||||
2. **[[UX-02]]'s ingress panel is where they become visible**, and it renders
|
||||
the roster itself rather than handing it to `ha-selector-object`. The
|
||||
`fields:` block is the shape Supervisor's own schema defines for exactly
|
||||
this, and it is what a surface that wires `label` through consumes.
|
||||
3. **They cost nothing.** No runtime path reads them; they are two YAML blocks
|
||||
in files the Supervisor already parses, and the parse is graded green.
|
||||
4. **Deleting them would remove the only thing that catches a per-entry key
|
||||
added without a translation.** `test_translations.py` grades top-level and
|
||||
per-entry coverage symmetrically; drop the per-entry half and SAFETY-32's
|
||||
`bms_soc_floor` — a *required* per-entry key added after UX-01 — would have
|
||||
joined the roster with no label and nothing to say so.
|
||||
|
||||
This is a written decision, not a change. The tree is unmodified in
|
||||
`translations/`.
|
||||
|
||||
### 4.3 Not re-filed
|
||||
|
||||
`tools/supervisor_selfapi_probe.py` has been known red since OPT-09 — it
|
||||
hardcodes `len(_sub) == 7` against 8 per-entry keys. That is **TOOL-04**,
|
||||
already filed, and it is not re-filed or fixed here. It is named only so a
|
||||
reader of §4.1 knows why the *other* probe is the one quoted.
|
||||
|
||||
---
|
||||
|
||||
## 5. What this card did not do
|
||||
|
||||
* It did not remove a key from `options:`. See §1.5 — the deep-merge default is
|
||||
what makes the pending 0.9.0 upgrade safe on a 0.7.0 box.
|
||||
* It did not pay down the 27-pair CFG-03 ledger. See §2.3.
|
||||
* It did not correct `main.py`'s `target_grid_w` fallback. See §2.4.
|
||||
* It did not touch `translations/`. See §4.2.
|
||||
* It did not re-do CFG-04. See §3.
|
||||
* It did not touch hardware, the live Home Assistant at 192.168.2.158, or
|
||||
`sim/`. Both probe runs were throwaway containers over a read-only mount.
|
||||
+424
-5
@@ -110,7 +110,8 @@ so Home Assistant's *Configuration* tab renders human labels and a description
|
||||
for every option instead of the raw option keys. What that does and does not buy
|
||||
you is worth stating plainly, because it was **measured against the Supervisor
|
||||
image this site runs** (`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`)
|
||||
rather than assumed — `tools/supervisor_shape_probe.py` section F prints the
|
||||
rather than assumed — `tools/supervisor_shape_probe.py` section F GRADES (it
|
||||
used only to print) the
|
||||
whole rendered list, in both languages.
|
||||
|
||||
| | |
|
||||
@@ -136,6 +137,7 @@ either direction, and the check is mutation-tested both ways.
|
||||
| `soc_entity` | yes | Battery state of charge — use the **ESP32's own read** |
|
||||
| `batt_entity` | yes | Battery power — again the ESP32's read, `+` = discharging |
|
||||
| `batt_invert` | | Flip if needed |
|
||||
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
|
||||
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
|
||||
|
||||
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
|
||||
@@ -161,11 +163,13 @@ inverters:
|
||||
soc_entity: sensor.goodwe_a_soc
|
||||
batt_entity: sensor.goodwe_a_battery_power
|
||||
max_w: 5000
|
||||
capacity_kwh: 15.36
|
||||
- name: inverter-b
|
||||
setpoint_entity: number.goodwe_b_setpoint_w
|
||||
soc_entity: sensor.goodwe_b_soc
|
||||
batt_entity: sensor.goodwe_b_battery_power
|
||||
max_w: 4600
|
||||
capacity_kwh: 20.48
|
||||
```
|
||||
|
||||
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
|
||||
@@ -229,6 +233,54 @@ in the log rather than silent, but it is an outage until someone puts the
|
||||
single-inverter entity back in that field. **Before downgrading, write down the
|
||||
`setpoint_entity` of the unit you want to keep running.**
|
||||
|
||||
#### How the site target is divided between the packs (OPT-09)
|
||||
|
||||
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
|
||||
version before it the split is a fixed 50/50 regardless of what either pack can
|
||||
do** — that is the defect this section describes the fix for, so if the add-on
|
||||
you are running reports 0.7.0, none of what follows is happening yet. From 0.8.0
|
||||
the split is driven by two separate things, and it is worth knowing which is
|
||||
which, because only one of them is a rule:
|
||||
|
||||
* **The envelope — a permission.** A pack whose own state of charge is at or
|
||||
under its own `bms_soc_floor` is **not asked to discharge**; a pack at 100 %
|
||||
is **not asked to charge**. Its share goes to the sibling, inside that
|
||||
sibling's own rating. This is absolute: the allocator cannot exceed it.
|
||||
* **The weights — a preference.** Between those limits, each pack is asked for
|
||||
the fraction of the site target that matches its **energy headroom**: the kWh
|
||||
it has above its own cut-out when the site is discharging, the kWh it has
|
||||
below full when the site is charging. That is what `capacity_kwh` is for.
|
||||
|
||||
⚠️ **`capacity_kwh` is optional, and it is all-or-nothing.** Leave it out
|
||||
everywhere and the add-on has no opinion about the split — the target is divided
|
||||
evenly, exactly as it was before. Fill it in on **every** entry or on none: a
|
||||
pack measured in kWh beside one that is not is not a comparison, and the add-on
|
||||
refuses to start rather than ignore half of what you typed.
|
||||
|
||||
⚠️ **Why energy and not the rating.** The two packs here pull in opposite
|
||||
directions — A is 5000 W with 15.36 kWh, B is 4600 W with 20.48 kWh, so B has
|
||||
about a third more energy and about 8 % less power. Dividing by rating hands the
|
||||
work to A; dividing by energy hands it to B. The question a split is answering is
|
||||
"which pack can sustain its share for the hour", so it is the energy one. Each
|
||||
inverter's own rating still binds its own share, separately, through `max_w`.
|
||||
|
||||
⚠️ **What the weights will NOT do for you, stated plainly.** They are a comfort
|
||||
choice about which pack does the work. They are **not** a way to make the site
|
||||
deliver. A unit that is at its energy limit — or that has simply stopped
|
||||
responding — does not hand its load to its sibling: the loop sees the shortfall,
|
||||
freezes, and the site runs short by that unit's share. Weighting a failing unit
|
||||
down does not fix that, because when the healthy unit reaches its own rating the
|
||||
allocator hands the remainder to the down-weighted one anyway rather than leave
|
||||
watts unplaced. What you get instead is honesty: the action log reports the watts
|
||||
that could not be placed (`ALLOCATOR: +2000 W of the site target could not be
|
||||
placed on any inverter`), and a unit that accepts its share and does not produce
|
||||
it is named by the delivery alarm (SAFETY-42, above).
|
||||
|
||||
⚠️ **At one inverter none of this applies and nothing changed.** There is no
|
||||
split to weight, `capacity_kwh` is not read, and no envelope is closed —
|
||||
self-consumption still discharges down to the battery's own cut-out exactly as
|
||||
it always has.
|
||||
|
||||
### What appears in Home Assistant at two inverters
|
||||
|
||||
**Nothing you already have changes.** The eight entities this add-on has always
|
||||
@@ -1294,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 |
|
||||
|---|---|
|
||||
@@ -1308,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 |
|
||||
@@ -1343,6 +1409,50 @@ 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.
|
||||
|
||||
**`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
|
||||
@@ -1372,6 +1482,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
|
||||
@@ -1495,6 +1684,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 |
|
||||
@@ -1521,3 +1718,225 @@ If an MQTT broker is available the add-on publishes setpoint, grid power,
|
||||
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
|
||||
reports as success:
|
||||
|
||||
**The silent no-op.** The Supervisor keys the built image by `version:` in
|
||||
`config.yaml`. `ha apps update` against an unchanged version installs nothing
|
||||
and says it worked. This happened twice: PKG-06 (six merges behind an unbumped
|
||||
version) and PKG-07 (six again). Both times a human noticed. Nothing else would have.
|
||||
|
||||
**The refused config.** A config the shipped validator rejects is
|
||||
`SystemExit(78)` — the add-on does not start. ⚠️ **And the ingress panel goes
|
||||
with it**, because the add-on serving it is not running, so recovery means the
|
||||
Supervisor UI. SAFETY-39's rule 10 made this reachable from a config that
|
||||
started fine on the previous version: 0.7.0's own default `slew_w: 1000`
|
||||
exceeds the 555.6 W ceiling on a 5000 W rail.
|
||||
|
||||
Run it inside the container, so the validator half actually runs:
|
||||
|
||||
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
--deployed-version 0.7.0 --options /w/live-options.json
|
||||
|
||||
Export `live-options.json` from the Supervisor first — the options blob is not
|
||||
reachable through the Home Assistant REST API, and no entity carries it.
|
||||
|
||||
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
|
||||
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
|
||||
add-on's dependencies the validator half skips and returns 2 rather than
|
||||
pretending; `test_version_gate.py` pins that a skipped check can never exit 0,
|
||||
because the first draft of that test reported four silent skips as passes.
|
||||
|
||||
⚠️ **Why this is not a CI check.** The obvious gate — "a pull request touching
|
||||
runtime code must bump the version" — is the wrong shape here. This project
|
||||
deliberately batches many merges into one deploy, so a per-PR bump would put
|
||||
every branch in conflict over one line and still not prevent the failure, which
|
||||
happens at deploy and not at merge. The check belongs where the mistake is made.
|
||||
|
||||
⚠️ This file's own change does **not** need a version bump, and the gate agrees:
|
||||
`tools/` and `test_*.py` are not in its runtime set, because demanding a bump
|
||||
for a typo fix trains people to bump without reading — the habit the check
|
||||
exists to prevent.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -394,6 +394,14 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
`.pinned_by` and `.contradiction` so that this module does not
|
||||
import the arbiter and cannot grow a dependency on claims.
|
||||
`weights` unit -> non-negative comfort weight. Missing units weigh 1.0.
|
||||
⚠️ A MAPPING, ALWAYS - never a function. OPT-09 lets `resolve()`
|
||||
accept a callable of the site target, because an energy-headroom
|
||||
weighting is direction-dependent and the direction is stage 2's
|
||||
answer; `resolve()` calls it and hands the RESULT here. Keeping
|
||||
that out of this module is what keeps `plan()` a pure function of
|
||||
numbers and intervals: a callback invoked inside the fill would
|
||||
put a caller's code, and whatever it reads, back into the module
|
||||
whose whole discipline is that it reads nothing.
|
||||
`live` the units whose master has reported recently enough to be
|
||||
commanded, or None for "all of them". A unit not listed is
|
||||
DROPPED and the record says so; the survivors then carry the
|
||||
|
||||
@@ -205,6 +205,27 @@ CLAIM_SCOPE = {
|
||||
# At N=1 the two are the same comparison and this claim is the one filed,
|
||||
# exactly as it shipped.
|
||||
"failsafe": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("pack", P_SAFETY, lo, hi, ...)`, OPT-09. THIS PACK'S
|
||||
# OWN STATE OF CHARGE against THIS PACK'S OWN `bms_soc_floor`, filed only
|
||||
# where it BINDS and only at a roster of two or more. It says "this battery
|
||||
# cannot do that", which is a fact about one cupboard in the same way
|
||||
# `device`'s rating is a fact about one box, so it is scoped like `device`
|
||||
# and filed at the same priority.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT SAFETY-25's DEFERRED LOOP-SIDE RESERVE, WHICH LOOKS
|
||||
# EXACTLY LIKE IT AND IS THE REASON test_peakguard.py PART 10f EXISTS. That
|
||||
# one was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
|
||||
# `loop_soc_floor` option - an EMS preference, chosen by an operator,
|
||||
# stopping the grid-following loop at a depth somebody picked. This is the
|
||||
# pack's own BMS cut-out, which is measured off register 0x0560 and required
|
||||
# of every roster entry since SAFETY-32; no option is added, `peak_soc_floor`
|
||||
# is deliberately NOT consulted, and the SITE target is not touched - only
|
||||
# which unit carries it. Part 10f's own note names the revisit trigger:
|
||||
# "under ALLOC-01 the reserve becomes a PER-INVERTER INTERVAL AT THE
|
||||
# ALLOCATOR rather than a ninth claim here", and stage 1 is how an interval
|
||||
# reaches the allocator, so a unit-scoped claim is the shape that sentence
|
||||
# asks for rather than an evasion of it.
|
||||
"pack": SCOPE_UNIT,
|
||||
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
|
||||
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
|
||||
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
|
||||
@@ -544,7 +565,10 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
|
||||
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
|
||||
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
|
||||
wear - and cannot destabilise the site whatever it says. `live` is the set
|
||||
wear - and cannot destabilise the site whatever it says. ⚠️ OPT-09 LET IT BE
|
||||
A CALLABLE as well as a mapping: called with the resolved site target once
|
||||
stage 2 has run, because an energy-headroom weighting is direction-dependent
|
||||
and the direction is stage 2's answer. See the call site below. `live` is the set
|
||||
of units whose master has reported recently enough to be commanded; a unit
|
||||
left out of it is DROPPED from the envelope set and the record says so,
|
||||
because the survivor carrying a dead unit's share silently is the failure
|
||||
@@ -600,6 +624,27 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
# left as a log line nothing grades.
|
||||
out.scope_errors = scope_violations(claims)
|
||||
out.unit_bounds = bounds
|
||||
# OPT-09. THE WEIGHTS MAY ARRIVE AS A FUNCTION OF THE SITE TARGET, and this
|
||||
# is the one line that makes SoC-aware weighting expressible at all.
|
||||
#
|
||||
# ⚠️ THE REASON IS THAT THE DIRECTION IS NOT KNOWN ANY EARLIER. A weighting
|
||||
# by ENERGY HEADROOM is direction-dependent - room above the floor when the
|
||||
# site is discharging, room below full when it is charging - and the two
|
||||
# give opposite answers on the same roster. The site target is chosen by
|
||||
# stage 2, three lines up, so a caller computing weights before `resolve()`
|
||||
# would have to guess which way the site is about to go, and would be wrong
|
||||
# in one of the two directions on every tick.
|
||||
#
|
||||
# ⚠️ IT IS CALLED ONCE, HERE, BETWEEN STAGE 2 AND STAGE 3, AND NEVER FROM
|
||||
# INSIDE THE ALLOCATOR. `allocator.plan()` goes on taking a plain mapping,
|
||||
# which is what keeps it a pure function of numbers and intervals: a
|
||||
# callback invoked inside the fill would put a caller's code - and whatever
|
||||
# it reads - back into the module whose whole discipline is that it reads
|
||||
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
|
||||
# own direction can differ from the target's sign under a pin, in which case
|
||||
# the weighting is a worse preference and still cannot be unsafe).
|
||||
if callable(weights):
|
||||
weights = weights(out.target_w)
|
||||
out.shares, out.allocated, out.allocation = allocate_record(
|
||||
out.target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
||||
|
||||
@@ -173,6 +173,17 @@ IDLE_NO_SOC = (
|
||||
"state of charge is not being read, and a battery whose fill nobody knows "
|
||||
"cannot be told to empty or to fill")
|
||||
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
|
||||
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
|
||||
# and are now SIZED to the packs whose state of charge permits that direction, so
|
||||
# a caller can hand this function a zero. A zero must not reach `claim()`: a
|
||||
# `Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the loop,
|
||||
# which is the failure the note on `claim()` is written against - it would
|
||||
# silently disable self-consumption for the whole hour. So the sizing is checked
|
||||
# where the decision is made rather than trusted to arrive positive.
|
||||
IDLE_NO_SIZE = (
|
||||
"no pack's state of charge permits this side of the trade, so it is sized "
|
||||
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
|
||||
NEGATIVE_PRICE = (
|
||||
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
|
||||
"energy away and pay for the privilege. Arbitrage must not export at a "
|
||||
@@ -372,6 +383,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
|
||||
f"arbitrage may not spend (§10.1 priority 4)", **common)
|
||||
if not abs(float(discharge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=DISCHARGE, power_w=abs(float(discharge_w)),
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
|
||||
@@ -388,6 +401,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
|
||||
f"nowhere to put the cheap energy", **common)
|
||||
if not abs(float(charge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
return ArbitrageDecision(
|
||||
action=CHARGE, power_w=-abs(float(charge_w)),
|
||||
reason=(f"{CHARGE}: this is the cheapest firm hour at "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -192,8 +192,8 @@ class Inverter:
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
bms_soc_floor=None, store=None, sign_cfg=None,
|
||||
delivery_cfg=None):
|
||||
bms_soc_floor=None, capacity_kwh=None, store=None,
|
||||
sign_cfg=None, delivery_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
@@ -215,6 +215,20 @@ class Inverter:
|
||||
# the config by name long before that, at startup.
|
||||
self.bms_soc_floor = (None if bms_soc_floor is None
|
||||
else float(bms_soc_floor))
|
||||
# OPT-09. THIS PACK'S USABLE CAPACITY IN kWh, or None when the roster
|
||||
# entry did not state one.
|
||||
#
|
||||
# ⚠️ OPTIONAL WHERE `bms_soc_floor` IS REQUIRED, AND THE ASYMMETRY IS
|
||||
# THE POINT RATHER THAN AN INCONSISTENCY. A missing cut-out would have
|
||||
# to be defaulted, and one default for two batteries protects the
|
||||
# shallower pack with the deeper pack's number - SAFETY-32's whole card.
|
||||
# A missing capacity has an honest answer that costs nothing: this pack
|
||||
# states no energy headroom, so `packshare.weights()` has NO OPINION
|
||||
# about the split and every unit weighs 1.0, which is exactly the
|
||||
# behaviour that shipped. None, never 0.0: a zero-capacity pack is one
|
||||
# that may never be used, and nobody decides that by omitting a key.
|
||||
self.capacity_kwh = (None if capacity_kwh is None
|
||||
else float(capacity_kwh))
|
||||
|
||||
# --- the device's own reported range, read from its own entity -------
|
||||
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
|
||||
@@ -482,7 +496,9 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
it - see the options-block note on `inverters:`.
|
||||
|
||||
NON-EMPTY each entry describes one inverter and the flat keys describe
|
||||
none of them. ⚠️ NOTHING IS INHERITED. An entry that omits
|
||||
none of them. OPT-09's `capacity_kwh` is an entry key too, and an
|
||||
OPTIONAL one - see `_entry_capacity_kwh()` and `Inverter.__init__`
|
||||
on why that absence is answerable where a missing cut-out is not. ⚠️ NOTHING IS INHERITED. An entry that omits
|
||||
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
|
||||
because "this unit's number came from somewhere else" is the
|
||||
defect DEV-02 spent a card removing and a fallback here would
|
||||
@@ -519,6 +535,13 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
# mirror exactly as validate.py rule 8 defaults it, so an install
|
||||
# that never set the key reads the same 10 % in both places.
|
||||
bms_soc_floor=_flat_bms_floor(opts),
|
||||
# ⚠️ OPT-09 ADDS NO FLAT `capacity_kwh`, AND THE ABSENCE IS THE
|
||||
# DECISION. Capacity is read for ONE purpose - weighting a split
|
||||
# between two packs - and at the single-inverter shape there is no
|
||||
# split: `allocator.plan()` is a pass-through at N<=1 and a weight
|
||||
# is arithmetic nobody reads. A flat key would be an option an
|
||||
# operator can set, that the add-on reads, and that changes nothing,
|
||||
# which is the worst of the three.
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
|
||||
|
||||
roster = []
|
||||
@@ -546,6 +569,11 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
# SAFETY-32's own defect written in three lines - the shallower pack
|
||||
# graded against the deeper pack's BMS.
|
||||
bms_soc_floor=_entry_bms_floor(entry),
|
||||
# OPT-09, and NOT inherited either, for the reason above it: a
|
||||
# capacity borrowed from a sibling would weight a 15.36 kWh pack as
|
||||
# though it were a 20.48 kWh one, which is the split this card
|
||||
# exists to stop being wrong.
|
||||
capacity_kwh=_entry_capacity_kwh(entry),
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
|
||||
return roster
|
||||
|
||||
@@ -617,6 +645,30 @@ def _flat_max_w(opts: dict) -> float:
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def _entry_capacity_kwh(entry: dict):
|
||||
"""One entry's OWN usable capacity in kWh, or None when it stated none.
|
||||
|
||||
None rather than 0.0 and rather than a borrowed number: `_entry_max_w()` can
|
||||
answer 0.0 because a zero rating commands nothing and rule 9 refuses it by
|
||||
name, but a zero CAPACITY is a pack with no energy headroom in either
|
||||
direction, i.e. a pack the weighting would silently stop preferring. "Not
|
||||
stated" and "empty" must not be the same answer - `packshare.weights()`
|
||||
turns the first into "no opinion at all" and the second into a weight of
|
||||
zero, and those are different splits.
|
||||
|
||||
A NON-POSITIVE or unparseable value also answers None. Rule 9 refuses it at
|
||||
startup with a message; until it does, withholding is the direction that
|
||||
cannot invent a preference.
|
||||
"""
|
||||
raw = entry.get("capacity_kwh")
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return val if val > 0.0 else None
|
||||
|
||||
|
||||
def _entry_max_w(entry: dict) -> float:
|
||||
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
||||
|
||||
+477
-27
@@ -65,8 +65,10 @@ from .maintenance import (
|
||||
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
unit_state_key)
|
||||
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
|
||||
@@ -136,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:
|
||||
@@ -145,9 +201,27 @@ def load_options() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
def pack_energy_of(inv):
|
||||
"""OPT-09. ONE roster entry as the view `packshare` consumes.
|
||||
|
||||
⚠️ ONE BUILDER, TWO CALLERS, AND THAT IS THE POINT. `Controller.pack_energy`
|
||||
needs the whole roster (for the weights and the trade sizing) and
|
||||
`Controller.cycle()` needs one entry at a time (for that unit's `pack`
|
||||
claim). Written out twice, the two would drift - and the direction they
|
||||
would drift in is a unit whose envelope is computed from a different set of
|
||||
numbers than its weight, which is precisely the class of defect DEV-02 spent
|
||||
a card removing. It is a module function rather than a method on `Inverter`
|
||||
so that `inverter.py` does not have to import `packshare`.
|
||||
"""
|
||||
return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor,
|
||||
inv.capacity_kwh, inv.max_w)
|
||||
|
||||
|
||||
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
|
||||
@@ -229,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
|
||||
@@ -531,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
|
||||
@@ -628,6 +771,12 @@ class Controller:
|
||||
# edge-triggered. Starts as the empty tuple - nothing has been dropped
|
||||
# yet and nothing has been said about it.
|
||||
self._dropped_last = ()
|
||||
# OPT-09. The last cycle's `unmet_w`, so the line that reports it is
|
||||
# edge-triggered for `_dropped_last`'s reason: a site that cannot place
|
||||
# every watt stays that way for as long as the pack is empty, and a line
|
||||
# a second would flush the 40-row action log of the context explaining
|
||||
# it. 0 is "every watt was placed", which is the state at boot.
|
||||
self._unmet_last = 0
|
||||
# ⚠️ THE SITE MEASUREMENT, AND THE ONLY ONE LEFT ON THIS OBJECT. `soc`
|
||||
# and `batt` were per-device scalars and now live on each `Inverter`;
|
||||
# `grid` is P1, one meter, one connection, and belongs here.
|
||||
@@ -727,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 "")
|
||||
@@ -1015,6 +1273,55 @@ class Controller:
|
||||
"""
|
||||
return float(sum(inv.max_w for inv in self.inverters))
|
||||
|
||||
def trade_power_w(self, floor_pct, ceiling_pct) -> tuple:
|
||||
"""OPT-09. `(charge_w, discharge_w)` - how hard a trade may be SIZED.
|
||||
|
||||
The site rating is what the site can push; this is what the site can
|
||||
push AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` used to be
|
||||
handed `site_max_w` on both sides, so on a roster of two the trade was
|
||||
sized at the sum of the ratings whatever either pack's state of charge
|
||||
was - the site would buy at 9600 W with only one pack able to absorb it,
|
||||
and the watts that could not be placed would come back as `unmet_w` for
|
||||
the site integrator to chase for the rest of the hour.
|
||||
|
||||
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
|
||||
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, because SIZING
|
||||
A TRADE IS THE TRADE'S BUSINESS. This method answers "how much may this
|
||||
strategy ask for"; the ENVELOPE - `packshare.envelope()`, filed as a
|
||||
`pack` claim - answers "what may this pack be commanded at all", and
|
||||
that one is the pack's own physical cut-out for the reason SAFETY-25
|
||||
gives. Sizing with the physical floor would let the strategy ask for
|
||||
watts its own reserve forbids; bounding the envelope with the trade's
|
||||
reserve would let a trade preference stop the grid-following loop.
|
||||
THEY ARE DIFFERENT QUESTIONS AND THEY GET DIFFERENT NUMBERS.
|
||||
|
||||
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack ends up
|
||||
carrying the trade is the allocator's answer, and the only per-pack
|
||||
instrument it has for a TRADE reserve is the weights - which are a
|
||||
preference, not a permission (`allocator._fill()` will hand watts to a
|
||||
zero-weighted unit rather than leave them unmet). So a pack between its
|
||||
arbitrage floor and its BMS cut-out can still take part of a sell. That
|
||||
is stated rather than fixed: fixing it needs a per-pack envelope that
|
||||
depends on WHICH claim won the tick, which the arbiter's partition does
|
||||
not express, and is a card of its own.
|
||||
|
||||
A pack whose SoC did not read is counted on NEITHER side. It is not
|
||||
reachable with control running - `inputs_ok` stops the site when any
|
||||
pack's SoC is missing - and withholding is the direction that cannot
|
||||
over-size.
|
||||
|
||||
⚠️ NEITHER SIDE CAN BE ZERO ON A TICK THAT ACTUALLY TRADES, and it is
|
||||
not left to that coincidence: `arbitrage.decide()` refuses a 0 W sizing
|
||||
by name (`IDLE_NO_SIZE`), because a 0 W arbitrage claim is a park order
|
||||
that outranks the loop.
|
||||
"""
|
||||
# ⚠️ THE REDUCTION ITSELF LIVES IN `packshare`, NOT HERE, and this
|
||||
# method is the roster view it is applied to. A transcription of the
|
||||
# arithmetic into a check is one copy plus a future disagreement about
|
||||
# what it means, and the copy that drifts is always the grading one -
|
||||
# so `test_packshare.py` part 9 calls the SAME function this does.
|
||||
return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct)
|
||||
|
||||
@property
|
||||
def site_rating_w(self) -> float:
|
||||
"""The largest magnitude the SITE can actually be commanded, in W.
|
||||
@@ -1132,6 +1439,44 @@ class Controller:
|
||||
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
|
||||
for inv in self.inverters]
|
||||
|
||||
@property
|
||||
def pack_energy(self) -> list:
|
||||
"""OPT-09. Each pack as the FOUR numbers a split needs, in roster order.
|
||||
|
||||
The fourth is `capacity_kwh`, and it is why this is not `packs` above.
|
||||
That list answers the FLOOR question - may this pack be spent - and its
|
||||
consumers (`pack_permissions`, `binding_soc_floor`) have no use for a
|
||||
capacity and would have to default one. This list answers the SPLIT
|
||||
question - which pack should carry more of the site target - and cannot
|
||||
be asked without one.
|
||||
|
||||
⚠️ EVERY unit, live or not, for `packs`'s reason. The allocator drops
|
||||
what `live_units()` does not list and `plan()` ignores a weight for a
|
||||
unit it dropped, so filtering here would only move the same decision to
|
||||
a worse place - and `packshare.weights()` needs to see a pack that did
|
||||
not answer in order to withhold its opinion about the whole roster.
|
||||
"""
|
||||
return [pack_energy_of(inv) for inv in self.inverters]
|
||||
|
||||
def pack_weights(self, target_w):
|
||||
"""OPT-09. The comfort weights for a site target of `target_w`.
|
||||
|
||||
⚠️ IT IS A METHOD HANDED TO `resolve()`, NOT A DICT COMPUTED BEFORE IT,
|
||||
and that is not indirection for its own sake. An energy-headroom
|
||||
weighting is direction-dependent - room above the floor when the site is
|
||||
discharging, room below full when it is charging - and the two give
|
||||
opposite answers on the same roster. The direction is the SIGN OF THE
|
||||
SITE TARGET, which is stage 2's output, so nothing here can know it
|
||||
until `resolve()` has run stage 2. `arbiter.resolve()` calls this once,
|
||||
between stage 2 and stage 3, with the number it just chose.
|
||||
|
||||
⚠️ AND IT IS READ FRESH ON EVERY CALL, NEVER CACHED, for the reason the
|
||||
allocator refuses to cache an envelope: a pack's SoC moves for reasons
|
||||
that have nothing to do with the allocator, and a weighting held from
|
||||
construction prefers a pack that has since emptied.
|
||||
"""
|
||||
return packshare.weights(self.pack_energy, target_w)
|
||||
|
||||
@property
|
||||
def inputs_ok(self) -> bool:
|
||||
"""The site meter, and EVERY pack's own two readings.
|
||||
@@ -1930,6 +2275,39 @@ class Controller:
|
||||
# setpoint entity (§11.3), not a bound on the grid connection.
|
||||
claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w",
|
||||
actuator=self.actuator_of("supervised", inv)))
|
||||
# --- OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS -------------
|
||||
# ⚠️ THE POINT OF THE CARD, AND IT IS AN ENVELOPE RATHER THAN A
|
||||
# WEIGHT. Weighting an empty pack down does NOT stop it being
|
||||
# commanded: `allocator._fill()` runs a deliberate last-resort pass
|
||||
# over the zero-weight units when the preferred ones saturate, so
|
||||
# that a preference can never manufacture a standing error at the
|
||||
# meter. Only a closed envelope stops the ask, and `cap` is obeyed
|
||||
# absolutely. So a pack at or under its own BMS cut-out gets
|
||||
# `hi = 0` and a pack at 100 % gets `lo = 0`, and the site target
|
||||
# goes to the sibling instead of being asked of a battery that
|
||||
# cannot answer it.
|
||||
#
|
||||
# ⚠️ AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME
|
||||
# IDENTITY `sign_site` IS GUARDED BY RATHER THAN A SPECIAL CASE.
|
||||
# `actuator_for()` routes every source to the SITE at N=1 - with one
|
||||
# inverter the fabricated grid reading and the battery setpoint are
|
||||
# the same number - so this interval would bound THE SITE TARGET
|
||||
# there, not a share. That is a behaviour change on the live
|
||||
# single-inverter install and it is the shape SAFETY-25 decided
|
||||
# against on 2026-09-02 (Option A): ordinary self-consumption is not
|
||||
# given an SoC floor by this card. There is also nothing for it to
|
||||
# do at N=1 - no sibling to move the share to - so the whole of AC 4
|
||||
# ("at N=1 allocation is byte-identical to today") is this `if`.
|
||||
#
|
||||
# WHAT IT COSTS, SAID PLAINLY: at N=1 nothing here stops the loop
|
||||
# discharging into the BMS cut-out. That is unchanged, it is
|
||||
# SAFETY-25's deferred Option B, and the BMS itself is what stops it.
|
||||
if n_units > 1:
|
||||
_pe = packshare.envelope(pack_energy_of(inv))
|
||||
if _pe.binding:
|
||||
claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi,
|
||||
_pe.why,
|
||||
actuator=self.actuator_of("pack", inv)))
|
||||
# The SITE bound, for the site-level consumers below. It is the roster
|
||||
# sum: what this site can deliver, not what one of its inverters can.
|
||||
max_w = self.site_max_w
|
||||
@@ -2202,6 +2580,7 @@ class Controller:
|
||||
self.arb = None
|
||||
if arbitrage.enabled(self.o):
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
|
||||
self.arb = arbitrage.decide(
|
||||
self.fresh_tariff_plan(now), now,
|
||||
peak_guard_active=arbitrage.guard_state(self.peak_guard),
|
||||
@@ -2214,7 +2593,22 @@ class Controller:
|
||||
soc=self.pack_floor_soc,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
|
||||
charge_w=max_w, discharge_w=max_w,
|
||||
# OPT-09. PER-PACK SIZING. Not `max_w` - the site rating - on
|
||||
# both sides any more: the trade is sized to the packs whose own
|
||||
# state of charge permits that direction, so it can never ask
|
||||
# for more than the roster can take. `trade_power_w()` carries
|
||||
# the argument, including which of the two SoC bounds belongs
|
||||
# here and which belongs on the envelope. ⚠️ AT N=1 BOTH SIDES
|
||||
# ARE `max_w` EXACTLY, whenever the trade is permitted at all:
|
||||
# the one pack that passed the gate is the whole roster.
|
||||
# ⚠️ AND ON THE SELL SIDE THIS IS A NO-OP TODAY on any roster,
|
||||
# because `soc=` below is the EMPTIEST pack, so a sell is
|
||||
# refused outright whenever any pack is at or under the floor -
|
||||
# i.e. whenever this sum could be smaller than the site rating.
|
||||
# It becomes reachable the day that gate becomes per-pack;
|
||||
# `test_packshare.py` part 9 grades it directly rather than
|
||||
# letting an unreachable branch go ungraded.
|
||||
charge_w=charge_w, discharge_w=discharge_w,
|
||||
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
|
||||
# SITE, and arbitrage.claim() says so on the object it builds. It
|
||||
# buys and sells at the grid against a day-ahead price: the price is
|
||||
@@ -2419,8 +2813,24 @@ class Controller:
|
||||
# stale from this line onwards. Incremented BEFORE resolve() so that the
|
||||
# record resolve() builds is stamped with the tick it is for.
|
||||
self.alloc_tick += 1
|
||||
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. ARCH-01 left the
|
||||
# allocator one free parameter and ALLOC-01 built the machinery that
|
||||
# consumes it (`allocator._fill`); this line is the first thing that has
|
||||
# ever passed one. Without it every unit weighed 1.0 and the split was
|
||||
# a fixed 50/50 regardless of what either pack could do.
|
||||
#
|
||||
# ⚠️ A METHOD, NOT A DICT. The weighting is by ENERGY HEADROOM and is
|
||||
# therefore direction-dependent, and the direction is the sign of the
|
||||
# site target - which stage 2 chooses inside `resolve()`. See
|
||||
# `pack_weights()` and `packshare.weights()`; the arbiter calls it once,
|
||||
# between stage 2 and stage 3.
|
||||
#
|
||||
# ⚠️ AND IT IS A PREFERENCE, NOT A PERMISSION. Nothing about the site's
|
||||
# ability to DELIVER rests on this argument - a weight cannot stop a
|
||||
# unit being commanded and cannot move a failing unit's load to its
|
||||
# sibling. The `pack` claim filed above is the half that constrains.
|
||||
resolution = resolve(claims, units=self.units, live=self.live_units(),
|
||||
tick=self.alloc_tick)
|
||||
weights=self.pack_weights, tick=self.alloc_tick)
|
||||
self.allocation = resolution.allocation
|
||||
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
|
||||
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
|
||||
@@ -2514,6 +2924,25 @@ class Controller:
|
||||
if self._dropped_last and not _dropped:
|
||||
self.log_event("ALLOCATOR: every inverter is back in the split")
|
||||
self._dropped_last = _dropped
|
||||
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
|
||||
# `unmet_w` is the site target the envelopes could not absorb. It is
|
||||
# NOT a broken allocation - `Allocation`'s own docstring says why
|
||||
# fusing it with `ok` would let one saturated inverter park the whole
|
||||
# site - but it IS the site failing to deliver what the loop asked
|
||||
# for, and before this card nothing anywhere reported it. It is the
|
||||
# state a closed envelope produces when BOTH packs are at their
|
||||
# limits: the ask has nowhere left to go, and an operator watching a
|
||||
# standing grid error needs the reason rather than the symptom.
|
||||
# Edge-triggered on the VALUE, for `_dropped` reason.
|
||||
_unmet = int(self.allocation.unmet_w if self.allocation else 0)
|
||||
if _unmet != self._unmet_last:
|
||||
if _unmet:
|
||||
self.log_event(
|
||||
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
|
||||
f"placed on any inverter - {self.allocation.explain()}")
|
||||
else:
|
||||
self.log_event("ALLOCATOR: the whole site target fits again")
|
||||
self._unmet_last = _unmet
|
||||
if resolution.scope_errors:
|
||||
# A claim reached the arbiter on an actuator its source's scope does
|
||||
# not permit. It is a BUG in the filing site, not a state to survive:
|
||||
@@ -3118,7 +3547,8 @@ class Controller:
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
plan = await resolve_tariff(
|
||||
self.o, now, now + timedelta(hours=horizon), now=now)
|
||||
self.o, now, now + timedelta(hours=horizon), now=now,
|
||||
history=self.price_history)
|
||||
self.tariff_plan, self.tariff_at = plan, now
|
||||
_LOG.info("tariff refreshed: %s", plan.words)
|
||||
except Exception as err: # noqa: BLE001
|
||||
@@ -3967,9 +4397,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()),
|
||||
]
|
||||
@@ -4222,11 +4667,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
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
"""OPT-09 - what each pack can still do, in the two forms stage 3 accepts.
|
||||
|
||||
ARCH-01 left the allocator ONE free parameter - `weights` - and ALLOC-01 built
|
||||
the machinery that consumes it (`allocator._fill`). Nothing ever passed one.
|
||||
`main.py` called `resolve()` without `weights=`, `arbiter.resolve()` defaulted
|
||||
it to None and `allocator.plan()` turned that into 1.0 for every unit, so the
|
||||
split was a fixed 50/50 REGARDLESS OF WHAT EITHER PACK COULD DO. This module is
|
||||
what fills that argument, and it fills the other one too - the per-unit
|
||||
interval - because on their own the weights cannot do the job. Read the two
|
||||
sections below in order; the second is the important one.
|
||||
|
||||
⚠️ THE TWO ARE NOT INTERCHANGEABLE, AND THE ALLOCATOR SAYS SO FIRST
|
||||
===================================================================
|
||||
`allocator.py`'s docstring states the rule this module is built on:
|
||||
|
||||
WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
|
||||
"please do not use this unit", and it is honoured while any other unit has
|
||||
headroom - but it is NOT a constraint [...] To make a unit genuinely
|
||||
unusable, close its envelope or leave it out of `live`.
|
||||
|
||||
That is mechanical, not advisory: `_fill()` runs a SECOND, last-resort pass over
|
||||
the zero-weight units when the preferred ones fill up, deliberately, so that a
|
||||
preference can never manufacture a standing error at the meter. So a pack at its
|
||||
own cut-out that is only WEIGHTED to zero is still commanded to discharge the
|
||||
moment its sibling saturates. Closing its `hi` is what actually stops the ask.
|
||||
|
||||
Hence this module produces two things and they answer two different questions:
|
||||
|
||||
envelope(pack) MAY this pack be asked, at all, in this direction?
|
||||
-> a `hi`/`lo` that main.py files as a UNIT claim, and the
|
||||
allocator obeys absolutely (`cap` is never overridden).
|
||||
weights(packs,S) GIVEN that both may, which should carry more of it?
|
||||
-> a comfort choice, in the sense ARCH-01 means: the gain
|
||||
seen at the meter is a CONVEX COMBINATION of the
|
||||
per-unit gains for ANY split, so this cannot destabilise
|
||||
the site whatever it says.
|
||||
|
||||
⚠️ AND WHAT THE WEIGHTS CANNOT DO, BECAUSE THE CONVEX-COMBINATION ARGUMENT IS
|
||||
EASY TO OVER-READ
|
||||
============================================================================
|
||||
ARCH-01 guarantees that a bad split cannot DESTABILISE the site. It says nothing
|
||||
whatever about whether the site can REGULATE. A unit sitting at its energy limit
|
||||
has g_i = 0 - it answers no watts to any command - and the convex combination
|
||||
then contains a zero: `gbar = sum(a_i * g_i)` is still inside [min g_i, max g_i]
|
||||
and the site is still stable, while the load it was asked to carry simply does
|
||||
not move. `compute()` sees the resulting standing error, trips its saturation
|
||||
freeze and holds the site target where it is, so the share of a unit that cannot
|
||||
deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
|
||||
|
||||
⚠️ SO: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY.
|
||||
Weighting a failing unit down is not a remedy for it. The remedies this
|
||||
add-on has are the ENVELOPE (here, when the reason is the pack's own state
|
||||
of charge - a fact this controller reads) and `live_units()` (when the unit
|
||||
cannot be commanded at all). SAFETY-42 measured what the third case costs:
|
||||
with one unit wedged and equal weights, the site settles 700 W short and
|
||||
never recovers, because the freeze pins the target and the sibling's share
|
||||
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 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
|
||||
case at all. THE ENVELOPE HALF IS TRUE: a wedged box reads a healthy SoC and
|
||||
nothing here closes. THE WEIGHT HALF WAS FALSE. `weights()` is a function of a
|
||||
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()` 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 % 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 absolute arm names it.
|
||||
|
||||
⚠️ 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.
|
||||
|
||||
⚠️ 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
|
||||
========================
|
||||
The two packs at this site are asymmetric IN OPPOSITE DIRECTIONS. From the
|
||||
datasheet and the serials (NOTES.md 1.1b): A is 5000 W / 15.36 kWh, B is 4600 W
|
||||
/ 20.48 kWh - B has about 33 % more ENERGY and about 8 % less POWER. A split by
|
||||
power rating hands the trade to A; a split by energy headroom hands it to B. The
|
||||
question the split is answering is "which pack can sustain its share for the
|
||||
hour", so it is the energy one, and the ratings are already applied - as the
|
||||
`supervised` and `device` claims, per unit, which is where a POWER bound belongs.
|
||||
|
||||
⚠️ INVERTER B DOES NOT EXIST YET (expected ~2026-09-12) AND NOTHING HERE IS
|
||||
MEASURED ON IT. `sim/plantsim.py`'s INVERTER_B is inverter A copied, with
|
||||
`measured=False` on it. Every figure above is FROM DATASHEET AND SERIAL, and
|
||||
every two-unit result this card reports is TWO COPIES OF A driven with different
|
||||
numbers, which is a statement about the arithmetic and not about the hardware.
|
||||
|
||||
WHICH FLOOR, AND WHICH CEILING - THE TWO CHOICES THAT ARE POLICY IF YOU GET
|
||||
THEM WRONG
|
||||
==========================================================================
|
||||
⚠️ THE FLOOR IS `bms_soc_floor` ALONE, NOT `peakguard.pack_floor_pct()`'s
|
||||
conjunction with `peak_soc_floor`. This is the same choice `delivery.py`'s
|
||||
`account()` had to make and it is made for the same reason, one direction
|
||||
further out: `peak_soc_floor` bounds the PEAK GUARD ONLY (SAFETY-25), ordinary
|
||||
self-consumption discharges straight past it, and closing a unit's envelope at
|
||||
it would silently turn an EMS preference into a hard stop on the grid-following
|
||||
loop - which is the memo's DEFERRED Option B, decided against on 2026-09-02, and
|
||||
not this card's to build. `bms_soc_floor` is a property of the battery in the
|
||||
cupboard: below it the pack's own BMS stops, so a discharge command sent there
|
||||
is a command that cannot be executed by anything.
|
||||
|
||||
⚠️ AND THE CEILING IS 100 %, WHICH IS COARSE, AND THE COARSENESS IS THE HONEST
|
||||
ANSWER RATHER THAN A GAP TO BE PAPERED OVER. There is no per-pack charge ceiling
|
||||
anywhere in this add-on - `delivery.py` says so at length and calls it a card,
|
||||
and it is why SAFETY-42 is armed on discharge shares only. The numbers that
|
||||
exist are `arbitrage_soc_ceiling` (one site-wide number, and a TRADE policy) and
|
||||
nothing else. Filing a trade policy as a P_SAFETY envelope on the loop would be
|
||||
exactly the mistake the paragraph above refuses, in the charge direction. So the
|
||||
envelope closes only where the ask is physically impossible - a pack reporting
|
||||
100 % being told to take more - and everything between is left to the WEIGHTS,
|
||||
which is the right instrument for a graded preference and is honest about being
|
||||
only a preference.
|
||||
|
||||
CAPACITY IS OPTIONAL AND "UNKNOWN" MEANS "NO OPINION"
|
||||
=====================================================
|
||||
`capacity_kwh` is a new, OPTIONAL per-entry key. Optional, where SAFETY-32 made
|
||||
`bms_soc_floor` required, because the two absences mean different things: a
|
||||
missing cut-out would have to be DEFAULTED and a default protects the shallower
|
||||
pack with the deeper pack's number, whereas a missing capacity can be answered
|
||||
with "then this module has no opinion about the split" - which is exactly
|
||||
today's behaviour, 1.0 for every unit. `weights()` therefore answers None unless
|
||||
EVERY pack in the roster states both a capacity and a state of charge: a
|
||||
half-known roster would weight the packs that answered against a sibling
|
||||
weighing 1.0, which is a comparison between a kWh and a dimensionless one.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import inf, isfinite
|
||||
|
||||
from .peakguard import discharge_permitted
|
||||
|
||||
# The only charge ceiling that is a property of a PACK rather than of a policy.
|
||||
# See the module docstring: `arbitrage_soc_ceiling` is a trade preference and
|
||||
# must not become a P_SAFETY bound on the grid-following loop.
|
||||
FULL_PCT = 100.0
|
||||
|
||||
# The claim source name main.py files these intervals under.
|
||||
#
|
||||
# ⚠️ MAIN.PY STILL WRITES THE STRING LITERALLY AND MUST GO ON DOING SO, so this
|
||||
# constant is a NAME FOR THE CHECKS rather than something to import at the
|
||||
# filing site. test_peakguard.py part 10f reads `Claim.limit(...)`'s FIRST
|
||||
# POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set, and part
|
||||
# 10g reads `actuator_of('<literal>')` the same way; both go blind on a name
|
||||
# they would have to resolve. So the two spellings are tied by a check instead -
|
||||
# `CLAIM_SCOPE[packshare.SOURCE] == SCOPE_UNIT` in test_packshare.py part 10 -
|
||||
# which is the one direction that cannot make the scanners vacuous.
|
||||
SOURCE = "pack"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackEnergy:
|
||||
"""ONE pack's four numbers for the two questions this module answers.
|
||||
|
||||
`name` the roster name. An envelope that does not say WHICH pack it
|
||||
closed sends an operator to the wrong cupboard - the defect
|
||||
`peakguard.Pack` and CFG-04's `unit_caps_indexed()` were
|
||||
both written for.
|
||||
`soc` THIS pack's measured state of charge, in percent. None when
|
||||
its sensor did not answer, and ⚠️ None IS NOT A FULL
|
||||
BATTERY - `discharge_permitted()` says why at length.
|
||||
`bms_floor` THIS pack's OWN `bms_soc_floor`, the percent at which its
|
||||
BMS cuts out. Required of every roster entry since
|
||||
SAFETY-32, so at a declared roster this is never None.
|
||||
`capacity_kwh` THIS pack's usable capacity. None when the entry did not
|
||||
state one, which means "no opinion" and never 0 - a zero
|
||||
capacity is a pack that may never be used, which is a
|
||||
decision nobody made by omitting a key.
|
||||
`max_w` THIS unit's supervised rating. ⚠️ IT IS NOT AN INPUT TO THE
|
||||
SPLIT AND MUST NOT BECOME ONE - the whole of deliverable 2
|
||||
is that the split is by ENERGY and the rating is applied
|
||||
separately, per unit, as the `supervised` and `device`
|
||||
claims. It is here for `trade_power_w()` alone, which
|
||||
answers a different question: how many watts the packs that
|
||||
are ALLOWED to take a trade can take between them.
|
||||
|
||||
⚠️ IT IS A SEPARATE TYPE FROM `peakguard.Pack` RATHER THAN AN EXTENSION OF
|
||||
IT, AND THAT IS DELIBERATE. `Pack` carries the three numbers the FLOOR
|
||||
question needs and is consumed by `pack_permissions()` and
|
||||
`binding_soc_floor()`, whose answers are site verdicts about spending a pack
|
||||
for money. This one carries a fourth number that is meaningless to those
|
||||
reductions and would have to be defaulted into them. Two small types with
|
||||
one purpose each beat one type with a field half its callers must ignore.
|
||||
"""
|
||||
|
||||
name: str
|
||||
soc: float | None
|
||||
bms_floor: float | None
|
||||
capacity_kwh: float | None
|
||||
max_w: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackEnvelope:
|
||||
"""The interval OPT-09 asks for on one pack, and the sentence that says why.
|
||||
|
||||
`(-inf, +inf)` is "no opinion" and is the common answer; `binding` is how a
|
||||
caller asks whether there is anything to file. A claim filed for every pack
|
||||
on every cycle regardless would put an open limit into the arbiter's
|
||||
considered list on every tick, which costs nothing arithmetically and buries
|
||||
the one cycle where it means something.
|
||||
"""
|
||||
|
||||
lo: float = -inf
|
||||
hi: float = inf
|
||||
why: str = ""
|
||||
|
||||
@property
|
||||
def binding(self) -> bool:
|
||||
return self.lo != -inf or self.hi != inf
|
||||
|
||||
|
||||
def usable_kwh(pack) -> float | None:
|
||||
"""Energy this pack can still DISCHARGE before its own BMS cuts out.
|
||||
|
||||
None when it cannot be computed - no capacity, no reading, or no floor - and
|
||||
never 0.0 for those, because "no opinion" and "nothing left" are the two
|
||||
answers a weighting must not confuse: the first weighs 1.0 and the second
|
||||
weighs nothing at all.
|
||||
|
||||
Clamped at 0.0 from below. A pack measured BELOW its own cut-out is at
|
||||
-0.4 kWh of usable energy by arithmetic and at zero in fact.
|
||||
"""
|
||||
if (pack.capacity_kwh is None or pack.soc is None
|
||||
or pack.bms_floor is None):
|
||||
return None
|
||||
return max(0.0, float(pack.capacity_kwh)
|
||||
* (float(pack.soc) - float(pack.bms_floor)) / 100.0)
|
||||
|
||||
|
||||
def spare_kwh(pack) -> float | None:
|
||||
"""Energy this pack can still ACCEPT before it is full.
|
||||
|
||||
`FULL_PCT`, not `arbitrage_soc_ceiling`: the module docstring carries the
|
||||
argument, and it is the same one that keeps `peak_soc_floor` out of
|
||||
`usable_kwh()`. A pack's own fullness is a fact; a trade's reserve is not.
|
||||
|
||||
⚠️ AND IT IS A WEIGHTING INPUT, NOT A PERMISSION. It says nothing about the
|
||||
constant-voltage taper, in which a pack legitimately accepts far less than
|
||||
it is told for tens of minutes - there is no configured ceiling in this
|
||||
add-on that could describe that, which is `delivery.py`'s stated reason for
|
||||
being armed on discharge shares only.
|
||||
"""
|
||||
if pack.capacity_kwh is None or pack.soc is None:
|
||||
return None
|
||||
return max(0.0, float(pack.capacity_kwh)
|
||||
* (FULL_PCT - float(pack.soc)) / 100.0)
|
||||
|
||||
|
||||
def envelope(pack) -> PackEnvelope:
|
||||
"""The interval this pack may be commanded in, from its state of charge.
|
||||
|
||||
Positive is DISCHARGE throughout this add-on, so:
|
||||
|
||||
* a pack at or below its own cut-out gets `hi = 0` - it may still be
|
||||
charged, it may not be asked to discharge;
|
||||
* a pack at 100 % gets `lo = 0` - it may still be discharged, it may not
|
||||
be asked to charge.
|
||||
|
||||
⚠️ THE DISCHARGE HALF ASKS `peakguard.discharge_permitted()`, THE PREDICATE
|
||||
SAFETY-32 MADE PUBLIC, rather than writing `soc <= floor` a third time. That
|
||||
function is already the one place this question is answered - for the peak
|
||||
guard's own verdict and for `delivery.account()`'s discriminator - and a
|
||||
safety predicate transcribed into a third copy is two future disagreements
|
||||
about what it means. What is NOT reused is its wording, which is peak-guard
|
||||
flavoured; the floor handed in is the pack's own cut-out, exactly as
|
||||
`delivery.account()` hands it.
|
||||
|
||||
⚠️ AND A MISSING READING OR A MISSING FLOOR WITHHOLDS THE CLOSURE, NOT THE
|
||||
DISCHARGE - WHICH IS THE OPPOSITE DIRECTION FROM `discharge_permitted()`'s
|
||||
OWN, ON PURPOSE. That predicate answers "may this site SPEND this pack for
|
||||
money", where an unread SoC must not authorise the spend and a full battery
|
||||
is exactly the value that would disarm the floor. The question here is
|
||||
different: whether to take a unit's share away from it and hand it to a
|
||||
sibling. An absent measurement is not evidence that a battery is flat, and
|
||||
closing on it would park a healthy unit's discharge on every tick for as
|
||||
long as its sensor was missing - moving load onto its sibling on the
|
||||
strength of a reading nobody took.
|
||||
|
||||
Both absences already have a stronger answer arriving first, which is why
|
||||
this one costs nothing: a missing SoC stops the WHOLE SITE through
|
||||
`inputs_ok` (a site-level P_SAFETY set of 0 W), and a roster entry with no
|
||||
`bms_soc_floor` is refused at startup by validate.py rule 8 and by
|
||||
config.yaml's own schema, which requires the key of every entry. So the
|
||||
conjunction below is "positive evidence only": both numbers present, and the
|
||||
predicate says the pack is at or under its own cut-out.
|
||||
|
||||
⚠️ `discharge_permitted()` IS STILL THE PREDICATE, and it is still the only
|
||||
place the comparison is written - what this module decides for itself is the
|
||||
two None cases, which it decides differently and says why. The BOUNDARY
|
||||
(`<=`, so a pack sitting exactly on its floor is closed) comes from there
|
||||
and is graded against it over the whole percent range in test_packshare.py
|
||||
part 2, so a future change to that boundary moves both.
|
||||
|
||||
⚠️ AT P_SAFETY IT BINDS THE MAINTENANCE DRAIN TOO, AND THAT IS RIGHT RATHER
|
||||
THAN AN OVERSIGHT. main.py files this as a `P_SAFETY` limit, so clause 2 of
|
||||
the arbiter's rule applies it to the §12 recalibration drain (a SET at
|
||||
P_MAINTENANCE): a drain into a pack already at its BMS cut-out is clamped to
|
||||
0 W. On the shipped numbers it cannot even bite - `maintenance_soc_floor`
|
||||
defaults to 11 % against a 10 % cut-out, so the drain finishes above this
|
||||
interval. On a config that sets the drain target BELOW the cut-out
|
||||
(`int(5,30)` against `int(0,50)`, so the schema permits it) the drain stalls
|
||||
and the FSM's 12 h DRAIN timeout eventually reports "drain stuck" - which is
|
||||
what ALREADY happens without this card, because the pack's own BMS refuses
|
||||
the command whatever the allocator sends. The failure is the config's, the
|
||||
remedy is the same, and this envelope does not make it worse; it only makes
|
||||
the refusal visible one layer earlier, in the allocation record.
|
||||
|
||||
⚠️ BOTH ENDS CANNOT CLOSE AT ONCE ON ANY CONFIGURATION THE SCHEMA ACCEPTS.
|
||||
It would take a pack reporting at least 100 % while sitting at or under its
|
||||
own cut-out, and `config.yaml` bounds `bms_soc_floor` at `int(0,50)` on both
|
||||
the flat key and every roster entry - so the two conditions cannot both hold.
|
||||
If they somehow did, the result is `[0, 0]`, which is an interval containing
|
||||
exactly one integer rather than a contradiction, and the allocator commands
|
||||
that unit 0 W. Nothing is special-cased for it here: inventing a second
|
||||
opinion about an unreachable state is how unreachable states get opinions
|
||||
that are wrong.
|
||||
"""
|
||||
hi, lo, why = inf, -inf, ""
|
||||
if (pack.soc is not None and pack.bms_floor is not None
|
||||
and discharge_permitted(pack.soc, pack.bms_floor) is not None):
|
||||
hi = 0.0
|
||||
why = (f"{pack.name} is at {float(pack.soc):g} %, at or under its own "
|
||||
f"{float(pack.bms_floor):g} % BMS cut-out - it is not asked to "
|
||||
f"discharge")
|
||||
if pack.soc is not None and float(pack.soc) >= FULL_PCT:
|
||||
lo = 0.0
|
||||
why = (f"{pack.name} is at {float(pack.soc):g} % and cannot take a "
|
||||
f"charge - it is not asked to")
|
||||
return PackEnvelope(lo=lo, hi=hi, why=why)
|
||||
|
||||
|
||||
def weights(packs, target_w) -> dict | None:
|
||||
"""Per-unit comfort weights for THIS tick's site target, or None for none.
|
||||
|
||||
The weight of a pack is the energy it has left IN THE DIRECTION THE SITE IS
|
||||
ABOUT TO TRAVEL - usable energy above its own cut-out when the site is
|
||||
discharging, room below full when it is charging. `allocator._fill()` uses
|
||||
only the RATIOS, so the unit is free and kWh is the honest one to leave it
|
||||
in: it is what a reader of the action log can check against the datasheet.
|
||||
|
||||
⚠️ THE DIRECTION IS AN ARGUMENT BECAUSE IT IS NOT KNOWABLE ANY EARLIER. The
|
||||
two directions give OPPOSITE answers on the same roster - A at 20 % of
|
||||
15.36 kWh and B at 80 % of 20.48 kWh weigh 1.5 : 14.3 to discharge and
|
||||
12.3 : 4.1 to charge - so a direction-free weighting is not a compromise
|
||||
between them, it is wrong in one of the two on every tick. The site target
|
||||
is chosen by `arbiter.site_resolve()`, i.e. by stage 2, so the earliest
|
||||
honest moment to ask this is between stage 2 and stage 3. That is why
|
||||
`resolve()` accepts a CALLABLE here and calls it with the resolved target,
|
||||
and why main.py hands it a method rather than a dict.
|
||||
|
||||
⚠️ `target_w >= 0` IS THE DISCHARGE SIDE, AND IT IS THE SITE TARGET'S SIGN
|
||||
RATHER THAN THE FILL'S. `allocator.plan()` decides its own direction from
|
||||
`need = remainder - placed`, which differs from `sign(target_w)` only when a
|
||||
PIN or an envelope that excludes zero has already moved the base - a
|
||||
maintenance drain, a failsafe park. On those ticks the weighting is computed
|
||||
for the other direction and is therefore merely a worse preference; it
|
||||
cannot be unsafe, for ARCH-01's reason, and it cannot break the sum, which
|
||||
is integer arithmetic in the allocator. Chasing the exact direction would
|
||||
mean handing this function into `plan()` and calling it from inside the
|
||||
fill, which would make the allocator's purity depend on a caller's callback.
|
||||
|
||||
None means "no opinion, weigh everything 1.0", and there are three ways to
|
||||
get it, all of which are today's behaviour rather than a new one:
|
||||
|
||||
* fewer than two packs. At N<=1 `plan()` is a pass-through and a weighting
|
||||
would be arithmetic nobody reads;
|
||||
* any pack that cannot state its headroom - see the module docstring on
|
||||
why a half-known roster is worse than an unweighted one;
|
||||
* every pack at zero headroom in this direction. All-zero weights would
|
||||
put the whole roster into `_fill()`'s LAST-RESORT pass, where the shares
|
||||
come out equal anyway and every `UnitPlan.why` reads "last resort: the
|
||||
preferred units were full" - a true sentence about the wrong thing.
|
||||
"""
|
||||
packs = list(packs)
|
||||
if len(packs) < 2:
|
||||
return None
|
||||
room = (usable_kwh if float(target_w) >= 0.0 else spare_kwh)
|
||||
out = {}
|
||||
for p in packs:
|
||||
kwh = room(p)
|
||||
if kwh is None or not isfinite(kwh):
|
||||
return None
|
||||
out[p.name] = float(kwh)
|
||||
if not any(v > 0.0 for v in out.values()):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def trade_power_w(packs, floor_pct, ceiling_pct) -> tuple:
|
||||
"""`(charge_w, discharge_w)` - how hard an arbitrage trade may be SIZED.
|
||||
|
||||
The site rating is what the site can push; this is what the site can push
|
||||
AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` was handed
|
||||
`site_max_w` on BOTH sides, so a two-pack site would ask to buy at the sum of
|
||||
the ratings whatever either pack's state of charge was - and the watts that
|
||||
could not be placed would come back as `unmet_w` for the site integrator to
|
||||
chase for the rest of the hour.
|
||||
|
||||
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
|
||||
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, BECAUSE SIZING A
|
||||
TRADE IS THE TRADE'S BUSINESS. This answers "how much may this strategy ask
|
||||
for"; `envelope()` above answers "what may this pack be commanded at all",
|
||||
and that one is the pack's own physical cut-out for SAFETY-25's reason.
|
||||
Sizing with the physical floor would let the strategy ask for watts its own
|
||||
reserve forbids; bounding the envelope with the trade's reserve would let a
|
||||
trade preference stop the grid-following loop. TWO QUESTIONS, TWO NUMBERS.
|
||||
|
||||
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack carries the trade
|
||||
is the allocator's answer, and the only per-pack instrument it has for a
|
||||
TRADE reserve is the weights - which are a preference, not a permission. So
|
||||
a pack between its arbitrage floor and its BMS cut-out can still take part
|
||||
of a sell. That is stated rather than fixed: fixing it needs an envelope
|
||||
that depends on WHICH CLAIM WON the tick, which the arbiter's partition does
|
||||
not express, and it is a card of its own.
|
||||
|
||||
A pack whose SoC did not read is counted on NEITHER side: withholding is the
|
||||
direction that cannot over-size, and `inputs_ok` has stopped the site by
|
||||
then anyway.
|
||||
|
||||
⚠️ NEITHER SIDE MAY REACH `arbitrage.claim()` AS A ZERO - a 0 W arbitrage
|
||||
claim is a PARK ORDER that outranks the loop. It cannot be zero on a tick
|
||||
that trades today, because the sell gate refuses on the emptiest pack and
|
||||
the buy gate refuses when every pack is at the ceiling; `arbitrage.decide()`
|
||||
refuses a 0 W sizing by name anyway, so that is structural rather than a
|
||||
coincidence of the gates.
|
||||
"""
|
||||
return (float(sum(p.max_w for p in packs
|
||||
if p.soc is not None and float(p.soc) < ceiling_pct)),
|
||||
float(sum(p.max_w for p in packs
|
||||
if p.soc is not None and float(p.soc) > floor_pct)))
|
||||
@@ -140,6 +140,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from textwrap import dedent
|
||||
from urllib.parse import urlencode
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price")
|
||||
|
||||
@@ -184,6 +185,87 @@ def brussels(instant: datetime) -> datetime:
|
||||
return instant.astimezone(brussels_offset(instant))
|
||||
|
||||
|
||||
def brussels_date_range(since: datetime, until: datetime) -> tuple:
|
||||
"""The Brussels calendar days a half-open window [since, until) touches.
|
||||
|
||||
Returns `(first, last)`, two `date`s, BOTH INCLUSIVE - which is the shape
|
||||
`api.energy-charts.info` takes: `?bzn=BE&start=2026-09-09&end=2026-09-10`
|
||||
answered 192 quarter-hours across exactly those two local days, measured
|
||||
2026-09-10T07:45:49Z and committed as
|
||||
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`.
|
||||
|
||||
THE DEFECT THIS EXISTS TO PREVENT (TARIFF-09). `generic_dayahead.fetch()`
|
||||
accepted `since` and `until` and requested the bare default URL, so the
|
||||
feed answered ONE Brussels day. Everything past today was then carried
|
||||
forward from yesterday's profile, flagged provisional, and §8.3 dropped all
|
||||
of it from the trade set - so the optimiser never saw tomorrow as firm, at
|
||||
any hour. Measured on the merged tree at 2026-09-10T09:00+02:00 over a 48 h
|
||||
horizon: 15 firm whole hours and 33 carried, and both ends of the only
|
||||
permitted cycle inside ONE calendar day. The ceiling was structural: at
|
||||
hour H of the local day a one-day window gives at most 24-H firm hours and
|
||||
24+H carried - 1/47 at 23:00, where the firm spread no longer even clears
|
||||
the round-trip cost. That is not what FCST-04's fill is for; it covers the
|
||||
pre-publication window until the ~13:00-14:00 CET auction, after which a
|
||||
healthy feed hands over tomorrow in full.
|
||||
|
||||
⚠️ THE CALENDAR IS BRUSSELS, NEVER UTC, AND THIS IS `_bdate()`'s LESSON ONE
|
||||
LAYER UP. `since`/`until` are INSTANTS; the API takes DATES, and the two are
|
||||
not the same question. 22:30 UTC in summer is already tomorrow in Belgium,
|
||||
so a range derived from `since.date()` asks for the wrong FIRST day for the
|
||||
last one or two hours of every single day - and the day it drops is the one
|
||||
the controller is standing in. price_history.py made this mistake at the
|
||||
cache layer and documents the same arithmetic; here it decides what is
|
||||
fetched at all, so getting it wrong loses the hours rather than misfiling
|
||||
them.
|
||||
|
||||
⚠️ `until` IS EXCLUSIVE, SO LOCAL MIDNIGHT BELONGS TO THE DAY BEFORE IT.
|
||||
A 48 h horizon from local midnight ends at local midnight two days later,
|
||||
and that instant is the first tick of a day the caller did not ask for.
|
||||
Taking `brussels(until).date()` unconditionally would request a THIRD day
|
||||
on every whole-hour horizon. This feed answers such a request without
|
||||
complaint, so nothing would report it - which is the reason to get it right
|
||||
here rather than to rely on the server's tolerance: the range is a claim
|
||||
about what the caller asked for, and it should be true.
|
||||
|
||||
⚠️ AND AN EMPTY OR INVERTED WINDOW COLLAPSES TO ONE DAY RATHER THAN TO A
|
||||
BACKWARDS RANGE. `start` after `end` is a request no feed has a defined
|
||||
answer for; some return everything, some return an error, and neither is a
|
||||
thing to hand a battery controller.
|
||||
"""
|
||||
first = brussels(since).date()
|
||||
edge = brussels(until)
|
||||
last = edge.date()
|
||||
if (edge.hour, edge.minute, edge.second, edge.microsecond) == (0, 0, 0, 0):
|
||||
last = last - timedelta(days=1)
|
||||
if last < first:
|
||||
last = first
|
||||
return first, last
|
||||
|
||||
|
||||
def dated_feed_url(base: str, **params) -> str:
|
||||
"""`base` plus a query string. Refuses a `base` that already carries one.
|
||||
|
||||
⚠️ THE REFUSAL IS THE POINT, NOT THE FORMATTING. Appending `&start=...` to
|
||||
a URL that may already hold its own query string is how a plugin produces a
|
||||
request nobody intended - `?bzn=BE?bzn=BE&start=...`, or a duplicated
|
||||
parameter whose winner is the server's choice rather than ours. So the base
|
||||
is required to be parameterless and the parameters are added exactly once,
|
||||
by `urlencode`, which is also what keeps a date out of the escaping
|
||||
question. A future edit that folds the query back into the constant is a
|
||||
refused fetch with a sentence, not a silently malformed URL.
|
||||
|
||||
⚠️ IT IS NOT REACHED BY AN OPERATOR'S `price_feed_url`. See
|
||||
`GenericDayAhead.fetch()`: an override is used exactly as given.
|
||||
"""
|
||||
if "?" in base or "#" in base:
|
||||
raise SourceUnavailable(
|
||||
f"the feed base {base!r} already carries a query string or "
|
||||
f"fragment, and this builder appends parameters. Adding a second "
|
||||
f"'?' produces a URL the server parses differently from the one "
|
||||
f"anybody intended; put the fixed parameters in the call instead")
|
||||
return f"{base}?{urlencode(params)}" if params else base
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Absence, borrowed wholesale from sim/riglock.py (TEST-04).
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -684,6 +766,47 @@ def price_option(opts: dict, key: str):
|
||||
return v
|
||||
|
||||
|
||||
def provider_id(opts: dict) -> str:
|
||||
"""The selected plugin id, normalised. `"off"` when none is selected.
|
||||
|
||||
⚠️ ONE SPELLING OF THIS EXPRESSION, AND TARIFF-10 IS WHY IT IS A FUNCTION.
|
||||
`str(price_option(opts, "price_provider") or "off").strip()` was written out
|
||||
twice - in `load_prices()` below and in `static_tariff.resolve_tariff()` -
|
||||
and main.py's startup gate would have been a third. "Is a provider
|
||||
configured" is now a DECISION the add-on takes (it is what starts the
|
||||
background refresh), and a decision spelled out at three call sites is three
|
||||
places to disagree about whether `" off "` or `None` counts.
|
||||
⚠️ `validate.py` deliberately keeps its own reader: it validates the RAW
|
||||
options blob, including one the Supervisor never saw, so it must not route
|
||||
through `price_option()`'s defaults. Note what this does NOT do: it does not
|
||||
decide
|
||||
whether the id names a shipped plugin - `load_prices()` and validate.py's
|
||||
rule 7 own that, and answering "unknown" here would turn a typo into
|
||||
"no provider" instead of into a startup refusal.
|
||||
"""
|
||||
return str(price_option(opts, "price_provider") or "off").strip()
|
||||
|
||||
|
||||
def provider_configured(opts: dict) -> bool:
|
||||
"""Has the operator selected a price feed at all? (TARIFF-10)
|
||||
|
||||
THE DEFECT THIS PREVENTS: the background tariff refresh used to start only
|
||||
when `arbitrage_enabled` was on, which ships `false` - so a site with a
|
||||
provider configured fetched NOTHING, ever. `/data/price_history.json` was
|
||||
never created, the *Price history* status row read `empty` forever, and
|
||||
TARIFF-09's short-horizon warning could not fire because no fetch happened
|
||||
to warn about. Measured over a 40 h two-instance soak: zero feed requests
|
||||
and `present: false` across all 802 samples on the shipped default, against
|
||||
a resolved plan on the same feed with arbitrage armed.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH AND MUST NEVER BECOME ONE. It answers
|
||||
"fetch prices?"; `arbitrage.enabled()` answers "move energy on them?", and
|
||||
§10.1's priority 3 is gated on the second alone. See
|
||||
`main.tariff_refresh_wanted()` for the one place the two meet.
|
||||
"""
|
||||
return provider_id(opts) not in ("", "off")
|
||||
|
||||
|
||||
def export_price_eur_kwh(opts: dict) -> float:
|
||||
"""AC4 / §9.3: the injection price is a SEPARATE SCALAR, not a second series.
|
||||
|
||||
@@ -731,6 +854,24 @@ class PriceProvider(ABC):
|
||||
`transport(url, timeout_s) -> bytes` is injected so the checks never
|
||||
touch a network and `manual_csv` never needs one. `opts` is a
|
||||
`PluginOptions`, so the markup and taxes are not reachable from here.
|
||||
|
||||
⚠️ `since`/`until` ARE THE RANGE THE CALLER WANTS, AND A PLUGIN THAT
|
||||
IGNORES THEM GETS WHATEVER ITS FEED DEFAULTS TO (TARIFF-09). That is
|
||||
not a small difference: `generic_dayahead` ignored both, the public
|
||||
EPEX feed answered ONE Brussels day, and §8.3 then dropped every hour
|
||||
past today from the trade set as provisional - so the optimiser never
|
||||
saw tomorrow as firm, at any hour of any day, and nothing reported it.
|
||||
|
||||
This promise is NOT mechanically enforced, and the honest reason is
|
||||
TARIFF-08's: three of the five shipped plugins point at endpoints
|
||||
nobody has verified (TARIFF-07 is the open decision on withdrawing
|
||||
them), so nobody knows how those feeds spell a date range. A rule
|
||||
obliging them to pass one would be satisfied by an INVENTED parameter
|
||||
name - the parser-graded-against-its-own-guess defect, moved from the
|
||||
response to the request. What exists instead is a roster pin in
|
||||
test_price.py: which plugins consume the range and which do not is
|
||||
asserted as a literal, so a sixth supplier, or a change to one of the
|
||||
three, forces the decision rather than inheriting the silence.
|
||||
"""
|
||||
|
||||
|
||||
@@ -979,9 +1120,166 @@ def _points_from_json(body, price_key, scale, provisional_default):
|
||||
return out
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TARIFF-08: the COLUMNAR document, which is what the public EPEX feed really
|
||||
# publishes.
|
||||
#
|
||||
# ⚠️ THIS IS A SECOND SHAPE, READ BY A SECOND FUNCTION ON PURPOSE.
|
||||
# `_points_from_json()` above reads a row-per-point document: `{"data": [{...},
|
||||
# {...}]}`. `https://api.energy-charts.info/price?bzn=BE` does not publish that
|
||||
# and never did - it publishes PARALLEL ARRAYS:
|
||||
#
|
||||
# {"unix_seconds": [1788991200, 1788992100, ...], <- UTC epoch seconds
|
||||
# "price": [216.08, 209.90, ...], <- one per instant
|
||||
# "unit": "EUR / MWh", "license_info": "...", "deprecated": false}
|
||||
#
|
||||
# The shipped parser was pointed at that URL and asked for `doc["data"]`, so
|
||||
# every fetch ended `SourceUnavailable: the feed carried no 'data' list` ->
|
||||
# §11.4 -> static profile, arbitrage off, and nothing in the status page said
|
||||
# why. Verified live 2026-09-10 against both fixtures under `scenarios/`.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT ONE FUNCTION THAT SNIFFS FOR BOTH. A parser that decides
|
||||
# which document it got is a parser that can decide wrong, and a wrong-but-
|
||||
# parseable response then becomes a price. Two shapes, two functions, and each
|
||||
# plugin names the one its own feed publishes.
|
||||
#
|
||||
# The columnar shape was already known in this repository: DATA-02's
|
||||
# `scenarios/fetch_epex_be.py` has read `zip(d["unix_seconds"], d["price"])`
|
||||
# since 2026-08-31. Only the SHIPPED path never learned it, because every
|
||||
# fixture in the suite was built from the invented row shape - the parser was
|
||||
# graded against its own assumption. See test_price.py section 2b.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
COLUMNAR_TIME_KEY = "unix_seconds"
|
||||
COLUMNAR_PRICE_KEY = "price"
|
||||
COLUMNAR_UNIT_KEY = "unit"
|
||||
|
||||
|
||||
def _columnar_unit(text) -> str:
|
||||
"""`"EUR / MWh"` -> `"eur/mwh"`. Spacing and case are not information."""
|
||||
return "".join(str(text).split()).lower()
|
||||
|
||||
|
||||
def _points_from_columnar_json(body, expect_unit, scale,
|
||||
provisional_default):
|
||||
"""Parse a COLUMNAR price document: parallel `unix_seconds` / `price`.
|
||||
|
||||
Defects this refuses, each of which has a way of becoming a number rather
|
||||
than an error:
|
||||
|
||||
* ⚠️ UNEQUAL ARRAYS ARE `SourceUnavailable`, NOT A SHORT SERIES. `zip()`
|
||||
stops at the shorter array and says nothing, so a `price` list that
|
||||
arrived truncated would produce a REAL price series with its tail
|
||||
missing - and the tail of a Belgian day is the evening peak. A series
|
||||
that is missing the expensive hours is worse than no series: §11.4's
|
||||
no-data row switches arbitrage OFF, whereas a truncated series is
|
||||
traded on. Nothing downstream can tell the two apart, so the refusal
|
||||
has to happen here.
|
||||
* ⚠️ A UNIT THAT IS NOT THE DECLARED ONE IS REFUSED. `scale` is the
|
||||
caller's standing assumption about the unit; if the feed ever
|
||||
publishes EUR/kWh under the same keys, every price silently comes out
|
||||
a THOUSAND times too small. `_validate_raw()` cannot catch that -
|
||||
`MAX_PLAUSIBLE_EUR_KWH` only bounds prices that are too LARGE.
|
||||
DATA-02's fetcher refuses on the same field for the same reason.
|
||||
* ⚠️ `unix_seconds` ARE UTC INSTANTS, SAID SO EXPLICITLY.
|
||||
`datetime.fromtimestamp(t, timezone.utc)`. Without that second
|
||||
argument CPython returns a NAIVE datetime in the HOST's zone, and a
|
||||
naive datetime is a wall clock reading plus an assumption about whose
|
||||
clock it was. `brussels()` and `_validate_raw()` both refuse one, so
|
||||
the mistake surfaces as a refused fetch rather than as a mispriced
|
||||
day - but they refuse ALL naive datetimes and can say nothing about
|
||||
whether this one was the right instant. That part is decided here.
|
||||
|
||||
§9.3's rendering AT THE BRUSSELS OFFSET is `assemble()`'s, once, for
|
||||
every plugin - `brussels(p.start)` there is the single line that does
|
||||
it, and calling it here as well would be a second copy of a rule that
|
||||
already has an owner, indistinguishable from a no-op under mutation.
|
||||
A plugin's job is the right INSTANT; the framework's is the rendering.
|
||||
|
||||
A `null` price is DROPPED and logged, not fatal, and that is a different
|
||||
judgement from the one above: an unequal pair of arrays means the pairing
|
||||
of instant to price is unreliable, whereas a null means the source stated
|
||||
it published no price for that one instant. A dropped instant leaves a
|
||||
hole, and `_Series.at()` already answers a hole with `Absence` - §11.4 for
|
||||
that hour. `scenarios/fetch_epex_be.py` dropped 0 nulls over the 12 months
|
||||
to 2026-08-31, so this is a shape the source allows rather than one it uses.
|
||||
"""
|
||||
try:
|
||||
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
|
||||
else str(body)
|
||||
doc = json.loads(text)
|
||||
except (ValueError, UnicodeDecodeError) as e:
|
||||
raise SourceUnavailable(f"the feed did not parse as JSON: {e}")
|
||||
if not isinstance(doc, dict):
|
||||
raise SourceUnavailable(
|
||||
f"the feed is a {type(doc).__name__}, not a columnar object with "
|
||||
f"{COLUMNAR_TIME_KEY!r} and {COLUMNAR_PRICE_KEY!r} arrays")
|
||||
times = doc.get(COLUMNAR_TIME_KEY)
|
||||
prices = doc.get(COLUMNAR_PRICE_KEY)
|
||||
for key, col in ((COLUMNAR_TIME_KEY, times), (COLUMNAR_PRICE_KEY, prices)):
|
||||
if not isinstance(col, list):
|
||||
raise SourceUnavailable(
|
||||
f"the feed carried no {key!r} array; this provider reads the "
|
||||
f"columnar shape (parallel {COLUMNAR_TIME_KEY}/"
|
||||
f"{COLUMNAR_PRICE_KEY} arrays), and a row-per-point document "
|
||||
f"is a different feed, not this one with a field missing")
|
||||
if len(times) != len(prices):
|
||||
raise SourceUnavailable(
|
||||
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
|
||||
f"{COLUMNAR_PRICE_KEY} has {len(prices)}; refusing to zip them. "
|
||||
f"The short array would truncate the series silently, and a price "
|
||||
f"series missing its last hours is one missing the evening peak - "
|
||||
f"traded on as if complete, where no data at all would have "
|
||||
f"switched arbitrage off (§11.4)")
|
||||
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
|
||||
if unit != _columnar_unit(expect_unit):
|
||||
raise SourceUnavailable(
|
||||
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
|
||||
f"{expect_unit!r}. This provider converts with a fixed factor of "
|
||||
f"{scale}, so a changed unit is a factor of 1000 on every price "
|
||||
f"with nothing downstream able to notice it")
|
||||
if doc.get("deprecated"):
|
||||
# Not a refusal: today's prices are still prices. But §11.4 makes a
|
||||
# dead feed look exactly like a quiet one, so the warning that the
|
||||
# source is going away has to be emitted while it still works.
|
||||
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
|
||||
"but this endpoint is scheduled to stop",
|
||||
doc.get("deprecated"))
|
||||
out = []
|
||||
dropped = []
|
||||
for i, (t, v) in enumerate(zip(times, prices)):
|
||||
if v is None:
|
||||
dropped.append(i)
|
||||
continue
|
||||
if isinstance(t, bool) or not isinstance(t, (int, float)) or \
|
||||
not math.isfinite(t):
|
||||
raise SourceUnavailable(
|
||||
f"{COLUMNAR_TIME_KEY}[{i}] is {t!r}, not epoch seconds")
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float)) or \
|
||||
not math.isfinite(v):
|
||||
raise SourceUnavailable(
|
||||
f"{COLUMNAR_PRICE_KEY}[{i}] is {v!r}, which is not a price")
|
||||
start = datetime.fromtimestamp(float(t), timezone.utc)
|
||||
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
|
||||
provisional=bool(provisional_default)))
|
||||
if dropped:
|
||||
_LOG.warning("the feed published no price for %d of %d instants "
|
||||
"(index %s); those instants are left OUT of the series, "
|
||||
"never zero-filled - a missing hour is not a free hour",
|
||||
len(dropped), len(times),
|
||||
", ".join(str(i) for i in dropped[:5])
|
||||
+ (", ..." if len(dropped) > 5 else ""))
|
||||
if not out:
|
||||
raise SourceUnavailable(
|
||||
f"the feed carried {len(times)} instants and a price for none of "
|
||||
f"them")
|
||||
return out
|
||||
|
||||
|
||||
@register
|
||||
class GenericDayAhead(PriceProvider):
|
||||
"""Public EPEX BE day-ahead feed, in EUR/MWh.
|
||||
"""Public EPEX BE day-ahead feed, COLUMNAR, in EUR/MWh.
|
||||
|
||||
SOURCES: energy
|
||||
The framework ADDS: markup, taxes
|
||||
@@ -990,6 +1288,19 @@ class GenericDayAhead(PriceProvider):
|
||||
is the wholesale clearing price and nothing else, so both user-entered
|
||||
components have to be added to it - and because this plugin cannot read
|
||||
either number, it cannot be the one that adds them twice.
|
||||
|
||||
⚠️ IT READS THE COLUMNAR SHAPE, AND SAYS SO IN ITS OWN `fetch()`. Until
|
||||
TARIFF-08 it called `_points_from_json()` and asked
|
||||
`api.energy-charts.info` for a `"data"` list it has never published, so
|
||||
every fetch this provider ever made ended in `SourceUnavailable` and §11.4
|
||||
turned that into a silent fall back to the static profile. See
|
||||
`_points_from_columnar_json()` for the shape and for why there are two
|
||||
parsers rather than one that guesses.
|
||||
|
||||
⚠️ THE FEED IS QUARTER-HOURLY SINCE BELGIUM'S 15-MINUTE MTU (2025-10-01):
|
||||
96 points a day at 900 s, not 24 at 3600 s. Nothing here assumes either -
|
||||
the instants come from the document - and both granularities are replayed
|
||||
from real captured bodies in test_price.py section 2b.
|
||||
"""
|
||||
|
||||
id = "generic_dayahead"
|
||||
@@ -1001,12 +1312,68 @@ class GenericDayAhead(PriceProvider):
|
||||
sources=frozenset({Component.ENERGY}),
|
||||
egress_hosts=("api.energy-charts.info", "web-api.tp.entsoe.eu"))
|
||||
|
||||
FEED_BASE = "https://api.energy-charts.info/price"
|
||||
BZN = "BE"
|
||||
|
||||
async def fetch(self, opts, since, until, transport):
|
||||
url = opts.get("price_feed_url") or \
|
||||
"https://api.energy-charts.info/price?bzn=BE"
|
||||
"""Request the range the CALLER asked for, on the Brussels calendar.
|
||||
|
||||
⚠️ THE ARGUMENTS ARE USED. Until TARIFF-09 this method took `since` and
|
||||
`until` and asked for the bare `?bzn=BE`, which answers ONE Brussels
|
||||
day - so a 48 h horizon was 15 firm hours and 33 carried at 09:00, and
|
||||
1 firm and 47 carried at 23:00, with the whole trade set inside a
|
||||
single calendar day at every hour of every day. See
|
||||
`brussels_date_range()` for the measurement and for why the calendar
|
||||
is Brussels rather than UTC.
|
||||
|
||||
⚠️ AN OVERRIDE IS THE EXACT URL AND THIS METHOD APPENDS NOTHING TO IT,
|
||||
WHICH COSTS THE OVERRIDING SITE THE RANGE. That is a decision, not an
|
||||
oversight, and the sentence below is why it is not a footnote:
|
||||
|
||||
* `price_feed_url` says "override the plugin's default feed", and an
|
||||
override that is silently rewritten is not one. Merging parameters
|
||||
into an operator's query string means parsing and re-serialising a
|
||||
URL they wrote - which reorders, de-duplicates and re-escapes it,
|
||||
and can hand the server a request nobody typed.
|
||||
* `start`/`end` are ENERGY-CHARTS' spelling, and this provider
|
||||
DECLARES A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has
|
||||
verified that host's day-ahead parameters and nothing here should
|
||||
guess them - but they are not `start`/`end`, so an override to the
|
||||
other DECLARED host is exactly the case where appending our
|
||||
vocabulary adds two parameters that host ignores and yields the
|
||||
short horizon anyway, with nothing saying so.
|
||||
* and blind appending is refused outright: `dated_feed_url()` will
|
||||
not add a query string to a base that has one.
|
||||
|
||||
So the cost is real and it is REPORTED, at fetch time, on the shipped
|
||||
logger, naming the horizon consequence rather than the substitution.
|
||||
§11.4 is what makes a price defect quiet; an operator who has taken the
|
||||
short horizon has to be able to find out from the log that they did.
|
||||
"""
|
||||
override = str(opts.get("price_feed_url") or "").strip()
|
||||
if override:
|
||||
url = override
|
||||
_LOG.warning(
|
||||
"price_feed_url is set, so %s is requesting %s exactly as "
|
||||
"given and is adding NO date range to it. The public feed "
|
||||
"answers a single Brussels day to a request that names no "
|
||||
"range, and every hour past that day is then carried forward "
|
||||
"from yesterday's profile and flagged provisional - §8.3 "
|
||||
"drops provisional hours from the trade set, so arbitrage "
|
||||
"would never see tomorrow as firm. Clear price_feed_url to "
|
||||
"let this plugin ask for the %s..%s it was given.",
|
||||
self.id, url, *(d.isoformat()
|
||||
for d in brussels_date_range(since, until)))
|
||||
else:
|
||||
first, last = brussels_date_range(since, until)
|
||||
url = dated_feed_url(self.FEED_BASE, bzn=self.BZN,
|
||||
start=first.isoformat(),
|
||||
end=last.isoformat())
|
||||
check_egress(url, self.capabilities())
|
||||
body = await transport(url, float(opts.get("price_timeout_s")))
|
||||
return _points_from_json(body, "price_eur_mwh", 0.001, False)
|
||||
return _points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||||
scale=0.001,
|
||||
provisional_default=False)
|
||||
|
||||
|
||||
@register
|
||||
@@ -1190,8 +1557,8 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
site condition, it is a plugin trying to read the markup, and turning it
|
||||
into "no price data" would hide the exact defect trap 1 exists to catch.
|
||||
"""
|
||||
pid = str(price_option(opts, "price_provider") or "off").strip()
|
||||
if pid in ("", "off"):
|
||||
pid = provider_id(opts)
|
||||
if not provider_configured(opts):
|
||||
return PriceRead.no_data(
|
||||
"no price provider configured (price_provider is 'off'); "
|
||||
"§11.4: static profile, plan provisional, no arbitrage")
|
||||
|
||||
@@ -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")
|
||||
@@ -1019,7 +1019,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 +1033,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>>")
|
||||
|
||||
@@ -570,7 +570,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
rules up. A rule the Supervisor duplicates costs nothing and covers the
|
||||
callers it does not.
|
||||
|
||||
Five things are refused, and each is a config that would otherwise RUN:
|
||||
Seven things are refused, and each is a config that would otherwise RUN:
|
||||
|
||||
a. `inverters` is not a list. `roster_entries()` wraps a scalar so this
|
||||
can name it; without the rule the roster would be one inverter built
|
||||
@@ -593,6 +593,18 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
config. Refused rather than ignored, for §1.5's reason and for this
|
||||
card's: a config that silently becomes a different config is worse than
|
||||
one that refuses to load.
|
||||
f. OPT-09. An entry whose `capacity_kwh` is stated but is not a usable
|
||||
positive number of kWh. It is read to weight the SPLIT by energy
|
||||
headroom; a zero, a negative or a word is a pack the split would
|
||||
quietly stop preferring while the operator can see a value in the box.
|
||||
g. OPT-09. `capacity_kwh` stated on SOME entries and not others. The
|
||||
weighting is a COMPARISON between packs, so a pack measured in kWh
|
||||
against a sibling weighing a dimensionless 1.0 is not one -
|
||||
`packshare.weights()` answers "no opinion" for the WHOLE roster in that
|
||||
case, which means every key the operator filled in does nothing at all
|
||||
and nothing says so. All or none, and this is clause (e)'s argument
|
||||
restated: a config that silently becomes a different config is worse
|
||||
than one that refuses to load.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
raw = opts.get("inverters")
|
||||
@@ -651,6 +663,42 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
"sibling is exactly what DEV-02 removed. State this unit's own "
|
||||
"rating in watts.")
|
||||
|
||||
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
|
||||
stated, blank = [], []
|
||||
for i, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue # already refused above, by name
|
||||
raw = entry.get("capacity_kwh")
|
||||
nm = str(entry.get("name") or "").strip()
|
||||
who = f"inverters[{i}] ({nm})" if nm else f"inverters[{i}]"
|
||||
if raw is None or raw == "":
|
||||
blank.append(who)
|
||||
continue
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
val = None
|
||||
if val is None or not (val > 0.0):
|
||||
problems.append(
|
||||
f"{who} has capacity_kwh {raw!r}, which is not a usable number "
|
||||
"of kilowatt-hours. It is THIS pack's usable capacity and it is "
|
||||
"read to weight the split of the site target by ENERGY headroom "
|
||||
"rather than by power rating; a zero or an unparseable value is "
|
||||
"a pack the split would quietly stop preferring while the box "
|
||||
"still shows a value. State it in kWh, or leave it empty on "
|
||||
"every entry.")
|
||||
else:
|
||||
stated.append(who)
|
||||
if stated and blank:
|
||||
problems.append(
|
||||
f"capacity_kwh is stated on {', '.join(stated)} and left empty on "
|
||||
f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the "
|
||||
"split weights one pack's energy headroom against another's, so a "
|
||||
"pack measured in kWh beside a sibling that weighs a dimensionless "
|
||||
"1.0 is not a comparison - the add-on answers 'no opinion' for the "
|
||||
"whole roster and the value you filled in changes nothing. State a "
|
||||
"capacity for every inverter, or for none of them.")
|
||||
|
||||
flat_sp = str(opts.get("setpoint_entity") or "").strip()
|
||||
if entries and flat_sp:
|
||||
problems.append(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.8.0"
|
||||
version: "0.9.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -108,6 +108,25 @@ options:
|
||||
# EVERY entry and names the one it refuses. The flat key below is unchanged and
|
||||
# is the single-inverter shape's own cut-out.
|
||||
#
|
||||
# ⚠️ OPT-09: `capacity_kwh` IS PER ENTRY AND IS OPTIONAL, WHICH IS THE
|
||||
# OPPOSITE CHOICE TO `bms_soc_floor` ABOVE AND IS MADE FOR THE SAME REASON.
|
||||
# It is THAT pack's usable capacity in kWh, and it is read for exactly one
|
||||
# purpose: weighting the SPLIT of the site target between packs by ENERGY
|
||||
# headroom rather than by power rating. The two packs here are asymmetric in
|
||||
# OPPOSITE directions - 5000 W / 15.36 kWh against 4600 W / 20.48 kWh, from
|
||||
# the datasheet and the serials - so a split by rating and a split by energy
|
||||
# hand the work to DIFFERENT packs, and the energy one is the question ("which
|
||||
# pack can sustain its share for the hour"). A missing cut-out would have to be
|
||||
# DEFAULTED, and one default for two batteries protects the shallower pack with
|
||||
# the deeper pack's number; a missing capacity has an honest answer instead -
|
||||
# this add-on then has NO OPINION about the split and every unit weighs 1.0,
|
||||
# which is exactly what shipped before this card. ⚠️ IT IS ALL OR NOTHING
|
||||
# ACROSS THE ROSTER: state it on every entry or on none, because a pack
|
||||
# weighted in kWh against a sibling weighing a dimensionless 1.0 is not a
|
||||
# comparison. There is deliberately NO flat `capacity_kwh` - at one inverter
|
||||
# there is no split to weight, so the key would be one an operator can set and
|
||||
# nothing reads.
|
||||
#
|
||||
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
|
||||
# roster is declared is two config sites aimed at actuators, one of which
|
||||
# nothing writes; if it names an entity an entry also names, SAFETY-28's
|
||||
@@ -246,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
|
||||
@@ -299,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
|
||||
@@ -325,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
|
||||
@@ -529,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.
|
||||
@@ -541,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.
|
||||
@@ -621,6 +678,12 @@ schema:
|
||||
# enforced per entry, a missing required key names the key, a scalar where the
|
||||
# list belongs is refused, and the UI renders it as a repeatable section.
|
||||
#
|
||||
# ⚠️ AND OPT-09's `capacity_kwh` IS DECLARED OPTIONAL (`?`), UNLIKE
|
||||
# `bms_soc_floor`, WHICH IS THE ARGUMENT IN THE OPTIONS BLOCK ABOVE: an
|
||||
# optional cut-out is a defaulted one and a default is one number for two
|
||||
# batteries, whereas an absent capacity has an honest answer - no opinion
|
||||
# about the split, every unit weighs 1.0, which is what shipped.
|
||||
#
|
||||
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
|
||||
# no `name` has no Store namespace and would share another unit's SAFETY-02
|
||||
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
|
||||
@@ -642,6 +705,7 @@ schema:
|
||||
soc_entity: str?
|
||||
batt_entity: str?
|
||||
batt_invert: bool?
|
||||
capacity_kwh: float(0.1,200)?
|
||||
|
||||
estop_fitted: bool
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
|
||||
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
|
||||
|
||||
print()
|
||||
print("ARB-03 2. the split: eight sources, each classified where it is BUILT")
|
||||
print("ARB-03 2. the split: eleven sources, each classified where it is BUILT")
|
||||
# The classification is graded against what each claim is ABOUT, restated here
|
||||
# so that the table in arbiter.py and the expectation in a test are not the same
|
||||
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
|
||||
@@ -180,16 +180,25 @@ UNIT_SOURCES = {
|
||||
"measured power (0x0518), so what it learns is about one "
|
||||
"device. The site-wide half moved to `sign_site`",
|
||||
"maintenance": "the monthly recalibration is a pack procedure",
|
||||
# OPT-09, and it is the entry a reader will want to check against
|
||||
# SAFETY-25's deferred loop-side reserve, because it has the same shape.
|
||||
"pack": "THIS pack's own state of charge against THIS pack's own "
|
||||
"`bms_soc_floor` - a fact about one battery in one cupboard, "
|
||||
"exactly as `device`'s rating is a fact about one box. It is "
|
||||
"not SAFETY-25's deferred `loop_soc_floor` reserve: no option "
|
||||
"is added, `peak_soc_floor` is not consulted, the SITE target "
|
||||
"is untouched, and main.py does not file it at all at a "
|
||||
"roster of one",
|
||||
}
|
||||
for _src, _why in sorted(SITE_SOURCES.items()):
|
||||
print(f" site {_src:<11} {_why}")
|
||||
for _src, _why in sorted(UNIT_SOURCES.items()):
|
||||
print(f" unit {_src:<11} {_why}")
|
||||
check("all TEN claim sources are classified, and only those ten",
|
||||
check("all ELEVEN claim sources are classified, and only those eleven",
|
||||
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||
check("the six that are about the GRID CONNECTION are site claims",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
|
||||
check("the four that are about a PACK, a DEVICE or one write are per-inverter",
|
||||
check("the five that are about a PACK, a DEVICE or one write are per-inverter",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
|
||||
check("a source nobody classified cannot be routed at all - actuator_for() "
|
||||
"raises rather than defaulting to the site, because defaulting to the "
|
||||
@@ -207,7 +216,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
|
||||
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
||||
check("...and with none configured at all, likewise",
|
||||
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
||||
check("with TWO the identity breaks and the split becomes real: the four "
|
||||
check("with TWO the identity breaks and the split becomes real: the five "
|
||||
"per-inverter sources route to the unit this controller drives, the six "
|
||||
"site sources stay on the grid connection",
|
||||
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""UX-04 - the defaults pin UX-01 AC 4 promised and never built.
|
||||
`python3 -B test_defaults_pin.py`
|
||||
|
||||
WHAT UX-01 AC 4 ASKED FOR, IN ITS OWN WORDS: "every `schema:` key absent from
|
||||
`options:` has exactly one defaults-table entry ... the table changed goes red
|
||||
... and NO MODULE CARRIES ITS OWN LITERAL FALLBACK FOR A SCHEMA DEFAULT - an AST
|
||||
check, not a grep."
|
||||
|
||||
WHAT SHIPPED. Clause one is VACUOUS: `options:` and `schema:` declare the same
|
||||
71 keys, so "every schema key absent from options" quantifies over the empty set
|
||||
and is true of any tree, including one with no defaults at all. Clause two -
|
||||
"the table changed goes red" - does work, six times over, once per mirror in its
|
||||
own suite. Clause three, THE ONE WITH THE CONTENT, was never written. The only
|
||||
literal scan in this repository before this file is
|
||||
`test_validate.py`'s `re.findall(r'"(maintenance_[a-z_]+)"...')`, which is a
|
||||
grep, is restricted to eight `maintenance_*` keys and three named files, and
|
||||
predates UX-01.
|
||||
|
||||
⚠️ AND A CHECK THAT CANNOT GO RED IS NOT A CHECK, so this file does not repeat
|
||||
clause one's mistake. Every part below states the size of the set it quantifies
|
||||
over and grades that size against a floor derived from the tree, and part 5
|
||||
plants a literal in a synthetic module and REQUIRES the scanner to catch it, so
|
||||
the red direction is exercised on every run rather than attested in a commit
|
||||
message. MUTATION.md habit 4.
|
||||
|
||||
--------------------------------------------------------------------------- #
|
||||
WHAT AN "IN-CODE LITERAL FALLBACK" IS, STRUCTURALLY, so a reader can argue with
|
||||
the definition rather than guess it. It is a call in `app/` that takes a STRING
|
||||
LITERAL naming a `schema:` key and, in the next positional slot, a CONSTANT:
|
||||
|
||||
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
|
||||
|
||||
Two shapes, one rule, found by walking `ast.Call` nodes - not by matching text,
|
||||
so `opts.get(\n "max_w",\n 2000,\n)` and a renamed `opts` are found too and
|
||||
a `max_w` inside a comment or a docstring is not.
|
||||
|
||||
TWO EXCLUSIONS, both structural and both narrow:
|
||||
|
||||
* `getattr(obj, "max_w", 0.0)` reads an ATTRIBUTE off an object, not an option
|
||||
off the operator's blob. Its third argument is an absent-attribute guard and
|
||||
has no relationship to config.yaml. Excluded by function name.
|
||||
|
||||
* THE EMPTY STRING. `opts.get("static_profile", "")` is Python's "this key is
|
||||
not stated", and every one of the six sites in `validate.py` immediately
|
||||
normalises it - `str(opts.get(k, "") or "").strip()`. It cannot be a stale
|
||||
copy of a NUMBER, which is the whole of the CFG-04 defect class, and
|
||||
rewriting those six to index a mirror would replace a sentence that reads
|
||||
"absent" with one that reads "the shipped default" at sites where absent is
|
||||
what is meant. Excluded by value. `0` is NOT excluded: `slew_w: 0` and
|
||||
`integrator_max_w: 0` are real shipped defaults with real meanings.
|
||||
|
||||
--------------------------------------------------------------------------- #
|
||||
WHAT THIS FILE DOES *NOT* DO, said before the parts rather than after them.
|
||||
|
||||
It does not remove the 27 (module, key) pairs in part 4's ledger. That is
|
||||
CFG-03's open ticket - option defaults living in four places - and it is a body
|
||||
of behaviour change across `main.py`, `p1.py`, `inverter.py` and `validate.py`
|
||||
that no single card should make in passing. What this file does instead is
|
||||
FREEZE the ledger: the set is graded EXACTLY, so the twenty-eighth pair is red
|
||||
on the commit that introduces it and the ledger can only ever shrink. A backlog
|
||||
that cannot grow is a backlog being paid down; one that is merely written in a
|
||||
comment is the state UX-01 left.
|
||||
|
||||
MUTATION.md is the method for the red-direction evidence in part 5, and
|
||||
`tools/mutation_run.sh` is the harness.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
APP = HERE / "app"
|
||||
|
||||
FAILED = 0
|
||||
|
||||
|
||||
def check(what, ok):
|
||||
global FAILED
|
||||
print((" ok " if ok else " FAIL ") + what)
|
||||
if not ok:
|
||||
FAILED += 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# config.yaml, parsed by hand.
|
||||
#
|
||||
# ⚠️ NO PyYAML, AND NOT AS A PREFERENCE. The add-on image installs py3-aiohttp
|
||||
# and py3-paho-mqtt only; `import yaml` raises in the checks container and a
|
||||
# check that skips on ImportError is not a check. `test_maintenance.py` and
|
||||
# `test_price.py` each carry their own two-line reader for the same reason, and
|
||||
# this is the third - which is itself an instance of the defect this file
|
||||
# grades, one level up. It is written down rather than tidied away because
|
||||
# tidying it into a shared helper is a change to two suites that are green.
|
||||
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
_SCHEMA_AT = CFG_TEXT.index("\nschema:")
|
||||
_OPTS_BLOCK, _SCHEMA_BLOCK = CFG_TEXT[:_SCHEMA_AT], CFG_TEXT[_SCHEMA_AT:]
|
||||
|
||||
|
||||
def _scalar(raw):
|
||||
"""One `options:` value, as the yaml module this image lacks would read it."""
|
||||
raw = raw.split("#", 1)[0].strip().strip('"').strip("'")
|
||||
if raw in ("true", "false"):
|
||||
return raw == "true"
|
||||
if raw == "[]":
|
||||
return []
|
||||
for cast in (int, float):
|
||||
try:
|
||||
return cast(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
return raw
|
||||
|
||||
|
||||
def _block_keys(block):
|
||||
return [m.group(1) for m in re.finditer(r"^ ([a-z0-9_]+):", block, re.M)]
|
||||
|
||||
|
||||
SHIPPED = {m.group(1): _scalar(m.group(2))
|
||||
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", _OPTS_BLOCK, re.M)}
|
||||
SCHEMA = set(_block_keys(_SCHEMA_BLOCK))
|
||||
|
||||
print("UX-04 - the defaults pin\n")
|
||||
print("1. the surface this file quantifies over")
|
||||
print(f" config.yaml: {len(SHIPPED)} keys under `options:`, {len(SCHEMA)} "
|
||||
f"under `schema:`")
|
||||
# The floor, for part 1's own reason: a reader that returned {} would make every
|
||||
# check below vacuously true. 60 is the count of REQUIRED schema keys and is the
|
||||
# number `tools/supervisor_shape_probe.py` section F now grades; any floor well
|
||||
# inside the real size does the job, and this one cannot be reached by accident.
|
||||
check(f"the hand reader found a real options block ({len(SHIPPED)} keys, floor 60)",
|
||||
len(SHIPPED) >= 60)
|
||||
check(f"...and a real schema block ({len(SCHEMA)} keys, floor 60)",
|
||||
len(SCHEMA) >= 60)
|
||||
check("every shipped default names a schema key, so a mirror pinned to one is "
|
||||
"pinned to the other",
|
||||
sorted(set(SHIPPED) - SCHEMA) == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. the mirrors, DISCOVERED from the tree rather than listed here")
|
||||
# ⚠️ WHY DISCOVERED. Six suites already pin their own mirror to config.yaml -
|
||||
# test_maintenance, test_price, test_watchdog (twice), test_peakguard,
|
||||
# test_delivery. Every one of those pins names its table by hand, so a SEVENTH
|
||||
# table added tomorrow is pinned by nobody and nothing says so. This walks
|
||||
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
|
||||
# grades whatever it finds, so the seventh is covered on the commit that
|
||||
# introduces it. That is the property the six hand-written pins cannot have.
|
||||
MODULES = sorted(p.name for p in APP.glob("*.py"))
|
||||
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
|
||||
for name in MODULES}
|
||||
|
||||
MIRROR = {} # schema key -> (module, table name, value)
|
||||
TABLES = []
|
||||
for name in MODULES:
|
||||
for node in TREES[name].body:
|
||||
if not (isinstance(node, ast.Assign) and len(node.targets) == 1
|
||||
and isinstance(node.targets[0], ast.Name)
|
||||
and node.targets[0].id.endswith("OPTION_DEFAULTS")):
|
||||
continue
|
||||
table = node.targets[0].id
|
||||
TABLES.append((name, table))
|
||||
for k, v in zip(node.value.keys, node.value.values):
|
||||
# A literal key with a literal value is the only shape a mirror
|
||||
# has; anything else is not a table this file can grade, and
|
||||
# saying so out loud beats skipping it silently.
|
||||
MIRROR[k.value] = (name, table, ast.literal_eval(v))
|
||||
|
||||
print(f" {len(TABLES)} table(s): " + ", ".join(f"{m}:{t}" for m, t in TABLES))
|
||||
print(f" {len(MIRROR)} mirrored key(s) of {len(SCHEMA)} in `schema:`")
|
||||
check(f"the walk found the mirrors that exist ({len(TABLES)} tables, floor 6)",
|
||||
len(TABLES) >= 6)
|
||||
_unschemad = sorted(k for k in MIRROR if k not in SCHEMA)
|
||||
check(f"every mirrored key is a real schema key {_unschemad or ''}",
|
||||
_unschemad == [])
|
||||
_unshipped = sorted(k for k in MIRROR if k not in SHIPPED)
|
||||
check(f"...and is shipped in the options block {_unshipped or ''}",
|
||||
_unshipped == [])
|
||||
_drift = sorted(k for k, (m, t, v) in MIRROR.items()
|
||||
if k in SHIPPED and v != SHIPPED[k])
|
||||
check(f"...and reproduces config.yaml exactly, so ANY mirror - including one no "
|
||||
f"suite names by hand - goes red on drift {_drift or ''}",
|
||||
_drift == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. the AST scan: no module carries its own literal fallback for a "
|
||||
"MIRRORED schema default")
|
||||
|
||||
|
||||
def literal_fallbacks(tree, name):
|
||||
"""Every `f(..., "<schema key>", <constant>, ...)` in one module.
|
||||
|
||||
Yields (module, lineno, callee, key, value). See the header for the two
|
||||
exclusions and the argument for each.
|
||||
"""
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
callee = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", "")
|
||||
if callee == "getattr":
|
||||
continue
|
||||
for i, arg in enumerate(node.args[:-1]):
|
||||
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)
|
||||
and arg.value in SCHEMA):
|
||||
continue
|
||||
default = node.args[i + 1]
|
||||
if not isinstance(default, ast.Constant):
|
||||
continue
|
||||
if default.value is None or default.value == "":
|
||||
continue
|
||||
yield (name, node.lineno, callee, arg.value, default.value)
|
||||
|
||||
|
||||
ALL_SITES = [s for name in MODULES for s in literal_fallbacks(TREES[name], name)]
|
||||
MIRRORED_SITES = [s for s in ALL_SITES if s[3] in MIRROR]
|
||||
UNMIRRORED_SITES = [s for s in ALL_SITES if s[3] not in MIRROR]
|
||||
|
||||
print(f" scanned {len(MODULES)} modules, {len(ALL_SITES)} literal fallback "
|
||||
f"site(s): {len(MIRRORED_SITES)} over mirrored keys, "
|
||||
f"{len(UNMIRRORED_SITES)} over unmirrored ones")
|
||||
# ⚠️ THE SCANNER'S OWN FLOOR, and it is the line that stops this whole file
|
||||
# rotting into clause one. A scanner that silently stopped matching - a renamed
|
||||
# helper, an ast API change, a `SCHEMA` that came back empty - would report ZERO
|
||||
# sites and every check below would pass. So the total is graded against a floor
|
||||
# the tree already exceeds by a wide margin, and part 5 additionally feeds the
|
||||
# scanner a source it MUST flag.
|
||||
check(f"the scanner is not blind: it found {len(ALL_SITES)} sites (floor 20)",
|
||||
len(ALL_SITES) >= 20)
|
||||
for site in sorted(MIRRORED_SITES):
|
||||
print(f" {site[0]}:{site[1]} {site[2]}({site[3]!r}, {site[4]!r})")
|
||||
check(f"no literal fallback survives for any of the {len(MIRROR)} keys a mirror "
|
||||
f"already answers for - those sites must index the mirror",
|
||||
MIRRORED_SITES == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. the CFG-03 ledger: the unmirrored keys that still carry a literal, "
|
||||
"frozen at exactly today's set")
|
||||
# ⚠️ WHY A FROZEN SET AND NOT A COUNT. A count goes green again the moment
|
||||
# somebody deletes one site and adds another, which is the same-size drift this
|
||||
# repository keeps finding. The pair (module, key) is stable under every edit
|
||||
# that is not the one being guarded against: reformatting, moving the call
|
||||
# within the file, renaming the local `opts`. It changes only when a module
|
||||
# starts or stops carrying a literal for a key - which is exactly the event.
|
||||
#
|
||||
# LINE NUMBERS ARE DELIBERATELY NOT IN THE KEY. A ledger that goes red when an
|
||||
# unrelated edit shifts a line is a ledger people learn to re-bless without
|
||||
# reading, and a ledger re-blessed without reading grades nothing.
|
||||
LEDGER = {
|
||||
("inverter.py", "max_w"),
|
||||
("main.py", "auto_start"),
|
||||
("main.py", "cycle_floor_s"),
|
||||
("main.py", "deadband_w"),
|
||||
("main.py", "estop_fitted"),
|
||||
("main.py", "gain"),
|
||||
("main.py", "heartbeat_s"),
|
||||
("main.py", "log_level"),
|
||||
("main.py", "meter_freeze_s"),
|
||||
("main.py", "meter_max_age_s"),
|
||||
("main.py", "meter_phases"),
|
||||
("main.py", "peak_cap_w"),
|
||||
("main.py", "saturation_cycles"),
|
||||
("main.py", "saturation_w"),
|
||||
("main.py", "sign_disagree_s"),
|
||||
("main.py", "sign_disagree_w"),
|
||||
("main.py", "slew_w"),
|
||||
("main.py", "stale_input_s"),
|
||||
("main.py", "step_w"),
|
||||
("main.py", "target_grid_w"),
|
||||
("p1.py", "meter_poll_s"),
|
||||
("p1.py", "meter_source"),
|
||||
("validate.py", "max_w"),
|
||||
("validate.py", "meter_max_age_s"),
|
||||
("validate.py", "meter_phases"),
|
||||
("validate.py", "sign_disagree_w"),
|
||||
("validate.py", "slew_w"),
|
||||
}
|
||||
_found = {(s[0], s[3]) for s in UNMIRRORED_SITES}
|
||||
_new = sorted(_found - LEDGER)
|
||||
_gone = sorted(LEDGER - _found)
|
||||
print(f" {len(_found)} (module, key) pair(s) carry a literal for a key no "
|
||||
f"mirror answers for")
|
||||
check(f"no NEW module/key pair has started carrying a literal fallback {_new or ''}",
|
||||
_new == [])
|
||||
check(f"...and none has been paid off without the ledger being shortened - "
|
||||
f"shrink LEDGER when it has {_gone or ''}",
|
||||
_gone == [])
|
||||
|
||||
# --- and every one of them is graded AGAINST config.yaml, which is the half
|
||||
# that catches CFG-04's actual failure: two copies of one number, one moves.
|
||||
DIVERGENT = {
|
||||
# main.py:290 `target_grid_w=float(opts.get("target_grid_w", 0))`.
|
||||
# config.yaml ships -10 W. MEASURED here for the first time, 2026-09-07,
|
||||
# and it is a real divergence, not a sentinel: a dict handed to
|
||||
# `Controller` WITHOUT the key holds net exchange at 0 W instead of the
|
||||
# shipped -10 W bias into import. On a Supervisor install the key is always
|
||||
# present - the deep-merge of `options:` guarantees it - so this cannot
|
||||
# reach an operator; the paths that CAN hand over a partial dict are
|
||||
# `sim/inject.py` and this repository's own fixtures. It is recorded rather
|
||||
# than corrected because changing it is a behaviour change to the control
|
||||
# loop's target, which belongs to a card that grades the loop.
|
||||
("main.py", "target_grid_w"): (0, -10),
|
||||
}
|
||||
_diverge = {}
|
||||
for mod, line, callee, key, value in UNMIRRORED_SITES:
|
||||
want = SHIPPED.get(key)
|
||||
same = (isinstance(value, bool) == isinstance(want, bool)) and value == want
|
||||
if not same:
|
||||
_diverge[(mod, key)] = (value, want)
|
||||
for pair, (got, want) in sorted(_diverge.items()):
|
||||
print(f" {pair[0]} {pair[1]}: code {got!r} vs config.yaml {want!r}")
|
||||
check(f"every remaining literal still AGREES with config.yaml, except the "
|
||||
f"{len(DIVERGENT)} divergence(s) recorded above it",
|
||||
_diverge == DIVERGENT)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. the red direction, RUN on every invocation")
|
||||
# ⚠️ A CHECK THAT HAS NEVER BEEN SHOWN FAILING IS NOT A CHECK. Parts 3 and 4
|
||||
# both pass on a clean tree, which is exactly what UX-01 AC 4's clause one did
|
||||
# while grading nothing. So the scanner is fed a source that is a deliberate
|
||||
# violation of each rule and is REQUIRED to flag it - in-process, against the
|
||||
# same `literal_fallbacks()` the parts above call, so a scanner that stops
|
||||
# working stops working here too.
|
||||
_PLANTED = '''
|
||||
def build(opts):
|
||||
a = opts.get("maintenance_charge_w", 2000) # mirrored key -> part 3
|
||||
b = _number(opts, "cycle_floor_s", 15) # unmirrored, main.py's key
|
||||
c = opts.get("static_profile", "") # the "" sentinel: NOT a hit
|
||||
d = getattr(unit, "max_w", 0.0) # attribute guard: NOT a hit
|
||||
e = opts.get("peak_soc_floor", 20) # mirrored key -> part 3
|
||||
return a, b, c, d, e
|
||||
'''
|
||||
_hits = list(literal_fallbacks(ast.parse(_PLANTED, "<planted>"), "<planted>"))
|
||||
_keys = sorted(h[3] for h in _hits)
|
||||
for h in _hits:
|
||||
print(f" caught {h[2]}({h[3]!r}, {h[4]!r}) at line {h[1]}")
|
||||
check("a planted literal for a MIRRORED key is caught - this is part 3's red "
|
||||
"direction, exercised",
|
||||
"maintenance_charge_w" in _keys and "peak_soc_floor" in _keys)
|
||||
check("a planted literal for an unmirrored key is caught too - part 4's",
|
||||
"cycle_floor_s" in _keys)
|
||||
check('...and the two documented exclusions still hold: the "" sentinel and '
|
||||
"getattr are not reported",
|
||||
"static_profile" not in _keys and _keys.count("max_w") == 0)
|
||||
check(f"exactly the three intended sites, no more {_keys}",
|
||||
_keys == ["cycle_floor_s", "maintenance_charge_w", "peak_soc_floor"])
|
||||
|
||||
# ...and the mirror comparison's red direction, likewise executed rather than
|
||||
# claimed: part 2 goes red when a table and config.yaml disagree by one.
|
||||
_drifted = dict(MIRROR)
|
||||
_pick = sorted(MIRROR)[0]
|
||||
_mod, _tab, _val = _drifted[_pick]
|
||||
_drifted[_pick] = (_mod, _tab, "###drifted###")
|
||||
check(f"part 2's comparison rejects a mirror that has drifted (simulated on "
|
||||
f"{_pick!r})",
|
||||
any(v != SHIPPED.get(k) for k, (_, _, v) in _drifted.items()))
|
||||
|
||||
|
||||
print()
|
||||
if FAILED:
|
||||
print(f"{FAILED} FAILED")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -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()
|
||||
@@ -475,13 +555,42 @@ def _power_domain(rows):
|
||||
|
||||
|
||||
_pw, _pf = _power_domain(_sw), _power_domain(_sf)
|
||||
check(f"THE TWO ARMS ARE BYTE-IDENTICAL IN THE POWER DOMAIN on all "
|
||||
f"{len(_pw)} cycles - grid, site target, every share, every measured "
|
||||
f"watt. No threshold on any of those numbers, at any N and any X, can "
|
||||
f"separate a wedged unit from a pack at its floor, and the card's own "
|
||||
f"proposed discriminator reads only those numbers",
|
||||
len(_pw) == len(_pf) and _pw == _pf)
|
||||
check("...and the ONLY thing that differs is the state of charge",
|
||||
# ⚠️ OPT-09 CHANGED WHAT THIS PAIR OF ARMS LOOKS LIKE, AND THE CARD'S FINDING
|
||||
# SURVIVES IT INTACT. UNTIL OPT-09 the two traces were byte-identical in every
|
||||
# power number - the cold pack was ASKED for the same +2000 W the wedged one was
|
||||
# asked for, and both produced 0 W - and that identity was this file's headline.
|
||||
# OPT-09 closes a pack's `hi` at 0 W once its own state of charge is at or under
|
||||
# its own `bms_soc_floor`, so the cold arm's B is now commanded 0 W and the two
|
||||
# arms' SHARES differ. ⚠️ THAT IS A CHANGE IN WHAT THE SITE ASKS FOR, NOT IN
|
||||
# WHAT ANY DETECTOR CAN SEE, and the card's argument is about the latter: the
|
||||
# proposed discriminator reads SATURATION-PERSISTENCE and |GRID ERROR|, and on
|
||||
# BOTH arms the loop is saturation-frozen on every cycle while ~1000 W stands at
|
||||
# the meter. No (N, X) separates them, exactly as measured on the rig - so the
|
||||
# claim is now stated on the two quantities the predicate actually reads rather
|
||||
# than on the whole trace, which is the stronger form of the same sentence.
|
||||
_ERR_W = 900.0
|
||||
_frozen = [[r["reason"] for r in rows] for rows in (_sw, _sf)]
|
||||
_settled = [[r for r in rows if abs(r["grid"]) > _ERR_W] for rows in (_sw, _sf)]
|
||||
print(f" the two quantities the card's predicate reads, last 8 cycles: "
|
||||
f"wedged grid {[round(r['grid']) for r in _sw[-8:]]}, "
|
||||
f"cold grid {[round(r['grid']) for r in _sf[-8:]]}")
|
||||
check(f"THE TWO ARMS ARE INSEPARABLE IN THE TWO QUANTITIES THE CARD'S PROPOSED "
|
||||
f"DISCRIMINATOR READS. On both, the loop reports the SAME reason on the "
|
||||
f"last {len(_sw[-8:])} cycles and |grid error| stands over "
|
||||
f"{_ERR_W:.0f} W on every one of them. No threshold on saturation "
|
||||
f"persistence and no threshold on grid error, at any N and any X, can "
|
||||
f"separate a wedged unit from a pack at its floor",
|
||||
[r["reason"] for r in _sw[-8:]] == [r["reason"] for r in _sf[-8:]]
|
||||
and len(_settled[0]) >= 8 and len(_settled[1]) >= 8)
|
||||
check("...and the SHARES now differ, which is OPT-09 and not a weakening of the "
|
||||
"line above: the cold pack is commanded 0 W because its own SoC closed "
|
||||
"its own envelope, while the wedged pack - whose SoC is healthy - is "
|
||||
"still asked for its full share and still delivers nothing. The two "
|
||||
"cards meet here from opposite sides: OPT-09 stops the ASK, this file "
|
||||
"grades the ALARM, and neither can do the other's half",
|
||||
_pw != _pf
|
||||
and _sf[-1]["shares"][1] == 0.0 and _sw[-1]["shares"][1] == 2000.0)
|
||||
check("...and the state of charge is still what tells them apart",
|
||||
[r["soc"] for r in _sw] != [r["soc"] for r in _sf])
|
||||
print(f" both arms, last cycle: grid {_sw[-1]['grid']:.0f} W, shares "
|
||||
f"{tuple(round(v) for v in _sw[-1]['shares'])}, measured "
|
||||
@@ -490,27 +599,48 @@ print(f" SoC: wedged arm {_sw[-1]['soc']} cold arm {_sf[-1]['soc']}")
|
||||
|
||||
check("AC 1: the WEDGED unit is NAMED, and only it",
|
||||
_sw[-1]["named"] == ("inverter-b",))
|
||||
check("AC 2: the COLD PACK AT ITS FLOOR IS NOT - on the identical trace",
|
||||
check("AC 2: the COLD PACK AT ITS FLOOR IS NOT",
|
||||
_sf[-1]["named"] == ())
|
||||
print(f" {_cw.inverters[1].delivery_wd.reason}")
|
||||
|
||||
# ⚠️ THE VACUOUS-PASS GUARD, AND IT IS NOT DECORATION. MUTATION.md's third
|
||||
# failure mode is a check whose data cannot reach the failure it names: "the
|
||||
# cold arm named nobody" reads exactly the same against a fixture where nothing
|
||||
# was short, nothing was asked, or the loop never ran. So the cold arm is
|
||||
# asserted to have REACHED the branch and been turned away by the account, not
|
||||
# to have missed it.
|
||||
# was short, nothing was asked, or the loop never ran.
|
||||
#
|
||||
# ⚠️ AND OPT-09 MOVED WHICH OF THOSE IS TRUE, WHICH IS WORTH MORE THAN THE
|
||||
# CHECK IT REPLACES. Until OPT-09 the cold pack WAS asked for its full share,
|
||||
# was short of it, and was turned away by `account()` alone - the account was the
|
||||
# only thing standing between an empty battery and an alarm. It is not any more:
|
||||
# the pack's own envelope is closed, so it is never asked, `short_by()` answers
|
||||
# None on a zero share, and AC 2 is delivered STRUCTURALLY - by the site not
|
||||
# asking - rather than by a discriminator applied after the fact.
|
||||
#
|
||||
# BOTH HALVES ARE ASSERTED, because the second is the one that survives OPT-09
|
||||
# being reverted or a pack whose numbers are absent: `account()` STILL turns this
|
||||
# pack away when the ask IS present, and that is asked of it directly here on
|
||||
# this arm's own measured SoC and configured cut-out. Parts 2 and 3 grade the
|
||||
# predicate over its whole input space; this line pins it to THIS fixture's
|
||||
# numbers so the end-to-end arm cannot pass for a reason unrelated to the pack.
|
||||
_cold_b = _cf.inverters[1]
|
||||
check("...and the cold arm REACHED that decision rather than falling short of "
|
||||
"it: B was asked for a discharge share, was short of it by more than "
|
||||
"saturation_w, and was turned away by `account()` alone",
|
||||
float(_cold_b.standing_w()) > 0
|
||||
and short_by(_cold_b.standing_w(), _cold_b.batt) > _cf.tuning.saturation_w
|
||||
and account(live=True, parked=False, soc=_cold_b.soc,
|
||||
bms_floor=_cold_b.bms_soc_floor) is not None)
|
||||
check("...and the WEDGED arm's B differs from it in exactly that one place",
|
||||
check("...and the cold arm reaches AC 2 the way OPT-09 makes it reach it: B's "
|
||||
"own state of charge closed B's own envelope, so B is commanded 0 W and "
|
||||
"`short_by()` has no discharge share to be short of. The ask never "
|
||||
"arrives, which is a better answer than an alarm suppressed after it does",
|
||||
float(_cold_b.standing_w()) == 0.0
|
||||
and short_by(_cold_b.standing_w(), _cold_b.batt) is None)
|
||||
check("...and `account()` STILL turns this pack away if the ask does arrive - "
|
||||
"asked directly, on this arm's own measured SoC and its own configured "
|
||||
"cut-out. That is the half that must not depend on OPT-09: an unreadable "
|
||||
"SoC or an unstated floor is a case OPT-09 deliberately does NOT close on, "
|
||||
"and the account is what keeps those quiet",
|
||||
account(live=True, parked=False, soc=_cold_b.soc,
|
||||
bms_floor=_cold_b.bms_soc_floor) is not None)
|
||||
check("...and the WEDGED arm's B differs from it in exactly that one place - "
|
||||
"which is why it is still asked for its share, and still named",
|
||||
account(live=True, parked=False, soc=_cw.inverters[1].soc,
|
||||
bms_floor=_cw.inverters[1].bms_soc_floor) is None)
|
||||
bms_floor=_cw.inverters[1].bms_soc_floor) is None
|
||||
and float(_cw.inverters[1].standing_w()) > 0)
|
||||
|
||||
# ⚠️ WHICH FLOOR, AND THIS IS A WIRING CHECK THAT THE PURE ONES ABOVE CANNOT
|
||||
# MAKE. `peakguard.pack_floor_pct()` conjoins a pack's own cut-out with the
|
||||
@@ -580,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)
|
||||
|
||||
@@ -624,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",
|
||||
@@ -694,11 +847,17 @@ check("...and each arm is a config validate.py rule 8 accepts: its "
|
||||
"peak_soc_floor is above EVERY unit's own cut-out",
|
||||
all(float(c.o.get("peak_soc_floor")) > float(i.bms_soc_floor)
|
||||
for c in (_c_deep, _c_shal) for i in c.inverters))
|
||||
check("...and the two arms are IDENTICAL IN THE POWER DOMAIN on every cycle - "
|
||||
"grid, site target, every share, every measured watt. The configured "
|
||||
"cut-out is the only thing that differs, which is part 4's claim made "
|
||||
"again at two cut-outs that are not the shipped one",
|
||||
_power_domain(_r_deep) == _power_domain(_r_shal))
|
||||
check("...and the two arms are INSEPARABLE IN THE QUANTITIES THE CARD'S "
|
||||
"PROPOSED DISCRIMINATOR READS - the same loop reason and over 900 W of "
|
||||
"grid error on every one of the last eight cycles - which is part 4's "
|
||||
"claim made again at two cut-outs that are not the shipped one. ⚠️ THEIR "
|
||||
"SHARES DIFFER, because OPT-09 closes the DEEP arm's envelope (15 % "
|
||||
"against a 25 % cut-out) and leaves the SHALLOW arm's open (8 % against "
|
||||
"5 %); that is the site declining to ask, not a detector telling them "
|
||||
"apart, and it is why the pin below still needs the pack's OWN number",
|
||||
[r["reason"] for r in _r_deep[-8:]] == [r["reason"] for r in _r_shal[-8:]]
|
||||
and all(abs(r["grid"]) > 900.0
|
||||
for rows in (_r_deep, _r_shal) for r in rows[-8:]))
|
||||
|
||||
check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is "
|
||||
f"cold, and is NOT named - even though it is far above the "
|
||||
@@ -706,11 +865,14 @@ check(f"a pack at {_DEEP_SOC:.0f} % against its OWN {_FLOOR_DEEP} % cut-out is "
|
||||
f"against, which would have called an empty pack a fault",
|
||||
_r_deep[-1]["named"] == () and float(_bd.bms_soc_floor) == _FLOOR_DEEP
|
||||
and _bd.soc == _DEEP_SOC)
|
||||
check("...and it REACHED that decision: it was asked for a discharge share, "
|
||||
"was short of it by more than saturation_w, and was turned away by "
|
||||
"`account()` alone",
|
||||
float(_bd.standing_w()) > 0
|
||||
and short_by(_bd.standing_w(), _bd.batt) > _c_deep.tuning.saturation_w
|
||||
check("...and it reaches that decision the way OPT-09 makes it reach it - the "
|
||||
"pack's own 25 % cut-out closed its own envelope, so it is commanded 0 W "
|
||||
"and has no discharge share to be short of - while `account()`, asked "
|
||||
"directly on the same two numbers, STILL turns it away. Both halves, for "
|
||||
"the reason part 4 gives: the second is what survives OPT-09 being "
|
||||
"reverted, and it is what covers the absences OPT-09 will not close on",
|
||||
float(_bd.standing_w()) == 0.0
|
||||
and short_by(_bd.standing_w(), _bd.batt) is None
|
||||
and account(live=True, parked=False, soc=_bd.soc,
|
||||
bms_floor=_bd.bms_soc_floor) is not None)
|
||||
check(f"a pack at {_SHAL_SOC:.0f} % against its OWN {_FLOOR_SHALLOW} % cut-out "
|
||||
@@ -801,12 +963,21 @@ check("the saturation freeze: FIRES - and that is the point. It is the one "
|
||||
print(f" saturation counter {_cw.sat_count} (freezes at "
|
||||
f"{_cw.tuning.saturation_cycles}); site commanded "
|
||||
f"{_cw.target:.0f} W against {_cw.site_batt_w:.0f} W measured")
|
||||
check("...and it fires IDENTICALLY on the benign arm, on the identical trace, "
|
||||
"at the same counter. A saturation-shaped alarm cannot tell these two "
|
||||
"apart at any threshold and any duration",
|
||||
_cf.sat_count == _cw.sat_count
|
||||
and _cf.site_batt_w == _cw.site_batt_w
|
||||
and _cf.target == _cw.target)
|
||||
print(f" ...and on the benign arm: counter {_cf.sat_count}, site "
|
||||
f"commanded {_cf.target:.0f} W against {_cf.site_batt_w:.0f} W measured")
|
||||
check("...and it fires ON THE BENIGN ARM TOO, from the same site numbers. The "
|
||||
"site is commanded the same watts against the same measured watts and is "
|
||||
"frozen on both, many times over the configured cycle count. ⚠️ THE "
|
||||
"COUNTERS DIFFER BY ONE CYCLE SINCE OPT-09, and that rescues nothing: the "
|
||||
"cold arm reaches its steady state one cycle sooner because its healthy "
|
||||
"sibling gets the whole share immediately instead of half of it. A "
|
||||
"duration threshold that both arms exceed by an order of magnitude "
|
||||
"cannot be set between them, which is the claim - and the two settled "
|
||||
"states are identical to the watt",
|
||||
_cf.site_batt_w == _cw.site_batt_w
|
||||
and _cf.target == _cw.target
|
||||
and min(_cf.sat_count, _cw.sat_count) >= _cw.tuning.saturation_cycles
|
||||
and abs(_cf.sat_count - _cw.sat_count) <= 1)
|
||||
|
||||
print()
|
||||
print(" ...and a unit the ALLOCATOR HAS ALREADY DROPPED is not reported twice")
|
||||
|
||||
@@ -1529,14 +1529,23 @@ check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
|
||||
"⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS "
|
||||
"`int(0,50)?`, and the difference is the whole of that card: an optional "
|
||||
"per-pack cut-out is a DEFAULTED one, and a default is one number for "
|
||||
"two batteries - the shallower pack protected by the deeper pack's BMS",
|
||||
"two batteries - the shallower pack protected by the deeper pack's BMS. "
|
||||
"⚠️ OPT-09 ADDED `capacity_kwh` AND MADE THE OPPOSITE CHOICE ON PURPOSE: "
|
||||
"it is OPTIONAL (`float(0.1,200)?`) because an absent capacity has an "
|
||||
"honest answer - the add-on then has no opinion about the SPLIT and every "
|
||||
"unit weighs 1.0, which is exactly what shipped - where an absent cut-out "
|
||||
"would have to be invented. If you are here because you added a REQUIRED "
|
||||
"key, read `Inverter.__init__` before changing this line: a required key "
|
||||
"refuses every options blob written before it existed",
|
||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
|
||||
"soc_entity", "batt_entity", "batt_invert"}
|
||||
"soc_entity", "batt_entity", "batt_invert",
|
||||
"capacity_kwh"}
|
||||
and not any(_decl9[k].endswith("?")
|
||||
for k in ("name", "setpoint_entity", "max_w",
|
||||
"bms_soc_floor"))
|
||||
and all(_decl9[k].endswith("?")
|
||||
for k in ("soc_entity", "batt_entity", "batt_invert")))
|
||||
for k in ("soc_entity", "batt_entity", "batt_invert",
|
||||
"capacity_kwh")))
|
||||
|
||||
# ⚠️ NOTHING IS INHERITED, AND THIS IS DEV-02 RESTATED AS A CONFIG RULE.
|
||||
# ⚠️ THE FLAT KEYS ARE SET TO REAL ENTITIES HERE, AND THAT IS THE WHOLE CHECK.
|
||||
|
||||
@@ -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]}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2576,9 +2576,42 @@ check("...and so does the persistent notification, which outlives the page and "
|
||||
# observe exactly {device, supervised, loop} at every SoC, because the shipped
|
||||
# roster is one inverter and this claim cannot be filed there at all. See
|
||||
# DECISIONS/SAFETY-30-per-unit-sign.md.
|
||||
# ⚠️ OPT-09 ADDED THE ELEVENTH, AND IT IS THE ONE THIS TRIPWIRE WAS BUILT FOR
|
||||
# THE SHAPE OF, SO IT NEEDS THE LONGEST PARAGRAPH. `pack` is a
|
||||
# `Claim.limit(... P_SAFETY ...)` on ONE INVERTER'S actuator, filed only where it
|
||||
# BINDS and only at a roster of TWO OR MORE, carrying that pack's own state of
|
||||
# charge against that pack's own `bms_soc_floor`: `hi = 0` for a battery at or
|
||||
# under its cut-out, `lo = 0` for one at 100 %.
|
||||
#
|
||||
# IT IS CONDITIONED ON A STATE OF CHARGE, WHICH IS EXACTLY WHAT THIS SET WATCHES
|
||||
# FOR, so the argument has to be made rather than asserted. SAFETY-25's deferred
|
||||
# Option B was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
|
||||
# `loop_soc_floor` option: an EMS PREFERENCE, chosen by an operator, stopping the
|
||||
# grid-following loop at a depth somebody picked, ON THE SITE. Four things
|
||||
# separate this from it, and all four are graded elsewhere in this repository:
|
||||
#
|
||||
# 1. THE NUMBER IS NOT AN OPTION. It is `bms_soc_floor` - measured off
|
||||
# register 0x0560 and required of every roster entry since SAFETY-32. No
|
||||
# new option is added and `peak_soc_floor` is deliberately NOT consulted
|
||||
# (test_packshare.py part 2 grades that a pack at 15 % with a 10 % cut-out
|
||||
# is NOT closed, though a 20 % EMS floor would have closed it).
|
||||
# 2. IT IS A UNIT CLAIM, SO IT CANNOT BOUND THE SITE TARGET. It changes WHICH
|
||||
# INVERTER carries the target, never how big the target is.
|
||||
# 3. IT IS NOT FILED AT ALL AT A ROSTER OF ONE, which is why the sweeps below
|
||||
# still observe exactly {device, supervised, loop} at every state of charge
|
||||
# from 0 to 100 % on the shipped install. Option A stands untouched there:
|
||||
# ordinary self-consumption still discharges to the BMS cut-out.
|
||||
# 4. AND THE MEMO ITSELF NAMES THIS AS THE REVISIT: "under ALLOC-01 the
|
||||
# reserve becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a
|
||||
# ninth claim here". Stage 1 is how an interval reaches the allocator, and
|
||||
# stage 1's input is claims - so a unit-scoped claim is the shape that
|
||||
# sentence asks for.
|
||||
#
|
||||
# If you are here because you added a TWELFTH, that argument is what this line
|
||||
# wants from you, not an edit.
|
||||
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||
"arbitrage", "maintenance", "loop", "circulation",
|
||||
"sign_site"}
|
||||
"sign_site", "pack"}
|
||||
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
|
||||
# that a THIRD such indirection reads as an unrecognised append rather than as a
|
||||
# silent gap in the scan.
|
||||
@@ -2619,10 +2652,11 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
|
||||
"- a claim filed through a shape it cannot parse has to fail here rather "
|
||||
"than be counted as nothing, which is how a scan like this goes quietly "
|
||||
"vacuous", len(_appends) >= 10 and None not in _filed)
|
||||
check("...and the sources that cycle can file are EXACTLY these nine. THIS is "
|
||||
"what makes part 10 a tripwire: replay_loop() hand-builds the claim list, "
|
||||
"so it cannot see a claim main.py adds - and a loop-side SoC reserve is a "
|
||||
"NINTH source. Filing one takes this line red on the line it is written",
|
||||
check("...and the sources that cycle can file are EXACTLY these ELEVEN. THIS "
|
||||
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
|
||||
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
|
||||
"reserve is a new source. Filing one takes this line red on the line it "
|
||||
"is written, and OPT-09's `pack` had to argue its way past it above",
|
||||
set(_filed) == CLAIM_SOURCES)
|
||||
|
||||
|
||||
@@ -2749,8 +2783,8 @@ check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into "
|
||||
# structurally impossible. The structure is `CLAIM_SCOPE`, so `CLAIM_SCOPE` is
|
||||
# pinned to the AST-scanned set rather than trusted to agree with it.
|
||||
check("every source main.py can file is CLASSIFIED site or per-inverter, and "
|
||||
"nothing else is. A TENTH source now goes red twice - once here for "
|
||||
"having no classification, once above for being a tenth - and it takes an "
|
||||
"nothing else is. A TWELFTH source now goes red twice - once here for "
|
||||
"having no classification, once above for being a twelfth - and it takes an "
|
||||
"argument about what the claim is ABOUT to make either line green",
|
||||
set(CLAIM_SCOPE) == CLAIM_SOURCES)
|
||||
print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
|
||||
@@ -2758,8 +2792,8 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
|
||||
+ " | per-inverter: "
|
||||
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
|
||||
check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
|
||||
"SAFETY-30's additions: the six about the GRID CONNECTION are site "
|
||||
"claims and the four about a pack, a device or one write are "
|
||||
"SAFETY-30's and OPT-09's additions: the six about the GRID CONNECTION "
|
||||
"are site claims and the five about a pack, a device or one write are "
|
||||
"per-inverter. `circulation` is a site claim because its quantity is "
|
||||
"defined over the roster and is identically zero for any one unit; "
|
||||
"`sign_site` is one because the number that tripped it is the SITE "
|
||||
@@ -2769,7 +2803,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
|
||||
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
|
||||
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
|
||||
== {"device", "supervised", "failsafe", "maintenance"})
|
||||
== {"device", "supervised", "failsafe", "maintenance", "pack"})
|
||||
|
||||
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
|
||||
# real cycle files is a site claim. Observed on the claims main.py's own resolve
|
||||
@@ -2853,8 +2887,9 @@ _restore_b()
|
||||
print(" with a second inverter on the roster, main.py routes: "
|
||||
+ ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items())))
|
||||
check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
"inverter and its OWN routing method splits the ten: `device`, "
|
||||
"`supervised`, `failsafe` and `maintenance` bind the unit this controller "
|
||||
"inverter and its OWN routing method splits the eleven: `device`, "
|
||||
"`supervised`, `failsafe`, `maintenance` and OPT-09's `pack` bind the "
|
||||
"unit this controller "
|
||||
"drives, `loop`, `peak`, `arbitrage`, `safety`, SAFETY-31's `circulation` "
|
||||
"and SAFETY-30's `sign_site` stay on the grid connection. Swap any one of "
|
||||
"them - route `peak` to the unit and the site keeps no floor; route "
|
||||
@@ -2865,6 +2900,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
"rest of the roster goes on pushing the same way - and this line goes red",
|
||||
_routed == {"device": "inverter-a", "supervised": "inverter-a",
|
||||
"failsafe": "inverter-a", "maintenance": "inverter-a",
|
||||
"pack": "inverter-a",
|
||||
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
|
||||
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
|
||||
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
|
||||
@@ -2948,16 +2984,16 @@ _unit_sources_unrouted = [
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
|
||||
print(f" {len(_direct)} direct appends, each read at its own line: "
|
||||
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
|
||||
check("all TEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
check("all ELEVEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
|
||||
"of EVERY one of them. An append that computed its actuator, or omitted "
|
||||
"the argument, or hand-wrote a string, fails HERE rather than being "
|
||||
"silently skipped - which is how a scan like this goes quietly vacuous, "
|
||||
"and 10f is in this file because that already happened once",
|
||||
len(_direct) == 10
|
||||
len(_direct) == 11
|
||||
and all(a is not None and a.startswith("self.actuator_of('")
|
||||
for _s, a, _ln in _routing))
|
||||
check("⚠️ AND AT EVERY ONE OF THE TEN THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
"SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed "
|
||||
"`actuator_of(...)` at a call site goes red on - all nine of them, on "
|
||||
"the line the typo is written, at every roster, INCLUDING the six "
|
||||
@@ -2971,14 +3007,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
|
||||
"site-scoped sources and is the whole defect for a per-inverter one: a "
|
||||
"`device` limit built inside the roster loop and routed without `inv` "
|
||||
"files inverter B's rating on inverter A, where it binds the wrong "
|
||||
"share. Five of the ten appends are SCOPE_UNIT - `device`, "
|
||||
"`supervised`, `failsafe` and maintenance's two - and all five pass one. "
|
||||
"share. Six of the eleven appends are SCOPE_UNIT - `device`, "
|
||||
"`supervised`, `failsafe`, maintenance's two and OPT-09's `pack` - and "
|
||||
"all six pass one. "
|
||||
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
|
||||
"the site, beside `circulation`, because the number that tripped it is "
|
||||
"the site target",
|
||||
_unit_sources_unrouted == []
|
||||
and len([r for r in _routing
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 5)
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 6)
|
||||
check("...and the only appends this pin does NOT reach are the two that file a "
|
||||
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
|
||||
"`actuator=` at the call site to mis-type. Their routing is pinned where "
|
||||
@@ -2987,7 +3024,7 @@ check("...and the only appends this pin does NOT reach are the two that file a "
|
||||
"arbitrage.claim() by test_arbitrage.py",
|
||||
sorted(_indirect) == sorted(CLAIM_BUILDERS)
|
||||
and len(_direct) + len(_indirect) == len(_appends))
|
||||
check("...and between them the eleven appends account for exactly the nine "
|
||||
check("...and between them the twelve appends account for exactly the eleven "
|
||||
"sources 10f pins, so the routing pin and the source set are one scan "
|
||||
"and neither can drift from the other",
|
||||
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
|
||||
@@ -3027,10 +3064,19 @@ check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
|
||||
"connection",
|
||||
sorted((c.source, c.actuator) for c in _cl2)
|
||||
== [("device", "inverter-a"), ("device", "inverter-b"),
|
||||
("loop", ACTUATOR_SITE), ("supervised", "inverter-a"),
|
||||
("loop", ACTUATOR_SITE),
|
||||
# ⚠️ OPT-09, AND IT IS OBSERVED HERE RATHER THAN ONLY PINNED
|
||||
# STATICALLY. This fixture leaves both packs at 100 % - the last value
|
||||
# of the SoC sweep above - so each one's `pack` claim closes its `lo`
|
||||
# at 0 W: a full battery is not asked to charge. It is on ITS OWN
|
||||
# actuator, which is the half a `pack` limit filed on the site would
|
||||
# fail, and it appears ONLY here: the one-inverter sweep above files
|
||||
# no `pack` claim at any state of charge, which is AC 4.
|
||||
("pack", "inverter-a"), ("pack", "inverter-b"),
|
||||
("supervised", "inverter-a"),
|
||||
("supervised", "inverter-b")])
|
||||
check("...and naming those three is not an accident of this fixture but the "
|
||||
"reason the static pin exists - SEVEN of the ten sources need a state "
|
||||
"reason the static pin exists - SEVEN of the eleven sources need a state "
|
||||
"no check in this file reaches. SAFETY-31's `circulation` is one of the "
|
||||
"seven: it is filed only while the site is MEASURED to be circulating, "
|
||||
"which no fixture here is. SAFETY-30's `sign_site` is another, and it "
|
||||
@@ -3079,16 +3125,21 @@ check("...and so is each unit's `device` bound, which is the other number the "
|
||||
"inverter's reported range bound nothing at all",
|
||||
_dev2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W),
|
||||
"inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)})
|
||||
_n2_plans = {p.unit: (p.lo, p.hi) for p in (_n2_alloc.plans if _n2_alloc else ())}
|
||||
print(f" the envelopes the ARBITER RESOLVED: {_n2_plans}")
|
||||
check("...and the bounds the ARBITER RESOLVED for inverter B - not just the "
|
||||
"claims filed at it - are B's own, because a claim filed correctly and "
|
||||
"then resolved against the wrong envelope would be the same defect one "
|
||||
"layer down",
|
||||
"layer down. ⚠️ THE `hi` IS B'S OWN RATING AND THE `lo` IS 0 W, and the "
|
||||
"second half is OPT-09 rather than a defect: both packs in this fixture "
|
||||
"sit at 100 % - the last value of the SoC sweep above - so each one's "
|
||||
"`pack` claim closes its charge end. The two units' envelopes are still "
|
||||
"DIFFERENT NUMBERS, which is what this check exists to observe: a bound "
|
||||
"copied from the first unit would read +5000 on B",
|
||||
_n2_alloc is not None
|
||||
and next((p for p in _n2_alloc.plans if p.unit == "inverter-b"), None)
|
||||
is not None
|
||||
and (lambda p: p.lo == -B_RATING_HYPOTHESIS
|
||||
and p.hi == B_RATING_HYPOTHESIS)(
|
||||
next(p for p in _n2_alloc.plans if p.unit == "inverter-b")))
|
||||
and _n2_plans.get("inverter-b") == (0.0, B_RATING_HYPOTHESIS)
|
||||
and _n2_plans.get("inverter-a") == (0.0, LIVE_MAX_W)
|
||||
and _n2_plans["inverter-a"] != _n2_plans["inverter-b"])
|
||||
|
||||
|
||||
# --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER ---
|
||||
|
||||
+1283
-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)
|
||||
|
||||
@@ -74,7 +74,7 @@ check(f"the option surface was actually read - {len(ORDER)} options, not zero",
|
||||
check("...and the repeatable `inverters:` section's per-entry keys were read "
|
||||
"too, which is the half a top-level-only reader would miss",
|
||||
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
|
||||
and len(NESTED["inverters"]) == 7)
|
||||
and len(NESTED["inverters"]) == 8)
|
||||
|
||||
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
|
||||
"languages")
|
||||
@@ -248,10 +248,17 @@ for what, text in BAD:
|
||||
check(f"every shape outside the accepted subset RAISES rather than being "
|
||||
f"skipped. Silently accepted: {_accepted}", not _accepted)
|
||||
check("...and the accepted subset really does round-trip the shipped files - "
|
||||
"71 options and 7 per-entry keys came back out of each, which is what "
|
||||
"says the strictness above is not just refusing everything",
|
||||
# ⚠️ COUNTED, NOT TYPED. UX-02 corrected this sentence's option count
|
||||
# 70 -> 71 while OPT-09 independently added an eighth per-entry key
|
||||
# (`capacity_kwh`), and the two edits collided here on the rebase - each
|
||||
# restating a number the other had moved. The counts now come from the
|
||||
# same parse the assertion uses, so the sentence cannot drift from it
|
||||
# again. PROC-01.
|
||||
f"{len(ORDER)} options and {sum(len(v) for v in NESTED.values())} "
|
||||
f"per-entry keys came back out of each, which is what says the "
|
||||
f"strictness above is not just refusing everything",
|
||||
all(len(TR[lg]["configuration"]) == len(ORDER)
|
||||
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
|
||||
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8
|
||||
for lg in LANGS))
|
||||
print(" ⚠️ the licence for this hand parser is NOT this file. "
|
||||
"tools/supervisor_shape_probe.py section F loads the same two files with "
|
||||
|
||||
@@ -264,6 +264,53 @@ check("...but the ENTRY is refused all the same, by rule 9 and not by the "
|
||||
"setpoint_entity": " "}]),
|
||||
-5000, 5000), "has no setpoint_entity"))
|
||||
|
||||
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
|
||||
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
|
||||
# RUN, which is the bar every clause of this rule is held to. A capacity that is
|
||||
# not a usable number is a pack the split would quietly stop preferring while
|
||||
# the operator can see a value in the box; a capacity on SOME entries is worse,
|
||||
# because `packshare.weights()` answers "no opinion" for the WHOLE roster in
|
||||
# that case - so every key the operator filled in does exactly nothing and
|
||||
# nothing says so.
|
||||
def _with_cap(*caps):
|
||||
o = {k: v for k, v in _PER_INVERTER_OK.items()}
|
||||
o["inverters"] = [dict(e) for e in _PER_INVERTER_OK["inverters"]]
|
||||
for entry, cap in zip(o["inverters"], caps):
|
||||
if cap is not None:
|
||||
entry["capacity_kwh"] = cap
|
||||
return o
|
||||
|
||||
|
||||
check("a roster stating a capacity on EVERY entry is accepted - and the two "
|
||||
"differ (15.36 / 20.48, FROM DATASHEET for B), because a fixture whose "
|
||||
"two per-unit numbers are equal cannot show a check reading the wrong "
|
||||
"unit's",
|
||||
validate_startup_config(_with_cap(15.36, 20.48), -5000, 5000) == [])
|
||||
check("...and a roster stating NONE is accepted too, which is what OPTIONAL "
|
||||
"means: the add-on has no opinion about the split and every unit weighs "
|
||||
"1.0, exactly as it did before this card",
|
||||
validate_startup_config(_with_cap(None, None), -5000, 5000) == [])
|
||||
check("a capacity that is not a usable number of kWh is REFUSED, and the "
|
||||
"refusal names the entry - zero, negative and a word alike",
|
||||
all(why(validate_startup_config(_with_cap(15.36, bad), -5000, 5000),
|
||||
"capacity_kwh") for bad in (0, -3, "twenty", 0.0)))
|
||||
check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left empty "
|
||||
"on the other. It is the clause that costs an operator something, and it "
|
||||
"is the one worth having: a pack measured in kWh beside a sibling "
|
||||
"weighing a dimensionless 1.0 is not a comparison, so the key would "
|
||||
"silently change nothing at all",
|
||||
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
||||
"not PARTIAL")
|
||||
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
|
||||
"not PARTIAL"))
|
||||
check("...and the refusal names WHICH entry is which, because an operator with "
|
||||
"three inverters told 'one of them is wrong' is sent to the wrong "
|
||||
"cupboard",
|
||||
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
||||
"inverter-a")
|
||||
and why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
||||
"inverter-b"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("3. a meter that can never read")
|
||||
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""PKG-09 - grade the check that refuses a no-op deploy. `python3 test_version_gate.py`
|
||||
|
||||
⚠️ THE POINT OF THIS FILE IS THE REFUSALS, NOT THE CLEARANCES. A pre-deploy
|
||||
check that only ever says "CLEARED" is the defect `MUTATION.md` habit 4 names -
|
||||
"a check whose DATA cannot reach the failure it names" - which this repository
|
||||
has recorded twelve times. So every refusal below is DRIVEN: a repository is
|
||||
built in which the failure is really present, and the check is required to say
|
||||
so. A clearance is only meaningful next to a refusal from the same harness.
|
||||
|
||||
The third property is the one that is easy to lose: a SKIPPED check must not
|
||||
look like a PASSED one. Omitting `--options` cannot exit 0.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
GATE = os.path.join(HERE, "tools", "predeploy_check.py")
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
fails = []
|
||||
tmpdirs = []
|
||||
|
||||
|
||||
def check(name, ok):
|
||||
print(f" {'ok ' if ok else 'FAIL'} {name}")
|
||||
if not ok:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def git(repo, *args):
|
||||
return subprocess.run(
|
||||
["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t", *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def make_repo(version="0.7.0"):
|
||||
"""A repo shaped like this one: <root>/goodwe_controller/{config.yaml,app,tools}."""
|
||||
root = tempfile.mkdtemp(prefix="test_version_gate.")
|
||||
tmpdirs.append(root)
|
||||
addon = os.path.join(root, "goodwe_controller")
|
||||
os.makedirs(os.path.join(addon, "app"))
|
||||
write(addon, "config.yaml", f'name: t\nversion: "{version}"\n')
|
||||
write(addon, "app/control.py", "X = 1\n")
|
||||
write(addon, "CHANGELOG.md", "# Changelog\n")
|
||||
git(root, "init", "-q", ".")
|
||||
git(root, "add", "-A")
|
||||
git(root, "commit", "-q", "-m", f"initial at {version}")
|
||||
return root, addon
|
||||
|
||||
|
||||
def write(addon, rel, text):
|
||||
p = os.path.join(addon, rel)
|
||||
with open(p, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
|
||||
|
||||
def run_gate(addon, root, deployed, options=None):
|
||||
"""Run the REAL gate against a throwaway tree.
|
||||
|
||||
⚠️ The script is invoked where it SHIPS, not copied into the fixture. It
|
||||
imports the validator from its own sibling `app/`, which is the behaviour
|
||||
that matters: the gate must check a config against the validator it ships
|
||||
beside, not against whatever happens to sit next to a copy. Copying it into
|
||||
the fixture made every validator assertion silently SKIP - the first draft
|
||||
of this file did exactly that and reported four green skips as passes.
|
||||
"""
|
||||
cmd = [sys.executable, GATE,
|
||||
"--deployed-version", deployed, "--tree", addon, "--repo", root]
|
||||
if options:
|
||||
cmd += ["--options", options]
|
||||
return subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
|
||||
VALID = {"max_w": 2000, "slew_w": 0, "target_grid_w": -10, "gain": 0.6,
|
||||
"inverters": [], "setpoint_entity": "number.goodwe_setpoint_w"}
|
||||
|
||||
|
||||
def opts_file(d):
|
||||
fd, p = tempfile.mkstemp(suffix=".json")
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
json.dump(d, fh)
|
||||
return p
|
||||
|
||||
|
||||
print("classification - what forces a rebuild and what does not")
|
||||
from predeploy_check import is_runtime, shipped_version # noqa: E402
|
||||
|
||||
for p in ("goodwe_controller/app/control.py", "goodwe_controller/config.yaml",
|
||||
"goodwe_controller/translations/en.yaml", "goodwe_controller/run.sh"):
|
||||
check(f"runtime: {p}", is_runtime(p))
|
||||
for p in ("goodwe_controller/CHANGELOG.md", "goodwe_controller/DOCS.md",
|
||||
"goodwe_controller/test_control.py", "goodwe_controller/tools/x.py",
|
||||
"goodwe_controller/DECISIONS/a.md", "README.md"):
|
||||
check(f"NOT runtime: {p}", not is_runtime(p))
|
||||
|
||||
print("\nTHE REFUSAL - a runtime change behind an unchanged version")
|
||||
root, addon = make_repo("0.7.0")
|
||||
write(addon, "app/control.py", "X = 2\n")
|
||||
git(root, "commit", "-qam", "SAFETY-99: change behaviour, bump nothing")
|
||||
r = run_gate(addon, root, "0.7.0", opts_file(VALID))
|
||||
check("refuses (exit 1)", r.returncode == 1)
|
||||
check("...and says the deploy would be a NO-OP", "NO-OP" in r.stdout)
|
||||
check("...and names the offending commit", "SAFETY-99" in r.stdout)
|
||||
check("...and does NOT say cleared", "CLEARED for deploy" not in r.stdout)
|
||||
|
||||
print("\nPOSITIVE CONTROL - the same check must NOT fire on a docs-only change")
|
||||
root2, addon2 = make_repo("0.7.0")
|
||||
write(addon2, "CHANGELOG.md", "# Changelog\n\n## notes\n")
|
||||
git(root2, "commit", "-qam", "PKG-99: documentation only")
|
||||
r2 = run_gate(addon2, root2, "0.7.0", opts_file(VALID))
|
||||
check("does not refuse a docs-only tree", r2.returncode == 0)
|
||||
check("...and says there is nothing to deploy",
|
||||
"nothing to deploy" in r2.stdout)
|
||||
|
||||
print("\nTHE CLEARANCE - a bumped version with a valid config")
|
||||
root3, addon3 = make_repo("0.7.0")
|
||||
write(addon3, "app/control.py", "X = 3\n")
|
||||
write(addon3, "config.yaml", 'name: t\nversion: "0.8.0"\n')
|
||||
git(root3, "commit", "-qam", "PKG-99: change and bump together")
|
||||
r3 = run_gate(addon3, root3, "0.7.0", opts_file(VALID))
|
||||
check("clears (exit 0)", r3.returncode == 0)
|
||||
check("...and says so", "CLEARED for deploy" in r3.stdout)
|
||||
|
||||
print("\n⭐ A SKIPPED CHECK MUST NOT LOOK LIKE A PASSED ONE")
|
||||
r4 = run_gate(addon3, root3, "0.7.0") # no --options
|
||||
check("omitting --options cannot exit 0", r4.returncode != 0)
|
||||
check("...says SKIPPED", "SKIPPED" in r4.stdout)
|
||||
check("...says the config was NEVER CHECKED", "NEVER CHECKED" in r4.stdout)
|
||||
check("...and does NOT claim clearance", "CLEARED for deploy" not in r4.stdout)
|
||||
|
||||
print("\nTHE OTHER REFUSAL - a config this version's validator rejects")
|
||||
bad = dict(VALID, slew_w=1000) # rule 10: > 2/18 x 2000 = 222 W
|
||||
r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
|
||||
check("refuses a config the validator rejects", r5.returncode == 1)
|
||||
check("...and warns it is exit 78", "78" in r5.stdout)
|
||||
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
|
||||
check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout)
|
||||
|
||||
print()
|
||||
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
|
||||
# ⚠️ EVERY FIXTURE ABOVE BUILDS A FRESH REPOSITORY WHOSE `version:` LINE IS
|
||||
# INTRODUCED ONCE AND NEVER EDITED. That is precisely the shape in which the
|
||||
# original `git log -S 'version: "'` looked correct - the pickaxe finds commits
|
||||
# that change a string's COUNT, and introducing the line changes it from 0 to 1.
|
||||
# A real repository edits the line IN PLACE, the count never moves again, and
|
||||
# the lookup silently returns the commit that first added it.
|
||||
#
|
||||
# MEASURED on admin/goodwe-addon before the fix: `-S` named `0a1e61d`
|
||||
# (2026-08-23, the original packaging commit) as "the last bump", so a correctly
|
||||
# bumped tree was reported as 87 commits behind an unbumped version - with
|
||||
# PKG-06's and PKG-07's own bump commits listed among the offenders. The tool
|
||||
# cried wolf on every run, and none of the fixtures above could see it, because
|
||||
# a test that builds its own one-bump history is a test asserting against a
|
||||
# structure it built itself. MUTATION.md habit 4, in the file written to
|
||||
# prevent a recurrence.
|
||||
from predeploy_check import last_version_change # noqa: E402
|
||||
|
||||
root4 = tempfile.mkdtemp(prefix="test_version_gate.hist.")
|
||||
tmpdirs.append(root4)
|
||||
addon4 = os.path.join(root4, "goodwe_controller")
|
||||
os.makedirs(os.path.join(addon4, "app"))
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.5.0"\n')
|
||||
write(addon4, "app/control.py", "X = 1\n")
|
||||
git(root4, "init", "-q", ".")
|
||||
git(root4, "add", "-A")
|
||||
git(root4, "commit", "-q", "-m", "initial at 0.5.0")
|
||||
_first = git(root4, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
write(addon4, "app/control.py", "X = 2\n")
|
||||
git(root4, "commit", "-qam", "runtime change between bumps")
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.6.0"\n')
|
||||
git(root4, "commit", "-qam", "bump to 0.6.0")
|
||||
write(addon4, "app/control.py", "X = 3\n")
|
||||
git(root4, "commit", "-qam", "another runtime change")
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0"\n')
|
||||
git(root4, "commit", "-qam", "bump to 0.7.0")
|
||||
_last_bump = git(root4, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
check("⭐ THE LAST BUMP IS FOUND, NOT THE FIRST. An in-place edit of the "
|
||||
"version line does not change how many times the string occurs, so a "
|
||||
"pickaxe search returns the commit that introduced it and every bump "
|
||||
"after that is invisible",
|
||||
last_version_change(root4) == _last_bump)
|
||||
check("...and it is emphatically not the commit that introduced the line",
|
||||
last_version_change(root4) != _first)
|
||||
|
||||
r6 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
|
||||
check("a tree bumped with nothing after it clears, even with bumps in history",
|
||||
r6.returncode == 0 and "CLEARED for deploy" in r6.stdout)
|
||||
|
||||
write(addon4, "app/control.py", "X = 4\n")
|
||||
git(root4, "commit", "-qam", "SAFETY-98: runtime change after the last bump")
|
||||
r7 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
|
||||
check("and one runtime commit after that last bump is refused", r7.returncode == 1)
|
||||
check("...naming only the commit since the LAST bump, not since the first",
|
||||
"SAFETY-98" in r7.stdout and "runtime change between bumps" not in r7.stdout)
|
||||
|
||||
|
||||
# ⚠️ AND THE FIXTURE ABOVE STILL COULD NOT TELL TWO IMPLEMENTATIONS APART.
|
||||
# Review swapped `last_version_change` for "-G's newest raw hit, skip the value
|
||||
# comparison entirely" and THE WHOLE SUITE STAYED GREEN, including the section
|
||||
# above - because a linear history of real bumps never produces a commit that
|
||||
# touches the version LINE without changing the version VALUE. So the docstring's
|
||||
# central claim ("the VALUE is compared, and that is the only thing that answers
|
||||
# it") was not pinned by anything, and `-G` alone would have shipped as if it
|
||||
# were the fix. That is the third distinct shape of habit 4 in this one file.
|
||||
#
|
||||
# A cosmetic touch of the version line is the discriminator: `-G '^version: "'`
|
||||
# matches it, and the value comparison does not.
|
||||
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0" # pinned deliberately\n')
|
||||
git(root4, "commit", "-qam", "COSMETIC: annotate the version line, same value")
|
||||
_cosmetic = git(root4, "rev-parse", "HEAD").stdout.strip()
|
||||
|
||||
check("⭐ A COMMIT THAT TOUCHES THE VERSION LINE WITHOUT CHANGING THE VERSION "
|
||||
"IS NOT A BUMP. `-G` matches it and a filter-only lookup would name it; "
|
||||
"only comparing the VALUE against the parent's answers the question the "
|
||||
"tool is actually asking",
|
||||
last_version_change(root4) == _last_bump)
|
||||
check("...and it is emphatically not the cosmetic commit",
|
||||
last_version_change(root4) != _cosmetic)
|
||||
|
||||
r8 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
|
||||
check("...and because config.yaml IS a runtime path, that cosmetic commit is "
|
||||
"still counted as something unshipped - conservative, and correct: the "
|
||||
"Supervisor rebuilds on the version string alone, so a changed file "
|
||||
"behind an unchanged version is a no-op whatever changed in it",
|
||||
r8.returncode == 1 and "COSMETIC" in r8.stdout)
|
||||
|
||||
|
||||
print()
|
||||
print("⭐ A GIT LOOKUP THAT GENUINELY FAILS - THE SHAPE NO FIXTURE COULD REACH")
|
||||
# ⚠️ EVERY FIXTURE ABOVE CREATES ITS OWN REPOSITORY AS ITS OWN UID, so none of
|
||||
# them could ever meet the failure that actually happened: the documented
|
||||
# invocation runs the container as uid 0 against a checkout owned by the
|
||||
# operator, git refuses with "detected dubious ownership", `_git` returns
|
||||
# non-zero, and the no-op half reports SKIPPED. It degraded honestly - exit 2,
|
||||
# never a false clearance - but the check this file exists for had never once
|
||||
# run in its documented form, and nothing here could see that.
|
||||
#
|
||||
# `_git` now sets `safe.directory`, so ownership no longer breaks it. This
|
||||
# section grades the REMAINING failure mode: git answering not at all.
|
||||
root5 = tempfile.mkdtemp(prefix="test_version_gate.nogit.")
|
||||
tmpdirs.append(root5)
|
||||
addon5 = os.path.join(root5, "goodwe_controller")
|
||||
os.makedirs(addon5)
|
||||
write(addon5, "config.yaml", 'name: t\nversion: "0.7.0"\n') # NOT a git repo
|
||||
|
||||
check("a repository git cannot answer for reports no last-bump commit",
|
||||
last_version_change(root5) is None)
|
||||
|
||||
r9 = run_gate(addon5, root5, "0.7.0", opts_file(VALID))
|
||||
check("⭐ AND THE GATE DOES NOT CLEAR ON IT. An unanswerable git lookup is "
|
||||
"INCONCLUSIVE, not clean - the one thing it must never do is call a "
|
||||
"tree it could not inspect deployable",
|
||||
r9.returncode != 0 and "CLEARED for deploy" not in r9.stdout)
|
||||
check("...it says INCONCLUSIVE in those words", "INCONCLUSIVE" in r9.stdout)
|
||||
|
||||
# ⚠️ AND THE EXIT CODES ARE PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0`
|
||||
# is satisfied by collapsing NOT-CLEARED into REFUSED, which review found the
|
||||
# earlier draft could not tell apart. 1 and 2 mean different things to an
|
||||
# operator: 1 says "this deploy is wrong", 2 says "nobody checked".
|
||||
check("NOT CLEARED is exactly 2, not merely non-zero", r9.returncode == 2)
|
||||
r10 = run_gate(addon3, root3, "0.7.0", opts_file(dict(VALID, slew_w=1000)))
|
||||
check("REFUSED is exactly 1, not merely non-zero",
|
||||
r10.returncode == 1 and "REFUSED" in r10.stdout)
|
||||
|
||||
|
||||
print("\nthe shipped tree's own version is readable")
|
||||
check("shipped_version() finds a version", bool(shipped_version(HERE)))
|
||||
|
||||
for d in tmpdirs:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -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,306 @@
|
||||
"""PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject.
|
||||
|
||||
python3 tools/predeploy_check.py --deployed-version 0.7.0 --options live-options.json
|
||||
|
||||
⚠️ RUN IT WHERE THE ADD-ON'S DEPENDENCIES EXIST. `app/validate.py` imports its
|
||||
siblings and those need aiohttp, so on a bare host the validator half SKIPS -
|
||||
loudly, and exit 2, never a silent pass. To get both halves:
|
||||
|
||||
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
--deployed-version 0.7.0 --options /w/live-options.json
|
||||
|
||||
EXIT CODES. 0 cleared; 1 REFUSED, do not deploy; 2 NOT CLEARED - a check did not
|
||||
run, and that is not the same as passing.
|
||||
|
||||
TWO FAILURES THIS ANSWERS, BOTH OF WHICH HAVE HAPPENED.
|
||||
|
||||
1. THE SILENT NO-OP. The Supervisor keys the built image by `version:` in
|
||||
config.yaml. `ha apps update` against an unchanged version does nothing and
|
||||
REPORTS SUCCESS. PKG-06 recorded this on 2026-09-05 with SIX merges behind
|
||||
an unbumped version - its own commit message says so, and this line said
|
||||
"four" through two review rounds; PKG-07 recorded it again on 2026-09-06 with six. Both
|
||||
times a human noticed. Nothing else was ever going to.
|
||||
|
||||
2. THE REFUSED CONFIG. A config the shipped validator rejects is
|
||||
`SystemExit(78)` - the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES
|
||||
WITH IT, because the add-on serving it is not running, so recovery means the
|
||||
Supervisor UI. SAFETY-39's rule 10 made this reachable by a config that
|
||||
started fine on the previous version: it refuses an explicit `slew_w` above
|
||||
SLEW_RAIL_FRAC x the site rail, and 0.7.0's own shipped default of 1000
|
||||
exceeds the 555.6 W ceiling on a 5000 W rail.
|
||||
|
||||
⚠️ WHY THIS IS NOT A CI CHECK. The obvious version gate - "a pull request that
|
||||
touches runtime code must bump the version" - is the WRONG shape for this
|
||||
project, which deliberately batches many merges into one deploy. It would demand
|
||||
a bump per pull request, put every branch in conflict over one line of
|
||||
config.yaml, and still not prevent the actual failure, which happens at DEPLOY
|
||||
and not at merge. The check belongs where the mistake is made.
|
||||
|
||||
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
|
||||
reachable only through the Supervisor API, which needs a supervisor token, so
|
||||
the operator exports it and passes `--options`. Without that flag the validator
|
||||
half is SKIPPED and says so - a skipped check must never look like a passed one.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ADDON = os.path.dirname(HERE)
|
||||
sys.path.insert(0, ADDON)
|
||||
|
||||
# Files that end up in the built image and can change what it DOES. A change to
|
||||
# any of these needs a new `version:` or the Supervisor will not rebuild.
|
||||
# CHANGELOG.md, DOCS.md, MUTATION.md, DECISIONS/, test_*.py, tools/ and
|
||||
# scenarios/ are deliberately absent: they ship in the repo but cannot change
|
||||
# runtime behaviour, and demanding a bump for a typo fix trains people to bump
|
||||
# without reading, which is the habit this file exists to prevent.
|
||||
RUNTIME = (
|
||||
"goodwe_controller/app/",
|
||||
"goodwe_controller/translations/",
|
||||
"goodwe_controller/config.yaml",
|
||||
"goodwe_controller/Dockerfile",
|
||||
"goodwe_controller/run.sh",
|
||||
"goodwe_controller/requirements.txt",
|
||||
"goodwe_controller/build.yaml",
|
||||
)
|
||||
|
||||
CONFIG_PATH = "goodwe_controller/config.yaml"
|
||||
_VER = re.compile(r'^version:\s*"([^"]+)"', re.M)
|
||||
|
||||
|
||||
def shipped_version(tree=ADDON):
|
||||
with open(os.path.join(tree, "config.yaml"), encoding="utf-8") as fh:
|
||||
m = _VER.search(fh.read())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def is_runtime(path):
|
||||
return any(path == p or path.startswith(p) for p in RUNTIME)
|
||||
|
||||
|
||||
def _git(*args, repo=None):
|
||||
"""git, run so it answers regardless of who owns the checkout.
|
||||
|
||||
⚠️ WITHOUT `safe.directory` THIS TOOL SILENTLY DID HALF ITS JOB. The
|
||||
invocation this file's own docstring documents runs the container as uid 0
|
||||
against a checkout owned by the operator, and git refuses that:
|
||||
|
||||
fatal: detected dubious ownership in repository at '/w/goodwe-addon'
|
||||
|
||||
`_git` then returned non-zero, `last_version_change()` returned None, and
|
||||
the no-op check reported SKIPPED - so **the silent-no-op half, which is the
|
||||
reason this file exists, had never once run in its documented form.**
|
||||
|
||||
It degraded honestly: exit 2, INCONCLUSIVE, never a false clearance. But a
|
||||
check that cannot fire is not a check, and the two documented environments
|
||||
were mutually exclusive - in the container the validator ran and git
|
||||
skipped; on a bare host git ran and the validator skipped on aiohttp.
|
||||
Neither documented command ran both halves.
|
||||
|
||||
⚠️ AND IT WAS INVISIBLE IN THE ONLY CASE ANYONE HAD RUN. `main()` consults
|
||||
the git result only when `shipped == deployed`; every real run so far
|
||||
compared 0.7.0 against 0.9.0 and printed its verdict without needing git.
|
||||
|
||||
`test_version_gate.py` could not see it either: it invokes the gate with
|
||||
`sys.executable` against fixtures it creates as its own uid, so no fixture
|
||||
ever met the ownership mismatch. MUTATION.md habit 4, in a fourth shape,
|
||||
inside the file written to prevent it.
|
||||
|
||||
`safe.directory` is the right fix rather than `--user`: it makes the tool
|
||||
correct however it is invoked, instead of making every future caller
|
||||
remember a flag. The read is read-only, on a repository the operator is
|
||||
about to deploy from.
|
||||
"""
|
||||
where = repo or os.path.dirname(ADDON)
|
||||
return subprocess.run(
|
||||
["git", "-c", f"safe.directory={where}", "-C", where, *args],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def _version_at(sha, repo=None, path=CONFIG_PATH):
|
||||
"""`version:` as of one commit, or None if the file or the key is absent."""
|
||||
r = _git("show", f"{sha}:{path}", repo=repo)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
m = _VER.search(r.stdout)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def last_version_change(repo=None):
|
||||
"""The newest commit whose `version:` VALUE differs from its parent's.
|
||||
|
||||
⚠️ THIS USED TO BE `git log -S 'version: "'` AND IT WAS WRONG ON EVERY REAL
|
||||
REPOSITORY. `-S` is the pickaxe: it finds commits that change the COUNT of
|
||||
a literal string. A bump edits the line in place - `version: "0.7.0"` ->
|
||||
`version: "0.8.0"` - so the substring still occurs exactly once before and
|
||||
after, and `-S` never sees it. It found only the commit that first
|
||||
INTRODUCED the line.
|
||||
|
||||
Measured on this repository: `-S` returned `0a1e61d` (2026-08-23, the
|
||||
original packaging commit) as "the last bump", so the script reported the
|
||||
correctly-bumped tree as 87 commits behind an unbumped version - listing
|
||||
PKG-06's and PKG-07's own bump commits among the offenders. A tool whose
|
||||
entire purpose is to be trusted before a deploy cried wolf on every run.
|
||||
|
||||
⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER. `-G'^version: "'`
|
||||
matches a commit whose diff touches a line with that shape - which includes
|
||||
a reformat, a quote change, or a comment moved onto the line, none of which
|
||||
are bumps. So `-G` narrows the candidates and then the VALUE is compared
|
||||
against the parent's. The question is "did the version change", and that is
|
||||
the only thing that answers it.
|
||||
|
||||
⚠️ TWO GIT SHAPES THIS STILL GETS WRONG, NEITHER OF WHICH HAS EVER OCCURRED
|
||||
IN THIS REPOSITORY - every bump here has been a clean, linear, forward-only
|
||||
edit. Both were constructed in review rather than found:
|
||||
|
||||
1. A BUMP RESOLVED INSIDE A MERGE COMMIT IS INVISIBLE. `git log -G -- path`
|
||||
does not diff merge commits at all without `-m`, so a version line whose
|
||||
final value is decided by a conflict resolution is never a candidate.
|
||||
The lookup then names the last ordinary bump, and the merge itself counts
|
||||
as a runtime change since it - a FALSE REFUSAL of a tree that is in fact
|
||||
correctly bumped.
|
||||
2. REVERTING A BUMP WHILE KEEPING THE WORK MASKS THE WORK. Bump, change
|
||||
`app/`, then revert only `config.yaml`: the version is back where it
|
||||
started, the runtime change is still in the tree, and this reports
|
||||
"nothing to deploy" - a FALSE CLEARANCE, which is the dangerous
|
||||
direction.
|
||||
|
||||
Card PKG-11 carries both. They are recorded here rather than fixed because
|
||||
a lookup that handles them costs more than the failure they represent, and
|
||||
a caveat a reader can check beats a guarantee that is not true.
|
||||
"""
|
||||
r = _git("log", "--format=%H", "-G", r'^version: "', "--", CONFIG_PATH,
|
||||
repo=repo)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
shas = r.stdout.split()
|
||||
for sha in shas: # newest first
|
||||
here = _version_at(sha, repo)
|
||||
there = _version_at(sha + "^", repo)
|
||||
if here is not None and here != there:
|
||||
return sha
|
||||
return shas[-1] if shas else None
|
||||
|
||||
|
||||
def runtime_commits_since_version_change(repo=None):
|
||||
"""Commits touching RUNTIME since `version:` last changed. ([], None) if unknown.
|
||||
|
||||
Returns (commits, last_bump_sha). An empty list with a sha means the tree is
|
||||
clean; an empty list with None means git could not answer and the caller
|
||||
must NOT read that as a pass.
|
||||
"""
|
||||
last = last_version_change(repo)
|
||||
if not last:
|
||||
return [], None
|
||||
r = _git("log", "--format=%H %s", f"{last}..HEAD", repo=repo)
|
||||
if r.returncode != 0:
|
||||
return [], None
|
||||
out = []
|
||||
for line in r.stdout.splitlines():
|
||||
sha, _, subject = line.partition(" ")
|
||||
f = _git("show", "--name-only", "--format=", sha, repo=repo)
|
||||
if any(is_runtime(p) for p in f.stdout.split()):
|
||||
out.append((sha[:10], subject))
|
||||
return out, last[:10]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--deployed-version", required=True,
|
||||
help="the version currently running ON THE BOX")
|
||||
ap.add_argument("--options", help="path to the live options blob, exported "
|
||||
"from the Supervisor. Omitted = that half is SKIPPED.")
|
||||
ap.add_argument("--tree", default=ADDON)
|
||||
ap.add_argument("--repo", default=None)
|
||||
a = ap.parse_args()
|
||||
|
||||
problems, skipped = [], []
|
||||
shipped = shipped_version(a.tree)
|
||||
print(f" deployed on the box : {a.deployed_version}")
|
||||
print(f" shipped in this tree: {shipped}")
|
||||
|
||||
commits, last = runtime_commits_since_version_change(a.repo)
|
||||
if shipped is None:
|
||||
problems.append("could not read `version:` out of config.yaml")
|
||||
elif shipped == a.deployed_version:
|
||||
if last is None:
|
||||
skipped.append("could not ask git what changed since the last bump; "
|
||||
"the no-op check is INCONCLUSIVE, not clean")
|
||||
if commits:
|
||||
problems.append(
|
||||
f"THE DEPLOY WOULD BE A NO-OP. Version is {shipped} on both sides, and "
|
||||
f"{len(commits)} commit(s) touching runtime files have landed since the "
|
||||
f"version last changed ({last}). `ha apps update` will report success "
|
||||
f"and install NOTHING. Bump `version:` first.")
|
||||
for sha, subj in commits[:12]:
|
||||
print(f" {sha} {subj[:76]}")
|
||||
elif last is not None:
|
||||
print(" no runtime change since the last bump - nothing to deploy")
|
||||
|
||||
if a.options:
|
||||
# ⚠️ A TOOL RUN IN A HURRY BEFORE A DEPLOY MUST NOT ANSWER WITH A
|
||||
# TRACEBACK. A missing or malformed export is the likeliest operator
|
||||
# error here, and it is a REFUSAL, not a skip - the config was not
|
||||
# checked and the script must not be re-run past it.
|
||||
opts = None
|
||||
try:
|
||||
with open(a.options, encoding="utf-8") as fh:
|
||||
opts = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
problems.append(f"--options file not found: {a.options}")
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
||||
problems.append(f"--options file could not be read as JSON "
|
||||
f"({type(exc).__name__}: {exc}); the config was NOT checked")
|
||||
if a.options and opts is not None:
|
||||
try:
|
||||
from app.validate import validate_startup_config
|
||||
except Exception as exc: # pragma: no cover
|
||||
skipped.append(
|
||||
f"could not import the shipped validator ({exc}); the config check "
|
||||
f"did NOT run. `app/validate.py` imports its siblings, and those need "
|
||||
f"the add-on's runtime dependencies - so THIS SCRIPT ONLY CHECKS A "
|
||||
f"CONFIG WHERE THOSE DEPENDENCIES EXIST: inside the add-on container, "
|
||||
f"or in the CI image. Re-run it as:\n"
|
||||
f" docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \\\n"
|
||||
f" python3 /w/goodwe_controller/tools/predeploy_check.py \\\n"
|
||||
f" --deployed-version <ver> --options /w/<options.json>")
|
||||
else:
|
||||
found = validate_startup_config(opts)
|
||||
if found:
|
||||
problems.append(
|
||||
f"THE LIVE CONFIG IS REFUSED BY THIS VERSION'S VALIDATOR "
|
||||
f"({len(found)} problem(s)). Deploying it is SystemExit(78): the "
|
||||
f"add-on does not start, and the ingress panel goes with it because "
|
||||
f"the add-on serving it is not running. Recovery is the Supervisor UI.")
|
||||
for p in found:
|
||||
print(f" - {p}")
|
||||
else:
|
||||
print(" live options pass this version's validator")
|
||||
else:
|
||||
skipped.append("--options was not given, so the live config was NEVER CHECKED "
|
||||
"against this version's validator. A config refused at startup is "
|
||||
"exit 78. Export the options and re-run before deploying.")
|
||||
|
||||
print()
|
||||
for s in skipped:
|
||||
print(f" ⚠️ SKIPPED: {s}")
|
||||
if problems:
|
||||
for p in problems:
|
||||
print(f" ❌ {p}")
|
||||
print("\n REFUSED - do not deploy.")
|
||||
return 1
|
||||
if skipped:
|
||||
print(" NOT CLEARED - checks above did not run. Deploying now is a decision "
|
||||
"made without them.")
|
||||
return 2
|
||||
print(" CLEARED for deploy.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
@@ -315,6 +315,114 @@ print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order "
|
||||
"(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE "
|
||||
"SCHEMA BLOCK'S and not the options block's)")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ UX-04: THAT COUNT IS NOW GRADED, AND IT IS WHY UX-01's AC 3 IS WITHDRAWN.
|
||||
#
|
||||
# UX-01 AC 3 said the visible count would drop to ~15, with the rest behind
|
||||
# HA's "show unused optional configuration options" toggle. The line above has
|
||||
# printed the refutation since the day it was written - 71, not 15 - and PRINTED
|
||||
# IS NOT GRADED: this probe exited 0 through the whole of UX-01's review while
|
||||
# displaying the number that contradicted the criterion it was evidence for.
|
||||
# That is the defect, not the count.
|
||||
#
|
||||
# THE FRONTEND RULE, transcribed from the bundle in THIS image at the top of
|
||||
# this section and replayed rather than paraphrased:
|
||||
#
|
||||
# _filteredSchema = (opts, schema) => schema.filter(e => e.name in opts
|
||||
# || e.required)
|
||||
#
|
||||
# `opts` is the add-on's OPTIONS AS SUPERVISOR SERVES THEM - the operator's
|
||||
# stored blob after `options:` has been deep-merged into it - so a key is
|
||||
# hideable only when it is BOTH absent from `options:` AND not required.
|
||||
#
|
||||
# WHY THE CRITERION IS WITHDRAWN RATHER THAN IMPLEMENTED, in two measurements:
|
||||
#
|
||||
# 1. IT IS ARITHMETICALLY UNREACHABLE. Supervisor's own UiOptions marks
|
||||
# `required` on every non-`?` key, and the filter above shows a required
|
||||
# key UNCONDITIONALLY. So the floor - what the tab renders with `options:`
|
||||
# EMPTIED ALTOGETHER, which is the most hiding that is even expressible -
|
||||
# is the required count below. It is nowhere near 15. No arrangement of
|
||||
# `options:` can reach the target; the criterion was never satisfiable.
|
||||
#
|
||||
# 2. THE 12 KEYS THAT *COULD* BE HIDDEN COST THE DEEP-MERGE DEFAULT. A key
|
||||
# absent from `options:` has nothing for the Supervisor to merge into an
|
||||
# existing box's stored blob. That is exactly the `delivery_hold_s`
|
||||
# mechanism the 0.9.0 upgrade rests on: a key that 0.7.0's stored blob
|
||||
# cannot contain resolves to its `options:` default instead of failing
|
||||
# validation, and moving it out of `options:` is precisely how you get the
|
||||
# exit 78 that mechanism exists to prevent. Worse, it would promote the 27
|
||||
# unpinned in-code literal fallbacks that `test_defaults_pin.py` part 4
|
||||
# ledgers into the LOAD-BEARING source of those defaults on a live install
|
||||
# - which is the CFG-03 defect the same card's AC 4 exists to police.
|
||||
#
|
||||
# So: four fewer clicks, bought with the upgrade path and with the defaults
|
||||
# pin. The decision is written up in
|
||||
# DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md
|
||||
# and the three assertions below are what keeps it a decision rather than an
|
||||
# omission - if anyone empties `options:` to shorten the form, this goes red.
|
||||
_hideable = sorted(set(schema) - set(defaults))
|
||||
_required = [n["name"] for n in ui if n.get("required")]
|
||||
|
||||
|
||||
def _filtered(opts):
|
||||
"""The frontend's own `_filteredSchema`, replayed on this manifest."""
|
||||
return [n for n in ui if n["name"] in opts or n.get("required")]
|
||||
|
||||
|
||||
show(f"every one of the {len(schema)} `schema:` keys is ALSO in `options:`, so "
|
||||
f"schema-only - the only set HA's toggle can hide - is EMPTY. UX-01 AC 3 "
|
||||
f"is not partially met, it is unstarted. Hideable: {_hideable}",
|
||||
not _hideable and len(schema) == len(defaults))
|
||||
show(f"...so replaying the frontend's own _filteredSchema on the served options "
|
||||
f"hides NOTHING: {len(_filtered(defaults))} of {len(ui)} fields render",
|
||||
len(_filtered(defaults)) == len(ui))
|
||||
# ⚠️ DERIVED, NOT TYPED, AND NOT A FLOOR. The assertion above read
|
||||
# `len(_required) > 15` while the prose said 59 - so a manifest change dropping
|
||||
# the required count to 20 would have passed while the sentence beside it stayed
|
||||
# wrong. That is the same shape as the count this repo already fixed in
|
||||
# `test_translations.py`: a number displayed but not graded.
|
||||
#
|
||||
# So the expected value is computed from config.yaml by Supervisor's OWN rule -
|
||||
# a key is optional iff its schema string ends in `?`, plus `inverters`, which
|
||||
# `UiOptions` marks optional because it is a list - and the assertion is an
|
||||
# EQUALITY against that. Typing 59 here would rot the first time a key is added.
|
||||
_OPTIONAL = {k for k, v in schema.items()
|
||||
if isinstance(v, str) and v.rstrip().endswith("?")} | {"inverters"}
|
||||
_EXPECT_REQ = len(schema) - len(_OPTIONAL)
|
||||
show(f"...and that floor is DERIVED: {len(schema)} schema keys minus "
|
||||
f"{len(_OPTIONAL)} optional ones (the `?` suffix, plus `inverters`) is "
|
||||
f"{_EXPECT_REQ}, and Supervisor's own required flag agrees",
|
||||
_EXPECT_REQ == len(_required))
|
||||
|
||||
# ⚠️ AND THE WITHDRAWAL'S OWN PREMISE, GRADED SEPARATELY. The equality above is
|
||||
# deliberately NOT enough on its own: both sides read the same `?` convention,
|
||||
# so marking a key optional moves BOTH and it stays green. That is correct - it
|
||||
# makes the printed number impossible to leave stale - but it grades Supervisor
|
||||
# agreeing with us, not the thing AC 3 was withdrawn for.
|
||||
#
|
||||
# AC 3 was withdrawn because the floor is FAR above the ~15 it asked for. If
|
||||
# enough keys ever became optional that the floor approached 15, the withdrawal
|
||||
# stops being true and this file should say so rather than keep printing a
|
||||
# sentence that has quietly become wrong.
|
||||
_AC3_ASKED = 15
|
||||
show(f"...and the floor of {_EXPECT_REQ} is still far above the ~{_AC3_ASKED} "
|
||||
f"AC 3 asked for, which is the whole reason it was withdrawn. If this ever "
|
||||
f"goes red the withdrawal needs re-arguing, not re-asserting",
|
||||
_EXPECT_REQ > 2 * _AC3_ASKED)
|
||||
|
||||
|
||||
# ⚠️ AN ISOLATED COUNTERFACTUAL, and the number is Supervisor's own `required`
|
||||
# flag rather than a count typed here. `options:` is emptied and NOTHING else
|
||||
# varies, so the answer cannot be about anything but the merge.
|
||||
show(f"...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is "
|
||||
f"expressible - the tab still renders {len(_filtered({}))} required "
|
||||
f"fields. THAT is the floor, and AC 3 asked for ~15. The criterion is "
|
||||
f"withdrawn as unsatisfiable, not deferred.",
|
||||
len(_filtered({})) == len(_required) and len(_required) == _EXPECT_REQ)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _entry(lang, name, path=()):
|
||||
node = TRANSLATIONS.get(lang, {}).get("configuration")
|
||||
|
||||
@@ -78,6 +78,9 @@ configuration:
|
||||
batt_invert:
|
||||
name: "Battery power sign is reversed (optional)"
|
||||
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
|
||||
capacity_kwh:
|
||||
name: "Usable battery capacity (kWh) (optional)"
|
||||
description: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or its label. It is read for one purpose: dividing the site's target between the inverters by how much ENERGY each pack has left rather than by how many watts it is rated for. The two packs at the reference site are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so those two ways of dividing the work hand it to different batteries, and the energy one is the question worth asking. Optional: leave it empty and the add-on has no opinion, dividing the target evenly exactly as it did before. State it on EVERY inverter or on none of them - a pack measured in kWh next to one that is not is not a comparison, and the add-on refuses to start rather than ignore half of what you filled in."
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485 emergency stop is fitted"
|
||||
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
|
||||
@@ -140,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."
|
||||
@@ -158,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."
|
||||
@@ -173,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."
|
||||
@@ -236,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."
|
||||
|
||||
@@ -58,6 +58,9 @@ configuration:
|
||||
batt_invert:
|
||||
name: "Teken van het batterijvermogen omkeren (optioneel)"
|
||||
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
|
||||
capacity_kwh:
|
||||
name: "Bruikbare capaciteit van de batterij (kWh) (optioneel)"
|
||||
description: "De bruikbare capaciteit van DEZE batterij in kilowattuur, van het datablad of het typeplaatje. Ze wordt voor één ding gelezen: het verdelen van de doelwaarde van de installatie over de omvormers volgens hoeveel ENERGIE elke batterij nog heeft, in plaats van volgens het vermogen waarvoor ze gebouwd is. De twee batterijen op de referentiesite zijn 5000 W met 15,36 kWh en 4600 W met 20,48 kWh, dus die twee manieren van verdelen wijzen het werk aan verschillende batterijen toe, en de energieverdeling is de juiste vraag. Optioneel: laat leeg en de add-on heeft geen voorkeur en verdeelt gelijk, precies zoals voordien. Vul ze in bij ELKE omvormer of bij geen enkele - een batterij in kWh naast een batterij zonder waarde is geen vergelijking, en de add-on start liever niet op dan de helft van wat u invulde te negeren."
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485-noodstop is geplaatst"
|
||||
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
|
||||
@@ -120,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."
|
||||
@@ -138,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."
|
||||
@@ -153,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."
|
||||
@@ -216,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