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};
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 = match Date::from_calendar_date(2026, Month::July, 1) {
38    Ok(d) => d,
39    Err(_) => panic!("valid date"),
40};
41
42/// `MaBiS` Anlage 1 Kap. 17 is revoked with the end of this day (Tenor Ziff. 5);
43/// surviving content continues as "Anlage zur `BilAReM`" from 01.10.2026.
44pub const MABIS_ANLAGE1_KAP17_ENDE: Date =
45    match Date::from_calendar_date(2026, Month::September, 30) {
46        Ok(d) => d,
47        Err(_) => panic!("valid date"),
48    };
49
50/// Grandfathered Pauschal-Abrechnung ends with this day; from 01.01.2029 those
51/// TR fall into vereinfachte Spitzabrechnung unless Spitzabrechnung was
52/// elected by 30.11.2028 (`BilAReM` Kap. 3.2.1).
53pub const PAUSCHAL_ABRECHNUNG_ENDE: Date = match Date::from_calendar_date(2028, Month::December, 31)
54{
55    Ok(d) => d,
56    Err(_) => panic!("valid date"),
57};
58
59/// Election deadline for grandfathered TR choosing Spitzabrechnung over the
60/// vereinfachte Spitzabrechnung default.
61pub const SPITZ_WAHL_FRIST: Date = match Date::from_calendar_date(2028, Month::November, 30) {
62    Ok(d) => d,
63    Err(_) => panic!("valid date"),
64};
65
66/// Soll-target: SR that improve transmission-grid congestion-relief efficiency
67/// are to be in the Planwertmodell by this day.
68pub const MIGRATION_SOLL_ZIEL: Date = match Date::from_calendar_date(2031, Month::January, 1) {
69    Ok(d) => d,
70    Err(_) => panic!("valid date"),
71};
72
73/// End of the statutory Prognosemodell/BKV window (§14 Abs. 1 S. 3 `EnWG`,
74/// befristet bis 31.12.2031). From 2032 the NB performs the full Ausgleich.
75pub const PROGNOSEMODELL_ENDE: Date = match Date::from_calendar_date(2031, Month::December, 31) {
76    Ok(d) => d,
77    Err(_) => panic!("valid date"),
78};
79
80/// Settlement model of a Steuerbare Ressource for the bilanzieller Ausgleich.
81///
82/// Each SR is in exactly one model; clusters must be model-pure. Migration is
83/// one-way (Prognose → Planwert only).
84#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum Bilanzierungsmodell {
87    /// NB-side Ausgleich via korrespondierende Fahrpläne (`BilAReM` Kap. 2.1).
88    Planwertmodell,
89    /// BKV keeps the imbalance; NB owes Aufwendungsersatz (§14 Abs. 1b `EnWG`).
90    Prognosemodell,
91}
92
93/// Ausfallarbeit settlement method of a Technische Ressource.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum Abrechnungsverfahren {
97    /// Spitzabrechnung (measured; KF/Wind-Bin variants per `BilAReM` Kap. 3).
98    Spitz,
99    /// Vereinfachte Spitzabrechnung.
100    VereinfachteSpitz,
101    /// Pauschal — grandfathered TR only, until 31.12.2028.
102    Pauschal,
103}
104
105impl Abrechnungsverfahren {
106    /// Whether this method is admissible for a TR on `date`.
107    ///
108    /// `grandfathered` = the TR was in Pauschal-Abrechnung at the
109    /// Bekanntmachung of BK6-23-241. New TR can never elect Pauschal, and TR
110    /// in the Planwertmodell must use Spitz-/vereinfachte Spitzabrechnung.
111    #[must_use]
112    pub fn admissible(self, date: Date, grandfathered: bool, modell: Bilanzierungsmodell) -> bool {
113        match self {
114            Self::Spitz | Self::VereinfachteSpitz => true,
115            Self::Pauschal => {
116                grandfathered
117                    && date <= PAUSCHAL_ABRECHNUNG_ENDE
118                    && modell == Bilanzierungsmodell::Prognosemodell
119            }
120        }
121    }
122
123    /// The default method a grandfathered Pauschal TR falls into from
124    /// 01.01.2029 when no Spitzabrechnung election was made by 30.11.2028.
125    #[must_use]
126    pub const fn post_pauschal_default() -> Self {
127        Self::VereinfachteSpitz
128    }
129}
130
131/// Errors validating a model migration (Zuordnungsmitteilung).
132#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
133pub enum MigrationError {
134    /// Effective date is not a quarter boundary (01.01/01.04/01.07/01.10).
135    #[error("Wirksamkeitsdatum {0} ist kein Quartalsbeginn (01.01/01.04/01.07/01.10)")]
136    KeinQuartalsbeginn(Date),
137    /// Less than 6 months between notice and effective date.
138    #[error("Ankündigungsfrist unterschritten: {notice} → {effective} (< 6 Monate)")]
139    FristUnterschritten {
140        /// Notice date.
141        notice: Date,
142        /// Requested effective date.
143        effective: Date,
144    },
145    /// Planwert → Prognose is not permitted (one-way migration).
146    #[error("Rückkehr vom Planwert- ins Prognosemodell ist unzulässig")]
147    KeinWegZurueck,
148}
149
150/// Zuordnungsmitteilung: the ANB announces an SR's migration into the
151/// Planwertmodell to LF/EIV/BTR (`BilAReM` Kap. 2.3).
152#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
153pub struct Zuordnungsmitteilung {
154    /// The Steuerbare Ressource being migrated.
155    pub sr_id: String,
156    /// The NB's dedicated Redispatch-Bilanzkreis (exactly one per NB).
157    pub redispatch_bilanzkreis: String,
158    /// Day the notice was issued.
159    pub mitteilungsdatum: Date,
160    /// Requested effective date (quarter boundary, ≥6 months out).
161    pub wirksam_ab: Date,
162}
163
164impl Zuordnungsmitteilung {
165    /// Validate the migration per `BilAReM` Kap. 2.3: quarter-boundary
166    /// effectiveness, ≥6 months notice, one-way only.
167    ///
168    /// # Errors
169    ///
170    /// Returns the first violated [`MigrationError`].
171    ///
172    /// # Panics
173    ///
174    /// Never in practice: the six-month arithmetic keeps the month in
175    /// `1..=12` and the clamped day always exists in the target month.
176    pub fn validate(&self, current: Bilanzierungsmodell) -> Result<(), MigrationError> {
177        if current == Bilanzierungsmodell::Planwertmodell {
178            return Err(MigrationError::KeinWegZurueck);
179        }
180        let d = self.wirksam_ab;
181        let is_quarter_start = d.day() == 1
182            && matches!(
183                d.month(),
184                Month::January | Month::April | Month::July | Month::October
185            );
186        if !is_quarter_start {
187            return Err(MigrationError::KeinQuartalsbeginn(d));
188        }
189        // ≥ 6 months notice: effective date must be on/after notice + 6 months.
190        let mut y = self.mitteilungsdatum.year();
191        let mut m = self.mitteilungsdatum.month() as u8 + 6;
192        if m > 12 {
193            m -= 12;
194            y += 1;
195        }
196        let month = Month::try_from(m).expect("1..=12");
197        let day = self.mitteilungsdatum.day().min(month.length(y));
198        let earliest = Date::from_calendar_date(y, month, day).expect("valid date");
199        if d < earliest {
200            return Err(MigrationError::FristUnterschritten {
201                notice: self.mitteilungsdatum,
202                effective: d,
203            });
204        }
205        Ok(())
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use time::macros::date;
213
214    fn mitteilung(notice: Date, effective: Date) -> Zuordnungsmitteilung {
215        Zuordnungsmitteilung {
216            sr_id: "SR-1".into(),
217            redispatch_bilanzkreis: "11XRD-NB-00001-A".into(),
218            mitteilungsdatum: notice,
219            wirksam_ab: effective,
220        }
221    }
222
223    #[test]
224    fn migration_requires_quarter_boundary() {
225        let m = mitteilung(date!(2026 - 08 - 01), date!(2027 - 05 - 01));
226        assert_eq!(
227            m.validate(Bilanzierungsmodell::Prognosemodell),
228            Err(MigrationError::KeinQuartalsbeginn(date!(2027 - 05 - 01)))
229        );
230    }
231
232    #[test]
233    fn migration_requires_six_months_notice() {
234        let m = mitteilung(date!(2026 - 08 - 15), date!(2027 - 01 - 01));
235        assert!(matches!(
236            m.validate(Bilanzierungsmodell::Prognosemodell),
237            Err(MigrationError::FristUnterschritten { .. })
238        ));
239        let ok = mitteilung(date!(2026 - 08 - 15), date!(2027 - 04 - 01));
240        assert_eq!(ok.validate(Bilanzierungsmodell::Prognosemodell), Ok(()));
241    }
242
243    #[test]
244    fn migration_is_one_way() {
245        let m = mitteilung(date!(2026 - 08 - 01), date!(2027 - 04 - 01));
246        assert_eq!(
247            m.validate(Bilanzierungsmodell::Planwertmodell),
248            Err(MigrationError::KeinWegZurueck)
249        );
250    }
251
252    #[test]
253    fn pauschal_only_for_grandfathered_prognose_tr_until_2028() {
254        use Abrechnungsverfahren as A;
255        use Bilanzierungsmodell as B;
256        // Grandfathered TR in the Prognosemodell: Pauschal admissible until end-2028.
257        assert!(A::Pauschal.admissible(date!(2028 - 12 - 31), true, B::Prognosemodell));
258        // From 2029: no longer admissible.
259        assert!(!A::Pauschal.admissible(date!(2029 - 01 - 01), true, B::Prognosemodell));
260        // New TR: never.
261        assert!(!A::Pauschal.admissible(date!(2027 - 01 - 01), false, B::Prognosemodell));
262        // Planwertmodell TR: never Pauschal.
263        assert!(!A::Pauschal.admissible(date!(2027 - 01 - 01), true, B::Planwertmodell));
264        // Spitz variants always admissible.
265        assert!(A::Spitz.admissible(date!(2032 - 01 - 01), false, B::Planwertmodell));
266        assert!(A::VereinfachteSpitz.admissible(date!(2029 - 01 - 01), true, B::Prognosemodell));
267        assert_eq!(A::post_pauschal_default(), A::VereinfachteSpitz);
268    }
269}