Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1b9254626 | ||
|
|
478ba93710 | ||
|
|
995da777ce | ||
|
|
4dcd9b0fc6 | ||
|
|
9c65ac2cf8 | ||
|
|
7796f168a9 | ||
|
|
2fac0aaf06 | ||
|
|
c518007caf | ||
|
|
a11597e0b3 | ||
|
|
610725bed9 | ||
|
|
b4a3801f68 | ||
|
|
a991438e40 | ||
|
|
068b83f57c |
@@ -22,6 +22,12 @@ if it has, it needs 0.10.0. Whoever deploys decides.
|
||||
| _this branch_ | TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
|
||||
| _this branch_ | TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so **⚠️ BACKWARD-INCOMPATIBLE** |
|
||||
| _this branch_ | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price |
|
||||
| _this branch_ | TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving |
|
||||
| _this branch_ | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface |
|
||||
| _this branch_ | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest |
|
||||
| _this branch_ | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** |
|
||||
| _this branch_ | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero |
|
||||
| _this branch_ | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** |
|
||||
|
||||
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
|
||||
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
|
||||
@@ -57,6 +63,497 @@ separate card. *(Annotated 2026-09-13: that card is **TARIFF-15**, below, and it
|
||||
shipped on this branch as `custom_formula`. It is still not an alias — the
|
||||
operator types their own coefficients; nothing is invented on their behalf.)*
|
||||
|
||||
### SAFETY-45 - the trade is sized by the capacity guard, not switched by it
|
||||
|
||||
**A price-driven charge now asks the peak guard HOW HARD it may pull, instead of
|
||||
only whether it may pull at all.** On any site where `load + max_w` reaches
|
||||
`peak_cap_w` - at the shipped `peak_cap_w: 3500` and `max_w: 2000`, any house
|
||||
drawing over 1500 W in the cheapest quarter - arbitrage and the capacity-tariff
|
||||
guard were driving each other in a sustained limit cycle. Measured on TEST-23
|
||||
stage 3's closed rig at `peak_cap_w: 1500` against a 1200 W house: **28 charge
|
||||
onsets in one hour, mean period 131 s, the grid swinging −456 to +3200 W**, and
|
||||
every billed quarter in the three affected configurations finishing **over** the
|
||||
cap it was oscillating about. §10.2: *"an EMS that oscillates is worse than no
|
||||
EMS, both for hardware wear and - per §9.1 - for the peak it is trying to
|
||||
avoid."*
|
||||
|
||||
**What was happening.** The guard computes `wanted = batt_w + excess` - the
|
||||
battery discharge that would land the quarter exactly on the ceiling - and files
|
||||
a claim only while that number is positive. While a trade charges at `max_w`
|
||||
the number is negative for the first minute or so of every cycle, so the guard
|
||||
files **nothing** and the trade is not constrained at all. `wanted` then crosses
|
||||
zero, the guard engages on a floor of a few watts, and that flips
|
||||
`arbitrage.guard_state()` - which cancels **the whole trade**. The
|
||||
grid-following loop takes the actuator, drives the meter to zero and past it,
|
||||
the projection collapses, the guard releases, and the trade comes back at
|
||||
`max_w`. 115 s later, again.
|
||||
|
||||
**The fix, and it is a shape this add-on already had.**
|
||||
`control.maintenance_charge_floor()` has always capped a MAINTENANCE charge at
|
||||
"the headroom left under the site's cap", because *"a maintenance charge is the
|
||||
only thing this system does that is big enough and long enough to set that
|
||||
peak"*. ⚠️ That stopped being true when OPT-04 landed: a trade commands `max_w`
|
||||
for a whole tariff interval, deliberately, against the meter.
|
||||
`peakguard.charge_headroom_w()` publishes the same quantity off the **measured**
|
||||
guard rather than off the optional `peak_forecast_entity`, and
|
||||
`arbitrage.decide()` sizes the buy leg to it.
|
||||
|
||||
**No new arithmetic and no new constant.** The number is `wanted_w` read in the
|
||||
other direction: a negative `wanted` is not "nothing to say", it is a permitted
|
||||
charge of `−wanted`. `peakguard.decide()` is **unchanged** - byte-identical to
|
||||
`release/1.0`, `sha256 = 200090d0835a3a55` - so 2026-08-20's anti-bang-bang rung
|
||||
is exactly where it was, and `tools/replay_ac3.py` reproduces the same 190/190
|
||||
guarded quarters, 110 engaged cycles and 878 Wh on this branch as on the tip.
|
||||
|
||||
**After, on the same rig, same command:**
|
||||
|
||||
| `peak_cap_w` / house | onsets per hour | grid swing | energy bought | billed quarters |
|
||||
|---|---|---|---|---|
|
||||
| 1500 / 1200 W | 28 → **1** | 3656 → **282 W** | 180 → **285 Wh** | 1490/1497/**1509** → **1500/1500/1500** |
|
||||
| 3500 / 1800 W | 8 → **1** | 3673 → 1688 W | 1653 → 1616 Wh | **3511/3536/3559** → **3500/3500/3500** |
|
||||
| 3500 / 3000 W | 32 → **1** | 3798 → **475 W** | 483 → 475 Wh | **3519/3542/3557** → **3500/3500/3500** |
|
||||
| 5000 / 1200 W | 1 → 1 | *identical* | *identical* | *identical* |
|
||||
|
||||
One onset means the trade starts once and runs for the whole window - the
|
||||
battery is now trading 100 % of the cheap hour where it traded 43 %, 92 % and
|
||||
54 %. **Every billed quarter now lands on the cap and none over it.** At the
|
||||
aggressive cap the site buys 58 % MORE energy, because a continuous 300 W trade
|
||||
beats a 2000 W trade that is cancelled for well over half the hour; at the two
|
||||
shipped-default rows it buys about 2 % less and the worst quarter drops from
|
||||
59 W over the cap to zero. The last row is the negative control - `load + max_w`
|
||||
never reaches the cap there, and the two runs are bit-identical. **This costs
|
||||
nothing on the installs it is not about.**
|
||||
|
||||
⚠️ **NOT A CONFIG CHANGE.** No option is added, removed or re-defaulted, and
|
||||
arbitrage remains off in the shipped configuration. A site that has not armed
|
||||
`arbitrage_enabled` is unaffected in every respect.
|
||||
|
||||
⚠️ **WHAT IS NOT FIXED, AND IT IS NAMED RATHER THAN IMPLIED.** TEST-23 finding 1
|
||||
- the export overshoot at the END of any trade, 356-938 W, about 0.37 × `max_w`
|
||||
- is untouched and is a different card. A house whose load ALONE exceeds
|
||||
`peak_cap_w` still has the guard engage and the trade refused outright, which is
|
||||
correct, and the grid-following loop then drives to grid = 0 rather than to the
|
||||
ceiling. See `DECISIONS/SAFETY-45-sizing-the-trade.md` §5, and §2 there for the
|
||||
negative-`lo` design that was rejected because it creates a new `lo > hi`
|
||||
contradiction against the maintenance charge shaping.
|
||||
|
||||
### TARIFF-16 - the injection leg stops being one number
|
||||
|
||||
**`price_export_formula`: what a site is PAID for export, as `factor × index +
|
||||
offset` with its own coefficients, alongside the flat `export_price_eur_kwh`.**
|
||||
|
||||
⚠️ **THIS IS NOT A WRONG-TRADE FIX, AND SAYING SO IS PART OF SHIPPING IT.**
|
||||
`arbitrage.py` does not read `export_price_eur_kwh` at all — its own comment
|
||||
says so, and gives §9.3's near-zero Belgian injection price as the reason the
|
||||
economically sound trade on this market is *charge cheap to displace expensive
|
||||
import*. Both hours it compares are all-in **consumption** prices. So no trade
|
||||
this add-on takes today is decided by an injection price, and nothing here
|
||||
changes one. What it changes is what an operator is told, and what any future
|
||||
export-aware strategy rests on: OPT-06 and any export logic would otherwise
|
||||
inherit a scalar that cannot represent the market, and the 6% VAT asymmetry
|
||||
between the two legs would be invisible in the arithmetic. `test_price.py` 17h
|
||||
parses every module under `app/` and goes red the day something calls an
|
||||
injection reader, so that claim is graded rather than attested.
|
||||
|
||||
**The market publishes a line, not a number.** Verbatim off the cards: Eneco
|
||||
Zon & Wind Dynamisch `0,1 x BELPEX-H - 1,188` c€/kWh — *"Het injectietarief is
|
||||
dynamisch en kan per uur wijzigen"*, identical on the January and the September
|
||||
2026 cards; ENGIE Dynamic `(B x eSpot_15) - A`; Aspiravi `0,07 x Belpex - 2`.
|
||||
Four properties a scalar cannot hold:
|
||||
|
||||
1. **Its own factor.** Eneco `0,102` on consumption against `0,100` on
|
||||
injection; Aspiravi `0,07` against about `0,10` on its consumption leg. Not
|
||||
the consumption `B` with a sign flip.
|
||||
2. **A negative offset** — `− 1,188`, `− 2`. `price_markup_eur_kwh: float(0,1)`
|
||||
cannot express a negative markup at all, so it has no home on the
|
||||
consumption side either.
|
||||
3. **VAT-exempt** — *"Injectietarieven zijn vrijgesteld van btw"* — while
|
||||
consumption is at 6%. `vat_pct` is now **per leg**, so the six-point
|
||||
asymmetry is expressible; a scalar carries no VAT field at all.
|
||||
4. **It goes negative with no floor**, below `11,88 EUR/MWh` of BELPEX-H — the
|
||||
midday hours a PV household exports in. You can be charged to export.
|
||||
|
||||
⚠️ **A NEGATIVE INJECTION PRICE IS ACCEPTED, NOT REFUSED**, and that needed a
|
||||
different band rather than a lower floor. `implausible_all_in()` floors the
|
||||
consumption leg at `+0.02 EUR/kWh` of energy, which is right there and fatal
|
||||
here — measured, not assumed: `test_price.py` 17c puts one spec through **both**
|
||||
bands and prints that the consumption band refuses `−0.03008 EUR/kWh` while the
|
||||
injection band accepts it. The floor was not decoration, though: it is what
|
||||
catches a card read a hundred times too small, the one unit error the card
|
||||
self-check cannot see because it cancels out of a comparison between the two
|
||||
sides. So the bound moves **off the price and onto the coefficients**, where it
|
||||
is index-free and sign-free — a **pass-through** band (`factor` as a share of
|
||||
the wholesale price; Eneco's injection leg is 1.00×, Aspiravi's 0.70×, the band
|
||||
is 0.10–10×) and an **offset magnitude** bound (0.20 EUR/kWh against published
|
||||
values of 0.01188 and 0.02). Two rungs that can both answer a hundredfold
|
||||
error, so each is graded on its **reason**, each gets a stimulus the other
|
||||
cannot reach, and they are mutated **together** (TEST-16).
|
||||
|
||||
⚠️ **THE CARD SELF-CHECK APPLIES WHERE THERE IS A CARD, AND WHERE THERE IS NOT
|
||||
THAT IS STATED RATHER THAN WORKED AROUND.** `price_formula` can require a `card`
|
||||
block because a Belgian card prints the consumption formula, the reference index
|
||||
*and* the resulting price. **Eneco's injection formula is printed with no price
|
||||
beside it.** Requiring a block would make the market's own published line
|
||||
inexpressible; inventing the estimate would grade the coefficients against
|
||||
arithmetic done with the same coefficients. So the block is **optional on this
|
||||
leg and unchanged on the other**, the self-check runs at the same tolerance
|
||||
wherever a printed estimate exists, and validate.py says at startup —
|
||||
*"its coefficients are UNGRADED"* — when it does not, naming the two bounds that
|
||||
ran instead. This is TARIFF-15 demonstrating two tariffs end to end rather than
|
||||
three, one card later.
|
||||
|
||||
**ONE PARSER, NOT TWO.** `custom_formula.py` already shipped the units table,
|
||||
the NaN rule, the closed enumerations, the unknown-key refusal, the card
|
||||
comparison and `to_resolution()`'s DST handling, so `parse_export_formula()` is
|
||||
that parser with its **option name as an argument** — TARIFF-17's move on
|
||||
`parse_static_profile()`, exactly. Everything that differs between the two legs
|
||||
is a string in one `_LEGS` table. `test_price.py` 17b pins it two ways: by
|
||||
parsing every module under `app/` and asserting all 32 grammar names are defined
|
||||
in exactly **one** of them, and by deriving twelve refusal sentences for the new
|
||||
option from the old option's own sentence rather than transcribing them. The
|
||||
three sentences that deliberately **differ** are named, with what differs, and
|
||||
asserted to differ.
|
||||
|
||||
**NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT.** The option ships `""`, and
|
||||
`export_price_eur_kwh()`'s body is byte-identical — pinned on the AST, not on
|
||||
the source text — with `export_price_at()` falling through to it for every index
|
||||
value. `test_price.py` 17f compares seven option dicts × five index values
|
||||
against the pre-TARIFF-16 expression with `==`.
|
||||
|
||||
`export_price_eur_kwh` and the new key are both in `FRAMEWORK_KEYS`;
|
||||
`BANNED_IN_PLUGIN_CODE` is derived from that set, so `code_leaks()` refuses a
|
||||
plugin that names either with nothing else edited.
|
||||
|
||||
**Also corrected here:** `config.yaml`'s comment beside `export_price_eur_kwh`
|
||||
still read *"no shipped supplier publishes a dynamic injection curve"*.
|
||||
TARIFF-07 struck that claim from `app/price.py` and `DOCS.md` and did not reach
|
||||
`config.yaml`, where it has been shipping since.
|
||||
|
||||
**20 mutations, every one red on a named check**, ledger in `test_price.py`
|
||||
below section 17 with byte deltas — including a **zero-byte** one (the two legs
|
||||
transposed in the VAT-asymmetry banner, which changes no decision and only
|
||||
moves the sentence) and a **one-byte** one (`card_required=True` on the
|
||||
injection leg). Three were run over all 29 files and are red on `test_price.py`
|
||||
alone; the rest over the five suites that can reach this card. Each row gets a
|
||||
fresh copy of the tree under a `mktemp` path, mounted `:ro` — no revert to fail
|
||||
and no shared backup path, which is MUTATION.md habit 18 closed structurally.
|
||||
29/29 green.
|
||||
|
||||
### TARIFF-17 - a price component that varies by hour has somewhere to live
|
||||
|
||||
**`price_taxes_profile`: the taxes component as a period table, alongside the
|
||||
flat `price_taxes_eur_kwh`.** ⚠️ **The flat scalar was not a wrong number, and
|
||||
this is not a correction of one.** The optimiser reads exactly two things off a
|
||||
series — the spread `max − min` over the firm points, and which hour is
|
||||
cheapest — and an additive constant identical in every hour cancels out of the
|
||||
first and leaves the second where it was. It is *provably* inert. What was
|
||||
missing was anywhere for an **hour-varying** non-energy component to live.
|
||||
|
||||
Belgium has one. Fluvius distribution is a single rate, and its row on the
|
||||
tariff card reads `Normaal | Uitsl. nacht` — *exclusive night* being a separate
|
||||
physical circuit, not a night register — so for the reference site in Flanders
|
||||
nothing here changes. The Walloon DSOs and Sibelga are on the same card with
|
||||
four columns, `Enkelvoudig | Dag | Nacht | Uitsl. nacht`, and the gap is real
|
||||
money (c€/kWh): ORES `13,27 / 7,39`, AIESH `15,17 / 8,21`, Tecteo RESA
|
||||
`12,20 / 7,01`, Sibelga `9,39 / 7,10`, ORES IMPACT 2026 `16,578 / 5,090`.
|
||||
|
||||
⚠️ **AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO.** The
|
||||
cheap network hours *are* the cheap spot hours, so flattening them does not
|
||||
shift the curve — it **understates the spread** and declines cycles that would
|
||||
have paid. Measured on one ordinary Belgian winter weekday, same feed, same
|
||||
markup, the only difference being the taxes argument:
|
||||
|
||||
```
|
||||
flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 -> cheapest hour 13, spread 0.1500
|
||||
ORES dag 0.1327 / nacht 0.0739 -> cheapest hour 03, spread 0.1788
|
||||
```
|
||||
|
||||
Five different flat figures spanning and exceeding the table's own range give
|
||||
**the same hour and the same spread to the last digit**; the table moves the
|
||||
hour a battery would buy in from 13:00 to 03:00. That is the regression
|
||||
`test_price.py` section 16 grades, and it grades **the hour**, not the spread:
|
||||
an implementation that reads the table once and adds one number to every point
|
||||
produces the right magnitude and the wrong shape, and a spread assertion passes
|
||||
on it. `DOCS.md` now names the three buckets — LEVEL, SPREAD, ORDER — so the
|
||||
next person adding a component knows which one it is in.
|
||||
|
||||
**The change is contained.** `static_tariff.py` already shipped the grammar —
|
||||
periods tiling the day, gaps and overlaps refused, day types, DST — so it is
|
||||
**the same parser**, taking the option name and the "what this number means"
|
||||
clause as arguments. There is no second tiling rule, because a second one would
|
||||
be a second set of 23- and 25-hour-day bugs; `test_static_tariff.py` section 10
|
||||
pins that by parsing every module under `app/` and asserting the grammar is
|
||||
defined in exactly one of them, and by deriving each of thirteen refusal
|
||||
sentences for the new option from the old option's own sentence.
|
||||
`Component.TAXES` semantics, `code_leaks()` and the double-counting guarantee
|
||||
are untouched: the new key joins `FRAMEWORK_KEYS`, `BANNED_IN_PLUGIN_CODE` is
|
||||
derived from that set, and a plugin naming it is refused at `register()`.
|
||||
|
||||
⚠️ **A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH.** It is
|
||||
read on the ~30 s planning path through `assemble()`, and a table that first
|
||||
fails at 03:00 is a site that stops pricing at 03:00 — which §11.4 turns into a
|
||||
quiet fallback indistinguishable from a healthy fixed-tariff day. `validate.py`
|
||||
parses it at startup, and refuses three things: a table that does not tile the
|
||||
day (the same sentence `static_profile` gives, naming this key); a table set
|
||||
*beside* a non-default `price_taxes_eur_kwh`, because the table wins and the
|
||||
scalar would then be a number the operator typed that decides nothing; and a
|
||||
whole card typed **in cents**, which is how these figures are printed. That
|
||||
last rung is not a restatement of the parser's ±10 EUR/kWh bound — ORES's
|
||||
`13,27` trips the parser, but Sibelga's entire card, `9,39` and `7,10`, is
|
||||
under it, and would otherwise have started as a 9.39 EUR/kWh levy.
|
||||
|
||||
**Nothing changes for a site that does not set it.** The option ships `""`, the
|
||||
scalar path is unchanged arithmetically — `test_price.py` 16f reproduces the
|
||||
pre-TARIFF-17 expression and compares 12 capability/scalar combinations point
|
||||
for point with `==` — and the reference site is in Flanders, where the flat
|
||||
figure is correct. This is a product concern rather than a site one: the add-on
|
||||
is sold to clients, and a Walloon or Brussels client could not express their
|
||||
own tariff.
|
||||
|
||||
### TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real figure
|
||||
|
||||
⚠️ **A SHIPPED DEFAULT CHANGED: `price_taxes_eur_kwh` 0.06 -> 0.11736 EUR/kWh.**
|
||||
Same shape as 0.8.0's `slew_w` rule, and the same instruction follows — **read
|
||||
the live options before `ha addons update`**, this time to see whether
|
||||
`price_taxes_profile` is set, which is the one shape the move could have
|
||||
refused. The "who is affected" section below is the part that matters, and it
|
||||
is shorter on consequences than a changed default usually is.
|
||||
|
||||
The old `0.06` had **no recorded provenance anywhere**: not a card, not a date,
|
||||
not a component list. It is roughly **half** the Flemish figure. The new default
|
||||
is the Eneco residential card for Flanders, September 2026, incl. 6 % VAT, and
|
||||
its rows are now **in the code** (`app/price.py`'s
|
||||
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, re-added by `test_price.py` section 1b):
|
||||
|
||||
| component | c€/kWh |
|
||||
|---|---|
|
||||
| Fluvius distribution (**Antwerpen**) | 5,35 |
|
||||
| Bijzondere accijns, *"Alle verbruik"* — no volume bands | 4,876 |
|
||||
| Bijdrage groene stroom en WKK Vlaanderen | 1,51 |
|
||||
| Energiebijdrage — **0 EUR/MWh since 2026-08-01**, merged into the accijns | 0 |
|
||||
| **total** | **11,736 c€/kWh = 0.11736** |
|
||||
|
||||
⚠️ **IT IS PER-DSO, AND THE DEFAULT IS NOT AN AVERAGE.** Only the distribution
|
||||
row moves: across the eight Fluvius DSOs it runs 5,28 c€/kWh
|
||||
(Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about **0.117 to
|
||||
0.131**. The default is a real DSO's real row, near the **low** end, and DOCS
|
||||
tells an operator to look up their own. A **Walloon or Brussels** site should
|
||||
not use this scalar at all — its distribution tariff has a day and a night band
|
||||
and belongs in TARIFF-17's `price_taxes_profile`.
|
||||
|
||||
⚠️ **THIS IS A DISPLAY AND GUARD FIX. IT DOES NOT IMPROVE ARBITRAGE AND CANNOT.**
|
||||
This scalar is a **LEVEL** component: an additive constant cancels exactly in
|
||||
`max − min` and leaves the argmin where it was. TARIFF-17 shipped the machinery
|
||||
and the proof — six flat scalars spanning and exceeding the whole per-DSO range
|
||||
give the **identical** cheapest hour and the **identical** spread to the last
|
||||
digit (`test_price.py` 16b, printed on every run), and none of that changes
|
||||
here. No trade happens that did not happen before *because of the level*. What
|
||||
does change is two things, and nothing else:
|
||||
|
||||
* **every number an operator reads** — the all-in prices on the status page,
|
||||
`custom_formula`'s startup preview and the recorded price history. At half
|
||||
the true non-energy component they made the add-on look considerably better
|
||||
than it is; and
|
||||
* **§17's negative-price guard** (`arbitrage.py`), the one rung that tests an
|
||||
**absolute** all-in price rather than a difference. It fires when
|
||||
`spot < −(markup + taxes)`, so its boundary moves **−0.09000 -> −0.14736
|
||||
EUR/kWh of spot**. A −0.11868 EUR/kWh spot hour used to read **−0.02868**
|
||||
all-in and the discharge was refused as paid-for disposal; at the real tax
|
||||
level it reads **+0.02868** and discharges, because that hour genuinely
|
||||
paid.
|
||||
`test_arbitrage.py` section 2c drives both sides through the shipped
|
||||
`decide()`, and pins the boundary from below as well.
|
||||
|
||||
⚠️ **AND THAT ASYMMETRY IS WHY THE DEFAULT SITS AT THE BOTTOM OF THE BAND.** Set
|
||||
too **high**, the figure carries a genuinely negative hour above zero and the
|
||||
add-on exports into paid-for disposal — §17's own case. Set too **low**, it only
|
||||
declines a discharge that would have paid. One default standing in for eight
|
||||
DSOs belongs at the bottom.
|
||||
|
||||
#### Who is affected
|
||||
|
||||
* **A site that set `price_taxes_eur_kwh` itself: nothing changes.** Its value
|
||||
is kept and is never silently replaced.
|
||||
* **A site whose stored options blob already carries the key: nothing changes
|
||||
either, and this is most existing installs.** The Supervisor sets
|
||||
`App.options = deepmerge(config.yaml defaults, stored options)`
|
||||
(`tools/supervisor_selfapi_probe.py` §D), so a moved default reaches only
|
||||
keys the blob does **not** carry — and the reference install's export carries
|
||||
**70 of the 71**, the one absentee being a key added after its last save.
|
||||
Such a site goes on running at `0.06` and reading numbers that are about half
|
||||
its real all-in price until somebody opens the Configuration tab. ⚠️ **That
|
||||
is a real limit of this card and it is not fixed here**: the change reaches a
|
||||
**fresh install**, and an existing one only when its options are re-saved.
|
||||
⚠️ **NOT re-measured on this branch** — the 70-of-71 figure is 0.9.0's, and
|
||||
PKG-09's `tools/predeploy_check.py --options <live blob>` against the live
|
||||
options is what would confirm it before deploying.
|
||||
* ⚠️ **A site with `price_taxes_profile` SET would have been refused at startup,
|
||||
and is deliberately not.** TARIFF-17 made a taxes period table beside a
|
||||
**non-default** `price_taxes_eur_kwh` a `SystemExit(78)` refusal, comparing
|
||||
against the shipped default precisely because the Supervisor writes the key
|
||||
whether it was typed or not. Moving the default turns every stored `0.06`
|
||||
into "non-default" **without anybody editing anything**, so a working Walloon
|
||||
or Brussels site would have stopped starting on the upgrade over an inert
|
||||
number the table overrides anyway. `price.py` now carries
|
||||
`SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)` and `validate.py` treats a
|
||||
superseded default as *untouched* as well. **The rung is not weakened**: a
|
||||
figure that is neither the shipped nor a superseded default — ORES's 0.1198,
|
||||
say — is still refused, by name, and the refusal now says which of the two
|
||||
figures it will accept (`test_validate.py`, four new checks plus the
|
||||
difference precondition).
|
||||
|
||||
Nothing hardcoded `0.06` in that rung — it read `PRICE_OPTION_DEFAULTS` — which
|
||||
is why the interaction is a *behaviour* change rather than a drift, and why it
|
||||
had to be decided rather than noticed.
|
||||
|
||||
`config.yaml` and `PRICE_OPTION_DEFAULTS` moved together and CFG-03's tripwire
|
||||
(`test_price.py` section 1, `test_defaults_pin.py`) stayed green by agreeing,
|
||||
not by being widened. ⚠️ One check did have to be **rewritten rather than
|
||||
re-pointed**: `test_price.py` section 0 used to justify its own grading
|
||||
constants by observing that the shipped pair was degenerate — `0.06` is exactly
|
||||
twice `0.03`. TARIFF-18 ends that coincidence, so the sentence is no longer true
|
||||
and is not kept; the file now asserts per-figure that neither shipped default is
|
||||
what it grades with, which is the property that was actually load-bearing.
|
||||
|
||||
### TARIFF-13 - the coverage figure beside the peak, and which quarter gets it
|
||||
|
||||
**The capacity-tariff status row's `N quarter(s) banked` is read from the
|
||||
database instead of from process memory.** It was an `int` on `PeakTracker`,
|
||||
starting at 0 and counted up inside `sync()`, printed next to a peak read from
|
||||
SQLite. A 40-hour soak caught what that means at a restart, on both instances:
|
||||
|
||||
```
|
||||
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
|
||||
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
```
|
||||
|
||||
The peak is durable and was never wrong. The **count** is what says how much of
|
||||
the billing month that peak covers, and 365 W over 120 quarters and 365 W over
|
||||
0 quarters are different claims about the same number — so mid-month, after
|
||||
every restart, the page made the weaker claim while showing the stronger one's
|
||||
value. TARIFF-01's rule that an unreadable store must not read as a zero peak,
|
||||
one column over. Two more defects in the same variable, neither in the ticket:
|
||||
it was **never scoped to a month** (on 1 October the row still showed
|
||||
September's tally beside October's empty month), and it **missed the block
|
||||
`restore()` banks** from the checkpoint at startup.
|
||||
|
||||
⚠️ **AN UNREADABLE STORE NOW SAYS SO IN THAT CLAUSE.** Where the database will
|
||||
not answer a count, the row reads `an unreadable number of quarter(s) banked`.
|
||||
`0` was never available; `None` was rejected too, because `None quarter(s)
|
||||
banked` reads as the same zero. `0 quarter(s) banked` stays true and reachable —
|
||||
it is what a month nothing has been written to says.
|
||||
|
||||
⚠️ **AND A TIED PEAK'S ATTRIBUTED QUARTER IS NOW DECIDED: the EARLIEST wins.**
|
||||
The wattage is what the utility bills, so this changes no bill and no guard —
|
||||
`peak_w` is still only ever raised and SAFETY-07 reads the same watts. What it
|
||||
changes is the answer to "when did my monthly peak happen", which an operator
|
||||
goes and investigates; it now stops moving. The two readers of that record
|
||||
**did** disagree: the `monthly_peak` upsert's strict `>` kept whichever tied
|
||||
block was *written* first, while `recompute_month()` has always kept the
|
||||
earliest — the same by accident only while blocks arrive in time order, and
|
||||
measured to diverge (09:45 against 09:00) when they do not.
|
||||
|
||||
⚠️ **THE SOAK'S DRIFTING QUARTER WAS NOT A TIE, AND IS LEFT ALONE.** Driven,
|
||||
exactly equal quarters never re-attributed at all. What moved the timestamp was
|
||||
a genuine new maximum **below the row's whole-watt resolution** — 365.0000001 W
|
||||
is a new peak to the database and the same `365 W` to the reader. That is the
|
||||
instrument being right, and printing more decimals to explain it would put
|
||||
noise in the one row an operator reads when the guard engages.
|
||||
|
||||
Reasoning in `DECISIONS/TARIFF-13-the-coverage-of-a-peak.md`; parts 11 and 12
|
||||
of `test_tariff.py` drive a real restart — a second `Controller` against the
|
||||
same file — and pin both decisions.
|
||||
### TARIFF-11 - the plan's own account of itself, corrected and put on the page
|
||||
|
||||
**Four defects, one shape: every one of them made the add-on look better than
|
||||
it was.** They shipped together because nothing in this repository graded the
|
||||
strings a human is shown — every tariff check inspects the `TariffPlan`
|
||||
*object*, and all four were in the *sentences* built out of it.
|
||||
|
||||
**1. `TariffPlan.words` counted points and called them hours.** The live
|
||||
add-on logged `dynamic_dayahead from generic_dayahead, 132 h` for **132
|
||||
quarter-hourly points = 33 hours**. Belgium moved to a 15-minute MTU on
|
||||
2025-10-01, so on the real feed this was wrong every time it was printed, and
|
||||
⚠️ **not by a constant factor**: measured across a 40-hour soak the
|
||||
overstatement ranged **1.79× to 5.33×**, because §8.3's carried points are
|
||||
hourly while the firm ones are quarter-hourly and the ratio moves with the mix
|
||||
through the day. It now reads
|
||||
|
||||
```
|
||||
dynamic_dayahead from generic_dayahead, 132 points over 33 h,
|
||||
132 firm / 0 carried, 900 s spacing
|
||||
```
|
||||
|
||||
— the same plan, with each number in its own unit. On the weekday morning that
|
||||
plan is partly carried and reads `60 points over 33 h, 36 firm / 24 carried,
|
||||
900+3600 s spacing, provisional` instead, which is the state the soak found an
|
||||
operator could learn nothing about. The `N points over H h` wording is
|
||||
FCST-04's *Price history* wording on purpose, so the two rows describing the
|
||||
same feed can be compared rather than being two conventions on one page.
|
||||
|
||||
**2. The §8.3 and flat-tariff refusals did the same, and they reach an
|
||||
operator.** `§8.3: only 1 of 96 hours carry a published price` is 96 points,
|
||||
which is 24 hours; the flat rung counted the same points and called them `firm
|
||||
hours`. ⚠️ **This is exactly the point≠hour conflation TARIFF-14 removed from
|
||||
`at()`, left standing in the prose that describes it** — and it is not confined
|
||||
to a log line, because `self.arb.explain()` puts it on the status page. Both
|
||||
now print the two quantities separately: `only 1 of 96 points carry a published
|
||||
price - 1 of 24 hours`. The same conflation in `price_forecast.py`'s §8.3
|
||||
carry-forward log line — `%d of %d hours` with a carried-HOUR count over a
|
||||
POINT count — is corrected in the same commit.
|
||||
|
||||
**3. The plan reached no HTTP surface at all.** `origin`, `product`, the
|
||||
firm/carried split and the spacing appeared in **no** status row. `rows["Tariff"]`
|
||||
was `static_status()`'s sentence, computed on the 30 s I/O-free tick from its
|
||||
own static plan, and it **never saw the provider plan** — it read `'no static
|
||||
profile'` at all **802** soak samples on a site whose feed was answering. So an
|
||||
operator could not tell a working dynamic tariff from no tariff machinery at
|
||||
all, and could not learn that the plan was `provisional` for 76 % of the soak
|
||||
or mixed 900/3600 s for 70 %. The *Tariff* row now carries the plan.
|
||||
|
||||
⚠️ **The two halves of that row are not computed at the same moment and the row
|
||||
says so.** `static_status()` runs on the 30 s tick; the provider plan comes off
|
||||
the 1800 s background refresh and is served for up to `TARIFF_MAX_AGE_S`
|
||||
(5400 s since TARIFF-12). So the plan half is stamped with **its own age**,
|
||||
measured from `tariff_at` — which TARIFF-12 deliberately does not move on a
|
||||
kept plan. A provider site with no usable plan says that too, rather than
|
||||
falling silently back to the static sentence.
|
||||
|
||||
**4. `main.py`'s "if and only if" was false, and the row named an option that
|
||||
was on.** TARIFF-10's comment asserted that `self.arb is None` **iff**
|
||||
`arbitrage.enabled(self.o)` was False. `cycle()` returns at the input hold —
|
||||
inputs missing, `bad_for < stale_input_s`, 15 s by default — **before** it
|
||||
reaches `self.arb = None`. Driven: `arbitrage_enabled: true` plus a sensor
|
||||
answering `None` gives `arbitrage.enabled(opts) = True`, `self.arb = None` and
|
||||
a row reading `off (arbitrage_enabled)`. Past the first completed cycle the
|
||||
same early return leaves the **previous** decision standing, which the row
|
||||
rendered as a current one, up to ~15 s stale. The row now distinguishes three
|
||||
facts with three remedies — trading is off; trading is on and this cycle
|
||||
reached no decision; a decision, with its age — and it reads the **option**
|
||||
rather than inferring it from a missing decision. The comment states what is
|
||||
true.
|
||||
|
||||
⚠️ **THE DELIVERABLE IS THE CHECK, NOT THE FIXES.** All four survived 29 green
|
||||
suites because no check in this repository had ever read a rendered row.
|
||||
`test_price.py` section 12 renders through the shipped `Controller.status_payload()`,
|
||||
parses each number out of the row **by its own slot**, and compares it against
|
||||
a figure computed from the fixture's own construction parameters — never from
|
||||
the property the row was built out of (MUTATION.md habit 4). It is deliberately
|
||||
**not** a substring test over a sentence the same file wrote: `"132 points" in
|
||||
row` is satisfied by the number appearing in the *other* number's slot, which
|
||||
habit 14 shows is a zero-byte edit away. The fixtures are a **set** of four
|
||||
plan shapes, and the check computes and prints the table of field pairs no
|
||||
fixture separates — `none` — so a pair that became degenerate would go red
|
||||
rather than quietly vacuous. The *Arbitrage* row's three states are driven
|
||||
through real cycles with a sensor answering `None`, and graded by
|
||||
**inequality**: two facts that must not render identically.
|
||||
|
||||
**Nothing about what the battery decides changes.** No threshold, no refusal
|
||||
rung, no claim, no setpoint. Every edit is to a sentence, to a derived counter
|
||||
behind one, or to where a sentence is rendered.
|
||||
|
||||
### TARIFF-15 - your own tariff card, as two coefficients
|
||||
|
||||
**New price provider `custom_formula`, and one new option, `price_formula`.**
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# SAFETY-45 — the trade is SIZED by the capacity guard, not switched by it
|
||||
|
||||
**Status: closed.** `peakguard.charge_headroom_w()` publishes the watts the open
|
||||
billed quarter can still afford; `arbitrage.decide()` takes it as a required
|
||||
keyword-only argument and sizes the BUY leg to it; `main.py` passes the measured
|
||||
guard's own number at its one call site. `peakguard.decide()` is **not changed** —
|
||||
byte-identical to `release/1.0` at `478ba93`, `sha256(decide) = 200090d0835a3a55`,
|
||||
7252 B on both trees.
|
||||
|
||||
⚠️ **THE HOUSE LOAD IN EVERY FIGURE BELOW IS SYNTHETIC.** TEST-23 stage 3's
|
||||
`priceloop.py` drives a flat modelled house against a modelled pack
|
||||
(`plantsim.Plant`, 800 W/s ramp into a 10 s lag, 15 360 Wh). The PRICES are real,
|
||||
the decision code is the shipped code, the billed-quarter accumulator is
|
||||
`app/p1.py`'s own — the load is not. Nothing here is a measurement of the live
|
||||
site at 192.168.2.158, which this work did not touch.
|
||||
|
||||
## 0. The defect, measured
|
||||
|
||||
TEST-23 stage 3 found a sustained limit cycle with arbitrage armed and a charge
|
||||
decision active. Re-measured here on the same rig against the same checkout,
|
||||
metrics recomputed rather than copied — a **charge onset** is a cycle at which
|
||||
`arbitrage.decide()` transitions into `ARBITRAGE_CHARGE`, which is the edge the
|
||||
rig already logs:
|
||||
|
||||
| `peak_cap_w` | house | onsets/h | mean period | grid swing | billed quarters (W) |
|
||||
|---|---|---|---|---|---|
|
||||
| 1500 | 1200 W | **28** | 131.1 s | **−455.5 … +3200.0 W** | 1490.1 / 1496.5 / **1508.8** |
|
||||
| 3500 *(shipped)* | 1800 W | 8 | 509.3 s | +127.5 … +3800.0 W | **3511.3 / 3536.0 / 3559.1** |
|
||||
| 3500 *(shipped)* | 3000 W | 32 | 114.8 s | +1202.0 … +5000.0 W | **3518.9 / 3542.2 / 3556.7** |
|
||||
| 5000 | 1200 W | 1 | — | +1240.6 … +3200.0 W | 3169.5 / 3200.0 / 3200.0 |
|
||||
|
||||
The last row is the **negative control**: `load + max_w = 3200 W` never reaches
|
||||
the cap, so there is no cycle to remove. The bite rule is `load + max_w >
|
||||
peak_cap_w`, which at the shipped `peak_cap_w: 3500` / `max_w: 2000` is any house
|
||||
drawing over 1500 W in the cheapest quarter.
|
||||
|
||||
§10.1's ordering did win on the money — the guard kept the quarters within tens
|
||||
of watts of the cap — but **every billed quarter in the three biting rows
|
||||
finished OVER it**, which is the thing §9.1 bills. §10.2: *"an EMS that
|
||||
oscillates is worse than no EMS, both for hardware wear and — per §9.1 — for the
|
||||
peak it is trying to avoid."*
|
||||
|
||||
## 1. ⚠️ The mechanism is NOT mainly the quarter boundary, and the card says it is
|
||||
|
||||
The card, and `.cache/s45.md` before it, describe the loop as *"at each quarter
|
||||
boundary the projection resets to 0, the guard disengages, and arbitrage re-arms
|
||||
at full power."* **That is not what the 28 onsets are.** Their timestamps, off
|
||||
the run above:
|
||||
|
||||
1, 231, 351, 471, 581, 696, 806, 896, 1136, 1256, 1371, 1486, 1596,
|
||||
1711, 1811, 2061, 2176, 2296, 2411, 2521, 2626, 2716, 2971, 3091,
|
||||
3206, 3321, 3431, 3541 (s, quarter boundaries at 900/1800/2700)
|
||||
|
||||
**23 of the 27 gaps are 90–120 s, mean 111.5 s, entirely inside one quarter.**
|
||||
The four long gaps — 230, 240, 250, 255 s — are precisely the four that SPAN a
|
||||
boundary, so the boundary **lengthens** the interval rather than causing an
|
||||
onset. Only three onsets land within 15 s of a boundary and one of those is
|
||||
`t = 1`, the start of the run.
|
||||
|
||||
The real cycle, read off the recorded fields at t = 1000…1180 s:
|
||||
|
||||
1. the trade charges at `−2000 W`; the meter sits at 3200 W against a 1500 W
|
||||
ceiling; `wanted = batt_w + excess` is **negative** (−132 → −8 W), so
|
||||
`decide()` files **nothing** and arbitrage is not constrained at all;
|
||||
2. `wanted` drifts up as the quarter's own history worsens and the `900 /
|
||||
remaining` gain grows, and crosses zero at t = 1055 with **`floor_w = 5.3 W`**;
|
||||
3. that 5 W floor flips `arbitrage.guard_state()` to `True`, which refuses the
|
||||
**whole trade** — so the setpoint goes from −2000 W to +5 W and the
|
||||
grid-following loop, which targets grid = 0 and not grid = ceiling, takes the
|
||||
actuator and discharges to **+1870 W**, exporting 420 W;
|
||||
4. the projection collapses, `wanted` goes negative, the guard releases, and
|
||||
arbitrage re-arms at **`max_w`**. 115 s later, again.
|
||||
|
||||
So the defect is **a binary interlock keyed on a continuous quantity**, and the
|
||||
quarter boundary is one of the ways it is crossed rather than the cause. The
|
||||
card's other claim — *"projection 3092 W against a 1509 W ceiling, `engaged=False`,
|
||||
for 170 consecutive seconds"* — reproduces, but it is the **first** such window
|
||||
(`first_engaged_t_s = 151.0`); the steady-state silent window is ~40–65 s.
|
||||
|
||||
None of this changes what the fix has to do. It is recorded because a card that
|
||||
says "the boundary is the trigger" invites a fix in the boundary handling, and
|
||||
such a fix would have removed four of twenty-eight onsets.
|
||||
|
||||
## 2. What was rejected, and why
|
||||
|
||||
### 2a. Reverting `decide()`'s `wanted <= 0.0` rung — rejected, and it is a trap
|
||||
That rung is 2026-08-20's anti-bang-bang. Its own comment records the cost of not
|
||||
having it: 17 engage/release cycles in 900 s and a quarter finishing 49 W over
|
||||
the limit it had spent a quarter of an hour holding. It is untouched, pinned on
|
||||
the AST in `test_peakguard.py` 13g, and `tools/replay_ac3.py` reproduces the same
|
||||
190/190, 110 engaged cycles, 5 blind, 878 Wh on this branch as on `478ba93`.
|
||||
|
||||
### 2b. Filing the affordable charge as a NEGATIVE `lo` — rejected
|
||||
Arithmetically this is the prettiest answer: `lo = wanted` with no sign gate is
|
||||
continuous, is exactly the control law `decide()` already computes, and would
|
||||
have put the constraint on the arbiter where it binds every claimant rather than
|
||||
only arbitrage. It was rejected on two grounds, in this order:
|
||||
|
||||
1. ⚠️ **IT CREATES A NEW `lo > hi` CONTRADICTION CLASS AGAINST THE MAINTENANCE
|
||||
SHAPING.** `main.py`'s cheap-window maintenance charge files
|
||||
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` — a NEGATIVE `hi`.
|
||||
`arbiter.py` clause 2 applies every limit at or above the winner's priority
|
||||
and takes the most restrictive, with **no notion of one limit out-ranking
|
||||
another**; a peak `lo = −300 W` against a shaping `hi = −1000 W` is `lo > hi`,
|
||||
which resolves to **0 W — the battery parked, in the one quarter of the month
|
||||
that sets the bill**. That is the exact failure `peakguard.shaping_suspended()`
|
||||
exists to prevent, and the only way to close it would be to key that
|
||||
suppression on "a claim exists" — which, on every site this card is about,
|
||||
means the maintenance charge-only shaping is suspended **permanently**. A
|
||||
silent, large change to a different subsystem is not a price this card should
|
||||
pay.
|
||||
2. It changes the meaning of `PeakDecision.engaged` at five call sites outside
|
||||
this module — `arbitrage.guard_state()`, `shaping_suspended()`,
|
||||
`claim()`, `main.py`'s edge-logged action line and `main.py`'s status row —
|
||||
each of which today means "the guard is HOLDING" and would silently start
|
||||
meaning "a claim exists".
|
||||
|
||||
`test_peakguard.py` part 5's sweep asserts **no negative floor, ever**, and the
|
||||
reason it gives ("a `lo` limit is incapable of capping discharge or export only
|
||||
while it is non-negative") is, strictly, not the true reason — a `lo` of any sign
|
||||
caps neither. The invariant is still right and is still worth holding; §2b above
|
||||
is the argument that actually supports it, and it is recorded here rather than
|
||||
left for the next person to re-derive.
|
||||
|
||||
### 2c. A backoff, cooldown or hysteresis constant in `arbitrage.py` — rejected
|
||||
The card asks whether the second half of the fix belongs in arbitrage: *"a trade
|
||||
refused at 14:59 and re-armed at 15:00 has learned nothing from the refusal."*
|
||||
It does belong there, but as a **size**, not as a memory. `arbitrage.py`'s
|
||||
hysteresis note is explicit that the switching decision is "which hour is it" and
|
||||
that **no timer is needed and none is invented**; `peakguard.py`'s design note
|
||||
spends a paragraph on why it carries no hysteresis constant. A cooldown would be
|
||||
a second control loop compensating for a limit the first one declined to file,
|
||||
and its constant would outlive whatever it was tuned against. The trade now
|
||||
re-arms at `ceiling − load` instead of at `max_w`, which is what "learning from
|
||||
the refusal" means when the refusal is a measurement.
|
||||
|
||||
## 3. What was done, and the precedent it follows
|
||||
|
||||
`control.maintenance_charge_floor()` has capped MAINTENANCE charging at *"the
|
||||
headroom left under the site's cap"* since the loop was written, and its
|
||||
docstring says why:
|
||||
|
||||
> "A maintenance charge is the only thing this system does that is big enough and
|
||||
> long enough to set that peak, so it is capped by the headroom left under the
|
||||
> site's cap."
|
||||
|
||||
⚠️ **THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED.** Price arbitrage is a
|
||||
second thing that is big enough and long enough — it commands `max_w` for a whole
|
||||
tariff interval, deliberately, against the meter — and it was given no such cap.
|
||||
This card gives it the same shape, with one difference that matters:
|
||||
`maintenance_charge_floor()` sizes itself from `peak_forecast_entity`, which is
|
||||
OPTIONAL and empty on a default install — the exact hole SAFETY-25 had to close
|
||||
in `shaping_suspended()`. `charge_headroom_w()` comes off `decide()`, which runs
|
||||
wherever P1 does.
|
||||
|
||||
**The number is `wanted_w` read in the other direction.** `decide()` already
|
||||
computes `wanted = batt_w + excess` as the DISCHARGE that lands the quarter on the
|
||||
ceiling; a negative `wanted` is a permitted CHARGE of `−wanted`. No new
|
||||
arithmetic, no new constant.
|
||||
|
||||
Two properties make it safe to hand a strategy, and both are swept rather than
|
||||
argued (`test_peakguard.py` part 13):
|
||||
|
||||
* ⚠️ **IT IS INVARIANT IN THE BATTERY.** Raise the charge by dW and the meter
|
||||
rises by dW, so `excess` rises by exactly dW while `batt_w` falls by dW:
|
||||
`wanted` does not move. Measured across `batt_w` from −5000 W to +2000 W on one
|
||||
banked quarter — the headroom is 1350.0 W at every one of the six points while
|
||||
the `excess` those runs see moves through six distinct values. It is a
|
||||
**feed-forward** number, so consuming it adds no feedback path around the plant.
|
||||
The fixed point is `charge = ceiling − load`, with the meter ON the ceiling and
|
||||
`wanted` parked a whole `ceiling − load` clear of the engagement boundary.
|
||||
* **HOLDING IMPLIES ZERO OR UNKNOWN.** Across 1080 swept combinations, not one
|
||||
ENGAGED decision answers a positive headroom, so the function can never hand a
|
||||
caller permission the guard's own `lo` is at that instant refusing.
|
||||
|
||||
## 4. After, on the same rig, same command, same checkout
|
||||
|
||||
| `peak_cap_w` | house | onsets/h | grid swing | energy bought | billed quarters (W) |
|
||||
|---|---|---|---|---|---|
|
||||
| 1500 | 1200 W | 28 → **1** | 3655.5 → **282.0 W** | 180.0 → **285.2 Wh** | **1500.0 / 1500.0 / 1500.0** |
|
||||
| 3500 | 1800 W | 8 → **1** | 3672.5 → 1687.6 W | 1652.9 → 1616.1 Wh | **3500.0 / 3500.0 / 3500.0** |
|
||||
| 3500 | 3000 W | 32 → **1** | 3798.0 → **474.7 W** | 482.9 → 475.3 Wh | **3500.0 / 3500.0 / 3500.0** |
|
||||
| 5000 | 1200 W | 1 → 1 | **identical** | **identical** | **identical** |
|
||||
|
||||
* **One onset** means the trade starts once and runs for the whole window. The
|
||||
battery is trading 100 % of the hour where it used to trade 43 %, 92 % and 54 %.
|
||||
* **Every billed quarter lands exactly on the cap and none over it**, where every
|
||||
quarter in the three biting rows used to finish over.
|
||||
* **The money.** At 1500/1200 the site buys **58 % MORE** energy, because a
|
||||
continuous 300 W trade beats a 2000 W trade that is cancelled for 57 % of the
|
||||
hour. At the two shipped-default rows it buys **2.2 % and 1.6 % less** — and in
|
||||
exchange the worst quarter drops from 59 W over the cap to 0 W over it. The
|
||||
capacity tariff bills the month's single worst quarter with no averaging and no
|
||||
forgiveness (specs.md:583), so that is not a trade-off, it is the ordering
|
||||
§10.1 asks for.
|
||||
* The remaining 1687.6 W at 3500/1800 is the opening **ramp**, not a cycle: the
|
||||
pack goes from 0 to 1700 W of charge in about ten seconds and the meter with
|
||||
it. One onset, no export, no reversal.
|
||||
* The last row is bit-identical before and after. **This card costs nothing on
|
||||
the installs it is not about.**
|
||||
|
||||
## 5. What this does NOT fix, with sizes
|
||||
|
||||
* **TEST-23 finding 1 — the export overshoot at the END of a trade** (356–938 W,
|
||||
≈0.37 × `max_w`) is untouched and is a different card. It happens when the
|
||||
cheap interval ends and the grid-following loop takes back an actuator that was
|
||||
holding a charge; nothing here changes the handover.
|
||||
* **A house whose load ALONE exceeds the cap** (`load > peak_cap_w`) still puts
|
||||
`wanted > 0`, still engages the guard, and still refuses the trade outright
|
||||
through the interlock — correctly, since you cannot buy in a quarter you are
|
||||
already over on. The loop then drives to grid = 0 rather than to grid =
|
||||
ceiling, which is more discharge than the guard asked for. Not swept here: all
|
||||
four (cap, load) pairs on the record have `load < cap`.
|
||||
* **`arbitrage.claim()` still files the SIZED number, but the ARBITER can clamp
|
||||
it further** and the strategy is not told. The reason line names the guard's
|
||||
headroom, not the arbiter's `bound_by`. Pre-existing; not widened.
|
||||
* **The container leg was not re-run.** `hadrive.py` needs `test23s3-addon`
|
||||
started, and that container is deliberately stopped because it is a second
|
||||
writer on the setpoint entity under measurement. The in-process rig's twelve
|
||||
transcribed lines are pinned by `test_arbitrage.py` 3b's AST check on
|
||||
`main.py`'s call site, which is the line this card adds to them.
|
||||
@@ -0,0 +1,225 @@
|
||||
# TARIFF-13 — the coverage of a peak, and which quarter gets the credit
|
||||
|
||||
Two decisions. §1 is the defect the ticket was raised on; §2 is the smaller
|
||||
question it asked to be settled deliberately rather than left as an accident.
|
||||
§3 is what this branch deliberately did **not** do, and why.
|
||||
|
||||
---
|
||||
|
||||
## 0. What was actually wrong, stated once
|
||||
|
||||
`main.py`'s capacity-tariff status row prints a peak read from SQLite and a
|
||||
coverage figure read from **process memory**:
|
||||
|
||||
```python
|
||||
_pk = self.peak.peak(when) # SQLite, durable
|
||||
...
|
||||
+ f"; {self.peak.recorded} quarter(s) banked" # an int on the tracker
|
||||
```
|
||||
|
||||
`PeakTracker.recorded` was `self.recorded = 0` in `__init__`, incremented once
|
||||
per block inside `sync()`. A 40-hour soak caught what that means at a driven
|
||||
restart, on both instances independently:
|
||||
|
||||
```
|
||||
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
|
||||
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
```
|
||||
|
||||
Reproduced here before anything was changed, through the real `Controller`, the
|
||||
real `P1Ingest`, and a **real restart** — a second `Controller` object against
|
||||
the same file on disk, never a re-read of the first:
|
||||
|
||||
```
|
||||
before: 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 12 quarter(s) banked
|
||||
after : 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
|
||||
peak survives the restart: True
|
||||
count survives the restart: False (12 -> 0, store held 12 rows)
|
||||
```
|
||||
|
||||
**TARIFF-01's argument, one column over.** That module's whole design is that
|
||||
an unreadable store must not read as a zero peak, *because a zero peak is
|
||||
disarming*. A coverage figure of zero is disarming in exactly the same way and
|
||||
about the same number: the count is what says how much of the billing month the
|
||||
watt figure actually covers, and 365 W over 120 quarters and 365 W over 0
|
||||
quarters are different claims about the same 365 W. After a restart the page
|
||||
made the weaker claim while displaying the stronger one's value. It did not
|
||||
under-report the peak — it under-reported **its own evidence**.
|
||||
|
||||
Two further defects fell out of the same variable once it was driven, neither
|
||||
of them in the ticket:
|
||||
|
||||
* **it was never scoped to a month.** It counted every block the *process* had
|
||||
banked, so on 1 October the row read September's tally beside October's "no
|
||||
completed quarter recorded yet". Measured, on the shipped tree:
|
||||
`2026-10: no completed quarter recorded yet - 0 % of the 3500 W soft limit;
|
||||
1 quarter(s) banked`.
|
||||
* **it missed the block `restore()` banks.** A stale block closed from the
|
||||
checkpoint goes into `quarter_blocks` without passing through `sync()`, so
|
||||
the counter never saw it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Decision: the count is READ FROM THE TIER THE PEAK IS READ FROM
|
||||
|
||||
The acceptance criterion allowed either route — make the count durable, or stop
|
||||
printing a number that silently resets. **Durable**, because the count is the
|
||||
useful half of the row and removing it would have cost the operator the one
|
||||
thing that says whether the peak means anything yet.
|
||||
|
||||
`PeakStore.banked_count(month)` asks the database `SELECT COUNT(*) FROM
|
||||
quarter_blocks WHERE month=?`, and `PeakTracker.recorded` is a property over it
|
||||
rather than an attribute. Three consequences, and all three are the defects
|
||||
above:
|
||||
|
||||
* a restart re-reads the same rows, so the figure survives it;
|
||||
* the count is per **month**, so it agrees with the peak it is printed beside;
|
||||
* a block banked by `restore()` is a row like any other and is counted.
|
||||
|
||||
Three things this route deliberately does **not** do:
|
||||
|
||||
* **it does not count `len(blocks_in(month))`.** `blocks_in()` returns `[]` on a
|
||||
read error — safe there only because no caller reads its emptiness as a peak,
|
||||
which its own docstring says — so taking its length would rebuild the
|
||||
disarming zero one call further out. `banked_count()` returns **`None`** when
|
||||
the database will not answer.
|
||||
* **it does not count `monthly_peak`.** That tier holds one row per *month* and
|
||||
would answer `1` for every month ever measured — wrong by exactly the amount
|
||||
that makes it look plausible. The two tables are proven equal on the **peak**
|
||||
by `recompute_month()`; they were never equal on the **count**.
|
||||
* **it does not remove the dedupe.** `_seen` is still what stops `cycle()`'s
|
||||
once-a-second `sync()` re-banking a block that `P1Ingest.blocks` keeps for a
|
||||
day, and `sync()`'s return value is still the number newly banked. What moved
|
||||
is only where the *status page* reads its figure. ⚠️ The check that graded the
|
||||
dedupe used to assert `recorded == 1` — the dedupe and the figure reporting it
|
||||
were the same variable. Counting rows in `quarter_blocks` cannot see a
|
||||
re-bank at all (it is a PRIMARY KEY no-op), so that check is now graded on
|
||||
`sync()`'s **return**, with the store's tally as the second half.
|
||||
|
||||
### ⚠️ The unreadable case renders a WORD, not a number
|
||||
|
||||
`banked_count()` returns `int | None`; `recorded_in()` renders the `None` as
|
||||
the module constant `COUNT_UNREADABLE = "an unreadable number of"`, so the
|
||||
sentence reads
|
||||
|
||||
```
|
||||
... ; an unreadable number of quarter(s) banked
|
||||
```
|
||||
|
||||
`0` was never available. `None` was rejected too: `f"{None} quarter(s) banked"`
|
||||
renders **"None quarter(s) banked"**, which an operator reads as the same zero.
|
||||
This is the `PeakReading` trade — no `peak_w_or(default)`, ever — applied to the
|
||||
coverage figure: a caller that formats it gets a true sentence in both states,
|
||||
and a caller that does arithmetic on it gets a `TypeError`, which is loud.
|
||||
|
||||
"0 quarter(s) banked" remains a **true and reachable** sentence: it is what a
|
||||
month nothing has been written to says, and part 11 asserts that the two states
|
||||
are spelled differently.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision: at EQUAL wattage, the EARLIEST quarter keeps the attribution
|
||||
|
||||
**First, not most recent.** The reasoning, in the order it matters:
|
||||
|
||||
1. **It is not a money question.** The utility bills the wattage; the timestamp
|
||||
is not part of the bill. So the decision is settled entirely on what the
|
||||
status row is *for*.
|
||||
2. **The row answers "when did my monthly peak happen".** An operator reading it
|
||||
goes and looks at one event — what was running, what the weather was. A
|
||||
stable answer is the one that can be investigated; "most recent wins"
|
||||
guarantees the answer moves every time the wattage recurs, and an operator
|
||||
who looked yesterday is sent somewhere else today for no reason.
|
||||
3. **It is what one reader already did unconditionally.** `recompute_month()`
|
||||
is `ORDER BY offtake_avg_w DESC, quarter_start ASC LIMIT 1` — earliest wins.
|
||||
Choosing "most recent" would have meant changing both readers; choosing
|
||||
"earliest" meant making the other one say out loud what it was already doing
|
||||
by accident.
|
||||
|
||||
### ⚠️ And the two readers DID diverge, which is why this is a code change
|
||||
|
||||
The `monthly_peak` upsert was `WHERE excluded.peak_w > monthly_peak.peak_w` —
|
||||
strictly greater, so an exact tie never wrote and the row kept whichever tied
|
||||
block was **written first**. That coincides with *earliest* only while blocks
|
||||
arrive in time order. Fed four equal 365 W blocks newest-first, measured on the
|
||||
shipped tree:
|
||||
|
||||
```
|
||||
ascending arrival monthly_peak -> 09:00:00 recompute_month -> 09:00:00 agree=True
|
||||
DESCENDING arrival monthly_peak -> 09:45:00 recompute_month -> 09:00:00 agree=False
|
||||
```
|
||||
|
||||
That is a divergence in the derived record that part 8 of `test_tariff.py`
|
||||
exists to prove cannot happen — latent, because nothing in the shipped path
|
||||
banks out of order today. The upsert now carries the tie term explicitly:
|
||||
|
||||
```sql
|
||||
WHERE excluded.peak_w > monthly_peak.peak_w
|
||||
OR (excluded.peak_w = monthly_peak.peak_w
|
||||
AND excluded.quarter_start < monthly_peak.quarter_start)
|
||||
```
|
||||
|
||||
**`peak_w` is untouched.** The `>` term is exactly as it was, no tie can move
|
||||
the billed number, and SAFETY-07's guard reads the same watt figure it read
|
||||
before. The second term moves only the timestamp.
|
||||
|
||||
### ⚠️ AND THE SOAK'S DRIFT WAS NOT A TIE AT ALL
|
||||
|
||||
The ticket's reading — "ties at the same wattage are re-attributed to the most
|
||||
recent matching quarter" — is **false**, and driving it is what showed that.
|
||||
Six exactly-equal 365.0 W quarters on the shipped tree moved the attribution
|
||||
**zero** times, because `>` is strict.
|
||||
|
||||
What the soak saw was a genuine new maximum **smaller than the row's own
|
||||
resolution**. The row renders `{peak_w:.0f}` — whole watts — so 365.0000001 W is
|
||||
a new peak to the database and the same `365 W` to the reader:
|
||||
|
||||
```
|
||||
q0 fed 365.0 -> renders 365 W, attributed 09:00:00
|
||||
q1 fed 365.0000001 -> renders 365 W, attributed 09:15:00
|
||||
q2 fed 365.0000002 -> renders 365 W, attributed 09:30:00
|
||||
```
|
||||
|
||||
**Decision: left alone, deliberately.** It is the instrument behaving correctly
|
||||
— the peak really did rise — and the alternative, printing more decimal places,
|
||||
puts noise in the one row an operator reads at the moment the guard engages, to
|
||||
explain a movement that is real. Both cases are pinned in part 12 so that
|
||||
neither can be changed by accident, and the distinction is recorded here so the
|
||||
next reader does not re-diagnose it as a tie.
|
||||
|
||||
---
|
||||
|
||||
## 3. What this branch did NOT do, and who owns it
|
||||
|
||||
**`main.py`'s status row was not touched.** TARIFF-11 holds `main.py`'s status
|
||||
rows on a concurrent branch, and the standing instruction is to stop rather than
|
||||
race there. Everything above is in `app/tariff.py`, and the row picks the fix up
|
||||
without editing, because `self.peak.recorded` is an attribute access and is now
|
||||
a property.
|
||||
|
||||
**The residual, stated with its scope.** `recorded` answers for
|
||||
`datetime.now()`. The peak beside it follows `checks(when=...)`. The shipped
|
||||
install has **exactly one** call site — `self.checks()` with no argument, at
|
||||
`main.py:4359` — where the two are the same month, so **the live page is correct
|
||||
and durable**. They part company only under the `when=` affordance TEST-17 added
|
||||
for the suite, and the mismatch there is strictly smaller than the one it
|
||||
replaces (this month's tally, rather than a process-wide total across all
|
||||
months).
|
||||
|
||||
`PeakTracker.recorded_in(when)` is the scoped form and is shipped. Moving the
|
||||
row onto it is:
|
||||
|
||||
```python
|
||||
- + f"; {self.peak.recorded} quarter(s) banked"
|
||||
+ + f"; {self.peak.recorded_in(when)} quarter(s) banked"
|
||||
```
|
||||
|
||||
one token, on one line, inside the block TARIFF-11 owns. `test_tariff.py`
|
||||
part 10 **pins the present behaviour** — graded against a row count taken
|
||||
straight from `quarter_blocks`, not against the property that produces it — so
|
||||
the gap goes red if it changes and cannot be quietly forgotten.
|
||||
@@ -85,11 +85,14 @@ refusal: the card's own point must land inside 0.02–1.00 EUR/kWh.
|
||||
|
||||
⚠️ **Its floor is applied to the ENERGY figure and not to the all-in one, and
|
||||
that asymmetry was found by a mutation rather than by reasoning.** The shipped
|
||||
`price_taxes_eur_kwh` is 0.06 — three times the floor — so a card read a
|
||||
`price_taxes_eur_kwh` was 0.06 — three times the floor — so a card read a
|
||||
hundred times too small comes out at 0.0016 EUR/kWh of energy, is carried over
|
||||
0.02 by the flat tax addend alone, and passes a band applied to the total. The
|
||||
hundredfold error is exactly what the band exists for. Graded from both
|
||||
directions in `test_price.py` 15e/15i and driven red as M10.
|
||||
directions in `test_price.py` 15e/15i and driven red as M10. *(Annotated
|
||||
2026-09-13: TARIFF-18 raised the default to 0.11736, nearly six times the
|
||||
floor. The asymmetry is unchanged in kind and larger in degree — the flat
|
||||
addend that carries a hundredfold-small card over 0.02 got bigger.)*
|
||||
|
||||
## 4. Fixed coefficients. No expression evaluation of any kind
|
||||
|
||||
@@ -155,4 +158,9 @@ granularity it was handed. Graded.
|
||||
* **The hour-varying network charge that bites Wallonia.** Separate card.
|
||||
* **Raising `price_taxes_eur_kwh` from 0.06 to ~0.12.** Separate card, and an
|
||||
owner's decision. Note §3b: the band's floor is deliberately insensitive to
|
||||
whatever that number becomes.
|
||||
whatever that number becomes. *(Annotated 2026-09-13: that card is
|
||||
**TARIFF-18** and it shipped on this branch at **0.11736** — the Eneco
|
||||
residential card for Flanders, September 2026, incl. 6 % VAT, summed from
|
||||
Fluvius Antwerpen distribution 5,35 + bijzondere accijns 4,876 + bijdrage
|
||||
groene stroom en WKK 1,51 c€/kWh. §3b held: the floor is still on the energy
|
||||
figure and nothing in this module needed a new number.)*
|
||||
|
||||
+415
-28
@@ -979,6 +979,7 @@ What exists today, and what does not:
|
||||
|---|---|
|
||||
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
|
||||
| Maintenance charging capped under `peak_cap_w` | ✅ |
|
||||
| **Arbitrage charging capped under `peak_cap_w`** | ✅ — SAFETY-45, sized to the guard's own MEASURED headroom rather than to `peak_forecast_entity` |
|
||||
| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
|
||||
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
|
||||
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
|
||||
@@ -1041,6 +1042,19 @@ projected over — raises one notification (`goodwe_peak_saturated`), because th
|
||||
is the one state where the money will be spent and shedding load is the only
|
||||
thing left that changes the bill.
|
||||
|
||||
**And it sizes a price-driven charge, not only a forced discharge (SAFETY-45).**
|
||||
The same arithmetic read the other way answers *"how hard may this site charge
|
||||
without putting the quarter over"* — `peakguard.charge_headroom_w()` — and
|
||||
`arbitrage.decide()` sizes its buy leg to it. Before that, a trade commanded
|
||||
`max_w` until the projection rose far enough to engage the guard, at which point
|
||||
the interlock cancelled the trade outright, the loop took the actuator, the
|
||||
projection collapsed and the trade came back at `max_w`: **28 charge onsets in
|
||||
one hour** on TEST-23 stage 3's closed rig, with the grid swinging −456 to
|
||||
+3200 W and every billed quarter finishing over the cap. It is one onset now,
|
||||
the trade runs continuously at the affordable rate, and the billed quarters land
|
||||
**on** the cap. ⚠️ The guard is what changed *around* arbitrage; `decide()`
|
||||
itself is untouched, so nothing above this line is different.
|
||||
|
||||
**No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The
|
||||
coverage threshold is `meter_max_age_s`, the freeze predicate is
|
||||
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
|
||||
@@ -1371,8 +1385,9 @@ does, and only after you have read why.
|
||||
|---|---|
|
||||
| `price_provider` | `off` (default) or one of the three plugins below |
|
||||
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
|
||||
| `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_taxes_eur_kwh` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
|
||||
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
|
||||
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
|
||||
| `price_csv_path` | Where `manual_csv` reads its file |
|
||||
| `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 |
|
||||
@@ -1691,10 +1706,13 @@ the default markup and taxes, not an illustration.)
|
||||
|
||||
##### What it does not do
|
||||
|
||||
The **injection** leg is a separate card. Eneco uses `0,102` on consumption and
|
||||
The **injection** leg is a separate card, and it has its own option:
|
||||
`price_export_formula` (TARIFF-16). Eneco uses `0,102` on consumption and
|
||||
`0,100` on injection, and injection is VAT-exempt where consumption is at 6 %,
|
||||
so it is a second per-contract formula rather than a second copy of this one.
|
||||
`export_price_eur_kwh` is still the scalar you enter by hand.
|
||||
so it is a second per-contract formula rather than a second copy of this one —
|
||||
the same parser, its own coefficients. `export_price_eur_kwh` remains the flat
|
||||
injection price for a site that is paid one, and **no strategy reads either**;
|
||||
see *Injection is priced separately* above.
|
||||
|
||||
#### `manual_csv` — the offline fallback
|
||||
|
||||
@@ -1765,12 +1783,50 @@ 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`.
|
||||
firm/provisional split whenever that split is what refused a trade. And
|
||||
**Tariff** on the status page reads `provisional` through the morning, because
|
||||
since TARIFF-11 that row carries the provider plan itself:
|
||||
|
||||
```
|
||||
Tariff: no static profile (recomputed this tick); provider plan fetched 412 s
|
||||
ago for a 48 h horizon: dynamic_dayahead from generic_dayahead,
|
||||
60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing,
|
||||
provisional
|
||||
```
|
||||
|
||||
⚠️ **The two halves of that row are not computed at the same moment, and the
|
||||
row says so.** Everything before the semicolon is the static-profile or Home
|
||||
Assistant price-entity sentence, regenerated on the **30 s tick** from options
|
||||
alone — no network. Everything after it is the **background refresh's**
|
||||
resolution, which runs every 1800 s and is served for up to 5400 s before it is
|
||||
retired; `fetched N s ago` is the age of the fetch that produced it and is the
|
||||
only figure in the row that tells you whether the feed is still answering. On a
|
||||
site with a provider configured and no usable plan the row says that too,
|
||||
rather than falling silently back to the static half.
|
||||
|
||||
Read the plan half as five separate facts: which *product* the feed publishes
|
||||
and which *plugin* read it, how many **points** it carries and how many **whole
|
||||
hours** those points cover, how many of the points are **firm** (published for
|
||||
their own interval) against **carried** (filled from yesterday under §8.3), the
|
||||
**spacing** of the points in seconds — `900+3600` is the normal mixed steady
|
||||
state, quarter-hourly firm points beside hourly carried ones — and `provisional`
|
||||
whenever any point is carried.
|
||||
|
||||
⚠️ **Points and hours are two different numbers and this row used to print one
|
||||
of them under the other's name.** `words` was `f"… {len(points)} h"`, so a
|
||||
33-hour plan of quarter-hourly points logged `dynamic_dayahead from
|
||||
generic_dayahead, 132 h`. It is the same conflation the *Price history* row
|
||||
carried before FCST-04 and the same one `at()` carried before TARIFF-14, and it
|
||||
is **not a constant factor**: measured over a 40-hour soak the overstatement
|
||||
ranged **1.79× to 5.33×**, because the carried points are hourly while the firm
|
||||
ones are quarter-hourly, so the ratio moves with the mix as the day goes on.
|
||||
The same sentence is in the **add-on log** at every refresh, alongside `§8.3:
|
||||
33 of 48 hours carried forward from yesterday's profile and flagged
|
||||
provisional; the feed published 60 points over the other 15 hours`.
|
||||
|
||||
The §8.3 refusal an operator reads when a trade is declined counts in the same
|
||||
two units — `§8.3: only 1 of 96 points carry a published price - 1 of 24 hours`
|
||||
— where it used to say `only 1 of 96 hours` about a 24-hour day.
|
||||
|
||||
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:
|
||||
@@ -1807,31 +1863,335 @@ 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.
|
||||
Negative values are accepted — you can be paid to *not* export at midday in
|
||||
summer.
|
||||
### Injection is priced separately
|
||||
|
||||
⚠️ It is a single number rather than a curve, and the reason given here until
|
||||
TARIFF-07 — *"no supported supplier publishes a dynamic injection tariff"* — was
|
||||
**false**. Suppliers do. Eneco publishes `0,1 x BELPEX-H − 1,188` on the same
|
||||
tariff card as its consumption formula (identical on the January and the
|
||||
September 2026 cards), and ENGIE publishes one in the same shape,
|
||||
`(B x eSpot_15) − A`. What is true is narrower: an injection curve is the **same
|
||||
two-coefficient formula** as the consumption curve but with **its own**
|
||||
coefficients and **its own VAT treatment** — injection is VAT-exempt where
|
||||
consumption is at 6% — so it is a second per-contract formula rather than a copy
|
||||
of the first, and the add-on cannot evaluate either one yet. Until the formula
|
||||
provider ships, **this scalar is a simplification you enter by hand — an average
|
||||
of what you are actually paid — and not a reflection of the market.**
|
||||
There are two ways to say what you are paid for an exported kWh, and which one
|
||||
you want depends on your contract:
|
||||
|
||||
**The old Home Assistant price sensors.** `price_now_entity` and
|
||||
| your contract | what to set |
|
||||
|---|---|
|
||||
| a flat injection price | `export_price_eur_kwh` — one number, EUR/kWh. This is the default and what every existing install uses |
|
||||
| a price that moves with the market | `price_export_formula` — your card's injection line as `factor × index + offset`, in exactly the shape `price_formula` uses for consumption |
|
||||
|
||||
Negative values are accepted on both — you can be charged to export at midday in
|
||||
summer, and the add-on says so rather than flooring it.
|
||||
|
||||
#### ⚠️ What the add-on does *not* do with an injection price
|
||||
|
||||
**No strategy in this add-on reads one.** Not the scalar, not the formula.
|
||||
`arbitrage.py` prices the sell side as **displaced import** — both hours it
|
||||
compares are all-in *consumption* prices — and its own comment gives the reason:
|
||||
§9.3's near-zero Belgian injection price is exactly why the economically sound
|
||||
trade on this market is *charge cheap to displace expensive import*, not *charge
|
||||
cheap to export dear*. So setting `price_export_formula` changes **no decision
|
||||
the add-on takes today**. It changes what you are shown at startup, and it is
|
||||
the shape any future export-aware strategy would read.
|
||||
|
||||
That is the argument for doing it now rather than later: OPT-06 and any export
|
||||
logic would otherwise inherit a scalar that cannot represent the market, and the
|
||||
6% VAT asymmetry between the two legs would be invisible in the arithmetic.
|
||||
`test_price.py` 17h parses every module under `app/` and goes red the day
|
||||
something calls an injection reader, so this paragraph is graded rather than
|
||||
attested.
|
||||
|
||||
#### Why a scalar was not enough
|
||||
|
||||
Belgian suppliers publish the injection leg as a formula, on the same card as
|
||||
the consumption one. The reason given here until TARIFF-07 — *"no supported
|
||||
supplier publishes a dynamic injection tariff"* — was **false**, and what they
|
||||
publish has four properties one number cannot hold:
|
||||
|
||||
```
|
||||
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c/kWh
|
||||
"Het injectietarief is dynamisch en kan per uur wijzigen"
|
||||
- identical on the January and the September 2026 cards
|
||||
ENGIE Dynamic (B x eSpot_15) - A
|
||||
Aspiravi (non-dynamic) 0,07 x Belpex - 2
|
||||
```
|
||||
|
||||
1. **Its own factor.** Eneco uses `0,102` on consumption and `0,100` on
|
||||
injection; Aspiravi `0,07` against about `0,10` on its consumption leg. It is
|
||||
**not** the consumption `B` with a sign flip.
|
||||
2. **A negative offset** — `− 1,188`, `− 2`. Note that
|
||||
`price_markup_eur_kwh: float(0,1)` cannot express a negative markup at all,
|
||||
so the offset has no home on the consumption side either.
|
||||
3. **VAT-exempt.** *"Injectietarieven zijn vrijgesteld van btw"*, while
|
||||
consumption is at 6%. `vat_pct` is **per leg**, so that six-point asymmetry
|
||||
is expressible; a single scalar carries no VAT field at all.
|
||||
4. **It goes negative, with no floor.** Eneco's injection price is below zero
|
||||
for any BELPEX-H under **11,88 EUR/MWh** — and a PV household exports
|
||||
precisely in the hours most likely to be negative. **You can be charged to
|
||||
export.**
|
||||
|
||||
#### `price_export_formula`, field by field
|
||||
|
||||
Same grammar as `price_formula` — literally the same parser, with its option
|
||||
name as an argument, so there is no second unit table and no second card rule to
|
||||
diverge from the first. Eneco's published injection line, typed out:
|
||||
|
||||
```json
|
||||
{"index":"epex_be","resolution":"hourly","factor":0.1,"offset":-1.188,
|
||||
"units":"c_eur_per_kwh","vat_pct":0}
|
||||
```
|
||||
|
||||
⚠️ **`vat_pct` is 0 here and 6 on the consumption leg.** That is the one field
|
||||
people copy across from the other formula and get wrong.
|
||||
|
||||
⚠️ **The `card` block is optional on this leg and required on the other**, and
|
||||
that is a statement about the cards rather than a relaxed rule. `price_formula`
|
||||
can demand one because a Belgian card prints the consumption formula, the
|
||||
reference index it assumed *and* the resulting price — so the add-on can
|
||||
recompute that price and refuse to start unless your numbers reproduce it.
|
||||
**Eneco's injection formula is printed with no price beside it.** There is
|
||||
nothing to grade against on that card. Requiring a block would make the market's
|
||||
own published line inexpressible; inventing the estimate would grade your
|
||||
coefficients against arithmetic done with your coefficients, which is not a
|
||||
check. So:
|
||||
|
||||
* if **your** card prints an injection estimate, fill the block in and it is
|
||||
graded at the same tolerance as the consumption leg — the add-on refuses to
|
||||
start if your numbers do not reproduce it;
|
||||
* if it does not, the add-on **says so at startup** — *"its coefficients are
|
||||
UNGRADED"* — and names what it checked instead.
|
||||
|
||||
What it checks instead, always, on both paths:
|
||||
|
||||
* **pass-through**: `factor`, converted to a share of the wholesale price, must
|
||||
be between 0.10× and 10×. Eneco's injection leg is `1.00×` and Aspiravi's
|
||||
`0.70×`; a coefficient typed a hundred times out reads `0.01×` or `100×`.
|
||||
* **offset magnitude**: `|offset|` must be at most 0.20 EUR/kWh. The published
|
||||
ones are `−0.01188` and `−0.02`.
|
||||
|
||||
Both bounds are on the **coefficients**, not on the price, which is why they can
|
||||
bound a leg whose price legitimately goes below zero. `price_formula`'s
|
||||
plausibility band is deliberately **not** reused here: its floor is `+0.02
|
||||
EUR/kWh` of energy, and applying that to injection would refuse the real market
|
||||
in exactly the hours where knowing the number matters most.
|
||||
|
||||
⚠️ **And the gap those bounds leave, with its size.** They bound the *shape* of
|
||||
the coefficients, never their truth. With no printed estimate, a `factor` of
|
||||
`0.070` where your contract says `0.100` — 30% of every export payment — passes
|
||||
both, because `0.70×` is a pass-through somebody really publishes. Only a
|
||||
printed estimate can catch a wrong-contract pair, and on Eneco's card there is
|
||||
not one. That is why the startup line says the leg is ungraded instead of
|
||||
implying it was checked.
|
||||
|
||||
### The old Home Assistant price sensors
|
||||
|
||||
`price_now_entity` and
|
||||
`price_avg_entity` still work and are unchanged: the add-on compares them and
|
||||
uses the result as a cheap-window hint for the maintenance charge. They are
|
||||
**not compatible with `price_provider`, and not with `static_profile` either**
|
||||
— configure two of them and the add-on refuses to start, naming the conflict,
|
||||
because the add-on reads exactly one and the loser is silent. Use one.
|
||||
|
||||
### The three buckets — which price components can move a decision (TARIFF-17)
|
||||
|
||||
**Read this before adding a price component of any kind.** The optimiser reads
|
||||
exactly two quantities off a price series, and nothing else:
|
||||
|
||||
- the **spread**, `max − min` over the *firm* points, weighed against
|
||||
`cycle_cost_threshold_eur`; and
|
||||
- **which hour is cheapest**, `min(firm, key=…)` — the hour a battery would
|
||||
buy in.
|
||||
|
||||
Every component of a Belgian bill reaches those two through one of three
|
||||
buckets, and which bucket it is in is a property of its *shape*, not of its
|
||||
size:
|
||||
|
||||
| bucket | what it looks like | reaches the decision? |
|
||||
|---|---|---|
|
||||
| **LEVEL** | additive, **identical every hour** | **No — provably.** An additive constant cancels exactly in `max − min`, and adding it to every point leaves the argmin where it was |
|
||||
| **SPREAD** | **multiplicative** on the spot index | **Yes.** It scales the spread against a fixed `cycle_cost_threshold_eur` |
|
||||
| **ORDER** | **varies** by hour, block or day-type | **Yes, and worst.** It changes *which hour is cheapest* |
|
||||
|
||||
Measured on the real arithmetic (`test_price.py` section 16b, printed on every
|
||||
run): with a flat taxes figure of 0.00, 0.0739, 0.11736 (the shipped default),
|
||||
0.1198, 0.1327 or 1.00 EUR/kWh, the same 24-hour feed is cheapest at **13:00 in
|
||||
all six cases** and its spread is **0.1500 EUR/kWh in all six cases**. That is
|
||||
the LEVEL bucket doing nothing, and it is not a rounding artefact — it is
|
||||
arithmetic.
|
||||
|
||||
Three consequences worth having in mind:
|
||||
|
||||
1. **`price_taxes_eur_kwh` is not a wrong number and never was.** It sits in
|
||||
the LEVEL bucket and *cannot* distort either quantity. What it also cannot
|
||||
do is express a component that varies by hour — which is why
|
||||
`price_taxes_profile` exists. The defect TARIFF-17 closed was a **missing
|
||||
capability**, not a bad constant.
|
||||
2. **`custom_formula`'s `factor` is a SPREAD component, and its `offset` is
|
||||
LEVEL.** The contract price is `(factor × index + offset) × (1 + VAT)`, so
|
||||
the spread over a feed is `factor × (1 + VAT) × (max index − min index)` —
|
||||
the offset drops out entirely, and the spread is *proportional* to `factor`.
|
||||
Moving `factor` from 0.100 to 0.10812 therefore moves the spread by exactly
|
||||
**+8.1 %** on any feed whatever. A coefficient typed one digit wrong is not
|
||||
a small pricing error; it is a different trading threshold. The VAT rate is
|
||||
multiplicative on the whole expression, so it is SPREAD too.
|
||||
3. **A check that grades the spread is grading the wrong bucket.** An
|
||||
implementation that reads an hour-varying table *once* and adds the same
|
||||
number to every point produces a series of the right magnitude and the wrong
|
||||
shape — the LEVEL bucket wearing a period table. The spread would look
|
||||
plausible; the buy hour would be wrong. So the regression check for an
|
||||
ORDER-bucket component asserts **which hour is cheapest**, not how big the
|
||||
spread is (`test_price.py` 16c and 16d).
|
||||
|
||||
⚠️ **Where the buckets are today.** `price_markup_eur_kwh`,
|
||||
`price_taxes_eur_kwh` and `custom_formula`'s `offset` are LEVEL.
|
||||
`custom_formula`'s `factor` and its VAT rate are SPREAD. `price_taxes_profile`
|
||||
is the only ORDER component the add-on has. Anything added next belongs in one
|
||||
of the three, and saying which is part of adding it.
|
||||
|
||||
### What `price_taxes_eur_kwh` is made of, and why yours is different (TARIFF-18)
|
||||
|
||||
**It ships at `0.11736` EUR/kWh.** That is the all-in non-energy part of a
|
||||
Flemish residential bill — everything on your invoice that is *not* the energy
|
||||
itself — read off the Eneco residential card for Flanders, **September 2026**,
|
||||
**including 6 % VAT**:
|
||||
|
||||
| component | c€/kWh |
|
||||
|---|---|
|
||||
| Fluvius distribution (**Antwerpen**) | 5,35 |
|
||||
| Bijzondere accijns, *"Alle verbruik"* — no volume bands | 4,876 |
|
||||
| Bijdrage groene stroom en WKK Vlaanderen | 1,51 |
|
||||
| Energiebijdrage | **0** |
|
||||
| **total** | **11,736 c€/kWh = 0.11736 €/kWh** |
|
||||
|
||||
The rows are in the code, in `app/price.py`'s
|
||||
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, and `test_price.py` section 1b re-adds them
|
||||
and refuses a default that is not their sum. It replaces a shipped `0.06`,
|
||||
which was roughly **half** the true figure and had no recorded provenance at
|
||||
all.
|
||||
|
||||
⚠️ **`energiebijdrage` is zero and that is not a mistake.** *"Vanaf 1 augustus
|
||||
2026 bedraagt de energiebijdrage 0 EUR/MWh"* — it was merged into the
|
||||
bijzondere accijns. If you are re-deriving this total from a guide written
|
||||
before that date you will find a separate energiebijdrage line; adding it would
|
||||
count it twice. It is carried as a **zero row** rather than left out for
|
||||
exactly that reason.
|
||||
|
||||
⚠️ **THIS IS A PER-DSO FIGURE AND YOURS IS PROBABLY NOT 0.11736.** Only the
|
||||
distribution row moves. Across the eight Fluvius DSOs it runs **5,28 c€/kWh**
|
||||
(Midden-Vlaanderen) to **6,70** (West), so the Flemish all-in total runs about
|
||||
**0.117 to 0.131 €/kWh** — a 1,42 c€/kWh spread, which on 4 000 kWh a year is
|
||||
about €57. The other two rows are Flanders-wide and do not move. **Look up your
|
||||
own distribution row, add the same accijns and groenestroombijdrage, and type
|
||||
the total.**
|
||||
|
||||
⚠️ **A WALLOON OR BRUSSELS SITE SHOULD NOT USE THIS SCALAR AT ALL.** Their
|
||||
distribution tariff has a **day and a night band** — `Enkelvoudig | Dag |
|
||||
Nacht | Uitsl. nacht` on the same card, where Fluvius prints a single rate —
|
||||
and a flat figure flattens exactly the component that is anti-correlated with
|
||||
what the battery wants to do. Use **`price_taxes_profile`** (the next section);
|
||||
it is the only ORDER-bucket component the add-on has.
|
||||
|
||||
⚠️ **THIS IS A DISPLAY AND GUARD FIX. IT DOES NOT IMPROVE ARBITRAGE, AND IT
|
||||
CANNOT.** `price_taxes_eur_kwh` is a **LEVEL** component — added identically to
|
||||
every hour — so it **cancels exactly** in `max − min` and leaves the argmin
|
||||
where it was. It does not change which hour is cheapest, it does not change the
|
||||
spread, and it does not make any trade happen that did not happen before. See
|
||||
*The three buckets* above, where six flat scalars spanning and exceeding the
|
||||
whole per-DSO range give the identical cheapest hour and the identical spread
|
||||
to the last digit. What raising it **does** change is exactly two things:
|
||||
|
||||
* **every figure an operator reads** — the all-in prices on the status page,
|
||||
`custom_formula`'s startup preview and the recorded price history. At half
|
||||
the true non-energy component they made the add-on look considerably better
|
||||
than it is.
|
||||
* **§17's negative-price guard** (`arbitrage.py`), which is the one rung that
|
||||
tests an **absolute** all-in price rather than a difference. The guard fires
|
||||
when `spot < −(markup + taxes)`, so its boundary moved from **−0.09000** to
|
||||
**−0.14736 €/kWh** of spot. A spot hour at −0.11868 €/kWh used to read
|
||||
**−0.02868 €/kWh** all-in and the discharge was refused as paid-for disposal;
|
||||
at the real tax level it reads **+0.02868** and the discharge is allowed,
|
||||
because the operator really does pay those levies and that hour really was
|
||||
profitable (`test_arbitrage.py` section 2c drives both, and pins the boundary
|
||||
from below as well).
|
||||
|
||||
⚠️ **Which is why the default sits near the LOW end of the per-DSO band rather
|
||||
than in the middle of it.** The two error directions are not symmetric. Set too
|
||||
**high**, the figure carries a genuinely negative hour up above zero and the
|
||||
add-on exports into paid-for disposal — §17's own regression case. Set too
|
||||
**low**, it only declines a discharge that would have paid. Where one default
|
||||
has to stand in for eight DSOs, it belongs at the bottom.
|
||||
|
||||
**If you set this yourself, nothing above affects you.** A site with its own
|
||||
`price_taxes_eur_kwh` keeps it; the default only answers where the option is
|
||||
absent from the stored options blob.
|
||||
|
||||
### Levies and VAT by time of day (`price_taxes_profile`)
|
||||
|
||||
**Leave this empty unless your network tariff really does have a day and a
|
||||
night rate**, which in Flanders it does not. Fluvius distribution is a single
|
||||
rate: its row on the tariff card reads `Normaal | Uitsl. nacht`, and
|
||||
*exclusive night* is a **separate physical circuit** (a night-storage heater on
|
||||
its own meter), not the night register of a dual-rate meter. For a Flemish site
|
||||
the flat `price_taxes_eur_kwh` is correct, and this option changes nothing.
|
||||
|
||||
**Wallonia and Brussels are different, and the same card proves it.** The
|
||||
Walloon DSOs and Sibelga get four columns — `Enkelvoudig | Dag | Nacht |
|
||||
Uitsl. nacht` — and the day/night gap is real money (c€/kWh):
|
||||
|
||||
| DSO | enkelvoudig | dag | nacht | gap |
|
||||
|---|---|---|---|---|
|
||||
| ORES | 11,98 | 13,27 | 7,39 | **5,88** |
|
||||
| AIESH | 13,64 | 15,17 | 8,21 | **6,96** |
|
||||
| Tecteo RESA | 11,06 | 12,20 | 7,01 | 5,19 |
|
||||
| Sibelga (Brussels) | 9,39 | 9,39 | 7,10 | 2,29 |
|
||||
| ORES IMPACT (2026) | — | 16,578 | 5,090 | **11,49** |
|
||||
|
||||
⚠️ **The bands are anti-correlated with what the battery wants to do.** The
|
||||
cheap network hours are the cheap spot hours. So flattening them does not
|
||||
merely shift the curve — it **systematically understates the true spread**, and
|
||||
the add-on declines cycles that are genuinely profitable. On ORES's bands over
|
||||
one ordinary winter weekday the measured effect is: cheapest hour **13:00 →
|
||||
03:00**, spread **0.1500 → 0.1788 EUR/kWh**.
|
||||
|
||||
It is written in **exactly the same grammar as `static_profile`** — the same
|
||||
periods, the same day selectors, the same rule that every day type is covered
|
||||
once with no gap and no overlap, the same DST handling. It is literally the
|
||||
same parser; there is no second tiling rule, because a second one would be a
|
||||
second set of 23- and 25-hour-day bugs.
|
||||
|
||||
```json
|
||||
{"periods": [
|
||||
{"name":"dag", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.1327},
|
||||
{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.0739},
|
||||
{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.0739}]}
|
||||
```
|
||||
|
||||
- ⚠️ **`eur_per_kwh` here is the LEVIES ALONE, not an all-in price.** It is the
|
||||
non-energy part of the bill for that band, and the add-on adds it to whatever
|
||||
your price source publishes — exactly as it adds the flat figure. This is the
|
||||
one place the two options differ in meaning while sharing a grammar, and the
|
||||
refusal you get for leaving the number out says which one is wanted.
|
||||
- ⚠️ **The card prints cents. This option takes EUR.** ORES prints `13,27`;
|
||||
type `0.1327`. A whole card typed in cents is refused at startup, naming both
|
||||
bands and the factor — including the cards (Sibelga's 9,39 / 7,10) whose
|
||||
figures are small enough to slip past the ±10 EUR/kWh bound the parser holds
|
||||
every price to.
|
||||
- ⚠️ **Set this *or* `price_taxes_eur_kwh`, not both.** The table wins when it
|
||||
is set, so a scalar left at a non-default value beside it would be a number
|
||||
you typed that decides nothing. That is refused at startup rather than
|
||||
silently resolved; leave `price_taxes_eur_kwh` at its shipped `0.11736` when
|
||||
you fill this in. ⚠️ **A scalar still sitting on the superseded `0.06` is
|
||||
also accepted**, and deliberately so — TARIFF-18 moved that default, and your
|
||||
stored options blob carries the old number whether you typed it or not, so
|
||||
refusing it would have stopped working Walloon and Brussels sites on an
|
||||
upgrade over a figure the table overrides anyway.
|
||||
- A **malformed table is refused at startup**, not at the next fetch. It is
|
||||
read on the ~30 s planning path, and a table that first fails at 03:00 is a
|
||||
site that stops pricing at 03:00 — which §11.4 turns into a quiet fallback
|
||||
that looks exactly like a healthy fixed-tariff day.
|
||||
- It applies to the components the framework **adds**: `generic_dayahead`
|
||||
(which declares `{ENERGY}`) and `custom_formula` (which declares
|
||||
`{ENERGY, MARKUP}`) both get it. `manual_csv` publishes an all-in figure and
|
||||
is untouched — the table is routed through the same `capabilities().adds`
|
||||
rule as the scalar, not bolted on.
|
||||
- It does **not** apply to `static_profile`, whose prices are already all-in,
|
||||
nor to `export_price_eur_kwh` or `price_export_formula`. Those are the
|
||||
injection leg, and nothing is added to what you are *paid* for export:
|
||||
`price_taxes_eur_kwh` is a consumption component.
|
||||
|
||||
### Static day/night and fully-fixed tariffs (`static_profile`)
|
||||
|
||||
Most Belgian households are **not** on a dynamic contract, and an EMS that only
|
||||
@@ -1951,6 +2311,33 @@ 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.
|
||||
|
||||
**The *Arbitrage* status row says which of three things is true** — trading is
|
||||
off, trading is on and this cycle reached no decision, or a decision was
|
||||
reached — and it stamps every decision with its age. Until TARIFF-11 it said
|
||||
one of two, because the first two rendered identically. They have three
|
||||
different remedies:
|
||||
|
||||
| the row opens with | what is true | what to do |
|
||||
|---|---|---|
|
||||
| **off** — *"arbitrage_enabled is false, so nothing trades"* | Trading is not armed. Nothing will happen, and nothing is wrong | Arm it, if you meant to |
|
||||
| **ON (arbitrage_enabled), but NO DECISION has been reached yet** | Trading **is** armed, and no cycle has run as far as the arbitrage step. The row then names the control loop's own reason, usually *inputs missing* | Fix the input the loop names |
|
||||
| **decided N s ago:** … | The strategy answered this cycle, and `N` is how old that answer is | Read the sentence that follows — it is the strategy's own |
|
||||
| **no decision this cycle (reason); the last one was taken N s ago:** … | Trading is armed, a decision stands from an earlier cycle, and **this** cycle stopped before reaching the step | As row 2 — the standing decision is history, not a verdict on now |
|
||||
|
||||
A fifth rendering — *"…no decision this cycle …, and none stands from an
|
||||
earlier one"* — covers the case where trading was armed after a cycle had
|
||||
already run without it.
|
||||
|
||||
⚠️ **The second row used to render exactly like the first one**, naming
|
||||
`arbitrage_enabled` on a site where `arbitrage_enabled` is `true`. A cycle with
|
||||
a missing input returns after roughly `stale_input_s` (15 s by default) without
|
||||
reaching the arbitrage step at all, so on an armed site the row reported *off*
|
||||
at startup and, once one decision had been taken, went on showing that decision
|
||||
for up to 15 s after the site stopped deciding anything. The age on the third
|
||||
row is printed **always**, including when it is zero: a row that reports
|
||||
staleness only once it is stale cannot be told from one that never reports
|
||||
it.
|
||||
|
||||
### Site
|
||||
|
||||
| option | meaning |
|
||||
|
||||
@@ -831,13 +831,16 @@ document (`' 216.080 -> 0.30123'`), and M13 is red.
|
||||
|
||||
**M10 is `+0 B` and it is a real defect, not a synthetic one.** It came out of
|
||||
writing the mutation rather than out of review: `implausible_all_in()`'s floor
|
||||
was applied to the all-in figure, and `price_taxes_eur_kwh` ships at 0.06 —
|
||||
was applied to the all-in figure, and `price_taxes_eur_kwh` shipped at 0.06 —
|
||||
**three times the 0.02 floor** — so a card read a hundred times too small
|
||||
(0.0016 EUR/kWh of energy) is carried over the floor by the flat tax addend
|
||||
alone and passes. The hundredfold error is exactly what that band exists for.
|
||||
The floor is applied to the **energy** figure now and the ceiling to the all-in
|
||||
one, both directions are graded, and the trap is stated as a check in its own
|
||||
right so the asymmetry cannot be "tidied up" later.
|
||||
right so the asymmetry cannot be "tidied up" later. *(Annotated 2026-09-13:
|
||||
TARIFF-18 raised that default to 0.11736, **nearly six times** the floor. The
|
||||
addend that does the carrying got BIGGER, so the trap is wider than when M10
|
||||
was measured and the asymmetry is more load-bearing rather than less.)*
|
||||
|
||||
**M15 is the one the framework CANNOT catch, and the reds say which half does.**
|
||||
Declaring `ALL_IN` makes `assemble()` add nothing, and section 3's all-in check
|
||||
|
||||
@@ -184,6 +184,17 @@ IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||
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")
|
||||
# SAFETY-45. THE BUY LEG'S SIZE, AND ONLY THE BUY LEG'S - §9.1: "Discharge and
|
||||
# export are never capped by the peak guard; only charging and import are."
|
||||
IDLE_NO_HEADROOM = (
|
||||
"the capacity-tariff guard has measured this billed quarter and it can "
|
||||
"afford no charging at all, so a trade of any size would push it over the "
|
||||
"ceiling - and a 0 W arbitrage claim is a park order, not a trade")
|
||||
IDLE_HEADROOM_UNMEASURED = (
|
||||
"the capacity-tariff guard did not size this quarter's charging headroom "
|
||||
"this cycle, and an UNMEASURED headroom is not an unlimited one - charging "
|
||||
"creates the billed quantity (§9.1) and nothing is measuring the quarter "
|
||||
"this one would land in")
|
||||
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 "
|
||||
@@ -303,7 +314,7 @@ def _point_at(plan, when: datetime):
|
||||
return None
|
||||
|
||||
|
||||
def decide(plan, now: datetime, *, peak_guard_active, soc,
|
||||
def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, soc,
|
||||
min_spread_eur_kwh, uncertainty, charge_w, discharge_w,
|
||||
soc_floor_pct, soc_ceiling_pct) -> ArbitrageDecision:
|
||||
"""Should the battery trade this cycle, and how hard.
|
||||
@@ -314,6 +325,18 @@ def decide(plan, now: datetime, *, peak_guard_active, soc,
|
||||
None refuses, because the alternative is a strategy that creates the billed
|
||||
quantity while the thing that would have limited it is switched off.
|
||||
|
||||
⚠️ `charge_headroom_w` IS SAFETY-45's SECOND, CONTINUOUS ANSWER FROM THE
|
||||
SAME GUARD, AND IT IS NOT A DUPLICATE OF THE FIRST. `peak_guard_active` is a
|
||||
SWITCH - it says whether the guard is holding - and a switch keyed on a
|
||||
continuous quantity is what made the loop oscillate: the guard engaged at a
|
||||
5 W floor, this module refused the whole trade, the loop took the actuator,
|
||||
the meter fell, the guard released and the trade re-armed AT FULL POWER
|
||||
130 s later. 28 times an hour, measured. `peakguard.charge_headroom_w()` is
|
||||
the watts the billed quarter can still afford; sizing the buy leg to it means
|
||||
the trade is CLAMPED rather than switched, which is the same shape
|
||||
`control.maintenance_charge_floor()` has always given a maintenance charge.
|
||||
⚠️ None IS NOT UNLIMITED here either - see IDLE_HEADROOM_UNMEASURED.
|
||||
|
||||
⚠️ EVERY OTHER ARGUMENT IS KEYWORD-ONLY AND HAS NO DEFAULT, for
|
||||
`TariffPlan.arbitrage()`'s reason. A defaulted `soc_floor_pct` is a reserve
|
||||
floor this module asserted rather than one the operator configured, and the
|
||||
@@ -330,10 +353,12 @@ def decide(plan, now: datetime, *, peak_guard_active, soc,
|
||||
return verdict.match(
|
||||
refused=lambda v: _idle(v.reason),
|
||||
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
|
||||
soc_floor_pct, soc_ceiling_pct))
|
||||
soc_floor_pct, soc_ceiling_pct,
|
||||
charge_headroom_w))
|
||||
|
||||
|
||||
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
headroom_w):
|
||||
"""The permitted case: which hour is this, and may the pack do it."""
|
||||
here = _point_at(plan, now)
|
||||
if here is None:
|
||||
@@ -403,13 +428,44 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
f"nowhere to put the cheap energy", **common)
|
||||
if not abs(float(charge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
# SAFETY-45. THE BILLED QUARTER IS ASKED LAST, AND THE PLACEMENT IS
|
||||
# DELIBERATE: every refusal that existed before this card keeps the
|
||||
# precedence it had, so no sentence an operator already reads changes when
|
||||
# it fires, and the two added here are placed where they cannot displace
|
||||
# one. The pack's reasons come first because they are properties of the
|
||||
# hardware in the cupboard; this one is a property of the fifteen minutes
|
||||
# the trade would land in.
|
||||
if headroom_w is None:
|
||||
# ⚠️ NOT "the guard is clear". This is `guard_state()`'s None argument
|
||||
# one layer down: a headroom nothing measured is not an unlimited one,
|
||||
# and charging at max_w creates the billed quantity (§9.1).
|
||||
return _idle(IDLE_HEADROOM_UNMEASURED, **common)
|
||||
asked_w = abs(float(charge_w))
|
||||
size_w = min(asked_w, max(0.0, float(headroom_w)))
|
||||
if not size_w > 0.0:
|
||||
# ⚠️ NOT IDLE_NO_SIZE, WHICH IS THE LINE ABOVE AND BLAMES THE PACKS.
|
||||
# `asked_w > 0` is already established here, so the pack may be empty
|
||||
# and willing and it is the billed quarter that has run out. A refusal
|
||||
# naming the wrong subsystem sends an operator to the wrong page
|
||||
# (§10.2), and these two are a whole quarter-hour apart in what they
|
||||
# ask a human to do about them.
|
||||
return _idle(IDLE_NO_HEADROOM, **common)
|
||||
# ⚠️ THE SENTENCE CARRIES BOTH NUMBERS IN THEIR OWN SLOTS, not one figure
|
||||
# twice. MUTATION.md habit 14: two values in one sentence can be transposed
|
||||
# by a ZERO-BYTE edit that no byte delta and no shape pin can see, so the
|
||||
# trade's own size and the guard's headroom are each named by the literal
|
||||
# text around them.
|
||||
capped = size_w < asked_w
|
||||
return ArbitrageDecision(
|
||||
action=CHARGE, power_w=-abs(float(charge_w)),
|
||||
action=CHARGE, power_w=-size_w,
|
||||
reason=(f"{CHARGE}: this is the cheapest firm hour at "
|
||||
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
|
||||
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
|
||||
f"had to cover; charging {abs(float(charge_w)):.0f} W from "
|
||||
f"{soc:g} %"),
|
||||
f"had to cover; charging {size_w:.0f} W from {soc:g} %"
|
||||
+ (f" - SIZED BY THE CAPACITY-TARIFF GUARD: the trade asked for "
|
||||
f"{asked_w:.0f} W and this billed quarter can afford "
|
||||
f"{float(headroom_w):.0f} W (§9.1, §10.1 priority 2)"
|
||||
if capped else "")),
|
||||
spread_eur_kwh=v.spread_eur_kwh,
|
||||
required_eur_kwh=v.required_spread_eur_kwh,
|
||||
price_now_eur_kwh=price)
|
||||
|
||||
@@ -125,6 +125,49 @@ units and the market aggregator prints both conventions in one column, so the
|
||||
add-on has to be told which one the card is in - and the unit conversion is the
|
||||
add-on's job precisely because it is one of the two steps that silently costs a
|
||||
factor of 10, 100 or 1000. The other is VAT, which is also the add-on's.
|
||||
|
||||
TARIFF-16 - THE SECOND LEG, AND WHY IT IS THIS PARSER AND NOT A SECOND ONE
|
||||
==========================================================================
|
||||
A Belgian dynamic contract has TWO published lines, not one, and the second is
|
||||
what the site is PAID for export:
|
||||
|
||||
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
|
||||
ENGIE Dynamic (B x eSpot_15) - A
|
||||
Aspiravi (non-dynamic) 0,07 x Belpex - 2
|
||||
|
||||
Same `B x index + A` shape - which is why `price_export_formula` is this
|
||||
module's parser with ONE argument moved (`option`), exactly as TARIFF-17 made
|
||||
`parse_static_profile()`'s option name a parameter rather than forking the
|
||||
period grammar. What differs between the two legs is FOUR numbers and a
|
||||
handful of sentences, and all of them are rows of `_LEGS`:
|
||||
|
||||
* its OWN factor - Eneco 0,102 on consumption against 0,100 on injection,
|
||||
Aspiravi 0,07 against ~0,10. NOT the consumption `B` with a sign flip;
|
||||
* a NEGATIVE offset, which `factor`/`offset` already allow and which
|
||||
`price_markup_eur_kwh: float(0,1)` cannot express at all;
|
||||
* `vat_pct: 0` - "Injectietarieven zijn vrijgesteld van btw" - against 6 on
|
||||
consumption, which is a SIX PERCENT ASYMMETRY between the buy leg and the
|
||||
sell leg and is representable only because `vat_pct` is PER SPEC;
|
||||
* a price that goes BELOW ZERO with no floor, in the hours a PV household
|
||||
exports. `implausible_injection()` is the band that accepts that and still
|
||||
refuses an implausible one; `implausible_all_in()` is NOT it, and its
|
||||
docstring says why.
|
||||
|
||||
⚠️ AND THE CARD CANNOT ALWAYS GRADE IT. Eneco's injection formula is printed
|
||||
with NO price beside it, so on that card there is nothing to reproduce. The
|
||||
`card` block is therefore OPTIONAL on this leg and REQUIRED on the other, the
|
||||
self-check runs wherever there is a printed estimate, and validate.py says at
|
||||
startup when a leg is ungraded. Inventing an estimate to grade against would
|
||||
be grading arithmetic against itself, which is the defect MUTATION.md habit 4
|
||||
is about; TARIFF-15 demonstrated two tariffs end to end rather than three for
|
||||
the same reason.
|
||||
|
||||
⚠️ NOTHING TRADES ON THIS TODAY, AND SAYING SO IS PART OF SHIPPING IT.
|
||||
`arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment
|
||||
says so, citing §9.3's near-zero Belgian injection price - and both hours it
|
||||
compares are all-in CONSUMPTION prices. So this is not a wrong-trade defect
|
||||
and must not be described as one. What it changes is what an operator is told,
|
||||
and what a future export-aware strategy would rest on.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -183,6 +226,150 @@ _CARD_KEYS = ("month", "index_eur_per_mwh", "expect_incl_vat")
|
||||
_TOP_KEYS = ("index", "resolution", "factor", "offset", "units", "vat_pct",
|
||||
"card")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TARIFF-16 - the SECOND leg, and the one parameter that carries it
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
EXPORT_FORMULA_OPTION = "price_export_formula"
|
||||
|
||||
# ⚠️ THE PASS-THROUGH FRACTION, WHICH IS WHY THE NEXT TWO BOUNDS CAN BE
|
||||
# INDEX-FREE. One EUR/MWh IS 0.001 EUR/kWh, so `factor x UNITS[units] / 0.001`
|
||||
# is the dimensionless share of the wholesale price the contract passes
|
||||
# through - 1.00 for Eneco's injection leg (0,100 c EUR/kWh per EUR/MWh), 0.70
|
||||
# for Aspiravi's (0,07). A coefficient typed a hundred times too small or too
|
||||
# large moves this by a hundred, whatever the index and whatever the sign of
|
||||
# the price.
|
||||
EUR_KWH_PER_EUR_MWH = 0.001
|
||||
|
||||
# The two plausibility bounds for an INJECTION leg. ⚠️ NEITHER LOOKS AT THE
|
||||
# SIGN OF THE PRICE, AND THAT IS THE WHOLE POINT - see
|
||||
# `implausible_injection()`.
|
||||
PASSTHROUGH_LO = 0.10 # 10% of the wholesale price
|
||||
PASSTHROUGH_HI = 10.0 # 1000% of it
|
||||
INJECTION_OFFSET_MAX_EUR_KWH = 0.20
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Leg:
|
||||
"""What ONE option's coefficients price, as sentences its refusals read.
|
||||
|
||||
⚠️ TARIFF-16 MADE `option` A PARAMETER RATHER THAN FORKING THE PARSER,
|
||||
exactly as TARIFF-17 made `option`/`means` parameters of
|
||||
`parse_static_profile()` rather than forking the period grammar. A second
|
||||
copy of this parser would be a second `_finite()`, a second unknown-key
|
||||
rule, a second unit table and a second `to_resolution()` - and the two
|
||||
would diverge first on the 23- and 25-hour days, where nobody looks.
|
||||
Everything that differs between the consumption leg and the injection leg
|
||||
is a STRING in this table; the arithmetic, the enumerations, the card
|
||||
comparison and the resolution rule are ONE implementation with one set of
|
||||
checks over it.
|
||||
"""
|
||||
|
||||
option: str
|
||||
what: str # "consumption" / "injection", for a sentence
|
||||
example: str # a VALID blob for this leg, in the JSON-error hint
|
||||
when: str # when this option is read at all
|
||||
flat: str # where a FLAT price of this kind already lives
|
||||
zero_factor: str # what a factor of 0 actually costs on this leg
|
||||
neg_factor: str # why a negative FACTOR is wrong on this leg
|
||||
card_required: bool
|
||||
card_note: str # why it is required, or what is lost without it
|
||||
card_causes: str # the leg-specific tail of `card_refusal()`
|
||||
|
||||
|
||||
_LEGS = {
|
||||
"price_formula": _Leg(
|
||||
option="price_formula",
|
||||
what="consumption",
|
||||
example=('{"index":"epex_be","resolution":"hourly","factor":0.102,'
|
||||
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
|
||||
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
||||
'"expect_incl_vat":15.55}}'),
|
||||
when="Leave it empty unless price_provider is 'custom_formula'.",
|
||||
zero_factor=("every spread is exactly 0, and arbitrage can never "
|
||||
"clear any threshold - a strategy that silently never "
|
||||
"fires rather than one that is off"),
|
||||
flat=("that is `static_profile` with a single period covering "
|
||||
"00:00-24:00 on all days and price_provider 'off'; the add-on "
|
||||
"then says so at startup instead of leaving you to find out "
|
||||
"from a year of unchanged bills"),
|
||||
neg_factor=("No consumption tariff is indexed negatively; if your "
|
||||
"card shows a minus sign it is on the OFFSET (`A`), "
|
||||
"which is allowed here and common on injection"),
|
||||
card_required=True,
|
||||
card_note=("It is the only thing that can tell a correct pair of "
|
||||
"coefficients from a plausible-looking wrong one"),
|
||||
card_causes=(
|
||||
"or - the expensive one - network charges, levies or excise "
|
||||
"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian "
|
||||
"card's Tariefformule line is the ENERGY component alone (Eneco "
|
||||
"tabulates network tariffs and levies separately; ENGIE says in "
|
||||
"so many words that the formula excludes network, distribution, "
|
||||
"transport, levies and excise), the add-on adds "
|
||||
"price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of "
|
||||
"network charge into `offset` bills you for it twice. That is "
|
||||
"the error this check exists to catch, and it is why it cannot "
|
||||
"be switched off."),
|
||||
),
|
||||
EXPORT_FORMULA_OPTION: _Leg(
|
||||
option=EXPORT_FORMULA_OPTION,
|
||||
what="injection",
|
||||
# ⚠️ NO `card` BLOCK IN THE EXAMPLE, because Eneco's injection line has
|
||||
# none to copy - the formula is printed with no price beside it. An
|
||||
# example carrying one would invite an operator to compute the figure
|
||||
# themselves, which is the one thing this leg must not encourage.
|
||||
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
|
||||
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
|
||||
when=("Leave it empty unless your supplier pays you an injection "
|
||||
"price that moves with the market; a flat one is "
|
||||
"export_price_eur_kwh. ⚠️ This option is read whatever "
|
||||
"price_provider is set to - it is the leg you are PAID on, not "
|
||||
"the one you buy on."),
|
||||
zero_factor=("every hour pays the same, which is a FLAT injection "
|
||||
"price written as a formula. ⚠️ Note this is NOT the "
|
||||
"arbitrage argument the consumption leg gives: no "
|
||||
"strategy in this add-on reads an injection price at "
|
||||
"all, so a flat one here fires nothing and declines "
|
||||
"nothing - it is simply the wrong option"),
|
||||
flat=("that is `export_price_eur_kwh`, the flat injection scalar, "
|
||||
"which is what every site used before this option existed and "
|
||||
"which still answers when this one is empty"),
|
||||
neg_factor=("No Belgian injection tariff is indexed negatively - you "
|
||||
"are paid MORE when the market is dearer, not less. The "
|
||||
"minus sign on an injection card is on the OFFSET (`A`): "
|
||||
"Eneco prints `0,1 x BELPEX-H - 1,188` and Aspiravi "
|
||||
"`0,07 x Belpex - 2`, and a negative offset is accepted "
|
||||
"here"),
|
||||
card_required=False,
|
||||
card_note=(
|
||||
"⚠️ IT IS OPTIONAL ON THIS LEG, AND THAT IS A STATED GAP RATHER "
|
||||
"THAN A RELAXED RULE. A Belgian card prints the CONSUMPTION "
|
||||
"formula with the reference index and the resulting price beside "
|
||||
"it, which is what makes `price_formula`'s self-check possible. "
|
||||
"Eneco's injection line has NO printed price beside it, so on "
|
||||
"that card there is nothing to grade against and requiring a "
|
||||
"`card` block would make the market's own published formula "
|
||||
"inexpressible - or, worse, invite an operator to compute the "
|
||||
"estimate themselves, at which point the check grades the "
|
||||
"coefficients against arithmetic done with the same coefficients. "
|
||||
"Supply it if YOUR card prints one; when it is absent the add-on "
|
||||
"says at startup that this leg is UNGRADED and the two "
|
||||
"coefficient bounds below are all there is"),
|
||||
card_causes=(
|
||||
"or VAT added where there is none. ⚠️ INJECTION IS VAT-EXEMPT IN "
|
||||
"BELGIUM - \"Injectietarieven zijn vrijgesteld van btw\" - while "
|
||||
"consumption is at 6%, so `vat_pct` on THIS option is 0 on every "
|
||||
"Belgian card the research found, and a 6 typed here because it "
|
||||
"is 6 on the consumption leg is a ratio of exactly 1.06. Nothing "
|
||||
"is added to this leg by the framework: price_taxes_eur_kwh is a "
|
||||
"CONSUMPTION component and is not applied to what you are paid "
|
||||
"for export, so an offset carrying network charges is a "
|
||||
"different error here than it is on price_formula and this "
|
||||
"check cannot tell you which one you made."),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormulaSpec:
|
||||
@@ -199,14 +386,44 @@ class FormulaSpec:
|
||||
offset: float
|
||||
units: str
|
||||
vat_pct: float
|
||||
card_month: str # "YYYY-MM", as the card is dated
|
||||
card_index_eur_per_mwh: float
|
||||
card_expect_incl_vat: float # in `units`, as the card prints it
|
||||
card_month: str = "" # "YYYY-MM", as the card is dated
|
||||
card_index_eur_per_mwh: float = None
|
||||
card_expect_incl_vat: float = None # in `units`, as the card prints it
|
||||
# TARIFF-16. Which OPTION this spec was read from, so a refusal, a log line
|
||||
# and `card_graded` name the leg the operator actually typed.
|
||||
option: str = "price_formula"
|
||||
|
||||
@property
|
||||
def unit_to_eur_kwh(self) -> float:
|
||||
return UNITS[self.units]
|
||||
|
||||
@property
|
||||
def card_graded(self) -> bool:
|
||||
"""Is there a printed estimate to grade these coefficients against?
|
||||
|
||||
⚠️ FALSE IS A REAL, SHIPPED STATE ON THE INJECTION LEG and is reported
|
||||
rather than defaulted: Eneco's injection formula is printed with no
|
||||
price beside it. Never False on `price_formula`, where `_card()`
|
||||
refuses an absent block.
|
||||
"""
|
||||
return self.card_expect_incl_vat is not None
|
||||
|
||||
@property
|
||||
def passthrough(self) -> float:
|
||||
"""The dimensionless share of the wholesale price this passes through.
|
||||
|
||||
`factor x UNITS[units] / 0.001`. 1.00 for Eneco's injection leg, 0.70
|
||||
for Aspiravi's. ⚠️ INDEX-FREE AND SIGN-FREE, which is what lets
|
||||
`implausible_injection()` bound a leg whose price legitimately goes
|
||||
below zero.
|
||||
"""
|
||||
return self.factor * self.unit_to_eur_kwh / EUR_KWH_PER_EUR_MWH
|
||||
|
||||
@property
|
||||
def offset_eur_kwh(self) -> float:
|
||||
"""The offset in EUR/kWh, whatever units the card printed it in."""
|
||||
return self.offset * self.unit_to_eur_kwh
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The arithmetic. Two functions, and the SECOND one is the contract.
|
||||
@@ -241,7 +458,18 @@ def card_discrepancy(spec: FormulaSpec) -> float:
|
||||
In the card's units, signed: positive means the formula computes MORE than
|
||||
the card prints, which is the direction an `offset` carrying network
|
||||
charges moves it.
|
||||
|
||||
⚠️ RAISES ON A SPEC WITH NO CARD RATHER THAN RETURNING 0.0 (TARIFF-16). An
|
||||
ungraded injection leg has no printed estimate, and a discrepancy of zero
|
||||
is exactly what "the coefficients reproduce the card" looks like - so a
|
||||
silent zero here would report the strongest possible result for the one
|
||||
state in which nothing was compared at all.
|
||||
"""
|
||||
if not spec.card_graded:
|
||||
raise FormulaInvalid(
|
||||
f"{spec.option} has no card block, so there is no printed "
|
||||
f"estimate to measure a discrepancy against. Ask "
|
||||
f"`spec.card_graded` before calling this.")
|
||||
return in_card_units(spec, spec.card_index_eur_per_mwh) \
|
||||
- spec.card_expect_incl_vat
|
||||
|
||||
@@ -250,7 +478,7 @@ def card_discrepancy(spec: FormulaSpec) -> float:
|
||||
# Reading the option
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _finite(value, field: str) -> float:
|
||||
def _finite(value, field: str, option: str = "price_formula") -> float:
|
||||
"""A real, finite number - or a refusal naming the field.
|
||||
|
||||
⚠️ SAME CLASS AS `_validate_raw()`'s NaN RULE, one layer earlier. A NaN
|
||||
@@ -260,26 +488,37 @@ def _finite(value, field: str) -> float:
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.{field} is {value!r}, which is not a number. "
|
||||
f"{option}.{field} is {value!r}, which is not a number. "
|
||||
f"Write the coefficient as a bare JSON number - 0.102, not "
|
||||
f"\"0.102\" and not an expression: this provider takes FIXED "
|
||||
f"coefficients and evaluates nothing.")
|
||||
if not math.isfinite(float(value)):
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.{field} is {value!r}, which is not finite. A NaN "
|
||||
f"{option}.{field} is {value!r}, which is not finite. A NaN "
|
||||
f"or an infinity here makes every price NaN, every price "
|
||||
f"comparison False, and an arbitrage strategy that never fires "
|
||||
f"while reporting that it is armed.")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _card(raw) -> tuple:
|
||||
"""The `card` block: the estimate the coefficients are graded against."""
|
||||
def _card(raw, leg: _Leg = None) -> tuple:
|
||||
"""The `card` block: the estimate the coefficients are graded against.
|
||||
|
||||
⚠️ REQUIRED ON THE CONSUMPTION LEG AND OPTIONAL ON THE INJECTION ONE, and
|
||||
the asymmetry is a fact about the CARDS rather than a relaxed rule. See
|
||||
`_LEGS[EXPORT_FORMULA_OPTION].card_note`: Eneco's injection formula is
|
||||
printed with no price beside it, so on that card there is nothing to grade
|
||||
against. `("", None, None)` is the ungraded answer and
|
||||
`FormulaSpec.card_graded` is how every caller asks.
|
||||
"""
|
||||
if leg is None:
|
||||
leg = _LEGS["price_formula"]
|
||||
if raw is None:
|
||||
if not leg.card_required:
|
||||
return ("", None, None)
|
||||
raise FormulaInvalid(
|
||||
"price_formula has no \"card\" block, and it is REQUIRED. It is "
|
||||
"the only thing that can tell a correct pair of coefficients from "
|
||||
"a plausible-looking wrong one: the add-on recomputes your "
|
||||
f"{leg.option} has no \"card\" block, and it is REQUIRED. "
|
||||
f"{leg.card_note}: the add-on recomputes your "
|
||||
"supplier's own printed example from the numbers you typed and "
|
||||
"refuses to start if it does not come out. Copy the three figures "
|
||||
"off the same tariff card as the coefficients: "
|
||||
@@ -287,24 +526,26 @@ def _card(raw) -> tuple:
|
||||
"\"expect_incl_vat\": 15.55}.")
|
||||
if not isinstance(raw, dict):
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.card is {type(raw).__name__}, not an object with "
|
||||
f"{leg.option}.card is {type(raw).__name__}, not an object with "
|
||||
f"{', '.join(_CARD_KEYS)}.")
|
||||
unknown = sorted(set(raw) - set(_CARD_KEYS))
|
||||
if unknown:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.card has no field(s) {unknown}. The fields it "
|
||||
f"{leg.option}.card has no field(s) {unknown}. The fields it "
|
||||
f"takes are {list(_CARD_KEYS)}; a key that is merely ignored is a "
|
||||
f"number the operator believes took effect.")
|
||||
month = str(raw.get("month", "") or "").strip()
|
||||
if not _month_date(month):
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.card.month is {raw.get('month')!r}; write the "
|
||||
f"{leg.option}.card.month is {raw.get('month')!r}; write the "
|
||||
f"month the card is dated as \"YYYY-MM\", e.g. \"2026-09\". It is "
|
||||
f"not used in any price - it is what lets the add-on tell you "
|
||||
f"your card has gone stale.")
|
||||
return (month,
|
||||
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh"),
|
||||
_finite(raw.get("expect_incl_vat"), "card.expect_incl_vat"))
|
||||
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
|
||||
leg.option),
|
||||
_finite(raw.get("expect_incl_vat"), "card.expect_incl_vat",
|
||||
leg.option))
|
||||
|
||||
|
||||
def _month_date(month: str):
|
||||
@@ -316,9 +557,16 @@ def _month_date(month: str):
|
||||
return None
|
||||
|
||||
|
||||
def parse_price_formula(value):
|
||||
def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
"""`price_formula` -> a `FormulaSpec`, or None when unset.
|
||||
|
||||
⚠️ `option` IS TARIFF-16'S WHOLE REUSE, and it is TARIFF-17's move. It
|
||||
selects a row of `_LEGS` and nothing else: the enumerations, the
|
||||
arithmetic, `to_resolution()`, the unknown-key rule and the card
|
||||
comparison are shared, and every pre-existing call site - which passes no
|
||||
`option` at all - lands on the consumption row and reads byte-identical
|
||||
sentences. `parse_export_formula()` is a two-line wrapper, not a fork.
|
||||
|
||||
Accepts what the option can actually arrive as, exactly as
|
||||
`parse_static_profile()` does:
|
||||
|
||||
@@ -335,6 +583,7 @@ def parse_price_formula(value):
|
||||
fetch - go through this one function, so there is no route to a price that
|
||||
has not been graded against the card.
|
||||
"""
|
||||
leg = _LEGS[option]
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
@@ -345,16 +594,13 @@ def parse_price_formula(value):
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula is not valid JSON ({e}). It is a JSON object "
|
||||
f"{option} is not valid JSON ({e}). It is a JSON object "
|
||||
f"on one line, e.g.\n"
|
||||
f' {{"index":"epex_be","resolution":"hourly","factor":0.102,'
|
||||
f'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
|
||||
f'"card":{{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
||||
f'"expect_incl_vat":15.55}}}}\n'
|
||||
f"Leave it empty unless price_provider is 'custom_formula'.")
|
||||
f" {leg.example}\n"
|
||||
f"{leg.when}")
|
||||
if not isinstance(value, dict):
|
||||
raise FormulaInvalid(
|
||||
f"price_formula is {type(value).__name__}, not a JSON object with "
|
||||
f"{option} is {type(value).__name__}, not a JSON object with "
|
||||
f"{', '.join(_TOP_KEYS)}.")
|
||||
|
||||
# ⚠️ AN UNKNOWN KEY IS REFUSED, NOT IGNORED, AND THIS IS THE HALF THAT
|
||||
@@ -366,7 +612,7 @@ def parse_price_formula(value):
|
||||
unknown = sorted(set(value) - set(_TOP_KEYS))
|
||||
if unknown:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula has no field(s) {unknown}. The fields it takes "
|
||||
f"{option} has no field(s) {unknown}. The fields it takes "
|
||||
f"are {list(_TOP_KEYS)}. ⚠️ There is deliberately NO field that "
|
||||
f"takes an expression: this provider multiplies and adds two "
|
||||
f"FIXED coefficients and evaluates nothing, because two "
|
||||
@@ -378,7 +624,7 @@ def parse_price_formula(value):
|
||||
index = str(value.get("index", "") or "").strip()
|
||||
if index not in INDEXES:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.index is {value.get('index')!r}. The reference "
|
||||
f"{option}.index is {value.get('index')!r}. The reference "
|
||||
f"series this add-on can fetch are "
|
||||
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}. "
|
||||
f"If your card names a different index, the add-on cannot get it "
|
||||
@@ -388,7 +634,7 @@ def parse_price_formula(value):
|
||||
resolution = str(value.get("resolution", "") or "").strip()
|
||||
if resolution not in RESOLUTIONS:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.resolution is {value.get('resolution')!r}; it "
|
||||
f"{option}.resolution is {value.get('resolution')!r}; it "
|
||||
f"must be one of {list(RESOLUTIONS)}. Belgium has been on a "
|
||||
f"15-minute MTU since 2025-10-01 and the feed publishes 96 points "
|
||||
f"a day; a contract written against the HOURLY series is priced "
|
||||
@@ -402,16 +648,16 @@ def parse_price_formula(value):
|
||||
units = str(value.get("units", "") or "").strip()
|
||||
if units not in UNITS:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.units is {value.get('units')!r} and is REQUIRED - "
|
||||
f"{option}.units is {value.get('units')!r} and is REQUIRED - "
|
||||
f"there is no default, on purpose. It must be one of "
|
||||
f"{sorted(UNITS)}. Your card prints the formula in ONE of these "
|
||||
f"and two suppliers use two different conventions for the same "
|
||||
f"shape, so assuming one would be a factor of 100 or 1000 on "
|
||||
f"every hour with nothing able to notice.")
|
||||
|
||||
factor = _finite(value.get("factor"), "factor")
|
||||
offset = _finite(value.get("offset"), "offset")
|
||||
vat_pct = _finite(value.get("vat_pct"), "vat_pct")
|
||||
factor = _finite(value.get("factor"), "factor", option)
|
||||
offset = _finite(value.get("offset"), "offset", option)
|
||||
vat_pct = _finite(value.get("vat_pct"), "vat_pct", option)
|
||||
|
||||
# ⚠️ ZERO IS NOT "NO INDEXATION", IT IS A FIXED TARIFF WEARING A DYNAMIC
|
||||
# ONE'S CLOTHES. Every hour comes out at the same price, so every spread is
|
||||
@@ -421,15 +667,10 @@ def parse_price_formula(value):
|
||||
# the operator at it rather than let them build it by accident.
|
||||
if factor == 0.0:
|
||||
raise FormulaInvalid(
|
||||
"price_formula.factor is 0, so the index would not enter the "
|
||||
f"{option}.factor is 0, so the index would not enter the "
|
||||
"price at all: every hour of every day comes out at "
|
||||
f"{offset:g} x (1 + {vat_pct:g}/100), every spread is exactly 0, "
|
||||
"and arbitrage can never clear any threshold - a strategy that "
|
||||
"silently never fires rather than one that is off. If your tariff "
|
||||
"really is one flat price, that is `static_profile` with a single "
|
||||
"period covering 00:00-24:00 on all days and price_provider "
|
||||
"'off'; the add-on then says so at startup instead of leaving you "
|
||||
"to find out from a year of unchanged bills.")
|
||||
f"{offset:g} x (1 + {vat_pct:g}/100), {leg.zero_factor}. If "
|
||||
f"your {leg.what} tariff really is one flat price, {leg.flat}.")
|
||||
|
||||
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
|
||||
# the dearest hour becomes the cheapest one, so the battery is told to
|
||||
@@ -437,37 +678,96 @@ def parse_price_formula(value):
|
||||
# mispriced site, it is a site actively trading against itself.
|
||||
if factor < 0.0:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.factor is {factor:g}, which is NEGATIVE. That "
|
||||
f"{option}.factor is {factor:g}, which is NEGATIVE. That "
|
||||
f"inverts the market: the dearest hour of the day comes out as "
|
||||
f"the cheapest one, so the battery would charge at the evening "
|
||||
f"peak and discharge at 03:00 - trading against itself on every "
|
||||
f"cycle. No consumption tariff is indexed negatively; if your "
|
||||
f"card shows a minus sign it is on the OFFSET (`A`), which is "
|
||||
f"allowed here and common on injection.")
|
||||
f"cycle. {leg.neg_factor}.")
|
||||
|
||||
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
|
||||
# and some cards are already quoted VAT-inclusive. The bound exists for the
|
||||
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
|
||||
if not 0.0 <= vat_pct <= 30.0:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.vat_pct is {vat_pct:g} and must be between 0 and "
|
||||
f"{option}.vat_pct is {vat_pct:g} and must be between 0 and "
|
||||
f"30. It is a PERCENTAGE, so Belgian residential electricity is "
|
||||
f"6, not 0.06 and not 106. Zero is legitimate and is not refused: "
|
||||
f"injection is VAT-exempt, and a card that already quotes "
|
||||
f"VAT-inclusive coefficients needs no second multiplication.")
|
||||
|
||||
month, card_index, card_expect = _card(value.get("card"))
|
||||
month, card_index, card_expect = _card(value.get("card"), leg)
|
||||
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
|
||||
offset=offset, units=units, vat_pct=vat_pct,
|
||||
card_month=month, card_index_eur_per_mwh=card_index,
|
||||
card_expect_incl_vat=card_expect)
|
||||
card_expect_incl_vat=card_expect, option=option)
|
||||
|
||||
delta = card_discrepancy(spec)
|
||||
if abs(delta) > CARD_TOLERANCE:
|
||||
raise FormulaInvalid(card_refusal(spec, delta))
|
||||
# ⚠️ THE CARD SELF-CHECK STILL RUNS WHEREVER THERE IS A CARD TO RUN IT
|
||||
# AGAINST, on either leg, with the same tolerance. What TARIFF-16 adds is
|
||||
# the honest answer for the leg where the card prints no estimate: NOT a
|
||||
# weakened comparison and NOT an estimate computed here, but two bounds on
|
||||
# the COEFFICIENTS that need no printed price - see
|
||||
# `implausible_injection()` - and a startup line saying the leg is
|
||||
# ungraded.
|
||||
if spec.card_graded:
|
||||
delta = card_discrepancy(spec)
|
||||
if abs(delta) > CARD_TOLERANCE:
|
||||
raise FormulaInvalid(card_refusal(spec, delta))
|
||||
if option == EXPORT_FORMULA_OPTION:
|
||||
band = implausible_injection(spec)
|
||||
if band:
|
||||
raise FormulaInvalid(band)
|
||||
return spec
|
||||
|
||||
|
||||
def parse_export_formula(value):
|
||||
"""`price_export_formula` -> the INJECTION leg's `FormulaSpec`, or None.
|
||||
|
||||
TARIFF-16. THE DEFECT THIS EXISTS TO CLOSE: `price.py` modelled injection
|
||||
as ONE scalar, and the market publishes a line with four properties a
|
||||
scalar cannot hold. Verbatim off the cards:
|
||||
|
||||
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
|
||||
"Het injectietarief is dynamisch en kan per uur wijzigen"
|
||||
- identical on the January and the September 2026 cards
|
||||
ENGIE Dynamic (B x eSpot_15) - A c EUR/kWh
|
||||
Aspiravi (non-dynamic) 0,07 x Belpex - 2
|
||||
|
||||
1. ITS OWN FACTOR. Eneco uses 0,102 on consumption and 0,100 on
|
||||
injection; Aspiravi 0,07 against ~0,10 on its consumption leg. It is
|
||||
NOT the consumption `B` with a sign flip.
|
||||
2. A NEGATIVE OFFSET - `- 1,188`, `- 2`. Note that
|
||||
`price_markup_eur_kwh: float(0,1)` cannot express a negative markup
|
||||
at all, so the offset has no home on the consumption side either.
|
||||
3. VAT-EXEMPT. "Injectietarieven zijn vrijgesteld van btw", while
|
||||
consumption is at 6% - a SIX PERCENT ASYMMETRY between the buy leg
|
||||
and the sell leg, which a single scalar cannot even state.
|
||||
4. IT GOES NEGATIVE, WITH NO FLOOR. Eneco's injection price is negative
|
||||
for any BELPEX-H below 11,88 EUR/MWh, and a PV household exports
|
||||
precisely in the hours most likely to be negative. You can be charged
|
||||
to export.
|
||||
|
||||
⚠️ AND WHAT THIS DOES **NOT** AFFECT TODAY, SAID HERE SO NOBODY READS THE
|
||||
ABOVE AS A TRADING BUG. `arbitrage.py` does not read
|
||||
`export_price_eur_kwh` at all - its own comment says so, and gives §9.3's
|
||||
near-zero Belgian injection price as the reason the economically sound
|
||||
trade on this market is charge-cheap-to-displace-expensive-IMPORT. Both
|
||||
hours it compares are all-in CONSUMPTION prices. So no trade the add-on
|
||||
takes today is decided by this number, and this card is not a correction
|
||||
of a wrong trade. What it changes is what an operator is told, and what
|
||||
any future export-aware strategy would rest on: OPT-06 and any export
|
||||
logic would otherwise inherit a scalar that cannot represent the market,
|
||||
and the 6% VAT asymmetry would be invisible in the arithmetic.
|
||||
|
||||
⚠️ THE SCALAR IS NOT REPLACED. `export_price_eur_kwh()` is untouched and
|
||||
still answers for every site with a flat injection price, which is every
|
||||
site that exists today; this option ships "". That is `price.py`'s own
|
||||
prediction coming true rather than a new design - "when it does land, an
|
||||
injection curve gets a `PriceProvider` of its own and this scalar becomes
|
||||
its FIXED-product default; the seam is already the right shape for that."
|
||||
"""
|
||||
return parse_price_formula(value, option=EXPORT_FORMULA_OPTION)
|
||||
|
||||
|
||||
def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
||||
"""The sentence an operator reads when their formula misses its own card.
|
||||
|
||||
@@ -479,8 +779,9 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
||||
got = in_card_units(spec, spec.card_index_eur_per_mwh)
|
||||
ratio = (got / spec.card_expect_incl_vat
|
||||
if spec.card_expect_incl_vat else float("inf"))
|
||||
leg = _LEGS[spec.option]
|
||||
return (
|
||||
f"price_formula does not reproduce its own card, so the add-on will "
|
||||
f"{spec.option} does not reproduce its own card, so the add-on will "
|
||||
f"NOT start on it. Your contract's arithmetic is "
|
||||
f"price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); "
|
||||
f"on the card's own index that is "
|
||||
@@ -496,15 +797,7 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
||||
f"1000 - `units` must be the one the formula line is printed in); VAT "
|
||||
f"left out or applied twice (ratio 1.06 or 1/1.06); a transposed "
|
||||
f"digit; the two sides copied off DIFFERENT cards or different "
|
||||
f"months; or - the expensive one - network charges, levies or excise "
|
||||
f"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian card's "
|
||||
f"Tariefformule line is the ENERGY component alone (Eneco tabulates "
|
||||
f"network tariffs and levies separately; ENGIE says in so many words "
|
||||
f"that the formula excludes network, distribution, transport, levies "
|
||||
f"and excise), the add-on adds price_taxes_eur_kwh on top of it, and "
|
||||
f"folding ~12 c EUR/kWh of network charge into `offset` bills you for "
|
||||
f"it twice. That is the error this check exists to catch, and it is "
|
||||
f"why it cannot be switched off.")
|
||||
f"months; {leg.card_causes}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -520,13 +813,16 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
|
||||
|
||||
⚠️ THE LOW END IS GRADED ON THE ENERGY COMPONENT AND THE HIGH END ON THE
|
||||
ALL-IN FIGURE, AND THAT ASYMMETRY IS THE CHECK RATHER THAN AN OVERSIGHT.
|
||||
`price_taxes_eur_kwh` ships at 0.06, which is three times the low bound -
|
||||
so a card read a HUNDRED times too small comes out at 0.0016 EUR/kWh of
|
||||
energy, is carried over the floor by the flat tax addend alone, and passes
|
||||
a band applied to the all-in total. The hundredfold error is precisely
|
||||
what this exists to catch, so the floor is applied where it can still see
|
||||
it. The ceiling is applied to the all-in figure because that is the number
|
||||
the site is really billed at, and taxes only push it up.
|
||||
`price_taxes_eur_kwh` ships at 0.11736 (TARIFF-18; it was 0.06 before, and
|
||||
the argument was already decisive at that figure), which is nearly SIX
|
||||
times the low bound - so a card read a HUNDRED times too small comes out at
|
||||
0.0016 EUR/kWh of energy, is carried over the floor by the flat tax addend
|
||||
alone, and passes a band applied to the all-in total. The hundredfold error
|
||||
is precisely what this exists to catch, so the floor is applied where it
|
||||
can still see it. ⚠️ Raising the default made this trap WIDER, not
|
||||
narrower: the addend that does the carrying got bigger. The ceiling is
|
||||
applied to the all-in figure because that is the number the site is really
|
||||
billed at, and taxes only push it up.
|
||||
|
||||
⚠️ AND THE LIMIT, WITH THE CLAIM. This grades the CARD'S OWN POINT - one
|
||||
price, at the monthly index the card assumed - and not every hour of every
|
||||
@@ -536,13 +832,27 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
|
||||
fetched series. What it catches is a formula whose whole LEVEL is wrong,
|
||||
which is the factor-of-1000 class arriving from the end
|
||||
`MAX_PLAUSIBLE_EUR_KWH` does not bound.
|
||||
|
||||
⚠️ AND IT IS THE CONSUMPTION LEG'S BAND ONLY - TARIFF-16 DELIBERATELY DID
|
||||
NOT POINT IT AT THE INJECTION LEG. Two reasons, and the second is an
|
||||
acceptance criterion rather than a preference:
|
||||
|
||||
* it adds `taxes_eur_kwh`, and `price_taxes_eur_kwh` is a CONSUMPTION
|
||||
component. Nothing is added to what a site is PAID for export, so the
|
||||
`all_in` half would be adding a number the injection leg never carries;
|
||||
* its floor is `energy >= 0.02 EUR/kWh`, and a REAL Eneco injection price
|
||||
is negative for any BELPEX-H below 11,88 EUR/MWh - the hours a PV
|
||||
household actually exports in. Applied to injection this floor would
|
||||
refuse the market, and refuse it hardest in the hours where knowing the
|
||||
number matters most. `implausible_injection()` is the injection band
|
||||
and it is deliberately NOT this function with a lower floor.
|
||||
"""
|
||||
energy = price_eur_kwh(spec, spec.card_index_eur_per_mwh)
|
||||
all_in = energy + float(taxes_eur_kwh)
|
||||
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
|
||||
return None
|
||||
return (
|
||||
f"price_formula's own card works out at {energy:.5f} EUR/kWh of "
|
||||
f"{spec.option}'s own card works out at {energy:.5f} EUR/kWh of "
|
||||
f"energy ({spec.card_expect_incl_vat:g} {spec.units} as the card "
|
||||
f"prints it) and {all_in:.5f} EUR/kWh all-in once "
|
||||
f"price_taxes_eur_kwh is added, which is outside the plausible band "
|
||||
@@ -558,6 +868,85 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
|
||||
f"the best charging opportunity of the year - is never refused.")
|
||||
|
||||
|
||||
def implausible_injection(spec: FormulaSpec):
|
||||
"""The INJECTION leg's plausibility band. Returns a refusal or None.
|
||||
|
||||
⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, NOT REFUSED, AND NEITHER RUNG
|
||||
BELOW LOOKS AT THE SIGN OR THE LEVEL OF A PRICE. Eneco's published line
|
||||
`0,1 x BELPEX-H - 1,188` is negative for any BELPEX-H under 11,88 EUR/MWh;
|
||||
those are the midday hours a PV household exports in, so a band with a
|
||||
floor above zero - which is what `implausible_all_in()` has, for the
|
||||
consumption leg's own good reasons - would refuse the real market and
|
||||
would hide it in exactly the hours it costs money. You CAN be charged to
|
||||
export. The add-on says so rather than declining to represent it.
|
||||
|
||||
THE PROBLEM THAT LEAVES, STATED BEFORE THE SOLUTION. The consumption
|
||||
floor was not decoration: it is what catches a whole card read a hundred
|
||||
times too small, the one unit error the card self-check cannot see because
|
||||
it cancels out of a comparison between the two sides. A band that spans
|
||||
zero cannot catch it - 0.00132 EUR/kWh is inside any interval that
|
||||
contains -0.03. So the bound moves OFF the price and ONTO the
|
||||
coefficients, where it is index-free and sign-free:
|
||||
|
||||
* PASS-THROUGH. `factor x UNITS[units] / 0.001` is the dimensionless
|
||||
share of the wholesale price the contract passes through, because one
|
||||
EUR/MWh IS 0.001 EUR/kWh. Eneco's injection leg is 1.00 and Aspiravi's
|
||||
is 0.70. A factor typed 100x too small reads 0.01 and 100x too large
|
||||
reads 100, whatever the index and whatever the sign of the result.
|
||||
* OFFSET MAGNITUDE. Eneco's is 1,188 c EUR/kWh = 0.01188 EUR/kWh and
|
||||
Aspiravi's is 2 c = 0.02. A hundredfold error is 1.188 EUR/kWh, which
|
||||
is not an injection tariff in any direction. The bound is on the
|
||||
ABSOLUTE value, so a negative offset of a believable size passes and
|
||||
that is the normal case on this leg.
|
||||
|
||||
⚠️ TWO RUNGS THAT CAN BOTH ANSWER ONE STIMULUS, SO THEY ARE GRADED ON
|
||||
THEIR REASONS AND MUTATED TOGETHER (MUTATION.md, TEST-16). A whole card
|
||||
read 100x too large trips both. The fixtures in test_price.py 17d are
|
||||
built so that each rung is the ONLY one that fires on its own stimulus.
|
||||
|
||||
⚠️ AND WHAT THIS CANNOT SEE, WITH THE CLAIM (habit 13). It bounds the
|
||||
SHAPE of the coefficients, never their truth: a contract really written
|
||||
against a different index, or last year's coefficients, passes both rungs
|
||||
and reproduces any card typed beside them. Where a card DOES print an
|
||||
estimate the self-check grades the pair against it; where it does not -
|
||||
which is Eneco's injection line - these two bounds are the whole of what
|
||||
is mechanised, and validate.py says so at startup.
|
||||
"""
|
||||
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
|
||||
return (
|
||||
f"{spec.option}.factor is {spec.factor:g} {spec.units} per "
|
||||
f"EUR/MWh, which passes through {spec.passthrough:.4g}x the "
|
||||
f"wholesale price - outside the plausible "
|
||||
f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x for an injection tariff. A "
|
||||
f"Belgian injection formula pays a share of the market price a "
|
||||
f"little under or about equal to it: Eneco's "
|
||||
f"`0,1 x BELPEX-H - 1,188` c EUR/kWh is 1.00x and Aspiravi's "
|
||||
f"`0,07 x Belpex - 2` is 0.70x. {spec.passthrough:.4g}x is what a "
|
||||
f"factor typed in the wrong units looks like - check `units` "
|
||||
f"({spec.units!r}) against the unit printed beside your formula, "
|
||||
f"because the card self-check cannot see a factor of 10, 100 or "
|
||||
f"1000 applied to BOTH sides at once. ⚠️ This bounds the "
|
||||
f"COEFFICIENT and not the price: an injection price below zero is "
|
||||
f"accepted and is the real market - Eneco's goes negative under "
|
||||
f"11.88 EUR/MWh of BELPEX-H, which is the middle of a sunny day.")
|
||||
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
|
||||
return (
|
||||
f"{spec.option}.offset is {spec.offset:g} {spec.units} = "
|
||||
f"{spec.offset_eur_kwh:+.5f} EUR/kWh, and |offset| above "
|
||||
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh is not a Belgian "
|
||||
f"injection tariff in either direction. The published ones are "
|
||||
f"small and NEGATIVE - Eneco -1,188 c EUR/kWh (-0.01188), "
|
||||
f"Aspiravi -2 c (-0.02) - so a minus sign here is expected and is "
|
||||
f"not what this refuses; a MAGNITUDE two orders out is. The usual "
|
||||
f"cause is the offset typed in a different unit from `units` "
|
||||
f"({spec.units!r}), or a consumption offset copied onto the "
|
||||
f"injection leg: they are different numbers on the same card. "
|
||||
f"⚠️ Network charges and levies do not belong here either - "
|
||||
f"nothing is added to what you are PAID for export, so an offset "
|
||||
f"carrying them is simply wrong rather than double-counted.")
|
||||
return None
|
||||
|
||||
|
||||
def stale_card(spec: FormulaSpec, today=None):
|
||||
"""A warning when the card is older than ~6 months, or None. NEVER a
|
||||
refusal: ENGIE freezes a contract's coefficients for its duration, so an
|
||||
@@ -568,7 +957,7 @@ def stale_card(spec: FormulaSpec, today=None):
|
||||
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
|
||||
return None
|
||||
return (
|
||||
f"price_formula's card is dated {spec.card_month}, which is "
|
||||
f"{spec.option}'s card is dated {spec.card_month}, which is "
|
||||
f"{(now - dated).days} days ago. Suppliers reprice: if your contract "
|
||||
f"has been renewed or rolled over since then, the coefficients here "
|
||||
f"are last year's and every price this add-on computes is wrong by "
|
||||
|
||||
+176
-10
@@ -218,6 +218,106 @@ def pack_energy_of(inv):
|
||||
inv.capacity_kwh, inv.max_w)
|
||||
|
||||
|
||||
def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
|
||||
provider: bool, max_age_s: float) -> str:
|
||||
"""The *Tariff* status row: the 30 s tick's sentence AND the provider plan.
|
||||
|
||||
THE DEFECT THIS PREVENTS (TARIFF-11). `origin`, `product`, the horizon,
|
||||
the firm/carried split and the spacing appeared in NO status row. The row
|
||||
was `static_status()`'s sentence alone, computed on the 30 s I/O-free tick
|
||||
from its own static plan, and it NEVER SAW the provider plan - it read
|
||||
`'no static profile'` at all 802 samples of a 40-hour soak on a site whose
|
||||
feed was answering. The only external evidence a plan existed at all was
|
||||
one `_LOG.info` line, and that line carried the point<->hour mislabel
|
||||
`TariffPlan.words` is fixed for.
|
||||
|
||||
⚠️ TWO SOURCES, TWO CLOCKS, AND THE ROW SAYS SO IN WORDS. These sentences
|
||||
are NOT computed at the same moment and presenting them as though they
|
||||
were would be a new version of the defect being fixed:
|
||||
|
||||
`tick_words` static_status() / the Home Assistant price entities,
|
||||
regenerated on THIS tick, every 30 s, I/O-free;
|
||||
`plan` the background refresh's resolution, up to
|
||||
TARIFF_REFRESH_S old before it is retried and up to
|
||||
TARIFF_MAX_AGE_S (5400 s since TARIFF-12) old before
|
||||
`fresh_tariff_plan()` retires it.
|
||||
|
||||
So the plan half is stamped with its OWN age in seconds, measured from
|
||||
`tariff_at` - the moment of the fetch that produced it, which TARIFF-12
|
||||
deliberately does not move on a kept plan.
|
||||
|
||||
⚠️ AND `age_s` IS THE CALLER'S MEASUREMENT, NOT THIS FUNCTION'S. A clock
|
||||
read in here would be a second clock disagreeing with the one that decided
|
||||
the plan was fresh enough to pass at all.
|
||||
|
||||
⚠️ "NO PLAN" IS SAID OUT LOUD ON A SITE THAT CONFIGURED A PROVIDER. A
|
||||
dynamic site whose refresh has never completed, or whose plan has aged
|
||||
past `max_age_s`, is the exact state the soak measured; rendering it as
|
||||
the bare static sentence is how it stayed invisible for 802 samples.
|
||||
"""
|
||||
if plan is None:
|
||||
if not provider:
|
||||
return tick_words
|
||||
return (f"{tick_words} (recomputed this tick); NO provider plan - "
|
||||
f"none has been resolved yet, or the last one is older than "
|
||||
f"{max_age_s:.0f} s")
|
||||
return (f"{tick_words} (recomputed this tick); provider plan fetched "
|
||||
f"{age_s:.0f} s ago for a {horizon_h:.0f} h horizon: {plan.words}")
|
||||
|
||||
|
||||
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
|
||||
why: str) -> str:
|
||||
"""The *Arbitrage* status row: THREE states, which used to render as two.
|
||||
|
||||
THE DEFECT THIS PREVENTS (TARIFF-11). The row was
|
||||
`"off (arbitrage_enabled)" if self.arb is None else self.arb.explain()`,
|
||||
under a comment asserting that `self.arb is None` if and only if
|
||||
`arbitrage.enabled(self.o)` was False. IT IS NOT: `cycle()` returns at the
|
||||
input hold - inputs missing, `bad_for < stale_input_s`, default 15 s -
|
||||
BEFORE it reaches `self.arb = None`. Driven: `arbitrage_enabled: true`
|
||||
plus a sensor answering `None` gives `arbitrage.enabled(opts) = True`,
|
||||
`self.arb = None` (its `__init__` value, untouched) and a row reading
|
||||
`off (arbitrage_enabled)` - NAMING AN OPTION THAT IS ON. Past the first
|
||||
completed cycle the same early return leaves the PREVIOUS decision
|
||||
standing instead, which the row then rendered as a current one.
|
||||
|
||||
THE THREE FACTS, and they are three because they have three remedies:
|
||||
|
||||
trading is off the operator has not armed it. Nothing is
|
||||
wrong and nothing will happen.
|
||||
no decision this cycle it is armed, and the cycle did not get as far
|
||||
as the arbitrage step. `why` is the control
|
||||
loop's own reason - usually "inputs missing".
|
||||
a decision the strategy's own sentence, with the age of
|
||||
the cycle that produced it.
|
||||
|
||||
⚠️ `on` IS READ FROM THE OPTIONS, NOT INFERRED FROM `decision is None`.
|
||||
Inferring it is the whole defect: the option is the only thing that
|
||||
answers "is trading armed", and it is available on every render.
|
||||
|
||||
⚠️ THE AGE IS ALWAYS PRINTED, INCLUDING WHEN IT IS ZERO. A row that
|
||||
reports staleness only once it is stale cannot be told, on any single
|
||||
render, from one that never reports it - and the bound TARIFF-10's
|
||||
comment claimed did not exist is ~15 s of it.
|
||||
"""
|
||||
if not on:
|
||||
return "off - `arbitrage_enabled` is false, so nothing trades"
|
||||
if decided_at is None:
|
||||
return (f"ON (`arbitrage_enabled`), but NO DECISION has been reached "
|
||||
f"yet - no cycle has run as far as the arbitrage step. The "
|
||||
f"loop's own reason: {why or 'none recorded'}")
|
||||
age = (now - decided_at).total_seconds()
|
||||
stale = cycle_at is not None and decided_at < cycle_at
|
||||
head = (f"no decision this cycle ({why or 'no reason recorded'}); the "
|
||||
f"last one was taken {age:.0f} s ago: " if stale else
|
||||
f"decided {age:.0f} s ago: ")
|
||||
if decision is None:
|
||||
return (f"ON (`arbitrage_enabled`), no decision this cycle "
|
||||
f"({why or 'no reason recorded'}), and none stands from an "
|
||||
f"earlier one")
|
||||
return head + decision.explain()
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||
peak_db_path: str = "/data/tariff.db",
|
||||
@@ -796,6 +896,19 @@ class Controller:
|
||||
self.tariff_plan = None
|
||||
self.tariff_at = None
|
||||
self.arb = None
|
||||
# TARIFF-11. WHEN the standing arbitrage decision was taken, or None if
|
||||
# this controller has never reached the arbitrage step. ⚠️ `self.arb is
|
||||
# None` DOES NOT MEAN "TRADING IS OFF" AND NEVER DID - see
|
||||
# `arbitrage_row()`. It also means "cycle() returned before the step
|
||||
# ran", and at that point `self.arb` is either this attribute's initial
|
||||
# None (startup) or the PREVIOUS cycle's decision (an input hold), and
|
||||
# the status row rendered all three identically.
|
||||
self.arb_at = None
|
||||
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
|
||||
# is the exact, derived statement of "the latest cycle did not get as
|
||||
# far as the arbitrage step", which is the fact the *Arbitrage* row
|
||||
# used to render identically to "trading is switched off".
|
||||
self.cycle_at = None
|
||||
self._arb_action = None
|
||||
self.inputs_bad_since = None
|
||||
# SAFETY-03. What the loop's OWN bounds did last cycle - a
|
||||
@@ -1565,6 +1678,24 @@ class Controller:
|
||||
self.cheap, self.tariff_words = static_status(
|
||||
o, datetime.now(timezone.utc))
|
||||
|
||||
# ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND
|
||||
# THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the
|
||||
# 30 s I/O-free tick; `fresh_tariff_plan()` is an attribute read and an
|
||||
# age comparison against TARIFF_MAX_AGE_S - still no fetch on this path,
|
||||
# which is the rule the whole branch above exists to keep. What the row
|
||||
# gains is the plan's SHAPE (origin, product, horizon, the firm/carried
|
||||
# split, the spacing) and what it must never gain is the suggestion
|
||||
# that the two halves were computed at the same instant, so the plan
|
||||
# half carries its own age in seconds. See `tariff_plan_row()`.
|
||||
_tick = datetime.now(timezone.utc)
|
||||
_standing = self.fresh_tariff_plan(_tick)
|
||||
self.tariff_words = tariff_plan_row(
|
||||
_standing, self.tariff_words,
|
||||
age_s=(None if _standing is None
|
||||
else (_tick - self.tariff_at).total_seconds()),
|
||||
horizon_h=float(price_option(o, "price_horizon_h")),
|
||||
provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S)
|
||||
|
||||
if self.inputs_ok:
|
||||
if self.inputs_bad_since is not None:
|
||||
self.log_event("inputs recovered")
|
||||
@@ -1918,6 +2049,11 @@ class Controller:
|
||||
method's only job is to state honestly what each strategy wants.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
# ⚠️ BEFORE THE FIRST `return` OF THIS METHOD, ON PURPOSE (TARIFF-11).
|
||||
# It stamps that a cycle RAN, which is a different fact from any of the
|
||||
# decisions the cycle may or may not reach, and the status row needs
|
||||
# the two separately.
|
||||
self.cycle_at = now
|
||||
|
||||
# ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration
|
||||
# drains and refills ONE pack and grades ONE coulomb counter, so each
|
||||
@@ -2577,13 +2713,35 @@ class Controller:
|
||||
# `frozenset()` would be main.py asserting a forecast is healthy when
|
||||
# there is not one, which is exactly what static_tariff's `uncertainty`
|
||||
# argument has no default in order to prevent.
|
||||
self.arb = None
|
||||
# ⚠️ TARIFF-11: BOTH ATTRIBUTES, AND `arb_at` IS WHAT MAKES THE ROW
|
||||
# HONEST. Reaching this line is the only proof that this cycle got as
|
||||
# far as the arbitrage step; the early return at the input hold above
|
||||
# leaves BOTH standing at whatever the last completed cycle left, and
|
||||
# `arb_at` is then the age the status row reports rather than a
|
||||
# decision presented as current.
|
||||
self.arb, self.arb_at = None, now
|
||||
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),
|
||||
# SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS
|
||||
# ONE. `guard_state()` is a switch and it was the whole loop:
|
||||
# the guard engaged on a 5 W floor, the trade was refused
|
||||
# outright, the loop took the actuator, the meter fell, the
|
||||
# guard released and the trade came back AT max_w. Measured on
|
||||
# TEST-23 stage 3 at the shipped peak_cap_w/max_w: 28 charge
|
||||
# onsets an hour, grid -456 <-> +3200 W. This is the watts the
|
||||
# billed quarter can still afford, so the buy leg is CLAMPED
|
||||
# instead - the shape control.maintenance_charge_floor() has
|
||||
# always given a maintenance charge, off the MEASURED guard
|
||||
# rather than off the optional peak_forecast_entity.
|
||||
# ⚠️ SAME ORDERING ARGUMENT AS THE LINE ABOVE: this reads the
|
||||
# peak block's conclusion, ~50 lines up, so it must stay after
|
||||
# it. Evaluated earlier it would size this quarter's trade from
|
||||
# the previous cycle's quarter.
|
||||
charge_headroom_w=peakguard.charge_headroom_w(self.peak_guard),
|
||||
# The emptiest pack again, and for the sell side that is the
|
||||
# right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there
|
||||
# is one `soc` argument - `pack_floor_soc` names that residual
|
||||
@@ -4481,15 +4639,23 @@ class Controller:
|
||||
# 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()),
|
||||
# ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A
|
||||
# COMMENT THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION. The
|
||||
# claim was that `self.arb is None` **if and only if**
|
||||
# `arbitrage.enabled(self.o)` was False. Only the forward direction
|
||||
# holds: `cycle()` returns at the input hold (`bad_for <
|
||||
# stale_input_s`, default 15 s) BEFORE it reaches `self.arb = None`
|
||||
# at all, so on an armed site `self.arb` is `__init__`'s None at
|
||||
# startup and the PREVIOUS cycle's decision during a later hold -
|
||||
# and the row rendered `off (arbitrage_enabled)` on a site whose
|
||||
# `arbitrage_enabled` is `true`. `arbitrage_row()` reads the option
|
||||
# instead of inferring it, and separates "trading is off" from "no
|
||||
# decision was reached this cycle"; `cycle_at` and `arb_at` are what
|
||||
# make the second one an observation rather than a guess.
|
||||
("Arbitrage", arbitrage_row(
|
||||
on=arbitrage.enabled(self.o), decision=self.arb,
|
||||
decided_at=self.arb_at, cycle_at=self.cycle_at,
|
||||
now=datetime.now(timezone.utc), why=self.reason)),
|
||||
]
|
||||
return {
|
||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||
|
||||
@@ -822,6 +822,145 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
f"only give {ceil_top:.0f} W" if saturated else "")))
|
||||
|
||||
|
||||
def charge_headroom_w(decision) -> float | None:
|
||||
"""SAFETY-45. The hardest this SITE may charge without putting the billed
|
||||
quarter over the ceiling, in W, positive - or None when nothing measured it.
|
||||
|
||||
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
|
||||
`discharge_permitted()`, `shaping_suspended()`, `guard_state()` and
|
||||
`looks_frozen()` are: the check that grades it has to assert on THE SHIPPED
|
||||
EXPRESSION, and a predicate transcribed into its own test is one copy plus a
|
||||
future disagreement about what the check means.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
THE DEFECT IT PREVENTS, AND IT IS NOT A NEW IDEA IN THIS REPOSITORY
|
||||
|
||||
`control.maintenance_charge_floor()` already caps MAINTENANCE charging at
|
||||
"the headroom left under the site's cap", and its docstring says why in
|
||||
words this card only has to widen:
|
||||
|
||||
"A maintenance charge is the only thing this system does that is big
|
||||
enough and long enough to set that peak, so it is capped by the
|
||||
headroom left under the site's cap."
|
||||
|
||||
⚠️ THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED. Price arbitrage is a
|
||||
SECOND thing that is big enough and long enough - it commands `max_w` for a
|
||||
whole tariff interval, deliberately, against the meter - and it was given no
|
||||
such cap. Measured on TEST-23 stage 3's closed rig at the shipped
|
||||
`peak_cap_w: 3500` / `max_w: 2000`: 28 charge onsets in one hour, mean period
|
||||
131 s, grid swinging -456 <-> +3200 W, on any house drawing over 1500 W in
|
||||
the cheapest quarter. §10.2: "an EMS that oscillates is worse than no EMS,
|
||||
both for hardware wear and - per §9.1 - for the peak it is trying to avoid."
|
||||
|
||||
⚠️ AND IT IS THE **MEASURED** GUARD, NOT `peak_forecast_entity`.
|
||||
`maintenance_charge_floor()` sizes itself from the forecast sensor, which is
|
||||
OPTIONAL and empty on a default install - the exact hole SAFETY-25 had to
|
||||
close in `shaping_suspended()`, where a suspension gated on the forecast
|
||||
alone could not fire on the very site that needed it. This number comes off
|
||||
`decide()`, which runs wherever P1 does.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
WHERE THE NUMBER COMES FROM: IT IS `wanted_w`, READ IN THE OTHER DIRECTION
|
||||
|
||||
`decide()` already computes
|
||||
|
||||
wanted = batt_w + excess = (the offtake there would be with the battery
|
||||
at zero) - (the level this quarter can still afford)
|
||||
|
||||
as the battery DISCHARGE that would land the quarter exactly on the ceiling.
|
||||
A negative `wanted` is not "nothing to say" - it is a permitted CHARGE of
|
||||
`-wanted` watts, and throwing it away is SAFETY-45's whole defect. So this
|
||||
function invents no arithmetic; it reads the sign the guard already had.
|
||||
|
||||
⚠️ AND THE QUANTITY IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE
|
||||
TO IT CANNOT OSCILLATE. Raise the charge by dW and the meter rises by dW, so
|
||||
`excess` rises by exactly dW while `batt_w` falls by dW: `wanted` does not
|
||||
move. It is a FEED-FORWARD number - a function of the house, the ceiling and
|
||||
the seconds already banked - so consuming it adds no feedback path around the
|
||||
plant. The fixed point is `charge = ceiling - load`, with the meter sitting
|
||||
ON the ceiling and `wanted` parked at `load - ceiling`, a whole `ceiling -
|
||||
load` clear of the engagement boundary rather than chattering across it.
|
||||
|
||||
⚠️ AND A QUARTER BOUNDARY DOES NOT HAND IT BACK AT FULL POWER, WHICH IS THE
|
||||
OTHER HALF OF SAFETY-45 AND IS NOT THE SAME CLAIM AS "IT DOES NOT CHANGE".
|
||||
It DOES change - it is `(900 / remaining) * (ceiling - projected)`, so it is
|
||||
largest in the last seconds of a block and collapses at the boundary. What
|
||||
matters is WHAT IT COLLAPSES TO: at `elapsed = 0` the projection IS the held
|
||||
level, so the answer is `ceiling - (load - pv)` - the house and the cap, and
|
||||
nothing else. A trade refused at 14:59 therefore resumes at 15:00 at the rate
|
||||
the new quarter can afford, not at `max_w`, and §10.1's answer survives the
|
||||
boundary because the arithmetic does: no timer, no backoff constant, no state
|
||||
carried between calls.
|
||||
|
||||
⚠️ AND THE AMPLIFICATION IS BOUNDED IN ITS EFFECT, NOT MERELY LARGE. Hold a
|
||||
charge of exactly this size for the rest of the block and the block's average
|
||||
lands on `ceiling` EXACTLY, wherever in the block it started:
|
||||
|
||||
(acc + (held + headroom) * remaining) / 900
|
||||
= projected + (900/remaining) * (ceiling - projected) * remaining/900
|
||||
= ceiling
|
||||
|
||||
so 162 kW of headroom with 5 s left and 900 W of it with the whole quarter to
|
||||
go are the same statement about the bill. `test_peakguard.py` part 13 sweeps
|
||||
that identity across the block rather than arguing it here.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
THE THREE ANSWERS
|
||||
|
||||
`None` nothing measured it: the guard never ran (`meter_source: off`), or
|
||||
it ran with no battery reading to size a floor from, or the block
|
||||
had no seconds left to influence. ⚠️ None IS NOT UNLIMITED. A caller
|
||||
that treats it as "charge away" is `arbitrage.guard_state()`'s
|
||||
collapse of None into False, one layer down - a strategy creating
|
||||
the billed quantity while nothing measures the quarter it lands in.
|
||||
`0.0` measured, and the answer is no charging: the guard is BLIND (its
|
||||
claim already says so at `floor 0 W` and this agrees with it rather
|
||||
than restating it), or it is holding and `wanted >= 0`.
|
||||
`> 0` the watts a charge may pull this cycle.
|
||||
|
||||
⚠️ HOLDING IMPLIES ZERO, BY CONSTRUCTION AND NOT BY COINCIDENCE. Every state
|
||||
that files a claim with `engaged=True` has `wanted > 0`, `wanted is None` or
|
||||
`blind`, so this answers 0.0 or None there and can never hand a strategy
|
||||
permission the guard's own `lo` is at that instant refusing.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
⚠️ WHY THIS IS A SIZING AND NOT A CLAIM, AND DO NOT "SIMPLIFY" IT INTO ONE
|
||||
|
||||
The obvious tidier answer is to delete `decide()`'s `wanted <= 0` gate and
|
||||
let it file `lo = wanted` unconditionally - a NEGATIVE floor, meaning "you
|
||||
may charge, but no harder than this". The arithmetic is identical, it is
|
||||
continuous, and it would bind every claimant rather than only arbitrage. It
|
||||
was measured, considered and REJECTED, and the reason is not style:
|
||||
|
||||
* `main.py`'s cheap-window maintenance charge files
|
||||
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` - a NEGATIVE `hi`.
|
||||
arbiter.py clause 2 applies EVERY limit at or above the winner's priority
|
||||
and takes the most restrictive, with no notion of one limit out-ranking
|
||||
another. A peak `lo = -300 W` against a shaping `hi = -1000 W` is
|
||||
`lo > hi`: a CONTRADICTION, which resolves to 0 W - the battery PARKED,
|
||||
in the one quarter of the month that sets the bill. That is the exact
|
||||
failure `shaping_suspended()` exists to prevent, and the only way to
|
||||
close it would be to key that suppression on "a claim exists", which on
|
||||
every site this card is about suspends the maintenance charge-only
|
||||
shaping PERMANENTLY.
|
||||
* and it would change what `engaged` MEANS at five call sites outside this
|
||||
module - `arbitrage.guard_state()`, `shaping_suspended()`, `claim()`,
|
||||
main.py's edge-logged action line and main.py's status row - each of
|
||||
which today reads it as "the guard is HOLDING".
|
||||
|
||||
`control.maintenance_charge_floor()` is a sizing and not a limit for the same
|
||||
reason, which is why this is the shape that already fits.
|
||||
DECISIONS/SAFETY-45-sizing-the-trade.md §2b carries the full argument.
|
||||
"""
|
||||
if decision is None:
|
||||
return None
|
||||
if decision.blind:
|
||||
return 0.0
|
||||
if decision.wanted_w is None:
|
||||
return None
|
||||
return max(0.0, -float(decision.wanted_w))
|
||||
|
||||
|
||||
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
"""Must the maintenance charge-only shaping stand down this cycle?
|
||||
|
||||
|
||||
+279
-30
@@ -151,8 +151,8 @@ from textwrap import dedent
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .custom_formula import (
|
||||
FormulaInvalid, PREVIEW_HOURS, parse_price_formula, preview_lines,
|
||||
price_eur_kwh, to_resolution)
|
||||
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
|
||||
parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price")
|
||||
|
||||
@@ -818,6 +818,69 @@ def plan_is_provisional(read: PriceRead) -> bool:
|
||||
# Options: what a plugin may see, and what it may not
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TARIFF-18: what `price_taxes_eur_kwh` IS MADE OF
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# ⚠️ THE DEFECT THIS TABLE PREVENTS IS THE ONE THE SHIPPED 0.06 WAS: A NUMBER
|
||||
# NOBODY COULD SOURCE. 0.06 EUR/kWh is roughly HALF the real Flemish non-energy
|
||||
# figure, and because no reader could see what it was made of there was nothing
|
||||
# to argue with. Every row below is one line of the Eneco residential card for
|
||||
# Flanders, September 2026, INCLUDING 6 % VAT, converted from the c EUR/kWh the
|
||||
# card prints to the EUR/kWh this option takes. `price_taxes_eur_kwh`'s default
|
||||
# is their sum, and test_price.py section 1b re-adds them and refuses a default
|
||||
# that is not exactly that.
|
||||
#
|
||||
# ⚠️ THIS IS A PER-DSO FIGURE, AND IT IS THE DISTRIBUTION ROW THAT MOVES. Across
|
||||
# the eight Fluvius DSOs that row runs 5,28 c EUR/kWh (Midden-Vlaanderen) to
|
||||
# 6,70 (West), so the all-in Flemish total runs about 0.117 to 0.131 EUR/kWh.
|
||||
# A site outside Antwerpen should type its own total; the other two rows are
|
||||
# Flanders-wide and do not move with the DSO.
|
||||
#
|
||||
# ⚠️ AND THE DEFAULT SITS NEAR THE LOW END OF THAT BAND ON PURPOSE, BECAUSE THE
|
||||
# TWO ERROR DIRECTIONS ARE NOT SYMMETRIC. This scalar is a LEVEL component: it
|
||||
# cancels exactly in `max - min` and cannot move which hour is cheapest
|
||||
# (test_price.py 16b measures that as a family). The one DECISION it reaches is
|
||||
# §17's negative-price guard in arbitrage.py, which tests the ABSOLUTE all-in
|
||||
# price of the sell hour. A figure set too HIGH carries a genuinely negative
|
||||
# hour up above zero, and the add-on exports into paid-for disposal - the exact
|
||||
# case §17 exists to refuse. A figure set too LOW only declines a trade that
|
||||
# would have paid. So where a default must stand in for eight different DSOs,
|
||||
# it belongs at the bottom of the band and not in the middle of it.
|
||||
#
|
||||
# ⚠️ `energiebijdrage` IS ZERO, AND IT IS A ROW HERE RATHER THAN AN ABSENCE.
|
||||
# "Vanaf 1 augustus 2026 bedraagt de energiebijdrage 0 EUR/MWh" - it was merged
|
||||
# into the bijzondere accijns. Anyone re-deriving this total from a guide
|
||||
# written before that date will find a separate energiebijdrage line and add it
|
||||
# on top of an accijns that already contains it. The zero row is here so that
|
||||
# the double-count is visible as a decision rather than as an omission.
|
||||
FLEMISH_TAXES_COMPONENTS_EUR_KWH = (
|
||||
("Fluvius distribution, Antwerpen", 0.0535),
|
||||
("Bijzondere accijns, 'Alle verbruik' - no volume bands", 0.04876),
|
||||
("Bijdrage groene stroom en WKK Vlaanderen", 0.0151),
|
||||
("Energiebijdrage - 0 EUR/MWh since 2026-08-01, inside the accijns", 0.0),
|
||||
)
|
||||
|
||||
# ⚠️ TARIFF-18, AND IT IS NOT HOUSEKEEPING - IT DECIDES WHICH CONFIGS START.
|
||||
# TARIFF-17's rung in validate.py refuses a site that sets `price_taxes_profile`
|
||||
# AND leaves `price_taxes_eur_kwh` at something other than the shipped default,
|
||||
# because the table wins and the scalar would decide nothing. It compares
|
||||
# against the DEFAULT rather than against presence, because the Supervisor sets
|
||||
# `App.options = deepmerge(config.yaml defaults, stored options)` and a saved
|
||||
# blob therefore carries every schema key whether the operator typed it or not
|
||||
# (measured: the reference install's export is 70 of 71 keys - CHANGELOG 0.9.0,
|
||||
# `tools/supervisor_selfapi_probe.py` §D).
|
||||
#
|
||||
# So moving the default turns a previously ACCEPTED configuration into
|
||||
# `SystemExit(78)`. A site that stored 0.06 - which is every install that has
|
||||
# ever saved its configuration - and that also carries a taxes table would stop
|
||||
# starting on the upgrade, over a number it never typed and that decides
|
||||
# nothing. That refusal would be TRUE and USELESS: the rung's subject is a
|
||||
# number the OPERATOR typed, and a superseded shipped default is not one. It
|
||||
# still reads as "untouched" here, and the rung's sentence names both figures.
|
||||
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) # TARIFF-02 .. TARIFF-17
|
||||
|
||||
|
||||
# ⚠️ CFG-03, AND HOW THIS TICKET AVOIDS WIDENING IT. Option defaults in this
|
||||
# add-on already live in three places - config.yaml, main.py's `o.get(k, X)`
|
||||
# fallbacks, and a dataclass default - held apart only by a tripwire in
|
||||
@@ -836,7 +899,9 @@ def plan_is_provisional(read: PriceRead) -> bool:
|
||||
PRICE_OPTION_DEFAULTS = {
|
||||
"price_provider": "off",
|
||||
"price_markup_eur_kwh": 0.03, # §14 tariffs.energy_price.markup_eur_kwh
|
||||
"price_taxes_eur_kwh": 0.06, # §14 tariffs.energy_price.taxes_eur_kwh
|
||||
# TARIFF-18: the sum of FLEMISH_TAXES_COMPONENTS_EUR_KWH above, to the
|
||||
# card's own last digit. §14 tariffs.energy_price.taxes_eur_kwh.
|
||||
"price_taxes_eur_kwh": 0.11736,
|
||||
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "",
|
||||
@@ -851,6 +916,18 @@ PRICE_OPTION_DEFAULTS = {
|
||||
# test_price.py section 1. A second defaults dict in the new module would be
|
||||
# a fourth place option defaults live, which is exactly the open ticket.
|
||||
"static_profile": "",
|
||||
# TARIFF-17. A JSON object in `static_profile`'s OWN period grammar whose
|
||||
# per-period `eur_per_kwh` is the TAXES COMPONENT for that band, not an
|
||||
# all-in price; "" is unset and the scalar above decides. Parsed and
|
||||
# validated by app/static_tariff.py (`parse_taxes_profile`), refused at
|
||||
# startup by validate.py - the `static_profile` precedent exactly, and here
|
||||
# for the same CFG-03 reason: this dict is the ONE in-code mirror of
|
||||
# config.yaml.
|
||||
#
|
||||
# ⚠️ IT IS IN FRAMEWORK_KEYS. A plugin that could read this could add the
|
||||
# taxes a second time, per hour, which is the double-counting defect trap 1
|
||||
# exists to refuse - wearing a period table instead of a scalar.
|
||||
"price_taxes_profile": "",
|
||||
# TARIFF-15. A JSON object carrying ONE contract's two coefficients, the
|
||||
# units they are printed in, its VAT rate and the card figures the add-on
|
||||
# grades them against; "" is unset. Parsed and validated by
|
||||
@@ -858,6 +935,20 @@ PRICE_OPTION_DEFAULTS = {
|
||||
# `static_profile` precedent exactly, and here for the same CFG-03 reason:
|
||||
# this dict is the ONE in-code mirror of config.yaml.
|
||||
"price_formula": "",
|
||||
# TARIFF-16. The INJECTION leg, in `price_formula`'s OWN grammar and read
|
||||
# by `custom_formula.parse_export_formula()` - the same parser with its
|
||||
# option name as an argument, so a second unit table and a second unknown-
|
||||
# key rule do not exist to diverge. "" is unset and the scalar
|
||||
# `export_price_eur_kwh` above decides, which is every site today.
|
||||
#
|
||||
# ⚠️ IT IS IN FRAMEWORK_KEYS, for the reason the scalar is: a CONSUMPTION
|
||||
# plugin that could read an injection curve could fold it into a
|
||||
# consumption price, and nothing downstream could tell.
|
||||
#
|
||||
# ⚠️ AND IT LIVES HERE RATHER THAN IN custom_formula.py FOR CFG-03's SAKE,
|
||||
# exactly as `price_formula` and `price_taxes_profile` do: this dict is the
|
||||
# ONE in-code mirror of config.yaml.
|
||||
"price_export_formula": "",
|
||||
# OPT-04. §10.1 priority 3, and §14's `optimizer.cycle_cost_threshold_eur`.
|
||||
#
|
||||
# ⚠️ THEY LIVE HERE FOR CFG-03's SAKE, exactly as `static_profile` does, and
|
||||
@@ -884,6 +975,19 @@ PRICE_OPTION_DEFAULTS = {
|
||||
# that can double-count, so `PluginOptions` refuses them by name. Trap 1.
|
||||
FRAMEWORK_KEYS = frozenset({
|
||||
"price_markup_eur_kwh", "price_taxes_eur_kwh", "export_price_eur_kwh",
|
||||
# TARIFF-17. The hour-varying spelling of `price_taxes_eur_kwh`, and it
|
||||
# belongs here for the same reason the scalar does: it is a component the
|
||||
# FRAMEWORK adds exactly once, by `caps.adds`. `BANNED_IN_PLUGIN_CODE` is
|
||||
# derived from this set, so `code_leaks()` refuses a plugin that names it
|
||||
# without anything else being edited.
|
||||
"price_taxes_profile",
|
||||
# TARIFF-16. The hour-varying spelling of `export_price_eur_kwh`, and it
|
||||
# belongs here for the same reason the scalar does: a plugin that could
|
||||
# read an INJECTION curve could fold it into the CONSUMPTION series it
|
||||
# emits, and `assemble()` has no way to notice a component that arrived
|
||||
# inside a RawPoint. `BANNED_IN_PLUGIN_CODE` is derived from this set, so
|
||||
# `code_leaks()` refuses a plugin that names it with nothing else edited.
|
||||
EXPORT_FORMULA_OPTION,
|
||||
})
|
||||
|
||||
|
||||
@@ -997,28 +1101,103 @@ def export_price_eur_kwh(opts: dict) -> float:
|
||||
consumption is at 6% - so it is a second per-contract formula, not a second
|
||||
copy of the first.
|
||||
|
||||
⚠️ TARIFF-15 LANDED THE EVALUATOR AND DID NOT LAND THIS CURVE, and the
|
||||
difference is the point rather than an omission. `custom_formula` can now
|
||||
express `B x index + A` exactly - it is what it exists for - but it is a
|
||||
CONSUMPTION provider: it declares {ENERGY, MARKUP}, the framework adds the
|
||||
consumption taxes, and an injection leg carries its OWN coefficients
|
||||
(Eneco: 0,102 consumption against 0,100 injection) and its own VAT
|
||||
treatment (injection is exempt; consumption is at 6%). Pointing this
|
||||
scalar at that machinery would mean pricing an injection curve with a
|
||||
consumption card, which is the wrong-card defect the card self-check
|
||||
exists to refuse. So until the injection card lands this scalar remains a
|
||||
SIMPLIFICATION an operator enters by hand - an average of what they are
|
||||
actually paid - and not a reflection of the market. When it does land, an
|
||||
injection curve gets a `PriceProvider` of its own and this scalar becomes
|
||||
its FIXED-product default; the seam is already the right shape for that.
|
||||
⚠️ TARIFF-16 LANDED THE INJECTION LEG, AND THIS FUNCTION IS UNCHANGED. The
|
||||
curve lives in `price_export_formula` and is read by `export_formula()`
|
||||
below; this reader still answers the FLAT injection price, still from one
|
||||
option, still with no formula anywhere in its arithmetic. That is the
|
||||
arrangement this docstring predicted before either existed - "an injection
|
||||
curve gets a `PriceProvider` of its own and this scalar becomes its
|
||||
FIXED-product default" - and it is why a site with a flat injection price
|
||||
sees nothing change: `price_export_formula` ships "", `export_price_at()`
|
||||
falls through to this function, and this function's body is byte-identical
|
||||
to the one TARIFF-02 shipped.
|
||||
|
||||
⚠️ It is NOT reachable from a plugin: `export_price_eur_kwh` is in
|
||||
FRAMEWORK_KEYS, so a consumption plugin cannot quietly fold an injection
|
||||
price into a consumption price.
|
||||
price into a consumption price. `price_export_formula` is in the same set
|
||||
for the same reason.
|
||||
|
||||
⚠️ AND NOTHING IN THIS ADD-ON READS EITHER ONE TO TAKE A DECISION.
|
||||
`arbitrage.py` prices the sell side as DISPLACED IMPORT and says so in its
|
||||
own comment; both hours it compares are all-in consumption prices. This
|
||||
reader has no caller under `app/` and neither does `export_price_at()`.
|
||||
They are what an operator is shown and what a future export-aware strategy
|
||||
would call - not a number any trade turns on today.
|
||||
"""
|
||||
return float(price_option(opts, "export_price_eur_kwh"))
|
||||
|
||||
|
||||
def export_formula(opts: dict):
|
||||
"""The configured `price_export_formula` as a `FormulaSpec`, or None.
|
||||
|
||||
TARIFF-16. Raises `custom_formula.FormulaInvalid` on a malformed leg - and
|
||||
⚠️ THAT EXCEPTION IS NOT MEANT TO REACH A RUNNING SITE, by the rule
|
||||
`taxes_profile()` states above and `static_profile` established:
|
||||
validate.py parses the same option at STARTUP and refuses to start on it,
|
||||
because a formula that only fails when something reads it is a defect
|
||||
discovered at the worst possible moment and §11.4 makes every price
|
||||
defect quiet.
|
||||
|
||||
⚠️ THE PARSE IS THE SAME ONE THE CONSUMPTION LEG GETS. There is no second
|
||||
parser, no second unit table and no second resolution rule -
|
||||
`parse_export_formula()` is `parse_price_formula()` with its `option`
|
||||
argument moved, so every refusal an operator can hit on this leg is a
|
||||
refusal that already had checks over it.
|
||||
"""
|
||||
return parse_export_formula(price_option(opts, EXPORT_FORMULA_OPTION))
|
||||
|
||||
|
||||
def export_price_at(opts: dict, index_eur_per_mwh: float) -> float:
|
||||
"""What the site is PAID for one exported kWh at one index value, EUR/kWh.
|
||||
|
||||
THE SEAM, AND ITS HONEST SCOPE. This is the one function a future
|
||||
export-aware strategy calls, and it has NO CALLER UNDER `app/` today -
|
||||
stated here rather than implied, because the add-on takes no decision on an
|
||||
injection price (see `export_price_eur_kwh()` above and `arbitrage.py`'s
|
||||
accounting-basis comment). What it buys now is that OPT-06 and anything
|
||||
after it inherit a shape the market fits, instead of a scalar that cannot
|
||||
hold its own factor, a negative offset, or a VAT rate different from the
|
||||
consumption leg's.
|
||||
|
||||
⚠️ THE FLAT PATH IS THE FALLTHROUGH AND IT IGNORES THE INDEX, which is
|
||||
CORRECT rather than a simplification: a site with a flat injection price is
|
||||
paid the same for every kWh whatever the market did, and that is what the
|
||||
scalar means. With `price_export_formula` empty - every site today - this
|
||||
returns exactly `export_price_eur_kwh(opts)` for every index.
|
||||
|
||||
⚠️ A NEGATIVE ANSWER IS A REAL ANSWER. Eneco's `0,1 x BELPEX-H - 1,188` is
|
||||
below zero for any BELPEX-H under 11,88 EUR/MWh, which is the middle of a
|
||||
sunny day and precisely when a PV household exports. Nothing here floors
|
||||
it: a caller that silently clamped it to zero would hide the hours in which
|
||||
exporting COSTS money, which is the one thing an injection price is for.
|
||||
"""
|
||||
spec = export_formula(opts)
|
||||
if spec is None:
|
||||
return export_price_eur_kwh(opts)
|
||||
return price_eur_kwh(spec, index_eur_per_mwh)
|
||||
|
||||
|
||||
def taxes_profile(opts: dict):
|
||||
"""The configured `price_taxes_profile` as a period table, or None.
|
||||
|
||||
TARIFF-17. Raises `static_tariff.ProfileInvalid` on a malformed table - and
|
||||
⚠️ THAT EXCEPTION IS NOT MEANT TO REACH A RUNNING SITE. validate.py parses
|
||||
the same option at STARTUP and refuses to start on it, for the reason
|
||||
`static_profile` is refused there: a table that only fails when a fetch
|
||||
happens is a site that stops pricing at 03:00, and §11.4 turns that into a
|
||||
quiet fallback nothing on the status page distinguishes from a healthy
|
||||
fixed-tariff day. `load_prices()` still handles it, loudly, because a guard
|
||||
whose only proof is "the other guard ran first" is not a guard.
|
||||
|
||||
⚠️ THE IMPORT IS LAZY AND THE CYCLE IS THE REASON. `static_tariff.py`
|
||||
imports this module at module scope; importing it back at module scope here
|
||||
would make which file is imported first decide whether the add-on starts.
|
||||
`sys.modules` makes the repeat cost a dict lookup.
|
||||
"""
|
||||
from .static_tariff import parse_taxes_profile
|
||||
return parse_taxes_profile(price_option(opts, "price_taxes_profile"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The plugin interface
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1286,23 +1465,68 @@ def _validate_raw(points, caps):
|
||||
seen = u
|
||||
|
||||
|
||||
def taxes_at(taxes, instant: datetime) -> float:
|
||||
"""The TAXES component at ONE instant. A scalar, or a period table.
|
||||
|
||||
TARIFF-17. THE DEFECT THIS EXISTS TO CLOSE, and it is a MISSING CAPABILITY
|
||||
rather than a wrong constant: `price_taxes_eur_kwh` is additive and
|
||||
identical every hour, so it cancels exactly out of `max - min` and cannot
|
||||
move `min(firm, key=...)`. It is not a distortion - it is provably inert on
|
||||
both quantities the optimiser reads. What was missing is anywhere for an
|
||||
HOUR-VARYING non-energy component to live, and the Walloon and Brussels
|
||||
DSOs bill one: ORES `dag 13,27 / nacht 7,39` c EUR/kWh, a 5,88 c gap whose
|
||||
cheap band coincides with the cheap spot hours. Flattening it does not
|
||||
shift the curve, it UNDERSTATES the spread and declines profitable cycles.
|
||||
See DOCS.md "The three buckets".
|
||||
|
||||
⚠️ DUCK-TYPED ON `price_at`, NOT ON `isinstance`. price.py must not import
|
||||
static_tariff.py - static_tariff.py imports THIS module, and a module-level
|
||||
cycle would make the import order load-bearing. `taxes_profile()` below is
|
||||
the one place the real parser is reached, and it imports it lazily.
|
||||
|
||||
⚠️ THE LOOKUP IS ON THE ~30 s PLANNING PATH through `assemble()`, so it
|
||||
stays a linear scan over the two-to-four periods a Belgian tariff has
|
||||
(`StaticProfile.at()`), with no parsing, no allocation and no I/O. A
|
||||
malformed table never reaches here: validate.py refuses it at STARTUP.
|
||||
"""
|
||||
at = getattr(taxes, "price_at", None)
|
||||
if at is None:
|
||||
return float(taxes)
|
||||
return float(at(instant))
|
||||
|
||||
|
||||
def assemble(caps: ProviderCapabilities, raw, markup: float,
|
||||
taxes: float) -> list:
|
||||
taxes) -> list:
|
||||
"""RawPoints -> canonical all-in PricePoints. THE ONLY PLACE MARKUP IS ADDED.
|
||||
|
||||
Adds exactly `caps.adds` - the complement of what the plugin declared its
|
||||
source already contains. A plugin that publishes an all-in figure gets
|
||||
nothing added; a raw EPEX figure gets both.
|
||||
|
||||
⚠️ `taxes` IS A SCALAR *OR* A PERIOD TABLE (TARIFF-17), AND IT IS EVALUATED
|
||||
AT EACH POINT'S OWN INSTANT. Hoisting the lookup out of the loop - reading
|
||||
it once and adding the same number to every point - is the mutation that
|
||||
collapses the whole feature back into the LEVEL bucket: the assembled
|
||||
series would still differ from the untaxed one by the right MAGNITUDE, and
|
||||
the cheapest hour would be wrong. `test_price.py` section 24 is the check
|
||||
that goes red for it, and it grades WHICH HOUR IS CHEAPEST rather than the
|
||||
spread, because a spread-magnitude assertion passes on the hoisted version.
|
||||
|
||||
⚠️ THE SCALAR PATH IS UNCHANGED, ARITHMETICALLY AND NOT ONLY IN INTENT.
|
||||
`taxes_at()` on a float returns that float at every instant, and
|
||||
`0.0 + markup` is exactly `markup` in IEEE 754 for every finite markup, so
|
||||
a site on a scalar gets the identical bytes it got before this landed.
|
||||
"""
|
||||
add = 0.0
|
||||
if Component.MARKUP in caps.adds:
|
||||
add += float(markup)
|
||||
if Component.TAXES in caps.adds:
|
||||
add += float(taxes)
|
||||
return [PricePoint(start=brussels(p.start),
|
||||
eur_per_kwh=round(p.eur_per_kwh + add, 6),
|
||||
provisional=bool(p.provisional))
|
||||
for p in raw]
|
||||
add_markup = float(markup) if Component.MARKUP in caps.adds else 0.0
|
||||
wants_taxes = Component.TAXES in caps.adds
|
||||
out = []
|
||||
for p in raw:
|
||||
local = brussels(p.start)
|
||||
add = add_markup + (taxes_at(taxes, local) if wants_taxes else 0.0)
|
||||
out.append(PricePoint(start=local,
|
||||
eur_per_kwh=round(p.eur_per_kwh + add, 6),
|
||||
provisional=bool(p.provisional)))
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1902,13 +2126,38 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
|
||||
caps = cls.capabilities()
|
||||
markup = float(price_option(opts, "price_markup_eur_kwh"))
|
||||
taxes = float(price_option(opts, "price_taxes_eur_kwh"))
|
||||
|
||||
# ⚠️ TARIFF-17: THE TABLE WINS OVER THE SCALAR, AND validate.py REFUSES THE
|
||||
# CASE WHERE BOTH SAY SOMETHING. Two writers of one quantity, one of which
|
||||
# silently loses, is the shape validate.py exists to refuse - so the
|
||||
# precedence here is only ever exercised on a configuration that has
|
||||
# already been accepted, and it is spelled out rather than left implicit.
|
||||
taxes_key = "price_taxes_eur_kwh"
|
||||
try:
|
||||
table = taxes_profile(opts)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Not silent, and not a traceback into the 30 s tick. validate.py
|
||||
# should have refused this at startup; if the add-on is running with it
|
||||
# anyway, the operator is told which key and what is wrong with it.
|
||||
_LOG.error("price_taxes_profile cannot be used (%s: %s), so this fetch "
|
||||
"has NO tax component to add - it is not falling back to "
|
||||
"price_taxes_eur_kwh, because a silently different tax "
|
||||
"basis is the defect this option exists to remove. Fix the "
|
||||
"table or clear it; validate.py refuses it at startup",
|
||||
type(e).__name__, e)
|
||||
return PriceRead.no_data(
|
||||
f"price_taxes_profile cannot be used: {e}")
|
||||
if table is not None:
|
||||
taxes = table
|
||||
taxes_key = "price_taxes_profile"
|
||||
else:
|
||||
taxes = float(price_option(opts, "price_taxes_eur_kwh"))
|
||||
|
||||
# ⚠️ Not a refusal, but not silent either. A plugin whose feed is already
|
||||
# all-in ignores these two numbers, and an operator who typed them has every
|
||||
# reason to believe they took effect.
|
||||
ignored = [n for c, n, v in ((Component.MARKUP, "price_markup_eur_kwh", markup),
|
||||
(Component.TAXES, "price_taxes_eur_kwh", taxes))
|
||||
(Component.TAXES, taxes_key, taxes))
|
||||
if c not in caps.adds and v]
|
||||
if ignored:
|
||||
_LOG.warning("%s publishes a figure that already contains %s, so the "
|
||||
|
||||
@@ -422,9 +422,19 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
_audit(out, published)
|
||||
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
|
||||
if carried:
|
||||
# ⚠️ THREE QUANTITIES, THREE NAMES (TARIFF-11). This line read
|
||||
# "%d of %d hours ... ; %d published" with `carried` (a count of
|
||||
# carried HOURS - the fill emits exactly one point per unpublished
|
||||
# hour) over `len(out)` (a count of POINTS, four per hour wherever the
|
||||
# feed printed) and `len(out) - carried` (POINTS again) under the same
|
||||
# noun. On the Belgian quarter-hourly feed that reads "33 of 141
|
||||
# hours" about a 48-hour horizon. The fill's unit is the HOUR and the
|
||||
# feed's is the POINT; nothing derived from one answers for the other.
|
||||
total_h = len({_utc_hour(p.start) for p in out})
|
||||
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
|
||||
"profile and flagged provisional; %d published",
|
||||
carried, len(out), len(out) - carried)
|
||||
"profile and flagged provisional; the feed published %d "
|
||||
"points over the other %d hours",
|
||||
carried, total_h, len(out) - carried, total_h - carried)
|
||||
|
||||
product = read.match(series=lambda r: r.product,
|
||||
no_data=lambda _r: yesterday.product)
|
||||
|
||||
@@ -222,6 +222,12 @@ _DAY_ALIASES = {
|
||||
|
||||
MINUTES_PER_DAY = 1440
|
||||
|
||||
# TARIFF-17. The option key whose value is a period table of the TAXES
|
||||
# component. One spelling, here, because price.py, validate.py and config.yaml
|
||||
# all name it and `provider_id()`'s note says what three spellings of one
|
||||
# decision cost.
|
||||
TAXES_PROFILE_OPTION = "price_taxes_profile"
|
||||
|
||||
# The mean that `cheap_now()` grades against is taken over this many local days.
|
||||
# Seven, so each weekday appears exactly once - see the module docstring. It is
|
||||
# a MODULE CONSTANT and not an option on purpose: CFG-03 already tracks three
|
||||
@@ -320,13 +326,37 @@ def _days(value, field: str) -> frozenset:
|
||||
return frozenset(out)
|
||||
|
||||
|
||||
def _price(value, field: str) -> float:
|
||||
"""The all-in EUR/kWh for a period, bounded exactly as price.py bounds a feed."""
|
||||
# What a period's `eur_per_kwh` MEANS, per option. The grammar is one grammar
|
||||
# (TARIFF-17); the quantity it carries is not, so the "you left this out"
|
||||
# sentence is the one thing the two tables do not share.
|
||||
_PERIOD_MEANS = {
|
||||
"static_profile":
|
||||
"an all-in eur_per_kwh (energy + supplier markup + taxes, §9.3) - the "
|
||||
"same basis the dynamic providers are assembled onto, so the two are "
|
||||
"comparable",
|
||||
}
|
||||
_TAXES_MEANS = (
|
||||
"the TAXES component alone in eur_per_kwh - the non-energy part of the "
|
||||
"bill for that band (distribution, levies, VAT), NOT an all-in price. The "
|
||||
"framework adds it to what the feed publishes, exactly as the scalar "
|
||||
"price_taxes_eur_kwh is added"
|
||||
)
|
||||
_PERIOD_MEANS[TAXES_PROFILE_OPTION] = _TAXES_MEANS
|
||||
|
||||
|
||||
def _price(value, field: str, means: str = None) -> float:
|
||||
"""One period's EUR/kWh, bounded exactly as price.py bounds a feed.
|
||||
|
||||
`means` names the quantity for the error sentence only. TARIFF-17: the same
|
||||
grammar carries an ALL-IN price under `static_profile` and a TAXES-ONLY
|
||||
component under `price_taxes_profile`, and an operator who confuses the two
|
||||
is out by an energy price - so the refusal says which one is wanted.
|
||||
"""
|
||||
if means is None:
|
||||
means = _PERIOD_MEANS["static_profile"]
|
||||
if value is None:
|
||||
raise ProfileInvalid(
|
||||
f"{field} is missing. Every period needs an all-in eur_per_kwh "
|
||||
f"(energy + supplier markup + taxes, §9.3) - the same basis the "
|
||||
f"dynamic providers are assembled onto, so the two are comparable.")
|
||||
f"{field} is missing. Every period needs {means}.")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
raise ProfileInvalid(f"{field} is {value!r}, which is not a number")
|
||||
try:
|
||||
@@ -383,14 +413,19 @@ class Period:
|
||||
class StaticProfile:
|
||||
"""A validated two-or-more-period tariff. Immutable, pure, I/O-free."""
|
||||
|
||||
def __init__(self, periods):
|
||||
def __init__(self, periods, *, option: str = "static_profile"):
|
||||
self.periods = tuple(periods)
|
||||
# ⚠️ TARIFF-17: the OPTION NAME travels with the profile so that every
|
||||
# refusal names the key the operator actually typed. It is a default
|
||||
# argument on purpose - every pre-existing call site and every
|
||||
# pre-existing sentence is byte-identical without it.
|
||||
self.option = option
|
||||
if not self.periods:
|
||||
raise ProfileInvalid(
|
||||
"static_profile has no periods. A profile with no periods is "
|
||||
"not a fixed tariff, it is an absent one - leave the option "
|
||||
"empty to say that on purpose (§11.4 then applies).")
|
||||
_check_tiling(self.periods)
|
||||
f"{option} has no periods. A profile with no periods is "
|
||||
f"not a fixed tariff, it is an absent one - leave the option "
|
||||
f"empty to say that on purpose (§11.4 then applies).")
|
||||
_check_tiling(self.periods, option=option)
|
||||
|
||||
# -- what kind of tariff this is, DERIVED --------------------------------
|
||||
@property
|
||||
@@ -477,7 +512,7 @@ class StaticProfile:
|
||||
u += timedelta(hours=1)
|
||||
if not out:
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.series({since.isoformat()}, "
|
||||
f"{self.option}.series({since.isoformat()}, "
|
||||
f"{until.isoformat()}) spans no whole hour. An empty series is "
|
||||
f"not constructible downstream on purpose (price.py trap 3).")
|
||||
return out
|
||||
@@ -489,7 +524,7 @@ class StaticProfile:
|
||||
return f"{kind}: " + "; ".join(p.words for p in self.periods)
|
||||
|
||||
|
||||
def _check_tiling(periods) -> None:
|
||||
def _check_tiling(periods, *, option: str = "static_profile") -> None:
|
||||
"""Every day type must be covered by EXACTLY ONE period, every minute.
|
||||
|
||||
⚠️ REFUSES BOTH DIRECTIONS. A gap means an hour with no price, and the only
|
||||
@@ -506,22 +541,22 @@ def _check_tiling(periods) -> None:
|
||||
if n > 1 and over_from is None:
|
||||
over_from = (minute, n)
|
||||
if n != 0 and gap_from is not None:
|
||||
_raise_gap(weekday, gap_from, minute)
|
||||
_raise_gap(weekday, gap_from, minute, option)
|
||||
if n <= 1 and over_from is not None:
|
||||
_raise_overlap(weekday, over_from, minute, periods)
|
||||
_raise_overlap(weekday, over_from, minute, periods, option)
|
||||
if gap_from is not None:
|
||||
_raise_gap(weekday, gap_from, MINUTES_PER_DAY)
|
||||
_raise_gap(weekday, gap_from, MINUTES_PER_DAY, option)
|
||||
if over_from is not None:
|
||||
_raise_overlap(weekday, over_from, MINUTES_PER_DAY, periods)
|
||||
_raise_overlap(weekday, over_from, MINUTES_PER_DAY, periods, option)
|
||||
|
||||
|
||||
def _hhmm(m):
|
||||
return f"{m // 60:02d}:{m % 60:02d}"
|
||||
|
||||
|
||||
def _raise_gap(weekday, start, end):
|
||||
def _raise_gap(weekday, start, end, option="static_profile"):
|
||||
raise ProfileInvalid(
|
||||
f"static_profile leaves {DAY_NAMES[weekday]} "
|
||||
f"{option} leaves {DAY_NAMES[weekday]} "
|
||||
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
|
||||
f"price. Refused rather than defaulted: an invented price is one an "
|
||||
f"optimiser would trade on, and §11.4 already says what a missing price "
|
||||
@@ -529,19 +564,28 @@ def _raise_gap(weekday, start, end):
|
||||
f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.")
|
||||
|
||||
|
||||
def _raise_overlap(weekday, over, end, periods):
|
||||
def _raise_overlap(weekday, over, end, periods, option="static_profile"):
|
||||
start, n = over
|
||||
names = [p.name for p in periods if p.covers(weekday, start)]
|
||||
raise ProfileInvalid(
|
||||
f"static_profile gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
|
||||
f"{option} gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
|
||||
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for "
|
||||
f"one hour means one of them silently wins on list order. Narrow one of "
|
||||
f"the windows or split the day selector.")
|
||||
|
||||
|
||||
def parse_static_profile(value):
|
||||
def parse_static_profile(value, *, option: str = "static_profile",
|
||||
means: str = None):
|
||||
"""`static_profile` -> a `StaticProfile`, or None when unset.
|
||||
|
||||
⚠️ TARIFF-17 MADE `option`/`means` PARAMETERS RATHER THAN FORKING THIS
|
||||
FUNCTION, and that is the whole of the reuse. A second period parser would
|
||||
be a second tiling rule, a second day-type rule and a second set of DST
|
||||
bugs, diverging first on the 23- and 25-hour days where nobody looks. The
|
||||
two arguments move only the OPTION NAME and the "what this number is"
|
||||
clause through the refusals; the grammar, the tiling check and the wrapping
|
||||
rule are one implementation with one set of checks over it.
|
||||
|
||||
Accepts what the option can actually arrive as:
|
||||
* `None` / `""` / `"null"` - unset. §14 ships `static_profile: null`.
|
||||
* a JSON string - what config.yaml's flat `str?` option
|
||||
@@ -555,6 +599,8 @@ def parse_static_profile(value):
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if means is None:
|
||||
means = _PERIOD_MEANS.get(option, _PERIOD_MEANS["static_profile"])
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text or text.lower() in ("null", "none", "off"):
|
||||
@@ -563,7 +609,7 @@ def parse_static_profile(value):
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise ProfileInvalid(
|
||||
f"static_profile is not valid JSON ({e}). It is a JSON object "
|
||||
f"{option} is not valid JSON ({e}). It is a JSON object "
|
||||
f"with a \"periods\" list, on one line, e.g.\n"
|
||||
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
|
||||
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
|
||||
@@ -573,7 +619,7 @@ def parse_static_profile(value):
|
||||
tz = str(value.get("timezone", "") or "").strip().lower()
|
||||
if tz not in SUPPORTED_TZ:
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.timezone is {value.get('timezone')!r}. Only "
|
||||
f"{option}.timezone is {value.get('timezone')!r}. Only "
|
||||
f"Europe/Brussels is supported: the shipped image carries no tz "
|
||||
f"database (see app/price.py), so any other zone could not be "
|
||||
f"honoured and would be silently treated as Brussels - which is "
|
||||
@@ -581,24 +627,24 @@ def parse_static_profile(value):
|
||||
rows = value.get("periods")
|
||||
if rows is None:
|
||||
raise ProfileInvalid(
|
||||
"static_profile has no \"periods\" list. Even a fully-fixed "
|
||||
"tariff is written as one period covering 00:00-24:00 all days "
|
||||
"- one shape for both products, so nothing downstream branches "
|
||||
"on which kind of site this is.")
|
||||
f"{option} has no \"periods\" list. Even a fully-fixed "
|
||||
f"tariff is written as one period covering 00:00-24:00 all days "
|
||||
f"- one shape for both products, so nothing downstream branches "
|
||||
f"on which kind of site this is.")
|
||||
else:
|
||||
rows = value
|
||||
|
||||
if not isinstance(rows, (list, tuple)):
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.periods is {type(rows).__name__}, not a list")
|
||||
f"{option}.periods is {type(rows).__name__}, not a list")
|
||||
|
||||
periods = []
|
||||
for i, row in enumerate(rows, start=1):
|
||||
if not isinstance(row, dict):
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.periods[{i}] is {type(row).__name__}, not an "
|
||||
f"{option}.periods[{i}] is {type(row).__name__}, not an "
|
||||
f"object with from/to/days/eur_per_kwh")
|
||||
where = f"static_profile.periods[{i}]"
|
||||
where = f"{option}.periods[{i}]"
|
||||
name = str(row.get("name", "") or f"period{i}").strip() or f"period{i}"
|
||||
start = _minutes(row.get("from", row.get("start")), f"{where}.from")
|
||||
end = _minutes(row.get("to", row.get("end")), f"{where}.to")
|
||||
@@ -617,8 +663,47 @@ def parse_static_profile(value):
|
||||
end_min=end,
|
||||
days=_days(row.get("days"), f"{where}.days"),
|
||||
eur_per_kwh=_price(row.get("eur_per_kwh"),
|
||||
f"{where}.eur_per_kwh")))
|
||||
return StaticProfile(periods)
|
||||
f"{where}.eur_per_kwh",
|
||||
means)))
|
||||
return StaticProfile(periods, option=option)
|
||||
|
||||
|
||||
def parse_taxes_profile(value):
|
||||
"""`price_taxes_profile` -> a `StaticProfile` of TAX values, or None.
|
||||
|
||||
TARIFF-17. THE DEFECT THIS EXISTS TO CLOSE: `price.py` modelled the whole
|
||||
non-energy part of a Belgian bill as ONE scalar. In Flanders that is
|
||||
correct - Fluvius distribution is a single rate and its tariff card offers
|
||||
only `Normaal | Uitsl. nacht`, where *exclusive night* is a separate
|
||||
physical circuit rather than a night register. The Walloon and Brussels
|
||||
DSOs are on the same card with four columns - `Enkelvoudig | Dag | Nacht |
|
||||
Uitsl. nacht` - and the day/night gap is real money:
|
||||
|
||||
ORES dag 13,27 nacht 7,39 -> 5,88 c EUR/kWh
|
||||
AIESH dag 15,17 nacht 8,21 -> 6,96
|
||||
Tecteo RESA dag 12,20 nacht 7,01 -> 5,19
|
||||
Sibelga (BXL) dag 9,39 nacht 7,10 -> 2,29
|
||||
ORES IMPACT dag 16,578 nacht 5,090 -> 11,49 (2026)
|
||||
|
||||
⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO.
|
||||
The cheap network hours are the cheap spot hours, so flattening them does
|
||||
not merely shift the curve - it SYSTEMATICALLY UNDERSTATES the true spread
|
||||
and makes the add-on decline cycles that are genuinely profitable. See
|
||||
DOCS.md "The three buckets" for why the flat scalar could never have done
|
||||
this: an additive constant, identical every hour, cancels exactly in
|
||||
`max - min` and cannot move `min(firm, key=...)` either.
|
||||
|
||||
⚠️ THE VALUE IS THE TAXES COMPONENT, NOT AN ALL-IN PRICE. `static_profile`
|
||||
uses the same grammar for an all-in figure; the two are one parser and two
|
||||
meanings, and `_price()`'s refusal names which one it wanted.
|
||||
|
||||
⚠️ NOT PARSED ON THE FETCH PATH. validate.py refuses a malformed table at
|
||||
STARTUP, by the rule `price.py` trap 3 states for `static_profile`: a table
|
||||
that cannot be parsed at 03:00 is a site that stops pricing at 03:00, and
|
||||
§11.4 makes that quiet.
|
||||
"""
|
||||
return parse_static_profile(value, option=TAXES_PROFILE_OPTION,
|
||||
means=_TAXES_MEANS)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -771,6 +856,63 @@ class TariffPlan:
|
||||
series=lambda r: any(p.provisional for p in r.points),
|
||||
no_data=lambda _r: True)
|
||||
|
||||
# -- the plan's SHAPE, in the two units it actually has (TARIFF-11) ------
|
||||
# ⚠️ POINTS AND HOURS ARE TWO NUMBERS AND THIS PLAN HAD ONLY EVER
|
||||
# REPORTED ONE OF THEM, UNDER THE OTHER'S NAME. `words` read
|
||||
# `f"... {len(self.read.points)} h"`, which counts POINTS: on the
|
||||
# quarter-hourly Belgian feed (15-minute MTU, 2025-10-01) a 33-hour plan
|
||||
# logged `dynamic_dayahead from generic_dayahead, 132 h`. It is the same
|
||||
# conflation TARIFF-14 removed from `at()` and FCST-04 removed from the
|
||||
# *Price history* row, left standing in the prose. ⚠️ AND THE ERROR IS
|
||||
# NOT A FLAT 4x: a 40 h soak measured 1.79x .. 5.33x, because §8.3's
|
||||
# carried points are HOURLY while the firm ones are quarter-hourly, so
|
||||
# the ratio moves with the firm/carried mix as the day goes on. Nothing
|
||||
# derived from ONE of these numbers can answer for the other.
|
||||
|
||||
@property
|
||||
def n_points(self) -> int:
|
||||
"""How many points the series carries. A `no_data` read carries none."""
|
||||
return self.read.match(series=lambda r: len(r.points),
|
||||
no_data=lambda _r: 0)
|
||||
|
||||
@property
|
||||
def hours(self) -> int:
|
||||
"""How many distinct WHOLE CLOCK HOURS those points touch.
|
||||
|
||||
Same rule as `price_history._held()`, deliberately - the *Tariff* and
|
||||
*Price history* rows describe the same feed and an operator reading
|
||||
`132 points over 33 h` beside `96 points over 24 h` must be able to
|
||||
compare them. A point is attributed to the UTC hour its own start
|
||||
falls in; hours the feed never printed are simply not in the set, so
|
||||
this counts what is PRICED rather than what was asked for.
|
||||
"""
|
||||
return self.read.match(series=lambda r: _whole_hours(r.points),
|
||||
no_data=lambda _r: 0)
|
||||
|
||||
@property
|
||||
def n_firm(self) -> int:
|
||||
"""Points carrying a published price for their own interval (§8.3)."""
|
||||
return len(self.firm_points())
|
||||
|
||||
@property
|
||||
def n_carried(self) -> int:
|
||||
"""Points filled from yesterday's profile, or from §11.4's fallback."""
|
||||
return self.n_points - self.n_firm
|
||||
|
||||
@property
|
||||
def spacings_s(self) -> tuple:
|
||||
"""The distinct gaps between consecutive points, ascending, in seconds.
|
||||
|
||||
⚠️ MIXED IS THE NORMAL STEADY STATE, NOT AN EDGE CASE. FCST-04's
|
||||
carry-forward emits HOURLY carried points beside QUARTER-HOURLY firm
|
||||
ones and the soak measured `[900, 3600]` in 70 % of samples - so a
|
||||
surface reporting one global spacing would be wrong about the usual
|
||||
case. `price._cover_table()` is where that rule is enforced; this
|
||||
only reports it.
|
||||
"""
|
||||
return self.read.match(
|
||||
series=lambda r: _spacings(r.points), no_data=lambda _r: ())
|
||||
|
||||
def price_at(self, instant: datetime) -> float:
|
||||
"""EUR/kWh now. Raises `Absence` when there is no price - never 0.0."""
|
||||
return self.read.match(
|
||||
@@ -926,20 +1068,30 @@ class TariffPlan:
|
||||
pts = self.read.points
|
||||
firm = tuple(p for p in pts if not p.provisional)
|
||||
if len(firm) < 2:
|
||||
# ⚠️ POINTS, AND THE HOUR FIGURE BESIDE IT RATHER THAN INSTEAD OF
|
||||
# IT (TARIFF-11). This sentence read "only 1 of 96 hours carry a
|
||||
# published price" about 96 quarter-hourly points, which are 24
|
||||
# hours - the point<->hour conflation TARIFF-14 removed from
|
||||
# `at()`, still standing here, and this one reaches an OPERATOR
|
||||
# through main.py's *Arbitrage* row and not only the log.
|
||||
return Arbitrage.refuse(
|
||||
f"§8.3: only {len(firm)} of {len(pts)} hours carry a published "
|
||||
f"price - the rest are provisional (origin {self.origin!r}), "
|
||||
f"and no irreversible action may be taken on a provisional "
|
||||
f"price. A buy hour and a sell hour must BOTH be firm before a "
|
||||
f"cycle can be committed. Load shifting, self-consumption and "
|
||||
f"peak shaving continue")
|
||||
f"§8.3: only {len(firm)} of {len(pts)} points carry a "
|
||||
f"published price - {_whole_hours(firm)} of "
|
||||
f"{_whole_hours(pts)} hours - and the rest are provisional "
|
||||
f"(origin {self.origin!r}), so no irreversible action may be "
|
||||
f"taken on them. A buy interval and a sell interval must BOTH "
|
||||
f"be firm before a cycle can be committed. Load shifting, "
|
||||
f"self-consumption and peak shaving continue")
|
||||
|
||||
spread = round(max(p.eur_per_kwh for p in firm)
|
||||
- min(p.eur_per_kwh for p in firm), 6)
|
||||
if spread == 0.0:
|
||||
# ⚠️ POINTS AND HOURS, SEPARATELY, for the §8.3 rung's reason -
|
||||
# this sentence counted points and called them "firm hours" too.
|
||||
return Arbitrage.refuse(
|
||||
f"the tariff is flat - every one of the {len(firm)} firm hours "
|
||||
f"costs {firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
|
||||
f"the tariff is flat - every one of the {len(firm)} firm "
|
||||
f"points, spanning {_whole_hours(firm)} hours, costs "
|
||||
f"{firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
|
||||
f"selling high is arithmetically impossible. §9.2: a fully "
|
||||
f"fixed product is self-consumption only")
|
||||
|
||||
@@ -969,15 +1121,77 @@ class TariffPlan:
|
||||
|
||||
@property
|
||||
def words(self) -> str:
|
||||
"""The plan's whole shape in one sentence - LOG *AND* STATUS PAGE.
|
||||
|
||||
⚠️ THIS STRING IS OPERATOR-FACING SINCE TARIFF-11, which is a reason
|
||||
for its wording to matter MORE rather than less. It used to reach only
|
||||
`_LOG.info("tariff refreshed: %s")`; main.py's *Tariff* row now carries
|
||||
it, age-stamped, so every number in it is read by somebody who cannot
|
||||
see the object.
|
||||
|
||||
⚠️ EVERY QUANTITY IS NAMED IN ITS OWN UNIT. `{n} points over {h} h` is
|
||||
FCST-04's *Price history* wording on purpose - the two rows describe
|
||||
the same feed and have to be comparable - and the firm/carried split
|
||||
and the spacing are here because they were the two facts the soak
|
||||
found an operator could not learn at all: the plan was `provisional`
|
||||
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
|
||||
surface said either.
|
||||
"""
|
||||
if not self.has_prices:
|
||||
return f"no tariff - {self.read.reason}"
|
||||
tag = {"provider": self.read.provider, "static": "static_profile",
|
||||
"fallback": "static_profile (feed down)"}.get(self.origin,
|
||||
self.origin)
|
||||
return (f"{self.product.value} from {tag}, {len(self.read.points)} h"
|
||||
gaps = self.spacings_s
|
||||
return (f"{self.product.value} from {tag}, "
|
||||
f"{self.n_points} points over {self.hours} h, "
|
||||
f"{self.n_firm} firm / {self.n_carried} carried"
|
||||
+ (f", {'+'.join(str(g) for g in gaps)} s spacing"
|
||||
if gaps else "")
|
||||
+ (", provisional" if self.provisional else ""))
|
||||
|
||||
|
||||
def _whole_hours(points) -> int:
|
||||
"""How many distinct whole CLOCK HOURS a point list touches. Never raises.
|
||||
|
||||
THE DEFECT THIS PREVENTS (TARIFF-11). Three sentences in this module
|
||||
reported `len(points)` under the name "hours": `TariffPlan.words`, §8.3's
|
||||
firm-pair refusal and the flat-tariff refusal. On the hourly series that
|
||||
was the only resolution any fixture carried before TARIFF-08 the two
|
||||
numbers are identical, which is why it shipped; on the Belgian
|
||||
quarter-hourly feed (15-minute MTU, 2025-10-01) `words` read
|
||||
`dynamic_dayahead from generic_dayahead, 132 h` for 33 hours and the
|
||||
refusal read `only 1 of 96 hours` for 24.
|
||||
|
||||
⚠️ IT IS NOT `n / 4`, AND A CONSTANT FACTOR IS THE WRONG FIX. §8.3's
|
||||
carry-forward emits ONE HOURLY point per unpublished hour beside the
|
||||
feed's four-per-hour firm ones, so the measured overstatement over a
|
||||
40-hour soak ranged 1.79x .. 5.33x as the mix moved. The hour count has
|
||||
to be COUNTED.
|
||||
|
||||
⚠️ IT COUNTS PRICED HOURS, NOT THE HORIZON. FCST-04 leaves an hour out
|
||||
of the series when neither the feed nor yesterday can price it, so this
|
||||
is at most the horizon and is smaller whenever the plan has holes - which
|
||||
is the honest direction for a figure an operator reads as "how far ahead
|
||||
do I have prices".
|
||||
"""
|
||||
return len({p.start.astimezone(timezone.utc)
|
||||
.replace(minute=0, second=0, microsecond=0) for p in points})
|
||||
|
||||
|
||||
def _spacings(points) -> tuple:
|
||||
"""Distinct gaps between consecutive points, ascending, in whole seconds.
|
||||
|
||||
Sorted by START, not by list order: `price._cover_table()` establishes
|
||||
that order for `at()` rather than assuming it, and a surface that reported
|
||||
the gaps of an unsorted list would print negative spacings about a series
|
||||
that answers correctly.
|
||||
"""
|
||||
starts = sorted(p.start.astimezone(timezone.utc) for p in points)
|
||||
return tuple(sorted({int((b - a).total_seconds())
|
||||
for a, b in zip(starts, starts[1:])}))
|
||||
|
||||
|
||||
def _absent(reason):
|
||||
raise Absence(
|
||||
f"this plan observed no prices ({reason}). A number invented here is "
|
||||
|
||||
@@ -132,6 +132,18 @@ OBSERVED = "observed" # the store answered and there is a peak
|
||||
NO_RECORD = "no_record" # the store answered and this month has none yet
|
||||
NOT_OBSERVED = "not_observed" # the store did not answer. NOT a zero peak.
|
||||
|
||||
# TARIFF-13. What the COVERAGE figure reads when the store cannot count.
|
||||
#
|
||||
# ⚠️ NOT 0, AND NOT `None`. This is the reading-state argument of TARIFF-01
|
||||
# applied to the number printed BESIDE the peak rather than to the peak. A
|
||||
# coverage figure of 0 says "this month has been measured over no quarters at
|
||||
# all", which is the strongest possible claim that the peak next to it means
|
||||
# nothing - and `f"{None} quarter(s) banked"` renders "None quarter(s) banked",
|
||||
# which an operator reads as that same zero. The one honest answer to "how many
|
||||
# quarters have I banked" when the table will not answer is that it is not
|
||||
# known, and it has to SAY so in the sentence the operator reads.
|
||||
COUNT_UNREADABLE = "an unreadable number of"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# local time, without a hard dependency on the tz database
|
||||
@@ -388,6 +400,23 @@ class PeakStore:
|
||||
" per_phase_json=excluded.per_phase_json"
|
||||
" WHERE excluded.offtake_avg_w > quarter_blocks.offtake_avg_w",
|
||||
(start, month, w, pp_json))
|
||||
# TARIFF-13. THE TIE RULE, DECIDED RATHER THAN EMERGENT:
|
||||
# at EQUAL wattage the EARLIEST quarter keeps the attribution.
|
||||
# See DECISIONS/TARIFF-13-the-coverage-of-a-peak.md §2.
|
||||
#
|
||||
# `peak_w` itself is still only ever RAISED - the `>` term is
|
||||
# untouched and no tie can move the billed number - so SAFETY-07
|
||||
# reads exactly the watt figure it read before. The second term
|
||||
# moves only the TIMESTAMP the status page attributes it to.
|
||||
#
|
||||
# ⚠️ WITHOUT THE SECOND TERM THE TWO READERS DISAGREE, measured:
|
||||
# `>` alone keeps whichever tied block was WRITTEN FIRST, while
|
||||
# `recompute_month()`'s `ORDER BY offtake_avg_w DESC,
|
||||
# quarter_start ASC` keeps the EARLIEST. Those coincide only
|
||||
# while blocks arrive in time order. Fed four equal 365 W blocks
|
||||
# newest-first, `monthly_peak` answered 09:45 and the recompute
|
||||
# answered 09:00 - a divergence in the derived record part 8 of
|
||||
# test_tariff.py exists to prove cannot happen.
|
||||
self._db.execute(
|
||||
"INSERT INTO monthly_peak(month, peak_w, quarter_start, per_phase_json)"
|
||||
" VALUES(?,?,?,?)"
|
||||
@@ -395,7 +424,9 @@ class PeakStore:
|
||||
" peak_w=excluded.peak_w,"
|
||||
" quarter_start=excluded.quarter_start,"
|
||||
" per_phase_json=excluded.per_phase_json"
|
||||
" WHERE excluded.peak_w > monthly_peak.peak_w",
|
||||
" WHERE excluded.peak_w > monthly_peak.peak_w"
|
||||
" OR (excluded.peak_w = monthly_peak.peak_w"
|
||||
" AND excluded.quarter_start < monthly_peak.quarter_start)",
|
||||
(month, w, start, pp_json))
|
||||
return True
|
||||
except sqlite3.Error as err:
|
||||
@@ -449,6 +480,49 @@ class PeakStore:
|
||||
quarter_start=datetime.fromisoformat(row[1]),
|
||||
per_phase_w=pp)
|
||||
|
||||
def banked_count(self, month: str) -> int | None:
|
||||
"""How many quarters this month has banked. `None` when unknowable.
|
||||
|
||||
TARIFF-13. THE DEFECT THIS PREVENTS: the status page printed a coverage
|
||||
figure held in PROCESS MEMORY - `PeakTracker.recorded`, incremented once
|
||||
per block banked by THIS process - beside a peak read from SQLite. The
|
||||
peak outlives a restart and the counter does not, so a driven restart
|
||||
mid-month took a real reading of
|
||||
|
||||
2026-09: peak 365 W ... ; 120 quarter(s) banked
|
||||
|
||||
to the same peak, the same attributed quarter and `0 quarter(s) banked`,
|
||||
on both instances independently. The count is what tells an operator how
|
||||
much of the billing month the number beside it actually covers: 365 W
|
||||
over 120 quarters and 365 W over 0 quarters are different claims about
|
||||
the same watt figure, and after a restart the page made the weaker claim
|
||||
while displaying the stronger one's value. Reading the count from the
|
||||
same durable tier the peak comes from is what makes the two agree.
|
||||
|
||||
⚠️ `None`, NOT 0, and NOT `len(blocks_in(month))`. `blocks_in()` returns
|
||||
`[]` on a read error - safe there only because no caller reads its
|
||||
emptiness as a peak (§ its own docstring) - so counting its length would
|
||||
rebuild the disarming zero one call further out. This asks the database
|
||||
for a COUNT and refuses to answer when the database refuses.
|
||||
|
||||
⚠️ Counts `quarter_blocks`, the tier with one row per closed block, and
|
||||
NOT `monthly_peak`, which has one row per MONTH and would answer 1 for
|
||||
every month that has ever been measured. The two tables are proven equal
|
||||
on the PEAK by `recompute_month()`; they were never equal on the COUNT
|
||||
and are not meant to be.
|
||||
"""
|
||||
if self._db is None:
|
||||
return None
|
||||
try:
|
||||
row = self._db.execute(
|
||||
"SELECT COUNT(*) FROM quarter_blocks WHERE month=?",
|
||||
(month,)).fetchone()
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
_LOG.error("could not count quarters for %s (%s)", month, err)
|
||||
return None
|
||||
return int(row[0])
|
||||
|
||||
def blocks_in(self, month: str) -> list:
|
||||
"""(quarter_start, offtake_avg_w) for a month, oldest first. [] on error
|
||||
is safe here ONLY because no caller reads emptiness as a peak - AC 3's
|
||||
@@ -539,11 +613,59 @@ class PeakTracker:
|
||||
self.cap_w = float(cap_w)
|
||||
self.policy = policy
|
||||
self.checkpoint_s = float(checkpoint_s)
|
||||
self.recorded = 0
|
||||
self.restore_state = "not attempted"
|
||||
self._seen: set = set()
|
||||
self._last_ckpt = 0.0
|
||||
|
||||
# -- coverage -----------------------------------------------------------
|
||||
#
|
||||
# TARIFF-13. `recorded` USED TO BE AN `int` ATTRIBUTE ON THIS OBJECT,
|
||||
# starting at 0 and incremented in `sync()`. It is a property over the store
|
||||
# now, and the three things that changes are all defects it was carrying:
|
||||
#
|
||||
# * it RESET TO 0 ON EVERY RESTART, beside a peak that did not - see
|
||||
# `PeakStore.banked_count()` for the driven reading;
|
||||
# * it was NOT SCOPED TO A MONTH. It counted every block this process had
|
||||
# banked, so on 1 October the row still read September's tally beside
|
||||
# October's "no completed quarter recorded yet";
|
||||
# * it MISSED the block `restore()` banks. A stale block closed from the
|
||||
# checkpoint goes into `quarter_blocks` and never went through `sync()`,
|
||||
# so the counter never saw it. Counting the table cannot miss it.
|
||||
#
|
||||
# ⚠️ REMOVING THE COUNTER DOES NOT REMOVE THE DEDUPE. `_seen` is still what
|
||||
# stops `cycle()`'s once-a-second `sync()` re-banking a closed quarter that
|
||||
# `P1Ingest.blocks` keeps for a DAY, and `sync()`'s RETURN value is still
|
||||
# the count of blocks newly banked on that call. What changed is only where
|
||||
# the STATUS PAGE's figure is read from. The check that grades the dedupe
|
||||
# must therefore grade `sync()`'s return, not this property, which now
|
||||
# answers 1 from the database however many times the block was offered.
|
||||
|
||||
def recorded_in(self, when: datetime | None = None):
|
||||
"""Quarters banked in `when`'s billing month - `int`, or the sentinel.
|
||||
|
||||
⚠️ The return type is `int | str` on purpose, and the `str` is
|
||||
`COUNT_UNREADABLE`. A caller that formats this into a sentence gets a
|
||||
true sentence in both cases; a caller that wants to do arithmetic on it
|
||||
gets a TypeError, which is loud. That is the same trade `PeakReading`
|
||||
makes by having no `peak_w_or(default)`.
|
||||
"""
|
||||
n = self.store.banked_count(month_key(when or datetime.now(timezone.utc)))
|
||||
return COUNT_UNREADABLE if n is None else n
|
||||
|
||||
@property
|
||||
def recorded(self):
|
||||
"""Quarters banked in the month being billed TODAY.
|
||||
|
||||
⚠️ This is the live status page's reading, where the peak beside it is
|
||||
also read for `datetime.now()`. `checks(when=X)` for an X in another
|
||||
month is a test affordance (TEST-17); the row scoped that way gets this
|
||||
month's count beside that month's peak, which is strictly better than
|
||||
the process-wide counter it replaces but is still a mismatch.
|
||||
`recorded_in(when)` is the scoped form the row should read once
|
||||
`main.py`'s status rows are free to change - TARIFF-11 holds them.
|
||||
"""
|
||||
return self.recorded_in()
|
||||
|
||||
# -- startup ------------------------------------------------------------
|
||||
def restore(self, averager, now: datetime | None = None) -> str:
|
||||
"""Put the open block back. Returns which of the three cases happened."""
|
||||
@@ -600,7 +722,6 @@ class PeakTracker:
|
||||
continue
|
||||
if self.store.record_block(block, self.policy):
|
||||
self._seen.add(key)
|
||||
self.recorded += 1
|
||||
n += 1
|
||||
if n:
|
||||
# A block just closed, so the checkpoint's block is gone: clearing it
|
||||
|
||||
@@ -325,13 +325,18 @@ import logging
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .custom_formula import (
|
||||
FormulaInvalid, implausible_all_in, parse_price_formula, stale_card)
|
||||
EXPORT_FORMULA_OPTION, FormulaInvalid, implausible_all_in,
|
||||
parse_export_formula, parse_price_formula, stale_card)
|
||||
from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
from .price import (
|
||||
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
|
||||
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
from .static_tariff import (
|
||||
ProfileInvalid, TAXES_PROFILE_OPTION, parse_static_profile,
|
||||
parse_taxes_profile)
|
||||
from .watchdog import SIGN_OPTION_DEFAULTS
|
||||
|
||||
_LOG = logging.getLogger("goodwe.config")
|
||||
@@ -1420,6 +1425,101 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
"for this product. Peak shaving, reserve management and the "
|
||||
"maintenance cycle are unaffected.", profile.prices[0])
|
||||
|
||||
# ⚠️ TARIFF-17: THE TAXES PERIOD TABLE, PARSED AT STARTUP RATHER THAN AT
|
||||
# 03:00, FOR `static_profile`'s REASON ONE OPTION OVER. This table is read
|
||||
# by `price.assemble()` on the ~30 s planning path; a table that only fails
|
||||
# when a fetch happens is a site that stops pricing mid-afternoon, and
|
||||
# §11.4 turns that into a quiet fallback that looks exactly like a healthy
|
||||
# fixed-tariff day. So the parse happens here, once, where a refusal is a
|
||||
# banner naming the key.
|
||||
raw_taxes_profile = opts.get(TAXES_PROFILE_OPTION, "")
|
||||
taxes_table = None
|
||||
if str(raw_taxes_profile or "").strip() \
|
||||
or isinstance(raw_taxes_profile, (list, dict)):
|
||||
try:
|
||||
taxes_table = parse_taxes_profile(raw_taxes_profile)
|
||||
except ProfileInvalid as e:
|
||||
problems.append(f"{TAXES_PROFILE_OPTION} cannot be used: {e}")
|
||||
|
||||
if taxes_table is not None:
|
||||
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY LOSES - the
|
||||
# exact case (a) this module exists for, and the same refusal shape as
|
||||
# the price-entity/static_profile pair above. The table decides when it
|
||||
# is set, so a non-default scalar left beside it is a number the
|
||||
# operator typed that changes nothing, on the quantity §11.4 makes
|
||||
# hardest to notice. It is compared against the SHIPPED DEFAULTS rather
|
||||
# than against presence, because the Supervisor writes every schema key
|
||||
# into the options file whether the operator touched it or not - so
|
||||
# "the operator did not type this" has to be read off the VALUE.
|
||||
#
|
||||
# ⚠️ TARIFF-18: "UNTOUCHED" IS A SET, NOT ONE NUMBER, AND THE DEFECT
|
||||
# THAT MAKES IT ONE IS AN UPGRADE THAT STOPS A BATTERY CONTROLLER
|
||||
# STARTING. The Supervisor sets `App.options = deepmerge(config.yaml
|
||||
# defaults, stored options)`, so a saved blob carries every schema key
|
||||
# whether the operator typed it or not - the reference install's export
|
||||
# is 70 of 71. When TARIFF-18 moved this default 0.06 -> 0.11736, every
|
||||
# such blob went from "equal to the shipped default" to "not equal to
|
||||
# it" without anybody editing anything, and a site that also carries a
|
||||
# taxes table would have met `SystemExit(78)` on the upgrade. The
|
||||
# sentence below is about a number the OPERATOR TYPED; a superseded
|
||||
# shipped default is not one, so it still reads as untouched. The set
|
||||
# is in price.py beside the default it supersedes, because the two only
|
||||
# ever move together.
|
||||
scalar = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
||||
untouched = (shipped,) + tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
superseded = ", ".join(
|
||||
str(u) for u in SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
if scalar is not None \
|
||||
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
|
||||
problems.append(
|
||||
f"{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is "
|
||||
f"{scalar} rather than its shipped {shipped}. THE TABLE WINS: "
|
||||
f"the add-on reads the per-period figures and never looks at "
|
||||
f"the scalar, so the number you typed there would decide "
|
||||
f"nothing and nothing would say so. Set price_taxes_eur_kwh "
|
||||
f"back to {shipped}, or clear {TAXES_PROFILE_OPTION} to keep "
|
||||
f"the flat figure. (A scalar still sitting on a SUPERSEDED "
|
||||
f"default - {superseded} - is accepted and NOT refused: "
|
||||
f"that is a number this add-on wrote into your options "
|
||||
f"blob, not one you typed.)")
|
||||
|
||||
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
|
||||
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
|
||||
# (ORES `13,27`), and the whole table is copied off one card - so the
|
||||
# factor-100 error arrives on EVERY row at once and nothing internal to
|
||||
# the table disagrees with itself. The parser's own +-10 EUR/kWh bound
|
||||
# catches a 13,27 typed raw, but NOT ORES IMPACT's night band of 5,090,
|
||||
# which would be a 5.09 EUR/kWh levy sailing through. Belgian
|
||||
# non-energy components run roughly 0.05-0.25 EUR/kWh, so anything at
|
||||
# or above 1.0 is the wrong unit and not a tariff.
|
||||
steep = sorted({p.eur_per_kwh for p in taxes_table.periods
|
||||
if abs(p.eur_per_kwh) >= 1.0})
|
||||
if steep:
|
||||
problems.append(
|
||||
f"{TAXES_PROFILE_OPTION} carries {steep} EUR/kWh. Belgian "
|
||||
f"levies and distribution run about 0.05-0.25 EUR/kWh, so "
|
||||
f"these are almost certainly the CENT figures off the tariff "
|
||||
f"card (ORES prints `13,27`, which is 0.1327 EUR/kWh) typed "
|
||||
f"without the conversion - a factor of 100 that would make "
|
||||
f"every hour look like an emergency. Divide by 100, or clear "
|
||||
f"the option.")
|
||||
negative = sorted({p.eur_per_kwh for p in taxes_table.periods
|
||||
if p.eur_per_kwh < 0})
|
||||
if negative:
|
||||
# ⚠️ A WARNING, NOT A REFUSAL. A negative non-energy component is
|
||||
# implausible rather than impossible - a prosumer rebate is
|
||||
# expressible this way - and refusing a site over it would be this
|
||||
# module asserting a fact about Belgian tariff design it cannot
|
||||
# observe. §14's warning box governs CONTRADICTIONS, and this is
|
||||
# not one.
|
||||
_LOG.warning(
|
||||
"%s carries negative levies %s EUR/kWh. That is accepted, but "
|
||||
"a tax component below zero is usually a sign error: the "
|
||||
"add-on will SUBTRACT it from the published price in those "
|
||||
"hours.", TAXES_PROFILE_OPTION, negative)
|
||||
|
||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||
# STARTUP RATHER THAN AT 03:00.
|
||||
#
|
||||
@@ -1448,6 +1548,7 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# CARD AGAINST REALITY. A consistent-but-wrong pair - both numbers off the
|
||||
# wrong product - reproduces its own printed price and passes. Nothing can
|
||||
# catch that.
|
||||
spec = None # TARIFF-16 reads this back, below
|
||||
raw_formula = opts.get("price_formula", "")
|
||||
has_formula = bool(str(raw_formula or "").strip()) \
|
||||
or isinstance(raw_formula, dict)
|
||||
@@ -1499,6 +1600,99 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# cannot start.
|
||||
_LOG.warning("%s", aged)
|
||||
|
||||
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
|
||||
#
|
||||
# THE DEFECT THIS EXISTS TO PREVENT: `export_price_eur_kwh` models
|
||||
# injection as ONE scalar, and the published market is a line -
|
||||
# Eneco `0,1 x BELPEX-H - 1,188` c EUR/kWh, "het injectietarief is
|
||||
# dynamisch en kan per uur wijzigen", identical on the January and the
|
||||
# September 2026 cards. A scalar cannot hold its own factor (0,100 against
|
||||
# 0,102 on the same supplier's consumption leg), cannot hold a negative
|
||||
# offset, cannot state that injection is VAT-EXEMPT while consumption is
|
||||
# at 6%, and cannot go below zero in the midday hours a PV household
|
||||
# actually exports in.
|
||||
#
|
||||
# ⚠️ AND WHAT IT DOES NOT AFFECT TODAY, said here rather than left to be
|
||||
# inferred: `arbitrage.py` does not read the injection price at all. Both
|
||||
# hours it compares are all-in CONSUMPTION prices, by its own
|
||||
# accounting-basis comment. No trade turns on this option. What it changes
|
||||
# is what the operator is told and what a future export-aware strategy
|
||||
# rests on - which is why it is validated here rather than trusted.
|
||||
raw_export = opts.get(EXPORT_FORMULA_OPTION, "")
|
||||
has_export = bool(str(raw_export or "").strip()) \
|
||||
or isinstance(raw_export, dict)
|
||||
if has_export:
|
||||
try:
|
||||
xspec = parse_export_formula(raw_export)
|
||||
except FormulaInvalid as e:
|
||||
xspec = None
|
||||
problems.append(f"{EXPORT_FORMULA_OPTION} cannot be used: {e}")
|
||||
if xspec is not None:
|
||||
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
|
||||
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
|
||||
# 0. Printing both beside each other is the only place an operator
|
||||
# can see that the add-on knows it - a status page shows one price
|
||||
# and no arithmetic.
|
||||
_LOG.info(
|
||||
"%s is set: injection priced as (%g x index %+g) x "
|
||||
"(1 + %g/100) %s, i.e. %.4g x the wholesale price with a "
|
||||
"%+.5f EUR/kWh offset. ⚠️ NO STRATEGY IN THIS ADD-ON READS "
|
||||
"IT: arbitrage.py prices the sell side as displaced IMPORT "
|
||||
"and compares two all-in CONSUMPTION prices, so this option "
|
||||
"changes no decision the add-on takes today. It is what you "
|
||||
"are shown, and the shape a future export-aware strategy "
|
||||
"would read.",
|
||||
EXPORT_FORMULA_OPTION, xspec.factor, xspec.offset,
|
||||
xspec.vat_pct, xspec.units, xspec.passthrough,
|
||||
xspec.offset_eur_kwh)
|
||||
if spec is not None:
|
||||
# ⚠️ THE ASYMMETRY, AS AN ORDERED PAIR RATHER THAN TWO NUMBERS
|
||||
# IN ONE SENTENCE (MUTATION.md habit 14). "6 and 0 appear
|
||||
# somewhere" is satisfied by the two legs transposed, which is
|
||||
# the sentence telling an operator that the add-on has their
|
||||
# VAT treatment exactly backwards.
|
||||
_LOG.info(
|
||||
"the two legs are priced independently: consumption "
|
||||
"factor %g at vat_pct %g, injection factor %g at vat_pct "
|
||||
"%g. In Belgium those differ - \"injectietarieven zijn "
|
||||
"vrijgesteld van btw\" while consumption is at 6%% - and "
|
||||
"%s is the option that makes the difference "
|
||||
"expressible; a single scalar could not state it.",
|
||||
spec.factor, spec.vat_pct, xspec.factor, xspec.vat_pct,
|
||||
EXPORT_FORMULA_OPTION)
|
||||
if not xspec.card_graded:
|
||||
# ⚠️ A WARNING AND NEVER A REFUSAL, AND THE GAP IS NAMED
|
||||
# RATHER THAN WORKED AROUND. `price_formula`'s card block is
|
||||
# required because a Belgian card prints the consumption
|
||||
# formula, the reference index AND the resulting price. Eneco's
|
||||
# INJECTION formula is printed with no price beside it, so on
|
||||
# that card there is nothing to reproduce. Requiring a block
|
||||
# here would make the market's own published line
|
||||
# inexpressible; inventing the estimate would grade the
|
||||
# coefficients against arithmetic done with the same
|
||||
# coefficients. So the leg is accepted, the two coefficient
|
||||
# bounds in `implausible_injection()` are applied, and the
|
||||
# operator is told which of the two checks did NOT run.
|
||||
_LOG.warning(
|
||||
"%s has no \"card\" block, so its coefficients are "
|
||||
"UNGRADED: nothing recomputed a printed price from them. "
|
||||
"That is accepted because it is the real market - Eneco "
|
||||
"prints its injection formula with no price beside it, so "
|
||||
"there is nothing to grade against on that card - and "
|
||||
"refusing would make the published line inexpressible. "
|
||||
"What IS checked is the shape of the coefficients: the "
|
||||
"pass-through (%.4g x the wholesale price) and the offset "
|
||||
"(%+.5f EUR/kWh). If YOUR card prints an injection "
|
||||
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
|
||||
"..., \"expect_incl_vat\": ...} and the add-on will "
|
||||
"refuse to start unless your numbers reproduce it.",
|
||||
EXPORT_FORMULA_OPTION, xspec.passthrough,
|
||||
xspec.offset_eur_kwh)
|
||||
else:
|
||||
xaged = stale_card(xspec)
|
||||
if xaged:
|
||||
_LOG.warning("%s", xaged)
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
# with none configured has nothing to fall back to when its feed dies. That
|
||||
|
||||
@@ -291,13 +291,58 @@ options:
|
||||
# price_markup_eur_kwh is ignored under it, with a line in the log saying so.
|
||||
price_provider: "off"
|
||||
price_markup_eur_kwh: 0.03
|
||||
price_taxes_eur_kwh: 0.06
|
||||
|
||||
# TARIFF-18. The all-in Flemish non-energy figure, EUR/kWh, incl. 6 % VAT.
|
||||
# Its components are named in app/price.py's FLEMISH_TAXES_COMPONENTS_EUR_KWH
|
||||
# and test_price.py section 1b re-adds them:
|
||||
#
|
||||
# Fluvius distribution (Antwerpen) 5,35 c EUR/kWh
|
||||
# Bijzondere accijns, "Alle verbruik" 4,876
|
||||
# Bijdrage groene stroom en WKK Vlaanderen 1,51
|
||||
# Energiebijdrage (0 EUR/MWh since 2026-08-01) 0
|
||||
# ------
|
||||
# 11,736 c EUR/kWh = 0.11736
|
||||
#
|
||||
# ⚠️ THIS IS PER-DSO AND YOURS IS PROBABLY DIFFERENT. Only the distribution
|
||||
# row moves: across the eight Fluvius DSOs it runs 5,28 c EUR/kWh
|
||||
# (Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about 0.117
|
||||
# to 0.131. Antwerpen is near the LOW end, which is the direction this
|
||||
# default should err in - it is inert on which hour is cheapest, but §17's
|
||||
# negative-price guard reads the ABSOLUTE all-in price, and a figure set too
|
||||
# high would carry a genuinely negative hour above zero.
|
||||
#
|
||||
# ⚠️ WALLONIA AND BRUSSELS DO NOT BELONG IN THIS SCALAR AT ALL. Their
|
||||
# distribution tariff has a day and a night band; use price_taxes_profile.
|
||||
price_taxes_eur_kwh: 0.11736
|
||||
|
||||
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
||||
# which by itself strongly favours self-consumption over export. A scalar, not
|
||||
# a second series: no shipped supplier publishes a dynamic injection curve.
|
||||
# which by itself strongly favours self-consumption over export. This is the
|
||||
# FLAT injection price, for a site paid the same for every exported kWh.
|
||||
#
|
||||
# ⚠️ THE REASON THIS COMMENT USED TO GIVE - "no shipped supplier publishes a
|
||||
# dynamic injection curve" - WAS FALSE, and TARIFF-07 struck the same
|
||||
# sentence from app/price.py and DOCS.md without reaching this file. Eneco
|
||||
# publishes `0,1 x BELPEX-H - 1,188` c EUR/kWh on the same card as its
|
||||
# consumption formula, ENGIE publishes `(B x eSpot_15) - A`, and Aspiravi
|
||||
# `0,07 x Belpex - 2`. A site on one of those sets price_export_formula
|
||||
# below; this scalar stays the fixed-product default and is what every site
|
||||
# uses today.
|
||||
export_price_eur_kwh: 0.04
|
||||
|
||||
# TARIFF-16. The INJECTION leg as a formula, in price_formula's own grammar
|
||||
# and with its OWN coefficients: Eneco uses 0,102 on consumption and 0,100 on
|
||||
# injection, and injection is VAT-EXEMPT where consumption is at 6%, so
|
||||
# `vat_pct` here is normally 0. A negative `offset` is expected - it is what
|
||||
# the published lines carry - and the resulting price legitimately goes below
|
||||
# zero in the midday hours a PV household exports in.
|
||||
#
|
||||
# ⚠️ NO STRATEGY IN THIS ADD-ON READS IT. arbitrage.py prices the sell side
|
||||
# as displaced IMPORT and compares two all-in CONSUMPTION prices; it does not
|
||||
# read the injection price at all. This option is what the operator is shown
|
||||
# and the shape a future export-aware strategy would rest on. Empty by
|
||||
# default, which leaves every existing site on the scalar above.
|
||||
price_export_formula: ""
|
||||
|
||||
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
|
||||
# of the installed base is on [a static profile]" - this is what makes those
|
||||
# sites first-class rather than "price_provider: off, good luck".
|
||||
@@ -328,6 +373,43 @@ options:
|
||||
# validate.py warns about exactly that.
|
||||
static_profile: ""
|
||||
|
||||
# TARIFF-17. THE TAXES COMPONENT AS A DAY/NIGHT TABLE, for the sites whose
|
||||
# network tariff actually has one. "" means the flat price_taxes_eur_kwh
|
||||
# above decides, which is what every install has had until now and is CORRECT
|
||||
# IN FLANDERS: Fluvius distribution is a single rate, and its row on the
|
||||
# tariff card offers only `Normaal | Uitsl. nacht` - exclusive night being a
|
||||
# separate physical circuit, not the night register of a dual-rate meter.
|
||||
#
|
||||
# The Walloon and Brussels DSOs are on the same card with FOUR columns -
|
||||
# `Enkelvoudig | Dag | Nacht | Uitsl. nacht` - and the gap is real money
|
||||
# (c EUR/kWh): ORES dag 13,27 / nacht 7,39; AIESH 15,17 / 8,21; Tecteo RESA
|
||||
# 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090.
|
||||
#
|
||||
# ⚠️ WHY THIS IS NOT COSMETIC. The optimiser reads the SPREAD (max - min) and
|
||||
# which hour is cheapest. A flat number is additive and identical every hour,
|
||||
# so it cancels out of both - it cannot distort anything, and it also cannot
|
||||
# express this. These bands are ANTI-CORRELATED with what the battery wants
|
||||
# to do (cheap network hours are cheap spot hours), so flattening them
|
||||
# UNDERSTATES the true spread and declines cycles that would have paid. See
|
||||
# DOCS.md, "The three buckets".
|
||||
#
|
||||
# Same JSON grammar as static_profile above - the same tiling rule, the same
|
||||
# day selectors, the same DST handling, because it is literally the same
|
||||
# parser. ⚠️ THE VALUE IS THE TAXES COMPONENT ALONE, NOT AN ALL-IN PRICE:
|
||||
#
|
||||
# {"periods": [
|
||||
# {"name":"dag", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.1327},
|
||||
# {"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.0739},
|
||||
# {"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.0739}]}
|
||||
#
|
||||
# ⚠️ SET THIS *OR* price_taxes_eur_kwh, NOT BOTH. Two writers of one quantity,
|
||||
# one of which silently wins, is refused at startup: leave the scalar at its
|
||||
# shipped default when you fill this in. A scalar still sitting on the
|
||||
# SUPERSEDED 0.06 is accepted too (TARIFF-18 moved that default, and your
|
||||
# stored options carry the old number whether you typed it or not) - the
|
||||
# refusal is about a figure you chose, not about one this add-on wrote.
|
||||
price_taxes_profile: ""
|
||||
|
||||
# TARIFF-15. YOUR OWN TARIFF CARD, as two fixed coefficients. Used only when
|
||||
# `price_provider: custom_formula`; "" is unset.
|
||||
#
|
||||
@@ -842,11 +924,24 @@ schema:
|
||||
# bottoms out at 0 would make that site inexpressible.
|
||||
export_price_eur_kwh: float(-1,1)
|
||||
|
||||
# TARIFF-16. A JSON string in `price_formula`'s grammar, parsed by
|
||||
# `custom_formula.parse_export_formula()` - the SAME parser with its option
|
||||
# name as an argument, so a second unit table and a second card rule do not
|
||||
# exist to diverge. The `card` block is OPTIONAL on this leg and required on
|
||||
# the other: Eneco prints its injection formula with no price beside it, so
|
||||
# on that card there is nothing to grade against, and the add-on says at
|
||||
# startup that the leg is ungraded rather than inventing an estimate.
|
||||
price_export_formula: str?
|
||||
|
||||
# A JSON string rather than a nested schema, deliberately: it is ONE option
|
||||
# key (§14 has one), it survives config.yaml's flat options block, and
|
||||
# parse_static_profile() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
static_profile: str?
|
||||
# TARIFF-17. A JSON string for `static_profile`'s reason, and parsed by
|
||||
# `static_tariff.parse_taxes_profile()` - the SAME period grammar, so a
|
||||
# second tiling rule and a second set of DST bugs do not exist to diverge.
|
||||
price_taxes_profile: str?
|
||||
# TARIFF-15. A JSON string for the same reason `static_profile` is one: it is
|
||||
# ONE option key, it survives config.yaml's flat options block, and
|
||||
# parse_price_formula() accepts an already-parsed dict too, so a future
|
||||
|
||||
@@ -73,6 +73,7 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -84,6 +85,11 @@ from app.arbiter import (
|
||||
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
|
||||
resolve,
|
||||
)
|
||||
# SAFETY-45 section 3c. The shipped loop, the shipped billed-quarter
|
||||
# accumulator and the shipped projection - nothing here is transcribed.
|
||||
from app.control import Tuning, compute
|
||||
from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed
|
||||
from app.tariff import PeakTracker
|
||||
from app.arbitrage import (
|
||||
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
|
||||
threshold_eur_kwh,
|
||||
@@ -203,10 +209,19 @@ def hour(i, *, start=D0):
|
||||
return start + timedelta(hours=i, minutes=30)
|
||||
|
||||
|
||||
# SAFETY-45. THE HEADROOM EVERY CHECK THAT IS NOT ABOUT THE HEADROOM USES, and
|
||||
# it is deliberately NOT binding: `min(MAX_W, this)` is MAX_W, so every existing
|
||||
# check in this file grades what it always graded and the new rung is decoration
|
||||
# in all of them. Section 3b is where it is made to bind, and 3b asserts the
|
||||
# precondition that it does (MUTATION.md habit 1) rather than hoping.
|
||||
HEADROOM_CLEAR = MAX_W * 10.0
|
||||
|
||||
|
||||
def act(plan, i, *, peak=False, soc=50.0, thresh=0.0, unc=frozenset(),
|
||||
floor=20.0, ceiling=95.0, start=D0):
|
||||
floor=20.0, ceiling=95.0, start=D0, headroom=HEADROOM_CLEAR):
|
||||
"""`decide()` with the fixture's constants filled in. One call shape."""
|
||||
return decide(plan, hour(i, start=start), peak_guard_active=peak, soc=soc,
|
||||
charge_headroom_w=headroom,
|
||||
min_spread_eur_kwh=thresh, uncertainty=unc,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
|
||||
@@ -527,6 +542,124 @@ check(f"...while THE SAME MAGNITUDES with the sign flipped all DISCHARGE, so "
|
||||
# header. Nothing in app/ changed - these rungs were correct all along.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2c. TARIFF-18 - where the \u00a717 boundary sits in SPOT terms, and the "
|
||||
"shipped default that decides it")
|
||||
# ⚠️ WHAT THIS GRADES AND WHAT IT DOES NOT CLAIM. `price_taxes_eur_kwh` is a
|
||||
# LEVEL component: it is added identically to every hour, so it cancels exactly
|
||||
# in `max - min` and cannot move which hour is cheapest. test_price.py 16b
|
||||
# measures that as a FAMILY - five flat scalars spanning and exceeding a real
|
||||
# per-DSO range, identical argmin and identical spread to the last digit - and
|
||||
# nothing here re-litigates it. Raising the shipped default 0.06 -> 0.11736 is
|
||||
# therefore NOT an arbitrage-correctness fix, and this section does not say it
|
||||
# is.
|
||||
#
|
||||
# What it IS: §17's guard is the one rung in this module that reads an
|
||||
# ABSOLUTE price rather than a difference, so it is the one decision a level
|
||||
# component can reach. The boundary is `spot < -(markup + taxes)`, and moving
|
||||
# the taxes default moves it. Below, driven through the shipped `decide()`.
|
||||
#
|
||||
# ⚠️ AND THE TWO ERROR DIRECTIONS ARE NOT SYMMETRIC, WHICH IS WHY THE DEFAULT
|
||||
# SITS AT THE BOTTOM OF THE PER-DSO BAND RATHER THAN IN THE MIDDLE. A taxes
|
||||
# figure set too HIGH carries a genuinely negative hour up above zero and the
|
||||
# add-on exports into paid-for disposal - §17's own regression case. A figure
|
||||
# set too LOW only declines a discharge that would have paid. Where one default
|
||||
# must stand in for eight Fluvius DSOs, it belongs at the low end.
|
||||
_T18_MARKUP = PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"]
|
||||
_T18_TAXES = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
||||
_T18_OLD_TAXES = 0.06 # what config.yaml shipped before TARIFF-18
|
||||
|
||||
# ⚠️ THE PRECONDITION FIRST (MUTATION.md habit 1). Every comparison below is
|
||||
# "the old level and the new level answer differently". If the two levels were
|
||||
# ever the same number, all of it would be one figure graded against itself -
|
||||
# DEV-02's `(-5000, 5000)` against `(-5000, 5000)`, one card over.
|
||||
check(f"the shipped taxes default {_T18_TAXES} and the superseded "
|
||||
f"{_T18_OLD_TAXES} are DIFFERENT numbers, and differ by more than the "
|
||||
f"0.01 EUR/kWh step this fixture's spot series is built on",
|
||||
abs(_T18_TAXES - _T18_OLD_TAXES) > 0.01)
|
||||
|
||||
# The §17 boundary, computed HERE from the two option values rather than read
|
||||
# off arbitrage.py - habit 2. all-in = spot + markup + taxes, so the guard
|
||||
# fires exactly when spot < -(markup + taxes).
|
||||
_T18_B_NEW = round(-(_T18_MARKUP + _T18_TAXES), 6)
|
||||
_T18_B_OLD = round(-(_T18_MARKUP + _T18_OLD_TAXES), 6)
|
||||
_T18_PROBE = round((_T18_B_NEW + _T18_B_OLD) / 2, 6)
|
||||
check(f"the guard's spot-price boundary moves {_T18_B_OLD:+.5f} -> "
|
||||
f"{_T18_B_NEW:+.5f} EUR/kWh, and the probe {_T18_PROBE:+.5f} lies "
|
||||
f"STRICTLY BETWEEN them - so one level answers each way and the probe is "
|
||||
f"not on either boundary",
|
||||
_T18_B_NEW < _T18_PROBE < _T18_B_OLD)
|
||||
|
||||
# A whole-day-negative spot series whose DEAREST hour is the probe. Same shape
|
||||
# as section 2's: the case a guard written "not all prices are negative" lets
|
||||
# through, in SPOT rather than all-in terms.
|
||||
_T18_SPOT = [round(-0.40 + 0.010 * i, 6) for i in range(24)]
|
||||
_T18_SPOT[DEAR_H] = _T18_PROBE
|
||||
check(f"the spot day's dearest hour is slot {DEAR_H} at {_T18_PROBE:+.5f} and "
|
||||
f"every other hour is below it, so the hour the guard is asked about is "
|
||||
f"the one the probe was chosen for",
|
||||
_T18_SPOT.index(max(_T18_SPOT)) == DEAR_H
|
||||
and _T18_SPOT.count(max(_T18_SPOT)) == 1
|
||||
and all(p < 0 for p in _T18_SPOT))
|
||||
|
||||
|
||||
def _t18_all_in(taxes):
|
||||
"""spot + markup + taxes, added here, by this file's own arithmetic."""
|
||||
return [round(p + _T18_MARKUP + taxes, 6) for p in _T18_SPOT]
|
||||
|
||||
|
||||
_T18_OLD_SERIES = _t18_all_in(_T18_OLD_TAXES)
|
||||
_T18_NEW_SERIES = _t18_all_in(_T18_TAXES)
|
||||
print(f" dearest hour all-in: old level {_T18_OLD_SERIES[DEAR_H]:+.5f}, "
|
||||
f"shipped level {_T18_NEW_SERIES[DEAR_H]:+.5f} EUR/kWh")
|
||||
|
||||
# ⚠️ THE LEVEL BUCKET RESTATED ON THIS FIXTURE, BEFORE ANYTHING IS CONCLUDED
|
||||
# FROM IT. If the two arms differed in spread or in argmin, the verdicts below
|
||||
# could be attributed to that instead, and this section would be claiming an
|
||||
# arbitrage effect it must not claim.
|
||||
check(f"the two levels leave the SPREAD identical to the last digit "
|
||||
f"({round(max(_T18_OLD_SERIES) - min(_T18_OLD_SERIES), 6)} EUR/kWh) and "
|
||||
f"the cheapest hour in the same slot - so nothing below is an optimiser "
|
||||
f"effect",
|
||||
round(max(_T18_OLD_SERIES) - min(_T18_OLD_SERIES), 6)
|
||||
== round(max(_T18_NEW_SERIES) - min(_T18_NEW_SERIES), 6)
|
||||
and _T18_OLD_SERIES.index(min(_T18_OLD_SERIES))
|
||||
== _T18_NEW_SERIES.index(min(_T18_NEW_SERIES)))
|
||||
|
||||
_T18_SELL_OLD = act(plan_of(_T18_OLD_SERIES), DEAR_H, thresh=_shipped,
|
||||
unc=NO_WEATHER)
|
||||
_T18_SELL_NEW = act(plan_of(_T18_NEW_SERIES), DEAR_H, thresh=_shipped,
|
||||
unc=NO_WEATHER)
|
||||
check(f"at the SUPERSEDED 0.06 the same spot hour is "
|
||||
f"{_T18_OLD_SERIES[DEAR_H]:+.5f} all-in, so \u00a717 REFUSES the discharge "
|
||||
f"- and the reason names \u00a717 and that price, not merely that nothing "
|
||||
f"happened",
|
||||
_T18_SELL_OLD.action is None
|
||||
and "§17" in _T18_SELL_OLD.reason
|
||||
and f"{_T18_OLD_SERIES[DEAR_H]:+.4f}" in _T18_SELL_OLD.reason)
|
||||
check(f"...and at the SHIPPED {_T18_TAXES} the SAME spot hour is "
|
||||
f"{_T18_NEW_SERIES[DEAR_H]:+.5f} all-in and the battery DISCHARGES. That "
|
||||
f"is the whole of what this card changes in a decision: the operator "
|
||||
f"really does pay those levies, so this hour was never paid-for disposal "
|
||||
f"and the old default was declining a trade that paid",
|
||||
_T18_SELL_NEW.action == DISCHARGE)
|
||||
|
||||
# ⚠️ AND THE OTHER SIDE OF THE SHIPPED BOUNDARY, or the pair above grades
|
||||
# "bigger taxes discharge more" rather than "the boundary is at -(markup +
|
||||
# taxes)". One step below the shipped boundary is still refused.
|
||||
_T18_BELOW = list(_T18_SPOT)
|
||||
_T18_BELOW[DEAR_H] = round(_T18_B_NEW - 0.001, 6)
|
||||
_T18_SELL_BELOW = act(
|
||||
plan_of([round(p + _T18_MARKUP + _T18_TAXES, 6) for p in _T18_BELOW]),
|
||||
DEAR_H, thresh=_shipped, unc=NO_WEATHER)
|
||||
check(f"a spot hour at {_T18_BELOW[DEAR_H]:+.5f} - one tenth of a cent BELOW "
|
||||
f"the shipped boundary - is still refused by \u00a717, so the boundary is "
|
||||
f"where -(markup + taxes) puts it and not merely somewhere lower than "
|
||||
f"before",
|
||||
_T18_SELL_BELOW.action is None and "§17" in _T18_SELL_BELOW.reason)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. AC3 - peak shaving outranks arbitrage, through both mechanisms")
|
||||
@@ -603,6 +736,7 @@ check("a peak floor does NOT cap an arbitrage DISCHARGE - §9.1 bills offtake "
|
||||
# MECHANISM 3, which is neither of the above: the guard that never ran.
|
||||
_blind = decide(PLAN, hour(CHEAP_H), peak_guard_active=None, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
check("a peak guard that NEVER RAN is a third state, and it refuses: `None` is "
|
||||
@@ -632,6 +766,323 @@ check("...and cycle() calls it rather than inlining the conjunction, so a "
|
||||
encoding="utf-8").read(), "cycle")))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3b. SAFETY-45 - the buy leg is SIZED by the guard, not switched by it")
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC3 above is the SWITCH: `peak_guard_active` refuses the whole trade while
|
||||
# the guard holds. That is right and it stays. What was missing is the other
|
||||
# half - the guard measures a CONTINUOUS number, "the watts this billed quarter
|
||||
# can still afford", and until this card nothing consumed it. So a trade sat at
|
||||
# max_w through the whole window in which the guard was silent, blew the
|
||||
# quarter, tripped the switch, was cancelled outright, and came back at max_w
|
||||
# the moment the meter fell far enough to release it. Measured on TEST-23
|
||||
# stage 3's closed rig at peak_cap_w 1500 / max_w 2000 / a 1200 W house:
|
||||
# 28 charge onsets in one hour, mean period 131 s, grid -456 <-> +3200 W.
|
||||
#
|
||||
# `control.maintenance_charge_floor()` has always given a MAINTENANCE charge
|
||||
# exactly this shape - "capped by the headroom left under the site's cap" - and
|
||||
# its own docstring says why: "a maintenance charge is the only thing this
|
||||
# system does that is big enough and long enough to set that peak". OPT-04 is
|
||||
# the second such thing and was given no cap. This section grades the cap.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ⚠️ THE PRECONDITION, AND IT IS THE HABIT-1 ONE. The headroom must be strictly
|
||||
# between 0 and the size the trade would otherwise ask for, or "sized by the
|
||||
# guard" is indistinguishable from "not sized at all" and from "refused".
|
||||
S45_HEADROOM = 300.0
|
||||
check(f"PRECONDITION: the headroom under test ({S45_HEADROOM:.0f} W) is "
|
||||
f"strictly inside the trade's own sizing ({MAX_W:.0f} W) and strictly "
|
||||
f"positive, so a cap that did nothing and a cap that refused everything "
|
||||
f"are both distinguishable from the answer below",
|
||||
0.0 < S45_HEADROOM < MAX_W)
|
||||
_s45_capped = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER,
|
||||
headroom=S45_HEADROOM)
|
||||
_s45_clear = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
|
||||
print(f" headroom {S45_HEADROOM:.0f} W -> {_s45_capped.power_w:+.0f} W; "
|
||||
f"headroom clear -> {_s45_clear.power_w:+.0f} W")
|
||||
print(f" {_s45_capped.reason}")
|
||||
check("the trade still HAPPENS - §10.1 ranks peak shaving above arbitrage, it "
|
||||
"does not abolish it - and it is sized to the watts the billed quarter "
|
||||
"can afford rather than to max_w",
|
||||
_s45_capped.action == CHARGE
|
||||
and near(_s45_capped.power_w, -S45_HEADROOM))
|
||||
check("...and the claim the arbiter receives carries the SIZED number, so the "
|
||||
"cap reaches the actuator and is not merely printed",
|
||||
near(claim(_s45_capped).value, -S45_HEADROOM)
|
||||
and claim(_s45_capped).priority == P_ARBITRAGE)
|
||||
check("...and the SAME call with the headroom clear asks for max_w, so the "
|
||||
"check above is graded against a difference this fixture can actually "
|
||||
"produce (habit 7: one thing varied, from an accepted baseline)",
|
||||
_s45_clear.action == CHARGE and near(_s45_clear.power_w, -MAX_W))
|
||||
|
||||
# ⚠️ THE ORDERED PAIR (MUTATION.md habit 14). Two numbers in one sentence can
|
||||
# always be transposed by a ZERO-BYTE edit, and this sentence is what an
|
||||
# operator reads when the battery buys less than the price says it should. So
|
||||
# the check asserts each value inside the literal text that names it, and
|
||||
# asserts that the TRANSPOSED sentence is not what it says.
|
||||
_S45_PAIR = (f"the trade asked for {MAX_W:.0f} W and this billed quarter can "
|
||||
f"afford {S45_HEADROOM:.0f} W")
|
||||
_S45_SWAP = (f"the trade asked for {S45_HEADROOM:.0f} W and this billed "
|
||||
f"quarter can afford {MAX_W:.0f} W")
|
||||
check("the reason names WHAT WAS ASKED FOR and WHAT WAS AFFORDABLE in their "
|
||||
"own slots, so an operator can tell a small trade from a small "
|
||||
"opportunity", _S45_PAIR in _s45_capped.reason)
|
||||
check("...and the transposed sentence is NOT what it says, so the check above "
|
||||
"discriminates rather than decorates - both numbers are in the string "
|
||||
"either way and a substring test would pass under the swap",
|
||||
_S45_SWAP not in _s45_capped.reason)
|
||||
check("...and it names the guard and the two spec sections, so the sentence "
|
||||
"cannot be read as a pack limit or a price limit",
|
||||
"CAPACITY-TARIFF GUARD" in _s45_capped.reason
|
||||
and "§9.1" in _s45_capped.reason and "§10.1" in _s45_capped.reason)
|
||||
check("...and an UNCAPPED trade does not carry that clause at all: a sentence "
|
||||
"that always says 'sized by the guard' tells nobody anything",
|
||||
"CAPACITY-TARIFF GUARD" not in _s45_clear.reason
|
||||
and f"charging {MAX_W:.0f} W" in _s45_clear.reason)
|
||||
|
||||
# --- the two refusals, and they are told apart from the ones that existed ---
|
||||
_s45_none = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=None)
|
||||
_s45_zero = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
|
||||
print(f" headroom None -> {_s45_none.action}; headroom 0 W -> "
|
||||
f"{_s45_zero.action}")
|
||||
check("a headroom nothing MEASURED refuses. This is guard_state()'s None "
|
||||
"argument one layer down: charging creates the billed quantity (§9.1) "
|
||||
"and an unmeasured quarter is not a clear one",
|
||||
not _s45_none.trading
|
||||
and _s45_none.reason == _arb.IDLE_HEADROOM_UNMEASURED)
|
||||
check("...and it is DISTINGUISHABLE from the guard never having run at all, so "
|
||||
"the status page does not report a missing battery sensor as a missing "
|
||||
"meter", _s45_none.reason != _arb.IDLE_NOT_COVERED)
|
||||
check("a headroom of ZERO refuses rather than filing a 0 W claim - "
|
||||
"`Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the "
|
||||
"loop, which is the failure claim()'s own note is written against",
|
||||
not _s45_zero.trading and claim(_s45_zero) is None
|
||||
and _s45_zero.reason == _arb.IDLE_NO_HEADROOM)
|
||||
# ⚠️ TEST-16's RULE: TWO RUNGS THAT CAN ANSWER THE SAME STIMULUS MUST BE
|
||||
# SEPARATED. `charge_w = 0` and `headroom = 0` both produce a 0 W sizing, and
|
||||
# grading only `kind == "refused"` would let either absorb the other's work -
|
||||
# after which weakening either one reports nothing. So the REASONS are graded,
|
||||
# and each rung is reached with the other one clear.
|
||||
_s45_nosize = decide(PLAN, hour(CHEAP_H), peak_guard_active=False, soc=50.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=0.0, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
check("...and the PACK being sized at 0 W still reports OPT-09's own sentence, "
|
||||
"not this card's: the two rungs answer the same stimulus and a refusal "
|
||||
"that named the wrong subsystem would send an operator to the wrong page",
|
||||
not _s45_nosize.trading
|
||||
and _s45_nosize.reason == _arb.IDLE_NO_SIZE
|
||||
and _s45_zero.reason != _s45_nosize.reason)
|
||||
|
||||
# --- §9.1: the SELL leg is untouched ---------------------------------------
|
||||
_s45_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
|
||||
check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
|
||||
"are never capped by the peak guard; only charging and import are' - the "
|
||||
"capacity tariff bills offtake, and a sell creates none",
|
||||
_s45_sell.action == DISCHARGE and near(_s45_sell.power_w, MAX_W))
|
||||
|
||||
# --- and main.py actually passes it, pinned on the AST ----------------------
|
||||
# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (MUTATION.md habit 15). Nothing
|
||||
# behavioural in this file can see main.py drop the argument - every check
|
||||
# above calls decide() itself - and a required keyword-only parameter that no
|
||||
# caller fills is a TypeError only on the one path a suite does not run. Same
|
||||
# pin, same reason, as `weights=` in test_packshare.py part 10.
|
||||
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
|
||||
_s45_calls = [n for n in ast.walk(ast.parse(_S45_MAIN))
|
||||
if isinstance(n, ast.Call)
|
||||
and ast.unparse(n.func).endswith("arbitrage.decide")]
|
||||
_s45_arg = [ast.unparse(k.value) for c in _s45_calls for k in c.keywords
|
||||
if k.arg == "charge_headroom_w"]
|
||||
print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); "
|
||||
f"charge_headroom_w= is {_s45_arg}")
|
||||
check("main.py passes the guard's OWN measured headroom at its one "
|
||||
"arbitrage.decide() call site - the shipped expression, not a "
|
||||
"transcription of it, so a mutation back to a literal or to max_w is red "
|
||||
"rather than silent",
|
||||
_s45_arg == ["peakguard.charge_headroom_w(self.peak_guard)"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3c. SAFETY-45 - THE REGRESSION: the closed loop SETTLES")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ THIS IS THE CHECK THE CARD IS ACTUALLY ABOUT, AND A UNIT TEST ON decide()
|
||||
# IS NOT IT. The defect is an INTERACTION - two strategies, a 900 s integrator
|
||||
# and a plant that answers back - and every individual decision in it was
|
||||
# correct. So the loop is closed here: prices decide, the battery model
|
||||
# responds, the shipped QuarterAverager reports the consequence, and the
|
||||
# shipped guard reacts to that.
|
||||
#
|
||||
# WHAT IS REAL, AND WHAT IS MODELLED - the same discipline TEST-23 stage 3's
|
||||
# harness holds itself to, because a transcription proves what somebody typed:
|
||||
#
|
||||
# REAL peakguard.decide/claim/charge_headroom_w, arbitrage.decide/claim/
|
||||
# guard_state, control.compute + Tuning, arbiter.resolve, and
|
||||
# p1.QuarterAverager - the billed-quarter accumulator itself, so the
|
||||
# quarters reported below are the ones a meter would bill.
|
||||
# MODEL ONE cycle's ORDER (peak block, then arbitrage reading its
|
||||
# conclusion, then the loop claim, then resolve) - twelve lines
|
||||
# transcribed from main.py:2660-2900 and pinned above in 3b; the
|
||||
# PLANT, an 800 W/s ramp into a 10 s first-order lag, which is
|
||||
# plantsim's; and a flat synthetic house.
|
||||
#
|
||||
# ⚠️ AND THE HEADLINE NUMBERS WERE NOT TAKEN HERE. They were taken on the
|
||||
# closed rig at /home/homelab/test23s3 against the shipped container's own
|
||||
# checkout, which is where a divergence between this model and main.py's real
|
||||
# event loop would show up. This section exists so CI can go red.
|
||||
# ---------------------------------------------------------------------------
|
||||
S45_CAP_W, S45_LOAD_W, S45_CYCLE_S, S45_RUN_S = 1500.0, 1200.0, 5.0, 2700.0
|
||||
S45_RAMP_W_S, S45_LAG_S = 800.0, 10.0
|
||||
# ⚠️ THE RUN STARTS ON A QUARTER BOUNDARY AND ENDS INSIDE THE SAME CHEAP HOUR,
|
||||
# and both halves matter. Starting on the boundary makes the three closed
|
||||
# blocks below whole billed quarters. Ending inside the hour keeps TEST-23
|
||||
# finding 1 - the export overshoot at the END of any trade, ~0.37 x max_w, a
|
||||
# separate and already-recorded property - out of a swing figure this card is
|
||||
# grading. The run still crosses TWO quarter boundaries, which is the half of
|
||||
# the defect the card says arbitrage must learn from.
|
||||
S45_T0 = D0 + timedelta(hours=CHEAP_H)
|
||||
|
||||
|
||||
def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
|
||||
seconds=S45_RUN_S):
|
||||
"""One closed hour. `sized=False` is the behaviour this card removes.
|
||||
|
||||
⚠️ THE COUNTERFACTUAL IS ONE ARGUMENT, NOT A SECOND CODE PATH. `sized=False`
|
||||
hands `decide()` a headroom nothing can bind - which is exactly what the
|
||||
tree did before this card, because nothing was passing one - so the two runs
|
||||
below differ in one value and share every line of shipped code. Habit 7.
|
||||
"""
|
||||
avg = QuarterAverager(phases=1, max_hold_s=900.0)
|
||||
tuning = Tuning(gain=0.6, max_w=MAX_W, slew_w=0.0)
|
||||
t0 = S45_T0
|
||||
target = i_w = 0.0
|
||||
sat = 0
|
||||
loop_w = None
|
||||
batt_cmd = batt = 0.0 # + discharge, as main.py's site_batt_w
|
||||
onsets = blocks = 0
|
||||
prev_action = "<start>"
|
||||
grid_lo, grid_hi = None, None
|
||||
billed = []
|
||||
acc = 0.0
|
||||
t = 0.0
|
||||
while t <= seconds:
|
||||
grid = load_w - batt
|
||||
imp, exp = split_signed(grid)
|
||||
for b in avg.add(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=t0 + timedelta(seconds=t),
|
||||
ingest_mono=(t0.timestamp() + t))):
|
||||
billed.append(round(float(b.offtake_avg_w), 1))
|
||||
blocks += 1
|
||||
if acc <= 1e-9:
|
||||
acc = S45_CYCLE_S
|
||||
now = t0 + timedelta(seconds=t)
|
||||
claims = []
|
||||
pk = _pg.decide(PeakTracker.project(avg, now), batt_w=batt,
|
||||
cap_w=cap_w, reading=None, achievable_w=MAX_W,
|
||||
soc=50.0, soc_floor=20.0, max_hold_s=900.0,
|
||||
frozen=False)
|
||||
pc = _pg.claim(pk)
|
||||
if pc is not None:
|
||||
claims.append(pc)
|
||||
arb = decide(PLAN, now,
|
||||
peak_guard_active=_arb.guard_state(pk),
|
||||
charge_headroom_w=(_pg.charge_headroom_w(pk) if sized
|
||||
else HEADROOM_CLEAR),
|
||||
soc=50.0, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER, charge_w=MAX_W,
|
||||
discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
ac = claim(arb)
|
||||
if ac is not None:
|
||||
claims.append(ac)
|
||||
if arb.action == CHARGE and prev_action != CHARGE:
|
||||
onsets += 1
|
||||
prev_action = arb.action
|
||||
if loop_w is None or target != loop_w:
|
||||
i_w = target
|
||||
d = compute(prev_w=target, grid_w=grid, actual_w=batt,
|
||||
tuning=tuning, sat_count=sat, i_w=i_w)
|
||||
sat, i_w, loop_w = d.sat_count, d.i_w, d.target_w
|
||||
claims.append(Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W,
|
||||
"max_w"))
|
||||
claims.append(Claim.set("loop", P_LOOP, d.target_w, d.reason))
|
||||
target = resolve(claims).target_w
|
||||
# THE PLANT, MODELLED: an 800 W/s ramp into a 10 s first-order lag.
|
||||
step = min(1.0, acc)
|
||||
delta = target - batt_cmd
|
||||
batt_cmd += math.copysign(min(abs(delta), S45_RAMP_W_S * step), delta)
|
||||
batt += (batt_cmd - batt) * (step / S45_LAG_S)
|
||||
acc -= step
|
||||
t += step
|
||||
grid_lo = grid if grid_lo is None else min(grid_lo, grid)
|
||||
grid_hi = grid if grid_hi is None else max(grid_hi, grid)
|
||||
return dict(onsets=onsets, grid_lo=round(grid_lo, 1),
|
||||
grid_hi=round(grid_hi, 1), swing=round(grid_hi - grid_lo, 1),
|
||||
billed=billed, blocks=blocks)
|
||||
|
||||
S45_BEFORE = derived(lambda: s45_loop(sized=False))
|
||||
S45_AFTER = derived(lambda: s45_loop(sized=True))
|
||||
for _lab, _r in (("unsized (the shipped behaviour before this card)", S45_BEFORE),
|
||||
("sized by the guard", S45_AFTER)):
|
||||
print(f" {_lab}: {_r['onsets']} charge onsets/h, grid "
|
||||
f"{_r['grid_lo']:.0f} .. {_r['grid_hi']:.0f} W (swing "
|
||||
f"{_r['swing']:.0f} W), billed quarters {_r['billed']} W")
|
||||
|
||||
# ⚠️ THE NEGATIVE CONTROL FIRST. If the unsized run did NOT limit-cycle, every
|
||||
# assertion below would be a statement about a loop that was never unstable -
|
||||
# MUTATION.md's "a check whose data cannot reach the failure it names", which
|
||||
# has now appeared in eleven cards in this repository.
|
||||
check("PRECONDITION: with the trade UNSIZED this loop really does run away - "
|
||||
"many charge onsets in one hour and a grid swing of kilowatts. Without "
|
||||
"this, everything below grades a loop that was never unstable",
|
||||
S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0)
|
||||
check("...and it is the bang-bang shape and not a slow drift: the meter "
|
||||
"reaches both a hard import and an EXPORT, so the actuator is being "
|
||||
"handed back and forth rather than converging",
|
||||
S45_BEFORE["grid_hi"] > S45_CAP_W + 1000.0
|
||||
and S45_BEFORE["grid_lo"] < 0.0)
|
||||
check(f"THE REGRESSION: sizing the trade to the guard's measured headroom "
|
||||
f"settles the loop. {S45_BEFORE['onsets']} charge onsets an hour become "
|
||||
f"{S45_AFTER['onsets']}, and the grid swing falls from "
|
||||
f"{S45_BEFORE['swing']:.0f} W to {S45_AFTER['swing']:.0f} W",
|
||||
S45_AFTER["onsets"] <= 2
|
||||
and S45_AFTER["swing"] < 0.25 * S45_BEFORE["swing"])
|
||||
check("...and the battery is still TRADING for the whole hour rather than "
|
||||
"settling by giving up: §10.1 ranks peak shaving above arbitrage, it "
|
||||
"does not cancel it, and a fix that simply stopped buying would pass an "
|
||||
"onset count and fail the card",
|
||||
S45_AFTER["onsets"] == 1)
|
||||
check(f"...and every BILLED quarter comes in at or under the "
|
||||
f"{S45_CAP_W:.0f} W cap, which the unsized run does not manage - the "
|
||||
f"oscillation was not merely ugly, it was losing the money it was "
|
||||
f"oscillating about (§10.2, §9.1)",
|
||||
S45_AFTER["blocks"] >= 3
|
||||
and all(v <= S45_CAP_W + 0.5 for v in S45_AFTER["billed"])
|
||||
and any(v > S45_CAP_W for v in S45_BEFORE["billed"]))
|
||||
check("...and the two runs are the same code on the same fixture: one "
|
||||
"argument differs, and the loop, the guard, the arbiter, the averager "
|
||||
"and the plant are shared line for line",
|
||||
S45_BEFORE["blocks"] == S45_AFTER["blocks"])
|
||||
|
||||
# ⚠️ AND IT IS INERT WHERE THE DEFECT IS INERT, WHICH IS THE OTHER HALF OF A
|
||||
# FIX. The rig's sweep found the bite rule `load + max_w > peak_cap_w`; below
|
||||
# it, the trade never threatens the quarter and this card must change NOTHING.
|
||||
S45_WIDE = derived(lambda: s45_loop(sized=False, cap_w=5000.0, load_w=1200.0,
|
||||
seconds=900.0))
|
||||
S45_WIDE_S = derived(lambda: s45_loop(sized=True, cap_w=5000.0, load_w=1200.0,
|
||||
seconds=900.0))
|
||||
print(f" peak_cap_w 5000 W, 1200 W house (load + max_w = "
|
||||
f"{S45_LOAD_W + MAX_W:.0f} W, under the cap): unsized "
|
||||
f"{S45_WIDE['billed']} W, sized {S45_WIDE_S['billed']} W")
|
||||
check("on a site where the trade cannot reach the cap the two runs are "
|
||||
"IDENTICAL to the watt - so this card costs nothing on the installs it "
|
||||
"is not about, and the check above is not passing because the cap "
|
||||
"dominates everything",
|
||||
S45_WIDE == S45_WIDE_S and S45_WIDE["onsets"] == 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. §8.3 - no irreversible action on a provisional price")
|
||||
@@ -704,7 +1155,8 @@ _ticks = []
|
||||
for _t in range(24 * 120): # 24 h at the 30 s control cadence
|
||||
_d = decide(_flap_plan, D0 + timedelta(seconds=30 * _t),
|
||||
peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
|
||||
uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
_ticks.append(_d.action)
|
||||
_transitions = sum(1 for a, b in zip(_ticks, _ticks[1:]) if a != b)
|
||||
@@ -726,7 +1178,8 @@ _no_plan = act(None, CHEAP_H, thresh=_shipped)
|
||||
_no_soc = act(PLAN, CHEAP_H, thresh=_shipped, soc=None)
|
||||
_uncovered = decide(PLAN, D0 + timedelta(days=9), peak_guard_active=False,
|
||||
soc=50.0, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
|
||||
uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
_reasons = [_no_plan.reason, _no_soc.reason, _uncovered.reason, _blind.reason,
|
||||
_held.reason]
|
||||
@@ -1100,7 +1553,8 @@ def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
||||
d = decide(plan, p.start + timedelta(minutes=30),
|
||||
peak_guard_active=False, soc=soc,
|
||||
min_spread_eur_kwh=threshold, uncertainty=unc,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
|
||||
if d.action is None:
|
||||
continue
|
||||
@@ -1834,10 +2288,12 @@ check("...and the same at the sell probe, against the verdict's dearest",
|
||||
_pa_sell is derived(lambda: _qv.dearest))
|
||||
_qbuy = decide(QPLAN, qprobe(Q_CHEAP_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
_qsell = decide(QPLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
check("...so decide() CHARGES in the cheapest quarter and DISCHARGES in the "
|
||||
@@ -1862,7 +2318,8 @@ check(f"the off-peak probe's own price differs from its hour's :00 price "
|
||||
and _QOFF_I not in (Q_CHEAP_I, Q_DEAR_I))
|
||||
_qoff = decide(QPLAN, qprobe(_QOFF_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
check("a quarter that is neither extremum still idles, for the reason that "
|
||||
"names the right rule, and reports ITS OWN price - not the price of the "
|
||||
@@ -1889,7 +2346,8 @@ check(f"the negative day's dearest quarter is slot {Q_DEAR_I} and it is BELOW "
|
||||
NO_WEATHER))
|
||||
_qneg_sell = decide(QNEG_PLAN, qprobe(Q_DEAR_I), peak_guard_active=False,
|
||||
soc=50.0, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER, charge_w=MAX_W, discharge_w=MAX_W,
|
||||
uncertainty=NO_WEATHER, charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
check("selling into a negative-price QUARTER is refused, and the reason names "
|
||||
"§17 and THAT QUARTER's price",
|
||||
@@ -1902,7 +2360,8 @@ check("...while the SAME quarter-hourly series with that ONE price's SIGN "
|
||||
"FLIPPED discharges instead",
|
||||
decide(qplan_of(QPOS), qprobe(Q_DEAR_I), peak_guard_active=False,
|
||||
soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == DISCHARGE
|
||||
and sum(1 for a, b in zip(QNEG, QPOS) if a != b) == 1)
|
||||
# §8.3's flag, read off the covering point. `_act`'s own provisional guard
|
||||
@@ -1959,11 +2418,13 @@ check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
|
||||
"their own prices",
|
||||
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == CHARGE
|
||||
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == DISCHARGE)
|
||||
# An instant inside a CARRIED hour of the same series. The covering point is
|
||||
# the carried one; the shipped scan answered a point from the FIRM block
|
||||
@@ -1978,7 +2439,8 @@ check("an instant inside a CARRIED hour of the mixed series matches the "
|
||||
and MIX_PTS[MIX_N_H - 1].provisional is True
|
||||
and decide(MIX_PLAN, _mix_carried_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action is None)
|
||||
|
||||
print()
|
||||
|
||||
@@ -1067,14 +1067,19 @@ class _V:
|
||||
|
||||
_here = _Pt(_NOW)
|
||||
_other = _Pt(_NOW - timedelta(hours=3))
|
||||
# SAFETY-45's `headroom_w` is the last positional. It is set to a value that
|
||||
# CANNOT BIND on either call - larger than every sizing below - so this part
|
||||
# goes on grading OPT-09's per-pack sizing and not the capacity-tariff cap that
|
||||
# now sits beside it. test_arbitrage.py section 3b is where it is made to bind.
|
||||
_CLEAR_W = 1e9
|
||||
_sell = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
|
||||
0.0, 0.0, 20.0, 90.0)
|
||||
0.0, 0.0, 20.0, 90.0, _CLEAR_W)
|
||||
_sell_ok = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
|
||||
0.0, 4600.0, 20.0, 90.0)
|
||||
0.0, 4600.0, 20.0, 90.0, _CLEAR_W)
|
||||
_buy = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0,
|
||||
0.0, 0.0, 20.0, 90.0)
|
||||
0.0, 0.0, 20.0, 90.0, _CLEAR_W)
|
||||
_buy_ok = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0, 5000.0,
|
||||
0.0, 20.0, 90.0)
|
||||
0.0, 20.0, 90.0, _CLEAR_W)
|
||||
print(f" sized at 0 W: sell -> {_sell.action}, buy -> {_buy.action}; "
|
||||
f"sized at a rating: sell -> {_sell_ok.action}, buy -> {_buy_ok.action}")
|
||||
check("⚠️ A TRADE NOTHING CAN TAKE IS NOT A TRADE. A 0 W sizing IDLES on both "
|
||||
|
||||
@@ -138,8 +138,8 @@ from app.peakguard import (
|
||||
BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP,
|
||||
CEILING_MONTH_PEAK, FLOOR_AT_SOC_FLOOR, FLOOR_SOC_UNREADABLE,
|
||||
PEAK_OPTION_DEFAULTS, Pack, PeakDecision, binding_soc_floor, blindness,
|
||||
decide, discharge_permitted, excess_w, pack_floor_pct, pack_permissions,
|
||||
quarter_ceiling_w, uncovered_s,
|
||||
charge_headroom_w, decide, discharge_permitted, excess_w, pack_floor_pct,
|
||||
pack_permissions, quarter_ceiling_w, uncovered_s,
|
||||
)
|
||||
from app.validate import validate_startup_config
|
||||
from app.tariff import (
|
||||
@@ -379,8 +379,17 @@ check("at the engagement boundary the excess is zero, so the floor is exactly "
|
||||
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0
|
||||
# ⚠️ `floor_w` IS FORMATTED DEFENSIVELY, AND MUTATION.md's OWN LEDGER IS WHY.
|
||||
# The mutation this check exists for - reverting the `floor > 0` rung to
|
||||
# `excess > 0` - makes `floor_w` None HERE, so a bare `:.0f` raises TypeError
|
||||
# and ABORTS THE WHOLE FILE on the line after the check that just went red.
|
||||
# Parts 2-13 are then ungraded and the recorded coverage of that mutation is
|
||||
# smaller than the truth: exactly what MAINT-05's `delinquent(...)['b']`
|
||||
# subscript did to test_delivery.py, and habit 5 ("a check whose failure mode
|
||||
# is a hang - or a crash - needs a deadline, or a name") in the other costume.
|
||||
print(f" at the boundary: excess {e_edge:.3f} W (an `excess > 0` test would "
|
||||
f"RELEASE: {would_release_on_excess}), floor {released.floor_w:.0f} W "
|
||||
f"RELEASE: {would_release_on_excess}), floor "
|
||||
f"{'(none)' if released.floor_w is None else f'{released.floor_w:.0f} W'} "
|
||||
f"(the shipped `floor > 0` test holds: {released.engaged})")
|
||||
check("...and the claim is held there, not released. `excess` falls to zero as "
|
||||
"soon as the battery is already taking the watts, so thresholding IT is "
|
||||
@@ -5947,6 +5956,281 @@ check("...and the row is green and un-warned, which is what makes the "
|
||||
"operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"]
|
||||
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("13. SAFETY-45 - charge_headroom_w(): the number decide() used to throw "
|
||||
"away")
|
||||
# ---------------------------------------------------------------------------
|
||||
# `decide()` computes `wanted = batt_w + excess` - the DISCHARGE that lands the
|
||||
# quarter exactly on the ceiling - and returns `engaged=False, floor_w=None`
|
||||
# the moment that number is not positive. A negative `wanted` is not "nothing
|
||||
# to say": it is a permitted CHARGE of `-wanted` W, and discarding it is what
|
||||
# let an arbitrage charge sit at max_w against a quarter it was blowing.
|
||||
#
|
||||
# ⚠️ NOTHING IN decide() CHANGES IN THIS CARD, AND THAT IS THE POINT. The
|
||||
# `wanted <= 0` branch is 2026-08-20's anti-bang-bang rung and part 6's replay
|
||||
# is what proves it still holds; this part grades a PURE READ of the number it
|
||||
# already carried. 13g asserts the branch is still there rather than trusting
|
||||
# the diff.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _proj_at(net_w, elapsed):
|
||||
return PeakTracker.project(averager_at(net_w, elapsed),
|
||||
B + timedelta(seconds=elapsed))
|
||||
|
||||
|
||||
def _decide_on(projection, *, batt_w, soc=SOC_OK, cap_w=CAP_W,
|
||||
achievable_w=LIVE_MAX_W, reading=None, frozen=False):
|
||||
return decide(projection, batt_w=batt_w, cap_w=cap_w, reading=reading,
|
||||
achievable_w=achievable_w, soc=soc, soc_floor=SOC_FLOOR,
|
||||
max_hold_s=MAX_HOLD_S, frozen=frozen)
|
||||
|
||||
|
||||
def _decide_at(net_w, elapsed, **kw):
|
||||
return _decide_on(_proj_at(net_w, elapsed), **kw)
|
||||
|
||||
|
||||
def _avg_held(base_w, elapsed, held_w, *, start=B, step=5.0):
|
||||
"""`elapsed` seconds BANKED at `base_w`, then `held_w` standing at the edge.
|
||||
|
||||
⚠️ THE HISTORY AND THE HELD VALUE ARE SEPARATE FIXTURE AXES, and 13c is why
|
||||
this helper exists rather than `averager_at(load - batt, ...)`. The averager
|
||||
credits a sample from its own timestamp to the NEXT one, so the last sample
|
||||
contributes ZERO accumulated seconds and only becomes `held`. That is the
|
||||
real shape of a battery whose command changed a moment ago - the quarter's
|
||||
banked seconds do not retroactively change - and a fixture that moved the
|
||||
whole history with it would be grading a different quarter each time.
|
||||
"""
|
||||
a = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
t = 0.0
|
||||
while t < elapsed:
|
||||
a.add(sample(start + timedelta(seconds=t), base_w))
|
||||
t += step
|
||||
a.add(sample(start + timedelta(seconds=elapsed), held_w))
|
||||
return a
|
||||
|
||||
|
||||
# --- 13a. the number, and it is DERIVED rather than read back ---------------
|
||||
# The house is flat at 2600 W under a 3500 W cap, the battery is idle, and 300 s
|
||||
# of the block are banked. 600 s remain, so the block can still absorb
|
||||
# 900 W x 900/600 = 1350 W of extra offtake and finish exactly on the ceiling.
|
||||
_H45_GAP = 900.0
|
||||
_H45_LOAD = CAP_W - _H45_GAP
|
||||
_H45_EL, _H45_REM = 300.0, QUARTER_S - 300.0
|
||||
_h45_idle = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0)
|
||||
_h45_head = charge_headroom_w(_h45_idle)
|
||||
# The CONSEQUENCE, computed from the fixture's own literals and not from the
|
||||
# module's arithmetic: hold that charge for the rest of the block and the
|
||||
# BILLED AVERAGE is the ceiling. This is the meaning of the number, which is a
|
||||
# different statement from the expression that produces it (habit 2).
|
||||
_H45_FINAL = (_H45_LOAD * _H45_EL
|
||||
+ (_H45_LOAD + _h45_head) * _H45_REM) / QUARTER_S
|
||||
print(f" house {_H45_LOAD:.0f} W under a {CAP_W:.0f} W cap, battery 0 W, "
|
||||
f"{_H45_EL:.0f} s banked: engaged={_h45_idle.engaged}, "
|
||||
f"wanted={_h45_idle.wanted_w:+.0f} W, headroom={_h45_head:.0f} W")
|
||||
print(f" holding that charge for the remaining {_H45_REM:.0f} s bills the "
|
||||
f"block at {_H45_FINAL:.1f} W against a {CAP_W:.0f} W ceiling")
|
||||
check("the guard files NOTHING here - this is the shipped `wanted <= 0` branch, "
|
||||
"untouched, and the state SAFETY-45 is about",
|
||||
not _h45_idle.engaged and _h45_idle.floor_w is None
|
||||
and _h45_idle.wanted_w is not None and _h45_idle.wanted_w < 0.0)
|
||||
check(f"...and yet the quarter can afford {_h45_head:.0f} W of charging, which "
|
||||
f"is the number that was being discarded. Graded by its CONSEQUENCE - "
|
||||
f"the block lands on the ceiling to the watt - rather than by "
|
||||
f"re-deriving -wanted_w with the module's own formula",
|
||||
near(_H45_FINAL, CAP_W, 0.5))
|
||||
check(f"...and it is none of the other numbers this fixture has: not the cap "
|
||||
f"({CAP_W:.0f}), not max_w ({LIVE_MAX_W:.0f}), not the house "
|
||||
f"({_H45_LOAD:.0f}) and not the raw gap under the ceiling "
|
||||
f"({_H45_GAP:.0f}) - the 900/remaining gain is 1.5 here and a check that "
|
||||
f"could not tell those apart would be decoration",
|
||||
not any(near(_h45_head, v, 1.0)
|
||||
for v in (CAP_W, LIVE_MAX_W, _H45_LOAD, _H45_GAP)))
|
||||
|
||||
# --- 13a2. AND THE IDENTITY HOLDS ACROSS THE WHOLE BLOCK --------------------
|
||||
# ⚠️ THE AMPLIFICATION IS REAL AND IT IS REPORTED WITH ITS SIZE (habit 13).
|
||||
# 900/remaining reaches 180 in the last five seconds, so the headroom there is
|
||||
# enormous - and it is bounded in EFFECT, not in magnitude: at every elapsed,
|
||||
# holding it bills the block at exactly the ceiling.
|
||||
H45_ID = []
|
||||
for _el in (0.0, 60.0, 300.0, 600.0, 870.0, 895.0):
|
||||
_rem = QUARTER_S - _el
|
||||
_hd = charge_headroom_w(_decide_at(_H45_LOAD, _el, batt_w=0.0))
|
||||
H45_ID.append((_el, _hd,
|
||||
(_H45_LOAD * _el + (_H45_LOAD + _hd) * _rem) / QUARTER_S))
|
||||
print(" headroom across the block (elapsed, headroom W, billed W): "
|
||||
+ ", ".join(f"({a:.0f}, {b:.0f}, {c:.1f})" for a, b, c in H45_ID))
|
||||
check("at EVERY point in the block, holding the headroom for the rest of it "
|
||||
"bills the block at exactly the ceiling - so the 162 kW answer five "
|
||||
"seconds from the boundary and the 900 W answer at the top of it are the "
|
||||
"same statement about the bill",
|
||||
all(near(c, CAP_W, 0.5) for _, _, c in H45_ID))
|
||||
check("...and the fixture SPANS the amplification rather than sitting in one "
|
||||
f"corner of it: the headroom moves by a factor of "
|
||||
f"{max(b for _, b, _ in H45_ID) / min(b for _, b, _ in H45_ID):.0f} "
|
||||
f"across those six points",
|
||||
max(b for _, b, _ in H45_ID) > 100.0 * min(b for _, b, _ in H45_ID))
|
||||
|
||||
# --- 13b. HOLDING implies ZERO OR UNKNOWN, swept - the family, not one case -
|
||||
# ⚠️ THE PROPERTY THAT MAKES THIS SAFE TO HAND A STRATEGY. Every state that
|
||||
# files a claim has `wanted > 0`, `wanted is None` or `blind`, so this function
|
||||
# can never hand a caller permission the guard's own `lo` is at that instant
|
||||
# refusing. Swept over the same grid part 5's no-negative-floor proof uses,
|
||||
# widened with the two ways a cycle can fail to measure at all.
|
||||
H45_N = H45_BAD = H45_POS = H45_NONE = H45_ZERO = 0
|
||||
for _load in (0.0, CAP_W - 500.0, CAP_W, CAP_W + 300.0, CAP_W + 3000.0,
|
||||
LIVE_MAX_W * 3):
|
||||
for _el in (30.0, 450.0, 870.0):
|
||||
for _bw in (-LIVE_MAX_W, -2000.0, 0.0, 400.0, 4000.0, None):
|
||||
for _sc in (None, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0):
|
||||
for _fz in (False, True):
|
||||
_d45 = _decide_at(_load, _el, batt_w=_bw, soc=_sc,
|
||||
frozen=_fz)
|
||||
_hr = charge_headroom_w(_d45)
|
||||
H45_N += 1
|
||||
if _hr is None:
|
||||
H45_NONE += 1
|
||||
elif _hr > 0.0:
|
||||
H45_POS += 1
|
||||
else:
|
||||
H45_ZERO += 1
|
||||
if _d45.engaged and _hr not in (None, 0.0):
|
||||
H45_BAD += 1
|
||||
print(f" swept {H45_N} (load x elapsed x battery x SoC x frozen) "
|
||||
f"combinations: {H45_BAD} ENGAGED decisions answering a positive "
|
||||
f"headroom; {H45_POS} permit charging, {H45_ZERO} answer 0 W, "
|
||||
f"{H45_NONE} unmeasured")
|
||||
check(f"NOT ONE of the {H45_N} combinations answers a positive headroom while "
|
||||
f"the guard is ENGAGED. A decision whose `lo` is at that instant "
|
||||
f"forbidding charging can never be read as permission to charge",
|
||||
H45_BAD == 0 and H45_N > 800)
|
||||
check("...and the sweep is vacuous in no direction: it reaches decisions that "
|
||||
f"permit charging ({H45_POS}), decisions that permit none ({H45_ZERO}) "
|
||||
f"and decisions nothing measured ({H45_NONE}), so all three answers are "
|
||||
f"exercised", H45_POS > 0 and H45_ZERO > 0 and H45_NONE > 0)
|
||||
|
||||
# --- 13c. IT IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE TO IT
|
||||
# CANNOT OSCILLATE -------------------------------------------------
|
||||
# Raise the charge by dW and the METER rises by dW, so `excess` rises by exactly
|
||||
# dW while `batt_w` falls by dW: `wanted` does not move. The headroom is a
|
||||
# FEED-FORWARD number - a function of the house, the ceiling and the seconds
|
||||
# already banked - so consuming it adds no feedback path around the plant.
|
||||
# Without this property the whole fix would be one loop replacing another.
|
||||
H45_INV, H45_EX = [], []
|
||||
for _bw in (-LIVE_MAX_W, -2000.0, -500.0, 0.0, 500.0, 2000.0):
|
||||
# ONE banked history, and the battery's change visible only in the HELD
|
||||
# value - which is what a command given a moment ago actually looks like.
|
||||
_d = _decide_on(PeakTracker.project(
|
||||
_avg_held(_H45_LOAD, _H45_EL, _H45_LOAD - _bw),
|
||||
B + timedelta(seconds=_H45_EL)), batt_w=_bw)
|
||||
H45_INV.append(charge_headroom_w(_d))
|
||||
H45_EX.append(round(_d.excess_w, 1))
|
||||
print(f" same house, same banked quarter, battery {-LIVE_MAX_W:+.0f}.."
|
||||
f"{2000.0:+.0f} W: headroom {[round(v, 3) for v in H45_INV]}")
|
||||
print(f" ...while the EXCESS those runs see is {H45_EX}")
|
||||
check("the headroom does NOT move when the battery moves across the whole "
|
||||
"range of what it can do - it is a property of the HOUSE and the "
|
||||
"CEILING, not of what the battery happens to be doing, which is what "
|
||||
"makes a trade sized from it feed-forward rather than a second loop "
|
||||
"around the plant",
|
||||
max(H45_INV) - min(H45_INV) < 1.0)
|
||||
check(f"...and the fixture SEPARATES it from the two quantities it is built "
|
||||
f"out of: the battery moved {LIVE_MAX_W + 2000.0:.0f} W and the excess "
|
||||
f"with it, over {len(set(H45_EX))} distinct values, so an implementation "
|
||||
f"answering `-batt_w` or `abs(excess)` would give six different answers "
|
||||
f"here (habit 4)",
|
||||
len(set(H45_EX)) == len(H45_EX)
|
||||
and len(set(round(v, 3) for v in H45_INV)) == 1)
|
||||
|
||||
# --- 13d. AND A BOUNDARY HANDS IT BACK AT THE AFFORDABLE RATE, NOT AT max_w --
|
||||
# ⚠️ SAFETY-45's SECOND HALF, AND THE CLAIM IS NOT "IT DOES NOT CHANGE".
|
||||
# `project()` resets to the held level at `elapsed = 0`, and the headroom
|
||||
# collapses with it - from 162 kW to 900 W here. What the card is about is WHAT
|
||||
# IT COLLAPSES TO: the house and the cap, and nothing else. The behaviour being
|
||||
# removed re-armed at `max_w` regardless of either.
|
||||
_H45_END = charge_headroom_w(_decide_at(_H45_LOAD, 895.0, batt_w=0.0))
|
||||
_H45_TOP = charge_headroom_w(_decide_at(_H45_LOAD, 0.0, batt_w=0.0))
|
||||
print(f" across a boundary, same house: {_H45_END:.0f} W with 5 s of the "
|
||||
f"old quarter left -> {_H45_TOP:.0f} W with the new one 0 s old, against "
|
||||
f"a max_w of {LIVE_MAX_W:.0f} W")
|
||||
check(f"at the top of the new quarter the headroom is exactly the ceiling less "
|
||||
f"the house ({CAP_W:.0f} - {_H45_LOAD:.0f} = {_H45_GAP:.0f} W). A trade "
|
||||
f"refused at 14:59 resumes at 15:00 at the rate the new quarter can "
|
||||
f"afford - the projection resets, this does not reset TO max_w",
|
||||
near(_H45_TOP, _H45_GAP, 1.0) and _H45_TOP < LIVE_MAX_W)
|
||||
check("...and the check is not passing because nothing moved: the headroom on "
|
||||
"the other side of that boundary is two orders of magnitude larger, and "
|
||||
"the PROJECTION really did reset",
|
||||
_H45_END > 100.0 * _H45_TOP and _proj_at(_H45_LOAD, 0.0).elapsed_s < 1.0)
|
||||
|
||||
# --- 13e. the unmeasured answers, and None is NOT unlimited -----------------
|
||||
_h45_nobatt = _decide_at(_H45_LOAD, _H45_EL, batt_w=None)
|
||||
_h45_blind = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0, frozen=True)
|
||||
print(f" no battery reading: {charge_headroom_w(_h45_nobatt)}; "
|
||||
f"BLIND: {charge_headroom_w(_h45_blind)}; no decision at all: "
|
||||
f"{charge_headroom_w(None)}")
|
||||
check("a guard that never ran answers None, not a number - `meter_source: off` "
|
||||
"reaches this with no decision at all and a caller that read it as a "
|
||||
"large headroom would be guard_state()'s None-is-not-False collapse one "
|
||||
"layer down", charge_headroom_w(None) is None)
|
||||
check("...and a cycle with NO BATTERY MEASUREMENT answers None too: there is "
|
||||
"nothing to size a floor from, so there is nothing to size a trade from "
|
||||
"either", charge_headroom_w(_h45_nobatt) is None
|
||||
and _h45_nobatt.wanted_w is None)
|
||||
check("...and BLIND answers 0.0 - a NUMBER, and the number is no charging. "
|
||||
"The claim it files already says exactly that at `floor 0 W`, and this "
|
||||
"agrees with it rather than restating it",
|
||||
charge_headroom_w(_h45_blind) == 0.0 and _h45_blind.blind
|
||||
and _h45_blind.floor_w == 0.0)
|
||||
check("...and the three are TOLD APART: blind is 0.0 and the two unmeasured "
|
||||
"states are None, so a caller can refuse for the reason that is true "
|
||||
"rather than for whichever fired first",
|
||||
charge_headroom_w(_h45_blind) is not None
|
||||
and charge_headroom_w(_h45_nobatt) is None)
|
||||
|
||||
# --- 13f. the SoC floor does not become charging permission -----------------
|
||||
# ⚠️ THE ONE ENGAGED STATE WHERE THE GUARD IS DELIBERATELY NOT DISCHARGING.
|
||||
# SAFETY-24's floor-limited decision files `lo = 0.0` - "charging stays
|
||||
# blocked" - on a quarter that IS over the ceiling. If that read as headroom,
|
||||
# an empty pack in a blown quarter would be told to buy.
|
||||
_h45_lim = _decide_at(CAP_W + 2000.0, _H45_EL, batt_w=0.0, soc=SOC_FLOOR - 1.0)
|
||||
print(f" floor-limited over a blown quarter: engaged={_h45_lim.engaged}, "
|
||||
f"floor_limited={_h45_lim.floor_limited}, wanted="
|
||||
f"{_h45_lim.wanted_w:+.0f} W, headroom={charge_headroom_w(_h45_lim)}")
|
||||
check("a FLOOR-LIMITED decision - the guard wanted a discharge and SoC refused "
|
||||
"to size it - answers 0.0 headroom. The quarter is over the ceiling; the "
|
||||
"pack being empty is not a reason to fill it from the grid in it",
|
||||
_h45_lim.floor_limited and _h45_lim.engaged
|
||||
and charge_headroom_w(_h45_lim) == 0.0)
|
||||
|
||||
# --- 13g. decide() ITSELF IS UNCHANGED, PINNED -----------------------------
|
||||
# ⚠️ MUTATION.md habit 4, in the direction a diff cannot cover. The whole safety
|
||||
# argument of this card is "the 2026-08-20 rung is still there and part 6 still
|
||||
# grades it". That is a claim about the SOURCE, so it is pinned on the source -
|
||||
# and on the BEHAVIOUR, because a shape pin alone would pass a rung that had
|
||||
# been inverted.
|
||||
_H45_SRC = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8")
|
||||
_H45_TREE = ast.parse(_H45_SRC)
|
||||
_h45_fn = next(n for n in ast.walk(_H45_TREE)
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "decide")
|
||||
_H45_RUNG = [n for n in ast.walk(_h45_fn) if isinstance(n, ast.If)
|
||||
and ast.unparse(n.test) == "wanted <= 0.0"]
|
||||
check("the `wanted <= 0.0` rung is still in decide(), spelled exactly that "
|
||||
"way, on the AST rather than as a substring - it is the rung whose "
|
||||
"removal put 2026-08-20's quarter 49 W over the limit after seventeen "
|
||||
"engage/release cycles in 900 s", len(_H45_RUNG) == 1)
|
||||
check("...and it still DISENGAGES rather than merely reporting: the fixture "
|
||||
"13a is built on reaches it and files no claim, so the pin above is not "
|
||||
"grading a rung that has been inverted under it",
|
||||
peakguard.claim(_h45_idle) is None)
|
||||
_h45_head_fn = next(n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef)
|
||||
and n.name == "charge_headroom_w")
|
||||
check("...and charge_headroom_w() is a PURE READ of the decision - one "
|
||||
"positional parameter, no projection, no clock and no options - so it "
|
||||
"cannot become a second answer to the question decide() already answers",
|
||||
[a.arg for a in _h45_head_fn.args.args] == ["decision"]
|
||||
and not _h45_head_fn.args.kwonlyargs)
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
|
||||
+2197
-28
File diff suppressed because it is too large
Load Diff
@@ -1107,21 +1107,59 @@ _OPT04_KEYS = ["arbitrage_enabled", "cycle_cost_threshold_eur",
|
||||
# in config.yaml and nowhere else, which is the arrangement `static_profile`
|
||||
# already has for the same reason.
|
||||
_T15_KEYS = ["price_formula"]
|
||||
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's "
|
||||
f"and TARIFF-15's keys - FCST-03 still added none of its own "
|
||||
f"{_defaults}",
|
||||
# TARIFF-17 added the sixth, and comes here to say so as the comment above
|
||||
# instructs. `price_taxes_profile` is the hour-varying spelling of
|
||||
# `price_taxes_eur_kwh` - a period table of the TAXES component, for the Walloon
|
||||
# and Brussels DSOs whose distribution tariff really does have a day and a night
|
||||
# band (ORES 13,27 / 7,39 c EUR/kWh). It is an ADDITION and not a replacement:
|
||||
# the scalar is untouched, every site on it is unaffected, and validate.py
|
||||
# refuses the configuration that sets both. It lives in this dict and in
|
||||
# config.yaml and nowhere else, which is `static_profile`'s arrangement for
|
||||
# `static_profile`'s reason.
|
||||
_T17_KEYS = ["price_taxes_profile"]
|
||||
# TARIFF-16. `price_export_formula` - the INJECTION leg as `factor x index +
|
||||
# offset` with its OWN coefficients and its OWN vat_pct, because a Belgian card
|
||||
# prices injection on a second formula (Eneco 0,100 against 0,102 on
|
||||
# consumption, VAT-exempt against 6%). Like TARIFF-17's key it is an ADDITION:
|
||||
# `export_price_eur_kwh` is untouched, every site on the scalar is unaffected,
|
||||
# and it lives in this dict and in config.yaml and nowhere else.
|
||||
_T16_KEYS = ["price_export_formula"]
|
||||
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
|
||||
f"TARIFF-15's, TARIFF-17's and TARIFF-16's keys - FCST-03 still added "
|
||||
f"none of its own {_defaults}",
|
||||
_defaults == sorted(["price_provider", "price_markup_eur_kwh",
|
||||
"price_taxes_eur_kwh", "export_price_eur_kwh",
|
||||
"price_csv_path", "price_feed_url",
|
||||
"price_horizon_h", "price_timeout_s",
|
||||
"static_profile"] + _OPT04_KEYS + _T15_KEYS))
|
||||
"static_profile"]
|
||||
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS
|
||||
+ _T16_KEYS))
|
||||
# ...and the later keys are genuinely NEW, not a rename of something that was
|
||||
# already there, or the line above would pass while the mirror silently lost a
|
||||
# key.
|
||||
check("...and OPT-04's four and TARIFF-15's one are additions rather than "
|
||||
"replacements: the nine TARIFF-02/03 keys are all still present",
|
||||
check("...and OPT-04's four, TARIFF-15's one, TARIFF-17's one and TARIFF-16's "
|
||||
"one are additions rather than replacements: the nine TARIFF-02/03 keys "
|
||||
"are all still present",
|
||||
isinstance(_defaults, list)
|
||||
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)) == 9)
|
||||
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)
|
||||
- set(_T17_KEYS) - set(_T16_KEYS)) == 9)
|
||||
# ⚠️ AND THE ONE TARIFF-17 IS THE HOUR-VARYING TWIN OF IS STILL THERE, BY NAME.
|
||||
# The line above counts nine, and a rename of `price_taxes_eur_kwh` to
|
||||
# `price_taxes_profile` would also count nine. The scalar path being unchanged
|
||||
# is this card's first acceptance criterion, and a count cannot grade it.
|
||||
# ⚠️ THE LITERAL 0.11736 IS TARIFF-18's, AND IT IS TYPED HERE ON PURPOSE. The
|
||||
# claim is "the scalar is still its own key, holding its own real number", and
|
||||
# reading that number out of the dict under test would compare it against
|
||||
# itself. TARIFF-18 raised it from 0.06; if it moves again this line goes red
|
||||
# and whoever moved it comes here and says which card.
|
||||
check("...and `price_taxes_eur_kwh` is still its own key beside the table, so "
|
||||
"TARIFF-17 widened the framework rather than renaming the scalar - and "
|
||||
"it still holds TARIFF-18's Flemish figure",
|
||||
isinstance(_defaults, list)
|
||||
and "price_taxes_eur_kwh" in _defaults
|
||||
and "price_taxes_profile" in _defaults
|
||||
and _price.PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"] == 0.11736
|
||||
and _price.PRICE_OPTION_DEFAULTS["price_taxes_profile"] == "")
|
||||
check("...and every one of them is still in config.yaml, which stays the "
|
||||
"source of truth",
|
||||
isinstance(_cfg, str)
|
||||
|
||||
@@ -59,6 +59,7 @@ is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
@@ -71,8 +72,9 @@ from app.price import (
|
||||
)
|
||||
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,
|
||||
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TAXES_PROFILE_OPTION,
|
||||
TariffPlan, parse_static_profile, parse_taxes_profile, resolve_tariff,
|
||||
static_plan, static_status,
|
||||
)
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
@@ -87,6 +89,16 @@ def check(name, cond):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def _reason_of(fn):
|
||||
"""The message fn() raised, or "" - so a check can assert WHICH rule
|
||||
refused rather than only that something did (MUTATION.md habit 6)."""
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return str(e)
|
||||
return ""
|
||||
|
||||
|
||||
def raises(fn, exc):
|
||||
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
|
||||
'it was refused' and 'it broke on the way to being refused' must not grade
|
||||
@@ -1130,6 +1142,189 @@ check("...and the table really does exercise both rungs and the allowing path, "
|
||||
{want for _n, _p, want in _TABLE9} == {"flat", "firm-pair", "allowed"})
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("10. TARIFF-17 - ONE period grammar, not two. A second tiling rule is a "
|
||||
"second set of DST bugs")
|
||||
# ⚠️ THE ACCEPTANCE CRITERION THIS GRADES, and why it needs two checks rather
|
||||
# than one. `price_taxes_profile` carries the TAXES component as a period table
|
||||
# so that a Walloon day/night distribution band can reach the optimiser. The
|
||||
# obvious way to build it is a second parser next to this one - and the two
|
||||
# would agree on every ordinary day and diverge on the 23- and 25-hour days,
|
||||
# where nobody looks and where §11.4 makes the divergence quiet.
|
||||
#
|
||||
# So the claim is an IDENTITY ("it is this grammar"), and MUTATION.md habit 10
|
||||
# says pin an identity structurally and a meaning behaviourally. Both are here:
|
||||
# 10a the grammar family is DEFINED IN EXACTLY ONE MODULE, computed by
|
||||
# parsing every file under app/ rather than by a list somebody typed;
|
||||
# 10b the two options produce the SAME REFUSAL SENTENCES, derived from each
|
||||
# other, which is what a fork would break first.
|
||||
|
||||
print()
|
||||
print("10a. the grammar is defined in exactly one module under app/, and the "
|
||||
"scope is COMPUTED")
|
||||
# ⚠️ A HAND-WRITTEN SCOPE IS THE SAME DEFECT AS A HAND-WRITTEN FIXTURE
|
||||
# (habit 12): it passes while the copy sits in the file nobody added to the
|
||||
# list. Every `app/*.py` is parsed; the family is taken from THIS module's own
|
||||
# top-level names, so it cannot drift from what the grammar actually is.
|
||||
#
|
||||
# ⚠️ AND THE FAMILY LIST HAS ONE DELIBERATE OMISSION, STATED HERE RATHER THAN
|
||||
# ROUNDED OFF (habit 13). `_days` is NOT in it, because `maintenance.py` has a
|
||||
# `_days` of its own - a NAMESAKE, not a copy - and the check below would go
|
||||
# red on it and be deleted by whoever hit it. The namesake is asserted to BE a
|
||||
# namesake, by signature, immediately afterwards, so the omission is graded
|
||||
# rather than assumed.
|
||||
_T17_FAMILY = ("Period", "StaticProfile", "ProfileInvalid", "_check_tiling",
|
||||
"_raise_gap", "_raise_overlap", "_minutes", "_price",
|
||||
"parse_static_profile", "DAY_NAMES", "MINUTES_PER_DAY",
|
||||
"_DAY_INDEX", "_DAY_ALIASES", "SUPPORTED_TZ")
|
||||
|
||||
|
||||
def _t17_defs(path):
|
||||
"""Top-level names a module DEFINES - classes, functions and assignments."""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
out = set()
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||||
ast.ClassDef)):
|
||||
out.add(node.name)
|
||||
elif isinstance(node, ast.Assign):
|
||||
out |= {t.id for t in node.targets if isinstance(t, ast.Name)}
|
||||
return out
|
||||
|
||||
|
||||
_T17_APP = sorted((HERE / "app").glob("*.py"))
|
||||
_T17_WHERE = {name: sorted(p.name for p in _T17_APP if name in _t17_defs(p))
|
||||
for name in _T17_FAMILY}
|
||||
for _n, _w in sorted(_T17_WHERE.items()):
|
||||
print(f" {_n:<22} defined in {_w}")
|
||||
check(f"every one of the {len(_T17_FAMILY)} grammar names is defined in "
|
||||
f"EXACTLY ONE of the {len(_T17_APP)} modules under app/ - a copy of "
|
||||
f"`_check_tiling` in price.py would take this red",
|
||||
all(len(w) == 1 for w in _T17_WHERE.values()))
|
||||
check("...and that module is static_tariff.py in every case, so the grammar "
|
||||
"did not move house either",
|
||||
{w[0] for w in _T17_WHERE.values() if w} == {"static_tariff.py"})
|
||||
check("...and the pin is not vacuous: each of those names really is a "
|
||||
"top-level definition, so the scan found something rather than nothing",
|
||||
all(w for w in _T17_WHERE.values()) and len(_T17_APP) > 5)
|
||||
|
||||
|
||||
def _t17_sig(module, name):
|
||||
tree = ast.parse((HERE / "app" / module).read_text(encoding="utf-8"))
|
||||
fn = next(n for n in tree.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
return [a.arg for a in fn.args.args]
|
||||
|
||||
|
||||
_T17_DAYS = (_t17_sig("static_tariff.py", "_days"),
|
||||
_t17_sig("maintenance.py", "_days"))
|
||||
print(f" _days: static_tariff{_T17_DAYS[0]} vs maintenance{_T17_DAYS[1]}")
|
||||
check(f"...and the ONE name held out of the family is held out honestly: "
|
||||
f"`maintenance._days{tuple(_T17_DAYS[1])}` renders an age in words and "
|
||||
f"is not a second copy of the grammar's "
|
||||
f"`_days{tuple(_T17_DAYS[0])}` day selector",
|
||||
_T17_DAYS[0] != _T17_DAYS[1] and _T17_DAYS[0] == ["value", "field"]
|
||||
and _T17_DAYS[1] == ["n"])
|
||||
# ⚠️ WHAT THIS PIN CANNOT SEE, WITH ITS SHAPE (habit 13). It grades WHERE the
|
||||
# grammar is defined, never WHAT it means: `_check_tiling` could be rewritten
|
||||
# in place to accept a gap and this would still pass. Section 5 and 10b are
|
||||
# what grade the meaning. It also covers `app/` only, and a second grammar
|
||||
# living under `tools/` or in a check file is outside it.
|
||||
|
||||
_T17_BODY = ast.parse(
|
||||
(HERE / "app" / "static_tariff.py").read_text(encoding="utf-8"))
|
||||
_T17_WRAP = next(
|
||||
(n for n in _T17_BODY.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "parse_taxes_profile"),
|
||||
None)
|
||||
_T17_CALLS = ([ast.unparse(n.func) for n in ast.walk(_T17_WRAP)
|
||||
if isinstance(n, ast.Call)] if _T17_WRAP else [])
|
||||
check(f"...and `parse_taxes_profile` is a WRAPPER and not a fork: its whole "
|
||||
f"body calls {_T17_CALLS}, pinned on the AST rather than on the source "
|
||||
f"text, because `\"parse_static_profile\" in src` is true of a copy that "
|
||||
f"merely mentions it",
|
||||
_T17_CALLS == ["parse_static_profile"])
|
||||
|
||||
|
||||
print()
|
||||
print("10b. and the two options give the SAME refusals - derived from each "
|
||||
"other, not typed twice")
|
||||
# ⚠️ EVERY EXPECTED SENTENCE HERE IS THE OTHER OPTION'S OWN SENTENCE with the
|
||||
# key substituted. Nothing is transcribed, so a fork that reworded one refusal
|
||||
# - or that quietly stopped refusing something - is red on the row it broke and
|
||||
# not on all of them.
|
||||
_T17_BAD = [
|
||||
("a gap", {"periods": [{"name": "d", "from": "07:00", "to": "22:00",
|
||||
"days": "all", "eur_per_kwh": 0.13}]}),
|
||||
("an overlap", {"periods": [
|
||||
{"name": "a", "from": "00:00", "to": "24:00", "days": "all",
|
||||
"eur_per_kwh": 0.13},
|
||||
{"name": "b", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": 0.07}]}),
|
||||
("a bad HH:MM", {"periods": [{"name": "d", "from": "7.5", "to": "22:00",
|
||||
"days": "all", "eur_per_kwh": 0.13}]}),
|
||||
("a bad day", {"periods": [{"name": "d", "from": "00:00", "to": "24:00",
|
||||
"days": "funday", "eur_per_kwh": 0.13}]}),
|
||||
("missing days", {"periods": [{"name": "d", "from": "00:00", "to": "24:00",
|
||||
"eur_per_kwh": 0.13}]}),
|
||||
("a zero-length window", {"periods": [
|
||||
{"name": "d", "from": "07:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": 0.13}]}),
|
||||
("no periods list", {"timezone": "europe/brussels"}),
|
||||
("an empty periods list", {"periods": []}),
|
||||
("a foreign timezone", {"timezone": "utc", "periods": []}),
|
||||
("periods that are not a list", {"periods": 3}),
|
||||
("a row that is not an object", {"periods": ["dag"]}),
|
||||
("invalid JSON", "{not json"),
|
||||
("a 24:00 start", {"periods": [{"name": "d", "from": "24:00", "to": "07:00",
|
||||
"days": "all", "eur_per_kwh": 0.13}]}),
|
||||
]
|
||||
_T17_ROWS = []
|
||||
for _what, _bad in _T17_BAD:
|
||||
_a = _reason_of(lambda: parse_static_profile(_bad))
|
||||
_b = _reason_of(lambda: parse_taxes_profile(_bad))
|
||||
_T17_ROWS.append((_what, _a, _b,
|
||||
_a.replace("static_profile", TAXES_PROFILE_OPTION) == _b))
|
||||
for _what, _a, _b, _same in _T17_ROWS:
|
||||
print(f" {'ok ' if _same else 'XX '}{_what:<28} {_b[:74]}")
|
||||
check(f"all {len(_T17_ROWS)} malformations are refused IDENTICALLY under both "
|
||||
f"options, sentence for sentence, once the key name is substituted - "
|
||||
f"which is what one parser means and a fork could not hold",
|
||||
all(row[3] for row in _T17_ROWS))
|
||||
check("...and every one of them really did refuse, or 'the two agree' would be "
|
||||
"two empty strings agreeing",
|
||||
all(row[1] and row[2] for row in _T17_ROWS))
|
||||
check("...and the sentences are not all the same sentence, or the row above "
|
||||
"would be one comparison repeated 13 times",
|
||||
len({row[2] for row in _T17_ROWS}) == len(_T17_ROWS))
|
||||
|
||||
# ⚠️ THE ONE REFUSAL THAT IS DELIBERATELY DIFFERENT, and it is different
|
||||
# because the two options carry different QUANTITIES in the same grammar. A
|
||||
# period without `eur_per_kwh` gets a sentence naming what was wanted, and an
|
||||
# operator who typed an ALL-IN price into the taxes table is out by an energy
|
||||
# price with nothing reporting it.
|
||||
_T17_NOPRICE = {"periods": [{"name": "d", "from": "00:00", "to": "24:00",
|
||||
"days": "all"}]}
|
||||
_T17_M_S = _reason_of(lambda: parse_static_profile(_T17_NOPRICE))
|
||||
_T17_M_T = _reason_of(lambda: parse_taxes_profile(_T17_NOPRICE))
|
||||
print(f" static_profile : {_T17_M_S[-96:]}")
|
||||
print(f" taxes table : {_T17_M_T[-96:]}")
|
||||
check("the 'you left the number out' refusal is the one sentence the two do "
|
||||
"NOT share, and each names its own quantity: an all-in price for "
|
||||
"static_profile, the taxes component alone for the table",
|
||||
"an all-in eur_per_kwh" in _T17_M_S
|
||||
and "an all-in eur_per_kwh" not in _T17_M_T
|
||||
and "TAXES component alone" in _T17_M_T
|
||||
and "NOT an all-in price" in _T17_M_T
|
||||
and _T17_M_S.replace("static_profile", TAXES_PROFILE_OPTION) != _T17_M_T)
|
||||
check("...and both still name the ROW the operator has to go and fix, which is "
|
||||
"the half they do share",
|
||||
"periods[1].eur_per_kwh is missing" in _T17_M_S
|
||||
and "periods[1].eur_per_kwh is missing" in _T17_M_T)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
python3 test_tariff.py
|
||||
|
||||
Ten parts. What each is for, and - where it matters - what it is deliberately
|
||||
NOT allowed to be tested at:
|
||||
Twelve parts. What each is for, and - where it matters - what it is
|
||||
deliberately NOT allowed to be tested at:
|
||||
|
||||
1. THE BLOCK ARITHMETIC, against a worked example written out segment by
|
||||
segment. The fixture is chosen to be non-degenerate on three axes at once:
|
||||
@@ -33,6 +33,17 @@ NOT allowed to be tested at:
|
||||
proven equal to the tier it derives from, and proven never to be lowered.
|
||||
9. AC 3, ON THE REPLAY DATA THAT ACTUALLY EXISTS. See the honesty note below.
|
||||
10. THE WIRING, through the real `Controller` and its real status page.
|
||||
11. TARIFF-13: THE COVERAGE FIGURE PRINTED BESIDE THE PEAK, across a REAL
|
||||
restart - a second `Controller` against the same file. The peak was
|
||||
always durable; the count of banked quarters was an `int` on the
|
||||
tracker and reset to 0, so the page asserted "0 quarter(s) banked"
|
||||
for a month it had measured 120 of. An unreadable store must not
|
||||
produce a coverage figure of 0 either - part 5's argument, one
|
||||
column over.
|
||||
12. TARIFF-13: WHICH QUARTER A TIED PEAK IS ATTRIBUTED TO, decided
|
||||
(earliest wins) rather than left emergent, and pinned on BOTH
|
||||
readers with a newest-first fixture - ascending arrival cannot tell
|
||||
"the earliest quarter" from "the first row written".
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
⚠️ PART 9 IS A TWO-DAY MEASUREMENT AND SAYS SO. THE 30-DAY ANSWER IS ELSEWHERE
|
||||
@@ -106,9 +117,9 @@ from pathlib import Path
|
||||
from app.main import Controller
|
||||
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
|
||||
from app.tariff import (
|
||||
BILLING_TZ, CHECKPOINT_S, NO_RECORD, NOT_OBSERVED, POLICY_MAX,
|
||||
POLICY_NET, POLICY_SUM, PeakStore, PeakTracker, _EUTimezone, billed_w,
|
||||
month_key,
|
||||
BILLING_TZ, CHECKPOINT_S, COUNT_UNREADABLE, NO_RECORD, NOT_OBSERVED,
|
||||
POLICY_MAX, POLICY_NET, POLICY_SUM, PeakStore, PeakTracker, _ClosedBlock,
|
||||
_EUTimezone, billed_w, month_key,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -932,18 +943,28 @@ check("the closed quarter reached SQLite through the tracker the cycle calls",
|
||||
|
||||
# ⚠️ `cycle()` calls sync() ONCE A SECOND against a `blocks` list that keeps a
|
||||
# closed quarter for a DAY. Without a dedupe that is ~86 000 re-banks of the
|
||||
# same row per block, and `recorded` - the number the status page prints as
|
||||
# "quarters banked" - would be a count of loop iterations rather than of
|
||||
# quarters. The database stays correct either way, which is exactly why this
|
||||
# needs its own check instead of being caught by the peak.
|
||||
# same row per block, and a coverage figure derived from the number of times a
|
||||
# block was OFFERED would be a count of loop iterations rather than of quarters.
|
||||
# The database stays correct either way, which is exactly why this needs its own
|
||||
# check instead of being caught by the peak.
|
||||
#
|
||||
# ⚠️ GRADED ON `sync()`'s RETURN, and on the STORE's tally for the month the
|
||||
# quarter is in - TARIFF-13. It used to be graded on `PeakTracker.recorded`,
|
||||
# which was then an `int` incremented inside this very loop, so the dedupe and
|
||||
# the figure that reported it were the same variable. They are not any more:
|
||||
# `recorded_in()` counts rows in `quarter_blocks`, where re-banking the same
|
||||
# `quarter_start` is a PRIMARY KEY no-op and could never have shown up as a
|
||||
# second quarter. A check written against it alone would now pass however many
|
||||
# times sync() re-banked, which is why the return value is the load-bearing
|
||||
# half of this assertion and is asserted first.
|
||||
again = c.peak.sync(c.p1)
|
||||
third = c.peak.sync(c.p1)
|
||||
print(f" sync() called three times over the same closed quarter: "
|
||||
f"{n}, {again}, {third} newly banked; tracker reports "
|
||||
f"{c.peak.recorded} quarter(s)")
|
||||
f"{n}, {again}, {third} newly banked; the store holds "
|
||||
f"{c.peak.recorded_in(B)} quarter(s) for {month_key(B)}")
|
||||
check("...and syncing the same quarter again banks nothing new, so the count "
|
||||
"the status page shows is quarters and not cycles",
|
||||
again == 0 and third == 0 and c.peak.recorded == 1)
|
||||
again == 0 and third == 0 and c.peak.recorded_in(B) == 1)
|
||||
check("...and the peak is unchanged by the repeat",
|
||||
near(c.peak.peak(B).peak_w, 4998.0, 1e-6))
|
||||
|
||||
@@ -957,11 +978,33 @@ peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r
|
||||
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
|
||||
check("the status page carries exactly one capacity-tariff peak row",
|
||||
len(peak_rows) == 1)
|
||||
check("...naming the peak, the soft limit and the quarters banked, all derived "
|
||||
check("...naming the peak, the soft limit and a coverage figure, all derived "
|
||||
"from the controller that was actually built",
|
||||
bool(peak_rows) and "4998 W" in peak_rows[0]["text"]
|
||||
and f"{SHIPPED_CAP_W:.0f} W soft limit" in peak_rows[0]["text"]
|
||||
and "1 quarter(s) banked" in peak_rows[0]["text"])
|
||||
and "quarter(s) banked" in peak_rows[0]["text"])
|
||||
|
||||
# ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH,
|
||||
# NOT TO `when` - TARIFF-13, and it is a KNOWN GAP stated rather than rounded
|
||||
# off. `main.py`'s row reads `self.peak.recorded`, which answers for
|
||||
# `datetime.now()`; the peak beside it follows `when`. The two agree on the
|
||||
# ONLY call site a shipped install has - `checks()` with no argument, at
|
||||
# main.py:4359 - which is why the live page is correct and durable; they part
|
||||
# company only under this suite's `when=` affordance. `PeakTracker` already
|
||||
# carries the scoped form, `recorded_in(when)`, and moving the row onto it is a
|
||||
# one-token edit inside main.py's status-row block, which TARIFF-11 holds. This
|
||||
# check PINS the present behaviour so the gap cannot be forgotten, and part 11
|
||||
# is the authority on the figure itself.
|
||||
_live_month = month_key(datetime.now(timezone.utc))
|
||||
_live_rows = len(c.peak.store.blocks_in(_live_month))
|
||||
print(f" the row read at {month_key(B)} carries the tally for "
|
||||
f"{_live_month} ({_live_rows} quarter(s)), which is this fixture's "
|
||||
f"known scoping gap")
|
||||
check("the row's coverage figure is the LIVE month's tally - graded against a "
|
||||
"row count taken straight from quarter_blocks, so this pins the gap "
|
||||
"rather than restating the property that produces it",
|
||||
bool(peak_rows)
|
||||
and f"; {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
|
||||
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
|
||||
f"on a {SHIPPED_CAP_W:.0f} W limit",
|
||||
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
|
||||
@@ -1149,6 +1192,308 @@ check(f"...and the same IMPORT grader over main.py finds {_main_imports}, so "
|
||||
bool(_main_imports))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("11. TARIFF-13: what the instrument says about its OWN COVERAGE, "
|
||||
"across a restart")
|
||||
|
||||
# THE DEFECT THIS PART PREVENTS. The status row prints a peak read from SQLite
|
||||
# beside a count of banked quarters, and the count used to be an `int` attribute
|
||||
# on `PeakTracker` that started at 0 in `__init__`. A 40-hour soak caught what
|
||||
# that means at a driven restart, on both instances independently:
|
||||
#
|
||||
# before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
# - 10 % of the 3500 W soft limit; 120 quarter(s) banked
|
||||
# after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
# - 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
#
|
||||
# The peak and its attributed quarter persist; the coverage beside them does
|
||||
# not. §9.1's peak is what the utility bills, but the COUNT is what says how
|
||||
# much of the billing month the figure covers - 365 W over 120 quarters and
|
||||
# 365 W over 0 quarters are different claims about the same watt figure, and
|
||||
# after a restart the page made the weaker claim while showing the stronger
|
||||
# one's value. TARIFF-01's argument is that an unreadable store must not read as
|
||||
# a zero peak because a zero peak is DISARMING; a zero COVERAGE beside a live
|
||||
# peak is the same error one column over.
|
||||
#
|
||||
# ⚠️ THE RESTART HERE IS A REAL ONE - a second `Controller` against the same
|
||||
# file on disk, never a re-read of the first object. 11b asserts that the second
|
||||
# object has banked nothing itself, so the number it prints cannot have come
|
||||
# from process memory. Without that precondition this whole part is satisfied by
|
||||
# a counter that was simply never reset.
|
||||
|
||||
_T13 = TMP / "t13-restart.db"
|
||||
|
||||
# The fixture lives in the month being billed TODAY, and the row is read with NO
|
||||
# `when` argument - the LIVE status page, the surface that misinformed. That
|
||||
# also makes the check stable on every day of the year, which a fixed calendar
|
||||
# date is not (TEST-17, one part up, is the same lesson).
|
||||
_now13 = datetime.now(timezone.utc)
|
||||
_mon13 = month_key(_now13)
|
||||
# Anchor on the first 900 s boundary of the billing month, so all N quarters are
|
||||
# inside `_mon13` however close to a month edge the suite happens to run.
|
||||
_ms = datetime(int(_mon13[:4]), int(_mon13[5:]), 1, tzinfo=timezone.utc)
|
||||
_q0 = datetime.fromtimestamp(
|
||||
(int(_ms.timestamp()) // QUARTER_S + 1) * QUARTER_S, timezone.utc)
|
||||
|
||||
_N13 = 6 # > 1, so "count == rows" cannot be satisfied by 0 == 0
|
||||
_W13 = 1234.0
|
||||
|
||||
c13 = built(_T13)
|
||||
for _q in range(_N13):
|
||||
_base = _q0 + timedelta(seconds=QUARTER_S * _q)
|
||||
for _i in range(0, QUARTER_S + 1, 5):
|
||||
c13.p1.submit(sample(_base + timedelta(seconds=_i), _W13))
|
||||
c13.peak.sync(c13.p1)
|
||||
|
||||
# Ground truth from the OTHER tier, which the tracker did not supply - the count
|
||||
# under test must be graded against something that is not itself (habit 2).
|
||||
_truth_before = len(c13.peak.store.blocks_in(_mon13))
|
||||
_row_before = _peak_rows(c13.checks())[0]["text"]
|
||||
print(" before restart: " + _row_before)
|
||||
check(f"precondition: the fixture banked {_N13} quarters in {_mon13}, so a "
|
||||
f"count of 0 and a count of the truth are different numbers",
|
||||
_truth_before == _N13 and _N13 > 1)
|
||||
check(f"...and the row says so before the restart",
|
||||
f"{_N13} quarter(s) banked" in _row_before)
|
||||
|
||||
c13.peak.store.close()
|
||||
|
||||
# THE RESTART.
|
||||
c13b = built(_T13)
|
||||
_row_after = _peak_rows(c13b.checks())[0]["text"]
|
||||
_truth_after = len(c13b.peak.store.blocks_in(_mon13))
|
||||
print(" after restart: " + _row_after)
|
||||
print(f" rows in quarter_blocks: {_truth_before} before, "
|
||||
f"{_truth_after} after; the restarted tracker has banked "
|
||||
f"{len(c13b.peak._seen)} block(s) of its own through sync()")
|
||||
|
||||
check("precondition: the second Controller is a REAL restart - it shares no "
|
||||
"object with the first and has banked nothing through sync(), so any "
|
||||
"count it prints came off the disk and not out of memory",
|
||||
c13b.peak is not c13.peak and len(c13b.peak._seen) == 0)
|
||||
check("the peak and its attributed quarter survive the restart - the durable "
|
||||
"half, asserted so that the coverage claim below is not graded against a "
|
||||
"reading that had already collapsed",
|
||||
_row_before.split(" - ")[0] == _row_after.split(" - ")[0]
|
||||
and f"{_W13:.0f} W" in _row_after)
|
||||
check("...and the COUNT BESIDE IT DOES NOT COLLAPSE TO ZERO, which is the "
|
||||
"defect: a coverage figure that resets while the value beside it "
|
||||
"persists actively misinforms",
|
||||
"; 0 quarter(s) banked" not in _row_after)
|
||||
check(f"...and it is the store's own tally, {_truth_after}, not a "
|
||||
f"process-lifetime counter - graded against a COUNT OF ROWS in the "
|
||||
f"other tier rather than against the property under test",
|
||||
f"{_truth_after} quarter(s) banked" in _row_after and _truth_after >= _N13)
|
||||
|
||||
# ⚠️ The restarted run banks one MORE than it drove, and that is correct: the
|
||||
# checkpointed open block was stale, so `restore()` closed and banked it. The
|
||||
# in-memory counter never saw that block at all - it only ever counted what went
|
||||
# through `sync()` - so this is a third way the old figure under-read, and it is
|
||||
# asserted rather than tolerated. Driven at a chosen `now` so it cannot depend
|
||||
# on when the suite runs.
|
||||
_t13s = fresh_store("t13-stale")
|
||||
_tr13 = PeakTracker(_t13s, cap_w=SHIPPED_CAP_W)
|
||||
_av13 = QuarterAverager(phases=1)
|
||||
_sb = _q0 + timedelta(seconds=QUARTER_S * 40)
|
||||
for _i in range(0, 600, 5):
|
||||
list(_av13.add(sample(_sb + timedelta(seconds=_i), 2000.0)))
|
||||
_t13s.save_partial(_av13.snapshot())
|
||||
_before_stale = _tr13.recorded_in(_sb)
|
||||
_state = _tr13.restore(QuarterAverager(phases=1),
|
||||
now=_sb + timedelta(seconds=QUARTER_S * 3))
|
||||
print(f" stale-block restore: {_state}")
|
||||
print(f" quarters banked in {month_key(_sb)}: {_before_stale} -> "
|
||||
f"{_tr13.recorded_in(_sb)}")
|
||||
check("a block closed by restore() rather than by sync() is counted too - the "
|
||||
"old counter incremented only in sync() and missed it entirely",
|
||||
_before_stale == 0 and _tr13.recorded_in(_sb) == 1
|
||||
and "banked" in _state)
|
||||
|
||||
# --- the count is scoped to the MONTH it is printed beside --------------------
|
||||
# ⚠️ TWO MONTHS AT DIFFERENT COUNTS. Equal counts would be satisfied by a
|
||||
# cross-month total, by a constant, and by reading either month for the other.
|
||||
_t13m = fresh_store("t13-months")
|
||||
_MA = datetime(2026, 4, 6, 9, 0, tzinfo=timezone.utc) # 2026-04
|
||||
_MB = datetime(2026, 5, 6, 9, 0, tzinfo=timezone.utc) # 2026-05
|
||||
_trm = PeakTracker(_t13m, cap_w=SHIPPED_CAP_W)
|
||||
for _m, _k in ((_MA, 3), (_MB, 5)):
|
||||
for _q in range(_k):
|
||||
_t13m.record_block(_ClosedBlock(
|
||||
start=_m + timedelta(seconds=QUARTER_S * _q),
|
||||
offtake_avg_w=900.0 + _q, per_phase_offtake_avg_w=None))
|
||||
print(f" banked by month: {month_key(_MA)}={_trm.recorded_in(_MA)}, "
|
||||
f"{month_key(_MB)}={_trm.recorded_in(_MB)}, "
|
||||
f"a month never written={_trm.recorded_in(datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc))}")
|
||||
check("precondition: the two months were given DIFFERENT numbers of quarters, "
|
||||
"so a count that answered either one for the other would be visible here",
|
||||
month_key(_MA) != month_key(_MB))
|
||||
check("each month reports ITS OWN quarters and not the pair's total - the old "
|
||||
"counter was per PROCESS, so on 1 October the row still showed "
|
||||
"September's tally beside October's empty month",
|
||||
_trm.recorded_in(_MA) == 3 and _trm.recorded_in(_MB) == 5)
|
||||
check("...and a month with no quarters reports 0, which is TRUE of a month "
|
||||
"nothing was written to - the sentinel below is for a store that will "
|
||||
"not answer, and the two states must not be spelled the same way",
|
||||
_trm.recorded_in(datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc)) == 0)
|
||||
|
||||
# --- and a store that cannot answer does NOT report zero ----------------------
|
||||
# The other half of part 5's argument, applied to the coverage figure. `0
|
||||
# quarter(s) banked` and `an unreadable number of quarter(s) banked` are
|
||||
# opposite states: the first says the month has been measured over nothing, the
|
||||
# second says the instrument cannot say. Spelling them the same way is exactly
|
||||
# what this card was raised about.
|
||||
_t13u = PeakStore(str(TMP / "no-such-dir" / "nope13.db"))
|
||||
_tru = PeakTracker(_t13u, cap_w=SHIPPED_CAP_W)
|
||||
print(f" unreadable store: banked_count -> {_t13u.banked_count(_mon13)!r}, "
|
||||
f"rendered -> {_tru.recorded_in(_now13)!r}")
|
||||
check("an unreadable store answers None to a COUNT, never 0 - the store-level "
|
||||
"half",
|
||||
_t13u.banked_count(_mon13) is None)
|
||||
check("...and the rendered figure says it is unreadable instead of naming a "
|
||||
"number, so the sentence cannot read as a measured zero",
|
||||
_tru.recorded_in(_now13) == COUNT_UNREADABLE
|
||||
and f"{_tru.recorded_in(_now13)} quarter(s) banked"
|
||||
== "an unreadable number of quarter(s) banked")
|
||||
check("...and that sentence is not merely NON-zero by accident: the string a "
|
||||
"count of 0 would produce is different text",
|
||||
f"{_tru.recorded_in(_now13)} quarter(s) banked"
|
||||
!= f"{0} quarter(s) banked")
|
||||
|
||||
# ⚠️ A STORE THAT IS OPEN AND WHOSE COUNT QUERY FAILS IS A SECOND CODE PATH, and
|
||||
# the checks above cannot reach it - a handle that never opened short-circuits
|
||||
# on `self._db is None` before any SQL runs, so `banked_count()`'s
|
||||
# `except sqlite3.Error` arm was executed by nothing. Found by this card's own
|
||||
# mutation battery, not by review: returning 0 instead of None from that arm
|
||||
# survived the whole suite at -3 B. It is the same third-path argument part 5
|
||||
# makes for `peak()`, and it is the arm a half-corrupt database on a real
|
||||
# install actually takes.
|
||||
_t13b = fresh_store("t13-broken")
|
||||
for _q in range(4):
|
||||
_t13b.record_block(_ClosedBlock(start=_q0 + timedelta(seconds=QUARTER_S * _q),
|
||||
offtake_avg_w=800.0 + _q,
|
||||
per_phase_offtake_avg_w=None))
|
||||
_trb = PeakTracker(_t13b, cap_w=SHIPPED_CAP_W)
|
||||
_count_ok = _t13b.banked_count(_mon13)
|
||||
_t13b._db.execute("DROP TABLE quarter_blocks")
|
||||
_t13b._db.commit()
|
||||
_count_broken = _t13b.banked_count(_mon13)
|
||||
print(f" open store, count query FAILS: {_count_ok} -> {_count_broken!r}, "
|
||||
f"rendered -> {_trb.recorded_in(_now13)!r}")
|
||||
check(f"precondition: before the table was dropped the same call answered "
|
||||
f"{_count_ok}, so 'it returns nothing now' is a change and not the "
|
||||
f"store's resting state",
|
||||
_count_ok == 4)
|
||||
check("a store whose COUNT QUERY fails answers None too - a different code "
|
||||
"path from a handle that never opened, and the one a half-corrupt "
|
||||
"database takes",
|
||||
_count_broken is None)
|
||||
check("...and it renders as unreadable rather than as 0, so a half-corrupt "
|
||||
"database cannot print a coverage figure that reads like a measured "
|
||||
"month with nothing in it",
|
||||
_trb.recorded_in(_now13) == COUNT_UNREADABLE)
|
||||
|
||||
# --- the count reads the BLOCK tier, not the month tier -----------------------
|
||||
# `monthly_peak` holds one row per MONTH, so counting it answers 1 for every
|
||||
# month that has ever been measured - a figure that is wrong by exactly the
|
||||
# amount that makes it look plausible.
|
||||
check("the coverage figure counts quarter_blocks and not monthly_peak, which "
|
||||
"would answer 1 for every measured month however many quarters it holds",
|
||||
_trm.recorded_in(_MB) == 5
|
||||
and _t13m.peak(_MB).is_observed and _t13m.recompute_month(month_key(_MB)).is_observed)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("12. TARIFF-13: which quarter a TIED peak is attributed to")
|
||||
|
||||
# THE DECISION, recorded in DECISIONS/TARIFF-13-the-coverage-of-a-peak.md §2:
|
||||
# at equal wattage the EARLIEST quarter keeps the attribution. The wattage is
|
||||
# what is billed, so this is not a money question - it is the answer to "when
|
||||
# did my monthly peak happen", and an operator investigating that goes and looks
|
||||
# at one event. A stable answer is the useful one; "most recent wins" guarantees
|
||||
# the answer moves every time the wattage recurs.
|
||||
#
|
||||
# ⚠️ AND THE SOAK'S DRIFT WAS NOT A TIE. The 40-hour run showed 365 W attributed
|
||||
# to three different quarters with no restart and no new peak, and the obvious
|
||||
# reading - "ties re-attribute to the most recent" - is false, measured: the
|
||||
# upsert's term is strictly `>`, so an exact tie never wrote. What moved was a
|
||||
# genuine new maximum SMALLER THAN THE ROW'S OWN RESOLUTION. The row renders
|
||||
# `{peak_w:.0f}`, whole watts; 365.0000001 W is a new peak to the database and
|
||||
# the same "365 W" to the reader. Both cases are pinned below, because the fix
|
||||
# for one is not the fix for the other.
|
||||
|
||||
_t13t = fresh_store("t13-ties")
|
||||
_TB = datetime(2026, 3, 9, 9, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _bank(store, q, w):
|
||||
store.record_block(_ClosedBlock(start=_TB + timedelta(seconds=QUARTER_S * q),
|
||||
offtake_avg_w=w, per_phase_offtake_avg_w=None))
|
||||
|
||||
|
||||
def _qt(n):
|
||||
return _TB + timedelta(seconds=QUARTER_S * n)
|
||||
|
||||
|
||||
for _q in (0, 1, 2, 3):
|
||||
_bank(_t13t, _q, 365.0)
|
||||
_asc = _t13t.peak(_TB)
|
||||
print(f" four EXACTLY equal 365 W quarters, oldest first -> attributed "
|
||||
f"{_asc.quarter_start.isoformat()}")
|
||||
check("an exact tie keeps the EARLIEST quarter: the attribution does not move "
|
||||
"when the same wattage recurs",
|
||||
_asc.quarter_start == _qt(0) and near(_asc.peak_w, 365.0))
|
||||
|
||||
# ⚠️ FED NEWEST-FIRST, which is what separates "the earliest quarter" from
|
||||
# "whichever row was written first". Ascending arrival cannot tell those apart -
|
||||
# they are the same row - so an ascending-only fixture grades neither.
|
||||
_t13r = fresh_store("t13-ties-rev")
|
||||
for _q in (3, 2, 1, 0):
|
||||
_bank(_t13r, _q, 365.0)
|
||||
_desc = _t13r.peak(_TB)
|
||||
_desc_rc = _t13r.recompute_month(month_key(_TB))
|
||||
print(f" the same four fed NEWEST first -> monthly_peak attributed "
|
||||
f"{_desc.quarter_start.isoformat()}, recompute_month attributed "
|
||||
f"{_desc_rc.quarter_start.isoformat()}")
|
||||
check("...and it is the earliest QUARTER, not the first row WRITTEN - fed "
|
||||
"newest-first the answer is still the oldest quarter",
|
||||
_desc.quarter_start == _qt(0))
|
||||
check("...and BOTH readers give it, so the derived record has not diverged "
|
||||
"from the tier it derives from on the tie rule either (part 8's "
|
||||
"invariant, at equal wattage)",
|
||||
_desc_rc.quarter_start == _desc.quarter_start
|
||||
and near(_desc_rc.peak_w, _desc.peak_w))
|
||||
|
||||
# The other direction, and the reason the tie rule may never touch `peak_w`: a
|
||||
# STRICTLY HIGHER quarter must still take the attribution, whenever it arrives.
|
||||
_t13h = fresh_store("t13-higher")
|
||||
for _q, _w in ((0, 365.0), (1, 366.0), (2, 365.0)):
|
||||
_bank(_t13h, _q, _w)
|
||||
_hi = _t13h.peak(_TB)
|
||||
print(f" 365, then 366, then 365 -> peak {_hi.peak_w:.0f} W at "
|
||||
f"{_hi.quarter_start.isoformat()}")
|
||||
check("a strictly higher quarter still takes both the peak and the "
|
||||
"attribution - the tie rule moves the TIMESTAMP and never the watts",
|
||||
near(_hi.peak_w, 366.0) and _hi.quarter_start == _qt(1))
|
||||
|
||||
# And the mechanism the soak actually saw: an increase below the row's own
|
||||
# resolution. Not a tie, and no tie rule can address it.
|
||||
_t13d = fresh_store("t13-subdisplay")
|
||||
_seen13 = []
|
||||
for _q, _w in enumerate((365.0, 365.0000001, 365.0000002)):
|
||||
_bank(_t13d, _q, _w)
|
||||
_seen13.append(_t13d.peak(_TB).quarter_start)
|
||||
print(f" 365.0, 365.0000001, 365.0000002 W all render "
|
||||
f"{_t13d.peak(_TB).peak_w:.0f} W and attribute to "
|
||||
f"{[s.time().isoformat() for s in _seen13]}")
|
||||
check("an increase BELOW the row's whole-watt resolution is a real new maximum "
|
||||
"and does move the attribution - so the soak's drifting quarter was a "
|
||||
"rounded display, not a tie, and is left alone deliberately",
|
||||
len(set(_seen13)) == 3
|
||||
and len({f"{w:.0f}" for w in (365.0, 365.0000001, 365.0000002)}) == 1)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
|
||||
@@ -147,6 +147,16 @@ DERIVED = {
|
||||
# where the ratio came from - a citation alone would leave an operator
|
||||
# reading "0" on the tab with no idea it is not "off".
|
||||
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
|
||||
# TARIFF-18. `price_taxes_eur_kwh` joins the table for `slew_w`'s reason:
|
||||
# it stopped being a figure somebody picked and became one SUMMED FROM
|
||||
# NAMED CARD ROWS. Three needles, because three different things about it
|
||||
# can go wrong on the Configuration tab and only the first is provenance:
|
||||
# where the number came from, that the operator's own DSO gives a
|
||||
# different one, and that changing it moves no trade - which is the claim
|
||||
# an operator is most likely to assume the opposite of when a number
|
||||
# doubles.
|
||||
"price_taxes_eur_kwh": ("TARIFF-18", "YOUR OWN DSO",
|
||||
"MOVES NO TRADING DECISION"),
|
||||
}
|
||||
_uncited = []
|
||||
for key, needles in sorted(DERIVED.items()):
|
||||
|
||||
@@ -18,6 +18,8 @@ import asyncio
|
||||
import sys
|
||||
|
||||
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
|
||||
from app.price import (
|
||||
PRICE_OPTION_DEFAULTS, SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
|
||||
from app.validate import (
|
||||
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
|
||||
@@ -1366,6 +1368,360 @@ check("...and the flat single-inverter config it always refused is refused "
|
||||
-5000, 5000) == [])
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("TARIFF-17 - the taxes period table is refused HERE, at startup, and not "
|
||||
"at 03:00")
|
||||
# ⚠️ WHY IT IS REFUSED IN THIS MODULE AND NOT AT FETCH TIME. `price.assemble()`
|
||||
# reads this table on the ~30 s planning path. A table that only fails when a
|
||||
# fetch happens is a site that stops pricing mid-afternoon, and §11.4 turns
|
||||
# that into a quiet fallback indistinguishable on the status page from a
|
||||
# healthy fixed-tariff day. That is `static_profile`'s argument, one option
|
||||
# over, and this is the same rule.
|
||||
_T17_DAG, _T17_NACHT = 0.1327, 0.0739
|
||||
_T18_SHIPPED_TAXES = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
||||
_T18_SUPERSEDED = tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
_T17_OK = ('{"periods":['
|
||||
'{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",'
|
||||
f'"eur_per_kwh":{_T17_DAG}}},'
|
||||
'{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri",'
|
||||
f'"eur_per_kwh":{_T17_NACHT}}},'
|
||||
'{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun",'
|
||||
f'"eur_per_kwh":{_T17_NACHT}}}]}}')
|
||||
|
||||
# ⚠️ THE ACCEPTED BASELINE COMES FIRST (MUTATION.md habit 7). Every refusal
|
||||
# below varies ONE thing from this line, so a refusal on the next line has
|
||||
# nowhere else to have come from.
|
||||
check("ORES's real dag/nacht table STARTS - it is a valid configuration, and "
|
||||
"the refusals below are one edit away from this one",
|
||||
validate_startup_config(base(price_taxes_profile=_T17_OK),
|
||||
-5000, 5000) == [])
|
||||
check("...and so does the shipped \"\", which is every install that exists: "
|
||||
"the option is an addition, not a requirement",
|
||||
validate_startup_config(base(price_taxes_profile=""), -5000, 5000) == []
|
||||
and validate_startup_config(base(), -5000, 5000) == [])
|
||||
|
||||
_T17_GAP = ('{"periods":[{"name":"dag","from":"07:00","to":"22:00",'
|
||||
f'"days":"all","eur_per_kwh":{_T17_DAG}}}]}}')
|
||||
_T17_P_GAP = validate_startup_config(base(price_taxes_profile=_T17_GAP),
|
||||
-5000, 5000)
|
||||
print(f" {(_T17_P_GAP or [''])[0][:120]}")
|
||||
check("a table that leaves 00:00-07:00 unpriced is REFUSED, by the same tiling "
|
||||
"rule static_profile has - and the refusal names the KEY and the HOURS, "
|
||||
"not merely that something was wrong",
|
||||
why(_T17_P_GAP, "price_taxes_profile cannot be used:")
|
||||
and why(_T17_P_GAP, "leaves mon 00:00-07:00 with no period"))
|
||||
check("...and it is refused for THAT reason and not as a side effect of "
|
||||
"something else this fixture happens to break: exactly one problem",
|
||||
len(_T17_P_GAP) == 1)
|
||||
|
||||
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY WINS. The table decides
|
||||
# when it is set, so a scalar left at a NON-DEFAULT value beside it is a number
|
||||
# the operator typed that changes nothing - on the quantity §11.4 makes hardest
|
||||
# to notice.
|
||||
_T17_P_BOTH = validate_startup_config(
|
||||
base(price_taxes_profile=_T17_OK, price_taxes_eur_kwh=0.1198), -5000, 5000)
|
||||
print(f" {(_T17_P_BOTH or [''])[0][:120]}")
|
||||
check("the table AND a non-default price_taxes_eur_kwh is refused, and the "
|
||||
"refusal says WHICH ONE WINS and what to do - not merely that the two "
|
||||
"are incompatible",
|
||||
why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is "
|
||||
"0.1198")
|
||||
and why(_T17_P_BOTH, "THE TABLE WINS"))
|
||||
check("...and the SHIPPED scalar beside the table is NOT refused, or every "
|
||||
"Walloon site would have to clear a key it never touched. The refusal is "
|
||||
"about a value the operator chose, and the shipped default is the one "
|
||||
"they did not",
|
||||
validate_startup_config(
|
||||
base(price_taxes_profile=_T17_OK,
|
||||
price_taxes_eur_kwh=_T18_SHIPPED_TAXES),
|
||||
-5000, 5000) == []
|
||||
and len(_T17_P_BOTH) == 1)
|
||||
|
||||
# ⚠️ TARIFF-18 - "UNTOUCHED" IS A SET, AND THIS IS THE HALF THAT DECIDES
|
||||
# WHETHER AN UPGRADE STOPS A RUNNING SITE. The Supervisor sets `App.options =
|
||||
# deepmerge(config.yaml defaults, stored options)`, so a saved blob carries
|
||||
# every schema key whether the operator typed it or not - the reference
|
||||
# install's export is 70 of 71 (CHANGELOG 0.9.0). When TARIFF-18 moved this
|
||||
# default 0.06 -> 0.11736, every such blob went from "equal to the shipped
|
||||
# default" to "not equal to it" WITHOUT ANYBODY EDITING ANYTHING. A site that
|
||||
# also carries a taxes table would have met `SystemExit(78)` on the upgrade,
|
||||
# over a number it never typed and that decides nothing. So a SUPERSEDED
|
||||
# default still reads as untouched.
|
||||
#
|
||||
# ⚠️ AND THE PRECONDITION FIRST (MUTATION.md habit 1): if the shipped default
|
||||
# and the superseded one were ever the same number, the two checks below would
|
||||
# be one check written twice, and dropping the superseded set entirely would
|
||||
# leave both green.
|
||||
check(f"the shipped taxes default {_T18_SHIPPED_TAXES} and the superseded "
|
||||
f"{_T18_SUPERSEDED} are DIFFERENT numbers, so 'the shipped one is "
|
||||
f"accepted' and 'the superseded one is accepted' are two claims",
|
||||
_T18_SHIPPED_TAXES not in _T18_SUPERSEDED and len(_T18_SUPERSEDED) >= 1)
|
||||
check(f"...and the superseded set is non-empty ({_T18_SUPERSEDED}), or the "
|
||||
f"loop below quantifies over nothing and passes on a tree that dropped "
|
||||
f"the set entirely - UX-01 AC 4's vacuous clause, one file over",
|
||||
len(_T18_SUPERSEDED) >= 1)
|
||||
for _old in _T18_SUPERSEDED:
|
||||
check(f"...and a blob that still carries the SUPERSEDED default {_old} "
|
||||
f"beside the table STARTS. That value was written into the options "
|
||||
f"file by this add-on, not typed by the operator, so refusing it "
|
||||
f"would stop a working site on an upgrade over an inert number",
|
||||
validate_startup_config(base(price_taxes_profile=_T17_OK,
|
||||
price_taxes_eur_kwh=_old),
|
||||
-5000, 5000) == [])
|
||||
# ⚠️ AND THE RUNG IS STILL A RUNG. A superseded default is not a licence for
|
||||
# any value: 0.1198 is ORES's enkelvoudig rate, a number an operator plausibly
|
||||
# types, and it is still refused beside the table. Without this line the set
|
||||
# above could be widened to "anything" and nothing would report.
|
||||
check("...while a value that is NEITHER the shipped default NOR a superseded "
|
||||
"one is still REFUSED beside the table, naming it - the set of "
|
||||
"'untouched' figures widened by exactly the old defaults and by nothing "
|
||||
"else",
|
||||
why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is "
|
||||
"0.1198")
|
||||
and 0.1198 not in _T18_SUPERSEDED
|
||||
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
|
||||
# ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told
|
||||
# both which figure to return to and that the old default is not the problem.
|
||||
check("...and the refusal names the SHIPPED default to return to AND says the "
|
||||
"superseded default is accepted, so the sentence is actionable on both "
|
||||
"sides of the upgrade",
|
||||
bool(_T18_SUPERSEDED)
|
||||
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}")
|
||||
and why(_T17_P_BOTH, "SUPERSEDED")
|
||||
and all(why(_T17_P_BOTH, str(u)) for u in _T18_SUPERSEDED))
|
||||
check("...and the same non-default scalar WITHOUT the table still starts, "
|
||||
"which is what makes the line above about the PAIR rather than about "
|
||||
"0.1198 being disliked",
|
||||
validate_startup_config(base(price_taxes_eur_kwh=0.1198),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# ⚠️ THE UNITS RUNG, A SECOND QUANTITY RATHER THAN A RESTATEMENT. Every Belgian
|
||||
# DSO prints these bands in c EUR/kWh, and the whole table is copied off ONE
|
||||
# card - so a factor-100 error lands on every row at once and nothing inside
|
||||
# the table disagrees with itself.
|
||||
#
|
||||
# ⚠️ AND THE TWO RUNGS ARE GRADED SEPARATELY, BY THE REASON EACH GIVES, because
|
||||
# the parser's own +-10 EUR/kWh bound catches SOME of these cards and not
|
||||
# others - and a check that only ever exercised the loud ones would pass with
|
||||
# this rung deleted. ORES's 13,27 is refused by the parser; SIBELGA's whole
|
||||
# card - 9,39 dag against 7,10 nacht, the Brussels DSO - is under 10 in both
|
||||
# bands and reaches this rung alone.
|
||||
_T17_ORES_C = ('{"periods":['
|
||||
'{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",'
|
||||
'"eur_per_kwh":13.27},'
|
||||
'{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri",'
|
||||
'"eur_per_kwh":7.39},'
|
||||
'{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun",'
|
||||
'"eur_per_kwh":7.39}]}')
|
||||
_T17_SIB_C = ('{"periods":['
|
||||
'{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",'
|
||||
'"eur_per_kwh":9.39},'
|
||||
'{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri",'
|
||||
'"eur_per_kwh":7.10},'
|
||||
'{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun",'
|
||||
'"eur_per_kwh":7.10}]}')
|
||||
_T17_P_ORES = validate_startup_config(base(price_taxes_profile=_T17_ORES_C),
|
||||
-5000, 5000)
|
||||
_T17_P_SIB = validate_startup_config(base(price_taxes_profile=_T17_SIB_C),
|
||||
-5000, 5000)
|
||||
print(f" ORES : {(_T17_P_ORES or [''])[0][:110]}")
|
||||
print(f" Sibelga : {(_T17_P_SIB or [''])[0][:110]}")
|
||||
check("ORES's card typed in CENTS is refused by the PARSER's bound, and the "
|
||||
"sentence is the parser's - `beyond +-10.0`, naming the row",
|
||||
why(_T17_P_ORES, "price_taxes_profile cannot be used:")
|
||||
and why(_T17_P_ORES, "beyond +-10.0"))
|
||||
check("...and SIBELGA's whole card - 9,39 and 7,10, BOTH under that bound - is "
|
||||
"refused by THIS rung instead, naming both bands and the factor. Without "
|
||||
"it a Brussels site would price a 9.39 EUR/kWh levy and start",
|
||||
why(_T17_P_SIB, "price_taxes_profile carries [7.1, 9.39] EUR/kWh")
|
||||
and why(_T17_P_SIB, "factor of 100"))
|
||||
check("...and the two refusals are DIFFERENT sentences, so the rungs cannot "
|
||||
"mask each other: deleting the units rung leaves the Sibelga line red "
|
||||
"while the ORES line goes on passing for the parser's reason",
|
||||
_T17_P_ORES != _T17_P_SIB
|
||||
and not why(_T17_P_ORES, "factor of 100")
|
||||
and not why(_T17_P_SIB, "beyond +-10.0"))
|
||||
check("...and BOTH cards divided by 100 start, which is habit 7: the refusals "
|
||||
"above are about the units and not about Brussels or Wallonia",
|
||||
validate_startup_config(
|
||||
base(price_taxes_profile=_T17_ORES_C.replace("13.27", "0.1327")
|
||||
.replace("7.39", "0.0739")), -5000, 5000) == []
|
||||
and validate_startup_config(
|
||||
base(price_taxes_profile=_T17_SIB_C.replace("9.39", "0.0939")
|
||||
.replace("7.10", "0.0710")), -5000, 5000) == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("TARIFF-16 - the INJECTION leg is refused HERE, at startup, and the leg "
|
||||
"the card cannot grade is SAID to be ungraded")
|
||||
# ⚠️ WHY IT IS REFUSED IN THIS MODULE. `export_price_at()` is the seam a future
|
||||
# export-aware strategy reads, and a formula that first fails when something
|
||||
# reads it is a defect discovered at the worst possible moment - which is
|
||||
# `static_profile`'s and `price_taxes_profile`'s argument, one option over.
|
||||
#
|
||||
# ⚠️ AND WHAT THIS OPTION DOES **NOT** DO, said before what it does: no
|
||||
# strategy in this add-on reads an injection price. `arbitrage.py` prices the
|
||||
# sell side as displaced IMPORT and compares two all-in CONSUMPTION prices.
|
||||
# test_price.py 17h grades that by parsing every module under app/; what is
|
||||
# graded HERE is that the operator is TOLD it, at startup, in the same breath
|
||||
# as the formula being accepted.
|
||||
import logging as _t16_logging # noqa: E402
|
||||
|
||||
|
||||
class _T16Log(_t16_logging.Handler):
|
||||
"""Every record app.validate emits during one call, rendered."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.lines = []
|
||||
|
||||
def emit(self, record):
|
||||
self.lines.append((record.levelname, record.getMessage()))
|
||||
|
||||
|
||||
def _t16_start(**over):
|
||||
"""`validate_startup_config()` -> (problems, rendered log lines).
|
||||
|
||||
⚠️ THE LEVEL IS FORCED TO INFO FOR THE DURATION AND RESTORED. Without it
|
||||
the capture returns an EMPTY list on a tree that logs correctly, which
|
||||
reads exactly like "the line is not there" - a check whose data cannot
|
||||
reach the failure it names, in the instrument rather than in the check.
|
||||
"""
|
||||
log = _t16_logging.getLogger("goodwe.config")
|
||||
h = _T16Log()
|
||||
was, log.level = log.level, _t16_logging.INFO
|
||||
log.addHandler(h)
|
||||
try:
|
||||
return validate_startup_config(base(**over), -5000, 5000), h.lines
|
||||
finally:
|
||||
log.removeHandler(h)
|
||||
log.level = was
|
||||
|
||||
|
||||
# The two published injection lines, transcribed from the cards. ⚠️ NEITHER
|
||||
# CARRIES A `card` BLOCK, because neither card prints a price beside its
|
||||
# injection formula - that is the state this section exists to grade.
|
||||
_T16_ENECO = ('{"index":"epex_be","resolution":"hourly","factor":0.1,'
|
||||
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}')
|
||||
_T16_CONS = ('{"index":"epex_be","resolution":"hourly","factor":0.102,'
|
||||
'"offset":1.0,"units":"c_eur_per_kwh","vat_pct":6,'
|
||||
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
||||
'"expect_incl_vat":15.55}}')
|
||||
|
||||
# ⚠️ THE ACCEPTED BASELINE COMES FIRST (habit 7). Every refusal below varies
|
||||
# ONE field from this line, so a refusal has nowhere else to have come from.
|
||||
_T16_OK, _T16_OK_LOG = _t16_start(price_export_formula=_T16_ENECO)
|
||||
check("Eneco's real published injection line `0,1 x BELPEX-H - 1,188` STARTS - "
|
||||
"including the part that makes its price NEGATIVE below 11,88 EUR/MWh, "
|
||||
"which is the market and not a misconfiguration",
|
||||
_T16_OK == [])
|
||||
check("...and so does the shipped \"\", which is every install that exists: "
|
||||
"the option is an addition and the scalar still answers without it",
|
||||
_t16_start(price_export_formula="")[0] == []
|
||||
and _t16_start()[0] == [])
|
||||
|
||||
_T16_BAD_PT = _T16_ENECO.replace('"factor":0.1,', '"factor":10.0,')
|
||||
_T16_BAD_OFF = _T16_ENECO.replace('"offset":-1.188', '"offset":-118.8')
|
||||
_T16_P_PT = _t16_start(price_export_formula=_T16_BAD_PT)[0]
|
||||
_T16_P_OFF = _t16_start(price_export_formula=_T16_BAD_OFF)[0]
|
||||
print(f" {(_T16_P_PT or [''])[0][:118]}")
|
||||
print(f" {(_T16_P_OFF or [''])[0][:118]}")
|
||||
check("PRECONDITION: each of those two blobs differs from the accepted "
|
||||
"baseline in exactly one coefficient, so the refusals below are about "
|
||||
"the field each names",
|
||||
_T16_BAD_PT != _T16_ENECO and _T16_BAD_OFF != _T16_ENECO
|
||||
and _T16_BAD_PT.replace('"factor":10.0,', '"factor":0.1,') == _T16_ENECO
|
||||
and _T16_BAD_OFF.replace('"offset":-118.8', '"offset":-1.188')
|
||||
== _T16_ENECO)
|
||||
check("a factor 100x out is REFUSED at startup, by the pass-through rung, "
|
||||
"naming the option the operator typed",
|
||||
why(_T16_P_PT, "price_export_formula cannot be used:")
|
||||
and why(_T16_P_PT, "passes through")
|
||||
and not why(_T16_P_PT, "|offset| above"))
|
||||
check("...and an offset 100x out is refused by the OFFSET rung, so the two "
|
||||
"rungs are graded on their reasons and cannot mask each other",
|
||||
why(_T16_P_OFF, "price_export_formula cannot be used:")
|
||||
and why(_T16_P_OFF, "|offset| above")
|
||||
and not why(_T16_P_OFF, "passes through"))
|
||||
check("...and a malformed blob is refused as a malformed blob, naming the "
|
||||
"field rather than 'invalid configuration'",
|
||||
why(_t16_start(price_export_formula='{"index":"belpex_h"}')[0],
|
||||
"price_export_formula.index is 'belpex_h'"))
|
||||
|
||||
# ⚠️ THE HONEST HALF, AND IT IS A WARNING RATHER THAN A REFUSAL. Eneco prints
|
||||
# its injection formula with NO price beside it, so on that card there is
|
||||
# nothing for the self-check to reproduce. Requiring a `card` block would make
|
||||
# the published line inexpressible; inventing the estimate would grade the
|
||||
# coefficients against arithmetic done with the same coefficients. So the leg
|
||||
# starts, and the operator is told WHICH check did not run.
|
||||
_T16_UNGRADED = [m for lvl, m in _T16_OK_LOG
|
||||
if lvl == "WARNING" and "UNGRADED" in m]
|
||||
print(f" {(_T16_UNGRADED or [''])[0][:118]}")
|
||||
check("a leg with no card block starts, and says at startup that its "
|
||||
"coefficients are UNGRADED - naming what was checked instead, so the "
|
||||
"gap is stated rather than worked around",
|
||||
len(_T16_UNGRADED) == 1
|
||||
and "no \"card\" block" in _T16_UNGRADED[0]
|
||||
and "pass-through" in _T16_UNGRADED[0])
|
||||
_T16_WITH_CARD, _T16_CARD_LOG = _t16_start(
|
||||
price_export_formula=_T16_CONS, price_formula=_T16_CONS,
|
||||
price_provider="custom_formula")
|
||||
# ...and a SECOND run with the two legs genuinely different, for the ordered
|
||||
# pair below. ⚠️ THE FIRST ONE PUTS THE CONSUMPTION CARD ON BOTH LEGS, so
|
||||
# 0.102/6 appears twice and an assertion about arrangement would pass on a
|
||||
# TRANSPOSED rendering - MUTATION.md habit 4, in the fixture of a habit-14
|
||||
# check. The precondition is asserted below rather than trusted.
|
||||
_T16_BOTH_LEGS, _T16_BOTH_LOG = _t16_start(
|
||||
price_export_formula=_T16_ENECO, price_formula=_T16_CONS,
|
||||
price_provider="custom_formula")
|
||||
check("...and a leg that DOES carry a card does NOT print it, or the sentence "
|
||||
"would be noise an operator learns to ignore rather than a report of "
|
||||
"which check ran",
|
||||
_T16_WITH_CARD == []
|
||||
and not [m for lvl, m in _T16_CARD_LOG if "UNGRADED" in m])
|
||||
# ⚠️ AND A CARD ON THIS LEG IS STILL GRADED, at startup, by the same rung.
|
||||
check("...and a card ON THIS LEG that the coefficients do not reproduce is "
|
||||
"REFUSED at startup, so the optional block is optional and not inert",
|
||||
why(_t16_start(price_export_formula=_T16_CONS.replace(
|
||||
'"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0],
|
||||
"price_export_formula does not reproduce its own card"))
|
||||
|
||||
# ⚠️ THE SENTENCE DOCS.md RESTS ON, AND IT IS THE ONE MOST LIKELY TO ROT.
|
||||
_T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO"
|
||||
and "NO STRATEGY IN THIS ADD-ON READS IT" in m]
|
||||
check("the startup line says, in so many words, that NO STRATEGY IN THIS "
|
||||
"ADD-ON READS IT - the operator is told what the option does not do in "
|
||||
"the same breath as it being accepted",
|
||||
len(_T16_INFO) == 1 and "displaced IMPORT" in _T16_INFO[0])
|
||||
# ⚠️ THE ASYMMETRY IS ASSERTED AS AN ORDERED PAIR, NOT AS TWO NUMBERS IN ONE
|
||||
# SENTENCE (MUTATION.md habit 14). "6 and 0 appear somewhere" is satisfied by
|
||||
# the two legs TRANSPOSED - which is the sentence telling an operator that the
|
||||
# add-on has their VAT treatment exactly backwards, on the one line where they
|
||||
# could have seen it.
|
||||
_T16_PAIR = [m for _l, m in _T16_BOTH_LOG if "priced independently" in m]
|
||||
print(f" {(_T16_PAIR or [''])[0][:118]}")
|
||||
check("PRECONDITION: the two legs of that run carry DIFFERENT factors and "
|
||||
"DIFFERENT VAT rates (0.102 at 6 against 0.1 at 0), or the ordered-pair "
|
||||
"assertion below would be satisfied by the transposed rendering it "
|
||||
"exists to refuse",
|
||||
_T16_BOTH_LEGS == [] and '"factor":0.102' in _T16_CONS
|
||||
and '"vat_pct":6' in _T16_CONS and '"factor":0.1,' in _T16_ENECO
|
||||
and '"vat_pct":0' in _T16_ENECO)
|
||||
check("and the 6% asymmetry is reported as an ORDERED PAIR: the transposed "
|
||||
"rendering would read `consumption factor 0.1 at vat_pct 0, injection "
|
||||
"factor 0.102 at vat_pct 6`, which is a DIFFERENT string - so an "
|
||||
"argument swap at the call site cannot hide behind `0.102 is in there "
|
||||
"somewhere`",
|
||||
len(_T16_PAIR) == 1
|
||||
and "consumption factor 0.102 at vat_pct 6, injection factor 0.1 at "
|
||||
"vat_pct 0" in _T16_PAIR[0]
|
||||
and "consumption factor 0.1 at vat_pct 0, injection factor 0.102 at "
|
||||
"vat_pct 6" not in _T16_PAIR[0])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -148,17 +148,23 @@ configuration:
|
||||
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 manual_csv, whose figures are all-in, it is ignored, and logged as ignored, never applied twice."
|
||||
price_taxes_eur_kwh:
|
||||
name: "Tariff · Levies and VAT (EUR/kWh)"
|
||||
description: "Taxes and levies per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them."
|
||||
name: "Tariff · Distribution, levies and VAT (EUR/kWh)"
|
||||
description: "Everything on your bill that is not the energy itself - distribution, levies, excise and VAT - per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them. ⚠️ THIS NUMBER IS NOT A PREFERENCE AND IT IS NOT YOUR INVERTER'S. It ships at 0.11736, DERIVED in TARIFF-18 from the Eneco residential card for Flanders of September 2026 including 6% VAT: Fluvius distribution for Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. The energiebijdrage is 0 since 1 August 2026 - it was merged into the excise, so a figure copied from an older guide would count it twice. ⚠️ CHECK IT AGAINST YOUR OWN DSO. Only the distribution row moves: across the eight Fluvius DSOs it runs from 5,28 c/kWh in Midden-Vlaanderen to 6,70 in West-Vlaanderen, so the Flemish total runs from about 0.117 to 0.131. In Wallonia or Brussels the distribution tariff has a separate day and night rate and does not belong in this field at all - use the levies-by-time-of-day option below instead. ⚠️ CHANGING IT MOVES NO TRADING DECISION: the same amount is added to every hour, so it cancels out of both the cheapest hour and the spread. What it does move is every price you are shown, and the rule that refuses to discharge into an hour whose all-in price is below zero."
|
||||
export_price_eur_kwh:
|
||||
name: "Tariff · Injection price (EUR/kWh)"
|
||||
description: "What you are paid for feeding a kWh back into the grid, all in. One number rather than a series: no supplier here publishes a dynamic injection curve. In Belgium it is often close to zero, which on its own strongly favours using your own production over exporting it. It may be negative - at midday in summer you can be paying to export."
|
||||
static_profile:
|
||||
name: "Tariff · Fixed or day/night tariff (one line of JSON)"
|
||||
description: "Your fixed or day/night contract, written as a single line of JSON. RECOMMENDED EVEN ON A DYNAMIC CONTRACT: this is what the add-on falls back to when the price feed dies, and with it empty a dead feed leaves the site with no tariff at all. The periods must cover every day type exactly once, with no gap and no overlap - a gap is refused at startup rather than filled in, because an invented price is one a strategy would trade on. A fully fixed tariff is one period covering 00:00-24:00 on all days. Prices are ALL IN; the markup and levies above are not added to them. Note that a window written 22:00-07:00 for mon-fri does NOT cover Saturday morning, so a weekend row is required rather than decorative."
|
||||
price_taxes_profile:
|
||||
name: "Tariff · Levies and VAT by time of day (one line of JSON)"
|
||||
description: "Leave this empty unless your network tariff really does charge a different day and night rate - in Flanders it does not, Fluvius distribution is one rate, and the flat figure above is correct. In Wallonia and Brussels it does: ORES charges 13,27 c/kWh by day against 7,39 at night, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10. Written as a single line of JSON in exactly the same shape as the fixed or day/night tariff above, with the same rule that the periods must cover every day type once with no gap and no overlap. ⚠️ THE NUMBERS HERE ARE THE LEVIES ALONE, NOT A FULL PRICE - the add-on adds them to whatever your price source publishes, exactly as it adds the flat figure. Why it matters: a flat figure is the same in every hour, so it moves every hour equally and cannot change which hour is cheapest - and a night network rate is cheap in the same hours the market is cheap, so flattening it makes the add-on think the day is flatter than it is and turn down battery cycles that would have paid for themselves. Set this or the flat figure above, not both: the add-on refuses to start if the two disagree."
|
||||
price_formula:
|
||||
name: "Tariff · Your own tariff formula (one line of JSON)"
|
||||
description: "For price source custom_formula only: your own contract's two coefficients, written as a single line of JSON. Your supplier's tariff card prints the formula as B x index + A - those two numbers exist only on your card, there is no public index of them anywhere, and you are the only person who can read them. Type what the card prints, IN THE UNITS THE CARD PRINTS: every unit conversion and the VAT step are the add-on's job, because those are the two places a human silently loses a factor of 10, 100 or 1.06. Example: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ THE card BLOCK IS REQUIRED AND IT IS WHAT MAKES THIS SAFE: your card also prints the reference index it assumed and the price that comes out, so the add-on recomputes that price from your numbers and REFUSES TO START if it does not come out, telling you by how much. That catches a wrong unit, VAT left out or counted twice, a transposed digit, and the expensive one - network charges folded into the offset, which the add-on would then bill you for a second time. ⚠️ AND ITS LIMIT, IN THE SAME BREATH: it checks your coefficients against your card, not your card against reality, so two numbers copied off the wrong product agree with each other and pass. Fixed numbers only - no expression of any kind is evaluated, and an unrecognised field is refused rather than ignored."
|
||||
price_export_formula:
|
||||
name: "Tariff · Your own INJECTION formula (one line of JSON)"
|
||||
description: "Optional, and only if your supplier pays you a price that moves with the market. Your tariff card has a SECOND formula for what you are paid for the electricity you export, and it is not the first one with a minus in front: Eneco prints `0,1 x BELPEX-H - 1,188` c/kWh for injection against `0,102 x ...` for consumption, and Aspiravi `0,07 x Belpex - 2`. Written in exactly the same shape as your own tariff formula above. ⚠️ INJECTION IS EXEMPT FROM VAT in Belgium while consumption is at 6%, so vat_pct here is normally 0 - that is the one field people copy across from the other formula and get wrong. ⚠️ THE PRICE THIS GIVES CAN BE NEGATIVE, and that is not an error: Eneco pays below zero whenever BELPEX-H is under 11,88 EUR/MWh, which is the middle of a sunny day - you can be charged to export. The add-on accepts that rather than hiding it. The card block is OPTIONAL here and required above, because Eneco prints the injection formula with NO example price beside it and there would be nothing to check your numbers against; if your card does print one, fill it in and the add-on will refuse to start unless your numbers reproduce it. ⚠️ WHAT THIS DOES NOT DO: no part of the add-on trades on an injection price today - it charges cheap to avoid buying dear, and both sides of that comparison are consumption prices. This is what you are shown, and what a future export strategy would read. Leave it empty and the flat injection price above is used, which is what every installation does now."
|
||||
price_csv_path:
|
||||
name: "Tariff · Price CSV file"
|
||||
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
|
||||
|
||||
@@ -128,17 +128,23 @@ configuration:
|
||||
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 manual_csv, waarvan de bedragen alles inbegrepen zijn, wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
|
||||
price_taxes_eur_kwh:
|
||||
name: "Tarief · Heffingen en btw (EUR/kWh)"
|
||||
description: "Belastingen en heffingen per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat."
|
||||
name: "Tarief · Distributie, heffingen en btw (EUR/kWh)"
|
||||
description: "Alles op uw factuur dat niet de energie zelf is - distributie, heffingen, accijns en btw - per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat. ⚠️ DIT GETAL IS GEEN VOORKEUR EN HET IS NIET DAT VAN UW OMVORMER. Het staat standaard op 0.11736, AFGELEID in TARIFF-18 van de Eneco-kaart voor residentiele klanten in Vlaanderen van september 2026, inclusief 6% btw: Fluvius-distributie voor Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. De energiebijdrage is sinds 1 augustus 2026 nul - ze is opgegaan in de accijns, dus een bedrag uit een oudere gids telt ze dubbel. ⚠️ CONTROLEER HET BIJ UW EIGEN DISTRIBUTIENETBEHEERDER. Alleen de distributieregel verschilt: over de acht Fluvius-netgebieden loopt die van 5,28 c/kWh in Midden-Vlaanderen tot 6,70 in West-Vlaanderen, zodat het Vlaamse totaal ongeveer van 0.117 tot 0.131 loopt. In Wallonie en Brussel heeft het distributietarief een apart dag- en nachttarief en hoort het hier helemaal niet thuis - gebruik dan de optie voor heffingen per tijdstip hieronder. ⚠️ DIT WIJZIGEN VERANDERT GEEN ENKELE HANDELSBESLISSING: hetzelfde bedrag komt bij elk uur, dus het valt weg uit zowel het goedkoopste uur als het prijsverschil. Wat het wel verandert is elke prijs die u te zien krijgt, en de regel die weigert te ontladen in een uur waarvan de totaalprijs onder nul ligt."
|
||||
export_price_eur_kwh:
|
||||
name: "Tarief · Injectievergoeding (EUR/kWh)"
|
||||
description: "Wat u krijgt voor een kWh die u op het net zet, alles inbegrepen. Eén getal en geen reeks: geen enkele leverancier hier publiceert een dynamische injectiecurve. In België ligt dit vaak dicht bij nul, wat op zich al sterk pleit voor eigenverbruik in plaats van injectie. De waarde mag negatief zijn - op een zomerse middag betaalt u soms om te injecteren."
|
||||
static_profile:
|
||||
name: "Tarief · Vast of dag/nacht-tarief (één regel JSON)"
|
||||
description: "Uw vaste of dag/nacht-contract, geschreven als één regel JSON. AANBEVOLEN ZELFS BIJ EEN DYNAMISCH CONTRACT: hierop valt de add-on terug wanneer de prijsfeed uitvalt, en staat dit leeg dan blijft de installatie bij een dode feed helemaal zonder tarief achter. De periodes moeten elk dagtype precies één keer bedekken, zonder gat en zonder overlap - een gat wordt bij het opstarten geweigerd in plaats van opgevuld, want een verzonnen prijs is er één waarop een strategie zou handelen. Een volledig vast tarief is één periode van 00:00 tot 24:00 op alle dagen. De prijzen zijn ALLES INBEGREPEN; de marge en heffingen hierboven worden er niet bij opgeteld. Let op: een venster van 22:00 tot 07:00 voor ma-vr dekt zaterdagochtend NIET, dus een regel voor het weekend is noodzakelijk en niet decoratief."
|
||||
price_taxes_profile:
|
||||
name: "Tarief · Heffingen en btw per dagdeel (één regel JSON)"
|
||||
description: "Laat dit leeg tenzij uw netbeheerder werkelijk een ander dag- en nachttarief aanrekent - in Vlaanderen is dat niet zo, de Fluvius-distributie is één tarief, en het vaste bedrag hierboven klopt. In Wallonië en Brussel is dat wél zo: ORES rekent 13,27 c/kWh overdag tegen 7,39 's nachts, AIESH 15,17 tegen 8,21, Sibelga 9,39 tegen 7,10. Geschreven als één regel JSON in precies dezelfde vorm als het vaste of dag/nacht-tarief hierboven, met dezelfde regel dat de periodes elk dagtype één keer moeten bedekken zonder gat en zonder overlap. ⚠️ DE BEDRAGEN HIER ZIJN ALLEEN DE HEFFINGEN, GEEN VOLLEDIGE PRIJS - de add-on telt ze op bij wat uw prijsbron publiceert, net zoals bij het vaste bedrag. Waarom het uitmaakt: een vast bedrag is in elk uur hetzelfde, verschuift dus elk uur evenveel en kan niet veranderen welk uur het goedkoopste is - en een nachttarief van het net is goedkoop in dezelfde uren waarin de markt goedkoop is, dus door het plat te slaan lijkt de dag vlakker dan hij is en weigert de add-on batterijcycli die zichzelf zouden terugverdienen. Vul dit in óf het vaste bedrag hierboven, niet allebei: de add-on weigert te starten als de twee elkaar tegenspreken."
|
||||
price_formula:
|
||||
name: "Tarief · Uw eigen tariefformule (één regel JSON)"
|
||||
description: "Alleen voor prijsbron custom_formula: de twee coëfficiënten van uw eigen contract, als één regel JSON. De tariefkaart van uw leverancier drukt de formule af als B x index + A - die twee getallen staan alleen op uw kaart, er bestaat nergens een publieke lijst van, en u bent de enige die ze kan lezen. Typ wat de kaart afdrukt, IN DE EENHEID DIE DE KAART GEBRUIKT: elke eenheidsomrekening en de btw-stap zijn het werk van de add-on, want dat zijn precies de twee plaatsen waar een mens stilzwijgend een factor 10, 100 of 1,06 verliest. Voorbeeld: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ HET BLOK card IS VERPLICHT EN MAAKT DIT VEILIG: uw kaart vermeldt ook de referentie-index die ze veronderstelt en de prijs die eruit volgt, dus de add-on berekent die prijs opnieuw uit uw getallen en START NIET als het resultaat niet klopt - met vermelding van het verschil. Dat vangt een verkeerde eenheid, btw die ontbreekt of dubbel geteld is, een omgewisseld cijfer, en de dure fout: nettarieven die in de offset verwerkt zitten en die de add-on er dan nog een tweede keer bovenop zou rekenen. ⚠️ EN DE GRENS DAARVAN, IN DEZELFDE ZIN: ze toetst uw coëfficiënten aan uw kaart, niet uw kaart aan de werkelijkheid - twee getallen van het verkeerde product kloppen onderling en komen er dus door. Alleen vaste getallen: er wordt geen enkele expressie geëvalueerd, en een onbekend veld wordt geweigerd in plaats van genegeerd."
|
||||
price_export_formula:
|
||||
name: "Tarief · Uw eigen INJECTIEformule (één regel JSON)"
|
||||
description: "Optioneel, en alleen als uw leverancier u een prijs betaalt die met de markt meebeweegt. Op uw tariefkaart staat een TWEEDE formule voor wat u krijgt voor de stroom die u injecteert, en dat is niet de eerste met een minteken ervoor: Eneco drukt `0,1 x BELPEX-H - 1,188` c/kWh af voor injectie tegenover `0,102 x ...` voor afname, en Aspiravi `0,07 x Belpex - 2`. Geschreven in precies dezelfde vorm als uw eigen tariefformule hierboven. ⚠️ INJECTIE IS IN BELGIË VRIJGESTELD VAN BTW terwijl afname op 6% staat, dus vat_pct is hier normaal 0 - dat is het ene veld dat men van de andere formule overneemt en verkeerd invult. ⚠️ DE PRIJS DIE HIERUIT VOLGT KAN NEGATIEF ZIJN, en dat is geen fout: Eneco betaalt onder nul zodra BELPEX-H lager is dan 11,88 EUR/MWh, en dat is het midden van een zonnige dag - u kunt moeten betalen om te injecteren. De add-on aanvaardt dat in plaats van het te verbergen. Het blok card is hier OPTIONEEL en hierboven verplicht, omdat Eneco de injectieformule afdrukt ZONDER voorbeeldprijs ernaast en er dan niets is om uw getallen aan te toetsen; drukt uw kaart er wél een af, vul ze dan in en de add-on start niet als uw getallen die prijs niet reproduceren. ⚠️ WAT DIT NIET DOET: geen enkel onderdeel van de add-on handelt vandaag op een injectieprijs - hij laadt goedkoop op om duur te vermijden, en beide zijden van die vergelijking zijn afnameprijzen. Dit is wat u te zien krijgt, en wat een toekomstige exportstrategie zou lezen. Laat het leeg en de vaste injectieprijs hierboven wordt gebruikt, zoals elke installatie nu doet."
|
||||
price_csv_path:
|
||||
name: "Tarief · CSV-bestand met prijzen"
|
||||
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
|
||||
|
||||
Reference in New Issue
Block a user