103 lines
5.7 KiB
Markdown
103 lines
5.7 KiB
Markdown
# 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.
|