mako_emob/lib.rs
1//! `mako-emob` — **NZR-EMob / Modell 2**: the virtual Bilanzierungsgebiet that
2//! lets a driver bring their own supplier to a charge point.
3//!
4//! A Ladepunktbetreiber (LPB/CPO) runs a regelzonenweites Bilanzierungsgebiet of
5//! its own, and every registered Übergabestelle's flows are an exchange between
6//! the VNB's BG and that one (Anlage 6 zum Beschluss **BK6-20-160**). The
7//! Marktlokation is then balanced in the LPB's BG — „**Modell 2**" — and the LPB
8//! assigns each charging session's energy to the Bilanzkreis of the supplier the
9//! customer chose, quarter hour by quarter hour, with intraday supplier changes.
10//! **BK6-24-267** extends the model to a private Kundenanlage under § 20 Abs. 1,
11//! 1a EnWG.
12//!
13//! This crate is the **allocation engine, its invariants, and the three UTILMD
14//! legs that move a Marktlokation between the models** — pure domain: no I/O,
15//! no wire rendering, no persistence.
16//!
17//! | Module | Answers |
18//! |---|---|
19//! | [`bg`] | may this Bilanzierungsgebiet exist, here, now? |
20//! | [`uebergabestelle`] | may this Marktlokation enter Modell 2? |
21//! | [`session`] | what quarter-hour energies does this Ladevorgang produce? |
22//! | [`allocation`] | who gets this quarter hour, and what is left over? |
23//! | [`fristen`] | when is each step due? |
24//! | [`modellwechsel`] | which UTILMD leg moves it, and what does the answer carry? |
25//! | [`wire`] | which UTILMD qualifier carries it? |
26//! | [`ids`] | which identifiers are ours to mint, and which are not? |
27//!
28//! The Entscheidungsbäume (`E_0510`–`E_0513`) live in `mako_pruefung::emob` and
29//! the answer Fristen in `mako_fristen::antwort`, where every other market
30//! process keeps them.
31//!
32//! # The one invariant everything else serves
33//!
34//! Anlage 6 §IV.1 obliges the LPB to assign **the whole Bilanzierungsgebiet**,
35//! every quarter hour:
36//!
37//! ```text
38//! NGZ(t, richtung) = Σ zugeordnete Marktlokationen + Deltamenge
39//! ```
40//!
41//! [`allocation::QuarterHourAllocation`] holds that exactly and returns a
42//! [`allocation::ConservationProof`] beside every row. The Deltamenge is a
43//! quantity, not a rounding error: it settles in a Bilanzkreis the LPB names, at
44//! the LPB's own cost (§IV.2).
45//!
46//! ```rust
47//! use mako_emob::allocation::{Anspruch, MaloKind, QuarterHourAllocation, Richtung};
48//! use mako_emob::ids::VirtualMaloId;
49//! use mako_emob::session::Viertelstunde;
50//! use rust_decimal::dec;
51//! use time::macros::datetime;
52//!
53//! let slot = Viertelstunde::containing(datetime!(2026-11-03 08:07:00 UTC));
54//!
55//! // 12 kWh crossed the Übergabestelle; two vehicles claim 9 of them.
56//! let row = QuarterHourAllocation::allocate(
57//! slot,
58//! Richtung::Bezug,
59//! dec!(12),
60//! &[
61//! Anspruch { malo: VirtualMaloId::new("veh-1")?, kind: MaloKind::Vehicle, kwh: dec!(6) },
62//! Anspruch { malo: VirtualMaloId::new("veh-2")?, kind: MaloKind::Vehicle, kwh: dec!(3) },
63//! ],
64//! )?;
65//!
66//! assert_eq!(row.delta_kwh, dec!(3)); // the LPB's own Bilanzkreis carries these
67//! assert!(row.proof.haelt()); // Anlage 6 §IV.1, checked
68//! # Ok::<(), Box<dyn std::error::Error>>(())
69//! ```
70//!
71//! # The market role is NB
72//!
73//! The BDEW Rollenmodell defines no LPB: „der LPB kommuniziert aus prozessualer
74//! Sicht wie die Rolle NB" (AWH Kap. 1.4). Every message leaves as **NB**;
75//! `mako_engine::marktrolle::Marktrolle::Lpb` is a *deployment* identity, the
76//! `Nmsb`/`Amsb` pattern, without which the shared Prüfidentifikatoren are
77//! ambiguous in a deployment that is both VNB and LPB.
78//!
79//! # Prüfidentifikatoren
80//!
81//! | PID | Message | From → To | Answered by |
82//! |---|---|---|---|
83//! | 55238 | Anmeldung in Modell 2 | NB (LPB) → NB (VNB) | 55239, `E_0513`→`E_0510` |
84//! | 55240 | Beendigung der Zuordnung zur MaLo | NB (VNB) → LF | 55241, `E_0511` |
85//! | 55242 | Abmeldung aus dem Modell 2 | NB (LPB) → NB (VNB) | 55243, `E_0512` |
86//! | 55235 / 55236 | Zuordnung / Beendigung ZP der NGZ zur NZR | verantw. NB → benachb. NB, ÜNB | 55237, `E_0102` / `E_0103` |
87//! | 55062 / 55063 | MaBiS-ZP für die tägliche BK-SZR eMob | NB (LPB) ↔ ÜNB | 55064 |
88//! | 13018 | MSCONS Netzgangzeitreihe | NB (VNB) → NB (LPB), ÜNB | — |
89//! | 13003 | MSCONS NZR (eMob), BK-SZR (Kat. A) eMob, tägliche BK-SZR eMob | NB ↔ NB, NB → BKV/ÜNB | 21001 / 21002 / 21004 |
90//!
91//! # Sources
92//!
93//! - **BK6-20-160** (21.12.2020) Anlage 6 „NZR-EMob"; Mitteilung Nr. 4 (03.05.2022)
94//! - **BDEW AWH „Zum Modell 2 zur ladevorgangscharfen bilanziellen
95//! Energiemengenzuordnungsmöglichkeit" V1.3** (01.04.2025)
96//! - **BDEW AWH Ergänzung der Marktregeln … Bilanzkreisabrechnung (MaBiS)**
97//! V1.0 (27.04.2022) — the Netzgangzeitreihe
98//! - **BK6-24-267** (15.05.2025), bestandskräftig
99//! - **UTILMD AHB Strom 2.2** Kap. 11; **EBD 4.3** Kap. 17
100//! - **MaBiS** BK6-24-174 Anlage 3, Kap. 3.8 / 3.10 / 5
101
102#![deny(unsafe_code)]
103#![deny(missing_docs)]
104#![warn(clippy::pedantic, clippy::must_use_candidate)]
105// German regulatory terms (MaLo, MaBiS, NGZ, Bilanzierungsgebiet…) are not Rust items.
106#![allow(clippy::doc_markdown)]
107
108pub mod allocation;
109pub mod bg;
110pub mod error;
111pub mod fristen;
112pub mod ids;
113pub mod modellwechsel;
114pub mod session;
115pub mod uebergabestelle;
116pub mod wire;
117
118pub use allocation::{
119 AllocationVersion, Anspruch, ConservationProof, Datenstatus, MaloKind, QuarterHourAllocation,
120 Richtung, Ueberdeckung, Versionsreihe, Zuordnung,
121};
122pub use bg::{BgRegistry, Regelzone, VirtualBalancingArea};
123pub use error::EmobError;
124pub use ids::{SessionId, TokenRef, VirtualMaloId};
125pub use modellwechsel::{
126 EmobAbmeldungWorkflow, EmobAnmeldungWorkflow, EmobAntwort, EmobZuordnungsendeWorkflow, LegWire,
127 Modellwechseldaten,
128};
129pub use session::{Ladevorgang, Provenance, SessionSplit, SlotEnergie, Viertelstunde};
130pub use uebergabestelle::{
131 Abwicklungsmodell, AccessBasis, MeteringMode, Modellwechsel, Uebergabestelle,
132};
133
134/// The Prüfidentifikatoren of the Modellwechsel, in process order.
135///
136/// `makod` registers these; `mako_pruefung::emob` decides the answers to the
137/// three that carry one.
138pub const MODELLWECHSEL_PIDS: [u32; 6] = [55_238, 55_239, 55_240, 55_241, 55_242, 55_243];
139
140/// The Prüfidentifikatoren of the Zuordnung des ZP der NGZ zur NZR.
141///
142/// MaBiS rather than Modell 2 — they come from the AWH Ergänzung der Marktregeln
143/// (27.04.2022) and are answered from `mako_pruefung::mabis` with `E_0102` and
144/// `E_0103`.
145pub const ZP_NGZ_PIDS: [u32; 3] = [55_235, 55_236, 55_237];
146
147// ── EngineModule ──────────────────────────────────────────────────────────────
148
149/// Engine module for **NZR-EMob / Modell 2**.
150///
151/// | Workflow | PIDs |
152/// |---|---|
153/// | `emob-anmeldung` | UTILMD 55238 / 55239 |
154/// | `emob-zuordnungsende` | UTILMD 55240 / 55241 |
155/// | `emob-abmeldung` | UTILMD 55242 / 55243 |
156///
157/// # What this module does not own
158///
159/// 55235–55237 (Zuordnung des ZP der NGZ zur NZR) are **MaBiS**, registered by
160/// `mako_mabis::MabisModule` and answered from `mako_pruefung::mabis` with
161/// `E_0102` / `E_0103`. MSCONS 13003 (NZR and the BK-SZR eMob) is a MaBiS
162/// Summenzeitreihe and belongs to `mabis-billing`; MSCONS 13018, which carries
163/// the Netzgangzeitreihe, is registered by `mako_gpke` (`gpke-messwerte`) under
164/// its AHB name „Lastgang Messlokation, Netzkoppelpunkt, Netzlokation".
165/// Registering either here would take it off a stream that already handles it.
166pub struct EmobModule;
167
168impl mako_engine::builder::EngineModule for EmobModule {
169 fn name(&self) -> &'static str {
170 "emob"
171 }
172
173 fn workflow_names(&self) -> &'static [&'static str] {
174 &[
175 modellwechsel::EmobAnmeldungWorkflow::WORKFLOW_NAME,
176 modellwechsel::EmobZuordnungsendeWorkflow::WORKFLOW_NAME,
177 modellwechsel::EmobAbmeldungWorkflow::WORKFLOW_NAME,
178 ]
179 }
180
181 fn register_pids(&self, router: &mut mako_engine::pid_router::PidRouter) {
182 // Both PIDs of a leg route to the same workflow: the answer arrives on
183 // a process this side started and resumes it. Routing an answer PID
184 // nowhere would dead-letter every reply and let the Frist expire as a
185 // false timeout.
186 for leg in [
187 modellwechsel::ANMELDUNG,
188 modellwechsel::ZUORDNUNGSENDE,
189 modellwechsel::ABMELDUNG,
190 ] {
191 let name = match leg.anfrage_pid {
192 55_238 => modellwechsel::EmobAnmeldungWorkflow::WORKFLOW_NAME,
193 55_240 => modellwechsel::EmobZuordnungsendeWorkflow::WORKFLOW_NAME,
194 _ => modellwechsel::EmobAbmeldungWorkflow::WORKFLOW_NAME,
195 };
196 router.register(leg.anfrage_pid, name);
197 router.register(leg.antwort_pid, name);
198 }
199 }
200
201 fn profile_requirements(&self) -> &'static [mako_engine::profile::ProfileRequirement] {
202 &[mako_engine::profile::ProfileRequirement {
203 message_type: "UTILMD",
204 label: "UTILMD Strom (NZR-EMob / Modell 2, AHB Kap. 11)",
205 }]
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn the_two_pid_families_are_disjoint() {
215 for pid in ZP_NGZ_PIDS {
216 assert!(
217 !MODELLWECHSEL_PIDS.contains(&pid),
218 "{pid} belongs to one family only"
219 );
220 }
221 }
222
223 /// Every PID the module routes is one of the six the crate publishes, and
224 /// every one of the six is routed — an unrouted PID dead-letters.
225 #[test]
226 fn the_module_routes_exactly_the_modellwechsel_pids() {
227 use mako_engine::builder::EngineModule;
228 let mut router = mako_engine::pid_router::PidRouter::new();
229 EmobModule.register_pids(&mut router);
230 for pid in MODELLWECHSEL_PIDS {
231 assert!(
232 router.route(pid).is_some(),
233 "{pid} routes nowhere and would dead-letter"
234 );
235 }
236 // 55235–55237 stay with MaBiS.
237 for pid in ZP_NGZ_PIDS {
238 assert!(router.route(pid).is_none(), "{pid} belongs to mabis");
239 }
240 }
241
242 /// Both PIDs of a leg share a workflow, and no two legs share one.
243 #[test]
244 fn each_leg_owns_one_workflow() {
245 use mako_engine::builder::EngineModule;
246 let mut router = mako_engine::pid_router::PidRouter::new();
247 EmobModule.register_pids(&mut router);
248 for (anfrage, antwort) in [(55_238, 55_239), (55_240, 55_241), (55_242, 55_243)] {
249 assert_eq!(router.route(anfrage), router.route(antwort));
250 }
251 let mut names: Vec<_> = EmobModule.workflow_names().to_vec();
252 names.sort_unstable();
253 names.dedup();
254 assert_eq!(names.len(), 3);
255 }
256
257 #[test]
258 fn every_modellwechsel_pid_is_a_utilmd_strom_pid() {
259 assert!(
260 MODELLWECHSEL_PIDS
261 .iter()
262 .all(|p| (55_000..56_000).contains(p))
263 );
264 assert!(ZP_NGZ_PIDS.iter().all(|p| (55_000..56_000).contains(p)));
265 }
266}