Skip to main content

mako_redispatch/
bilarem.rs

1//! `BilAReM` — Bilanzieller Ausgleich von Redispatch-Maßnahmen (BK6-23-241).
2//!
3//! Festlegung BK6-23-241 (Beschluss 07.05.2026) consolidates Redispatch 2.0:
4//! Tenor Ziff. 1 puts the ÜNB bilanzieller Ausgleich per §13a Abs. 1a `EnWG` on
5//! the `BilAReM` rules **from 01.07.2026**; Ziff. 3/4 revoke BK6-20-060/-061;
6//! Ziff. 5 revokes `MaBiS` Anlage 1 Kap. 17 effective 30.09.2026 (the surviving
7//! 17.1/17.3 content continues as "Anlage zur `BilAReM`" from 01.10.2026).
8//!
9//! Two settlement models coexist per Steuerbare Ressource (SR):
10//!
11//! - **Planwertmodell** — the anweisende Netzbetreiber performs the
12//!   bilanzielle Ausgleich against the BKV, sized from the geplante Fahrweise
13//!   (last ex-ante planning data before the Abruf), executed via
14//!   korrespondierende Fahrpläne between the NB's dedicated
15//!   Redispatch-Bilanzkreis and the betroffener Bilanzkreis. For fluctuating
16//!   plants the residual between actual Ausfallarbeit and the plan-based
17//!   Ausgleich is settled **financially only** (see
18//!   [`grid_billing`-side `bilarem_finanzielle_korrektur`]).
19//! - **Prognosemodell** — no NB-side bilanzieller Ausgleich; the imbalance
20//!   stays with the BKV (§14 Abs. 1 S. 3 `EnWG`, befristet bis 31.12.2031), who
21//!   receives Aufwendungsersatz from the NB per §14 Abs. 1b `EnWG` (amount not
22//!   standardised in `BilAReM`).
23//!
24//! Migration Prognose → Planwert is one-way, per SR, effective only at
25//! quarter boundaries with ≥6 months notice (Zuordnungsmitteilung ANB →
26//! LF/EIV/BTR). Soll-target: transmission-grid-relevant SR migrated by
27//! 01.01.2031; the statutory Prognosemodell window ends 31.12.2031.
28//!
29//! The EDI@Energy wire formats for `BilAReM` are published by the expert group
30//! on **relative** deadlines (no calendar date in the Tenor); this module is
31//! deliberately wire-format-free — the seam the formats will plug into.
32
33use time::{Date, Month, macros::date};
34
35/// `BilAReM` rules apply to the ÜNB bilanzieller Ausgleich from this day
36/// (BK6-23-241 Tenor Ziff. 1).
37pub const BILAREM_WIRKSAM: Date = date!(2026 - 07 - 01);
38
39/// `MaBiS` Anlage 1 Kap. 17 is revoked with the end of this day (Tenor Ziff. 5);
40/// surviving content continues as "Anlage zur `BilAReM`" from 01.10.2026.
41pub const MABIS_ANLAGE1_KAP17_ENDE: Date = date!(2026 - 09 - 30);
42
43/// Grandfathered Pauschal-Abrechnung ends with this day; from 01.01.2029 those
44/// TR fall into vereinfachte Spitzabrechnung unless Spitzabrechnung was
45/// elected by 30.11.2028 (`BilAReM` Kap. 3.2.1).
46pub const PAUSCHAL_ABRECHNUNG_ENDE: Date = date!(2028 - 12 - 31);
47
48/// Election deadline for grandfathered TR choosing Spitzabrechnung over the
49/// vereinfachte Spitzabrechnung default.
50pub const SPITZ_WAHL_FRIST: Date = date!(2028 - 11 - 30);
51
52/// Soll-target: SR that improve transmission-grid congestion-relief efficiency
53/// are to be in the Planwertmodell by this day.
54pub const MIGRATION_SOLL_ZIEL: Date = date!(2031 - 01 - 01);
55
56/// End of the statutory Prognosemodell/BKV window (§14 Abs. 1 S. 3 `EnWG`,
57/// befristet bis 31.12.2031). From 2032 the NB performs the full Ausgleich.
58pub const PROGNOSEMODELL_ENDE: Date = date!(2031 - 12 - 31);
59
60/// Settlement model of a Steuerbare Ressource for the bilanzieller Ausgleich.
61///
62/// Each SR is in exactly one model; clusters must be model-pure. Migration is
63/// one-way (Prognose → Planwert only).
64#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum Bilanzierungsmodell {
67    /// NB-side Ausgleich via korrespondierende Fahrpläne (`BilAReM` Kap. 2.1).
68    Planwertmodell,
69    /// BKV keeps the imbalance; NB owes Aufwendungsersatz (§14 Abs. 1b `EnWG`).
70    Prognosemodell,
71}
72
73/// Ausfallarbeit settlement method of a Technische Ressource.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum Abrechnungsverfahren {
77    /// Spitzabrechnung (measured; KF/Wind-Bin variants per `BilAReM` Kap. 3).
78    Spitz,
79    /// Vereinfachte Spitzabrechnung.
80    VereinfachteSpitz,
81    /// Pauschal — grandfathered TR only, until 31.12.2028.
82    Pauschal,
83}
84
85impl Abrechnungsverfahren {
86    /// Whether this method is admissible for a TR on `date`.
87    ///
88    /// `grandfathered` = the TR was in Pauschal-Abrechnung at the
89    /// Bekanntmachung of BK6-23-241. New TR can never elect Pauschal, and TR
90    /// in the Planwertmodell must use Spitz-/vereinfachte Spitzabrechnung.
91    #[must_use]
92    pub fn admissible(self, date: Date, grandfathered: bool, modell: Bilanzierungsmodell) -> bool {
93        match self {
94            Self::Spitz | Self::VereinfachteSpitz => true,
95            Self::Pauschal => {
96                grandfathered
97                    && date <= PAUSCHAL_ABRECHNUNG_ENDE
98                    && modell == Bilanzierungsmodell::Prognosemodell
99            }
100        }
101    }
102
103    /// The default method a grandfathered Pauschal TR falls into from
104    /// 01.01.2029 when no Spitzabrechnung election was made by 30.11.2028.
105    #[must_use]
106    pub const fn post_pauschal_default() -> Self {
107        Self::VereinfachteSpitz
108    }
109}
110
111/// Errors validating a model migration (Zuordnungsmitteilung).
112#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
113pub enum MigrationError {
114    /// Effective date is not a quarter boundary (01.01/01.04/01.07/01.10).
115    #[error("Wirksamkeitsdatum {0} ist kein Quartalsbeginn (01.01/01.04/01.07/01.10)")]
116    KeinQuartalsbeginn(Date),
117    /// Less than 6 months between notice and effective date.
118    #[error("Ankündigungsfrist unterschritten: {notice} → {effective} (< 6 Monate)")]
119    FristUnterschritten {
120        /// Notice date.
121        notice: Date,
122        /// Requested effective date.
123        effective: Date,
124    },
125    /// Planwert → Prognose is not permitted (one-way migration).
126    #[error("Rückkehr vom Planwert- ins Prognosemodell ist unzulässig")]
127    KeinWegZurueck,
128}
129
130/// Zuordnungsmitteilung: the ANB announces an SR's migration into the
131/// Planwertmodell to LF/EIV/BTR (`BilAReM` Kap. 2.3).
132#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
133pub struct Zuordnungsmitteilung {
134    /// The Steuerbare Ressource being migrated.
135    pub sr_id: String,
136    /// The NB's dedicated Redispatch-Bilanzkreis (exactly one per NB).
137    pub redispatch_bilanzkreis: String,
138    /// Day the notice was issued.
139    pub mitteilungsdatum: Date,
140    /// Requested effective date (quarter boundary, ≥6 months out).
141    pub wirksam_ab: Date,
142}
143
144impl Zuordnungsmitteilung {
145    /// Validate the migration per `BilAReM` Kap. 2.3: quarter-boundary
146    /// effectiveness, ≥6 months notice, one-way only.
147    ///
148    /// # Errors
149    ///
150    /// Returns the first violated [`MigrationError`].
151    ///
152    /// # Panics
153    ///
154    /// Never in practice: the six-month arithmetic keeps the month in
155    /// `1..=12` and the clamped day always exists in the target month.
156    pub fn validate(&self, current: Bilanzierungsmodell) -> Result<(), MigrationError> {
157        if current == Bilanzierungsmodell::Planwertmodell {
158            return Err(MigrationError::KeinWegZurueck);
159        }
160        let d = self.wirksam_ab;
161        let is_quarter_start = d.day() == 1
162            && matches!(
163                d.month(),
164                Month::January | Month::April | Month::July | Month::October
165            );
166        if !is_quarter_start {
167            return Err(MigrationError::KeinQuartalsbeginn(d));
168        }
169        // ≥ 6 months notice: effective date must be on/after notice + 6 months.
170        let mut y = self.mitteilungsdatum.year();
171        let mut m = self.mitteilungsdatum.month() as u8 + 6;
172        if m > 12 {
173            m -= 12;
174            y += 1;
175        }
176        let month = Month::try_from(m).expect("1..=12");
177        let day = self.mitteilungsdatum.day().min(month.length(y));
178        let earliest = Date::from_calendar_date(y, month, day).expect("valid date");
179        if d < earliest {
180            return Err(MigrationError::FristUnterschritten {
181                notice: self.mitteilungsdatum,
182                effective: d,
183            });
184        }
185        Ok(())
186    }
187}
188
189// ── Abstimmung der Ausfallarbeit (Kap. 6.4.3) ───────────────────────────────
190
191/// Why an Ausfallarbeits-Abstimmung may not be started or continued.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
193pub enum AbstimmungError {
194    /// The window closed. Kap. 6.4.3: „Danach dürfen die Prozesse zur
195    /// Abstimmung der Ausfallarbeit **nicht erneut gestartet** werden."
196    #[error(
197        "Abstimmungsfenster geschlossen: Maßnahme endete {massnahme_ende}, \
198         Frist lief am {frist} ab (BilAReM Kap. 6.4.3)"
199    )]
200    FensterGeschlossen {
201        /// Day the Redispatch-Maßnahme ended.
202        massnahme_ende: Date,
203        /// Last day the Abstimmung could run.
204        frist: Date,
205    },
206}
207
208/// Whether the Ausfallarbeit of a Maßnahme ending on `massnahme_ende` may still
209/// be adjusted on `heute` (`BilAReM` Kap. 6.4.3).
210///
211/// The window is a **hard stop**, not a target: once the end of the third
212/// following month has passed, the figure that stands is either the agreed one
213/// or the formally established Dissens, and neither side may reopen it. A
214/// system that keeps accepting corrections afterwards produces settlements the
215/// counterparty is entitled to refuse.
216///
217/// # Errors
218///
219/// [`AbstimmungError::FensterGeschlossen`], naming both dates.
220pub fn abstimmung_zulaessig(massnahme_ende: Date, heute: Date) -> Result<(), AbstimmungError> {
221    let frist = crate::fristen::ausfallarbeit_endet_am(massnahme_ende);
222    if heute > frist {
223        return Err(AbstimmungError::FensterGeschlossen {
224            massnahme_ende,
225            frist,
226        });
227    }
228    Ok(())
229}
230
231// ── Zuordnung einer neu eingerichteten SR (Kap. 2.3.2) ──────────────────────
232
233/// Latest day the ANB may notify the Bilanzierungsmodell of a **newly created**
234/// SR (`BilAReM` Kap. 2.3.2).
235///
236/// Two cases, and the second is the one that is easy to miss:
237///
238/// - The BTR or EIV gave the ANB everything it needed at least ten Werktage
239///   before the planned Inbetriebnahme → the notice is due **five Werktage
240///   before** that date.
241/// - They did not → the notice is due **five Werktage after** the information
242///   was complete, which can fall *after* the Inbetriebnahme. Late information
243///   moves the ANB's deadline; it does not remove it.
244///
245/// The Zuordnung takes effect with the Inbetriebnahme of the first TR assigned
246/// to the SR, regardless of which case applied.
247#[must_use]
248pub fn neue_sr_mitteilung_spaetestens(
249    geplante_inbetriebnahme: Date,
250    information_vollstaendig_am: Date,
251    kalender: mako_fristen::HolidayCalendar,
252) -> Date {
253    let rechtzeitig = mako_fristen::sub_werktage(
254        geplante_inbetriebnahme,
255        crate::fristen::PLANWERT_NEUE_SR_INFORMATION_WERKTAGE,
256        kalender,
257    );
258    if information_vollstaendig_am <= rechtzeitig {
259        mako_fristen::sub_werktage(
260            geplante_inbetriebnahme,
261            crate::fristen::PLANWERT_NEUE_SR_MITTEILUNG_WERKTAGE,
262            kalender,
263        )
264    } else {
265        mako_fristen::add_werktage(
266            information_vollstaendig_am,
267            crate::fristen::PLANWERT_NEUE_SR_MITTEILUNG_WERKTAGE,
268            kalender,
269        )
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use time::macros::date;
277
278    fn mitteilung(notice: Date, effective: Date) -> Zuordnungsmitteilung {
279        Zuordnungsmitteilung {
280            sr_id: "SR-1".into(),
281            redispatch_bilanzkreis: "11XRD-NB-00001-L".into(),
282            mitteilungsdatum: notice,
283            wirksam_ab: effective,
284        }
285    }
286
287    #[test]
288    fn migration_requires_quarter_boundary() {
289        let m = mitteilung(date!(2026 - 08 - 01), date!(2027 - 05 - 01));
290        assert_eq!(
291            m.validate(Bilanzierungsmodell::Prognosemodell),
292            Err(MigrationError::KeinQuartalsbeginn(date!(2027 - 05 - 01)))
293        );
294    }
295
296    #[test]
297    fn migration_requires_six_months_notice() {
298        let m = mitteilung(date!(2026 - 08 - 15), date!(2027 - 01 - 01));
299        assert!(matches!(
300            m.validate(Bilanzierungsmodell::Prognosemodell),
301            Err(MigrationError::FristUnterschritten { .. })
302        ));
303        let ok = mitteilung(date!(2026 - 08 - 15), date!(2027 - 04 - 01));
304        assert_eq!(ok.validate(Bilanzierungsmodell::Prognosemodell), Ok(()));
305    }
306
307    #[test]
308    fn migration_is_one_way() {
309        let m = mitteilung(date!(2026 - 08 - 01), date!(2027 - 04 - 01));
310        assert_eq!(
311            m.validate(Bilanzierungsmodell::Planwertmodell),
312            Err(MigrationError::KeinWegZurueck)
313        );
314    }
315
316    #[test]
317    fn pauschal_only_for_grandfathered_prognose_tr_until_2028() {
318        use Abrechnungsverfahren as A;
319        use Bilanzierungsmodell as B;
320        // Grandfathered TR in the Prognosemodell: Pauschal admissible until end-2028.
321        assert!(A::Pauschal.admissible(date!(2028 - 12 - 31), true, B::Prognosemodell));
322        // From 2029: no longer admissible.
323        assert!(!A::Pauschal.admissible(date!(2029 - 01 - 01), true, B::Prognosemodell));
324        // New TR: never.
325        assert!(!A::Pauschal.admissible(date!(2027 - 01 - 01), false, B::Prognosemodell));
326        // Planwertmodell TR: never Pauschal.
327        assert!(!A::Pauschal.admissible(date!(2027 - 01 - 01), true, B::Planwertmodell));
328        // Spitz variants always admissible.
329        assert!(A::Spitz.admissible(date!(2032 - 01 - 01), false, B::Planwertmodell));
330        assert!(A::VereinfachteSpitz.admissible(date!(2029 - 01 - 01), true, B::Prognosemodell));
331        assert_eq!(A::post_pauschal_default(), A::VereinfachteSpitz);
332    }
333
334    #[test]
335    fn the_ausfallarbeit_window_is_a_hard_stop() {
336        // Kap. 6.4.3 — a Maßnahme ending in January closes at the end of April.
337        let ende = date!(2026 - 01 - 20);
338        assert!(abstimmung_zulaessig(ende, date!(2026 - 04 - 30)).is_ok());
339        assert!(matches!(
340            abstimmung_zulaessig(ende, date!(2026 - 05 - 01)),
341            Err(AbstimmungError::FensterGeschlossen { .. })
342        ));
343    }
344
345    #[test]
346    fn late_information_moves_the_new_sr_deadline_instead_of_removing_it() {
347        use mako_fristen::HolidayCalendar::BdewMaKo;
348        let ibn = date!(2027 - 03 - 15);
349        // Information complete well ahead → five Werktage before the IBN.
350        let rechtzeitig = neue_sr_mitteilung_spaetestens(ibn, date!(2027 - 01 - 04), BdewMaKo);
351        assert_eq!(
352            rechtzeitig,
353            mako_fristen::sub_werktage(ibn, 5, BdewMaKo),
354            "the ordinary case is anchored on the Inbetriebnahme"
355        );
356        assert!(rechtzeitig < ibn);
357
358        // Information complete only two days before → five Werktage after that,
359        // which lands *after* the Inbetriebnahme. The obligation survives.
360        let spaet = neue_sr_mitteilung_spaetestens(ibn, date!(2027 - 03 - 13), BdewMaKo);
361        assert_eq!(
362            spaet,
363            mako_fristen::add_werktage(date!(2027 - 03 - 13), 5, BdewMaKo)
364        );
365        assert!(spaet > ibn);
366    }
367}