Files
goodwe-addon/goodwe_controller/DECISIONS/TARIFF-16-the-injection-leg.md
T

5.7 KiB

TARIFF-16 - the injection leg of a dynamic contract (price_export_formula)

Status: SHIPPED. Tests: test_price.py (17 series). Written down by MAINT-20 from app/custom_formula.py and app/price.py. Follows TARIFF-15, whose parser it reuses.

Why a formula, not the scalar

price.py modelled injection as one scalar. The market publishes a line a scalar cannot hold:

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
  1. Its own factor (Eneco 0,102 on consumption, 0,100 on injection; Aspiravi 0,07 against about 0,10): not the consumption B with a sign flip.
  2. A negative offset (- 1,188, - 2); price_markup_eur_kwh: float(0,1) cannot express one.
  3. VAT-exempt ("Injectietarieven zijn vrijgesteld van btw") against 6 % on consumption: representable only because vat_pct is per spec.
  4. It goes negative with no floor: Eneco's is below zero for any BELPEX-H under 11,88 EUR/MWh, the hours a PV household exports in. You can be charged to export.

price_export_formula is custom_formula's parser with one argument moved (option, a row of _LEGS), as TARIFF-17 did for parse_static_profile(). A second parser would be a second _finite(), unknown-key rule, unit table and to_resolution(), diverging first on the 23- and 25-hour days. Every earlier call site passes no option and reads byte-identical sentences. The flat scalar export_price_eur_kwh() is untouched and still answers when the option is "".

What it does not affect

arbitrage.py does not read the injection price at all (9.3: near-zero Belgian injection; it prices the sell side as displaced import), so no trade today turns on this number and it is not a wrong-trade fix. It changes what an operator is told and what a future export-aware strategy (OPT-06) rests on.

The card cannot always grade it

Eneco's injection formula is printed with no price beside it. The card block is required on the consumption leg and optional here (FormulaSpec.card_graded is how callers ask; card_discrepancy() raises on an ungraded spec rather than returning 0.0). The self-check runs wherever a printed estimate exists, and validate.py says at startup that a leg is ungraded. Inventing an estimate would grade the arithmetic against itself (MUTATION.md habit 4).

implausible_injection(): bounds on the coefficients, not the price

The consumption band (implausible_all_in()) cannot be reused: it adds price_taxes_eur_kwh, a consumption component, and its floor energy >= 0.02 would refuse the real market's negative hours. But that floor catches a whole card read 100x too small, which the card check cannot see (it cancels). A band spanning zero cannot catch 0.00132 EUR/kWh, so the bound moves onto the coefficients, index-free and sign-free:

  • pass-through factor x UNITS[units] / 0.001 (one EUR/MWh is 0.001 EUR/kWh): 1.00 for Eneco's injection leg, 0.70 for Aspiravi's; 100x off reads 0.01 or 100;
  • offset magnitude, on the absolute value (1,188 c = 0.01188; a hundredfold error is 1.188 EUR/kWh).

Two rungs can both answer one stimulus, so they are graded on their reasons and mutated together (MUTATION.md, TEST-16); test_price.py 17d builds fixtures so each is the only one firing. It bounds the shape of the coefficients, never their truth.

The consumption band's asymmetry (implausible_all_in())

The low end is graded on the energy component and the high end on the all-in figure. price_taxes_eur_kwh ships at 0.11736 (TARIFF-18; 0.06 before), nearly six times the low bound, so a card read 100x too small (0.0016 EUR/kWh) is carried over the floor by the flat tax addend alone and would pass a band applied to the all-in total. Raising the default made the trap wider. The ceiling is on the all-in figure because that is what the site is billed. It grades the card's own point, not every hour: live EPEX hours may fall outside (a negative hour is the best charging opportunity of the year, 17 keeps a regression case). It is called from validate.py only, since it reads the framework-owned taxes number.

Smaller rules

  • CARD_TOLERANCE is 0.01 in the card's units, the last digit both real cards print (Eneco 15,55 against 15.5471; ENGIE 7,522 against 7.5218). Every error class it exists for is at least two orders of magnitude bigger.
  • The card age warning (about 6 months) is never a refusal: ENGIE freezes a contract's coefficients, so an old card can be exactly right.
  • units is required with no default (a default is the factor-1000 class). A zero factor is a fixed tariff in a dynamic one's clothes (every spread 0, arbitrage never fires; 14's warning box), so the operator is pointed at the first-class way to say one price. A negative factor inverts the market (charge into the evening peak). vat_pct: 0 is legitimate (injection is exempt; some cards are quoted VAT-inclusive); the bound exists for the 106 / 1.06 / 6 confusion. An unknown key is refused, not ignored, so a user who types "expr" is not priced on the fields they also typed. A non-finite factor would make every price NaN; True is an int and would price the day at the index.
  • to_resolution(): the bucket is a UTC hour, exact for Belgium (CET and CEST are whole-hour offsets, so keying by UTC cannot collide the two 02:00s). It decides how many points are emitted, not how long one covers (TARIFF-14). An incomplete hour is averaged over what is there, not dropped or zero-filled.
  • The startup preview prints the index that went in beside the price that came out (61.96 -> 0.13522): every error this module refuses is invisible on the status page, and a factor of 1000 is one glance there.