# 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.