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}