mako_redispatch/lib.rs
1//! `mako-redispatch` — Redispatch 2.0 process engine for German grid
2//! congestion management (§§ 13, 13a, 14 `EnWG`).
3//!
4//! # Three-crate architecture for Redispatch 2.0
5//!
6//! | Crate | Responsibility |
7//! |---|---|
8//! | `edi-energy` | IFTSTA status messages (EDIFACT, PIDs 21037/21038) |
9//! | `redispatch-xml` | XML/XSD format parsing (`ActivationDocument`, `Stammdaten`, …) |
10//! | `mako-redispatch` ← **this crate** | Process engine — workflows, routing, deadlines |
11//!
12//! # Domain background
13//!
14//! **Redispatch 2.0** entered into force on **1 October 2021** via the
15//! Netzausbaubeschleunigungsgesetz (NABEG). It requires all German TSOs
16//! (ÜNB) and DSOs (VNB) to coordinate congestion management across
17//! transmission and distribution networks using CIM/IEC 62325 XML documents.
18//!
19//! Unlike GPKE/WiM/GeLi Gas (EDIFACT `RFF+Z13` Prüfidentifikatoren), routing
20//! here is document-type-driven via [`RedispatchRouter`].
21//!
22//! # Regulatory basis
23//!
24//! **BK6-23-241 (Beschluss 07.05.2026) consolidated Redispatch 2.0.** Its
25//! Anlage „Bilanzieller Ausgleich von Redispatch-Maßnahmen (`BilAReM`)" is the
26//! basis this crate cites, and it repeals the three earlier decisions:
27//!
28//! | Repealed | By | With effect from |
29//! |---|---|---|
30//! | BK6-20-059 Tenorziffer 1 | Tenorziffer 1 | end of 30.06.2026 |
31//! | BK6-20-060 (Netzbetreiberkoordinierung) | Tenorziffer 4 | 07.05.2026 |
32//! | BK6-20-061 (Informationsbereitstellung) | Tenorziffer 3 | 07.05.2026 |
33//! | BK6-20-059 Tenorziffer 2 · Anlage zur `BilAReM` | Tenorziffer 8 | first day the new EDI@Energy documents apply |
34//! | `MaBiS` Anlage 1 Kap. 17 | Tenorziffer 5 | end of 30.09.2026 |
35//!
36//! What did **not** arrive with it is a new table of Fristen. Tenorziffer 7
37//! obliges the ÜNB to develop bundesweit einheitliche Prozessbeschreibungen
38//! together with the industry and submit them to the Beschlusskammer, which
39//! then publishes them. Until that happens, most of the concrete windows are
40//! the operator's own.
41//!
42//! # Deadlines
43//!
44//! [`fristen`] splits them by whether a published source still carries the
45//! value, because the widely quoted figures no longer all have one:
46//!
47//! | Obligation | Value | Source |
48//! |---|---|---|
49//! | `AcknowledgementDocument` | **3 minutes**, unverzüglich | `AcknowledgementDocument` FB 1.0g |
50//! | Vorab-Information, Prognosemodell | 30 minutes before validity | `BilAReM` Kap. 6.3.1 |
51//! | Ausfallarbeit final or Dissens established | end of the **3rd** following month, no restart after | `BilAReM` Kap. 6.4.3 |
52//! | Wetterdaten of the Anlagenbetreiber | 4th Werktag of the following month | `BilAReM` Kap. 3.2.1 |
53//! | `Stammdaten` `gueltig_ab` | ≥ 5 or ≥ 10 Werktage ahead, ≤ 2 years | `Stammdaten` AWT 1.4b Fn. 27/31/32/33 |
54//! | Überführung ins Planwertmodell | ≥ 6 months' notice, only on 01.01./04./07./10. | `BilAReM` Kap. 2.3.2 |
55//! | Activation (ACO) response | **operator-configured** | — (was BK6-20-060) |
56//! | `Kostenblatt` submission | **operator-configured** | — (was BK6-20-061) |
57//! | `Stammdaten` forward (VNB→ÜNB) | **operator-configured** | — (was BK6-20-060) |
58//!
59//! > **The acknowledgement is three minutes, not six hours.** The 6-hour figure
60//! > this crate carried had no published source; the `AcknowledgementDocument`
61//! > Formatbeschreibung states „unverzüglich, jedoch spätestens 3 Minuten nach
62//! > Erhalt der Übertragungsdatei". The difference is architectural: six hours
63//! > is a batch job, three minutes has to be answered by the ingest path.
64//!
65//! > **`StatusRequest_MarketDocument` is not a request/response pair.** Its
66//! > `type` codes are `A60` (status request for a position independently from a
67//! > specific process) and `Z15` Erreichbarkeitsinformation, and its `status`
68//! > carries `A03` Deactivated / `A04` Reactivated / `A13` Withdrawn. There is
69//! > no 24-hour answer window and no answer document.
70//!
71//! # Deployment role gate
72//!
73//! `RedispatchModule` should only be registered when `DeploymentRoles` contains
74//! at least one of `Marktrolle::Nb`, `Marktrolle::Unb`, or `Marktrolle::Anb`.
75//! Lieferant (LF) and MSB deployments are out of scope for Redispatch 2.0.
76//!
77//! # IFTSTA PIDs (confirmed from IFTSTA AHB 2.1 + PID 4.0)
78//!
79//! | PID | Perspective | Process |
80//! |-------|-------------|---------|
81//! | 21037 | NB (VNB) | Kommunikationsprozesse Redispatch — Ansicht NB |
82//! | 21038 | BTR | Kommunikationsprozesse Redispatch — Ansicht BTR |
83//!
84//! These PIDs are registered into the `PidRouter` by [`RedispatchModule`] and
85//! route to the [`aktivierung`] workflow via conversation-ID lookup.
86//!
87//! # Module overview
88//!
89//! | Module | Workflow name | Document type |
90//! |---|---|---|
91//! | [`stammdaten`] | `redispatch-stammdaten` | `Stammdaten` |
92//! | [`aktivierung`] | `redispatch-aktivierung` | `ActivationDocument` |
93//! | [`ack_forward::verfuegbarkeit`] | `redispatch-verfuegbarkeit` | `UnavailabilityMarketDocument` |
94//! | [`ack_forward::netzengpass`] | `redispatch-netzengpass` | `NetworkConstraintDocument` |
95//! | [`ack_forward::kaskade`] | `redispatch-kaskade` | `Kaskade` |
96//! | [`ack_forward::planungsdaten`] | `redispatch-planungsdaten` | `PlannedResourceScheduleDocument` |
97//! | [`ack_forward::statusanfrage`] | `redispatch-statusanfrage` | `StatusRequest_MarketDocument` |
98//! | [`ack_forward::kostenblatt`] | `redispatch-kostenblatt` | `Kostenblatt` |
99
100#![deny(unsafe_code)]
101#![deny(missing_docs)]
102#![warn(clippy::pedantic)]
103
104pub mod ack_forward;
105pub mod aktivierung;
106pub mod ausfallarbeit;
107pub mod bilarem;
108pub mod fristen;
109pub mod router;
110pub mod stammdaten;
111
112pub use router::{RedispatchDocumentKind, RedispatchRouter};
113
114use mako_engine::{builder::EngineModule, pid_router::PidRouter, profile::ProfileRequirement};
115
116// ── RedispatchModule ──────────────────────────────────────────────────────────
117
118/// Engine module for the Redispatch 2.0 process family.
119///
120/// Registers:
121/// - All 8 Redispatch 2.0 workflows into the caller's `RedispatchRouter`
122/// (XML document-type routing, not PID routing).
123/// - IFTSTA PIDs 21037 and 21038 into the `PidRouter`
124/// (EDIFACT-based Vollzugsmeldung, routes to `redispatch-aktivierung`).
125///
126/// # Deployment gate
127///
128/// Only register this module when `DeploymentRoles` contains at least one of
129/// `Marktrolle::Nb`, `Marktrolle::Unb`, or `Marktrolle::Anb`:
130///
131/// ```rust,ignore
132/// if roles.contains_any(&[Marktrolle::Nb, Marktrolle::Unb, Marktrolle::Anb]) {
133/// builder.register(Box::new(RedispatchModule));
134/// }
135/// ```
136pub struct RedispatchModule;
137
138impl RedispatchModule {
139 /// Build a fully-populated [`RedispatchRouter`] for `makod` inbound dispatch.
140 ///
141 /// Called once during daemon startup, before the HTTP/AS4 servers are bound.
142 ///
143 /// # Acknowledgement routing
144 ///
145 /// `AcknowledgementDocument` is intentionally **not** registered in this
146 /// router. Inbound ACKs carry a `ReceivingDocumentIdentification` field that
147 /// identifies the workflow instance they belong to. The `makod` dispatcher
148 /// resolves that correlation key against the `ProcessRegistry` and delivers
149 /// the ACK directly to the correct workflow instance — no document-type
150 /// routing is needed.
151 #[must_use]
152 pub fn build_router() -> RedispatchRouter {
153 let mut router = RedispatchRouter::new();
154 router.register(
155 RedispatchDocumentKind::Activation,
156 aktivierung::WORKFLOW_NAME,
157 );
158 router.register(
159 RedispatchDocumentKind::PlannedResourceSchedule,
160 ack_forward::planungsdaten::WORKFLOW_NAME,
161 );
162 // Acknowledgement is routed by correlation (ReceivingDocumentIdentification),
163 // not by document kind — do NOT register it here.
164 router.register(
165 RedispatchDocumentKind::Stammdaten,
166 stammdaten::WORKFLOW_NAME,
167 );
168 router.register(
169 RedispatchDocumentKind::StatusRequest,
170 ack_forward::statusanfrage::WORKFLOW_NAME,
171 );
172 router.register(
173 RedispatchDocumentKind::Unavailability,
174 ack_forward::verfuegbarkeit::WORKFLOW_NAME,
175 );
176 router.register(
177 RedispatchDocumentKind::Kaskade,
178 ack_forward::kaskade::WORKFLOW_NAME,
179 );
180 router.register(
181 RedispatchDocumentKind::NetworkConstraint,
182 ack_forward::netzengpass::WORKFLOW_NAME,
183 );
184 router.register(
185 RedispatchDocumentKind::Kostenblatt,
186 ack_forward::kostenblatt::WORKFLOW_NAME,
187 );
188 router
189 }
190}
191
192impl EngineModule for RedispatchModule {
193 fn name(&self) -> &'static str {
194 "redispatch"
195 }
196
197 fn workflow_names(&self) -> &'static [&'static str] {
198 &[
199 stammdaten::WORKFLOW_NAME,
200 aktivierung::WORKFLOW_NAME,
201 ack_forward::verfuegbarkeit::WORKFLOW_NAME,
202 ack_forward::netzengpass::WORKFLOW_NAME,
203 ack_forward::kaskade::WORKFLOW_NAME,
204 ack_forward::planungsdaten::WORKFLOW_NAME,
205 ack_forward::statusanfrage::WORKFLOW_NAME,
206 ack_forward::kostenblatt::WORKFLOW_NAME,
207 ]
208 }
209
210 fn register_pids(&self, router: &mut PidRouter) {
211 // Redispatch 2.0 uses XML document-type routing, not EDIFACT PIDs.
212 // EDIFACT IFTSTA Vollzugsmeldungen for Redispatch 2.0:
213 //
214 // PID 21037 — Vollzugsmeldung (NB view)
215 // PID 21038 — Vollzugsmeldung (BTR view)
216 //
217 // 21035/21036/21040 are NOT Redispatch PIDs (see aktivierung.rs).
218 // Source: IFTSTA AHB 2.1 + PID 4.0 (01.04.2026).
219 // These route to the Aktivierung workflow via conversation-ID lookup.
220 for &pid in aktivierung::IFTSTA_PIDS {
221 router.register(pid, aktivierung::WORKFLOW_NAME);
222 }
223
224 // Redispatch 2.0 MSCONS data: 13021 meteorologische Ex-post-Daten,
225 // 13022 TR-scharfe Einzelzeitreihe Ausfallarbeit.
226 //
227 // 13020 and 13023 are **MaBiS** Summenzeitreihen and are registered by
228 // `MabisModule`; 13026 belongs to the EEG-Überführungszeitreihen family.
229 // See `aktivierung::MSCONS_PIDS` for what routing them here cost.
230 for &pid in aktivierung::MSCONS_PIDS {
231 router.register(pid, aktivierung::WORKFLOW_NAME);
232 }
233
234 // ORDERS 17209 — the anfNB requests the Ausfallarbeit from the ANB,
235 // which answers with MSCONS 13022. There is no ORDRSP in this family:
236 // 19204 is MaBiS, 19301/19302 belong to the Herkunftsnachweisregister.
237 for &pid in aktivierung::ORDERS_PIDS {
238 router.register(pid, aktivierung::WORKFLOW_NAME);
239 }
240 }
241
242 fn profile_requirements(&self) -> &'static [ProfileRequirement] {
243 &[
244 ProfileRequirement {
245 message_type: "IFTSTA",
246 label: "IFTSTA Redispatch 2.0 (21037 Ansicht NB, 21038 Ansicht BTR)",
247 },
248 ProfileRequirement {
249 message_type: "MSCONS",
250 label: "MSCONS Redispatch (13021 meteorologische Daten, 13022 Einzelzeitreihe Ausfallarbeit)",
251 },
252 ProfileRequirement {
253 message_type: "ORDERS",
254 label: "ORDERS Redispatch (17209 Anforderung Ausfallarbeit)",
255 },
256 ]
257 }
258
259 fn configure(&self) -> Result<(), String> {
260 // Verify that the router covers all document kinds that use kind-based routing.
261 // Acknowledgement is excluded: it is routed by correlation key, not
262 // by document kind (see build_router() doc comment).
263 let router = Self::build_router();
264 for dk in [
265 RedispatchDocumentKind::Activation,
266 RedispatchDocumentKind::PlannedResourceSchedule,
267 RedispatchDocumentKind::Stammdaten,
268 RedispatchDocumentKind::StatusRequest,
269 RedispatchDocumentKind::Unavailability,
270 RedispatchDocumentKind::NetworkConstraint,
271 RedispatchDocumentKind::Kaskade,
272 RedispatchDocumentKind::Kostenblatt,
273 ] {
274 router.route(dk).map_err(|e| format!("redispatch: {e}"))?;
275 }
276 Ok(())
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn build_router_covers_all_primary_doc_types() {
286 let router = RedispatchModule::build_router();
287 // All document kinds that use document-kind routing must be registered.
288 // Acknowledgement is excluded: it uses correlation-key routing.
289 for dk in [
290 RedispatchDocumentKind::Activation,
291 RedispatchDocumentKind::PlannedResourceSchedule,
292 RedispatchDocumentKind::Stammdaten,
293 RedispatchDocumentKind::StatusRequest,
294 RedispatchDocumentKind::Unavailability,
295 RedispatchDocumentKind::Kaskade,
296 RedispatchDocumentKind::NetworkConstraint,
297 RedispatchDocumentKind::Kostenblatt,
298 ] {
299 assert!(
300 router.is_registered(dk),
301 "RedispatchDocumentKind {dk:?} must be registered in RedispatchModule router"
302 );
303 }
304 // Acknowledgement must NOT be registered — it is routed by correlation key.
305 assert!(
306 !router.is_registered(RedispatchDocumentKind::Acknowledgement),
307 "Acknowledgement must not be in the document-kind router"
308 );
309 }
310
311 #[test]
312 fn configure_succeeds() {
313 assert!(RedispatchModule.configure().is_ok());
314 }
315
316 #[test]
317 fn iftsta_pids_are_correct() {
318 // Confirmed from IFTSTA AHB 2.1 §8 and PID 4.0 (2026-04-01).
319 // Only PIDs 21037 (Ansicht NB/VNB) and 21038 (Ansicht BTR) belong to
320 // Redispatch 2.0. PIDs 21035 (GPKE Rückmeldung Lieferstelle → gpke-supplier-change),
321 // 21036 (WiM Strom Teil 1, unassigned), and 21040 (AWH Sperrprozesse Gas, unassigned)
322 // are not Redispatch PIDs — see site/content/docs/regulatory/pid-reference.md.
323 assert_eq!(aktivierung::IFTSTA_PIDS, &[21_037, 21_038]);
324 }
325
326 #[test]
327 fn mscons_pids_are_correct() {
328 // PID 4.0, rows whose Prozessbeschreibung is "Kommunikationsprozesse
329 // Redispatch". 13020/13023 belong to MaBiS and 13026 to the
330 // EEG-Überführungszeitreihen family, so none of the three is one.
331 assert_eq!(aktivierung::MSCONS_PIDS, &[13_021, 13_022]);
332 }
333
334 #[test]
335 fn no_mabis_or_hkn_pid_is_claimed() {
336 let claimed: Vec<u32> = aktivierung::IFTSTA_PIDS
337 .iter()
338 .chain(aktivierung::MSCONS_PIDS)
339 .chain(aktivierung::ORDERS_PIDS)
340 .copied()
341 .collect();
342 // MaBiS Summenzeitreihen and list requests.
343 for pid in [13_020_u32, 13_023, 17_210, 17_211, 19_204] {
344 assert!(!claimed.contains(&pid), "{pid} is a MaBiS PID");
345 }
346 // Herkunftsnachweisregister.
347 for pid in [19_301_u32, 19_302] {
348 assert!(!claimed.contains(&pid), "{pid} is an HKN-R PID");
349 }
350 // EEG-Überführungszeitreihen.
351 assert!(!claimed.contains(&13_026));
352 }
353
354 #[test]
355 fn the_ack_frist_is_three_minutes_everywhere() {
356 // The 6-hour figure this crate carried had no published source.
357 assert_eq!(fristen::ACK_FRIST, time::Duration::minutes(3));
358 }
359
360 #[test]
361 fn workflow_names_are_non_empty() {
362 assert!(!RedispatchModule.workflow_names().is_empty());
363 for name in RedispatchModule.workflow_names() {
364 assert!(
365 name.starts_with("redispatch-"),
366 "workflow name '{name}' must start with 'redispatch-'"
367 );
368 }
369 }
370}