Skip to main content

grid_billing/
redispatch.rs

1//! §13a EnWG Redispatch 2.0 compensation (angemessene Vergütung).
2//!
3//! §13a Abs. 2 EnWG: the plant operator affected by a redispatch measure is
4//! left "wirtschaftlich weder besser noch schlechter" — the compensation is
5//!
6//! ```text
7//! Vergütung = zusätzliche Aufwendungen        (Abs. 2 Satz 3 Nr. 1, 2, 4)
8//!           + entgangene Einnahmen            (Nr. 3; Nr. 5 for EEG/KWKG)
9//!           − ersparte Aufwendungen           (Satz 4 — reimbursed to the NB)
10//! ```
11//!
12//! The `Verguetungsart` from the Redispatch Stammdaten (Z01 EEG / Z02 KWKG /
13//! Z03 sonstige) decides how the *entgangene Einnahmen* basis is formed: for
14//! EEG/KWKG plants it is the lost statutory remuneration for the
15//! Ausfallarbeit; for other plants the proven lost market revenue.
16//!
17//! This module is the pure arithmetic — deterministic, Decimal-only, with a
18//! per-component trace. Data acquisition (Ausfallarbeit from measured vs.
19//! reference Lastgang in the Duldungsfall, from the transmitted schedule in
20//! the Aufforderungsfall) and the payment run live in the service layer.
21
22use billing::EuroAmount;
23use rust_decimal::Decimal;
24
25use crate::error::BillingError;
26
27/// Vergütungsart of the affected resource (Redispatch Stammdaten field).
28#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
30pub enum RedispatchVerguetungsart {
31    /// Z01 — EEG plant: entgangene Einnahmen = lost EEG remuneration.
32    Eeg,
33    /// Z02 — KWKG plant: lost KWKG remuneration (incl. heat-side effects as
34    /// zusätzliche Aufwendungen).
35    Kwkg,
36    /// Z03 — other: proven lost market revenue.
37    Sonstige,
38}
39
40/// Inputs to the §13a Abs. 2 compensation for one activation.
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
42pub struct RedispatchVerguetungInput {
43    /// Curtailed energy in kWh (Ausfallarbeit). Duldungsfall: measured vs.
44    /// reference Lastgang; Aufforderungsfall: from the transmitted schedule.
45    pub ausfallarbeit_kwh: Decimal,
46    /// The resource's Vergütungsart (Stammdaten Z01/Z02/Z03).
47    pub verguetungsart: RedispatchVerguetungsart,
48    /// Entgangene Einnahmen in EUR (Abs. 2 Satz 3 Nr. 3 / Nr. 5).
49    /// For EEG plants use [`eeg_entgangene_einnahmen`].
50    pub entgangene_einnahmen_eur: Decimal,
51    /// Zusätzliche Aufwendungen in EUR (Nr. 1: required expenses of the
52    /// adjustment; Nr. 2: wear; Nr. 4: readiness/postponed maintenance).
53    pub zusaetzliche_aufwendungen_eur: Decimal,
54    /// Ersparte Aufwendungen in EUR (Satz 4) — fuel not burnt, avoided
55    /// Netzentgelte; reimbursed to the Netzbetreiber.
56    pub ersparte_aufwendungen_eur: Decimal,
57}
58
59/// The computed compensation with its component breakdown.
60#[derive(Debug, Clone, serde::Serialize)]
61pub struct RedispatchVerguetung {
62    /// Curtailed energy this compensation covers (kWh).
63    pub ausfallarbeit_kwh: Decimal,
64    /// Vergütungsart the entgangene-Einnahmen basis was formed under.
65    pub verguetungsart: RedispatchVerguetungsart,
66    /// Entgangene Einnahmen component, cent-rounded (Nr. 3 / Nr. 5).
67    pub entgangene_einnahmen_eur: Decimal,
68    /// Zusätzliche Aufwendungen component, cent-rounded (Nr. 1/2/4).
69    pub zusaetzliche_aufwendungen_eur: Decimal,
70    /// Ersparte Aufwendungen component, cent-rounded (Satz 4).
71    pub ersparte_aufwendungen_eur: Decimal,
72    /// `entgangene + zusätzliche − ersparte`, rounded to cents (half away
73    /// from zero). **May be negative**: §13a Abs. 2 Satz 4 obliges the
74    /// operator to reimburse saved costs even beyond the claim — "weder
75    /// besser noch schlechter" cuts both ways.
76    pub verguetung_eur: Decimal,
77    /// Human-readable derivation, one line per component.
78    pub trace: Vec<String>,
79}
80
81/// Entgangene EEG-Einnahmen for the Ausfallarbeit:
82/// `kWh × anzulegender Wert (ct/kWh) ÷ 100`, cent-rounded.
83///
84/// The anzulegender Wert is the plant's EEG rate (its `eeg-billing`
85/// settlement scheme provides it); §13a Abs. 2 Satz 3 Nr. 5 makes the lost
86/// statutory remuneration the compensation basis for EEG plants.
87#[must_use]
88pub fn eeg_entgangene_einnahmen(
89    ausfallarbeit_kwh: Decimal,
90    anzulegender_wert_ct: Decimal,
91) -> Decimal {
92    (ausfallarbeit_kwh * anzulegender_wert_ct / Decimal::ONE_HUNDRED)
93        .round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero)
94}
95
96/// Compute the §13a Abs. 2 EnWG compensation for one redispatch activation.
97///
98/// # Errors
99///
100/// Rejects negative component inputs — each component is a magnitude; the
101/// only signed quantity is the resulting net compensation.
102pub fn redispatch_verguetung(
103    input: &RedispatchVerguetungInput,
104) -> Result<RedispatchVerguetung, BillingError> {
105    for (label, v) in [
106        ("ausfallarbeit_kwh", input.ausfallarbeit_kwh),
107        ("entgangene_einnahmen_eur", input.entgangene_einnahmen_eur),
108        (
109            "zusaetzliche_aufwendungen_eur",
110            input.zusaetzliche_aufwendungen_eur,
111        ),
112        ("ersparte_aufwendungen_eur", input.ersparte_aufwendungen_eur),
113    ] {
114        if v < Decimal::ZERO {
115            return Err(BillingError::InvalidInput {
116                reason: format!("§13a component {label} must be non-negative, got {v}"),
117            });
118        }
119    }
120
121    let round = |d: Decimal| {
122        d.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero)
123    };
124    let entgangene = round(input.entgangene_einnahmen_eur);
125    let zusaetzliche = round(input.zusaetzliche_aufwendungen_eur);
126    let ersparte = round(input.ersparte_aufwendungen_eur);
127    let total = entgangene + zusaetzliche - ersparte;
128
129    // Same money boundary as the settle_* functions: every EUR result must be
130    // representable as an EuroAmount before it leaves the crate.
131    for v in [entgangene, zusaetzliche, ersparte, total] {
132        let _representable =
133            EuroAmount::checked_from_decimal(v).map_err(|_| BillingError::MonetaryOverflow {
134                input_value: Some(v),
135            })?;
136    }
137
138    let basis = match input.verguetungsart {
139        RedispatchVerguetungsart::Eeg => "entgangene EEG-Vergütung (§13a Abs. 2 S. 3 Nr. 5 EnWG)",
140        RedispatchVerguetungsart::Kwkg => "entgangene KWKG-Vergütung (§13a Abs. 2 S. 3 Nr. 5 EnWG)",
141        RedispatchVerguetungsart::Sonstige => {
142            "nachgewiesene entgangene Erlöse (§13a Abs. 2 S. 3 Nr. 3 EnWG)"
143        }
144    };
145
146    Ok(RedispatchVerguetung {
147        ausfallarbeit_kwh: input.ausfallarbeit_kwh,
148        verguetungsart: input.verguetungsart,
149        entgangene_einnahmen_eur: entgangene,
150        zusaetzliche_aufwendungen_eur: zusaetzliche,
151        ersparte_aufwendungen_eur: ersparte,
152        verguetung_eur: total,
153        trace: vec![
154            format!("Ausfallarbeit: {} kWh", input.ausfallarbeit_kwh),
155            format!("+ {entgangene} € {basis}"),
156            format!("+ {zusaetzliche} € zusätzliche Aufwendungen (Nr. 1/2/4)"),
157            format!("− {ersparte} € ersparte Aufwendungen (S. 4 — an den NB zu erstatten)"),
158            format!("= {total} € angemessene Vergütung (§13a Abs. 2 EnWG)"),
159        ],
160    })
161}
162
163/// BilAReM financial correction for fluctuating plants in the Planwertmodell
164/// (BK6-23-241, BilAReM Kap. 4): the residual between actual Ausfallarbeit and
165/// the plan-based bilanzieller Ausgleich is settled **financially only** —
166/// no ex-post energy correction:
167///
168/// `Korr_fin = (W_A − W_Ausgl) / 1000 × ID-AEP`
169///
170/// with `W_A`/`W_Ausgl` in kWh per quarter-hour and the Intraday-
171/// Auktionspreis (`ID-AEP`, fallback ID1/EPEX) in EUR/MWh. A positive result
172/// is owed to the Anlagenbetreiber-side Bilanzkreis, a negative one to the
173/// Netzbetreiber.
174///
175/// # Errors
176///
177/// Rejects non-finite arithmetic via the shared money boundary (result must
178/// round to a valid EUR amount).
179pub fn bilarem_finanzielle_korrektur(
180    ausfallarbeit_kwh: Decimal,
181    ausgleich_kwh: Decimal,
182    id_aep_eur_per_mwh: Decimal,
183) -> Result<Decimal, BillingError> {
184    let korr = (ausfallarbeit_kwh - ausgleich_kwh) / Decimal::from(1000) * id_aep_eur_per_mwh;
185    let rounded =
186        korr.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
187    // Money boundary: must be representable as EUR cents.
188    if rounded.abs() > Decimal::from(10_000_000) {
189        return Err(BillingError::InvalidInput {
190            reason: format!("BilAReM Korr_fin out of range: {rounded}"),
191        });
192    }
193    Ok(rounded)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use rust_decimal::dec;
200
201    #[test]
202    fn eeg_plant_compensation_from_the_anzulegender_wert() {
203        // 12 500 kWh curtailed at 7.30 ct/kWh anzulegender Wert.
204        let entgangene = eeg_entgangene_einnahmen(dec!(12_500), dec!(7.30));
205        assert_eq!(entgangene, dec!(912.50));
206
207        let v = redispatch_verguetung(&RedispatchVerguetungInput {
208            ausfallarbeit_kwh: dec!(12_500),
209            verguetungsart: RedispatchVerguetungsart::Eeg,
210            entgangene_einnahmen_eur: entgangene,
211            zusaetzliche_aufwendungen_eur: dec!(40),
212            ersparte_aufwendungen_eur: dec!(12.50),
213        })
214        .unwrap();
215        assert_eq!(v.verguetung_eur, dec!(940.00));
216        assert!(v.trace.iter().any(|l| l.contains("Nr. 5")));
217    }
218
219    #[test]
220    fn bilarem_korrektur_settles_the_residual_financially() {
221        // W_A 1200 kWh vs. plan-based Ausgleich 1000 kWh at ID-AEP 80 EUR/MWh:
222        // (1200 − 1000)/1000 × 80 = 16.00 EUR to the Anlagenbetreiber side.
223        let k = bilarem_finanzielle_korrektur(dec!(1200), dec!(1000), dec!(80)).unwrap();
224        assert_eq!(k, dec!(16.00));
225        // Overshoot of the Ausgleich flows back to the NB (negative).
226        let k = bilarem_finanzielle_korrektur(dec!(800), dec!(1000), dec!(80)).unwrap();
227        assert_eq!(k, dec!(-16.00));
228        // Negative ID-AEP inverts the direction — no clamping.
229        let k = bilarem_finanzielle_korrektur(dec!(1200), dec!(1000), dec!(-50)).unwrap();
230        assert_eq!(k, dec!(-10.00));
231    }
232
233    #[test]
234    fn saved_costs_can_exceed_the_claim() {
235        // "Weder besser noch schlechter": a thermal plant whose saved fuel
236        // exceeds lost revenue owes the difference to the NB.
237        let v = redispatch_verguetung(&RedispatchVerguetungInput {
238            ausfallarbeit_kwh: dec!(50_000),
239            verguetungsart: RedispatchVerguetungsart::Sonstige,
240            entgangene_einnahmen_eur: dec!(2_000),
241            zusaetzliche_aufwendungen_eur: dec!(100),
242            ersparte_aufwendungen_eur: dec!(2_500),
243        })
244        .unwrap();
245        assert_eq!(v.verguetung_eur, dec!(-400.00));
246    }
247
248    #[test]
249    fn negative_components_are_rejected() {
250        let err = redispatch_verguetung(&RedispatchVerguetungInput {
251            ausfallarbeit_kwh: dec!(100),
252            verguetungsart: RedispatchVerguetungsart::Kwkg,
253            entgangene_einnahmen_eur: dec!(-1),
254            zusaetzliche_aufwendungen_eur: Decimal::ZERO,
255            ersparte_aufwendungen_eur: Decimal::ZERO,
256        });
257        assert!(err.is_err());
258    }
259}