"""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.06, which is three times the low bound - so a card read a HUNDRED times too small comes out at 0.0016 EUR/kWh of energy, is carried over the floor by the flat tax addend alone, and passes a band applied to the all-in total. The hundredfold error is precisely what this exists to catch, so the floor is applied where it can still see it. The ceiling is applied to the all-in figure because that is the number the site is really billed at, and taxes only push it up. ⚠️ 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