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