- messages.setting() for one inverter in the list reads “Inverter rating” for inverter-b (inverters.max_w), so the config panel files per-unit reasons under `inverters`, where the value is changed. - The tariff card and plausibility reasons name “Distribution, levies and VAT” (price_taxes_eur_kwh) again. - The wrong-direction reasons say "safety stop", the sign_disagree_w option says what it protects, and the roster, price-entity, integrator_max_w and capacity_kwh texts read more plainly. - NL: stuurprint -> stuurmodule, "De piekbeperking". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
914 lines
46 KiB
Python
914 lines
46 KiB
Python
"""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.
|
|
|
|
TARIFF-16 - THE SECOND LEG, AND WHY IT IS THIS PARSER AND NOT A SECOND ONE
|
|
==========================================================================
|
|
A Belgian dynamic contract has TWO published lines, not one, and the second is
|
|
what the site is PAID for export:
|
|
|
|
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
|
|
ENGIE Dynamic (B x eSpot_15) - A
|
|
Aspiravi (non-dynamic) 0,07 x Belpex - 2
|
|
|
|
Same `B x index + A` shape - which is why `price_export_formula` is this
|
|
module's parser with ONE argument moved (`option`), exactly as TARIFF-17 made
|
|
`parse_static_profile()`'s option name a parameter rather than forking the
|
|
period grammar. What differs between the two legs is FOUR numbers and a
|
|
handful of sentences, and all of them are rows of `_LEGS`:
|
|
|
|
* its OWN factor - Eneco 0,102 on consumption against 0,100 on injection,
|
|
Aspiravi 0,07 against ~0,10. NOT the consumption `B` with a sign flip;
|
|
* a NEGATIVE offset, which `factor`/`offset` already allow and which
|
|
`price_markup_eur_kwh: float(0,1)` cannot express at all;
|
|
* `vat_pct: 0` - "Injectietarieven zijn vrijgesteld van btw" - against 6 on
|
|
consumption, which is a SIX PERCENT ASYMMETRY between the buy leg and the
|
|
sell leg and is representable only because `vat_pct` is PER SPEC;
|
|
* a price that goes BELOW ZERO with no floor, in the hours a PV household
|
|
exports. `implausible_injection()` is the band that accepts that and still
|
|
refuses an implausible one; `implausible_all_in()` is NOT it, and its
|
|
docstring says why.
|
|
|
|
⚠️ AND THE CARD CANNOT ALWAYS GRADE IT. Eneco's injection formula is printed
|
|
with NO price beside it, so on that card there is nothing to reproduce. The
|
|
`card` block is therefore OPTIONAL on this leg and REQUIRED on the other, the
|
|
self-check runs wherever there is a printed estimate, and validate.py says at
|
|
startup when a leg is ungraded. Inventing an estimate to grade against would
|
|
be grading arithmetic against itself, which is the defect MUTATION.md habit 4
|
|
is about; TARIFF-15 demonstrated two tariffs end to end rather than three for
|
|
the same reason.
|
|
|
|
⚠️ NOTHING TRADES ON THIS TODAY, AND SAYING SO IS PART OF SHIPPING IT.
|
|
`arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment
|
|
says so, citing §9.3's near-zero Belgian injection price - and both hours it
|
|
compares are all-in CONSUMPTION prices. So this is not a wrong-trade defect
|
|
and must not be described as one. What it changes is what an operator is told,
|
|
and what a future export-aware strategy would rest on.
|
|
"""
|
|
|
|
import json
|
|
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": "Belgian day-ahead price, 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")
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TARIFF-16 - the SECOND leg, and the one parameter that carries it
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
EXPORT_FORMULA_OPTION = "price_export_formula"
|
|
|
|
# ⚠️ THE PASS-THROUGH FRACTION, WHICH IS WHY THE NEXT TWO BOUNDS CAN BE
|
|
# INDEX-FREE. One EUR/MWh IS 0.001 EUR/kWh, so `factor x UNITS[units] / 0.001`
|
|
# is the dimensionless share of the wholesale price the contract passes
|
|
# through - 1.00 for Eneco's injection leg (0,100 c EUR/kWh per EUR/MWh), 0.70
|
|
# for Aspiravi's (0,07). A coefficient typed a hundred times too small or too
|
|
# large moves this by a hundred, whatever the index and whatever the sign of
|
|
# the price.
|
|
EUR_KWH_PER_EUR_MWH = 0.001
|
|
|
|
# The two plausibility bounds for an INJECTION leg. ⚠️ NEITHER LOOKS AT THE
|
|
# SIGN OF THE PRICE, AND THAT IS THE WHOLE POINT - see
|
|
# `implausible_injection()`.
|
|
PASSTHROUGH_LO = 0.10 # 10% of the wholesale price
|
|
PASSTHROUGH_HI = 10.0 # 1000% of it
|
|
INJECTION_OFFSET_MAX_EUR_KWH = 0.20
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _Leg:
|
|
"""What ONE option's coefficients price, as sentences its refusals read.
|
|
|
|
⚠️ TARIFF-16 MADE `option` A PARAMETER RATHER THAN FORKING THE PARSER,
|
|
exactly as TARIFF-17 made `option`/`means` parameters of
|
|
`parse_static_profile()` rather than forking the period grammar. A second
|
|
copy of this parser would be a second `_finite()`, a second unknown-key
|
|
rule, a second unit table and a second `to_resolution()` - and the two
|
|
would diverge first on the 23- and 25-hour days, where nobody looks.
|
|
Everything that differs between the consumption leg and the injection leg
|
|
is a STRING in this table; the arithmetic, the enumerations, the card
|
|
comparison and the resolution rule are ONE implementation with one set of
|
|
checks over it.
|
|
"""
|
|
|
|
option: str
|
|
what: str # "consumption" / "injection", for a sentence
|
|
example: str # a VALID blob for this leg, in the JSON-error hint
|
|
when: str # when this option is read at all
|
|
flat: str # where a FLAT price of this kind already lives
|
|
zero_factor: str # what a factor of 0 actually costs on this leg
|
|
neg_factor: str # why a negative FACTOR is wrong on this leg
|
|
card_required: bool
|
|
card_note: str # why it is required, or what is lost without it
|
|
card_causes: str # the leg-specific tail of `card_refusal()`
|
|
|
|
|
|
_LEGS = {
|
|
"price_formula": _Leg(
|
|
option="price_formula",
|
|
what="consumption",
|
|
example=('{"index":"epex_be","resolution":"hourly","factor":0.102,'
|
|
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
|
|
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
|
'"expect_incl_vat":15.55}}'),
|
|
when=("Leave it empty unless “Dynamic price source” (price_provider) "
|
|
"is 'custom_formula'."),
|
|
zero_factor=" and trading never finds a price difference",
|
|
flat="use “Fixed or day/night tariff” (static_profile) instead",
|
|
neg_factor="Put the minus sign on the offset, not on the factor",
|
|
card_required=True,
|
|
card_note="The add-on needs it to check your two numbers",
|
|
card_causes=("that network charges are in “Distribution, levies and VAT” "
|
|
"(price_taxes_eur_kwh), not in the offset"),
|
|
),
|
|
EXPORT_FORMULA_OPTION: _Leg(
|
|
option=EXPORT_FORMULA_OPTION,
|
|
what="injection",
|
|
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
|
|
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
|
|
when=("Fill it in only if your injection price follows the market. It "
|
|
"is used whatever the price source is."),
|
|
zero_factor="",
|
|
flat="use “Injection price” (export_price_eur_kwh) instead",
|
|
neg_factor="Put the minus sign on the offset, not on the factor",
|
|
card_required=False,
|
|
card_note=("It is optional here. Without it the add-on only checks "
|
|
"the size of your two numbers"),
|
|
card_causes="that vat_pct is 0 for injection",
|
|
),
|
|
}
|
|
|
|
|
|
|
|
@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 = None
|
|
card_expect_incl_vat: float = None # in `units`, as the card prints it
|
|
# TARIFF-16. Which OPTION this spec was read from, so a refusal, a log line
|
|
# and `card_graded` name the leg the operator actually typed.
|
|
option: str = "price_formula"
|
|
|
|
@property
|
|
def unit_to_eur_kwh(self) -> float:
|
|
return UNITS[self.units]
|
|
|
|
@property
|
|
def card_graded(self) -> bool:
|
|
"""Is there a printed estimate to grade these coefficients against?
|
|
|
|
⚠️ FALSE IS A REAL, SHIPPED STATE ON THE INJECTION LEG and is reported
|
|
rather than defaulted: Eneco's injection formula is printed with no
|
|
price beside it. Never False on `price_formula`, where `_card()`
|
|
refuses an absent block.
|
|
"""
|
|
return self.card_expect_incl_vat is not None
|
|
|
|
@property
|
|
def passthrough(self) -> float:
|
|
"""The dimensionless share of the wholesale price this passes through.
|
|
|
|
`factor x UNITS[units] / 0.001`. 1.00 for Eneco's injection leg, 0.70
|
|
for Aspiravi's. ⚠️ INDEX-FREE AND SIGN-FREE, which is what lets
|
|
`implausible_injection()` bound a leg whose price legitimately goes
|
|
below zero.
|
|
"""
|
|
return self.factor * self.unit_to_eur_kwh / EUR_KWH_PER_EUR_MWH
|
|
|
|
@property
|
|
def offset_eur_kwh(self) -> float:
|
|
"""The offset in EUR/kWh, whatever units the card printed it in."""
|
|
return self.offset * self.unit_to_eur_kwh
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The arithmetic. Two functions, and the SECOND one is the contract.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
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.
|
|
|
|
⚠️ RAISES ON A SPEC WITH NO CARD RATHER THAN RETURNING 0.0 (TARIFF-16). An
|
|
ungraded injection leg has no printed estimate, and a discrepancy of zero
|
|
is exactly what "the coefficients reproduce the card" looks like - so a
|
|
silent zero here would report the strongest possible result for the one
|
|
state in which nothing was compared at all.
|
|
"""
|
|
if not spec.card_graded:
|
|
raise FormulaInvalid(
|
|
f"{spec.option} has no card block, so there is no printed "
|
|
f"estimate to measure a discrepancy against. Ask "
|
|
f"`spec.card_graded` before calling this.")
|
|
return in_card_units(spec, spec.card_index_eur_per_mwh) \
|
|
- spec.card_expect_incl_vat
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Reading the option
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def _finite(value, field: str, option: str = "price_formula") -> float:
|
|
"""A real, finite number - or a refusal naming the field.
|
|
|
|
⚠️ SAME CLASS AS `_validate_raw()`'s NaN RULE, one layer earlier. A NaN
|
|
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"{option}.{field} is {value!r}, which is not a number. Write a "
|
|
f"plain number, for example 0.102.")
|
|
if not math.isfinite(float(value)):
|
|
raise FormulaInvalid(
|
|
f"{option}.{field} is {value!r}, which is not finite. Write a "
|
|
f"plain number, for example 0.102.")
|
|
return float(value)
|
|
|
|
|
|
def _card(raw, leg: _Leg = None) -> tuple:
|
|
"""The `card` block: the estimate the coefficients are graded against.
|
|
|
|
⚠️ REQUIRED ON THE CONSUMPTION LEG AND OPTIONAL ON THE INJECTION ONE, and
|
|
the asymmetry is a fact about the CARDS rather than a relaxed rule. See
|
|
`_LEGS[EXPORT_FORMULA_OPTION].card_note`: Eneco's injection formula is
|
|
printed with no price beside it, so on that card there is nothing to grade
|
|
against. `("", None, None)` is the ungraded answer and
|
|
`FormulaSpec.card_graded` is how every caller asks.
|
|
"""
|
|
if leg is None:
|
|
leg = _LEGS["price_formula"]
|
|
if raw is None:
|
|
if not leg.card_required:
|
|
return ("", None, None)
|
|
raise FormulaInvalid(
|
|
f"{leg.option} has no \"card\" block. {leg.card_note}. Copy "
|
|
"the three figures from your tariff card, for example "
|
|
"{\"month\": \"2026-09\", \"index_eur_per_mwh\": 133.9905, "
|
|
"\"expect_incl_vat\": 15.55}.")
|
|
if not isinstance(raw, dict):
|
|
raise FormulaInvalid(
|
|
f"{leg.option}.card is {type(raw).__name__}, not an object with "
|
|
f"{', '.join(_CARD_KEYS)}.")
|
|
unknown = sorted(set(raw) - set(_CARD_KEYS))
|
|
if unknown:
|
|
raise FormulaInvalid(
|
|
f"{leg.option}.card has unknown field(s) {unknown}. It takes "
|
|
f"only {list(_CARD_KEYS)}.")
|
|
month = str(raw.get("month", "") or "").strip()
|
|
if not _month_date(month):
|
|
raise FormulaInvalid(
|
|
f"{leg.option}.card.month is {raw.get('month')!r}. Write the "
|
|
f"month of the card as \"YYYY-MM\", for example \"2026-09\".")
|
|
return (month,
|
|
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
|
|
leg.option),
|
|
_finite(raw.get("expect_incl_vat"), "card.expect_incl_vat",
|
|
leg.option))
|
|
|
|
|
|
def _month_date(month: str):
|
|
"""`"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, *, option: str = "price_formula"):
|
|
"""`price_formula` -> a `FormulaSpec`, or None when unset.
|
|
|
|
⚠️ `option` IS TARIFF-16'S WHOLE REUSE, and it is TARIFF-17's move. It
|
|
selects a row of `_LEGS` and nothing else: the enumerations, the
|
|
arithmetic, `to_resolution()`, the unknown-key rule and the card
|
|
comparison are shared, and every pre-existing call site - which passes no
|
|
`option` at all - lands on the consumption row and reads byte-identical
|
|
sentences. `parse_export_formula()` is a two-line wrapper, not a fork.
|
|
|
|
Accepts what the option can actually arrive as, exactly as
|
|
`parse_static_profile()` does:
|
|
|
|
* `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.
|
|
"""
|
|
leg = _LEGS[option]
|
|
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"{option} is not valid JSON ({e}). Write it on one line, for "
|
|
f"example\n"
|
|
f" {leg.example}\n"
|
|
f"{leg.when}")
|
|
if not isinstance(value, dict):
|
|
raise FormulaInvalid(
|
|
f"{option} is {type(value).__name__}, not a JSON object with "
|
|
f"{', '.join(_TOP_KEYS)}.")
|
|
|
|
# ⚠️ AN UNKNOWN KEY IS REFUSED, NOT IGNORED, AND THIS IS THE HALF THAT
|
|
# 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"{option} has unknown field(s) {unknown}. It takes only "
|
|
f"{list(_TOP_KEYS)}.")
|
|
|
|
index = str(value.get("index", "") or "").strip()
|
|
if index not in INDEXES:
|
|
raise FormulaInvalid(
|
|
f"{option}.index is {value.get('index')!r}. The add-on can only "
|
|
f"fetch "
|
|
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}.")
|
|
|
|
resolution = str(value.get("resolution", "") or "").strip()
|
|
if resolution not in RESOLUTIONS:
|
|
raise FormulaInvalid(
|
|
f"{option}.resolution is {value.get('resolution')!r}. Choose one "
|
|
f"of {list(RESOLUTIONS)}, as your tariff card says.")
|
|
|
|
# ⚠️ `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"{option}.units is {value.get('units')!r}. Choose one of "
|
|
f"{sorted(UNITS)}, as printed beside the formula on your card.")
|
|
|
|
factor = _finite(value.get("factor"), "factor", option)
|
|
offset = _finite(value.get("offset"), "offset", option)
|
|
vat_pct = _finite(value.get("vat_pct"), "vat_pct", option)
|
|
|
|
# ⚠️ ZERO IS NOT "NO INDEXATION", IT IS A FIXED TARIFF WEARING A DYNAMIC
|
|
# ONE'S CLOTHES. Every hour comes out at the same price, so every spread is
|
|
# 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(
|
|
f"{option}.factor is 0, so every hour gets the same {leg.what} "
|
|
f"price{leg.zero_factor}. For one flat price, {leg.flat}.")
|
|
|
|
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
|
|
# the dearest hour becomes the cheapest one, so the battery is told to
|
|
# 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"{option}.factor is {factor:g}, which turns the market upside "
|
|
f"down. {leg.neg_factor}.")
|
|
|
|
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
|
|
# and some cards are already quoted VAT-inclusive. The bound exists for the
|
|
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
|
|
if not 0.0 <= vat_pct <= 30.0:
|
|
raise FormulaInvalid(
|
|
f"{option}.vat_pct is {vat_pct:g}. Write a percentage between 0 "
|
|
f"and 30, for example 6.")
|
|
|
|
month, card_index, card_expect = _card(value.get("card"), leg)
|
|
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
|
|
offset=offset, units=units, vat_pct=vat_pct,
|
|
card_month=month, card_index_eur_per_mwh=card_index,
|
|
card_expect_incl_vat=card_expect, option=option)
|
|
|
|
# ⚠️ THE CARD SELF-CHECK STILL RUNS WHEREVER THERE IS A CARD TO RUN IT
|
|
# AGAINST, on either leg, with the same tolerance. What TARIFF-16 adds is
|
|
# the honest answer for the leg where the card prints no estimate: NOT a
|
|
# weakened comparison and NOT an estimate computed here, but two bounds on
|
|
# the COEFFICIENTS that need no printed price - see
|
|
# `implausible_injection()` - and a startup line saying the leg is
|
|
# ungraded.
|
|
if spec.card_graded:
|
|
delta = card_discrepancy(spec)
|
|
if abs(delta) > CARD_TOLERANCE:
|
|
raise FormulaInvalid(card_refusal(spec, delta))
|
|
if option == EXPORT_FORMULA_OPTION:
|
|
band = implausible_injection(spec)
|
|
if band:
|
|
raise FormulaInvalid(band)
|
|
return spec
|
|
|
|
|
|
def parse_export_formula(value):
|
|
"""`price_export_formula` -> the INJECTION leg's `FormulaSpec`, or None.
|
|
|
|
TARIFF-16. THE DEFECT THIS EXISTS TO CLOSE: `price.py` modelled injection
|
|
as ONE scalar, and the market publishes a line with four properties a
|
|
scalar cannot hold. Verbatim off the cards:
|
|
|
|
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
|
|
"Het injectietarief is dynamisch en kan per uur wijzigen"
|
|
- identical on the January and the September 2026 cards
|
|
ENGIE Dynamic (B x eSpot_15) - A c EUR/kWh
|
|
Aspiravi (non-dynamic) 0,07 x Belpex - 2
|
|
|
|
1. ITS OWN FACTOR. Eneco uses 0,102 on consumption and 0,100 on
|
|
injection; Aspiravi 0,07 against ~0,10 on its consumption leg. It is
|
|
NOT the consumption `B` with a sign flip.
|
|
2. A NEGATIVE OFFSET - `- 1,188`, `- 2`. Note that
|
|
`price_markup_eur_kwh: float(0,1)` cannot express a negative markup
|
|
at all, so the offset has no home on the consumption side either.
|
|
3. VAT-EXEMPT. "Injectietarieven zijn vrijgesteld van btw", while
|
|
consumption is at 6% - a SIX PERCENT ASYMMETRY between the buy leg
|
|
and the sell leg, which a single scalar cannot even state.
|
|
4. IT GOES NEGATIVE, WITH NO FLOOR. Eneco's injection price is negative
|
|
for any BELPEX-H below 11,88 EUR/MWh, and a PV household exports
|
|
precisely in the hours most likely to be negative. You can be charged
|
|
to export.
|
|
|
|
⚠️ AND WHAT THIS DOES **NOT** AFFECT TODAY, SAID HERE SO NOBODY READS THE
|
|
ABOVE AS A TRADING BUG. `arbitrage.py` does not read
|
|
`export_price_eur_kwh` at all - its own comment says so, and gives §9.3's
|
|
near-zero Belgian injection price as the reason the economically sound
|
|
trade on this market is charge-cheap-to-displace-expensive-IMPORT. Both
|
|
hours it compares are all-in CONSUMPTION prices. So no trade the add-on
|
|
takes today is decided by this number, and this card is not a correction
|
|
of a wrong trade. What it changes is what an operator is told, and what
|
|
any future export-aware strategy would rest on: OPT-06 and any export
|
|
logic would otherwise inherit a scalar that cannot represent the market,
|
|
and the 6% VAT asymmetry would be invisible in the arithmetic.
|
|
|
|
⚠️ THE SCALAR IS NOT REPLACED. `export_price_eur_kwh()` is untouched and
|
|
still answers for every site with a flat injection price, which is every
|
|
site that exists today; this option ships "". That is `price.py`'s own
|
|
prediction coming true rather than a new design - "when it does land, an
|
|
injection curve gets a `PriceProvider` of its own and this scalar becomes
|
|
its FIXED-product default; the seam is already the right shape for that."
|
|
"""
|
|
return parse_price_formula(value, option=EXPORT_FORMULA_OPTION)
|
|
|
|
|
|
def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
|
"""The sentence an operator reads when their formula misses its own card.
|
|
|
|
It names both numbers and what to check. The usual causes are in DOCS.md,
|
|
because each of these errors looks like a working tariff on the status page.
|
|
"""
|
|
got = in_card_units(spec, spec.card_index_eur_per_mwh)
|
|
leg = _LEGS[spec.option]
|
|
return (
|
|
f"{spec.option} does not match its own card: it gives {got:.4f} "
|
|
f"{spec.units} and the card says {spec.card_expect_incl_vat:g}. Check "
|
|
f"the units, the VAT and {leg.card_causes}.")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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.
|
|
|
|
⚠️ AND IT IS THE CONSUMPTION LEG'S BAND ONLY - TARIFF-16 DELIBERATELY DID
|
|
NOT POINT IT AT THE INJECTION LEG. Two reasons, and the second is an
|
|
acceptance criterion rather than a preference:
|
|
|
|
* it adds `taxes_eur_kwh`, and `price_taxes_eur_kwh` is a CONSUMPTION
|
|
component. Nothing is added to what a site is PAID for export, so the
|
|
`all_in` half would be adding a number the injection leg never carries;
|
|
* its floor is `energy >= 0.02 EUR/kWh`, and a REAL Eneco injection price
|
|
is negative for any BELPEX-H below 11,88 EUR/MWh - the hours a PV
|
|
household actually exports in. Applied to injection this floor would
|
|
refuse the market, and refuse it hardest in the hours where knowing the
|
|
number matters most. `implausible_injection()` is the injection band
|
|
and it is deliberately NOT this function with a lower floor.
|
|
"""
|
|
energy = price_eur_kwh(spec, spec.card_index_eur_per_mwh)
|
|
all_in = energy + float(taxes_eur_kwh)
|
|
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
|
|
return None
|
|
return (
|
|
f"{spec.option} gives {energy:.5f} EUR/kWh for energy and "
|
|
f"{all_in:.5f} EUR/kWh with “Distribution, levies and VAT” "
|
|
f"(price_taxes_eur_kwh), outside the plausible band of "
|
|
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh. Check \"units\" against the unit beside your formula.")
|
|
|
|
|
|
def implausible_injection(spec: FormulaSpec):
|
|
"""The INJECTION leg's plausibility band. Returns a refusal or None.
|
|
|
|
⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, NOT REFUSED, AND NEITHER RUNG
|
|
BELOW LOOKS AT THE SIGN OR THE LEVEL OF A PRICE. Eneco's published line
|
|
`0,1 x BELPEX-H - 1,188` is negative for any BELPEX-H under 11,88 EUR/MWh;
|
|
those are the midday hours a PV household exports in, so a band with a
|
|
floor above zero - which is what `implausible_all_in()` has, for the
|
|
consumption leg's own good reasons - would refuse the real market and
|
|
would hide it in exactly the hours it costs money. You CAN be charged to
|
|
export. The add-on says so rather than declining to represent it.
|
|
|
|
THE PROBLEM THAT LEAVES, STATED BEFORE THE SOLUTION. The consumption
|
|
floor was not decoration: it is what catches a whole card read a hundred
|
|
times too small, the one unit error the card self-check cannot see because
|
|
it cancels out of a comparison between the two sides. A band that spans
|
|
zero cannot catch it - 0.00132 EUR/kWh is inside any interval that
|
|
contains -0.03. So the bound moves OFF the price and ONTO the
|
|
coefficients, where it is index-free and sign-free:
|
|
|
|
* PASS-THROUGH. `factor x UNITS[units] / 0.001` is the dimensionless
|
|
share of the wholesale price the contract passes through, because one
|
|
EUR/MWh IS 0.001 EUR/kWh. Eneco's injection leg is 1.00 and Aspiravi's
|
|
is 0.70. A factor typed 100x too small reads 0.01 and 100x too large
|
|
reads 100, whatever the index and whatever the sign of the result.
|
|
* OFFSET MAGNITUDE. Eneco's is 1,188 c EUR/kWh = 0.01188 EUR/kWh and
|
|
Aspiravi's is 2 c = 0.02. A hundredfold error is 1.188 EUR/kWh, which
|
|
is not an injection tariff in any direction. The bound is on the
|
|
ABSOLUTE value, so a negative offset of a believable size passes and
|
|
that is the normal case on this leg.
|
|
|
|
⚠️ TWO RUNGS THAT CAN BOTH ANSWER ONE STIMULUS, SO THEY ARE GRADED ON
|
|
THEIR REASONS AND MUTATED TOGETHER (MUTATION.md, TEST-16). A whole card
|
|
read 100x too large trips both. The fixtures in test_price.py 17d are
|
|
built so that each rung is the ONLY one that fires on its own stimulus.
|
|
|
|
⚠️ AND WHAT THIS CANNOT SEE, WITH THE CLAIM (habit 13). It bounds the
|
|
SHAPE of the coefficients, never their truth: a contract really written
|
|
against a different index, or last year's coefficients, passes both rungs
|
|
and reproduces any card typed beside them. Where a card DOES print an
|
|
estimate the self-check grades the pair against it; where it does not -
|
|
which is Eneco's injection line - these two bounds are the whole of what
|
|
is mechanised, and validate.py says so at startup.
|
|
"""
|
|
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
|
|
return (
|
|
f"{spec.option}.factor passes through {spec.passthrough:.4g}x the "
|
|
f"market price, outside {PASSTHROUGH_LO}-{PASSTHROUGH_HI}x. Check "
|
|
f"\"units\" ({spec.units!r}) against the unit beside your "
|
|
f"formula.")
|
|
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
|
|
return (
|
|
f"{spec.option}.offset is {spec.offset:g} {spec.units}, which is "
|
|
f"{spec.offset_eur_kwh:+.5f} EUR/kWh and larger than "
|
|
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh either way. Check its "
|
|
f"unit.")
|
|
return None
|
|
|
|
|
|
def stale_card(spec: FormulaSpec, today=None):
|
|
"""A warning when the card is older than ~6 months, or None. NEVER a
|
|
refusal: ENGIE freezes a contract's coefficients for its duration, so an
|
|
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"{spec.option}'s card is dated {spec.card_month}, "
|
|
f"{(now - dated).days} days ago. The add-on still starts, but if your "
|
|
f"contract changed since, copy the new figures from your supplier.")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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
|