Domain Model
BDEW market role model, market objects (MaLo, MeLo, NeLo, NeBe), territory definitions, identifier formats with check-digit rules, and EDIFACT encoding for all identifiers used in German energy market communication.
Domain Model — Market Roles, Objects, and Identifiers
This page is the definitive reference for the BDEW Rollenmodell für die
Marktkommunikation im deutschen Energiemarkt and all identifier types used
across EDI@Energy messages. It is the shared vocabulary of the whole platform —
all 17 services speak it — and every developer working on mako will need this
material to understand what a MaLo is, why the NB sends UTILMD messages on
behalf of the LF, and how to parse a 9900357000004 out of a NAD segment.
Source documents:
| Document | Version | Date |
|---|---|---|
| Rollenmodell für die Marktkommunikation im deutschen Energiemarkt | V2.2 | 2026-01-08 |
| Identifikatoren in der Marktkommunikation | V1.2 | 2025-02-07 |
| Allgemeine Festlegungen zu den EDIFACT- und XML-Nachrichten | 6.1d | 2026-04-01 |
Market Role Interaction Map
The following diagram shows how the core Marktrollen interact via the key MaKo processes. Gas-only roles (MGV, KN) are omitted for clarity — see the party role table below.
graph LR
LF["LF<br/>Lieferant"]
NB["NB<br/>Netzbetreiber"]
MSB["MSB<br/>Messstellenbetreiber"]
BKV["BKV<br/>Bilanzkreisverantwortlicher"]
UNB["ÜNB<br/>Übertragungsnetzbetreiber"]
BIKO["BIKO<br/>Bilanzkoordinator"]
EIV["EIV<br/>Einsatzverantwortlicher"]
LF -->|"GPKE: Lieferbeginn / Lieferende<br/>UTILMD 55001–55018"| NB
NB -->|"GPKE: Bestätigung / Ablehnung<br/>UTILMD 55002/55003"| LF
LF -->|"GeLi Gas: Anmeldung<br/>UTILMD 44001–44021"| NB
NB -->|"WiM: MSB-Wechsel<br/>UTILMD 55039/55042"| MSB
MSB -->|"WiM: Stammdaten<br/>UTILMD 55168"| NB
MSB -->|"INSRPT: Ablesesteuerung<br/>INSRPT 23001/23003"| NB
NB -->|"MSCONS: Messwerte<br/>(MSCONS 13xxx)"| BKV
NB -->|"INVOIC: NNE/MMM<br/>31001/31002/31005"| LF
LF -->|"INVOIC: Sperrung AWH Gas<br/>31011"| NB
NB -->|"MaBiS: Summenzeitreihe<br/>MSCONS 13003"| BIKO
BIKO -->|"MABIS: Abrechnungsdaten"| BKV
BIKO ---|"settlement"| UNB
EIV -->|"Redispatch 2.0<br/>ORDERS/ORDRSP"| NB
Table of Contents
- Party Roles (Marktrollen)
- Market Objects (Objekte)
- Territories (Gebiete)
- Identifier Formats
- Check Digit Algorithms
- EDIFACT Encoding
- Rust API
Party Roles (Marktrollen)
The market role model defines who the actors are. One company can hold multiple roles simultaneously (e.g., a DSO may also be its own MSB). A distinct MP-ID is required for each role per commodity (Strom / Gas).
Source: RME V2.2 §3.1
| Abbr. | German Name | English | Strom | Gas | Definition |
|---|---|---|---|---|---|
| LF | Lieferant | Energy Supplier | ✅ | ✅ | Responsible for supplying energy to market locations, settling billing with the DSO, and financially compensating the balance between profiled and metered energy quantities. |
| NB | Netzbetreiber | Distribution System Operator (DSO) | ✅ | ✅ | Responsible for grid operation, grid maintenance, and routing of energy. Creates and manages market locations, metering locations, and technical resources within the grid area. Aggregates energy quantities for settlement. Gas: responsible for forwarding metering values to trading partners. |
| ÜNB | Übertragungsnetzbetreiber | Transmission System Operator (TSO) | ✅ | — | Responsible for transmission grid stability, EEG allocation time-series, and short-term plausibility checks within the control zone. One TSO = one Regelzone. In MABIS: bilateral MSCONS exchange with BKV (PID 13003). |
| MSB | Messstellenbetreiber | Metering Point Operator | ✅ | ✅ | Responsible for installing, operating, and maintaining meters. gMSB (grundzuständig) is the incumbent — the NB by default, per §41 MsbG; nMSB (nicht-grundzuständig) is the challenger a customer may switch to; aMSB (abgebend) is the outgoing operator in a switch. Strom: distributes metered values, substitute values, and preliminary values to authorised partners. Gas: determines and forwards metering values to the DSO. |
| BKV | Bilanzkreisverantwortlicher | Balance Responsible Party (BRP) | ✅ | ✅ | Responsible for the energetic and financial balance within a Bilanzkreis. Counterparty to the BIKO (Strom) or MGV (Gas). |
| BIKO | Bilanzkoordinator | Balance Coordinator | ✅ | — | Responsible for Bilanzkreisabrechnung (balance-circle settlement) and financial settlement between BKVs. See mako-mabis (PID 13003). |
| MGV | Marktgebietsverantwortlicher | Market Area Manager | — | ✅ | Responsible for gas balance circle settlement and procurement/dispatch of balancing energy. Operates the virtual trading hub. |
| KN | Kapazitätsnutzer | Capacity User | — | ✅ | Acquires transport capacity at bookable entry/exit points in the gas entry-exit system and allocates it to Bilanzkreise. |
| BTR | Betreiber einer technischen Ressource | Technical Resource Operator | ✅ | — | Installs, operates, and maintains technical resources (generators, controllable loads). Does not change with DSO ownership transfer. |
| EIV | Einsatzverantwortlicher | Dispatch Responsible Party | ✅ | — | Responsible for deploying controllable resources. Assigns SR-IDs to steuerable resources. Central actor in Redispatch 2.0. |
| DP | Data Provider | Data Provider | ✅ | — | Forwards information to authorised trading partners on behalf of the DSO or MSB. |
| ESA | Energieserviceanbieter des Anschlussnutzers | Consumer-side Energy Service Provider | ✅ | — | Acts on behalf of the end-customer (Anschlussnutzer) to request and process metering data. Has no Zuordnung to a Marktlokation: access rests on the Anschlussnutzer's consent (§49 Abs. 2 Nr. 9 MsbG) plus a bilateral contract with the MSB, which §34 Abs. 2 S. 2 Nr. 10 MsbG makes a mandatory, non-discriminatory Zusatzleistung. Data may be used only in the consumer relationship. See Marktrolle::Esa. |
| RB | Registerbetreiber | Registry Operator | ✅ | ✅ | Operates a database for energy market data (e.g., the national Marktstammdatenregister). |
Role pairs in key processes
| Process | Sender (NAD+MS) | Receiver (NAD+MR) | Crate |
|---|---|---|---|
| GPKE Lieferbeginn / Lieferende | LF | NB | mako-gpke |
| GPKE Kündigung Lieferbeginn | LF | LFA (outgoing supplier) | mako-gpke |
| GPKE Antwort Lieferbeginn | NB | LF | mako-gpke |
| GPKE Sperrung / Entsperrung (NB-initiated) | NB | MSB | mako-gpke |
| GPKE Sperrung / Entsperrung (LF-initiated, Strom) | LF | NB | mako-gpke |
| WiM Gerätewechsel | MSB | NB | mako-wim |
| WiM Stammdaten | NB | LF / MSB | mako-wim |
| GeLi Gas Lieferbeginn / Lieferende | LF | GNB (gas DSO) | mako-geli-gas |
| GeLi Gas Sperrung / Entsperrung (LF-initiated, Gas) | LF | GNB | mako-geli-gas |
| WiM Gas Anmeldung / Kündigung gMSB | MSB (gas) | NB (gas) | mako-wim |
| MABIS Summenzeitreihe | ÜNB | BKV | mako-mabis |
| INVOIC Abrechnung | NB | LF | mako-gpke |
Market Objects (Objekte)
Objects are the entities that processes act on. The NB is responsible for creating and closing objects within the grid area and assigning MP identifiers to them.
Source: RME V2.2 §3.2; Allgemeine Festlegungen 6.1d §2.15, §2.19
| Abbr. | German | English | Strom | Gas | Definition |
|---|---|---|---|---|---|
| MaLo | Marktlokation | Market Location | ✅ | ✅ | The central billing entity. A point where energy is either produced or consumed, connected to a grid via at least one line. The NB is responsible for creating, managing, and closing MaLo. Identified by MaLo-ID (11-digit numeric). |
| MeLo | Messlokation | Metering Location | ✅ | ✅ | A location where energy is measured, containing all technical equipment required for measurement and value transmission. One MaLo may have one or more MeLo. Each physical quantity is measured at most once per timestamp. Identified by Zählpunktbezeichnung (VDE-AR-N 4400 Strom / DVGW G2000 Gas). |
| NeLo | Netzlokation | Network Location | ✅ | — | An interconnection point in a grid area. Connects one or more MaLo to the grid via exactly one line. Used for reactive-power billing (Blindarbeit) and monitoring power-curve limits. Identified by NeLo-ID (11-char alphanumeric, prefix E). Introduced by BNetzA BK6-22-128. |
| NeBe | Netzbereich | Network Zone | ✅ | — | A sub-area within a grid for managing controllable consumption facilities (§14a EnWG). Identified by NeBe-ID (11-char alphanumeric, prefix F). Introduced by BNetzA BK6-22-300 / BK8-22/010-A. |
| BK | Bilanzkreis | Balance Circle | ✅ | ✅ | An account that balances feed-in and consumption quantities, facilitating energy trading. One BKV manages one or more BK. |
| NKP | Netzkopplungspunkt | Grid Coupling Point | ✅ | ✅ | A physical point connecting two grid areas. |
| TR | Technische Ressource | Technical Resource | ✅ | — | A physical asset that consumes and/or generates electricity. One TR may be assigned to two MaLo if it both consumes and produces. Identified by TR-ID (prefix D). |
| SR | Steuerbare Ressource | Controllable Resource | ✅ | — | A controllable asset that affects at least one grid connection point. One or more TR are assigned to each SR. Identified by SR-ID (prefix C). |
| SG | Steuergruppe | Control Group | ✅ | — | A grouping of controllable resources for dispatch purposes. Identified by SG-ID (prefix B). |
| CR | Cluster Ressource | Cluster Resource | ✅ | — | A bundle of control groups. Identified by CR-ID (prefix A). |
MaLo vs MeLo — the critical distinction
These are not interchangeable. Confusion between them is the single most common domain modelling error in EDI@Energy implementations:
| Aspect | MaLo | MeLo |
|---|---|---|
| What it models | Commercial supply point (billing) | Physical measurement device location |
| Who manages it | NB — registers and closes | MSB — installs and operates the meter |
| How many per location | 1 per supply relationship | 1..n per MaLo |
| Identifier type | MaLo-ID (11-digit numeric) | Zählpunktbezeichnung (33-char Strom / 11-char Gas) |
| EDIFACT context | UTILMD IDE+Z01, INVOIC | MSCONS, UTILMD (WiM) |
| Rust type | mako_engine::types::MaLo | mako_engine::types::MeLo |
Business scenario: When a consumer switches supplier (GPKE Lieferbeginn), the LF sends a UTILMD referencing the MaLo-ID. When the MSB reads the meter and sends measurements, those go in a MSCONS referencing the MeLo-ID (Zählpunktbezeichnung). They refer to the same physical site but are structurally different objects in the BDEW role model.
Territories (Gebiete)
Territories are spatial containers. Each DSO operates one or more grid areas. Each TSO operates one control zone.
Source: RME V2.2 §3.2
| Abbr. | German | English | Strom | Gas | Definition |
|---|---|---|---|---|---|
| NG | Netzgebiet | Grid Area | ✅ | ✅ | A metrologically bounded area within a market area (Gas) or control zone (Strom). May span multiple voltage/pressure levels. Operated by the NB. |
| BG | Bilanzierungsgebiet | Balancing Zone | ✅ | — | One or more grid areas consolidated for settlement purposes. The synthetic (SLP) or analytical (RLM) balancing method is applied uniformly within a BG. |
| MG | Marktgebiet | Market Area | — | ✅ | Aggregation of gas transport networks sharing a virtual trading hub operated by the MGV. |
| RZ | Regelzone | Control Zone | ✅ | — | A bounded area within which one TSO (ÜNB) is responsible for frequency and voltage stability. Each ÜNB operates exactly one RZ. |
Identifier Formats
All market identifiers are:
- Immutable once assigned — a MaLo-ID does not change when the DSO changes ownership
- Centrally issued by bdew-codes.de (Strom) or codevergabe.dvgw-sc.de (Gas)
- Locally assigned to objects by the responsible code holder (NB in most cases)
Source: Identifikatoren in der Marktkommunikation V1.2 (BDEW, 2025-02-07)
MP-ID — Marktpartner (13 digits)
Identifies a trading partner in a specific market role and commodity. One company holds one MP-ID per role per Sparte.
| Positions | Length | Content |
|---|---|---|
| 1–2 | 2 | Issuer + commodity: 99 = BDEW/Strom, 98 = DVGW/Gas |
| 3 | 1 | Issue mode: 0–8 (BDEW), 9 (DVGW) |
| 4–12 | 9 | Sequence number |
| 13 | 1 | Check digit (Lok- und Waggon-Kennzeichnungsverfahren) |
Alternative: GLN (GS1, 13 digits). When the code holder uses a GS1-issued Global Location Number, the GS1 check-digit algorithm applies (EAN-13).
EDIFACT DE3055 qualifier:
293— BDEW-Codenummer or DVGW-Codenummer9— GS1 GLN
Segments: UNB DE0004 (sender), UNB DE0010 (recipient), NAD DE3035 = MS
(message sender), NAD DE3035 = MR (message recipient).
Databases:
- Strom: https://bdew-codes.de/Codenumbers/BDEWCodes/CodeOverview
- Gas: https://codevergabe.dvgw-sc.de/MarketParticipants
MaLo-ID — Marktlokation (11 digits)
Identifies a supply point (electricity or gas — same pool) for the life of the market location. The first digit indicates which code authority issued the ID but does not indicate the Sparte (Strom or Gas).
| Position | Length | Content |
|---|---|---|
| 1 | 1 | Issuer: 4–9 = BDEW, 1–3 = DVGW |
| 2–10 | 9 | Sequence number (auto-assigned) |
| 11 | 1 | Check digit (Lok- und Waggon-Kennzeichnungsverfahren) |
Who assigns: NB (network operator), who requests blocks of MaLo-IDs from bdew-codes.de or codevergabe.dvgw-sc.de.
Key rule: The same MaLo-ID identifies the location regardless of whether the grid was transferred to a new DSO — the NB keeps the ID.
Examples: 51238696012, 40130000551
MeLo-ID — Messlokation (Zählpunktbezeichnung)
The metering location identifier is the Zählpunktbezeichnung (metering code). It is not covered by the Identifikatoren AWH — format is defined by technical standards:
| Commodity | Standard | Typical length | Format description |
|---|---|---|---|
| Strom | VDE-AR-N 4400 §6 (MeteringCode) | 33 characters | Country code (2) + issuer (11) + sequence + check char |
| Gas | DVGW G2000 | 11 characters | Issuer code + sequence |
Strom example: DE0000123400007002500000000001234
(DE = Germany; remainder identifies the DSO grid area and metering point sequence)
Segments: Referenced in MSCONS LOC, UTILMD WiM IDE+Z01, ORDERS/ORDRSP LOC.
NeLo-ID — Netzlokation (11 chars)
Strom only. Issued since 15 February 2023 (per BNetzA BK6-22-128). Used for reactive-power billing and power-curve limit monitoring.
| Position | Length | Content |
|---|---|---|
| 1 | 1 | Type code: always E |
| 2–10 | 9 | Alphanumeric sequence (A–Z, 0–9), auto-assigned |
| 11 | 1 | Check digit (ASCII-Verfahren) |
Issuer: bdew-codes.de only.
NeBe-ID — Netzbereich (11 chars)
Strom only. Issued since 20 February 2025 (per BNetzA BK6-22-300 / BK8-22/010-A). Used to classify controllable consumption facilities under §14a EnWG.
| Position | Length | Content |
|---|---|---|
| 1 | 1 | Type code: always F |
| 2–10 | 9 | Alphanumeric sequence (A–Z, 0–9), auto-assigned |
| 11 | 1 | Check digit (ASCII-Verfahren) |
Issuer: bdew-codes.de only.
Ressourcen-ID — TR / SR / SG / CR (11 chars)
Used in Redispatch 2.0 and Netzbetreiberkoordination. Four sub-types share one format, distinguished by the first character.
| Position | Length | Content |
|---|---|---|
| 1 | 1 | Type code: A = Cluster Ressource, B = Steuergruppe, C = Steuerbare Ressource, D = Technische Ressource |
| 2–10 | 9 | Alphanumeric sequence (A–Z, 0–9), auto-assigned |
| 11 | 1 | Check digit (ASCII-Verfahren) |
Who assigns: NB assigns TR-IDs and SG-IDs; EIV assigns SR-IDs. Issuer: bdew-codes.de.
Paket-ID — Netzbetreiberwechsel (11 chars)
Identifies a bundle of market locations affected by a DSO ownership transfer
(Netzbetreiberwechsel). Used in PARTIN messages (mako-nbw).
| Position | Length | Content |
|---|---|---|
| 1 | 1 | Type code (assigned by Vergabestelle) |
| 2 | 1 | Sub-type (assigned by Vergabestelle) |
| 3–10 | 8 | Sequence number (auto-assigned) |
| 11 | 1 | Check digit (ASCII-Verfahren) |
Check Digit Algorithms
Two algorithms are used across all BDEW market identifiers.
Source: Identifikatoren V1.2 §8
Lok- und Waggon-Kennzeichnungsverfahren
Used for: BDEW-Code, DVGW-Code, MaLo-ID
- Starting from the leftmost digit, alternately multiply each digit by 2 and 1.
- If a product exceeds 9, subtract 9 (equivalent to summing the two digits of the product).
- Sum all weighted digits.
- Check digit =
(10 - (sum mod 10)) mod 10
This is the same as the ISO 6346 (railway wagon numbering) check digit, also known as the Luhn-like BDEW variant.
ASCII-Verfahren
Used for: NeLo-ID, NeBe-ID, Ressourcen-ID, Paket-ID
Each character is converted to its ASCII code value. The values are weighted by position and the check digit is computed using modulo arithmetic over the defined base. The exact weight and base tables are published in Identifikatoren V1.2 §8.2.
EDIFACT Encoding
Key segment positions where identifiers appear in EDI@Energy messages.
Source: Allgemeine Festlegungen 6.1d §2.13, §2.15, §2.16, §2.19
Interchange level (UNB)
| DE | Role | Content |
|---|---|---|
UNB DE0004 | Sender (Absender) | MP-ID of the transmitting party |
UNB DE0010 | Receiver (Empfänger) | MP-ID of the receiving party |
The MP-ID in UNB must be identical to the MP-ID in the corresponding NAD+MS / NAD+MR segment in the enclosed messages. Mismatches are rejected.
Message level (NAD segment, DE3035)
| Qualifier | Role |
|---|---|
MS | Message sender (Nachrichtenabsender) — always the same party as UNB DE0004 |
MR | Message receiver (Nachrichtenempfänger) — always the same party as UNB DE0010 |
Code scheme (DE3055): 293 for BDEW-Code or DVGW-Code; 9 for GS1 GLN.
Market / metering location (IDE segment, DE3129 qualifier)
The qualifier in IDE DE3129 identifies which type of object the IDE DE3130
value refers to. Common qualifiers:
| Qualifier | Object type | Identifier format |
|---|---|---|
Z01 | Marktlokation (MaLo) | 11-digit MaLo-ID |
Z01 | Messlokation (MeLo) / MaBiS-ZP | 33-char Zählpunktbezeichnung (Strom) or 11-char (Gas) |
Z08 | Netzlokation (NeLo) | 11-char NeLo-ID (prefix E) |
Note: Both MaLo and MeLo use qualifier
Z01in the IDE segment. The object type is determined by context (message type and segment group), not the qualifier alone. A UTILMD GPKE message references a MaLo-ID; a UTILMD WiM message for Zählerstandsgangmessung references a MeLo-ID.
File naming convention
Per Allgemeine Festlegungen 6.1d §2.12:
{type}_{anwendungsref}_{sender-MP-ID}_{receiver-MP-ID}_{yyyymmdd}_{DAR}.txt
Example: UTILMD__9900123400007_4012345393651_20261001_A177.txt
Rust API
mako-engine::types provides typed newtypes that prevent cross-domain
identifier confusion at compile time.
use mako_engine::types::{
MaLo, // Marktlokations-ID
MeLo, // Messlokations-ID (Zählpunktbezeichnung)
MarktpartnerCode, // MP-ID: BDEW-Code (99...), DVGW-Code (98...), or GLN
BkvId, // Bilanzkreisverantwortlicher-ID
UenbId, // Übertragungsnetzbetreiber-ID (ÜNB)
BikoId, // Bilanzkoordinator-ID (BIKO)
DeviceId, // Geräte-ID / Zählernummer (WiM MSB processes)
};
// --- Market location (MaLo) ---
// 11-digit numeric; first digit 4-9 = BDEW-issued, 1-3 = DVGW-issued
let malo: MaLo = MaLo::new("51238696012"); // starts with 5 = BDEW
// --- Metering location (MeLo) = Zählpunktbezeichnung ---
// 33-char Strom (VDE-AR-N 4400) or 11-char Gas (DVGW G2000)
let melo: MeLo = MeLo::new("DE0000123400007002500000000001234"); // Strom
// --- Market participant (MP-ID) ---
// 13-digit numeric; 99... = BDEW/Strom; 98... = DVGW/Gas
let nb: MarktpartnerCode = MarktpartnerCode::new("9900357000004"); // BDEW-Code, NB
let lf: MarktpartnerCode = MarktpartnerCode::new("9900357000011"); // BDEW-Code, LF
// --- Balance-circle roles (MABIS) ---
let bkv: BkvId = BkvId::new("9900357000004"); // Bilanzkreisverantwortlicher
let uenb: UenbId = UenbId::new("4012345000023"); // ÜNB
let biko: BikoId = BikoId::new("9900357000005"); // Bilanzkoordinator
// --- WiM MSB device identifier ---
// Format depends on meter type and MSB; not standardised as a BDEW ID format
let device: DeviceId = DeviceId::new("1EMH0012345678");
All types:
- Wrap
Box<str>— immutable, 1-word smaller thanString, no heap realloc - Implement
Serialize/Deserializeas transparent JSON strings - Implement
Display,AsRef<str>,From<String>,From<&str> - Are not validated at construction — validation happens at the EDIFACT
parsing boundary in
edi-energy. The type system enforces that aMaLocannot be passed where aMeLois expected; the format correctness is a runtime concern of the adapter layer.
Why no validation in the constructor?
The identifier format standards themselves allow for future extensions. Validating at construction would require bumping the library version every time BDEW extends a format. Format validation belongs at the boundary where untrusted input enters the system — i.e., in the EDIFACT parser adapters, not in the typed wrappers.
Sparte across layers — deliberately distinct enums
Four crates define their own Sparte enum. This is by design, not
duplication — each models a different domain with a different legal variant
set, and two of the serde casings are load-bearing wire formats:
| Crate | Variants | Why |
|---|---|---|
metering::Sparte | Strom, Gas, Waerme, Wasser | Physical metering commodities — heat/water submetering under HeizkostenV is metered but is not market communication |
mako-engine::types::Sparte | Strom, Gas | MaKo message routing — selects the WiM APERAK Frist (5 vs 10 Werktage); serialized lowercase in stored process events |
mako-markt::Sparte | Strom, Gas | MaKo master data — a Marktlokation exists only for Strom/Gas; serialized SCREAMING_SNAKE at the REST boundary |
grid-billing::Sparte | Strom, Gas | Legal-reference selector (StromNEV vs GasNEV) — regulated grid settlement has no Waerme/Wasser |
Adding Waerme/Wasser to the MaKo-side enums would create invalid states (a
Wasser MaLo cannot exist), and unifying the serde casings would break stored
event streams and the public REST contract. The same reasoning applies to the
SQL CHECKs: marktd restricts sparte to STROM/GAS, while edmd also
accepts WAERME/WASSER for submetering series.
Further Reading
| Topic | Document |
|---|---|
| Full PID table for all process families | PID Reference |
| BNetzA rulings governing each process | BNetzA Regulatory Reference |
| How EDIFACT messages are parsed and validated | Parsing Guide |
| How the engine routes messages to workflows | Process Engine |
| BO4E objects in ERP integration | ERP Integration |
| Gas balancing domain model | GaBi Gas domain (below) |
Gas Domain — GaBi Gas
The mako-gabi-gas crate provides a dedicated domain vocabulary for the German
gas market, all in src/domain.rs and src/portfolio.rs. All energy quantities
use Decimal — no float arithmetic (DVGW G 685 requires ≥ 3 decimal places).
GasDay — typed gas market day
The German gas day is defined by DVGW G 2000 §3.2: it starts and ends at 06:00 CET (Central European Time), which is UTC-offset aware:
| Season | Local | UTC |
|---|---|---|
| Winter (CET, UTC+1) | 06:00 CET | 05:00 UTC |
| Summer (CEST, UTC+2) | 06:00 CEST | 04:00 UTC |
DST transitions produce 23-hour (spring forward) or 25-hour (fall back) gas days. The nomination deadline per KoV §3.2 is D-1 13:00 CET.
let day = GasDay::new(date!(2026-01-15));
assert_eq!(day.start_utc().hour(), 5); // 05:00 UTC (CET winter)
assert_eq!(day.duration_hours(), 24);
assert_eq!(GasDay::new(date!(2026-03-28)).duration_hours(), 23); // spring-forward day
assert_eq!(GasDay::new(date!(2026-10-24)).duration_hours(), 25); // fall-back dayGasBeschaffenheit + GasQuantity
The DVGW G 685 conversion formula:
$$kWh_{Hs} = m^3 \times H_s \times Z$$
let beschaffenheit = GasBeschaffenheit {
brennwert_hs_kwh_per_m3: dec!(10.55), // Abrechnungsbrennwert from MSCONS PID 13007
zustandszahl: dec!(0.9764), // pressure/temperature correction
quality_class: GasQualityClass::HGas,
..
};
let q = GasQuantity::from_m3(dec!(100), beschaffenheit);
assert_eq!(q.energy_kwh_hs, dec!(1030.102)); // rounded to 3 decimal places
Gas quality classes per DVGW G 260:
| Class | Hs range (kWh/m³) | Usage |
|---|---|---|
| H-Gas | 9.5–13.1 | Most German transmission grids |
| L-Gas | 7.5–10.3 | Parts of northern Germany |
| Biogas | variable | Injected biomethane |
AllocationVersion — KoV §6.4 correction tracking
ALOCAT messages may be sent as initial, corrected, or final allocations:
| Variant | Meaning |
|---|---|
Initial | First ALOCAT for this gas day — preliminary |
Correction(n) | nth corrected allocation (1-based) |
Final | Binding for imbalance settlement — no further corrections |
GasMarketRole
| Role | GasMarketRole | Notes |
|---|---|---|
| Bilanzkreisverantwortlicher | Bkv | Submits NOMINT; receives ALOCAT; subject to IMBNOT |
| Fernleitungsnetzbetreiber | Fnb | Receives NOMINT; answers with NOMRES |
| Verteilnetzbetreiber | Vnb | Sends ALOCAT to the MGV |
| Marktgebietsverantwortlicher | Mgv | Sends ALOCAT to the BKV; imbalance settlement |
| Lieferant | Lf | Supplies end customers; does not submit DVGW nominations directly |
| Händler | Haendler | May submit nominations and delivery orders |
GasPortfolioBalance
GasPortfolioBalance aggregates all BKV positions across Bilanzkreise for a
gas day, enabling portfolio-level imbalance management:
let balance: GasPortfolioBalance = compute_portfolio(bkv_eic, gas_day, positions);
println!("Net imbalance: {} kWh", balance.net_imbalance_kwh());
println!("Direction: {:?}", balance.portfolio_direction()); // Mehr/Minder/Balanced
println!("Open positions: {}", balance.open_imbalance_count());
println!("Fully settled: {}", balance.is_fully_settled());Gas identifier formats
| Identifier | Format | Standard | Example |
|---|---|---|---|
| EIC (BKV / FNB / MGV) | 16 chars alphanumeric | ENTSO-E EIC code | 21X000000001368W |
| Bilanzkreis-EIC | 16 chars, object type X (Party) | ENTSO-E EIC code | 11XSUEDWESTSTRO8 |
| Bilanzierungsgebiet-EIC | 16 chars, object type Y (Area) | ENTSO-E EIC code | 10YDE-EON------1 |
| DVGW-Codenummer (NB) | 13 digits, starts 98 | DVGW registry | 9800357000001 |
| BDEW-Codenummer (LF) | 13 digits, starts 99 | BDEW registry | 9900357000004 |
| Gas Zählpunkt (MeLo) | 11 chars | DVGW G 2000 | DE000123400M |
The BO4E gate
BO4E's machine-readable schema constrains almost nothing. Of the 35
Geschäftsobjekte at v202607.1.0, exactly two declare a required field —
Lastgang and Tarif — and not one declares a oneOf, anyOf or not. A
payload that satisfies Marktlokation.json can be an empty object. Every rule
the standard actually has lives in prose: in a field description, in a class
docstring, in a comment above three fields in the reference implementation.
So "it deserialises as a Marktlokation" is not validation, and mako does not
treat it as such. Accepting a BO4E document is four decisions, and
mako_markt::bo4e::decode is all four, once, for every endpoint:
| Stage | Refuses | code |
|---|---|---|
| 1. Discriminator | a Zaehler posted to the Geraet endpoint. _typ is injected when absent — the endpoint already fixes which BO it takes — and read off the type's own Default, so it cannot drift from what the type serialises | bo4e.discriminator |
| 2. Schema | a value the type cannot hold | bo4e.schema |
| 3. Strict enums | "sparte": "STROMM", at any depth, reported by JSON-path (rubo4e's Bo4eStrict::ensure_known_enums) | bo4e.unknown_enum |
| 4. BO4E rules | a document the standard's prose forbids | bo4e.rule |
Stage 3 is the one that most needs to be unmissable, because BO4E's forward
compatibility cuts both ways. Every BO4E enum carries an Unknown catch-all, so
an unrecognised value decodes rather than failing — and Unknown serialises
back as the literal string "UNKNOWN". At an endpoint that stores the
canonical round-trip rather than the request body, a typo is therefore not
merely accepted: the value the caller sent is overwritten by a marker meaning
"something this build did not recognise". Eight write paths were missing this
stage, and the ones that canonicalise are where it did that.
Stage 4: the rules BO4E states and enforces nowhere
Rules that live only in prose still have to be run. rubo4e's .validate()
descends the whole tree and reports each failure at its path; mako's own module
holds the two it does not check:
| Rule | Source | Checked by |
|---|---|---|
marktlokation Ortsangabe | „Es darf immer nur eine Art der Ortsangabe vorhanden sein (entweder eine Adresse oder eine GeoKoordinate oder eine Katasteradresse)" — at most one. A location reference (a Marktlokation embedded in a Rechnung to say which location it settles) carries none, and is conformant | rubo4e |
messlokation Ortsangabe | the same, with messadresse | rubo4e |
zeitraum states a period | „Es muss daher eine der drei Möglichkeiten angegeben sein" — dauer, or start/enddatum, or start/enduhrzeit | rubo4e |
zeitraum ordering | both bounds documented inclusive, so a one-day period has start == end | rubo4e |
rechnung.gesamtbrutto | gesamtbrutto: „Die Summe aus Netto- und Steuerbetrag" | rubo4e |
rechnung.steuerbetraege | steuerbetraege: „die Summe dieser Beträge ergibt den Wert für gesamtsteuer" | rubo4e |
| currency agreement | the premise of both sums: net and tax denominated differently have no gross | rubo4e |
vertrag / bilanzierung dates, kostenposition line totals | field descriptions | rubo4e |
rechnung.gesamtnetto | gesamtnetto: „Die Summe der Nettobeträge der Rechnungsteile" | mako |
rechnung.storno | istStorno: „im Falle 'true' findet sich im Attribut 'originalrechnungsnummer' die Nummer der Originalrechnung" | mako |
Both sides report in the same shape — rubo4e's own
ValidationFailure { path, message } — so the stage returns one list and a
caller never has to know which side found what.
At most one Ortsangabe, not exactly one. All three fields are optional, and a location reference carries none of them by design.
A rule earns a place only if BO4E asserts it. The rules apply to documents
mako receives, so enforcing more than the standard does would reject a
conformant counterparty. Endpoint requirements ("this endpoint needs a
sparte") are a separate layer, stated at the endpoint that has them.
Money is compared at the scale of the stated total, with the cent as the floor. A position vector carried at six decimals sums to a figure no invoice states; demanding exact equality would reject the whole real-world corpus.
Received and emitted
Two call sites need something other than all four stages, and each says why:
decode_received— a market document from a counterparty. Stages 1–3 still refuse, because a document that will not type has nothing to adjudicate. The rules deliberately do not refuse: an invoice whosegesamtbruttois not net plus tax is disputable, and the market's answer is a REMADV naming the defect (invoic-checkerstage 3 already produces it). Refusing to parse would replace that answer with silence and a dead letter.ensure_conformant— the outbound gate: stages 3 and 4 on a value mako built (the first two are the compiler's job there), plus the outbound-only field check. mako never sends a document it would refuse to receive. It runs in tests over every shape the billing engines emit, and at runtime at every emission site — the Sammelrechnung and Korrekturrechnung, the VPP and EEG Gutschriften, the self-issued INVOIC 31006, the Redispatch-Kostenblatt. A fixture test covers the shapes a builder produces; the gate's rules are arithmetic over the values a request supplies.
A nested value is not a third case. A COM or standalone BO read out of the
extension map of the object that carried it — ZeitvariablePreisposition under
a PreisblattMessung, Standorteigenschaften under a Messlokation — crosses
the same decode, which injects an absent _typ rather than demanding it
(BO4E marks the field required on no schema). A _typ that is present and
wrong is still refused, because nothing downstream catches one:
ensure_known_enums walks a value's fields and never reaches typ itself, so
a nested {"_typ": "MARKTLOKATION"} would otherwise decode to
typ: Some(Unknown) and go back out as the literal string "UNKNOWN".
A BO4E Rechnungsposition is a net supply line: tax lives in
steuerbetraege/gesamtsteuer and advances in vorauszahlungen/zuZahlen, so
emitting either as a position states the amount twice and leaves gesamtnetto
irreconcilable. BillingPosition::is_rechnungsposition is the shared predicate
for the BO4E and EN 16931 mappings and for billingd's Sammelrechnung position
index.
One extra rule, outbound only
rubo4e::validation::current::quality holds rules that crate considers sensible
and BO4E does not state, kept out of .validate() so a consumer can assert
"this conforms to BO4E" without also asserting "…and satisfies rubo4e".
That is right for an inbound gate and backwards for an outbound one, where mako
controls the document.
rechnung_totals_are_complete — state all three invoice totals or none —
therefore runs in ensure_conformant and nowhere else. Every mako engine
already emits all three, so it pins that rather than requesting it.
BO4E extensions: ZusatzAttribut
BO4E cannot model everything mako bills for, and its answer is ZusatzAttribut
— a {name, wert} pair on every BO and most COMs. The standard mandates no
naming convention for it.
mako's is mako:<snake_case>, enforced. Without a prefix, rechnungsart is
indistinguishable from a field BO4E might introduce later and from an attribute
the ERP on the other side already writes — and several crates emit into the same
document.
cargo xtask check-bo4e-attributes (part of just ci) refuses any emitted
attribute that is not namespaced and listed in its registry. The registry
carries a one-line description per attribute and is the discoverable list: a
consumer cannot learn mako's extensions from the BO4E schema, because not being
in the schema is the point.
The gate decodes through rubo4e, not serde_json
Stage 2 is T::from_json_value, which enforces the crate's nesting-depth cap;
plain serde_json::from_value enforces nothing, and the gate is the one place
mako reads untrusted BO4E.
Not the hardened variant with the extension-field count closed to zero: that turns an undefined field into a decode error, which is right for a document you built and wrong for one a counterparty sent.
Out-of-schema fields are refused on the way out
Bo4eStrict::ensure_known_enums finds out-of-schema values;
Bo4eExtensions::ensure_no_extension_data finds out-of-schema fields.
Neither sees the other's finding, and the outbound gate runs both.
Only outbound: refusing an unknown field from a counterparty would throw away
the forward compatibility the extension map exists for — a sender one BO4E
release ahead is to be read, not rejected. On a document mako authored an
undefined field can only be a mistake, and it is the mistake nothing else can
see: a decode round-trip returns Ok for a misspelled key and reads the field
back as None.
_additional is the counterparty's extension slot. Everything mako adds
rides in zusatzAttribute — a real BO4E field — under the mako: namespace,
where the registry and check-bo4e-attributes reach it.
A BO4E document is built typed, never assembled as JSON
cargo xtask check-bo4e-discriminants (also part of just ci) refuses a _typ
written by hand — as typ: Some(BoTyp::X) beside a ..Default::default() that
already stamps it, as typ: None (which serialises to a document carrying no
discriminant at all), or as a "_typ": "X" key in a json! literal.
The discriminant is the visible half of the rule; the field names are the
half that matters. A document assembled as json! never meets the typed
constructor, so nothing checks its keys against the schema — rubo4e captures
unknown keys in _additional rather than rejecting them, so a misspelled field
decodes cleanly, reads back as None, and ships with the value missing.
A decode round-trip is not a defence against this: from_value::<T>(literal)
returns Ok for a renamed key, because absorbing it is what the extension map
is for. A struct literal fails to compile instead.
The same trap reaches documentation, because an example is copied.
cargo xtask check-bo4e-examples decodes every fenced JSON block in the docs
that carries a _typ and reports any field BO4E does not define.
Two rules follow:
- A mako value never occupies a BO4E field. Where mako's vocabulary exceeds
the standard's, the extra value goes in a
ZusatzAttributand the BO4E field is left absent.Rechnungstyphas no Gutschrift value, so a credit note leavesrechnungstypunset and labels itselfmako:rechnungsart; BO4EPreistyphas ten values against mako's thirty, so an EEG-Marktprämie rides asmako:preistyp. - What mako emits is checked, not just what it receives. Every
Rechnung,Angebotand storedTarifpreisblattcrosses the same gate on the way out (ensure_conformant, above) — no enum falling through toUnknown, and the BO4E-stated rules satisfied. The silent catch-all isrubo4e's own choice, not the market's: go-bo4e returnsinvalid <Enum> %qand declares no catch-all, BO4E-python raises a pydanticValidationError, and both reject the whole document. A document that fails the outbound gate is therefore either one a reader silently cannot understand, one they cannot parse at all, or one they openly dispute.