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 crate::EuroAmount;
23use crate::rounding::RoundMoney;
24use rust_decimal::Decimal;
25
26use crate::error::BillingError;
27
28/// Vergütungsart of the affected resource (Redispatch Stammdaten field).
29#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
30#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
31pub enum RedispatchVerguetungsart {
32    /// Z01 — EEG plant: entgangene Einnahmen = lost EEG remuneration.
33    Eeg,
34    /// Z02 — KWKG plant: lost KWKG remuneration (incl. heat-side effects as
35    /// zusätzliche Aufwendungen).
36    Kwkg,
37    /// Z03 — other: proven lost market revenue.
38    Sonstige,
39}
40
41/// Which §13a Abs. 2 basis the Ausfallarbeit was established on.
42///
43/// The two redispatch cases do not measure the curtailed energy the same way,
44/// and the difference is money:
45///
46/// - **Duldungsfall** — the Netzbetreiber steers the resource itself, so what
47///   the plant *would* have produced is not transmitted anywhere. The
48///   Ausfallarbeit is derived from the measured Lastgang against a reference.
49/// - **Aufforderungsfall** — the Einsatzverantwortliche steers to a transmitted
50///   schedule, and that schedule *is* the counterfactual. Deriving it from the
51///   Lastgang instead would settle against what happened rather than against
52///   what was instructed.
53///
54/// This is carried on the input so the basis is stated rather than assumed: a
55/// compensation computed on the wrong basis is a plain money error against
56/// either the operator or the network, and nothing downstream can tell.
57///
58/// It mirrors `mako_redispatch::aktivierung::Abwicklung` without depending on
59/// it — this crate settles, it does not run the activation workflow.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
62pub enum AusfallarbeitBasis {
63    /// Duldungsfall — measured Lastgang against a reference.
64    GemessenerLastgang,
65    /// Aufforderungsfall — the schedule transmitted to the EIV.
66    UebermittelterFahrplan,
67}
68
69impl AusfallarbeitBasis {
70    /// The §13a wording this basis rests on, for the calculation trace.
71    #[must_use]
72    pub const fn label(self) -> &'static str {
73        match self {
74            Self::GemessenerLastgang => {
75                "Duldungsfall — Ausfallarbeit aus gemessenem Lastgang (§13a Abs. 2 EnWG)"
76            }
77            Self::UebermittelterFahrplan => {
78                "Aufforderungsfall — Ausfallarbeit aus übermitteltem Fahrplan (§13a Abs. 2 EnWG)"
79            }
80        }
81    }
82}
83
84/// Inputs to the §13a Abs. 2 compensation for one activation.
85#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
86pub struct RedispatchVerguetungInput {
87    /// Curtailed energy in kWh (Ausfallarbeit).
88    pub ausfallarbeit_kwh: Decimal,
89    /// How that figure was established — see [`AusfallarbeitBasis`].
90    ///
91    /// Required rather than defaulted: the two redispatch cases use different
92    /// counterfactuals, and picking one silently misstates the compensation.
93    pub basis: AusfallarbeitBasis,
94    /// The resource's Vergütungsart (Stammdaten Z01/Z02/Z03).
95    pub verguetungsart: RedispatchVerguetungsart,
96    /// Entgangene Einnahmen in EUR (Abs. 2 Satz 3 Nr. 3 / Nr. 5).
97    /// For EEG plants use [`eeg_entgangene_einnahmen`].
98    pub entgangene_einnahmen_eur: Decimal,
99    /// Zusätzliche Aufwendungen in EUR (Nr. 1: required expenses of the
100    /// adjustment; Nr. 2: wear; Nr. 4: readiness/postponed maintenance).
101    pub zusaetzliche_aufwendungen_eur: Decimal,
102    /// Ersparte Aufwendungen in EUR (Satz 4) — fuel not burnt, avoided
103    /// Netzentgelte; reimbursed to the Netzbetreiber.
104    pub ersparte_aufwendungen_eur: Decimal,
105}
106
107/// The computed compensation with its component breakdown.
108#[derive(Debug, Clone, serde::Serialize)]
109pub struct RedispatchVerguetung {
110    /// Curtailed energy this compensation covers (kWh).
111    pub ausfallarbeit_kwh: Decimal,
112    /// How that figure was established — carried through so an audit can see
113    /// which counterfactual the compensation rests on.
114    pub basis: AusfallarbeitBasis,
115    /// Vergütungsart the entgangene-Einnahmen basis was formed under.
116    pub verguetungsart: RedispatchVerguetungsart,
117    /// Entgangene Einnahmen component, cent-rounded (Nr. 3 / Nr. 5).
118    pub entgangene_einnahmen_eur: Decimal,
119    /// Zusätzliche Aufwendungen component, cent-rounded (Nr. 1/2/4).
120    pub zusaetzliche_aufwendungen_eur: Decimal,
121    /// Ersparte Aufwendungen component, cent-rounded (Satz 4).
122    pub ersparte_aufwendungen_eur: Decimal,
123    /// `entgangene + zusätzliche − ersparte`, rounded to cents (half away
124    /// from zero). **May be negative**: §13a Abs. 2 Satz 4 obliges the
125    /// operator to reimburse saved costs even beyond the claim — "weder
126    /// besser noch schlechter" cuts both ways.
127    pub verguetung_eur: Decimal,
128    /// Human-readable derivation, one line per component.
129    pub trace: Vec<String>,
130}
131
132/// Entgangene EEG-Einnahmen for the Ausfallarbeit:
133/// `kWh × anzulegender Wert (ct/kWh) ÷ 100`, cent-rounded.
134///
135/// The anzulegender Wert is the plant's EEG rate (its `eeg-billing`
136/// settlement scheme provides it); §13a Abs. 2 Satz 3 Nr. 5 makes the lost
137/// statutory remuneration the compensation basis for EEG plants.
138#[must_use]
139pub fn eeg_entgangene_einnahmen(
140    ausfallarbeit_kwh: Decimal,
141    anzulegender_wert_ct: Decimal,
142) -> Decimal {
143    (ausfallarbeit_kwh * anzulegender_wert_ct / Decimal::ONE_HUNDRED).round_kfm(2)
144}
145
146/// Compute the §13a Abs. 2 EnWG compensation for one redispatch activation.
147///
148/// # Errors
149///
150/// Rejects negative component inputs — each component is a magnitude; the
151/// only signed quantity is the resulting net compensation.
152pub fn redispatch_verguetung(
153    input: &RedispatchVerguetungInput,
154) -> Result<RedispatchVerguetung, BillingError> {
155    for (label, v) in [
156        ("ausfallarbeit_kwh", input.ausfallarbeit_kwh),
157        ("entgangene_einnahmen_eur", input.entgangene_einnahmen_eur),
158        (
159            "zusaetzliche_aufwendungen_eur",
160            input.zusaetzliche_aufwendungen_eur,
161        ),
162        ("ersparte_aufwendungen_eur", input.ersparte_aufwendungen_eur),
163    ] {
164        if v < Decimal::ZERO {
165            return Err(BillingError::InvalidInput {
166                reason: format!("§13a component {label} must be non-negative, got {v}"),
167            });
168        }
169    }
170
171    let round = |d: Decimal| d.round_kfm(2);
172    let entgangene = round(input.entgangene_einnahmen_eur);
173    let zusaetzliche = round(input.zusaetzliche_aufwendungen_eur);
174    let ersparte = round(input.ersparte_aufwendungen_eur);
175    let total = entgangene + zusaetzliche - ersparte;
176
177    // Same money boundary as the settle_* functions: every EUR result must be
178    // representable as an EuroAmount before it leaves the crate.
179    for v in [entgangene, zusaetzliche, ersparte, total] {
180        let _representable =
181            EuroAmount::checked_from_decimal(v).map_err(|_| BillingError::MonetaryOverflow {
182                input_value: Some(v),
183            })?;
184    }
185
186    let basis = match input.verguetungsart {
187        RedispatchVerguetungsart::Eeg => "entgangene EEG-Vergütung (§13a Abs. 2 S. 3 Nr. 5 EnWG)",
188        RedispatchVerguetungsart::Kwkg => "entgangene KWKG-Vergütung (§13a Abs. 2 S. 3 Nr. 5 EnWG)",
189        RedispatchVerguetungsart::Sonstige => {
190            "nachgewiesene entgangene Erlöse (§13a Abs. 2 S. 3 Nr. 3 EnWG)"
191        }
192    };
193
194    Ok(RedispatchVerguetung {
195        ausfallarbeit_kwh: input.ausfallarbeit_kwh,
196        basis: input.basis,
197        verguetungsart: input.verguetungsart,
198        entgangene_einnahmen_eur: entgangene,
199        zusaetzliche_aufwendungen_eur: zusaetzliche,
200        ersparte_aufwendungen_eur: ersparte,
201        verguetung_eur: total,
202        trace: vec![
203            format!("Ausfallarbeit: {} kWh", input.ausfallarbeit_kwh),
204            input.basis.label().to_owned(),
205            format!("+ {entgangene} € {basis}"),
206            format!("+ {zusaetzliche} € zusätzliche Aufwendungen (Nr. 1/2/4)"),
207            format!("− {ersparte} € ersparte Aufwendungen (S. 4 — an den NB zu erstatten)"),
208            format!("= {total} € angemessene Vergütung (§13a Abs. 2 EnWG)"),
209        ],
210    })
211}
212
213/// BilAReM financial correction for fluctuating plants in the Planwertmodell
214/// (BK6-23-241, BilAReM Kap. 4): the residual between actual Ausfallarbeit and
215/// the plan-based bilanzieller Ausgleich is settled **financially only** —
216/// no ex-post energy correction:
217///
218/// `Korr_fin = (W_A − W_Ausgl) / 1000 × ID-AEP`
219///
220/// with `W_A`/`W_Ausgl` in kWh per quarter-hour and the Intraday-
221/// Auktionspreis (`ID-AEP`, fallback ID1/EPEX) in EUR/MWh. A positive result
222/// is owed to the Anlagenbetreiber-side Bilanzkreis, a negative one to the
223/// Netzbetreiber.
224///
225/// # Errors
226///
227/// Rejects non-finite arithmetic via the shared money boundary (result must
228/// round to a valid EUR amount).
229pub fn bilarem_finanzielle_korrektur(
230    ausfallarbeit_kwh: Decimal,
231    ausgleich_kwh: Decimal,
232    id_aep_eur_per_mwh: Decimal,
233) -> Result<Decimal, BillingError> {
234    let korr = (ausfallarbeit_kwh - ausgleich_kwh) / Decimal::from(1000) * id_aep_eur_per_mwh;
235    let rounded = korr.round_kfm(2);
236    // Money boundary: must be representable as EUR cents.
237    if rounded.abs() > Decimal::from(10_000_000) {
238        return Err(BillingError::InvalidInput {
239            reason: format!("BilAReM Korr_fin out of range: {rounded}"),
240        });
241    }
242    Ok(rounded)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use rust_decimal::dec;
249
250    #[test]
251    fn eeg_plant_compensation_from_the_anzulegender_wert() {
252        // 12 500 kWh curtailed at 7.30 ct/kWh anzulegender Wert.
253        let entgangene = eeg_entgangene_einnahmen(dec!(12_500), dec!(7.30));
254        assert_eq!(entgangene, dec!(912.50));
255
256        let v = redispatch_verguetung(&RedispatchVerguetungInput {
257            ausfallarbeit_kwh: dec!(12_500),
258            basis: AusfallarbeitBasis::GemessenerLastgang,
259            verguetungsart: RedispatchVerguetungsart::Eeg,
260            entgangene_einnahmen_eur: entgangene,
261            zusaetzliche_aufwendungen_eur: dec!(40),
262            ersparte_aufwendungen_eur: dec!(12.50),
263        })
264        .unwrap();
265        assert_eq!(v.verguetung_eur, dec!(940.00));
266        assert!(v.trace.iter().any(|l| l.contains("Nr. 5")));
267    }
268
269    #[test]
270    fn bilarem_korrektur_settles_the_residual_financially() {
271        // W_A 1200 kWh vs. plan-based Ausgleich 1000 kWh at ID-AEP 80 EUR/MWh:
272        // (1200 − 1000)/1000 × 80 = 16.00 EUR to the Anlagenbetreiber side.
273        let k = bilarem_finanzielle_korrektur(dec!(1200), dec!(1000), dec!(80)).unwrap();
274        assert_eq!(k, dec!(16.00));
275        // Overshoot of the Ausgleich flows back to the NB (negative).
276        let k = bilarem_finanzielle_korrektur(dec!(800), dec!(1000), dec!(80)).unwrap();
277        assert_eq!(k, dec!(-16.00));
278        // Negative ID-AEP inverts the direction — no clamping.
279        let k = bilarem_finanzielle_korrektur(dec!(1200), dec!(1000), dec!(-50)).unwrap();
280        assert_eq!(k, dec!(-10.00));
281    }
282
283    #[test]
284    fn saved_costs_can_exceed_the_claim() {
285        // "Weder besser noch schlechter": a thermal plant whose saved fuel
286        // exceeds lost revenue owes the difference to the NB.
287        let v = redispatch_verguetung(&RedispatchVerguetungInput {
288            ausfallarbeit_kwh: dec!(50_000),
289            basis: AusfallarbeitBasis::GemessenerLastgang,
290            verguetungsart: RedispatchVerguetungsart::Sonstige,
291            entgangene_einnahmen_eur: dec!(2_000),
292            zusaetzliche_aufwendungen_eur: dec!(100),
293            ersparte_aufwendungen_eur: dec!(2_500),
294        })
295        .unwrap();
296        assert_eq!(v.verguetung_eur, dec!(-400.00));
297    }
298
299    #[test]
300    fn negative_components_are_rejected() {
301        let err = redispatch_verguetung(&RedispatchVerguetungInput {
302            ausfallarbeit_kwh: dec!(100),
303            basis: AusfallarbeitBasis::GemessenerLastgang,
304            verguetungsart: RedispatchVerguetungsart::Kwkg,
305            entgangene_einnahmen_eur: dec!(-1),
306            zusaetzliche_aufwendungen_eur: Decimal::ZERO,
307            ersparte_aufwendungen_eur: Decimal::ZERO,
308        });
309        assert!(err.is_err());
310    }
311}
312
313#[cfg(test)]
314mod basis_tests {
315    use super::*;
316    use rust_decimal::dec;
317
318    fn input(basis: AusfallarbeitBasis) -> RedispatchVerguetungInput {
319        RedispatchVerguetungInput {
320            ausfallarbeit_kwh: dec!(1000),
321            basis,
322            verguetungsart: RedispatchVerguetungsart::Eeg,
323            entgangene_einnahmen_eur: dec!(80),
324            zusaetzliche_aufwendungen_eur: dec!(10),
325            ersparte_aufwendungen_eur: dec!(5),
326        }
327    }
328
329    /// The basis travels into the result and its trace, so an audit can see
330    /// which counterfactual the compensation rests on.
331    ///
332    /// §13a Abs. 2 measures the curtailed energy differently per case, and the
333    /// two produce different figures for the same activation. A compensation
334    /// that does not say which one it used cannot be checked.
335    #[test]
336    fn the_basis_is_carried_into_the_result_and_the_trace() {
337        for basis in [
338            AusfallarbeitBasis::GemessenerLastgang,
339            AusfallarbeitBasis::UebermittelterFahrplan,
340        ] {
341            let v = redispatch_verguetung(&input(basis)).expect("computes");
342            assert_eq!(v.basis, basis);
343            assert!(
344                v.trace.iter().any(|l| l == basis.label()),
345                "the trace must name the basis: {:?}",
346                v.trace
347            );
348        }
349    }
350
351    /// The labels name the case and the paragraph — they are read by auditors,
352    /// not only by code.
353    #[test]
354    fn the_labels_name_the_case_and_the_paragraph() {
355        assert!(
356            AusfallarbeitBasis::GemessenerLastgang
357                .label()
358                .contains("Duldungsfall")
359        );
360        assert!(
361            AusfallarbeitBasis::UebermittelterFahrplan
362                .label()
363                .contains("Aufforderungsfall")
364        );
365        for b in [
366            AusfallarbeitBasis::GemessenerLastgang,
367            AusfallarbeitBasis::UebermittelterFahrplan,
368        ] {
369            assert!(b.label().contains("§13a Abs. 2 EnWG"), "{}", b.label());
370        }
371    }
372
373    /// The arithmetic itself does not change with the basis — only the input
374    /// figure does. Making the basis alter the sum would double-count the
375    /// distinction.
376    #[test]
377    fn the_basis_does_not_change_the_arithmetic() {
378        let a = redispatch_verguetung(&input(AusfallarbeitBasis::GemessenerLastgang)).unwrap();
379        let b = redispatch_verguetung(&input(AusfallarbeitBasis::UebermittelterFahrplan)).unwrap();
380        assert_eq!(a.verguetung_eur, b.verguetung_eur);
381        assert_eq!(a.verguetung_eur, dec!(85));
382    }
383}