Compare commits

...
11 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 9c65ac2cf8 TARIFF-18: price_taxes_eur_kwh stops shipping at half the real figure
checks / unit (pull_request) Successful in 7m48s
`price_taxes_eur_kwh` shipped at 0.06 EUR/kWh with no recorded provenance -
no card, no date, no component list - and roughly HALF the true Flemish
all-in non-energy figure. It now ships at 0.11736, and the rows it is made
of are in the code rather than in this message:

  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

Eneco residential card for Flanders, September 2026, incl. 6 % VAT.
`app/price.py`'s FLEMISH_TAXES_COMPONENTS_EUR_KWH carries the rows and
test_price.py section 1b re-adds them - and also compares the total against
the card's own figures typed in the test file, so an edit that moved the
rows and the default together is still caught.

WHAT THIS IS NOT. It is not an arbitrage-correctness fix and it cannot be.
This scalar is a LEVEL component: an additive constant cancels exactly in
`max - min` and leaves the argmin where it was. TARIFF-17 shipped that proof
and it is unchanged - test_price.py 16b drives six flat scalars spanning and
exceeding the whole per-DSO range and gets the identical cheapest hour and
the identical spread to the last digit. DOCS and the CHANGELOG say so in
those words.

WHAT IT IS. A display and guard fix:

  * every number an operator reads, which at half the true value made the
    add-on look considerably better than it is; and
  * §17's negative-price guard in 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.09 -> -0.14736
    EUR/kWh of spot. test_arbitrage.py section 2c drives both sides through
    the shipped decide() and pins the boundary from below as well.

The default is Antwerpen's real row, near the LOW end of the 0.117-0.131
per-DSO band, and that is deliberate: the error directions are not
symmetric. Too high carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal - §17's own case. Too low only
declines a discharge that would have paid.

TARIFF-17's INTERACTION, WHICH HAD TO BE DECIDED. Nothing in that rung
hardcoded 0.06; it read PRICE_OPTION_DEFAULTS, so moving the default moves
what it refuses. The Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`, so a saved blob carries the key whether it was
typed or not - every stored 0.06 became "non-default" with nobody editing
anything, and a working Walloon or Brussels site with a taxes table would
have met SystemExit(78) on the upgrade over an inert number. price.py now
carries SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) and validate.py reads a
superseded default as untouched too. The rung is NOT weakened: 0.1198 beside
a table is still refused by name, and the refusal now names both figures.

config.yaml and PRICE_OPTION_DEFAULTS moved together; CFG-03's tripwire
(test_price.py section 1, test_defaults_pin.py) stayed green by agreeing.
test_price.py section 0's fourth check was REWRITTEN rather than re-pointed:
it used to justify the file's 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.

29/29 test files green. Mutation ledger on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 16:24:56 +02:00
admin 7796f168a9 Merge pull request 'TARIFF-17: a charge that varies by hour gets somewhere to live' (#85) from TARIFF-17 into release/1.0
price.py modelled taxes as one flat scalar. In Flanders that is correct - Fluvius distribution is a single rate - but the Walloon DSOs publish day/night bands on the same card: ORES 13,27 vs 7,39, AIESH 15,17 vs 8,21, ORES IMPACT 16,578 vs 5,090 c EUR/kWh. The bands are ANTI-CORRELATED with what the battery wants to do - cheap network hours coincide with cheap spot hours - so flattening them systematically UNDERSTATES the spread and declines cycles that are genuinely profitable.

The flat scalar was never a wrong number. It sits in the additive bucket and provably cannot change which hour is cheapest; the defect was that a component belonging in the order-changing bucket had nowhere to live. That framing is the ticket, and it is what the regression grades.

Reproduced on one ordinary Belgian winter Thursday, same feed, same markup, taxes the only argument differing: five flat scalars spanning AND exceeding the table's own range - 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 - all give cheapest hour 13 and spread 0.1500 to the last digit, while the ORES table gives hour 03 and 0.1788. Asserted through the shipped decision, static_tariff.arbitrage().cheapest, not through assemble()'s output. Before this existed the shipped tree answered assemble(..., <table>) -> TypeError.

The fixture differs in exactly ONE way and that is its own precondition - same caps object, same RawPoint list, same markup, by identity - and the flat arm is a FAMILY rather than a single scalar, so what the comparison measures cannot be a level effect. That closes the shape that produced a whole-suite survivor in TARIFF-11, where a fixture varied two things at once.

GRAMMAR REUSED, NOT DUPLICATED. parse_taxes_profile() is parse_static_profile() with option= and means= as default arguments, so every pre-existing call site and sentence is byte-identical. Graded two ways: one check parses every module under app/ and asserts the 14 grammar names are defined in exactly one of them, with an AST pin that the wrapper's whole body is one call; another DERIVES all thirteen refusal sentences for the new option from the old option's own sentence. Copying the tiling check into price.py is red.

A WHOLE CARD TYPED IN CENTS IS ITS OWN RUNG, and not a restatement of the parser's plausibility bound. ORES's 13,27 trips that bound - but SIBELGA'S ENTIRE BRUSSELS CARD (9,39 / 7,10) IS UNDER IT and would have started as a 9.39 EUR/kWh levy. Graded by its own reason, and the mutation is red on the Sibelga line only.

Malformed tables are refused at STARTUP by validate.py across three rungs. If one ever gets past, load_prices() returns a section 11.4 no-data read naming the key and logs an error - no silent fall back to the scalar, no traceback into the tick.

Both plugins that need the framework to add taxes move 13 -> 03 (custom_formula {ENERGY,MARKUP} and generic_dayahead {ENERGY}); manual_csv, which is all-in, stays at 13. Enumerated from PROVIDERS rather than typed.

Evidence: 29/29 green. Thirteen mutations, all red, and no row is an edit-and-revert - each gets a fresh rsync'd copy with no __pycache__, mounted :ro, then deleted, which closes habit 18 structurally rather than by discipline. Hoisting the lookup out of assemble()'s loop (+17 B) keeps the number right while stopping it varying by hour: red on six named checks in test_price.py and GREEN IN THE OTHER 28 FILES.

Three things named rather than rounded off: one mutant is equivalent and unseparable by any fixture, because StaticProfile.at() calls brussels() on whatever instant it is given; DOCS' bucket table is prose that nothing asserts, though the numbers it quotes are printed by the checks on every run; and the card's own 0.2240/0.2422 figures were NOT verified and were not repeated - replaced with a claim that is derivable from the contract price, that the spread is proportional to factor and the offset drops out.

One design point is explicitly the owner's, not the agent's: a table beside a NON-DEFAULT price_taxes_eur_kwh is a refusal, so an operator adopting the table must set that key back to 0.06. The alternative - warn, table wins - is the silent-loser shape this repo refuses everywhere else, so the refusal was taken and flagged rather than quietly chosen.
2026-09-13 15:40:17 +02:00
glenn schrooyenandClaude Opus 5 2fac0aaf06 TARIFF-17: a price component that varies by hour has somewhere to live
checks / unit (pull_request) Successful in 7m25s
`price.py` modelled the whole non-energy part of a Belgian bill as ONE flat
scalar. This adds `price_taxes_profile`, which carries the TAXES component as
a period table, and makes `assemble()` evaluate it at each point's own instant.

⚠️ THE FLAT SCALAR WAS NOT A WRONG NUMBER, AND THIS IS NOT A CORRECTION OF ONE.
The optimiser reads exactly two quantities 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. Measured, and printed by test_price.py 16b
on every run: with a flat taxes figure of 0.00, 0.0739, 0.1198, 0.1327 or 1.00
EUR/kWh the same 24-hour feed is cheapest at 13:00 in all five cases and its
spread is 0.1500 EUR/kWh in all five cases. What was missing is anywhere for an
HOUR-VARYING non-energy component to live. This is a missing capability, not a
bad constant.

Belgium has one. Fluvius distribution is a single rate - its tariff-card row
reads `Normaal | Uitsl. nacht`, where exclusive night is a separate physical
circuit and not a night register - so nothing changes for the reference site in
Flanders. 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.
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

The regression check grades WHICH HOUR IS CHEAPEST, not the spread: an
implementation that reads the table once and adds one number to every point is
the LEVEL bucket wearing a period table - right magnitude, wrong shape - and a
spread assertion passes on it. Measured: hoisting the lookup out of
`assemble()`'s loop (+17 B) is red on six named checks in test_price.py and
GREEN in the other 28 files.

ONE GRAMMAR, NOT TWO. `static_tariff.py` already shipped the periods, the day
selectors, the tiling rule and the DST handling, so `parse_taxes_profile()` is
the same parser with the option name and the "what this number means" clause as
arguments. A second tiling rule would be a second set of 23- and 25-hour-day
bugs. test_static_tariff.py section 10 pins that two ways: 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 rather than transcribing them.

`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 that names it is refused at `register()`.
It reaches both plugins that take taxes - `custom_formula` ({ENERGY, MARKUP})
and `generic_dayahead` ({ENERGY}) - and not `manual_csv`, which is all-in: the
table is routed through `capabilities().adds`, exactly as the scalar is.

⚠️ 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
indistinguishable from a healthy fixed-tariff day. validate.py refuses three
things: a table that does not tile the day; 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. Both rungs are graded by the REASON each gives,
and mutating them together (TEST-16) moves three rows.

NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "", and the
scalar path is unchanged arithmetically rather than merely in intent:
test_price.py 16f reproduces the pre-TARIFF-17 expression and compares 12
capability/scalar combinations point for point with `==`.

