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}