Skip to main content

mako_redispatch/
ausfallarbeit.rs

1//! Ausfallarbeit engine — `BilAReM` Kap. 3 (Anlage zur Festlegung BK6-23-241,
2//! Beschluss vom 07.05.2026).
3//!
4//! Implements the binding final text (not the second-consultation draft, which
5//! differs in several places: the Duldungsfall rule `P_lim = P_ist` was
6//! restored, the plausibility cap is per-TR Nennleistung, the Solar formulas
7//! carry `P_WR`, Wind-Pauschal carries `P_inst`, the nicht-fluktuierende
8//! Pauschal-Abrechnung dropped `P_mbA`, and the Überbauung cap subtracts the
9//! Einspeisung über die Netzlokation).
10//!
11//! All Leistungswerte are Viertelstundenmittelwerte in kW; every `W_A` result is
12//! the Ausfallarbeit of one Viertelstunde in kWh (`kW × ¼ h`). Sign convention
13//! (Kap. 3): negative Redispatch → Ausfallarbeit ≥ 0; positive Redispatch →
14//! Ausfallarbeit ≤ 0 (Mehrarbeit).
15//!
16//! The engine is pure: callers supply the measured/derived quarter-hour values
17//! (`P_ist`, `P_theo`, wind speeds, irradiation, Ex-ante-Planungsdaten) — the
18//! sourcing of those series (SCADA, edmd Lastgang, DWD, Referenzanlage) is a
19//! service concern. Elections/admissibility of the Abrechnungsvarianten live
20//! in [`crate::bilarem`].
21
22use rust_decimal::Decimal;
23use rust_decimal::prelude::Zero;
24use time::{Date, Month, OffsetDateTime, Time};
25
26// ── Frist constants (BilAReM Kap. 3.2.1) ─────────────────────────────────────
27
28/// Wetterdaten/Referenzmessdaten for Spitz-/vereinfachte Spitzabrechnung are
29/// due by the end of this Werktag of the following month; afterwards the ANB
30/// builds Ersatzwerte.
31pub const WETTERDATEN_LIEFERFRIST_WERKTAGE: u8 = 4;
32
33/// A TR leaving the (metering-driven) Pauschal-Abrechnung switches with this
34/// notice, effective at the end of the next 31.12., into the vereinfachte
35/// Spitzabrechnung (unless Spitzabrechnung was elected by 30.11.).
36pub const PAUSCHAL_WECHSEL_FRIST_MONATE: u8 = 3;
37
38/// Quarter-hours count towards the Vergleichszeitraum only if the
39/// Leistungsmittelwert is at least this share of the TR Nennleistung
40/// (Kap. 3.2.2.1 / 3.2.4.1: "mindestens 10 %").
41pub const VERGLEICHSZEITRAUM_MINDESTANTEIL: Decimal = Decimal::from_parts(1, 0, 0, false, 1); // 0.1
42
43/// Length of the Wind-KF Vergleichszeitraum: the nearest four fully measured,
44/// contiguous quarter-hours before or after the Maßnahme (ties → before;
45/// Folgemonat quarter-hours are never used).
46pub const VERGLEICHSZEITRAUM_VIERTELSTUNDEN: usize = 4;
47
48/// Minimum Wertepaare per Wind-Bin for a valid monthly Leistungsfaktor
49/// (Kap. 3.2.3.2: `m ≥ 3`; a bin also needs ≥ 30 minutes of valid data).
50pub const WIND_BIN_MINDEST_WERTEPAARE: usize = 3;
51
52/// Wind-Bin width in m/s (DIN EN 61400-12-1 method).
53pub const WIND_BIN_BREITE_MS: Decimal = Decimal::from_parts(5, 0, 0, false, 1); // 0.5
54
55const QUARTER_HOUR: Decimal = Decimal::from_parts(25, 0, 0, false, 2); // 0.25
56
57// ── Errors ───────────────────────────────────────────────────────────────────
58
59/// Errors from the Ausfallarbeit computation.
60#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
61pub enum AusfallarbeitError {
62    /// A divisor (`P_VZ,theo`, `G_VZ`, `ΣE_WEA`, `ΣP_inst`) was zero or negative.
63    #[error("unzulässiger Divisor: {0}")]
64    UnzulaessigerDivisor(&'static str),
65    /// The Verlustfaktor `KF_V` left its domain ]0;1[ (Kap. 3.2.3.2).
66    #[error("Verlustfaktor {0} liegt nicht in ]0;1[")]
67    VerlustfaktorAusserhalb(Decimal),
68    /// No admissible Wind-KF Vergleichszeitraum exists on either side of the
69    /// Maßnahme (Kap. 3.2.2.1).
70    #[error(
71        "kein zulässiger Vergleichszeitraum: keine {VERGLEICHSZEITRAUM_VIERTELSTUNDEN} \
72         zusammenhängenden, vollständig gemessenen Viertelstunden mit unbeschränkter \
73         Einspeisung ≥ 10 % der Nennleistung im Monat der Maßnahme"
74    )]
75    KeinVergleichszeitraum,
76    /// No admissible Solar Vergleichstag exists in the Maßnahme's month
77    /// (Kap. 3.2.4.1).
78    #[error(
79        "kein zulässiger Vergleichstag: kein Kalendertag im Monat der Maßnahme ohne \
80         Redispatch-Maßnahme mit mindestens einer Viertelstunde ≥ 10 % der Nennleistung \
81         ohne Nichtbeanspruchbarkeit oder marktbedingte Anpassung"
82    )]
83    KeinVergleichstag,
84    /// Too few Wertepaare for a valid Wind-Bin Leistungsfaktor (m ≥ 3).
85    #[error("Wind-Bin unterbesetzt: {0} Wertepaare (< {WIND_BIN_MINDEST_WERTEPAARE})")]
86    BinUnterbesetzt(usize),
87    /// A required input was negative where the Festlegung admits none.
88    #[error("negativer Eingabewert: {0}")]
89    NegativerWert(&'static str),
90}
91
92// ── Kap. 3.1 — Wert der Leistungslimitierung ────────────────────────────────
93
94/// Direction of the Redispatch-Maßnahme.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum RedispatchRichtung {
98    /// Erzeugung erhöhen / Bezug senken — Ausfallarbeit ≤ 0 (Mehrarbeit).
99    Positiv,
100    /// Erzeugung senken / Bezug erhöhen — Ausfallarbeit ≥ 0.
101    Negativ,
102}
103
104/// How the Wert der Leistungslimitierung `P_lim,i` of one Viertelstunde is
105/// determined (Kap. 3.1).
106#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
107#[serde(rename_all = "snake_case", tag = "fall")]
108pub enum Leistungslimitierung {
109    /// Aufforderungsfall: the anweisende NB requests the EIV to adapt;
110    /// `vorgabe` is `P_min` (positiver Redispatch) or `P_max` (negativer).
111    Aufforderung {
112        /// Tatsächlicher Leistungsmittelwert `P_ist,i` in kW.
113        p_ist: Decimal,
114        /// NB-Vorgabe aus der Redispatch-Abrufinformation in kW.
115        vorgabe: Decimal,
116    },
117    /// Duldungsfall: the anweisende NB steers the SR itself → `P_lim = P_ist`.
118    Duldung {
119        /// Tatsächlicher Leistungsmittelwert `P_ist,i` in kW.
120        p_ist: Decimal,
121    },
122    /// Referenzprofilverfahren (and Redispatch-Maßnahme mit beidseitiger
123    /// Fixierung): `P_lim` equals the NB-Vorgabe outright.
124    Referenzprofil {
125        /// NB-Vorgabe (`P_min` bzw. `P_max`) in kW.
126        vorgabe: Decimal,
127    },
128}
129
130impl Leistungslimitierung {
131    /// The Wert der Leistungslimitierung `P_lim,i` in kW.
132    ///
133    /// Aufforderungsfall: positiver Redispatch → `min{P_ist; P_min}`,
134    /// negativer → `max{P_ist; P_max}`. Duldungsfall: `P_ist`.
135    /// Referenzprofil/beidseitige Fixierung: the Vorgabe.
136    #[must_use]
137    pub fn wert(self, richtung: RedispatchRichtung) -> Decimal {
138        match self {
139            Self::Aufforderung { p_ist, vorgabe } => match richtung {
140                RedispatchRichtung::Positiv => p_ist.min(vorgabe),
141                RedispatchRichtung::Negativ => p_ist.max(vorgabe),
142            },
143            Self::Duldung { p_ist } => p_ist,
144            Self::Referenzprofil { vorgabe } => vorgabe,
145        }
146    }
147}
148
149// ── Shared helpers ───────────────────────────────────────────────────────────
150
151/// Splits a marktlokationsscharfer Wert onto the TR behind the `MaLo` pro rata
152/// by installed capacity (Kap. 3 i. V. m. § 24 Abs. 3 S. 2 EEG 2023).
153///
154/// # Errors
155///
156/// [`AusfallarbeitError::UnzulaessigerDivisor`] if `Σ P_inst ≤ 0`.
157pub fn malo_wert_auf_tr(
158    malo_wert: Decimal,
159    p_inst_kw: &[Decimal],
160) -> Result<Vec<Decimal>, AusfallarbeitError> {
161    let summe: Decimal = p_inst_kw.iter().copied().sum();
162    if summe <= Decimal::zero() {
163        return Err(AusfallarbeitError::UnzulaessigerDivisor("Σ P_inst"));
164    }
165    Ok(p_inst_kw.iter().map(|p| malo_wert * p / summe).collect())
166}
167
168/// `min` over the theoretical value and the optional `P_mbA` / `P_bean` / `P_WR`
169/// bounds, then `(… − P_lim) × ¼ h`, clamped by direction.
170fn w_a(theo: Decimal, bounds: &[Option<Decimal>], p_lim: Decimal, negativ: bool) -> Decimal {
171    let mut m = theo;
172    for b in bounds.iter().copied().flatten() {
173        m = m.min(b);
174    }
175    let w = (m - p_lim) * QUARTER_HOUR;
176    if negativ {
177        w.max(Decimal::zero())
178    } else {
179        w.min(Decimal::zero())
180    }
181}
182
183// ── Kap. 3.2.2 — Windenergieanlagen (Spitzabrechnung) ───────────────────────
184
185/// Korrekturfaktor `KF = P_VZ,ist / P_VZ,theo` (Kap. 3.2.2.1).
186///
187/// `P_VZ,ist`: measured mean over the nearest four fully measured contiguous
188/// quarter-hours before or after the Maßnahme (unrestricted feed-in, ≥ 10 %
189/// Nennleistung); `P_VZ,theo`: the theoretical mean over the same
190/// quarter-hours from the zertifizierte Leistungskennlinie.
191///
192/// # Errors
193///
194/// [`AusfallarbeitError::UnzulaessigerDivisor`] if `P_VZ,theo ≤ 0`.
195pub fn korrekturfaktor(
196    p_vz_ist: Decimal,
197    p_vz_theo: Decimal,
198) -> Result<Decimal, AusfallarbeitError> {
199    if p_vz_theo <= Decimal::zero() {
200        return Err(AusfallarbeitError::UnzulaessigerDivisor("P_VZ,theo"));
201    }
202    Ok(p_vz_ist / p_vz_theo)
203}
204
205/// One Viertelstunde of a Wind-Spitzabrechnung (Kap. 3.2.2.1) or — with
206/// `kf = KF_Bin` — of the Wind-Bin-Verfahren (Kap. 3.2.3.2). The vereinfachte
207/// Spitzabrechnung (Kap. 3.2.2.2) uses the same formula with wind speeds from
208/// a meteorological provider or Referenzanlage.
209#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
210pub struct WindSpitzInput {
211    /// Korrekturfaktor KF (or `KF_Bin`).
212    pub kf: Decimal,
213    /// Theoretischer Leistungsmittelwert `P_theo,i` of the Viertelstunde in kW
214    /// (from wind speed × zertifizierte Leistungskennlinie).
215    pub p_theo: Decimal,
216    /// Marktbedingte Anpassung `P_mbA,i` in kW (None if none applies).
217    pub p_mba: Option<Decimal>,
218    /// Beanspruchbare Leistung `P_bean,i` in kW (installed − Nichtbeanspruch-
219    /// barkeit; None if none applies).
220    pub p_bean: Option<Decimal>,
221    /// Wert der Leistungslimitierung `P_lim,i` in kW.
222    pub p_lim: Decimal,
223    /// Nennleistung of the TR in kW — plausibility cap for `KF × P_theo`.
224    pub p_nenn: Decimal,
225}
226
227/// `W_A,i = max{0; (min(KF·P_theo,i; P_mbA,i; P_bean,i) − P_lim,i) × ¼ h}`
228/// with `KF·P_theo,i` capped at the Nennleistung of the TR. Result in kWh.
229///
230/// Only defined for negativen Redispatch (Kap. 3.2 applies to fluktuierende
231/// Erzeugung under negative measures only).
232#[must_use]
233pub fn wind_spitz(input: &WindSpitzInput) -> Decimal {
234    let theo = (input.kf * input.p_theo).min(input.p_nenn);
235    w_a(theo, &[input.p_mba, input.p_bean], input.p_lim, true)
236}
237
238/// `W_A,i = max{0; [min(P_0; P_inst; P_mbA,i; P_bean,i) − P_lim,i] × ¼ h}` —
239/// Wind Pauschal-Abrechnung (Kap. 3.2.2.3), grandfathered TR only. `p_0` is
240/// the last fully measured unrestricted quarter-hour before the Maßnahme (or
241/// the Referenzprofilverfahren value if no ¼-h-Messung exists).
242#[must_use]
243pub fn wind_pauschal(
244    p_0: Decimal,
245    p_inst: Decimal,
246    p_mba: Option<Decimal>,
247    p_bean: Option<Decimal>,
248    p_lim: Decimal,
249) -> Decimal {
250    w_a(p_0.min(p_inst), &[p_mba, p_bean], p_lim, true)
251}
252
253/// RFC 3339 for a `Vec<OffsetDateTime>`.
254///
255/// `time::serde::rfc3339` covers the scalar and the `Option`, not the sequence,
256/// and the derived fallback is `time`'s internal component array — valid JSON
257/// that no consumer can read. This is the same contract `xtask
258/// check-wire-timestamps` enforces for `json!` fields.
259mod rfc3339_vec {
260    use serde::{Deserialize, Deserializer, Serialize, Serializer};
261    use time::OffsetDateTime;
262
263    #[derive(Serialize, Deserialize)]
264    struct One(#[serde(with = "time::serde::rfc3339")] OffsetDateTime);
265
266    pub(super) fn serialize<S: Serializer>(
267        value: &[OffsetDateTime],
268        serializer: S,
269    ) -> Result<S::Ok, S::Error> {
270        serializer.collect_seq(value.iter().copied().map(One))
271    }
272
273    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
274        deserializer: D,
275    ) -> Result<Vec<OffsetDateTime>, D::Error> {
276        Ok(Vec::<One>::deserialize(deserializer)?
277            .into_iter()
278            .map(|One(v)| v)
279            .collect())
280    }
281}
282
283/// One candidate quarter-hour for the Wind-KF Vergleichszeitraum.
284///
285/// Supplied by the caller from the TR's own series; this module decides only
286/// which four of them Kap. 3.2.2.1 admits.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
288pub struct VergleichsViertelstunde {
289    /// Start of the quarter-hour. Candidates are read in this order and must be
290    /// strictly ascending; a gap of more than a quarter-hour breaks contiguity.
291    ///
292    /// RFC 3339 on the wire: `time`'s derived representation is its internal
293    /// component array, which no consumer outside `time` can read.
294    #[serde(with = "time::serde::rfc3339")]
295    pub beginn: OffsetDateTime,
296    /// Gemessener Leistungsmittelwert `P_ist` in kW.
297    pub p_ist_kw: Decimal,
298    /// Theoretischer Leistungsmittelwert `P_theo` in kW from the zertifizierte
299    /// Leistungskennlinie.
300    pub p_theo_kw: Decimal,
301    /// `false` for a quarter-hour that is not fully measured — an Ersatzwert, a
302    /// partial interval, or a Störung.
303    pub vollstaendig_gemessen: bool,
304    /// `false` while the feed-in was restricted (a Redispatch-Maßnahme, an
305    /// Einspeisemanagement, a marktbedingte Anpassung).
306    pub unbeschraenkt: bool,
307}
308
309impl VergleichsViertelstunde {
310    /// Admissible on its own terms: fully measured, unrestricted, and carrying
311    /// at least [`VERGLEICHSZEITRAUM_MINDESTANTEIL`] of the Nennleistung.
312    fn zulaessig(&self, p_nenn_kw: Decimal) -> bool {
313        self.vollstaendig_gemessen
314            && self.unbeschraenkt
315            && self.p_ist_kw >= p_nenn_kw * VERGLEICHSZEITRAUM_MINDESTANTEIL
316    }
317}
318
319/// Which side of the Maßnahme the Vergleichszeitraum was taken from.
320#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
321#[serde(rename_all = "snake_case")]
322pub enum VergleichszeitraumLage {
323    /// Before the Maßnahme — the tie-break winner at equal distance.
324    Davor,
325    /// After the Maßnahme, within the same calendar month.
326    Danach,
327}
328
329/// The four quarter-hours Kap. 3.2.2.1 admits, and the two means they yield.
330#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
331pub struct Vergleichszeitraum {
332    /// `P_VZ,ist` — the measured mean over the four quarter-hours, in kW.
333    pub p_vz_ist_kw: Decimal,
334    /// `P_VZ,theo` — the theoretical mean over the same four, in kW.
335    pub p_vz_theo_kw: Decimal,
336    /// Which side of the Maßnahme they were taken from.
337    pub lage: VergleichszeitraumLage,
338    /// The start instants of the four, ascending.
339    #[serde(with = "rfc3339_vec")]
340    pub viertelstunden: Vec<OffsetDateTime>,
341}
342
343impl Vergleichszeitraum {
344    /// `KF = P_VZ,ist / P_VZ,theo` for this Vergleichszeitraum.
345    ///
346    /// # Errors
347    ///
348    /// [`AusfallarbeitError::UnzulaessigerDivisor`] if `P_VZ,theo ≤ 0`.
349    pub fn korrekturfaktor(&self) -> Result<Decimal, AusfallarbeitError> {
350        korrekturfaktor(self.p_vz_ist_kw, self.p_vz_theo_kw)
351    }
352}
353
354/// Select the Wind-KF Vergleichszeitraum from a TR's quarter-hour series
355/// (Kap. 3.2.2.1).
356///
357/// The rule has four parts and every one of them changes the answer:
358///
359/// - **four contiguous** quarter-hours ([`VERGLEICHSZEITRAUM_VIERTELSTUNDEN`]),
360///   so a run interrupted by one inadmissible interval does not qualify;
361/// - **fully measured and unrestricted**, so an Ersatzwert or a quarter-hour
362///   still under an Einspeisemanagement cannot set the Korrekturfaktor that
363///   then prices the Ausfallarbeit;
364/// - **at least 10 % of the Nennleistung**
365///   ([`VERGLEICHSZEITRAUM_MINDESTANTEIL`]) — near standstill the measured and
366///   theoretical means are both close to zero and their quotient is noise;
367/// - **nearest to the Maßnahme, ties to the side before it**, and never from the
368///   Folgemonat: the KF is a monthly figure, so reaching into the next month
369///   would settle one month with another month's weather.
370///
371/// **The two sides are measured from two different anchors**, which the text is
372/// explicit about: „die zeitlich nächsten … vier Viertelstunden vor oder nach
373/// der Viertelstunde, in der die Redispatch-Maßnahme **beginnt bzw. endet**".
374/// A run before the Maßnahme is measured to `massnahme_beginn`, one after it to
375/// `massnahme_ende`. Measuring both from the beginning inflates every „danach"
376/// distance by the length of the Maßnahme and hands a four-hour measure a
377/// Vergleichszeitraum from hours before it when the quarter-hours immediately
378/// after it are the nearest — a different KF, and the KF prices every kWh.
379///
380/// # Errors
381///
382/// [`AusfallarbeitError::KeinVergleichszeitraum`] when no admissible run of four
383/// exists on either side.
384pub fn vergleichszeitraum(
385    kandidaten: &[VergleichsViertelstunde],
386    massnahme_beginn: OffsetDateTime,
387    massnahme_ende: OffsetDateTime,
388    p_nenn_kw: Decimal,
389) -> Result<Vergleichszeitraum, AusfallarbeitError> {
390    let n = VERGLEICHSZEITRAUM_VIERTELSTUNDEN;
391    let viertelstunde = time::Duration::minutes(15);
392    let monat = (massnahme_beginn.year(), massnahme_beginn.month());
393
394    let mut best: Option<(
395        time::Duration,
396        VergleichszeitraumLage,
397        &[VergleichsViertelstunde],
398    )> = None;
399
400    for run in kandidaten.windows(n) {
401        // Contiguous, ascending, and every member admissible on its own.
402        if run
403            .windows(2)
404            .any(|pair| pair[1].beginn - pair[0].beginn != viertelstunde)
405        {
406            continue;
407        }
408        if !run.iter().all(|vs| vs.zulaessig(p_nenn_kw)) {
409            continue;
410        }
411
412        let ende = run[n - 1].beginn + viertelstunde;
413        let (abstand, lage) = if ende <= massnahme_beginn {
414            (massnahme_beginn - ende, VergleichszeitraumLage::Davor)
415        } else if run[0].beginn >= massnahme_ende {
416            (
417                run[0].beginn - massnahme_ende,
418                VergleichszeitraumLage::Danach,
419            )
420        } else {
421            // Overlaps the Maßnahme — those quarter-hours are the measure's
422            // own, not a comparison for it.
423            continue;
424        };
425
426        // „Folgemonat quarter-hours are never used": a run after the Maßnahme
427        // must stay inside the Maßnahme's calendar month. A run before it
428        // cannot leave the month by construction, but reaching back into the
429        // Vormonat is the mirror of the same objection.
430        if run
431            .iter()
432            .any(|vs| (vs.beginn.year(), vs.beginn.month()) != monat)
433        {
434            continue;
435        }
436
437        // Ties go to `Davor`, which `VergleichszeitraumLage::Davor < Danach`
438        // expresses — but only against an equal distance, so it is compared
439        // explicitly rather than through a derived `Ord` on a tuple.
440        let better = match &best {
441            None => true,
442            Some((d, l, _)) => {
443                abstand < *d
444                    || (abstand == *d
445                        && *l == VergleichszeitraumLage::Danach
446                        && lage == VergleichszeitraumLage::Davor)
447            }
448        };
449        if better {
450            best = Some((abstand, lage, run));
451        }
452    }
453
454    let Some((_, lage, run)) = best else {
455        return Err(AusfallarbeitError::KeinVergleichszeitraum);
456    };
457    let teiler = Decimal::from(u64::try_from(n).unwrap_or(u64::MAX));
458    Ok(Vergleichszeitraum {
459        p_vz_ist_kw: run.iter().map(|vs| vs.p_ist_kw).sum::<Decimal>() / teiler,
460        p_vz_theo_kw: run.iter().map(|vs| vs.p_theo_kw).sum::<Decimal>() / teiler,
461        lage,
462        viertelstunden: run.iter().map(|vs| vs.beginn).collect(),
463    })
464}
465
466// ── Kap. 3.2.3.2 — Wind-Bin-Verfahren (Windenergieanlagen auf See) ──────────
467
468/// Index of the 0,5-m/s-Bin a wind speed falls into (bins centred on
469/// multiples of 0,5 m/s per DIN EN 61400-12-1).
470#[must_use]
471pub fn wind_bin_index(windgeschwindigkeit_ms: Decimal) -> i64 {
472    // A speed exactly on a bin boundary goes to the outer bin — `Decimal::round`
473    // would send it to the even one, which is the workspace's banker's-rounding
474    // trap in a place that is not money.
475    let idx = (windgeschwindigkeit_ms / WIND_BIN_BREITE_MS)
476        .round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
477    idx.try_into().unwrap_or(i64::MAX)
478}
479
480/// Monthly Leistungsfaktor `KF_LBin = P̄_Bin / P_zertLK` of one bin, with
481/// `P̄_Bin = Σ P / m` (m ≥ 3) and `KF_LBin ≥ 0`. Wertepaare must already be
482/// filtered to störungsfreier Betrieb, unrestricted feed-in and ≥ 10 %
483/// Nennleistung; valid for the corresponding month of the next two Folgejahre.
484///
485/// # Errors
486///
487/// [`AusfallarbeitError::BinUnterbesetzt`] for `m < 3`;
488/// [`AusfallarbeitError::UnzulaessigerDivisor`] if `P_zertLK ≤ 0`.
489pub fn kf_lbin(
490    leistungswerte_kw: &[Decimal],
491    p_zert_lk: Decimal,
492) -> Result<Decimal, AusfallarbeitError> {
493    let m = leistungswerte_kw.len();
494    if m < WIND_BIN_MINDEST_WERTEPAARE {
495        return Err(AusfallarbeitError::BinUnterbesetzt(m));
496    }
497    if p_zert_lk <= Decimal::zero() {
498        return Err(AusfallarbeitError::UnzulaessigerDivisor("P_zertLK"));
499    }
500    let mittel: Decimal = leistungswerte_kw.iter().copied().sum::<Decimal>()
501        / Decimal::from(u64::try_from(m).unwrap_or(u64::MAX));
502    Ok((mittel / p_zert_lk).max(Decimal::zero()))
503}
504
505/// Where a bin's Leistungsfaktor came from.
506#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
507#[serde(rename_all = "snake_case")]
508pub enum KfLbinQuelle {
509    /// Regularly determined for the relevant month.
510    Monat,
511    /// Ersatzwert from the Vormonat.
512    Vormonat,
513    /// Ersatzwert from the Folgemonat.
514    Folgemonat,
515    /// Mittelwert of the twelve months before the relevant month.
516    ZwoelfMonatsMittel,
517    /// No sufficient Wertepaare anywhere → `KF_LBin = 1` (also used outside
518    /// the Leistungskennlinie range, below cut-in / above cut-out).
519    Standard,
520}
521
522/// Resolves the Ersatzwert chain for an invalid bin (Kap. 3.2.3.2, order
523/// binding): Vormonat → Folgemonat → 12-Monats-Mittel → `KF_LBin = 1`.
524#[must_use]
525pub fn kf_lbin_ersatzwert(
526    vormonat: Option<Decimal>,
527    folgemonat: Option<Decimal>,
528    zwoelf_monats_mittel: Option<Decimal>,
529) -> (Decimal, KfLbinQuelle) {
530    if let Some(v) = vormonat {
531        (v, KfLbinQuelle::Vormonat)
532    } else if let Some(v) = folgemonat {
533        (v, KfLbinQuelle::Folgemonat)
534    } else if let Some(v) = zwoelf_monats_mittel {
535        (v, KfLbinQuelle::ZwoelfMonatsMittel)
536    } else {
537        (Decimal::ONE, KfLbinQuelle::Standard)
538    }
539}
540
541/// Verlustfaktor `KF_V = E_Einsp / Σ E_WEA` over twelve months — parkinterne
542/// Verluste, per Messlokation, essentially constant over the park lifetime.
543///
544/// # Errors
545///
546/// [`AusfallarbeitError::UnzulaessigerDivisor`] if `Σ E_WEA ≤ 0`;
547/// [`AusfallarbeitError::VerlustfaktorAusserhalb`] if the ratio leaves the
548/// binding domain `]0;1[` (which also enforces `E_Einsp ≤ Σ E_WEA`).
549pub fn verlustfaktor(
550    e_einsp_kwh: Decimal,
551    summe_e_wea_kwh: Decimal,
552) -> Result<Decimal, AusfallarbeitError> {
553    if summe_e_wea_kwh <= Decimal::zero() {
554        return Err(AusfallarbeitError::UnzulaessigerDivisor("Σ E_WEA"));
555    }
556    let kf_v = e_einsp_kwh / summe_e_wea_kwh;
557    if kf_v <= Decimal::zero() || kf_v >= Decimal::ONE {
558        return Err(AusfallarbeitError::VerlustfaktorAusserhalb(kf_v));
559    }
560    Ok(kf_v)
561}
562
563/// `KF_Bin = KF_LBin × KF_V` (Kap. 3.2.3.2). Feed into [`wind_spitz`] as `kf`.
564#[must_use]
565pub fn kf_bin(kf_lbin: Decimal, kf_v: Decimal) -> Decimal {
566    kf_lbin * kf_v
567}
568
569// ── Kap. 3.2.4 — Solaranlagen ───────────────────────────────────────────────
570
571/// One Viertelstunde of a Solar-Spitzabrechnung (Kap. 3.2.4.1); the
572/// vereinfachte Spitzabrechnung (Kap. 3.2.4.2) uses the same formula with
573/// Einstrahlwerte from a meteorological provider (Heliosat-2 qualifies).
574#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
575pub struct SolarSpitzInput {
576    /// Durchschnittliche Ist-Einspeisung `P_VZ,ist` im Vergleichszeitraum in kW.
577    pub p_vz_ist: Decimal,
578    /// Durchschnittliche Einstrahlleistung `G_VZ` im Vergleichszeitraum in kW/m².
579    pub g_vz: Decimal,
580    /// Durchschnittliche Einstrahlleistung `G_i` of the Viertelstunde in kW/m².
581    pub g_i: Decimal,
582    /// Wechselrichterleistung `P_WR` je TR in kW (split pro rata by installed
583    /// capacity when several TR share one Wechselrichter).
584    pub p_wr: Decimal,
585    /// Marktbedingte Anpassung `P_mbA,i` in kW (None if none applies).
586    pub p_mba: Option<Decimal>,
587    /// Beanspruchbare Leistung `P_bean,i` in kW (None if none applies).
588    pub p_bean: Option<Decimal>,
589    /// Wert der Leistungslimitierung `P_lim,i` in kW.
590    pub p_lim: Decimal,
591    /// Nennleistung of the TR in kW — plausibility cap for
592    /// `P_VZ,ist / G_VZ × G_i`.
593    pub p_nenn: Decimal,
594}
595
596/// `W_A,i = max{0; (min(P_VZ,ist/G_VZ × G_i; P_WR; P_mbA,i; P_bean,i)
597/// − P_lim,i) × ¼ h}` with the irradiation-scaled term capped at the
598/// Nennleistung of the TR. Result in kWh.
599///
600/// # Errors
601///
602/// [`AusfallarbeitError::UnzulaessigerDivisor`] if `G_VZ ≤ 0`.
603pub fn solar_spitz(input: &SolarSpitzInput) -> Result<Decimal, AusfallarbeitError> {
604    if input.g_vz <= Decimal::zero() {
605        return Err(AusfallarbeitError::UnzulaessigerDivisor("G_VZ"));
606    }
607    let theo = (input.p_vz_ist / input.g_vz * input.g_i).min(input.p_nenn);
608    Ok(w_a(
609        theo,
610        &[Some(input.p_wr), input.p_mba, input.p_bean],
611        input.p_lim,
612        true,
613    ))
614}
615
616/// Anlagenfaktor AF for the Solar Pauschal-Abrechnung (Kap. 3.2.4.3).
617///
618/// `zeit` is the start of the Viertelstunde in **UTC+1** (the table is fixed
619/// to UTC+1 — no DST switch). Sommer = 01.03.–31.10., Winter = 01.11.–28./29.02.
620#[must_use]
621pub fn anlagenfaktor(datum: Date, zeit: Time) -> Decimal {
622    let sommer = matches!(
623        datum.month(),
624        Month::March
625            | Month::April
626            | Month::May
627            | Month::June
628            | Month::July
629            | Month::August
630            | Month::September
631            | Month::October
632    );
633    let minuten = i32::from(zeit.hour()) * 60 + i32::from(zeit.minute());
634    let af = |zehntausendstel: i64| Decimal::new(zehntausendstel, 4);
635    if sommer {
636        match minuten {
637            m if (360..540).contains(&m) => af(2456),  // 06:00–09:00
638            m if (540..900).contains(&m) => af(6189),  // 09:00–15:00
639            m if (900..1140).contains(&m) => af(2456), // 15:00–19:00
640            _ => Decimal::zero(),                      // 19:00–06:00
641        }
642    } else {
643        match minuten {
644            m if (540..600).contains(&m) => af(2796),  // 09:00–10:00
645            m if (600..840).contains(&m) => af(5030),  // 10:00–14:00
646            m if (840..1005).contains(&m) => af(2796), // 14:00–16:45
647            _ => Decimal::zero(),                      // 16:45–09:00
648        }
649    }
650}
651
652/// `W_A,i = max{0; [min(AF × P_inst; P_WR; P_mbA,i; P_bean,i) − P_lim,i]
653/// × ¼ h}` — Solar Pauschal-Abrechnung (Kap. 3.2.4.3), grandfathered TR only.
654/// `p_inst` is the Summe der Nennleistung der Module in kW.
655#[must_use]
656pub fn solar_pauschal(
657    af: Decimal,
658    p_inst_module: Decimal,
659    p_wr: Decimal,
660    p_mba: Option<Decimal>,
661    p_bean: Option<Decimal>,
662    p_lim: Decimal,
663) -> Decimal {
664    w_a(
665        af * p_inst_module,
666        &[Some(p_wr), p_mba, p_bean],
667        p_lim,
668        true,
669    )
670}
671
672// ── Kap. 3.2.4.1 — Solar-Vergleichstag ──────────────────────────────────────
673
674/// One quarter-hour of a candidate Solar-Vergleichstag.
675///
676/// Supplied by the caller from the TR's own series and the Einstrahlungs-
677/// messung; this module decides only which of them Kap. 3.2.4.1 admits and which
678/// calendar day they add up to.
679#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
680pub struct VergleichstagViertelstunde {
681    /// Start of the quarter-hour. Its calendar date is the day it belongs to.
682    #[serde(with = "time::serde::rfc3339")]
683    pub beginn: OffsetDateTime,
684    /// Gemessener Leistungsmittelwert `P_ist` in kW.
685    pub p_ist_kw: Decimal,
686    /// Durchschnittliche Einstrahlleistung in kW/m².
687    pub einstrahlung_kw_m2: Decimal,
688    /// `true` while a Nichtbeanspruchbarkeit or a marktbedingte Anpassung
689    /// applied — Kap. 3.2.4.1 excludes those quarter-hours from the means.
690    pub nichtbeanspruchbar_oder_mba: bool,
691}
692
693/// The Solar Vergleichstag and the two means it yields (Kap. 3.2.4.1).
694#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
695pub struct Vergleichstag {
696    /// The calendar day the values were taken from.
697    pub tag: Date,
698    /// `P_VZ,ist` — the mean measured feed-in over the admitted quarter-hours,
699    /// in kW.
700    pub p_vz_ist_kw: Decimal,
701    /// `G_VZ` — the mean irradiation over the same quarter-hours, in kW/m².
702    pub g_vz_kw_m2: Decimal,
703    /// Which side of the Maßnahme the day lies on.
704    pub lage: VergleichszeitraumLage,
705    /// How many quarter-hours of that day were admitted.
706    pub viertelstunden: usize,
707}
708
709/// Select the Solar Vergleichstag from a candidate series (Kap. 3.2.4.1).
710///
711/// The rule is a **calendar day**, not four quarter-hours — Solar and Wind do
712/// not share a Vergleichszeitraum, and using the wind rule for a Solaranlage
713/// changes `P_VZ,ist / G_VZ` and with it every kWh of Ausfallarbeit:
714///
715/// - „der letzte vorangegangene oder der erste nachfolgende **Kalendertag** vor
716///   oder nach der Redispatch-Maßnahme, an dem keine Redispatch-Maßnahme
717///   gegenüber der SR stattgefunden hat" — hence `tage_mit_massnahme`;
718/// - „bei gleichem zeitlichem Abstand ist der Kalendertag **vor** der
719///   Redispatch-Maßnahme zu verwenden";
720/// - „Kalendertage aus dem **Folgemonat** sind nicht zu verwenden";
721/// - only quarter-hours „in denen der Leistungsmittelwert mindestens 10 % der
722///   Nennleistung der TR beträgt und in denen keine Nichtbeanspruchbarkeiten
723///   oder marktbedingten Anpassungen vorliegen" enter the two means;
724/// - „für den Vergleichszeitraum ist zurückzugehen bis zu dem letzten Tag, an
725///   dem eine Viertelstunde mit mehr als 10 % Einspeisung stattgefunden hat" —
726///   so a run of dark days is stepped over rather than ending the search.
727///
728/// `massnahme_tag` is the calendar day the Maßnahme falls on; distance is
729/// counted in whole days from it.
730///
731/// # Errors
732///
733/// [`AusfallarbeitError::KeinVergleichstag`] when no day in the Maßnahme's month
734/// qualifies, and [`AusfallarbeitError::UnzulaessigerDivisor`] when the admitted
735/// quarter-hours carry no irradiation at all — `G_VZ` would be zero and
736/// [`solar_spitz`] could not divide by it.
737pub fn solar_vergleichstag(
738    kandidaten: &[VergleichstagViertelstunde],
739    massnahme_tag: Date,
740    tage_mit_massnahme: &[Date],
741    p_nenn_kw: Decimal,
742) -> Result<Vergleichstag, AusfallarbeitError> {
743    let schwelle = p_nenn_kw * VERGLEICHSZEITRAUM_MINDESTANTEIL;
744    let mut best: Option<(i64, VergleichszeitraumLage, Date)> = None;
745
746    // Group by calendar day without allocating a map: the candidate series is a
747    // month at most, so a linear pass per distinct day is cheaper than the map.
748    let mut tage: Vec<Date> = kandidaten.iter().map(|vs| vs.beginn.date()).collect();
749    tage.sort_unstable();
750    tage.dedup();
751
752    for tag in tage {
753        if tag == massnahme_tag || tage_mit_massnahme.contains(&tag) {
754            continue;
755        }
756        // „Kalendertage aus dem Folgemonat sind nicht zu verwenden." A day in
757        // the Vormonat is the mirror of the same objection: the Vergleichstag
758        // stays inside the month being settled.
759        if (tag.year(), tag.month()) != (massnahme_tag.year(), massnahme_tag.month()) {
760            continue;
761        }
762        if !kandidaten
763            .iter()
764            .any(|vs| vs.beginn.date() == tag && zulaessig(vs, schwelle))
765        {
766            continue;
767        }
768        let abstand = (tag - massnahme_tag).whole_days();
769        let lage = if abstand < 0 {
770            VergleichszeitraumLage::Davor
771        } else {
772            VergleichszeitraumLage::Danach
773        };
774        let entfernung = abstand.abs();
775        let better = match &best {
776            None => true,
777            Some((d, l, _)) => {
778                entfernung < *d
779                    || (entfernung == *d
780                        && *l == VergleichszeitraumLage::Danach
781                        && lage == VergleichszeitraumLage::Davor)
782            }
783        };
784        if better {
785            best = Some((entfernung, lage, tag));
786        }
787    }
788
789    let Some((_, lage, tag)) = best else {
790        return Err(AusfallarbeitError::KeinVergleichstag);
791    };
792
793    let admitted: Vec<&VergleichstagViertelstunde> = kandidaten
794        .iter()
795        .filter(|vs| vs.beginn.date() == tag && zulaessig(vs, schwelle))
796        .collect();
797    let teiler = Decimal::from(u64::try_from(admitted.len()).unwrap_or(u64::MAX));
798    let g_vz = admitted
799        .iter()
800        .map(|vs| vs.einstrahlung_kw_m2)
801        .sum::<Decimal>()
802        / teiler;
803    if g_vz <= Decimal::zero() {
804        return Err(AusfallarbeitError::UnzulaessigerDivisor("G_VZ"));
805    }
806    Ok(Vergleichstag {
807        tag,
808        p_vz_ist_kw: admitted.iter().map(|vs| vs.p_ist_kw).sum::<Decimal>() / teiler,
809        g_vz_kw_m2: g_vz,
810        lage,
811        viertelstunden: admitted.len(),
812    })
813}
814
815/// „mindestens 10 % der Nennleistung … und keine Nichtbeanspruchbarkeiten oder
816/// marktbedingten Anpassungen".
817fn zulaessig(vs: &VergleichstagViertelstunde, schwelle_kw: Decimal) -> bool {
818    !vs.nichtbeanspruchbar_oder_mba && vs.p_ist_kw >= schwelle_kw
819}
820
821// ── Kap. 3.3 — Anlagen mit nicht-fluktuierender Erzeugung ───────────────────
822
823/// Spitzabrechnung (Kap. 3.3.1): `W_A,i` from the geplante Fahrweise
824/// (Ex-ante-Planungsdaten). Positiver Redispatch → `min{0; (P_plan − P_lim)
825/// × ¼ h}` (Mehrarbeit ≤ 0); negativer → `max{0; (P_plan − P_lim) × ¼ h}`.
826/// TR im Planwertmodell are always settled this way.
827#[must_use]
828pub fn nichtfluktuierend_spitz(
829    richtung: RedispatchRichtung,
830    p_plan: Decimal,
831    p_lim: Decimal,
832) -> Decimal {
833    let w = (p_plan - p_lim) * QUARTER_HOUR;
834    match richtung {
835        RedispatchRichtung::Positiv => w.min(Decimal::zero()),
836        RedispatchRichtung::Negativ => w.max(Decimal::zero()),
837    }
838}
839
840/// Pauschal-Abrechnung (Kap. 3.3.2): Fortschreibung of the last fully
841/// measured quarter-hour `P_0`. Positiver Redispatch →
842/// `min{0; (P_0 − min(P_lim; P_bean)) × ¼ h}`; negativer →
843/// `max{0; (min(P_0; P_bean) − P_lim) × ¼ h}`. TR im Prognosemodell default
844/// here (Spitz on request with correct Ex-ante-Planungsdaten).
845#[must_use]
846pub fn nichtfluktuierend_pauschal(
847    richtung: RedispatchRichtung,
848    p_0: Decimal,
849    p_bean: Option<Decimal>,
850    p_lim: Decimal,
851) -> Decimal {
852    match richtung {
853        RedispatchRichtung::Positiv => {
854            let grenze = p_bean.map_or(p_lim, |b| p_lim.min(b));
855            ((p_0 - grenze) * QUARTER_HOUR).min(Decimal::zero())
856        }
857        RedispatchRichtung::Negativ => {
858            let basis = p_bean.map_or(p_0, |b| p_0.min(b));
859            ((basis - p_lim) * QUARTER_HOUR).max(Decimal::zero())
860        }
861    }
862}
863
864// ── Kap. 3.4 — Überbauung von Anschlüssen ───────────────────────────────────
865
866/// One TR's contribution to the Überbauung check of a Netzlokation.
867#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
868pub struct UeberbauungTr {
869    /// Ausfallarbeit `W_A,i,k` of the TR per Kap. 3.2/3.3 in kWh.
870    pub w_a_kwh: Decimal,
871    /// Installierte Leistung `P_inst,k` of the TR in kW.
872    pub p_inst_kw: Decimal,
873}
874
875/// Caps the summed Ausfallarbeit of all TR behind one Netzlokation at
876/// `P_anschl × ¼ h − Einspeisung über die Netzlokation` (Kap. 3.4) and
877/// distributes the Kürzung pro rata by installed capacity (the "jedenfalls
878/// sachgerecht" default), clamping each TR at zero and redistributing the
879/// remainder among the others.
880///
881/// Returns the gekürzte Ausfallarbeit per TR, same order as `trs`.
882///
883/// # Errors
884///
885/// [`AusfallarbeitError::NegativerWert`] if `p_anschl_kw` is negative;
886/// [`AusfallarbeitError::UnzulaessigerDivisor`] if a Kürzung is required but
887/// `Σ P_inst ≤ 0`.
888pub fn ueberbauung_kuerzung(
889    trs: &[UeberbauungTr],
890    p_anschl_kw: Decimal,
891    einspeisung_netzlokation_kwh: Decimal,
892) -> Result<Vec<Decimal>, AusfallarbeitError> {
893    if p_anschl_kw < Decimal::zero() {
894        return Err(AusfallarbeitError::NegativerWert("P_anschl"));
895    }
896    let cap = (p_anschl_kw * QUARTER_HOUR - einspeisung_netzlokation_kwh).max(Decimal::zero());
897    let summe: Decimal = trs.iter().map(|t| t.w_a_kwh).sum();
898    if summe <= cap {
899        return Ok(trs.iter().map(|t| t.w_a_kwh).collect());
900    }
901    // Pro-rata Kürzung by installed capacity with clamp-at-zero: a TR whose
902    // gekürzte Ausfallarbeit would turn negative is set to 0 and excluded from
903    // the remaining distribution (iterated until stable).
904    let mut werte: Vec<Decimal> = trs.iter().map(|t| t.w_a_kwh).collect();
905    let mut aktiv: Vec<bool> = trs.iter().map(|t| t.w_a_kwh > Decimal::zero()).collect();
906    loop {
907        let ueberschuss: Decimal = werte
908            .iter()
909            .zip(&aktiv)
910            .map(|(w, a)| if *a { *w } else { Decimal::zero() })
911            .sum::<Decimal>()
912            - cap;
913        if ueberschuss <= Decimal::zero() {
914            break;
915        }
916        let p_inst_summe: Decimal = trs
917            .iter()
918            .zip(&aktiv)
919            .filter(|(_, a)| **a)
920            .map(|(t, _)| t.p_inst_kw)
921            .sum();
922        if p_inst_summe <= Decimal::zero() {
923            return Err(AusfallarbeitError::UnzulaessigerDivisor("Σ P_inst"));
924        }
925        let mut geclampt = false;
926        for (idx, tr) in trs.iter().enumerate() {
927            if !aktiv[idx] {
928                continue;
929            }
930            let anteil = ueberschuss * tr.p_inst_kw / p_inst_summe;
931            let neu = werte[idx] - anteil;
932            if neu < Decimal::zero() {
933                werte[idx] = Decimal::zero();
934                aktiv[idx] = false;
935                geclampt = true;
936            } else {
937                werte[idx] = neu;
938            }
939        }
940        if !geclampt {
941            break;
942        }
943        // A clamp shifted burden — recompute against the survivors.
944    }
945    for (idx, w) in werte.iter_mut().enumerate() {
946        if !aktiv[idx] && trs[idx].w_a_kwh <= Decimal::zero() {
947            *w = trs[idx].w_a_kwh; // non-positive entries pass through untouched
948        }
949    }
950    Ok(werte)
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956    use rust_decimal::prelude::FromPrimitive;
957    use time::macros::{date, datetime, time};
958
959    fn dec(v: f64) -> Decimal {
960        Decimal::from_f64(v).expect("finite")
961    }
962
963    // ── Kap. 3.2.2.1 Vergleichszeitraum ──────────────────────────────────
964
965    fn vs(
966        minute_offset: i64,
967        p_ist: f64,
968        gemessen: bool,
969        unbeschraenkt: bool,
970    ) -> VergleichsViertelstunde {
971        VergleichsViertelstunde {
972            beginn: datetime!(2026-06-15 00:00 UTC) + time::Duration::minutes(minute_offset),
973            p_ist_kw: dec(p_ist),
974            p_theo_kw: dec(1000.0),
975            vollstaendig_gemessen: gemessen,
976            unbeschraenkt,
977        }
978    }
979
980    /// The nearest admissible run of four wins, and „nearest" is measured to the
981    /// edge of the run rather than to its start.
982    #[test]
983    fn vergleichszeitraum_takes_the_nearest_admissible_run() {
984        // Maßnahme at 03:00. Two admissible runs: 00:00–01:00 (2 h away) and
985        // 04:00–05:00 (1 h away). The later one is nearer.
986        let mut kandidaten: Vec<VergleichsViertelstunde> =
987            (0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
988        kandidaten.extend((16..20).map(|i| vs(i * 15, 800.0, true, true)));
989
990        let z = vergleichszeitraum(
991            &kandidaten,
992            datetime!(2026-06-15 03:00 UTC),
993            datetime!(2026-06-15 03:00 UTC),
994            dec(1000.0),
995        )
996        .expect("an admissible run exists");
997        assert_eq!(z.lage, VergleichszeitraumLage::Danach);
998        assert_eq!(z.p_vz_ist_kw, dec(800.0));
999        assert_eq!(z.korrekturfaktor().unwrap(), dec(0.8));
1000    }
1001
1002    /// At equal distance the run before the Maßnahme wins.
1003    #[test]
1004    fn an_equal_distance_resolves_to_the_run_before() {
1005        // Maßnahme at 02:00: 00:00–01:00 ends 1 h before, 03:00–04:00 starts
1006        // 1 h after.
1007        let mut kandidaten: Vec<VergleichsViertelstunde> =
1008            (0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
1009        kandidaten.extend((12..16).map(|i| vs(i * 15, 800.0, true, true)));
1010
1011        let z = vergleichszeitraum(
1012            &kandidaten,
1013            datetime!(2026-06-15 02:00 UTC),
1014            datetime!(2026-06-15 02:00 UTC),
1015            dec(1000.0),
1016        )
1017        .expect("an admissible run exists");
1018        assert_eq!(z.lage, VergleichszeitraumLage::Davor);
1019        assert_eq!(z.p_vz_ist_kw, dec(900.0));
1020    }
1021
1022    /// Each of the three admissibility criteria alone disqualifies a run, and
1023    /// one bad quarter-hour breaks the contiguity the other three needed.
1024    #[test]
1025    fn one_inadmissible_quarter_hour_disqualifies_the_whole_run() {
1026        let massnahme = datetime!(2026-06-15 03:00 UTC);
1027        for spoil in [
1028            vs(30, 900.0, false, true), // not fully measured
1029            vs(30, 900.0, true, false), // feed-in was restricted
1030            vs(30, 99.0, true, true),   // below 10 % of the 1000 kW Nennleistung
1031        ] {
1032            let mut kandidaten: Vec<VergleichsViertelstunde> =
1033                (0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
1034            kandidaten[2] = spoil;
1035            assert_eq!(
1036                vergleichszeitraum(&kandidaten, massnahme, massnahme, dec(1000.0)),
1037                Err(AusfallarbeitError::KeinVergleichszeitraum)
1038            );
1039        }
1040
1041        // Exactly 10 % is admissible — „mindestens 10 %".
1042        let kandidaten: Vec<VergleichsViertelstunde> =
1043            (0..4).map(|i| vs(i * 15, 100.0, true, true)).collect();
1044        assert!(vergleichszeitraum(&kandidaten, massnahme, massnahme, dec(1000.0)).is_ok());
1045    }
1046
1047    /// A run in the Folgemonat is never used, however near it is.
1048    #[test]
1049    fn the_folgemonat_is_never_reached_into() {
1050        // Maßnahme on 30 June at 23:00; the only admissible run starts 1 July.
1051        let kandidaten: Vec<VergleichsViertelstunde> = (0..4)
1052            .map(|i| VergleichsViertelstunde {
1053                beginn: datetime!(2026-07-01 00:00 UTC) + time::Duration::minutes(i * 15),
1054                p_ist_kw: dec(900.0),
1055                p_theo_kw: dec(1000.0),
1056                vollstaendig_gemessen: true,
1057                unbeschraenkt: true,
1058            })
1059            .collect();
1060        assert_eq!(
1061            vergleichszeitraum(
1062                &kandidaten,
1063                datetime!(2026-06-30 23:00 UTC),
1064                datetime!(2026-06-30 23:45 UTC),
1065                dec(1000.0)
1066            ),
1067            Err(AusfallarbeitError::KeinVergleichszeitraum)
1068        );
1069    }
1070
1071    /// A gap in the series breaks contiguity even when every value qualifies.
1072    #[test]
1073    fn a_gap_breaks_contiguity() {
1074        let kandidaten = vec![
1075            vs(0, 900.0, true, true),
1076            vs(15, 900.0, true, true),
1077            vs(45, 900.0, true, true), // 30 minutes later, not 15
1078            vs(60, 900.0, true, true),
1079        ];
1080        assert_eq!(
1081            vergleichszeitraum(
1082                &kandidaten,
1083                datetime!(2026-06-15 03:00 UTC),
1084                datetime!(2026-06-15 03:00 UTC),
1085                dec(1000.0)
1086            ),
1087            Err(AusfallarbeitError::KeinVergleichszeitraum)
1088        );
1089    }
1090
1091    /// „vor oder nach der Viertelstunde, in der die Maßnahme **beginnt bzw.
1092    /// endet**" — the two sides are measured from two different anchors, and a
1093    /// long Maßnahme is where that shows.
1094    #[test]
1095    fn the_danach_side_is_measured_from_the_end_of_the_massnahme() {
1096        // A four-hour Maßnahme, 02:00–06:00. Admissible runs: 00:00–01:00,
1097        // which ends 1 h before it starts, and 06:00–07:00, which starts right
1098        // at its end. Measured correctly the later one wins at zero distance;
1099        // measured from the beginning it would be 4 h away and lose.
1100        let mut kandidaten: Vec<VergleichsViertelstunde> =
1101            (0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
1102        kandidaten.extend((24..28).map(|i| vs(i * 15, 800.0, true, true)));
1103
1104        let z = vergleichszeitraum(
1105            &kandidaten,
1106            datetime!(2026-06-15 02:00 UTC),
1107            datetime!(2026-06-15 06:00 UTC),
1108            dec(1000.0),
1109        )
1110        .expect("an admissible run exists");
1111        assert_eq!(z.lage, VergleichszeitraumLage::Danach);
1112        assert_eq!(z.p_vz_ist_kw, dec(800.0));
1113    }
1114
1115    // ── Kap. 3.2.4.1 — Solar-Vergleichstag ───────────────────────────────
1116
1117    fn tag_vs(
1118        tag: Date,
1119        stunde: u8,
1120        p_ist: f64,
1121        einstrahlung: f64,
1122        gestoert: bool,
1123    ) -> VergleichstagViertelstunde {
1124        VergleichstagViertelstunde {
1125            beginn: tag.with_hms(stunde, 0, 0).expect("valid time").assume_utc(),
1126            p_ist_kw: dec(p_ist),
1127            einstrahlung_kw_m2: dec(einstrahlung),
1128            nichtbeanspruchbar_oder_mba: gestoert,
1129        }
1130    }
1131
1132    /// Solar has a **calendar-day** Vergleichszeitraum, not the wind rule's four
1133    /// quarter-hours: the nearest day without a Maßnahme, ties to the day
1134    /// before, never from another month.
1135    #[test]
1136    fn the_solar_vergleichstag_is_the_nearest_day_without_a_massnahme() {
1137        let d = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
1138        let kandidaten = vec![
1139            tag_vs(d(12), 10, 900.0, 0.9, false),
1140            tag_vs(d(13), 10, 800.0, 0.8, false), // has its own Maßnahme
1141            tag_vs(d(16), 10, 700.0, 0.7, false),
1142        ];
1143        // Maßnahme on the 14th: the 13th is nearer but excluded, so the 12th
1144        // (2 days) beats the 16th (2 days) on the tie-break.
1145        let z = solar_vergleichstag(&kandidaten, d(14), &[d(13)], dec(1000.0))
1146            .expect("an admissible day exists");
1147        assert_eq!(z.tag, d(12));
1148        assert_eq!(z.lage, VergleichszeitraumLage::Davor);
1149        assert_eq!(z.p_vz_ist_kw, dec(900.0));
1150        assert_eq!(z.g_vz_kw_m2, dec(0.9));
1151    }
1152
1153    /// Only the quarter-hours that reach 10 % of the Nennleistung and carry no
1154    /// Nichtbeanspruchbarkeit or marktbedingte Anpassung enter the two means.
1155    #[test]
1156    fn a_dark_or_curtailed_quarter_hour_is_left_out_of_the_means() {
1157        let d = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
1158        let kandidaten = vec![
1159            tag_vs(d(12), 6, 50.0, 0.1, false),   // below 10 % of 1000 kW
1160            tag_vs(d(12), 10, 900.0, 0.9, false), // counted
1161            tag_vs(d(12), 11, 700.0, 0.7, true),  // marktbedingte Anpassung
1162            tag_vs(d(12), 12, 700.0, 0.7, false), // counted
1163        ];
1164        let z = solar_vergleichstag(&kandidaten, d(14), &[], dec(1000.0))
1165            .expect("an admissible day exists");
1166        assert_eq!(z.viertelstunden, 2);
1167        assert_eq!(z.p_vz_ist_kw, dec(800.0));
1168        assert_eq!(z.g_vz_kw_m2, dec(0.8));
1169    }
1170
1171    /// A day with nothing above 10 % is stepped over rather than ending the
1172    /// search — „zurückzugehen bis zu dem letzten Tag, an dem eine Viertelstunde
1173    /// mit mehr als 10 % Einspeisung stattgefunden hat".
1174    #[test]
1175    fn a_dark_day_is_stepped_over() {
1176        let d = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
1177        let kandidaten = vec![
1178            tag_vs(d(11), 10, 900.0, 0.9, false),
1179            tag_vs(d(13), 10, 20.0, 0.02, false), // the whole day is below 10 %
1180        ];
1181        let z = solar_vergleichstag(&kandidaten, d(14), &[], dec(1000.0))
1182            .expect("the dark day is skipped, not fatal");
1183        assert_eq!(z.tag, d(11));
1184    }
1185
1186    /// The Folgemonat is never reached into, however near it is.
1187    #[test]
1188    fn the_solar_vergleichstag_stays_in_the_month() {
1189        let im_juni = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
1190        let erster_juli = Date::from_calendar_date(2026, Month::July, 1).expect("valid date");
1191        let kandidaten = vec![tag_vs(erster_juli, 10, 900.0, 0.9, false)];
1192        assert_eq!(
1193            solar_vergleichstag(&kandidaten, im_juni(30), &[], dec(1000.0)),
1194            Err(AusfallarbeitError::KeinVergleichstag)
1195        );
1196    }
1197
1198    // ── Kap. 3.1 ─────────────────────────────────────────────────────────
1199
1200    #[test]
1201    fn leistungslimitierung_aufforderungsfall() {
1202        // Positiver Redispatch: min{P_ist; P_min}.
1203        let l = Leistungslimitierung::Aufforderung {
1204            p_ist: dec(800.0),
1205            vorgabe: dec(1000.0),
1206        };
1207        assert_eq!(l.wert(RedispatchRichtung::Positiv), dec(800.0));
1208        // Negativer Redispatch: max{P_ist; P_max}.
1209        let l = Leistungslimitierung::Aufforderung {
1210            p_ist: dec(300.0),
1211            vorgabe: dec(500.0),
1212        };
1213        assert_eq!(l.wert(RedispatchRichtung::Negativ), dec(500.0));
1214    }
1215
1216    #[test]
1217    fn leistungslimitierung_duldung_und_referenzprofil() {
1218        let d = Leistungslimitierung::Duldung { p_ist: dec(420.0) };
1219        assert_eq!(d.wert(RedispatchRichtung::Positiv), dec(420.0));
1220        assert_eq!(d.wert(RedispatchRichtung::Negativ), dec(420.0));
1221        let r = Leistungslimitierung::Referenzprofil {
1222            vorgabe: dec(500.0),
1223        };
1224        assert_eq!(r.wert(RedispatchRichtung::Negativ), dec(500.0));
1225    }
1226
1227    // ── Wind Spitz / KF ──────────────────────────────────────────────────
1228
1229    #[test]
1230    fn korrekturfaktor_ratio_and_divisor_guard() {
1231        assert_eq!(korrekturfaktor(dec(900.0), dec(1000.0)), Ok(dec(0.9)));
1232        assert_eq!(
1233            korrekturfaktor(dec(900.0), Decimal::ZERO),
1234            Err(AusfallarbeitError::UnzulaessigerDivisor("P_VZ,theo"))
1235        );
1236    }
1237
1238    #[test]
1239    fn wind_spitz_basic_and_nennleistung_cap() {
1240        // KF·P_theo = 0.9 × 2000 = 1800, P_lim 400 → (1800−400)/4 = 350 kWh.
1241        let mut input = WindSpitzInput {
1242            kf: dec(0.9),
1243            p_theo: dec(2000.0),
1244            p_mba: None,
1245            p_bean: None,
1246            p_lim: dec(400.0),
1247            p_nenn: dec(3000.0),
1248        };
1249        assert_eq!(wind_spitz(&input), dec(350.0));
1250        // Cap: KF·P_theo > P_nenn → begrenzt auf 3000 → (3000−400)/4 = 650.
1251        input.kf = dec(1.8);
1252        assert_eq!(wind_spitz(&input), dec(650.0));
1253        // P_bean binds below the product.
1254        input.kf = dec(0.9);
1255        input.p_bean = Some(dec(1000.0));
1256        assert_eq!(wind_spitz(&input), dec(150.0));
1257        // P_lim above everything → keine (negative) Ausfallarbeit, floor 0.
1258        input.p_lim = dec(2500.0);
1259        assert_eq!(wind_spitz(&input), Decimal::ZERO);
1260    }
1261
1262    #[test]
1263    fn wind_pauschal_fortschreibung() {
1264        // min(P_0=1200; P_inst=2000) − P_lim=200 → 1000/4 = 250 kWh.
1265        assert_eq!(
1266            wind_pauschal(dec(1200.0), dec(2000.0), None, None, dec(200.0)),
1267            dec(250.0)
1268        );
1269        // P_inst binds: min(2500; 2000) − 200 → 450.
1270        assert_eq!(
1271            wind_pauschal(dec(2500.0), dec(2000.0), None, None, dec(200.0)),
1272            dec(450.0)
1273        );
1274    }
1275
1276    // ── Wind-Bin ─────────────────────────────────────────────────────────
1277
1278    #[test]
1279    fn wind_bin_index_centres_on_half_ms() {
1280        assert_eq!(wind_bin_index(dec(0.0)), 0);
1281        assert_eq!(wind_bin_index(dec(7.6)), 15); // 7.6/0.5 = 15.2 → bin 15 (7.5 m/s)
1282        assert_eq!(wind_bin_index(dec(7.74)), 15);
1283        assert_eq!(wind_bin_index(dec(7.8)), 16);
1284    }
1285
1286    #[test]
1287    fn kf_lbin_requires_three_wertepaare_and_clamps_at_zero() {
1288        assert_eq!(
1289            kf_lbin(&[dec(900.0), dec(950.0)], dec(1000.0)),
1290            Err(AusfallarbeitError::BinUnterbesetzt(2))
1291        );
1292        let kf = kf_lbin(&[dec(900.0), dec(950.0), dec(1000.0)], dec(1000.0)).unwrap();
1293        assert_eq!(kf, dec(0.95));
1294        // Negative mean (e.g. Eigenverbrauch artefacts) clamps to ≥ 0.
1295        let kf = kf_lbin(&[dec(-10.0), dec(-20.0), dec(-30.0)], dec(1000.0)).unwrap();
1296        assert_eq!(kf, Decimal::ZERO);
1297    }
1298
1299    #[test]
1300    fn kf_lbin_ersatzwert_chain_order() {
1301        assert_eq!(
1302            kf_lbin_ersatzwert(Some(dec(0.9)), Some(dec(0.8)), Some(dec(0.7))),
1303            (dec(0.9), KfLbinQuelle::Vormonat)
1304        );
1305        assert_eq!(
1306            kf_lbin_ersatzwert(None, Some(dec(0.8)), Some(dec(0.7))),
1307            (dec(0.8), KfLbinQuelle::Folgemonat)
1308        );
1309        assert_eq!(
1310            kf_lbin_ersatzwert(None, None, Some(dec(0.7))),
1311            (dec(0.7), KfLbinQuelle::ZwoelfMonatsMittel)
1312        );
1313        assert_eq!(
1314            kf_lbin_ersatzwert(None, None, None),
1315            (Decimal::ONE, KfLbinQuelle::Standard)
1316        );
1317    }
1318
1319    #[test]
1320    fn verlustfaktor_domain() {
1321        assert_eq!(verlustfaktor(dec(970.0), dec(1000.0)), Ok(dec(0.97)));
1322        // KF_V = 1 (E_Einsp == ΣE_WEA) is outside ]0;1[.
1323        assert!(matches!(
1324            verlustfaktor(dec(1000.0), dec(1000.0)),
1325            Err(AusfallarbeitError::VerlustfaktorAusserhalb(_))
1326        ));
1327        assert!(matches!(
1328            verlustfaktor(dec(1100.0), dec(1000.0)),
1329            Err(AusfallarbeitError::VerlustfaktorAusserhalb(_))
1330        ));
1331        assert!(matches!(
1332            verlustfaktor(dec(0.0), dec(1000.0)),
1333            Err(AusfallarbeitError::VerlustfaktorAusserhalb(_))
1334        ));
1335    }
1336
1337    #[test]
1338    fn wind_bin_composes_into_spitz_formula() {
1339        let kf = kf_bin(dec(0.95), dec(0.97));
1340        let input = WindSpitzInput {
1341            kf,
1342            p_theo: dec(1000.0),
1343            p_mba: None,
1344            p_bean: None,
1345            p_lim: dec(121.5),
1346            p_nenn: dec(5000.0),
1347        };
1348        // 0.9215 × 1000 − 121.5 = 800 → 200 kWh.
1349        assert_eq!(wind_spitz(&input), dec(200.0));
1350    }
1351
1352    // ── Solar ────────────────────────────────────────────────────────────
1353
1354    #[test]
1355    fn solar_spitz_scales_by_irradiation() {
1356        // P_VZ,ist/G_VZ = 800/0.4 = 2000 kW per kW/m²; G_i = 0.6 → 1200 kW.
1357        let input = SolarSpitzInput {
1358            p_vz_ist: dec(800.0),
1359            g_vz: dec(0.4),
1360            g_i: dec(0.6),
1361            p_wr: dec(1500.0),
1362            p_mba: None,
1363            p_bean: None,
1364            p_lim: dec(200.0),
1365            p_nenn: dec(1400.0),
1366        };
1367        // theo = min(1200, 1400) = 1200; min(1200, P_WR 1500) = 1200 → 250 kWh.
1368        assert_eq!(solar_spitz(&input).unwrap(), dec(250.0));
1369        // Wechselrichter binds.
1370        let engpass = SolarSpitzInput {
1371            p_wr: dec(1000.0),
1372            ..input
1373        };
1374        assert_eq!(solar_spitz(&engpass).unwrap(), dec(200.0));
1375        // G_VZ = 0 guarded.
1376        let kaputt = SolarSpitzInput {
1377            g_vz: Decimal::ZERO,
1378            ..input
1379        };
1380        assert_eq!(
1381            solar_spitz(&kaputt),
1382            Err(AusfallarbeitError::UnzulaessigerDivisor("G_VZ"))
1383        );
1384    }
1385
1386    #[test]
1387    fn anlagenfaktor_table_summer_winter() {
1388        // Sommer midday.
1389        assert_eq!(
1390            anlagenfaktor(date!(2027 - 06 - 15), time!(12:00)),
1391            dec(0.6189)
1392        );
1393        // Sommer morning shoulder, boundary inclusion 06:00.
1394        assert_eq!(
1395            anlagenfaktor(date!(2027 - 06 - 15), time!(06:00)),
1396            dec(0.2456)
1397        );
1398        // Sommer boundary 09:00 belongs to the midday band.
1399        assert_eq!(
1400            anlagenfaktor(date!(2027 - 06 - 15), time!(09:00)),
1401            dec(0.6189)
1402        );
1403        // Sommer night.
1404        assert_eq!(
1405            anlagenfaktor(date!(2027 - 06 - 15), time!(19:00)),
1406            Decimal::ZERO
1407        );
1408        // Winter midday and shoulders.
1409        assert_eq!(
1410            anlagenfaktor(date!(2027 - 01 - 15), time!(12:00)),
1411            dec(0.5030)
1412        );
1413        assert_eq!(
1414            anlagenfaktor(date!(2027 - 01 - 15), time!(09:15)),
1415            dec(0.2796)
1416        );
1417        assert_eq!(
1418            anlagenfaktor(date!(2027 - 01 - 15), time!(16:30)),
1419            dec(0.2796)
1420        );
1421        assert_eq!(
1422            anlagenfaktor(date!(2027 - 01 - 15), time!(16:45)),
1423            Decimal::ZERO
1424        );
1425        // Season boundaries: 01.03. is Sommer, 01.11. is Winter.
1426        assert_eq!(
1427            anlagenfaktor(date!(2027 - 03 - 01), time!(12:00)),
1428            dec(0.6189)
1429        );
1430        assert_eq!(
1431            anlagenfaktor(date!(2027 - 11 - 01), time!(12:00)),
1432            dec(0.5030)
1433        );
1434    }
1435
1436    #[test]
1437    fn solar_pauschal_uses_af_and_wr() {
1438        let af = anlagenfaktor(date!(2027 - 06 - 15), time!(12:00)); // 0.6189
1439        // min(0.6189 × 1000 = 618.9; P_WR 600) − P_lim 100 → 500/4 = 125 kWh.
1440        assert_eq!(
1441            solar_pauschal(af, dec(1000.0), dec(600.0), None, None, dec(100.0)),
1442            dec(125.0)
1443        );
1444    }
1445
1446    // ── Nicht-fluktuierend ───────────────────────────────────────────────
1447
1448    #[test]
1449    fn nichtfluktuierend_spitz_sign_convention() {
1450        // Negativer Redispatch: Plan 1000, Limit 400 → +150 kWh.
1451        assert_eq!(
1452            nichtfluktuierend_spitz(RedispatchRichtung::Negativ, dec(1000.0), dec(400.0)),
1453            dec(150.0)
1454        );
1455        // Positiver Redispatch (Mehrarbeit): Plan 400, Limit 1000 → −150 kWh.
1456        assert_eq!(
1457            nichtfluktuierend_spitz(RedispatchRichtung::Positiv, dec(400.0), dec(1000.0)),
1458            dec(-150.0)
1459        );
1460        // Clamps: no positive W_A on positive Redispatch and vice versa.
1461        assert_eq!(
1462            nichtfluktuierend_spitz(RedispatchRichtung::Positiv, dec(1000.0), dec(400.0)),
1463            Decimal::ZERO
1464        );
1465        assert_eq!(
1466            nichtfluktuierend_spitz(RedispatchRichtung::Negativ, dec(400.0), dec(1000.0)),
1467            Decimal::ZERO
1468        );
1469    }
1470
1471    #[test]
1472    fn nichtfluktuierend_pauschal_final_formulas() {
1473        // Negativ: min(P_0 1200; P_bean 1100) − P_lim 300 → 200 kWh.
1474        assert_eq!(
1475            nichtfluktuierend_pauschal(
1476                RedispatchRichtung::Negativ,
1477                dec(1200.0),
1478                Some(dec(1100.0)),
1479                dec(300.0)
1480            ),
1481            dec(200.0)
1482        );
1483        // Positiv: P_0 400 − min(P_lim 1000; P_bean 800) → (400−800)/4 = −100.
1484        assert_eq!(
1485            nichtfluktuierend_pauschal(
1486                RedispatchRichtung::Positiv,
1487                dec(400.0),
1488                Some(dec(800.0)),
1489                dec(1000.0)
1490            ),
1491            dec(-100.0)
1492        );
1493        // Ohne P_bean: plain Fortschreibungsdifferenz.
1494        assert_eq!(
1495            nichtfluktuierend_pauschal(RedispatchRichtung::Negativ, dec(1200.0), None, dec(300.0)),
1496            dec(225.0)
1497        );
1498    }
1499
1500    // ── Überbauung ───────────────────────────────────────────────────────
1501
1502    #[test]
1503    fn ueberbauung_no_cut_when_under_cap() {
1504        let trs = [
1505            UeberbauungTr {
1506                w_a_kwh: dec(100.0),
1507                p_inst_kw: dec(2000.0),
1508            },
1509            UeberbauungTr {
1510                w_a_kwh: dec(50.0),
1511                p_inst_kw: dec(1000.0),
1512            },
1513        ];
1514        // Cap: 1000 kW × ¼ h − 50 kWh Einspeisung = 200 kWh ≥ 150 → untouched.
1515        let out = ueberbauung_kuerzung(&trs, dec(1000.0), dec(50.0)).unwrap();
1516        assert_eq!(out, vec![dec(100.0), dec(50.0)]);
1517    }
1518
1519    #[test]
1520    fn ueberbauung_pro_rata_by_installed_capacity() {
1521        let trs = [
1522            UeberbauungTr {
1523                w_a_kwh: dec(100.0),
1524                p_inst_kw: dec(2000.0),
1525            },
1526            UeberbauungTr {
1527                w_a_kwh: dec(50.0),
1528                p_inst_kw: dec(1000.0),
1529            },
1530        ];
1531        // Cap: 400 kW × ¼ h − 10 kWh = 90 kWh; Überschuss 60 kWh split 2:1.
1532        let out = ueberbauung_kuerzung(&trs, dec(400.0), dec(10.0)).unwrap();
1533        assert_eq!(out, vec![dec(60.0), dec(30.0)]);
1534        assert_eq!(out.iter().copied().sum::<Decimal>(), dec(90.0));
1535    }
1536
1537    #[test]
1538    fn ueberbauung_clamps_at_zero_and_redistributes() {
1539        let trs = [
1540            UeberbauungTr {
1541                w_a_kwh: dec(10.0),
1542                p_inst_kw: dec(3000.0),
1543            },
1544            UeberbauungTr {
1545                w_a_kwh: dec(200.0),
1546                p_inst_kw: dec(1000.0),
1547            },
1548        ];
1549        // Cap 100 kWh, Überschuss 110. Pro-rata cut for TR1 = 82.5 > 10 →
1550        // TR1 clamps to 0, the rest of the Kürzung lands on TR2 → 100.
1551        let out = ueberbauung_kuerzung(&trs, dec(400.0), Decimal::ZERO).unwrap();
1552        assert_eq!(out[0], Decimal::ZERO);
1553        assert_eq!(out[1], dec(100.0));
1554        assert_eq!(out.iter().copied().sum::<Decimal>(), dec(100.0));
1555    }
1556
1557    // ── MaLo → TR split ──────────────────────────────────────────────────
1558
1559    #[test]
1560    fn malo_split_pro_rata() {
1561        let out = malo_wert_auf_tr(dec(900.0), &[dec(2000.0), dec(1000.0)]).unwrap();
1562        assert_eq!(out, vec![dec(600.0), dec(300.0)]);
1563        assert!(malo_wert_auf_tr(dec(900.0), &[]).is_err());
1564    }
1565}