MAINT-20 part 1: custom_formula.py comments cut; new TARIFF-16 note

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-01 01:17:46 +01:00
co-authored by Claude
parent a3f725e8a4
commit eda6d0aed1
2 changed files with 194 additions and 518 deletions
@@ -0,0 +1,102 @@
# 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.