13 mutations, every one red on a named check, ledger in test_price.py below
section 16k with byte deltas and the one equivalent mutant named. 29/29 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 15:31:15 +02:00
admin c518007caf Merge pull request 'TARIFF-11: the add-on stops flattering itself' (#84) from TARIFF-11 into release/1.0
Four places where the add-on's account of itself was wrong, every one in the direction that flatters it, and none of them pinned by any check because nothing graded the strings a human reads.

TariffPlan.words counted points and called them hours - 192 h for 192 quarter-hourly points, which is 48 hours; measured across a 40-hour soak the error ranged 1.79x to 5.33x with the firm/carried mix, and horizon_h was 48.0 at every sample while words never said 48. The section 8.3 refusal did the same and reached the status page through arb.explain(): 'only 1 of 96 hours carry a published price' for 96 points spanning 24. The plan's shape reached no HTTP surface at all - rows['Tariff'] was static_status()'s own sentence and read 'no static profile' at all 802 soak samples, while the plan was provisional for 76% of them and carried mixed 900/3600 s spacing for 70%. And main.py's 'if and only if' comment was false: cycle() returns on missing inputs before self.arb = None, so a site with trading ON rendered 'off (arbitrage_enabled)' - naming an option that was on.

A FIFTH instance was found that the card did not list, in price_forecast.py's '%d of %d hours' - carried hours over points - which DOCS quotes verbatim, so leaving it would have re-broken the doc.

THE CHECK IS THE DELIVERABLE, not the fixes. It renders Controller.status_payload()['rows'], parses each number out of its OWN slot, and compares it against a figure computed from the fixture's two construction parameters - never from the property the row was built out of. The slot parser is calibrated first against a correct row AND against a row wrong exactly as the defect was. Fixtures are a SET of four plan shapes and the check computes and prints the table of field pairs no fixture separates. origin and product are graded by disagreement over identical point lists, the Arbitrage row's states by inequality, the end-to-end row against the captured document's own length.

The two Tariff sources stay distinguishable: one row, two explicitly timestamped halves, with the age taken from tariff_at - which TARIFF-12 deliberately does not move on a kept plan - and measured by the caller rather than by a second clock inside the renderer. The same plan object at 0 s and 4000 s must render differently, and hardcoding the age to 0 is red.

FCST-04's Tariff-to-DOCS pin was honoured rather than routed around: wiring the plan in took test_price_history.py red until DOCS carried the pinned sentence, the row's five facts, the two-clock warning and a four-row table of the Arbitrage renderings. The trap fired exactly as designed.

Evidence: 29/29 green, re-verified after merging TARIFF-13. Sixteen mutations, no survivors; one is a zero-byte edit transposing firm and carried, and it is red.

TWO MUTATIONS BIT THE AGENT'S OWN CHECKS AND BOTH WERE CLOSED. One was a survivor of the whole suite at -190 B: the origin fixture gave the fallback plan a distinct provider string as well as a distinct origin, so the rows differed for two reasons and dropping origin entirely changed nothing observable - habit 4, occurring inside the fix for habit 4. The provider string is now held equal as its own precondition. The other produced a traceback and no named red, because a datetime comparison raised inside an f-string OPERAND rather than inside the condition - FCST-06's finding, one layer over.

Left for its own card, named rather than folded in: TARIFF-09's price_feed_url override warning still predicts the wrong degraded state - on a cold store the unpublished hours are ABSENT, not carried and flagged - and absence is the worse state.
2026-09-13 14:07:07 +02:00
admin a11597e0b3 Merge remote-tracking branch 'origin/release/1.0' into TARIFF-11
checks / unit (pull_request) Successful in 7m20s
# Conflicts:
#	goodwe_controller/CHANGELOG.md
2026-09-13 13:51:35 +02:00
adminandClaude Opus 5 610725bed9 TARIFF-11: the plan's account of itself, corrected and put on the page
Four defects of one shape - the add-on's report of its own tariff plan was
wrong, always in the direction that flattered it - and one check, which is
the deliverable.

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 this was wrong every time it was printed, and NOT by a
   constant factor: a 40-hour soak measured 1.79x .. 5.33x, because §8.3's
   carried points are hourly while the firm ones are quarter-hourly.
   `words` now reports both quantities, plus the firm/carried split and the
   spacing, in FCST-04's *Price history* wording so the two rows can be
   compared.

2. §8.3's firm-pair refusal and the flat-tariff refusal did the same, and
   they reach an operator through `self.arb.explain()`. This is exactly the
   point<->hour conflation TARIFF-14 removed from `at()`, left standing in
   the prose. `price_forecast.py`'s §8.3 carry-forward log line carried the
   third instance - a carried-HOUR count over a POINT count - and DOCS
   quotes it verbatim, so it is corrected here too.

3. The plan reached no HTTP surface at all. `rows["Tariff"]` was
   `static_status()`'s 30 s-tick sentence and never saw the provider plan -
   it read 'no static profile' at all 802 soak samples on a site whose feed
   was answering. It now carries the plan's shape. ⚠️ The two sources are
   kept distinguishable: the plan half is stamped with its own age, measured
   from `tariff_at`, because it can be up to TARIFF_MAX_AGE_S (5400 s) old.
   FCST-04 pins that row to DOCS as a pair; DOCS moves in this commit.

4. `main.py`'s "if and only if" was false. `cycle()` returns at the input
   hold before `self.arb = None`, so an armed site read
   `off (arbitrage_enabled)` - naming an option that is on - and, after one
   completed cycle, showed a decision up to 15 s stale as a current one. The
   row now reads the OPTION and separates three facts; `cycle_at` and
   `arb_at` make "this cycle reached no decision" an observation.

THE DELIVERABLE IS THE CHECK. All four survived 29 green suites because
nothing graded the strings a human is shown. `test_price.py` section 12
renders through the shipped `status_payload()`, parses each number out of
its OWN SLOT and compares it against a figure computed from the fixture's
construction parameters - not from the property the row was built out of
(habit 4), and not as a substring of a sentence the same file wrote (habit
14). The fixture set is four plan shapes and the check computes and prints
the field pairs no fixture separates. The *Arbitrage* row's states are
driven through real cycles with a sensor answering None and graded by
INEQUALITY.

No behaviour changed: no threshold, refusal rung, claim or setpoint moved.

MUTATIONS: 16, no survivors, one at +0 B (the risky delta) and red. Two were
found by the battery INSIDE THIS CARD'S OWN CHECK: a -190 B survivor whose
fixture varied two things at once (habit 4), and a mutation whose red was a
TRACEBACK in an f-string operand and therefore named nothing (FCST-06). Both
closed; the table is on the PR.

All 29 test files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 13:51:05 +02:00
admin b4a3801f68 Merge pull request 'TARIFF-13: the count of what was measured is as durable as the measurement' (#83) from TARIFF-13 into release/1.0
A 40-hour soak drove a restart and caught the capacity-tariff row asserting '0 quarter(s) banked' for a month in which 120 quarters had been measured. The peak and its attributed quarter persisted; the count did not. It is the count, not the peak, that tells an operator how much of the billing month the measurement actually covers - a peak of 365 W over 120 quarters and the same peak over 0 quarters are very different claims about the same number, and after a restart the page made the weaker claim while displaying the stronger one's value.

Reproduced through a real Controller and P1Ingest with a real restart, never by reading code: 12 -> 0 with the store still holding 12 rows. Fixed durable rather than removed - the count is the useful half of the row. PeakStore.banked_count(month) queries quarter_blocks and `recorded` became a property over it. After the fix the printed count matches the store on both sides, 12 -> 13, and 13 is CORRECT: the restart's restore() banks the stale open block, which the old process-wide counter never saw.

TWO DEFECTS THE TICKET DID NOT NAME, both closed by the same change: the count was never month-scoped, so a fresh October reported a quarter banked from September; and it missed blocks banked by restore(). An unreadable store now returns None and renders 'an unreadable number of quarter(s) banked' - rendering it as 0 was rejected because it reads as a real zero, which is TARIFF-01's own disarming-value argument applied to the coverage figure beside the peak.

THE TICKET'S SECOND FINDING WAS FALSE AND THE AGENT DISPROVED IT. I wrote that ties at the same wattage are re-attributed to the most recent matching quarter. The upsert is strictly '>', so an exact tie never writes at all - six exactly-equal 365.0 W quarters re-attributed zero times. The soak's drift was a real new maximum below the row's whole-watt display resolution: 365.0000001 W is a new peak to the database and the same '365 W' to the reader. A plausible diagnosis, checked rather than implemented.

Tie attribution is nevertheless decided deliberately - EARLIEST wins - on what the row is for rather than on money: wattage is what is billed, and an operator investigating one event needs a stable answer. It is also what recompute_month() already did, and the two readers genuinely diverged: fed newest-first, monthly_peak answered 09:45 and the recompute 09:00, breaking the invariant part 8 exists to prove. peak_w is untouched and SAFETY-07 reads identical watts.

Evidence: 29/29 green. Eleven mutations, all red, purged before each with the fingerprint verified after every revert. One was a REAL SURVIVOR at -3 B: banked_count() has two None paths and the first battery reached only one, because a never-opened handle short-circuits before any SQL. Closed by dropping the table out from under an OPEN store, behind a precondition asserting the same call answered 4 first.

main.py is untouched - the row picks the fix up unedited because `recorded` is now a property. The residual is named rather than hidden: `recorded` answers for now() while the peak follows checks(when=...). Production has exactly one call site, self.checks() with no argument, where both are the same month, so the live page is correct and durable; they part only under the suite's when= affordance, and that mismatch is strictly smaller than the process-wide counter it replaces. recorded_in(when) is shipped and the row's move onto it is one token.
2026-09-13 13:03:30 +02:00
glenn schrooyenandClaude Opus 5 a991438e40 TARIFF-13: the coverage figure beside the peak stops resetting at a restart
checks / unit (pull_request) Successful in 7m27s
The capacity-tariff status row printed a peak read from SQLite next to a count
of banked quarters read from PROCESS MEMORY - `PeakTracker.recorded`, an `int`
set to 0 in `__init__` and counted up 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 changed, through the real Controller and a real
restart - a second Controller object against the same file on disk - at 12 -> 0
with 12 rows standing in `quarter_blocks`.

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. Mid-month, after every
restart, the page made the weaker claim while displaying the stronger one's
value. That is TARIFF-01's own argument - an unreadable store must not read as a
zero peak, because a zero peak is disarming - one column over.

`PeakStore.banked_count(month)` asks the database, and `recorded` is a property
over it. Two more defects in the same variable fell out once it was driven, and
neither was 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 - measured), and it
missed the block `restore()` banks from the checkpoint, which never passes
through `sync()`.

⚠️ NOT 0, AND NOT None, WHEN THE STORE WILL NOT ANSWER. `banked_count()` returns
None and the row renders `an unreadable number of quarter(s) banked`.
`f"{None} quarter(s) banked"` was rejected because it 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 the two are asserted to be different text.

⚠️ THE DEDUPE IS NOT REMOVED WITH THE COUNTER. `_seen` still stops `cycle()`'s
once-a-second `sync()` re-banking a block `P1Ingest.blocks` keeps for a day. The
check that graded it asserted `recorded == 1` - the dedupe and the figure
reporting it were the same variable - and now grades `sync()`'s RETURN, because
counting rows cannot see a re-bank at all (PRIMARY KEY no-op).

THE TIE RULE, DECIDED RATHER THAN EMERGENT: at equal wattage the EARLIEST
quarter keeps the attribution. No bill and no guard moves - `peak_w` is still
only ever raised, the `>` term is untouched, SAFETY-07 reads the same watts.
The two readers DID disagree: the upsert's strict `>` kept whichever tied block
was WRITTEN first while `recompute_month()` has always kept the earliest, the
same only while blocks arrive in time order, and measured to diverge (09:45
against 09:00) fed newest-first.

⚠️ AND THE SOAK'S DRIFTING QUARTER WAS NOT A TIE. Six exactly equal 365.0 W
quarters re-attributed zero times on the shipped tree - `>` is strict. What
moved it was a real new maximum BELOW THE ROW'S OWN RESOLUTION: the row renders
whole watts, so 365.0000001 W is a new peak to the database and the same 365 W
to the reader. Left alone deliberately, and pinned so it is not re-diagnosed.

test_tariff.py parts 11 and 12. 12 mutations, all red, listed on the PR; M2 -
`banked_count()`'s sqlite3.Error arm returning 0 - was a SURVIVOR at -3 B on the
first battery, because a handle that never opened short-circuits before any SQL
runs and nothing reached the except arm. Closed with an open store whose table
is dropped under it, with a precondition that the same call answered 4 first.

main.py is NOT touched - TARIFF-11 holds its status rows. The row picks this up
without editing because `recorded` is an attribute access and is now a property.
The residual is stated in DECISIONS §3: `recorded` answers for now() while the
peak follows `checks(when=...)`, and the shipped install has exactly one call
site - `self.checks()` with no argument, main.py:4359 - where they are the same
month, so the live page is correct and durable. `recorded_in(when)` is shipped
and the row's move onto it is one token, for whoever holds main.py next; part 10
pins the present behaviour against a row count taken from `quarter_blocks` so
the gap cannot be quietly forgotten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 12:54:15 +02:00
admin 068b83f57c Merge pull request 'TARIFF-15: custom_formula, graded against the card it was copied from' (#82) from TARIFF-15 into release/1.0
The owner asked for a custom formula for exotic suppliers. TARIFF-07's research established why it is the only shape that can work: there is no machine-readable source of Belgian tariff coefficients anywhere - CREG's tariff-card pages 404, VREG was unreachable, and every coefficient found came from a supplier's own PDF, one image-only. Coefficients are per-contract and live behind a klantenzone login. So no scraping and no per-supplier plugin can work; the customer is the only party who can see their own numbers, and the card they read them from is the only artefact that can grade them.

The shape is the market's published arithmetic rather than our approximation: both readable cards print the formula, the reference index assumed, AND the resulting price. Through shipped code, Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.54705 against a card printing 15,55; ENGIE (0.1 x 61.96 + 0.9) x 1.06 = 7.52176 against 7,522.

That is also the safety mechanism. validate.py recomputes the card's own worked example at startup and REFUSES TO START unless it matches to 0.01. An offset inflated by network charges cannot pass, because the card's printed estimate is by construction the energy component alone. The refusal names how far out, in which direction, by what ratio, and then names the expensive cause: network charges folded into `offset` bills you for it twice, which is why the check cannot be switched off.

NO EXPRESSION EVALUATION, guaranteed three structural ways - coefficients must be numbers, an unknown field is refused rather than ignored, and there is no evaluator in the module - and checked by an AST walk rather than a grep, because the module's own docstring contains the text eval(s, {"__builtins__": {}}) explaining why that is not a sandbox, and a text scan would fire on the documentation it mandates. That is code_leaks()'s lesson applied to a second file. A mutation plants a real eval in the shipped module and turns it red.

TWO CORRECTIONS TO THE TICKET, both the agent's and both right. The plausibility band's floor was specified on the all-in figure; the shipped 0.06 of taxes is 3x the 0.02 floor, so a hundredfold error (0.0016 -> 0.0616) sails over it. The floor now sits on the ENERGY figure and the ceiling on the all-in. And the ticket claimed the card check catches 10x/100x/1000x errors: it catches a MISMATCH between the two sides, and a unit error applied to both cancels out of a comparison between them. The wording was narrowed everywhere rather than restated, and the band added as a separate refusal on a separate quantity.

Stated limits rather than glossed: the check grades the coefficients against the card, not the card against reality - a consistent-but-wrong pair, both numbers from the wrong product, passes, and DOCS says so in the same sentence as the claim. Only TWO real tariffs could be reproduced end to end, not three: the repo's third real formula (Eneco injection) has no printed price beside it, so there is nothing to grade it against and none was fabricated.

Evidence: 29/29 green, 347 checks in test_price.py. 22 mutations, no survivors. One was a -13 B SURVIVOR on the first attempt - the check graded the preview's heading rather than the row, the third instance this week of a check anchored to something it does not own - and is now graded on a rendered row built from the capture. One mutation passes the all-in check by declaring ALL_IN, exactly as trap 1 says no code can catch; what goes red is the declaration-agreement check plus the card reproductions.

The riskiest edit is flagged rather than buried: test_price.py's summary block had to move, because a section appended after `if fails: sys.exit(1)` would have left every new check's failure unreported with exit 0. Verified on the base that sections 13 and 14 were always before it, so no earlier evidence is affected.
2026-09-13 03:58:46 +02:00
Claude Opus 5 ac9854886b TARIFF-15: your own tariff card, as two coefficients the card itself grades
checks / unit (pull_request) Successful in 7m14s
A Belgian dynamic contract prices energy as `B x index + A`, and the two
coefficients exist on exactly one artefact: the customer's own tariff card.
There is no machine-readable source for them anywhere - CREG's consumer
tariff-card pages 404, CREG Scan is a JS wizard, the annual report has the
supplier list without per-product coefficients, VREG was unreachable, and
every coefficient found in research came off a supplier's own PDF, one
image-only. So nothing can be scraped and a per-supplier plugin would have no
source for its numbers, which is why TARIFF-07 withdrew three of them. The
customer is the only party who can see their own coefficients; they type them.

New provider `custom_formula`, one new option `price_formula` (a JSON string,
the `static_profile` precedent exactly), and a new module
`app/custom_formula.py`. The index path is `generic_dayahead`'s, not a second
one: the URL/override decision is now one shared `dayahead_feed_url()` both
plugins call, same feed, same Brussels-calendar range, same columnar parser,
same declared egress hosts.

It declares {ENERGY, MARKUP}, NOT ALL_IN. A card's Tariefformule line is the
energy component alone - Eneco tabulates network tariffs and levies
separately, ENGIE states the formula excludes network, distribution,
transport, levies and excise - so ALL_IN would lie for every operator who
typed what their card says and would drop the taxes from every hour
(0.2125 -> 0.1555 EUR/kWh, measured).

THE CARD IS THE GRADER, and that is why this is safe to ship. The framework
already mechanises that a PLUGIN cannot double-count. This introduces a risk
none of those guards can see because it is config, not code: the OPERATOR
double-counting, by folding network charges into `offset` while
price_taxes_eur_kwh carries them too. So validate.py recomputes the card's own
printed price from the operator's coefficients at startup and refuses to start
unless the two agree to within 0.01, naming the discrepancy. An inflated
offset cannot pass: 15.55 becomes 28.27. The same check catches VAT omitted,
VAT doubled, a transposed digit and a unit mismatch between the two sides.

And the limit, in the same sentence as the claim: it grades the coefficients
against the card, NOT the card against reality. A consistent-but-wrong pair
passes. A second refusal covers the one unit error the first cannot see - the
one applied to both sides at once - by bounding the card's own point to
0.02-1.00 EUR/kWh. Its FLOOR is applied to the energy figure rather than the
all-in one because the shipped 0.06 of taxes is three times that floor and
would carry a hundredfold error over it alone; found by writing the mutation,
not by review (M10, +0 B).

The market's own arithmetic, reproduced through the shipped code:

  Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.5471   card prints 15,55
  ENGIE (0.1   x  61.96   + 0.9) x 1.06 = 7.5218  card prints 7,522

NO EXPRESSION EVALUATION OF ANY KIND. The string is settable over HTTP through
the add-on's own ingress page, so eval on it is RCE in a root container
holding SUPERVISOR_TOKEN and driving an inverter over RS485 - but the deciding
argument is that an expression is UNVERIFIABLE: two coefficients can be graded
against the card, an arbitrary expression cannot be graded against anything,
so it would make the invisible-wrong-coefficient problem strictly worse. An
unknown field is REFUSED rather than ignored, so `"expr"` is told it does not
exist. Future shapes become named fields, never a grammar. test_price.py 15f
walks the module's AST for eval/exec/compile/__import__/literal_eval and
exercises the scanner's red direction on every run.

Refused at startup: card absent or out by >0.01; `units` absent (no default);
factor == 0 (every spread 0, arbitrage silently never fires - the message
points at static_profile); factor < 0 (inverts the market); vat_pct outside
0-30 (0 stays legitimate); non-finite or non-numeric; unknown field, index or
resolution; the plausibility band. A card older than ~6 months WARNS and does
not refuse - ENGIE freezes coefficients for a contract's duration. Negative
output prices are not refused anywhere.

A startup preview logs the next 24 h as `index EUR/MWh -> all-in EUR/kWh` with
min/median/max and the day's spread, through a new default-silent
`PriceProvider.startup_preview()` hook - only the plugin knows what went in to
its own arithmetic. Emitted once per process, and a preview that raises cannot
cost the site its prices.

`custom_formula` joins the config.yaml enum through the DERIVED set
PROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hardcoded list. Both
translations describe the option and the new id. DOCS.md replaces the
"planned" sentence with the shape, a worked example from a real card, a real
startup transcript and the limit of the check stated beside the claim.

22 mutations, one per check added, no survivors - MUTATION.md carries the
ledger. M13 (the preview's index column) was a -13 B survivor of the whole
suite on the first attempt because the check graded the HEADING and not the
row; it is graded on a rendered row now. All 29 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 03:49:08 +02:00
admin aade2c79fd Merge pull request 'TARIFF-07: withdraw three plugins that never fetched a price' (#81) from TARIFF-07 into release/1.0
engie_dynamic, totalenergies_dynamic and eneco_eddy had fabricated endpoints. Re-verified before deletion, 2026-09-13: api.engie.be/dynamic/be answers 404 from a Kong gateway with no route; api.totalenergies.be presents a certificate for CN=pge-apigee-dev.direct-energie.com with the host absent from its SAN, so TLS closes before a request is sent; api.eneco.be/eddy/prices answers 404 with a one-byte body. And 'Eddy' is not an Eneco product at all - theirs is 'Zon & Wind Dynamisch', indexed to Belpex/EPEX DAY-AHEAD rather than imbalance.

They are structurally impossible, not merely unfinished: supplier coefficients are per-contract and live behind the customer's klantenzone login, so no public unauthenticated endpoint could ever return a given customer's price. There is no regulator index either - CREG's tariff-card pages 404, VREG was unreachable, and every coefficient found in research came from a supplier's own PDF, one of them image-only. The three invented URLs are the predictable failure mode of a task with no canonical index to fall back on.

THE SCHEMA ENUM KEEPS ALL SIX IDS ON PURPOSE. config.yaml's price_provider is a list(...) enum validated by the Supervisor BEFORE the add-on starts, so removing the three would make an existing config schema-invalid, produce an opaque 'expected one of ...', and validate.py would never run - the refusal would never be seen on exactly the sites it exists for. PROVIDERS is two; the enum stays six; both translations label them withdrawn; the schema check is derived from PROVIDERS | RETIRED_PROVIDERS | {off}; and register() refuses to re-register a retired id so the registry and the retired table cannot both claim one.

The refusal tells a stranded operator the truth rather than aliasing them somewhere: that this is not a typo - the add-on offered the id, accepted it, and said nothing at every startup since; that it has never fetched a price anywhere, with the endpoint and its observed response named; that section 11.4 turned every failure into a silent fallback, so what the site has actually been running is the static profile, or nothing at all when none is set; and what to do instead, stating plainly that generic_dayahead APPROXIMATES a Belgian dynamic tariff and cannot reproduce a supplier's B x spot + A formula. There is deliberately no alias, because aliasing would require the contract's own two coefficients - which are in no public index, so the add-on would have to invent the numbers it prices a battery against.

False claims corrected across DOCS.md, price_forecast.py, price_history.py, static_tariff.py, DECISIONS/FCST-04 and CHANGELOG.md: eneco_eddy was described as a 'Belgian imbalance product' in all of them and is not. The quarter-hourly examples now cite Belgian day-ahead, which genuinely settles per quarter since the 15-minute MTU of 2025-10-01 - the mechanism was right, only the example was invented. export_price_eur_kwh's docstring claimed no shipped supplier publishes a dynamic injection curve; Eneco publishes 0,1 x BELPEX-H - 1,188 on the same card as its consumption formula, identical on the January and September 2026 cards, and it is VAT-exempt while consumption is at 6%. _points_from_json() is deleted: it read a shape no reachable feed publishes and was graded only against fixtures built from the same guess.

PriceProduct.REALTIME and the widening machinery are KEPT although eneco_eddy was their sole declarer, with a DECISIONS note recording that no shipped plugin declares it until one does - otherwise a later reviewer finds a mechanism with zero callers and reasonably deletes the AC2 seam.

ONE CHECK HAD QUIETLY STOPPED BEING ONE. test_price.py 4b proved code_leaks()'s docstring strip is load-bearing by using a real shipped docstring that a raw scan false-positives on - and that docstring belonged to engie_dynamic. Deleting the plugin would have left the check PASSING on a tree with the strip removed. The stimulus is rebuilt inside the test file and the shipped set asserted clean separately.

BACKWARD-INCOMPATIBLE: a config legal on the current version is refused on the next. That is the point of the refusal, and the CHANGELOG says so in the shape PKG-07 established for slew_w.

Evidence: 29/29 green, with the baseline established at 8d09b30 before any edit. Nine mutations, nine red, two of them zero-byte edits; two counts are lower bounds because the run hit a traceback mid-suite.
2026-09-13 02:54:59 +02:00
24 changed files with 6362 additions and 221 deletions
+468 -1
View File
@@ -21,6 +21,11 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| _this branch_ | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing |
| _this branch_ | TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
| _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** |
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
@@ -52,7 +57,467 @@ the customer's supplier login; CREG's tariff-card pages 404 and VREG was
unreachable, so there is no public index to read them from. An alias to
`generic_dayahead` — which reads *wholesale* — would have to invent the numbers
a battery is priced against. A formula provider is the planned answer and is a
separate card.
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.)*
### 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`.**
A Belgian dynamic contract prices energy as `B × index + A`; the two
coefficients live on the customer's own tariff card and **nowhere else**.
There is no machine-readable source for them anywhere — CREG's consumer
tariff-card pages 404, CREG Scan is a JavaScript wizard, the annual report
carries the supplier list without per-product coefficients, VREG was
unreachable, and every coefficient found in research came off a supplier's own
PDF, one image-only. So there is nothing to scrape and a per-supplier plugin
would have no source for its numbers — which is precisely why TARIFF-07
withdrew three of them. **The customer is the only party who can see their own
coefficients.** They type them, once:
```json
{"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}}
```
`price = (factor × index_eur_per_mwh + offset) × (1 + vat_pct/100)`, in
`units`, **converted to EUR/kWh by the add-on**. The operator types what the
card prints, in the card's own units; every unit conversion and the VAT
multiplication are the add-on's job, because those are the two steps that
silently cost a factor of 10, 100 or 1.06.
**The index is `generic_dayahead`'s, not a second one** — same EPEX BE feed,
same Brussels-calendar range request, same columnar parser, same declared
egress hosts. The URL decision is now one function both plugins call, so a
short-horizon or override bug cannot exist in one and not the other.
`resolution: quarter_hourly` emits the feed's 96 points as they are;
`hourly` averages each hour's members. Neither is `_Series.at()`'s hourly
assumption returning: it decides how many points are **emitted**, and the
window each one covers is still derived from its neighbours (TARIFF-14).
⚠️ **It declares `{ENERGY, MARKUP}`, not `ALL_IN`, and that is the whole safety
argument.** A Belgian card's `Tariefformule` line is the **energy component
only** — Eneco tabulates network tariffs in one table and levies in a second,
ENGIE states that the formula excludes network, distribution, transport, grid
levies and excise. Declaring `ALL_IN` would lie on behalf of every operator who
typed exactly what their card says, and would drop `price_taxes_eur_kwh` from
every hour. Measured: 0.2125 → 0.1555 EUR/kWh, silently.
⚠️ **THE CARD IS THE GRADER, AND IT IS WHY THIS IS SAFE TO SHIP.** The
framework already mechanises that a *plugin* cannot double-count
(`PluginOptions`, `FRAMEWORK_KEYS`, `code_leaks()`). This provider introduces a
risk none of those can see, because it is **config, not code**: the *operator*
double-counting, by folding network charges into `offset` while
`price_taxes_eur_kwh` carries them too. So:
> At startup `validate.py` computes
> `(factor × card.index_eur_per_mwh + offset) × (1 + vat_pct/100)` and
> **refuses to start** unless it equals `card.expect_incl_vat` to within
> `0.01`, naming the discrepancy, the ratio and the likely causes.
An inflated `offset` **cannot pass**: the printed estimate is the energy
component alone, so ~12 c€/kWh of network charge moves the computed 15.55 to
28.27 and the add-on stops. The same check catches VAT omitted, VAT applied
twice, a transposed digit, and a unit mismatch between the coefficients and the
printed price.
⚠️ **And the limit, in the same sentence as the claim: it grades the
coefficients against the card, not the card against reality.** A
consistent-but-wrong pair — both numbers off the wrong product — reproduces its
own printed price perfectly and passes. Nothing can catch that. A **second**
refusal covers the one unit error the first cannot see, the one applied to both
sides at once: the card's own point must land inside 0.02–1.00 EUR/kWh. Its
floor is applied to the **energy** figure rather than the all-in one, because
the shipped 0.06 of taxes is three times that floor and would carry a
hundredfold error over it on its own — measured, and driven red by a mutation.
**The market's own arithmetic, reproduced to the digit the supplier prints.**
Both cards that were readable print the formula, the index they assumed *and*
the resulting price:
| | computed by the shipped code | the card prints |
|---|---|---|
| Eneco `(0.102 × 133.9905 + 1) × 1.06` | **15.5471** | **15,55** |
| ENGIE `(0.1 × 61.96 + 0.9) × 1.06` | **7.5218** | **7,522** |
⚠️ **No expression evaluation of any kind, and a check that fails if one ever
appears.** `price_formula` takes numbers; there is no field that accepts an
expression and an **unrecognised field is refused rather than ignored**, so
typing `"expr"` is told it does not exist instead of quietly pricing the site
on the fields beside it. Two reasons, and the second decides it:
* the string is settable over HTTP — the add-on's own ingress page posts it to
`/addons/self/options` — so `eval` on it is RCE in a root container holding
`SUPERVISOR_TOKEN`, with `homeassistant_api` and `hassio_api`, driving an
inverter over RS485;
* **an expression is unverifiable.** Two coefficients can be graded against the
card's printed price; an arbitrary expression cannot be graded against
anything, so it would make the invisible-wrong-coefficient problem *strictly
worse* — the exact failure this provider exists to prevent.
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
`B × index + A`. Future shapes become **named fields** (`clamp_lo`,
`factor_negative`, `offset_bands`), each individually refusable and each
gradeable against a card. Never a grammar. `test_price.py` 15f walks
`app/custom_formula.py`'s AST for `eval`/`exec`/`compile`/`__import__`/
`literal_eval` and exercises the scanner's **red** direction on every run.
**Refused at startup, not at 03:00.** `card` absent or failing by more than
0.01; `units` absent (no default, on purpose — the same convention is printed
two ways by two suppliers); `factor == 0`, which makes every spread 0 and
arbitrage silently never fire (the message points at `static_profile`, which is
the first-class way to say "one price"); `factor < 0`, which inverts the market
so the battery charges at the evening peak; `vat_pct` outside 0–30 (**0 stays
legitimate** — injection is VAT-exempt and some cards are already VAT-inclusive);
a non-finite or non-numeric coefficient; an unknown field; an unknown `index` or
`resolution`; and the plausibility band above. A `card.month` older than about
six months **warns and does not refuse** — ENGIE freezes coefficients for a
contract's duration, so an old card is often exactly right.
⚠️ **Negative output prices are NOT refused.** They are legal on EPEX and they
are the best charging hours of the year (§17 keeps the regression case). The
card check bounds the formula's *magnitude* without constraining the sign of
any hour.
**A startup preview, so a factor of 1000 is visible in one line.** The first
time the feed answers, the add-on logs the next 24 h as
`index EUR/MWh -> all-in EUR/kWh` with the min, median, max and the day's
spread. It is a new framework hook (`PriceProvider.startup_preview()`, default
silent) rather than a line in `load_prices()`, because only the plugin knows
what went *in* to its own arithmetic; it is emitted once per process and it
cannot stop a fetch — a preview that raises is caught and logged.
**Surfaces.** `config.yaml` gains `price_formula` (options **and** schema) and
`custom_formula` joins the `price_provider` enum through the **derived** set
`PROVIDERS | RETIRED_PROVIDERS | {"off"}` rather than a hand-typed list; both
translations describe the option and the new id. `DOCS.md` replaces the
"planned" sentence with the shape, a worked example from a real card, the
startup transcript, and the limit of the check stated in the same sentence as
the claim.
**Not in scope, and each is a card of its own:** the injection leg's own
coefficients (Eneco uses 0,102 on consumption against 0,100 on injection, and
injection is VAT-exempt where consumption is at 6 %); the hour-varying network
charge that bites Wallonia; raising the `price_taxes_eur_kwh` default from 0.06
to ~0.12.
### TARIFF-07 - three plugins that never fetched a price, withdrawn
@@ -130,6 +595,8 @@ which **approximates** a retail dynamic tariff and **cannot express
`B × spot + A`** — it has no multiplier, and it cannot represent a negative
offset, which `price_markup_eur_kwh: float(0,1)` could not hold in any case. A
formula provider is named as the planned answer and is a separate card.
*(Annotated 2026-09-13: TARIFF-15 replaced that sentence. The table is three
rows and the prose points at `custom_formula`.)*
⚠️ **One check had to be rebuilt rather than deleted.** `test_price.py` 4b
proved `code_leaks()`'s docstring strip is load-bearing by finding a shipped
@@ -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.
@@ -0,0 +1,166 @@
# TARIFF-15 — the operator's own tariff card, and why the card has to grade it
**Status: DECIDED 2026-09-13 — ship `custom_formula` with FIXED coefficients and
a REQUIRED `card` block whose printed price the add-on reproduces at startup or
refuses to run. No expression evaluation, ever. Declare `{ENERGY, MARKUP}`,
never `ALL_IN`.**
Filed with TARIFF-15. Follows [[TARIFF-07]], which withdrew three per-supplier
plugins and named a formula provider as the answer it could not give.
---
## 1. Why a formula rather than more plugins
TARIFF-07 established the negative half: a per-supplier plugin has no source
for its numbers. This card establishes the positive one.
* **There is no machine-readable source of Belgian tariff coefficients
anywhere.** CREG's consumer tariff-card pages 404. CREG Scan is a
JavaScript-only wizard. The annual report carries the supplier list and no
per-product coefficients. VREG was unreachable. Every coefficient found in
research came from a supplier's **own PDF**, one of them image-only.
* So there is nothing to scrape, and fourteen plugins would still have no
source for their numbers — they would be fourteen copies of the invention
TARIFF-07 deleted three of.
* **The customer is the only party who can see their own coefficients**, and
the card they read them from **is the only artefact that can grade them**.
That second clause is the whole design; everything below follows from it.
## 2. The shape is the market's published arithmetic
Both suppliers whose 2026 cards were readable print three things on one line:
the formula, the reference index it assumes, **and the resulting price**.
| | computed | the card prints |
|---|---|---|
| Eneco `(0.102 × 133.9905 + 1) × 1.06` | **15.5471** | **15,55** |
| ENGIE `(0.1 × 61.96 + 0.9) × 1.06` | **7.5218** | **7,522** |
Two independent suppliers, reproduced to the last printed digit, through the
shipped code (`test_price.py` 15a runs both, and then runs both again end to
end through `load_prices()`).
That third column is not a nicety. It is the only externally-published number
in this whole feature, and it is what makes the design safe.
## 3. The double-counting risk, and which half of it is new
`price.py`'s trap 1 already mechanises that a **plugin** cannot double-count:
`PluginOptions` refuses the framework keys, `code_leaks()` refuses a plugin
whose *code* mentions them, `assemble()` adds the complement exactly once.
`custom_formula` introduces a risk none of those can see, **because it is not
in code**: the **operator** double-counting, by folding their network charges
into `offset` while `price_taxes_eur_kwh` also carries them. `code_leaks()`
scans code. This is config, typed by a person reading a PDF.
It is a real reading of a real card, not a hypothetical. A Belgian card's
`Tariefformule` line is the **energy component alone** — Eneco tabulates
network tariffs in one table and levies in a second; ENGIE states in so many
words that the formula excludes network, distribution, transport, grid levies
and excise. An operator who wants "the price I actually pay" and adds ~12
c€/kWh to `offset` has done something entirely reasonable and is now billed for
network charges twice, on every hour, with §11.4 making the whole class of
price defect invisible.
**Decision: the card grades it.** At startup `validate.py` recomputes the
card's own printed price from the operator's coefficients and refuses to start
unless the two agree to within `0.01` — which is the last digit a card prints.
An inflated `offset` cannot pass, because the printed estimate is by
construction the energy component: 15.55 becomes 28.27 and the add-on stops and
says why.
**And the limit, stated in the same sentence as the claim**, exactly as
`price.py` states trap 1's: *it grades the coefficients against the card, not
the card against reality.* A consistent-but-wrong pair — both numbers off the
wrong product, or off a card the contract has since rolled over from — agrees
with itself perfectly and passes. Nothing here or anywhere else can catch that.
### 3b. The one unit error the card check cannot see
A factor applied to **both** sides — coefficients *and* printed price — cancels
out of a comparison between them. That is a second quantity, so it is a second
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` 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. *(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
**Decided: never a grammar.** Two arguments, and the second is the one that
decides it.
* **Security.** The add-on serves `POST config/save` → `POST
/addons/self/options` on its own ingress page, so `price_formula` is
**settable over HTTP**. `eval` on it is remote code execution in a root
container holding `SUPERVISOR_TOKEN`, with `homeassistant_api` and
`hassio_api`, driving an inverter over RS485. `eval(s, {"__builtins__": {}})`
is not a sandbox and this repository is not going to pretend otherwise.
* **⚠️ AND THE DECIDING ARGUMENT IS NOT SECURITY: AN EXPRESSION IS
UNVERIFIABLE.** Two coefficients can be graded against the card's printed
estimate. An arbitrary expression cannot be graded against **anything** — so
it makes the invisible-wrong-coefficient problem *strictly worse*, which is
the exact failure this provider exists to prevent. An expression would buy
the operator freedom to be wrong in a way nothing could report.
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
`B × index + A`, so an expression buys nothing the market needs either.
**Future shapes become NAMED FIELDS** — `clamp_lo`, `factor_negative`,
`offset_bands` — each individually refusable and each individually gradeable
against a card. `parse_price_formula()` therefore **refuses an unknown key**
rather than ignoring it: an operator who types `"expr"` is told it does not
exist, instead of being priced on the fields they also typed with the one they
believed in dropped in silence.
`test_price.py` 15f walks the module's AST for `eval`, `exec`, `compile`,
`__import__` and `literal_eval`, and exercises the scanner's **red** direction
on every run so it cannot become a scanner that scans nothing.
## 5. `{ENERGY, MARKUP}`, not `ALL_IN`
The contract's formula **is** the supplier's markup — that is what `B` and `A`
are — but it is not the levies. Declaring `ALL_IN` would lie on behalf of every
operator who typed exactly what their card says and would drop
`price_taxes_eur_kwh` from every hour: measured at 0.2125 → 0.1555 EUR/kWh.
`price_markup_eur_kwh` is therefore **ignored** under this provider, and logged
as ignored, which is the framework's existing rule rather than a new one.
## 6. What was reused rather than rebuilt
The index path is `generic_dayahead`'s: same EPEX BE feed, same
Brussels-calendar range, same columnar parser, same declared egress hosts. The
override/range decision moved into one shared `dayahead_feed_url()` that both
plugins call, because two copies of that rule would be two places for the
short-horizon defect TARIFF-09 fixed to come back in only one of them.
`resolution: hourly` averages each hour's members. ⚠️ **That is not
`_Series.at()`'s hourly assumption returning (TARIFF-14):** it decides how many
points are *emitted*; the window each point covers is still derived from its
neighbours in `_cover_table()`. A feed that is already hourly averages one
member per hour — the identity — so the option cannot silently assume the
granularity it was handed. Graded.
## 7. What this card did NOT do
* **The injection leg.** Eneco uses `0,102` on consumption and `0,100` on
injection, and injection is VAT-exempt where consumption is at 6 % — a second
per-contract formula, not a second copy of this one. Separate card.
* **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. *(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.)*
+456 -17
View File
@@ -116,7 +116,7 @@ whole rendered list, in both languages.
| | |
|---|---|
| **All 71 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
| **All 72 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
@@ -1369,15 +1369,16 @@ does, and only after you have read why.
| option | meaning |
|---|---|
| `price_provider` | `off` (default) or one of the two plugins below |
| `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 |
| `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 **injection** price. A separate scalar, not a series — see below |
| `price_csv_path` | Where `manual_csv` reads its file |
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
| `price_horizon_h` | How far ahead to ask for, hours |
| `price_timeout_s` | Per-request timeout |
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
| `price_formula` | For `custom_formula` only: your own contract's two coefficients, off your own tariff card. Empty by default — see below |
**Which plugin sources what.** This is the one thing to get right, because
adding your markup to a price that already contains it makes every future
@@ -1386,6 +1387,7 @@ arbitrage decision wrong by that amount, in a direction nothing reports.
| plugin | product | the published figure contains | the add-on adds |
|---|---|---|---|
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
| `custom_formula` | day-ahead | energy **and** your contract's markup — your card's `Tariefformule` line | taxes |
| `manual_csv` | day-ahead | everything, all-in | nothing |
⚠️ **Three plugins were withdrawn in TARIFF-07, and if your configuration names
@@ -1425,7 +1427,9 @@ Belgian dynamic contract is a **formula**:
consumption: price = B x spot + A <- B and A are in YOUR contract
```
The add-on cannot express that yet. Two gaps, and the second is the harder one:
`generic_dayahead` cannot express that. Two gaps, and the second is the
harder one — **both are closed by `custom_formula`**, which is what the
rest of this section is about:
* **No multiplier.** `B` is typically slightly above 1 (it scales the wholesale
price before anything is added). A flat markup cannot scale anything, so the
@@ -1443,13 +1447,20 @@ deciding **which hours** are cheap that is usually good enough; for deciding
compares against — it is an approximation, and one that errs by however far `B`
is from 1.
A **formula provider** (`B x spot + A`, with your own coefficients entered once)
is the planned answer and is a separate card. It is not implemented here. Until
it ships, the two supported honest options are:
**`custom_formula` is the answer to exactly this, and it shipped in TARIFF-15.**
You type your contract's two coefficients once, off your own tariff card, and
the add-on evaluates `B x index + A` against the same EPEX BE day-ahead curve
`generic_dayahead` reads. Both gaps above close: `B` is a real multiplier, and
`A` may be negative. It is documented in full in **[Your own tariff formula
(`custom_formula`)](#your-own-tariff-formula-custom_formula)** below.
1. **`generic_dayahead`** with your markup and taxes set as well as you can —
So there are three honest options rather than two:
1. **`custom_formula`** — your contract's own arithmetic, if you can read the
two coefficients off your tariff card. This is the one to use.
2. **`generic_dayahead`** with your markup and taxes set as well as you can —
accepting the approximation above; or
2. **`manual_csv`**, where you compute the all-in prices yourself from your own
3. **`manual_csv`**, where you compute the all-in prices yourself from your own
contract's formula and hand the add-on the answers. Nothing is approximated,
but nothing is automatic either.
@@ -1472,7 +1483,7 @@ the `sources` declaration is the part a reviewer has to read.
**Real-time / imbalance products still have a place in the machinery, but no
plugin declares one.** A plugin that declares `PriceProduct.REALTIME` is flagged
high-volatility and gets widened arbitrage thresholds; that comes from the
plugin's own declaration, never from its name. Both shipped plugins are
plugin's own declaration, never from its name. All three shipped plugins are
day-ahead, so nothing exercises that path on a real install today — it is kept
on purpose so the first genuine imbalance product does not have to rebuild it.
See `DECISIONS/TARIFF-07-withdrawing-three-plugins.md`.
@@ -1529,7 +1540,165 @@ and the moment it retires; the plan's own age is **not** reset by the failed
refresh, so a kept plan still expires on schedule and is never presented as a
current one. One failed fetch is a blip, not a lost tariff.
**`manual_csv` — the offline fallback.** It needs no internet, no supplier
#### Your own tariff formula (`custom_formula`)
**What it is for.** A Belgian dynamic contract prices energy as a formula —
your supplier's tariff card prints it as `Tariefformule` — and the two
coefficients in it belong to *your contract*. There is no machine-readable
source for them anywhere: CREG's consumer tariff-card pages return 404, CREG
Scan is a JavaScript wizard, the annual report lists suppliers without
per-product coefficients, and VREG was unreachable. Every coefficient found in
research came off a supplier's own PDF, one of them image-only. **You are the
only party who can see your own coefficients**, so you type them, once.
Set `price_provider: custom_formula` and fill in `price_formula` with one line
of JSON:
```json
{"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 contract it expresses is:
```
price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct / 100)
```
expressed in `units`, and **converted to EUR/kWh by the add-on**. You type what
your card prints, in the card's own units; every unit conversion and the VAT
multiplication are the add-on's job, because those are exactly the two steps
where a human silently loses a factor of 10, 100 or 1.06.
| field | what it is |
|---|---|
| `index` | the reference series. `epex_be` — the EPEX BE day-ahead curve in EUR/MWh, the same feed `generic_dayahead` reads |
| `resolution` | `quarter_hourly` (the 96 points the feed publishes) or `hourly` (each hour's quarters averaged into one point). Your card says which series your contract is written against |
| `factor` | `B` — the multiplier on the index. Must be a number, and positive |
| `offset` | `A` — the fixed part, in the same units. **May be negative** |
| `units` | `c_eur_per_kwh`, `eur_per_kwh` or `eur_per_mwh`. **Required, with no default** |
| `vat_pct` | the VAT percentage. `6` for Belgian residential electricity — not `0.06` and not `106`. `0` is legitimate |
| `card` | the month the card is dated, the reference index it assumed, and the price it prints. **Required** — see below |
##### A worked example, from a real card
Eneco's September 2026 card prints the formula, the reference index it assumed
*and* the resulting price. With `factor: 0.102`, `offset: 1.00`, `vat_pct: 6`
and the card's own index of `133.9905` EUR/MWh:
```
(0.102 x 133.9905 + 1.00) x 1.06 = 15.5471 c€/kWh the card prints 15,55
```
ENGIE's card, in the same shape with its own numbers:
```
(0.100 x 61.96 + 0.90) x 1.06 = 7.5218 c€/kWh the card prints 7,522
```
Both reproduce to the last digit the card prints, through the shipped code —
`test_price.py` section 15a runs exactly these two.
##### ⚠️ The `card` block is required, and it is the safety mechanism
Because your card prints the index it assumed **and** the price that comes out,
the add-on can check your coefficients against your supplier's own worked
example. At startup it recomputes that printed price from the numbers you
typed and **refuses to start** unless the two agree to within `0.01` — naming
the discrepancy, the ratio, and the likely causes.
That catches, in one check: VAT left out, VAT applied twice, a transposed
digit, and a **unit mismatch** between your coefficients and the price you
typed beside them (a factor of 10, 100 or 1000).
And it catches the expensive one. **Your card's formula line is the energy
component only.** Eneco tabulates network tariffs in one table and levies in a
second; ENGIE states that the formula excludes network, distribution,
transport, grid levies and excise. The add-on therefore declares that
`custom_formula` supplies *energy and markup*, and adds `price_taxes_eur_kwh`
on top — once. If you fold your network charges into `offset`, you are billed
for them twice, and nothing on the status page would ever show it. It cannot
happen: ~12 c€/kWh of network charge moves the computed 15.55 to about 27, and
the add-on refuses to start and says so.
⚠️ **And the limit of that check, in the same breath as the claim: it grades
your coefficients against your card, not your card against reality.** Two
numbers copied off the wrong product — or off a card your contract has since
rolled over from — agree with each other perfectly and pass. Nothing can catch
that. What is mechanised is that your coefficients and your card's printed
price *agree*; whether the card is the right card is yours to know. The add-on
warns (and does not refuse) when `card.month` is more than about six months
old, because ENGIE freezes coefficients for a contract's duration and an old
card is often exactly right.
One more check runs beside it: the card's own point must land inside the range
a Belgian retail tariff can occupy (0.02–1.00 EUR/kWh). That exists because a
unit error applied to **both** sides at once cancels out of the comparison
above and cannot cancel out of this one. It bounds the card's own point, **not
every hour** — a live hour may legitimately fall outside it, and a negative
day-ahead hour, which is the best charging opportunity of the year, is never
refused.
##### ⚠️ Fixed coefficients. No expression is evaluated, ever
`price_formula` takes numbers. There is no field that accepts an expression,
and an unrecognised field is **refused** rather than ignored, so typing
`"expr": "..."` tells you it does not exist instead of quietly pricing you on
the other fields.
Two reasons, and the second is the decisive one:
* This string is settable over HTTP — the add-on's own ingress configuration
page posts it to the Supervisor — so evaluating it would be remote code
execution inside a root container that holds a Supervisor token and drives
an inverter. (`eval` with an emptied `__builtins__` is not a sandbox.)
* **An expression could not be graded against anything.** Two coefficients can
be checked against your card's printed price; an arbitrary expression
cannot, so it would make the invisible-wrong-coefficient problem strictly
worse — which is the exact failure this provider exists to prevent.
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
`B x index + A`, so an expression would buy nothing the market needs. If a
future contract needs a different shape, it gets a **named field**
(`clamp_lo`, `factor_negative`, `offset_bands`) that can be refused on its
own. Never a grammar.
##### What you see at startup
The first time the feed answers, the add-on logs a preview of the next 24
hours as `index EUR/MWh -> all-in EUR/kWh`, followed by the minimum, median,
maximum and the day's spread. It is there so that a formula off by a factor of
1000 is visible in one line rather than in a month of bills:
```
custom_formula preview - the next 24 h of YOUR tariff, index EUR/MWh -> all-in
EUR/kWh, computed as (0.102 x index + 1) x (1 + 6/100) c_eur_per_kwh, converted
to EUR/kWh and with price_taxes_eur_kwh added by the framework:
2026-09-10T00:00:00+02:00 216.080 -> 0.30423
2026-09-10T00:15:00+02:00 216.080 -> 0.30423
... 72 further point(s) not listed; the figures below cover ALL 96
min 0.17277 / median 0.28031 / max 0.67499 EUR/kWh, spread 0.50222 EUR/kWh
over 96 point(s). ⚠️ If those are a thousand times what your bill says,
`units` is wrong; if the spread is 0, `factor` is.
```
(That transcript is the shipped code run against the committed
`scenarios/energy-charts-be-price-2026-09-10-quarterhourly.json` capture with
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
`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.
#### `manual_csv` — the offline fallback
It needs no internet, no supplier
account and no HTTP stack at all, and it is the supported way to run this
add-on price-aware on a site with no outbound connectivity. Write a file and
point `price_csv_path` at it:
@@ -1596,12 +1765,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:
@@ -1663,6 +1870,211 @@ uses the result as a cheap-window hint for the maintenance charge. They are
— 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`.
### Static day/night and fully-fixed tariffs (`static_profile`)
Most Belgian households are **not** on a dynamic contract, and an EMS that only
@@ -1782,6 +2194,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 |
+86
View File
@@ -780,6 +780,92 @@ static routing pin catches. The *bound* variant — right actuator, wrong
numbers — is a different defect with a different fix, and it was absent. Both
are in the table now.
## TARIFF-15 — the card-graded formula, mutation by mutation
Twenty-two mutations, one per check the card added, **run** rather than
reasoned about. Harness: `/home/homelab/.cache/t15-mut/run.py`, backups beside
it rather than in a shared scratchpad — two agents' harnesses collided in one
this week. Each run restores from backup, purges `__pycache__` **on the host**
first, and grades through the CI image with the tree bind-mounted `:ro`, so the
interpreter cannot write a cache into the tree under test at all (TEST-15's
structural immunity, borrowed).
**No survivors.** Two entries are `+0 B` and are called out as such, per the
reporting rule.
| | mutation | file | Δ | red |
|---|---|---|---|---|
| M1 | `CARD_TOLERANCE` widened to 100 — the self-check stops checking | `custom_formula.py` | +1 B | **8** |
| M2 | `CARD_TOLERANCE` tightened to 0 — the other direction, so the tolerance is a number and not a shape | `custom_formula.py` | −1 B | **16** |
| M3 | `units` becomes optional | `custom_formula.py` | +10 B | 1 |
| M4 | the `factor == 0` rung removed | `custom_formula.py` | −8 B | 1 |
| M5 | the `factor < 0` rung removed | `custom_formula.py` | −7 B | 1 |
| M6 | the `vat_pct` band widened to ±1000 | `custom_formula.py` | +6 B | 2 |
| M7 | the non-finite rung removed from `_finite()` | `custom_formula.py` | −26 B | 2 |
| M8 | the non-numeric rung removed from `_finite()` | `custom_formula.py` | −16 B | 4 |
| M9 | an unknown top-level field ignored instead of refused | `custom_formula.py` | −2 B | 1 |
| M10 | the plausibility **floor** applied to the all-in figure instead of the energy one | `custom_formula.py` | **+0 B** | 2 |
| M11 | `hourly` stops averaging and emits the feed's own points | `custom_formula.py` | +12 B | 3 |
| M12 | the hourly bucket takes the FIRST quarter instead of the mean | `custom_formula.py` | −11 B | 1 |
| M13 | the preview drops the index column from each row | `custom_formula.py` | −13 B | 1 |
| M14 | the preview is emitted on every refresh rather than once | `price.py` | −8 B | 1 |
| M15 | `custom_formula` declares `ALL_IN` — the double-counting lie | `price.py` | −41 B | **10** |
| M16 | a stale card never warns | `custom_formula.py` | −8 B | 1 |
| M17 | a stale card REFUSES instead of warning | `validate.py` | −3 B | 1 |
| M18 | the empty-formula refusal removed from the startup gate | `validate.py` | −38 B | 1 |
| M19 | the index scaled to EUR/kWh before the formula — the factor-1000 | `price.py` | +2 B | **7** |
| M20 | an `eval` call planted in `app/custom_formula.py` | `custom_formula.py` | +14 B | 1 |
| M21 | `custom_formula` stops passing the caller's range to the feed | `price.py` | **+0 B** | 1 |
| M22 | **PAIRED**: M1 *and* the plausibility band removed, together | `custom_formula.py` | −60 B | **12** |
### The four that are worth reading rather than counting
**M13 was a SURVIVOR of the whole suite on the first attempt, at −13 B.** The
check read `"index EUR/MWh -> all-in EUR/kWh" in log` — which is the preview's
**heading**. Deleting the index column from every **row** left the heading
saying it prints something it no longer printed, and nothing went red. That is
this file's third failure mode arriving in the check written to close it: the
assertion ran, the sentence was true, and the data could not reach the failure
it named. It is graded on a rendered ROW now, built here from the captured
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` 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. *(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
is *derived from the declaration* — so it passes on the lie, exactly as
`price.py`'s trap 1 says no code can catch a misdeclaration. What goes red is
the **docstring/declaration agreement** check, the card reproductions, and the
`sources` pin: ten reds, none of them from the arithmetic. That is the
guarantee at its true size — the two claims are held to agree, and whether
either is true about somebody else's tariff remains human.
**M22 is the paired run (TEST-16).** The card self-check and the plausibility
band both answer a unit error, so each could be masking the other. Weakened
**together** they produce twelve reds rather than the eight M1 alone produces,
and the band's own checks stay red — so neither rung is doing the other's work.
### What this battery does NOT establish
It does not establish that a card is the right card. Every mutation above moves
the relationship between the coefficients and the printed price; **none of them
can move the relationship between the printed price and the operator's actual
contract**, because nothing in this repository observes that. A
consistent-but-wrong pair passes every one of these checks, by construction,
and `DECISIONS/TARIFF-15-the-card-is-the-grader.md` §3 says so in the same
sentence as the claim.
## Where each surface stands
* **Local and dev-harness runs — the exposed one.** This is where FCST-03 hit
+1 -1
View File
@@ -24,7 +24,7 @@ since the status page shipped; this file has five more chances to get it wrong.
⚠️ AND IT IS BUILT FOR A PHONE FIRST. This is the screen someone stands in
front of the inverter with: one column, big touch targets, sections that start
collapsed so 71 options do not arrive as one wall, and the refusal block pinned
collapsed so 72 options do not arrive as one wall, and the refusal block pinned
where it cannot be scrolled past.
"""
+673
View File
@@ -0,0 +1,673 @@
"""TARIFF-15 - the customer's own tariff card, as two fixed coefficients.
WHAT THIS IS FOR, in one sentence: a Belgian dynamic contract prices energy as
`B x index + A` with coefficients that exist in exactly one place - the tariff
card in the customer's klantenzone - so the customer types those two numbers
and the add-on does every unit conversion and the VAT multiplication itself.
WHY A FORMULA AND NOT FOURTEEN MORE PLUGINS (§9.2, TARIFF-07)
=============================================================
There is NO machine-readable source of Belgian tariff coefficients anywhere.
CREG's consumer tariff-card pages 404, CREG Scan is a JS-only wizard, the
annual report carries the supplier list and no per-product coefficients, and
VREG was unreachable. Every coefficient found in research came off a supplier's
own PDF, one of them image-only. So there is nothing to scrape and a
per-supplier plugin would have no source for its numbers - fourteen plugins
would be fourteen copies of the same invention, which is the defect TARIFF-07
withdrew three plugins for. THE CUSTOMER IS THE ONLY PARTY WHO CAN SEE THEIR
OWN COEFFICIENTS, and the card they read them from is the only artefact that
can grade them.
THE SHAPE IS THE MARKET'S PUBLISHED ARITHMETIC, NOT OUR APPROXIMATION
====================================================================
Both suppliers whose 2026 cards were readable print the formula, the reference
index they assumed AND the resulting price, so the arithmetic below is
reproducible against something they published rather than against our own
guess:
Eneco (0.102 x 133.9905 + 1.00) x 1.06 = 15.5471 card prints 15,55
ENGIE (0.100 x 61.96 + 0.90) x 1.06 = 7.5218 card prints 7,522
Two independent suppliers, reproduced to the last printed digit, in
c EUR/kWh. test_price.py section 13 runs both through the SHIPPED code.
⚠️ THE DOUBLE-COUNTING MECHANISM, AND WHY THIS DESIGN IS SAFE AT ALL
====================================================================
This provider declares `sources = {ENERGY, MARKUP}` and NOT `ALL_IN`. A Belgian
card's `Tariefformule` line is THE ENERGY COMPONENT ONLY: Eneco puts network
tariffs in one table and levies in a second, and ENGIE states in so many words
that the formula "does not include fixed network/distribution costs, transport,
grid levies, or excise duties". Declaring ALL_IN would lie on behalf of every
user who typed what their card says, and would silently drop ~12 c EUR/kWh.
price.py already mechanises that a PLUGIN cannot double-count (`PluginOptions`,
`FRAMEWORK_KEYS`, `code_leaks()`). This provider introduces a risk none of those
can see, because it is not in the code: THE OPERATOR double-counting, by folding
their network charges into `offset` while `price_taxes_eur_kwh` carries them as
well. `code_leaks()` scans code. This is config.
So the card is the grader:
At startup validate.py computes
`(factor x card.index_eur_per_mwh + offset) x (1 + vat_pct/100)` and
REFUSES TO START unless it equals `card.expect_incl_vat` to within
CARD_TOLERANCE, naming the discrepancy.
An `offset` inflated by network charges CANNOT PASS: the card's printed estimate
is by construction the energy component alone, so adding ~12 c EUR/kWh moves
15.55 to ~27 and the add-on refuses. The same check catches VAT omitted, VAT
applied twice, a transposed digit, and a UNIT MISMATCH BETWEEN THE TWO SIDES -
coefficients read off the formula line while the expected price is typed in
another convention, which is the factor-10/100/1000 class as it actually
happens.
⚠️ THE ONE UNIT ERROR THE CARD CHECK CANNOT SEE IS THE ONE APPLIED TO BOTH
SIDES AT ONCE, and saying so is the difference between a guard and a slogan:
scaling the coefficients AND the expected price by the same 100 cancels out of
a comparison between them. That is what `implausible_all_in()` is for, and it
is a SEPARATE refusal on a SEPARATE quantity - the card's own point, all-in,
against the band a Belgian retail tariff can occupy.
⚠️ AND THE LIMIT, IN THE SAME BREATH AS THE CLAIM, exactly as price.py's trap 1
states its own: THE CHECK GRADES THE COEFFICIENTS AGAINST THE CARD, NOT THE CARD
AGAINST REALITY. A consistent-but-wrong pair - both numbers copied off the wrong
product, or off last year's card - reproduces its own printed price perfectly
and passes. Nothing here or anywhere else can catch that; what is mechanised is
that the two coefficients and the printed estimate AGREE, which is the whole of
the unit/VAT/decimal-point class and none of the "wrong contract" class.
⚠️ FIXED COEFFICIENTS. NO EXPRESSION EVALUATION OF ANY KIND.
============================================================
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
`B x index + A`, so an expression buys nothing the market needs. Against that:
* the add-on serves `POST config/save` -> `POST /addons/self/options` on its
own ingress page, so this string is SETTABLE OVER HTTP. `eval` on it is
remote code execution in a root container holding SUPERVISOR_TOKEN, with
`homeassistant_api` and `hassio_api`, driving an inverter over RS485.
(`eval(s, {"__builtins__": {}})` is not a sandbox and never was.)
* ⚠️ AND THE DECIDING ARGUMENT IS NOT SECURITY - AN EXPRESSION IS
UNVERIFIABLE. Two coefficients can be graded against the card's printed
estimate. An arbitrary expression cannot be graded against anything, so it
makes the invisible-wrong-coefficient problem STRICTLY WORSE, which is the
exact failure this provider exists to prevent.
Future shapes become NAMED FIELDS - `clamp_lo`, `factor_negative`,
`offset_bands` - each individually refusable and each individually gradeable
against a card. Never a grammar. `parse_price_formula()` therefore REFUSES AN
UNKNOWN KEY rather than ignoring it, so a user who types `"expr"` is told it
does not exist instead of being silently priced on the fields they also typed.
test_price.py section 13f walks this module's AST and fails if `eval`, `exec`,
`compile`, `__import__` or `literal_eval` ever appears in it.
WHERE THE NUMBERS COME FROM AND WHERE THEY GO
=============================================
`index` names the reference series. `epex_be` is the EPEX BE day-ahead curve in
EUR/MWh, which is what `generic_dayahead` already fetches - same feed, same
`brussels_date_range()` request, same columnar parser, same declared egress
hosts. No new parser and no new failure mode.
`resolution` says which series the contract is written against:
* `quarter_hourly` - the 96 points the feed publishes since Belgium's
15-minute MTU (2025-10-01), emitted as they are;
* `hourly` - the four quarters of each hour averaged into one point.
⚠️ NEITHER IS AN HOURLY ASSUMPTION IN `_Series.at()`'s SENSE (TARIFF-14). The
hourly option changes how many POINTS are emitted and their spacing; the window
each point covers is still derived from its neighbours by `_cover_table()`, and
nothing here tells anything downstream how long a point lasts. A feed that is
already hourly averages one member per hour, which is the identity - so the
option cannot silently assume the granularity it was handed.
`units` is NOT bureaucracy. Two suppliers publish the same shape in different
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.
"""
import json
import math
from dataclasses import dataclass
from datetime import date, datetime, timezone
class FormulaInvalid(Exception):
"""`price_formula` cannot be used. Raised by the parser, refused by
validate.py at startup - never at 03:00 on a fetch."""
# --------------------------------------------------------------------------- #
# The enumerations. Every one of these is a CLOSED set, and that is the point:
# an unknown value is refused by name rather than guessed at.
# --------------------------------------------------------------------------- #
# unit -> how many EUR/kWh one of it is. ⚠️ THE WHOLE FACTOR-1000 CLASS LIVES
# HERE, so the table is tiny, explicit, and has no "default".
UNITS: dict = {
"c_eur_per_kwh": 0.01, # eurocent per kWh - Eneco and ENGIE print this
"eur_per_kwh": 1.0, # some aggregators print this
"eur_per_mwh": 0.001, # the wholesale convention, occasionally reused
}
RESOLUTIONS: tuple = ("hourly", "quarter_hourly")
# index id -> the human name of the series it refers to. One entry today; a
# second index is a new row here plus a fetch path, not a new grammar.
INDEXES: dict = {
"epex_be": "EPEX BE day-ahead, EUR/MWh",
}
# ⚠️ 0.01 IN THE CARD'S OWN UNITS, WHICH IS THE LAST DIGIT BOTH REAL CARDS
# PRINT. Eneco prints 15,55 against a computed 15.5471 (0.0029 out) and ENGIE
# prints 7,522 against a computed 7.5218 (0.0002 out), so the tolerance has to
# absorb the card's own rounding and nothing more. Every error class this check
# exists for - a factor of 10, of 100, of 1000, a missing or doubled 1.06, a
# transposed digit, ~12 c EUR/kWh of network charge folded into `offset` - is
# at least two orders of magnitude bigger than this.
CARD_TOLERANCE = 0.01
# The all-in plausibility band, EUR/kWh. `MAX_PLAUSIBLE_EUR_KWH` in price.py
# bounds only prices that are too LARGE; this is the other end, and it is the
# one the factor-1000 error falls off.
PLAUSIBLE_LO_EUR_KWH = 0.02
PLAUSIBLE_HI_EUR_KWH = 1.00
# ~6 months. ⚠️ A WARNING, NEVER A REFUSAL: ENGIE freezes a contract's
# coefficients for its duration, so a card two years old can be exactly right
# and a site refused over one would be a site that cannot start.
CARD_STALE_DAYS = 183
_CARD_KEYS = ("month", "index_eur_per_mwh", "expect_incl_vat")
_TOP_KEYS = ("index", "resolution", "factor", "offset", "units", "vat_pct",
"card")
@dataclass(frozen=True)
class FormulaSpec:
"""One contract's published arithmetic. SEVEN numbers and no code.
⚠️ EVERY FIELD IS A FIXED, NAMED SCALAR. There is deliberately no field
that holds an expression, a callable, a lambda or a template, and there is
no place to put one: `parse_price_formula()` refuses an unrecognised key.
"""
index: str
resolution: str
factor: float
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
@property
def unit_to_eur_kwh(self) -> float:
return UNITS[self.units]
# --------------------------------------------------------------------------- #
# The arithmetic. Two functions, and the SECOND one is the contract.
# --------------------------------------------------------------------------- #
def in_card_units(spec: FormulaSpec, index_eur_per_mwh: float) -> float:
"""`(factor x index + offset) x (1 + vat_pct/100)`, in the card's units.
This is the line the card prints, spelled exactly as the card spells it,
and it is the only place the coefficients are combined. The card
self-check and the price path both go through here, so a mutation to it
cannot move the price without also moving the self-check.
"""
return ((spec.factor * float(index_eur_per_mwh) + spec.offset)
* (1.0 + spec.vat_pct / 100.0))
def price_eur_kwh(spec: FormulaSpec, index_eur_per_mwh: float) -> float:
"""The energy+markup price for one index value, in EUR/kWh.
⚠️ THE UNIT CONVERSION IS THE ADD-ON'S JOB AND IT HAPPENS HERE, ONCE. The
user types what their card prints, in the card's own units; every step
from there to EUR/kWh is code, because those steps are exactly the ones
that silently cost a factor of 10, 100 or 1000 when a human does them.
"""
return in_card_units(spec, index_eur_per_mwh) * spec.unit_to_eur_kwh
def card_discrepancy(spec: FormulaSpec) -> float:
"""How far the coefficients land from the card's own printed estimate.
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.
"""
return in_card_units(spec, spec.card_index_eur_per_mwh) \
- spec.card_expect_incl_vat
# --------------------------------------------------------------------------- #
# Reading the option
# --------------------------------------------------------------------------- #
def _finite(value, field: str) -> float:
"""A real, finite number - or a refusal naming the field.
⚠️ SAME CLASS AS `_validate_raw()`'s NaN RULE, one layer earlier. A NaN
factor makes every price NaN, every comparison False, and an arbitrage
strategy that silently never fires; `True` is an `int` in Python and would
price the whole day at the index.
"""
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"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"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."""
if raw is 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 "
"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: "
"{\"month\": \"2026-09\", \"index_eur_per_mwh\": 133.9905, "
"\"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"{', '.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"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"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"))
def _month_date(month: str):
"""`"2026-09"` -> the first of that month, or None if it is not one."""
try:
year, mon = str(month).split("-")
return date(int(year), int(mon), 1)
except (TypeError, ValueError):
return None
def parse_price_formula(value):
"""`price_formula` -> a `FormulaSpec`, or None when unset.
Accepts what the option can actually arrive as, exactly as
`parse_static_profile()` does:
* `None` / `""` / `"null"` - unset.
* a JSON string - what config.yaml's flat `str?` option
carries on a Supervisor install.
* a dict - already parsed, from a future nested
schema, from sim/inject.py, or a check.
Raises `FormulaInvalid`; never returns a half-built spec.
⚠️ THE CARD SELF-CHECK IS PART OF PARSING, not a separate step a caller
may forget. Both callers - validate.py's startup gate and the plugin's own
fetch - go through this one function, so there is no route to a price that
has not been graded against the card.
"""
if value is None:
return None
if isinstance(value, str):
text = value.strip()
if not text or text.lower() in ("null", "none", "off"):
return None
try:
value = json.loads(text)
except ValueError as e:
raise FormulaInvalid(
f"price_formula 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'.")
if not isinstance(value, dict):
raise FormulaInvalid(
f"price_formula 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
# KEEPS THE "NEVER A GRAMMAR" PROMISE HONEST. A future shape is a NAMED
# field - clamp_lo, factor_negative, offset_bands - added here and
# refusable on its own. Ignoring unknown keys would mean a user who typed
# "expr" or "formula" gets priced on the fields they also typed, with the
# one they believed in dropped in silence.
unknown = sorted(set(value) - set(_TOP_KEYS))
if unknown:
raise FormulaInvalid(
f"price_formula 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 "
f"coefficients can be graded against your card's printed price "
f"and an arbitrary expression cannot be graded against anything. "
f"If your contract needs a shape this cannot express, it needs a "
f"new named field here - say which shape.")
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"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 "
f"and guessing which series your supplier meant would price your "
f"battery against the wrong curve.")
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"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 "
f"on the mean of each hour's quarters. Which one your contract "
f"uses is on your card and cannot be inferred from the feed.")
# ⚠️ `units` IS REQUIRED AND HAS NO DEFAULT. A default here would be the
# factor-1000 class with a friendly face: the add-on would silently assume
# a convention the card never stated, and the card self-check would then
# be graded in units nobody chose.
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"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")
# ⚠️ 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
# 0, arbitrage can never clear any threshold, and a strategy that never
# fires is indistinguishable from one that is switched off (§14's warning
# box). The add-on already has a first-class way to say "one price": point
# 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 "
"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.")
# ⚠️ 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
# charge into the evening peak and to discharge at 03:00. That is not a
# 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"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.")
# ⚠️ 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"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"))
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)
delta = card_discrepancy(spec)
if abs(delta) > CARD_TOLERANCE:
raise FormulaInvalid(card_refusal(spec, delta))
return spec
def card_refusal(spec: FormulaSpec, delta: float) -> str:
"""The sentence an operator reads when their formula misses its own card.
⚠️ IT NAMES THE DISCREPANCY AND THE SIX WAYS IT HAPPENS. "Invalid
configuration" would be true and useless: every one of these errors looks
exactly like a working tariff from the status page, which is why the check
exists at all.
"""
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"))
return (
f"price_formula 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 "
f"({spec.factor:g} x {spec.card_index_eur_per_mwh:g} + "
f"{spec.offset:g}) x (1 + {spec.vat_pct:g}/100) = {got:.4f} "
f"{spec.units}, but card.expect_incl_vat says "
f"{spec.card_expect_incl_vat:g} - OUT BY {delta:+.4f} {spec.units} "
f"(ratio {ratio:.4g}; the tolerance is {CARD_TOLERANCE}, which is the "
f"last digit a card prints). One of the two sides is wrong and the "
f"add-on cannot tell which, so it refuses rather than price a battery "
f"on it. The usual causes, in the order they happen: the coefficients "
f"and the expected price are in DIFFERENT units (ratio 10, 100 or "
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.")
# --------------------------------------------------------------------------- #
# The two questions the PLUGIN cannot ask, because they need framework numbers
# --------------------------------------------------------------------------- #
def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
"""The card's own point, if its LEVEL cannot be a Belgian retail price.
Returns a refusal sentence or None. ⚠️ CALLED FROM validate.py AND FROM
NOWHERE ELSE, because it reads a FRAMEWORK-owned number: a plugin that
could see `taxes` could add it a second time, which is price.py's trap 1.
⚠️ 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.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
day. A live EPEX hour can and should fall outside this band (a negative
hour is the best charging opportunity of the year, and §17 keeps a
regression case for it), so the band is deliberately NOT applied to the
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.
"""
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"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 "
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh for a Belgian "
f"retail tariff. The card self-check passed, so the coefficients and "
f"the printed price AGREE - they simply agree on a number no Belgian "
f"bill carries, which is what a whole card read in the wrong units "
f"looks like (a factor of 10, 100 or 1000 applied to BOTH sides at "
f"once cancels out of that check and cannot cancel out of this one). "
f"Check `units` against the unit printed beside your formula. "
f"⚠️ This bounds the CARD'S OWN POINT, not every hour: a live hour may "
f"legitimately fall outside the band, and a negative day-ahead hour - "
f"the best charging opportunity of the year - is never refused.")
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
old card can be exactly right and refusing one would stop the add-on on a
correctly configured site."""
dated = _month_date(spec.card_month)
now = today or datetime.now(timezone.utc).date()
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"{(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 "
f"however far they moved - and the card self-check cannot see it, "
f"because it grades your coefficients against the card you typed, not "
f"the card against your current contract. Not refused: ENGIE freezes "
f"coefficients for the contract's duration, so an old card is often "
f"exactly right. Worth re-reading your klantenzone once.")
# --------------------------------------------------------------------------- #
# Resolution
# --------------------------------------------------------------------------- #
def to_resolution(pairs, resolution: str) -> list:
"""`[(instant, index EUR/MWh)]` -> the series the CONTRACT is written on.
`quarter_hourly` hands the feed's own points back, ascending.
`hourly` averages each hour's members into one point AT THE TOP OF THE
HOUR.
⚠️ THE BUCKET IS A UTC HOUR AND THAT IS EXACT FOR BELGIUM, not an
approximation: CET and CEST are both WHOLE-hour offsets, so a Brussels hour
and a UTC hour start at the same instant on every day of the year including
both transition days. Keying by a naive local hour is what collides the two
02:00s in October; keying by the UTC instant cannot.
⚠️ AND THIS IS NOT `_Series.at()`'s HOURLY ASSUMPTION COMING BACK
(TARIFF-14). It decides how many points are EMITTED, not how long one
covers - the coverage window is still derived from the neighbours in
`_cover_table()`. A feed that already publishes hourly points averages one
member per hour, i.e. the identity, so this cannot silently assume the
granularity it was handed.
⚠️ AN INCOMPLETE HOUR IS AVERAGED OVER WHAT IS THERE rather than dropped or
zero-filled. A horizon that starts at 09:20 has two quarters in its first
hour; their mean is the best statement available about that hour, and
dropping it would leave a hole `at()` answers with `Absence` for an hour
the feed did publish.
"""
ordered = sorted(pairs, key=lambda r: r[0].astimezone(timezone.utc))
if resolution == "quarter_hourly":
return list(ordered)
if resolution != "hourly":
raise FormulaInvalid(f"unknown resolution {resolution!r}")
buckets: dict = {}
for start, value in ordered:
key = start.astimezone(timezone.utc).replace(
minute=0, second=0, microsecond=0)
buckets.setdefault(key, []).append(value)
return [(k, sum(v) / len(v)) for k, v in sorted(buckets.items())]
# --------------------------------------------------------------------------- #
# The startup preview
# --------------------------------------------------------------------------- #
PREVIEW_HOURS = 24
PREVIEW_MAX_ROWS = 24
def preview_lines(spec: FormulaSpec, rows) -> list:
"""`[(instant, index EUR/MWh, all-in EUR/kWh)]` -> lines for the log.
THE DEFECT THIS EXISTS TO MAKE VISIBLE: every error this module refuses is
one the operator cannot see from the status page, and the two numbers that
would have shown it - the index that went IN and the price that came OUT -
are never printed beside each other anywhere else. A factor of 1000 is
obvious in one line of `61.96 -> 0.13522` and invisible in a plan.
⚠️ IT PRINTS A BOUNDED NUMBER OF ROWS AND SAYS SO. At `quarter_hourly` the
next 24 h is 96 points; the cap is a row COUNT rather than "every fourth
point", because sampling every fourth point would assume the granularity
TARIFF-14 spent a card removing the assumption of.
"""
if not rows:
return []
shown = rows[:PREVIEW_MAX_ROWS]
prices = sorted(r[2] for r in rows)
mid = len(prices) // 2
median = (prices[mid] if len(prices) % 2
else (prices[mid - 1] + prices[mid]) / 2.0)
out = [
f"custom_formula preview - the next {PREVIEW_HOURS} h of YOUR tariff, "
f"index EUR/MWh -> all-in EUR/kWh, computed as "
f"({spec.factor:g} x index + {spec.offset:g}) x "
f"(1 + {spec.vat_pct:g}/100) {spec.units}, converted to EUR/kWh and "
f"with price_taxes_eur_kwh added by the framework:"]
for start, index, all_in in shown:
out.append(f" {start.isoformat()} {index:9.3f} -> {all_in:9.5f}")
if len(rows) > len(shown):
out.append(f" ... {len(rows) - len(shown)} further point(s) not "
f"listed; the figures below cover ALL {len(rows)}")
out.append(
f" min {prices[0]:.5f} / median {median:.5f} / max {prices[-1]:.5f} "
f"EUR/kWh, spread {prices[-1] - prices[0]:.5f} EUR/kWh over "
f"{len(rows)} point(s). ⚠️ If those are a thousand times what your "
f"bill says, `units` is wrong; if the spread is 0, `factor` is.")
return out
+160 -10
View File
@@ -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,7 +2713,13 @@ 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)
@@ -4481,15 +4623,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,
+459 -71
View File
@@ -72,9 +72,10 @@ THE THREE THINGS THIS MODULE IS SHAPED BY
reads the enum. A plugin id appears nowhere in this file except in
`PROVIDERS`, in `RETIRED_PROVIDERS`, and in the class that answers to it.
⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` (TARIFF-07). Both
shipped plugins are day-ahead, so `_WIDE`, `Uncertainty.REALTIME_PRODUCT`
and `WIDEN_FACTOR`'s 2.5 currently have no shipped declarer. They are KEPT
⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` (TARIFF-07). All
three shipped plugins are day-ahead, so `_WIDE`,
`Uncertainty.REALTIME_PRODUCT` and `WIDEN_FACTOR`'s 2.5 currently have no
shipped declarer. They are KEPT
deliberately - see DECISIONS/TARIFF-07-withdrawing-three-plugins.md - and
test_price.py section 10 keeps them live by declaring REALTIME from a class
defined outside this file, which is AC2's own stimulus.
@@ -149,6 +150,10 @@ from enum import Enum
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)
_LOG = logging.getLogger("goodwe.price")
@@ -813,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
@@ -831,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": "",
@@ -846,6 +916,25 @@ 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
# app/custom_formula.py, 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.
"price_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
@@ -872,6 +961,12 @@ 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",
})
@@ -983,11 +1078,20 @@ def export_price_eur_kwh(opts: dict) -> float:
the SAME `B x spot - A` shape as the consumption curve but carries 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, not a second
copy of the first. Nothing in this add-on can evaluate either one yet
(`generic_dayahead` cannot express `B x spot + A` at all; the formula
provider is a separate card). Until it can, this scalar is a SIMPLIFICATION
an operator enters by hand - an average of what they are actually paid -
and not a reflection of the market. When the formula provider lands, an
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.
@@ -998,6 +1102,27 @@ def export_price_eur_kwh(opts: dict) -> float:
return float(price_option(opts, "export_price_eur_kwh"))
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
# --------------------------------------------------------------------------- #
@@ -1046,6 +1171,28 @@ class PriceProvider(ABC):
inheriting the silence.
"""
def startup_preview(self, points) -> list:
"""Lines to log ONCE, the first time this provider answers (TARIFF-15).
`points` are the ASSEMBLED, all-in `PricePoint`s - what the site is
really priced on - and the default is to say nothing, so no existing
plugin changes behaviour.
⚠️ IT IS A HOOK RATHER THAN A LINE IN `load_prices()` BECAUSE THE
FRAMEWORK CANNOT KNOW WHAT IS WORTH PREVIEWING. Only the plugin knows
what went IN to its own arithmetic - for `custom_formula` the index in
EUR/MWh - and printing that beside the all-in figure is the whole
point: a factor of 1000 is obvious in one line of
`61.96 -> 0.13522` and invisible in a plan. The framework supplies the
one number a plugin may never read, by handing back the assembled
result rather than the tax rate.
⚠️ IT MUST NOT RAISE INTO THE CONTROL PATH and it is not trusted not
to: `_emit_startup_preview()` catches everything. A log line is not a
reason to stop regulating a battery.
"""
return []
# ⚠️ THE NAMES A PLUGIN'S OWN CODE MAY NOT MENTION. FRAMEWORK_KEYS is the
# obvious half; the other three are the routes review of PR #16 actually used to
@@ -1243,23 +1390,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
# --------------------------------------------------------------------------- #
@@ -1491,6 +1683,60 @@ def _points_from_columnar_json(body, expect_unit, scale,
return out
def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str:
"""The day-ahead URL for a window, or the operator's override, verbatim.
⚠️ SHARED BY `generic_dayahead` AND `custom_formula`, ON PURPOSE
(TARIFF-15). They read the SAME feed for the same range; two copies of this
rule would be two places for the Brussels-calendar derivation and the
override decision to disagree, and the one that is wrong would be wrong
silently - §11.4 turns a short horizon into provisional hours, not into an
error. It takes `plugin_id` rather than reading one, so the framework's
AC2 rule (no plugin id in shared code) still holds here.
⚠️ AN OVERRIDE IS THE EXACT URL AND NOTHING IS APPENDED TO IT, WHICH COSTS
THE OVERRIDING SITE THE RANGE. That is a decision, not an oversight, and
the sentence below is why it is not a footnote:
* `price_feed_url` says "override the plugin's default feed", and an
override that is silently rewritten is not one. Merging parameters
into an operator's query string means parsing and re-serialising a
URL they wrote - which reorders, de-duplicates and re-escapes it,
and can hand the server a request nobody typed.
* `start`/`end` are ENERGY-CHARTS' spelling, and both providers DECLARE
A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has verified that
host's day-ahead parameters and nothing here should guess them - but
they are not `start`/`end`, so an override to the other DECLARED host
is exactly the case where appending our vocabulary adds two parameters
that host ignores and yields the short horizon anyway, with nothing
saying so.
* and blind appending is refused outright: `dated_feed_url()` will not
add a query string to a base that has one.
So the cost is real and it is REPORTED, at fetch time, on the shipped
logger, naming the horizon consequence rather than the substitution.
§11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
"""
override = str(opts.get("price_feed_url") or "").strip()
if override:
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
plugin_id, override, *(d.isoformat()
for d in brussels_date_range(since, until)))
return override
first, last = brussels_date_range(since, until)
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
end=last.isoformat())
@register
class GenericDayAhead(PriceProvider):
"""Public EPEX BE day-ahead feed, COLUMNAR, in EUR/MWh.
@@ -1533,57 +1779,22 @@ class GenericDayAhead(PriceProvider):
async def fetch(self, opts, since, until, transport):
"""Request the range the CALLER asked for, on the Brussels calendar.
⚠️ THE ARGUMENTS ARE USED. Until TARIFF-09 this method took `since` and
`until` and asked for the bare `?bzn=BE`, which answers ONE Brussels
day - so a 48 h horizon was 15 firm hours and 33 carried at 09:00, and
1 firm and 47 carried at 23:00, with the whole trade set inside a
single calendar day at every hour of every day. See
`brussels_date_range()` for the measurement and for why the calendar
is Brussels rather than UTC.
⚠️ THE ARGUMENTS ARE USED, and `dayahead_feed_url()` above is where
they are turned into a URL. Until TARIFF-09 this method took `since`
and `until` and asked for the bare `?bzn=BE`, which answers ONE
Brussels day - so a 48 h horizon was 15 firm hours and 33 carried at
09:00, and 1 firm and 47 carried at 23:00, with the whole trade set
inside a single calendar day at every hour of every day.
⚠️ AN OVERRIDE IS THE EXACT URL AND THIS METHOD APPENDS NOTHING TO IT,
WHICH COSTS THE OVERRIDING SITE THE RANGE. That is a decision, not an
oversight, and the sentence below is why it is not a footnote:
* `price_feed_url` says "override the plugin's default feed", and an
override that is silently rewritten is not one. Merging parameters
into an operator's query string means parsing and re-serialising a
URL they wrote - which reorders, de-duplicates and re-escapes it,
and can hand the server a request nobody typed.
* `start`/`end` are ENERGY-CHARTS' spelling, and this provider
DECLARES A SECOND HOST: `web-api.tp.entsoe.eu`. Nothing here has
verified that host's day-ahead parameters and nothing here should
guess them - but they are not `start`/`end`, so an override to the
other DECLARED host is exactly the case where appending our
vocabulary adds two parameters that host ignores and yields the
short horizon anyway, with nothing saying so.
* and blind appending is refused outright: `dated_feed_url()` will
not add a query string to a base that has one.
So the cost is real and it is REPORTED, at fetch time, on the shipped
logger, naming the horizon consequence rather than the substitution.
§11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
⚠️ THE URL DECISION MOVED OUT OF THIS METHOD IN TARIFF-15 AND DID NOT
CHANGE. `custom_formula` reads the same feed with the same range and
the same parser, and a second copy of the override rule would be a
second place for it to be wrong - which is the reason `covers()`
delegates to `at()` rather than restating its window. The reasoning,
the warning and the exact URL are all in `dayahead_feed_url()`.
"""
override = str(opts.get("price_feed_url") or "").strip()
if override:
url = override
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
self.id, url, *(d.isoformat()
for d in brussels_date_range(since, until)))
else:
first, last = brussels_date_range(since, until)
url = dated_feed_url(self.FEED_BASE, bzn=self.BZN,
start=first.isoformat(),
end=last.isoformat())
url = dayahead_feed_url(self.id, opts, since, until,
self.FEED_BASE, self.BZN)
check_egress(url, self.capabilities())
body = await transport(url, float(opts.get("price_timeout_s")))
return _points_from_columnar_json(body, expect_unit="EUR / MWh",
@@ -1660,10 +1871,160 @@ class ManualCsv(PriceProvider):
return out
@register
class CustomFormula(PriceProvider):
"""The operator's OWN tariff card, as two fixed coefficients. TARIFF-15.
SOURCES: energy, markup
The framework ADDS: taxes
⚠️ IT DECLARES `{ENERGY, MARKUP}` AND NOT `ALL_IN`, AND THAT IS THE WHOLE
SAFETY ARGUMENT. A Belgian card's `Tariefformule` line is the ENERGY
component only - Eneco tabulates network tariffs and levies separately,
ENGIE states that the formula excludes network, distribution, transport,
grid levies and excise - so declaring ALL_IN would lie on behalf of every
operator who typed exactly what their card says, and would silently drop
~12 c EUR/kWh from every hour. The framework adds `price_taxes_eur_kwh`
on top, once, and this class cannot see or apply it (trap 1).
The risk that introduces is one no guard in this file can see, because it
is not in code: THE OPERATOR folding their network charges into `offset`
while the tax option carries them too. `app/custom_formula.py`'s card
self-check is what refuses that - the card's printed estimate is the
energy component alone, so an inflated offset moves 15.55 to ~27 and the
add-on will not start. Its limit is stated in the same breath there: it
grades the coefficients against the card, NOT the card against reality.
⚠️ NO EXPRESSION EVALUATION OF ANY KIND, AND THE COEFFICIENTS ARE NUMBERS
BECAUSE THE PARSER REFUSES ANYTHING ELSE. This string is settable over
HTTP through the add-on's own ingress config page, so `eval` on it would
be RCE in a root container holding SUPERVISOR_TOKEN - and, the deciding
argument, an arbitrary expression could not be graded against the card at
all. See app/custom_formula.py.
⚠️ THE INDEX PATH IS `generic_dayahead`'s, NOT A SECOND ONE. Same feed,
same `dayahead_feed_url()` request on the Brussels calendar, same columnar
parser, same declared egress hosts. A second parser would be a second
failure mode for a document that already has one that works.
"""
id = "custom_formula"
FEED_BASE = "https://api.energy-charts.info/price"
BZN = "BE"
# Written by fetch(), read by startup_preview(), and by nothing else.
spec = None
index_by_instant: dict = {}
@classmethod
def capabilities(cls):
return ProviderCapabilities(
product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY, Component.MARKUP}),
egress_hosts=("api.energy-charts.info", "web-api.tp.entsoe.eu"))
async def fetch(self, opts, since, until, transport):
"""Fetch the index, apply the card's arithmetic, return RawPoints.
⚠️ `since`/`until` ARE USED, through the shared URL builder - see
`dayahead_feed_url()` and TARIFF-09 for what ignoring them costs.
"""
try:
spec = parse_price_formula(opts.get("price_formula"))
except FormulaInvalid as e:
raise SourceUnavailable(f"price_formula: {e}")
if spec is None:
raise SourceUnavailable(
"price_provider is 'custom_formula' but price_formula is "
"empty, so there are no coefficients to price anything with. "
"validate.py refuses this configuration at startup; reaching "
"it here means the options blob changed under a running "
"add-on")
url = dayahead_feed_url(self.id, opts, since, until,
self.FEED_BASE, self.BZN)
check_egress(url, self.capabilities())
body = await transport(url, float(opts.get("price_timeout_s")))
# ⚠️ `scale=1.0`, SO THESE RawPoints CARRY EUR/**MWh** IN A FIELD
# SPELLED `eur_per_kwh`. That is a trap and it is contained on purpose:
# the list is destructured on the very next line and never leaves this
# method, so nothing downstream can read the wrong unit off it. The
# alternative - scaling to EUR/kWh and multiplying by 1000 again to
# feed the formula - is two conversions where the contract needs none,
# and every extra conversion here is another place to lose a factor of
# 1000. If this list ever DID escape, `_validate_raw()` would refuse it
# at `MAX_PLAUSIBLE_EUR_KWH` rather than price a day on it.
index = _points_from_columnar_json(body, expect_unit="EUR / MWh",
scale=1.0,
provisional_default=False)
pairs = to_resolution([(p.start, p.eur_per_kwh) for p in index],
spec.resolution)
self.spec = spec
self.index_by_instant = {t.astimezone(timezone.utc): v
for t, v in pairs}
# ⚠️ `provisional=False` FOR THE SAME REASON `generic_dayahead` SAYS
# SO: the day-ahead auction is settled once published, and the columnar
# document carries no per-point flag to read one from. FCST-04's
# carry-forward is what marks the hours nobody published yet.
return [RawPoint(start=t, eur_per_kwh=price_eur_kwh(spec, v),
provisional=False)
for t, v in pairs]
def startup_preview(self, points) -> list:
"""`index EUR/MWh -> all-in EUR/kWh` for the first 24 h, once.
⚠️ THE INDEX COLUMN IS WHY THIS HOOK EXISTS. The all-in price alone
says nothing about which of the two numbers is wrong; printed beside
the index it came from, a factor of 1000 is one glance. See
`preview_lines()` for the rows and the min/median/max/spread line.
"""
if self.spec is None or not points:
return []
cut = points[0].start + timedelta(hours=PREVIEW_HOURS)
rows = []
for point in points:
if point.start >= cut:
break
index = self.index_by_instant.get(
point.start.astimezone(timezone.utc))
if index is not None:
rows.append((point.start, index, point.eur_per_kwh))
return preview_lines(self.spec, rows)
# --------------------------------------------------------------------------- #
# The one entry point
# --------------------------------------------------------------------------- #
# Provider ids whose startup preview has already been emitted in this process.
# ⚠️ NOT A PLUGIN NAME IN THE FRAMEWORK: it is filled at runtime from whatever
# id was resolved, and starts empty. AC2 is intact.
_PREVIEWED: set = set()
def _emit_startup_preview(plugin, pid: str, points) -> None:
"""Log a plugin's preview once per process, and never fail because of it.
⚠️ THE ID IS RECORDED BEFORE THE HOOK RUNS, DELIBERATELY. A preview that
raises must not be retried every 30 minutes for the life of the add-on -
that would turn a cosmetic defect into a log flood on the one path §11.4
already makes hard to read.
"""
if pid in _PREVIEWED:
return
_PREVIEWED.add(pid)
try:
lines = list(plugin.startup_preview(points))
except Exception as e: # noqa: BLE001
_LOG.warning("%s's startup preview raised (%s: %s); prices are "
"unaffected - this is a log line, not a decision",
pid, type(e).__name__, e)
return
for line in lines:
_LOG.info("%s", line)
async def load_prices(opts: dict, since: datetime, until: datetime,
transport=None) -> PriceRead:
"""Resolve the configured provider, fetch, assemble all-in, return a read.
@@ -1690,13 +2051,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 "
@@ -1709,8 +2095,9 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
if transport is None:
transport = https_get
plugin = cls()
try:
raw = await cls().fetch(PluginOptions(opts), since, until, transport)
raw = await plugin.fetch(PluginOptions(opts), since, until, transport)
_validate_raw(raw, caps)
points = assemble(caps, raw, markup, taxes)
except FrameworkOption:
@@ -1720,5 +2107,6 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
except Exception as e: # noqa: BLE001 - see the docstring
return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}")
_emit_startup_preview(plugin, pid, points)
return PriceRead.series(points, caps.product, pid,
datetime.now(timezone.utc))
+12 -2
View File
@@ -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)
+255 -41
View File
@@ -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 "
+124 -3
View File
@@ -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
+188 -5
View File
@@ -324,12 +324,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)
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")
@@ -1249,9 +1255,12 @@ def _price_problems(opts: dict) -> list[str]:
share of the installed base, per §9.2 - cannot run at all.
Five refusals and two log lines (TARIFF-03 added one refusal, one parse and
two notices; TARIFF-07 added the retired-id one), and every refusal is still
case (a) from this module's docstring: a source the operator SET being
overridden by a layer they cannot see. The additions are `static_profile`
two notices; TARIFF-07 added the retired-id one; TARIFF-15 added three -
a `custom_formula` with no formula, a formula that does not parse or does
not reproduce its own card, and one whose card lands outside the plausible
band - plus two notices, a formula nothing reads and a stale card), and
every refusal is still case (a) from this module's docstring: a source the
operator SET being overridden by a layer they cannot see. The additions are `static_profile`
alongside the Home Assistant price pair - the same silent-loser shape as
provider-plus-pair, and a LIVE one - and a `static_profile` that does not
tile the day, which would leave hours with no price at all.
@@ -1415,6 +1424,180 @@ 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.
#
# THE DEFECT THIS EXISTS TO PREVENT, and it is not one `code_leaks()` can
# see because it is not in code: an operator folds their NETWORK CHARGES
# into `offset` while `price_taxes_eur_kwh` carries them as well, and the
# site is billed against a price ~12 c EUR/kWh high on every hour for as
# long as nobody re-reads the configuration. §11.4 makes every price
# defect quiet, so nothing on the status page would ever differ.
#
# The card is what grades it. A Belgian tariff card prints the formula,
# the reference index it assumed AND the resulting price, so the add-on
# recomputes that printed price from the numbers the operator typed and
# refuses to start unless the two agree to within CARD_TOLERANCE. An
# inflated `offset` cannot pass - the printed estimate is the energy
# component alone, so ~12 c EUR/kWh moves 15.55 to ~27. The same check
# catches VAT omitted, VAT applied twice, a transposed digit, and a unit
# MISMATCH between the coefficients and the printed estimate. The one unit
# error it cannot see is the one applied to BOTH sides at once, because
# that cancels out of a comparison between them - which is why
# `implausible_all_in()` below is a second refusal on a second quantity
# rather than a restatement of this one.
#
# ⚠️ AND ITS LIMIT, IN THE SAME SENTENCE AS THE CLAIM, exactly as price.py
# states trap 1's: IT GRADES THE COEFFICIENTS AGAINST THE CARD, NOT THE
# 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.
raw_formula = opts.get("price_formula", "")
has_formula = bool(str(raw_formula or "").strip()) \
or isinstance(raw_formula, dict)
if pid == "custom_formula" and not has_formula:
problems.append(
"price_provider is 'custom_formula' but price_formula is empty, "
"so the add-on has no coefficients to price anything with. Every "
"fetch would return no-price-data and §11.4 would fall back "
"silently - which is the shape of defect this provider exists to "
"remove, arrived at by leaving one field blank. Copy the "
"Tariefformule line off your supplier's tariff card, e.g. "
"{\"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}} "
"- see DOCS.md. Or set price_provider to 'generic_dayahead' for "
"the wholesale curve with a flat markup.")
elif has_formula:
try:
spec = parse_price_formula(raw_formula)
except FormulaInvalid as e:
spec = None
problems.append(f"price_formula cannot be used: {e}")
if spec is not None and pid != "custom_formula":
# ⚠️ A WARNING AND NOT A REFUSAL. This is not two price sources
# fighting over one flag - it is one that is simply not wired to
# anything - so the §14 warning box does not apply. But an option
# an operator filled in and that decides nothing is exactly the
# silent loser this module's case (a) is about, so it is said out
# loud rather than left to be discovered from a bill.
_LOG.warning(
"price_formula is filled in but price_provider is %r, so the "
"coefficients decide NOTHING - only 'custom_formula' reads "
"them. Set price_provider to 'custom_formula' to use them, or "
"clear price_formula so the configuration says what the "
"add-on is doing.", pid)
if spec is not None and pid == "custom_formula":
taxes = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
band = implausible_all_in(spec, taxes if taxes is not None else 0.0)
if band:
problems.append(band)
aged = stale_card(spec)
if aged:
# ⚠️ WARN, NEVER REFUSE. ENGIE freezes a contract's
# coefficients for its duration, so a two-year-old card can be
# exactly right and a site refused over one is a site that
# cannot start.
_LOG.warning("%s", aged)
# ⚠️ 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
+121 -2
View File
@@ -286,9 +286,34 @@ options:
# the FRAMEWORK, only for the components the chosen plugin declares its feed
# does not already contain. manual_csv carries an all-in figure, so setting
# either under it is ignored - and logged as ignored, never applied twice.
# `custom_formula` (TARIFF-15) carries energy AND markup - your contract's
# formula IS the markup - so it takes price_taxes_eur_kwh only, and your
# 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
@@ -324,6 +349,91 @@ options:
# to fall back to and a dead feed leaves the site with no tariff at all.
# 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.
#
# A Belgian dynamic contract prices energy as `B x index + A`, and the two
# coefficients exist in exactly ONE place - the tariff card in your
# klantenzone. There is no machine-readable source for them anywhere (CREG's
# tariff-card pages 404, CREG Scan is a JS wizard, VREG was unreachable), so
# no plugin and no scraper can ever know YOUR numbers. You are the only party
# who can see them.
#
# A JSON object, on one line. You type what your card prints, IN THE CARD'S
# OWN UNITS; every unit conversion and the VAT multiplication are the
# add-on's job, because those are exactly the two steps that silently cost a
# factor of 10, 100 or 1.06.
#
# {"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}}
#
# price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100), in
# `units`, converted to EUR/kWh by the add-on.
#
# ⚠️ THE `card` BLOCK IS REQUIRED AND IT IS THE SAFETY MECHANISM. Your card
# prints the formula, the reference index it assumed AND the resulting price.
# At startup the add-on recomputes that printed price from the numbers you
# typed and REFUSES TO START unless the two agree to within 0.01 - naming the
# discrepancy. That catches VAT left out or applied twice, a transposed
# digit, a unit MISMATCH between the coefficients and the printed price
# (10x/100x/1000x), and the expensive one: NETWORK CHARGES FOLDED INTO
# `offset`. A unit error applied to BOTH sides at once cancels out of that
# comparison; the add-on refuses that separately, by checking the card's own
# point against the band a Belgian retail tariff can occupy. Your card's formula line is the ENERGY component
# alone; the add-on adds price_taxes_eur_kwh on top, so folding ~12 c/kWh of
# network charge into `offset` would bill you for it twice - and it cannot
# pass, because it moves the computed 15.55 to about 27.
#
# ⚠️ AND THE LIMIT OF THAT CHECK, IN THE SAME BREATH: it grades your
# COEFFICIENTS AGAINST YOUR CARD, not your card against reality. Two numbers
# copied off the wrong product agree with each other perfectly and pass.
#
# ⚠️ FIXED COEFFICIENTS ONLY - the add-on evaluates NO expression of any kind
# and there is no field that takes one. An unknown key is refused rather than
# ignored. See DOCS.md for why (it is not only security: two coefficients can
# be graded against your card and an arbitrary expression cannot be graded
# against anything).
price_formula: ""
price_csv_path: "/share/goodwe_prices.csv"
# ⚠️ AN OVERRIDE IS THE EXACT URL (TARIFF-09). The plugin sends it verbatim
# and appends nothing - including the `start`/`end` date range it derives from
@@ -782,7 +892,7 @@ schema:
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
# typed - and register() refuses to re-register a retired id, so the two
# cannot both claim one.
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_provider: list(off|generic_dayahead|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_markup_eur_kwh: float(0,1)
price_taxes_eur_kwh: float(0,1)
@@ -796,6 +906,15 @@ schema:
# 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
# nested schema is a config.yaml change and no code change at all.
price_formula: str?
price_csv_path: str?
price_feed_url: str?
price_horizon_h: int(1,72)
+118
View File
@@ -527,6 +527,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")
+1 -1
View File
@@ -7,7 +7,7 @@ WHAT UX-01 AC 4 ASKED FOR, IN ITS OWN WORDS: "every `schema:` key absent from
check, not a grep."
WHAT SHIPPED. Clause one is VACUOUS: `options:` and `schema:` declare the same
71 keys, so "every schema key absent from options" quantifies over the empty set
72 keys, so "every schema key absent from options" quantifies over the empty set
and is true of any tree, including one with no defaults at all. Clause two -
"the table changed goes red" - does work, six times over, once per mirror in its
own suite. Clause three, THE ONE WITH THE CONTENT, was never written. The only
File diff suppressed because it is too large Load Diff
+46 -8
View File
@@ -1101,19 +1101,57 @@ _cfg = derived(lambda: open(os.path.join(HERE, "config.yaml"),
# why, as this line does.
_OPT04_KEYS = ["arbitrage_enabled", "cycle_cost_threshold_eur",
"arbitrage_soc_floor", "arbitrage_soc_ceiling"]
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's and OPT-04's "
f"keys - FCST-03 still added none of its own {_defaults}",
# TARIFF-15 added the fifth, and came here to say so as this comment instructs:
# `price_formula` is the `custom_formula` provider's only option - one JSON
# string carrying one contract's coefficients - and it lives in this dict and
# in config.yaml and nowhere else, which is the arrangement `static_profile`
# already has for the same reason.
_T15_KEYS = ["price_formula"]
# 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"]
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
f"TARIFF-15's and TARIFF-17's keys - FCST-03 still added none of its own "
f"{_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))
# ...and the four 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 keys are additions rather than replacements: the "
"nine TARIFF-02/03 keys are all still present",
"static_profile"]
+ _OPT04_KEYS + _T15_KEYS + _T17_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, TARIFF-15's one and TARIFF-17'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)) == 9)
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)
- set(_T17_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)
+197 -2
View File
@@ -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:
+359 -14
View File
@@ -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)
+11 -1
View File
@@ -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()):
@@ -362,7 +372,7 @@ print("\n7. no behaviour and no value moved")
# test_peakguard.py's. This line is the cheap one that says the reorder did not
# lose or duplicate a key on the way.
check("no option was lost, gained or duplicated by the reordering: the two "
"halves of config.yaml hold the same 71 keys, once each",
"halves of config.yaml hold the same 72 keys, once each",
sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER))
check("...and `inverters: []` is still shipped in the options half, which is "
"what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without "
+187
View File
@@ -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,191 @@ 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()
if fails:
+11 -5
View File
@@ -3,8 +3,8 @@
# ⚠️ WHAT THIS FILE IS FOR, AND WHAT HOME ASSISTANT ACTUALLY DOES WITH IT.
# Without a `translations/` directory the Configuration tab renders the raw
# option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w`.
# The frontend keeps a field when `e.name in options || e.required`, and all 71
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-ONE are
# The frontend keeps a field when `e.name in options || e.required`, and all 72
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-TWO are
# rendered, always, in `schema:` order, with no description. This file is what
# turns that into sentences.
#
@@ -143,19 +143,25 @@ configuration:
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
price_provider:
name: "Tariff · Dynamic price source"
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH - not the trading switch further down. With a source selected the add-on fetches the feed every half hour, checks it, records the day and shows what it holds on the Price history row, whether or not price trading is armed. On its own it still moves no energy: trading is the separate switch, and the cheap window the monthly maintenance cycle uses comes from the fixed or day/night tariff below. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed. ⚠️ THREE IDS IN THIS LIST ARE WITHDRAWN AND WILL NOT START: engie_dynamic, totalenergies_dynamic and eneco_eddy. Their endpoints do not exist and never did, so those plugins never once fetched a price - a site set to one of them has silently been running on the fixed or day/night tariff below, or on nothing, since the day it was set. They are still offered here only so that such a site gets that explanation at startup instead of an unreadable schema error. Choose generic_dayahead, manual_csv or off."
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH - not the trading switch further down. With a source selected the add-on fetches the feed every half hour, checks it, records the day and shows what it holds on the Price history row, whether or not price trading is armed. On its own it still moves no energy: trading is the separate switch, and the cheap window the monthly maintenance cycle uses comes from the fixed or day/night tariff below. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed. ⚠️ THREE IDS IN THIS LIST ARE WITHDRAWN AND WILL NOT START: engie_dynamic, totalenergies_dynamic and eneco_eddy. Their endpoints do not exist and never did, so those plugins never once fetched a price - a site set to one of them has silently been running on the fixed or day/night tariff below, or on nothing, since the day it was set. They are still offered here only so that such a site gets that explanation at startup instead of an unreadable schema error. Choose generic_dayahead for the wholesale curve with a flat markup, custom_formula to enter your own contract's B x index + A coefficients off your tariff card, manual_csv for a file you write yourself, or off."
price_markup_eur_kwh:
name: "Tariff · Supplier markup (EUR/kWh)"
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under 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_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."
+9 -3
View File
@@ -123,19 +123,25 @@ configuration:
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
price_provider:
name: "Tarief · Bron van dynamische prijzen"
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ DIT AANZETTEN IS WAT HET OPHALEN VAN PRIJZEN START - niet de handelsschakelaar verderop. Met een bron ingesteld haalt de add-on de feed elk half uur op, controleert die, bewaart de dag en toont op de rij Prijsgeschiedenis wat er bewaard is, of prijshandel nu aan staat of niet. Op zichzelf verplaatst dat nog geen energie: handelen is de aparte schakelaar, en het goedkope venster dat de maandelijkse onderhoudscyclus gebruikt komt uit het vaste of dag/nacht-tarief hieronder. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten. ⚠️ DRIE IDS IN DEZE LIJST ZIJN INGETROKKEN EN STARTEN NIET MEER: engie_dynamic, totalenergies_dynamic en eneco_eddy. Hun endpoints bestaan niet en hebben nooit bestaan, dus die plugins hebben nooit één prijs opgehaald - een installatie die op zo'n bron stond, draait sindsdien stilzwijgend op het vaste of dag/nacht-tarief hieronder, of op niets. Ze staan hier alleen nog zodat zo'n installatie bij het opstarten die uitleg krijgt in plaats van een onleesbare schemafout. Kies generic_dayahead, manual_csv of off."
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ DIT AANZETTEN IS WAT HET OPHALEN VAN PRIJZEN START - niet de handelsschakelaar verderop. Met een bron ingesteld haalt de add-on de feed elk half uur op, controleert die, bewaart de dag en toont op de rij Prijsgeschiedenis wat er bewaard is, of prijshandel nu aan staat of niet. Op zichzelf verplaatst dat nog geen energie: handelen is de aparte schakelaar, en het goedkope venster dat de maandelijkse onderhoudscyclus gebruikt komt uit het vaste of dag/nacht-tarief hieronder. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten. ⚠️ DRIE IDS IN DEZE LIJST ZIJN INGETROKKEN EN STARTEN NIET MEER: engie_dynamic, totalenergies_dynamic en eneco_eddy. Hun endpoints bestaan niet en hebben nooit bestaan, dus die plugins hebben nooit één prijs opgehaald - een installatie die op zo'n bron stond, draait sindsdien stilzwijgend op het vaste of dag/nacht-tarief hieronder, of op niets. Ze staan hier alleen nog zodat zo'n installatie bij het opstarten die uitleg krijgt in plaats van een onleesbare schemafout. Kies generic_dayahead voor de groothandelscurve met een vaste marge, custom_formula om de coëfficiënten B x index + A van uw eigen contract van uw tariefkaart over te typen, manual_csv voor een bestand dat u zelf schrijft, of off."
price_markup_eur_kwh:
name: "Tarief · Marge van de leverancier (EUR/kWh)"
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij 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_csv_path:
name: "Tarief · CSV-bestand met prijzen"
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."