dvgw_edi/model.rs
1//! The typed message model, shared by all four DVGW families.
2//!
3//! ALOCAT, NOMINT, NOMRES and SSQNOT have the same shape — they differ in
4//! which qualifiers are legal, not in structure — so one model serves all
5//! four and the per-family rules live in the validation layer.
6//!
7//! ```text
8//! BGM DTM×3 RFF+ NAD+MS NAD+MR
9//! └─ LIN ← LineItem (Positionsnummer)
10//! ├─ IMD ← NOMRES: nominated / counterparty / matched
11//! ├─ LOC ← LocationGroup, repeats
12//! │ ├─ DTM+2 ← period for the quantity that follows
13//! │ └─ QTY (+STS) ← Quantity; STS = Zeitreihentyp (ALOCAT) / Verfahren (SSQNOT)
14//! └─ NAD+ZEU / NAD+ZSH / … ← Bilanzkreis, Netzkonto, VHP
15//! ```
16//!
17//! The DVGW column of every Nachrichtenstruktur caps `DTM+2` and `SG37 QTY`
18//! at **one per `LOC` group**, so a profile is a run of `LOC` groups, one per
19//! period. The reader still keeps every `QTY` it meets under a `LOC` — a
20//! counterparty that packs a series under one `LOC` loses nothing — and
21//! validation reports the excess.
22
23use rust_decimal::Decimal;
24
25use crate::datetime::DvgwPeriod;
26
27/// A party from a `NAD` segment.
28#[derive(Debug, Clone, PartialEq, Eq)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
31pub struct Party {
32 /// DE 3035 party function qualifier — `MS`, `MR`, `ZEU`, `ZES`, `ZSZ`, …
33 pub role: String,
34 /// C082 DE 3039 party identifier (DVGW code, GLN or EIC).
35 pub id: String,
36 /// C082 DE 3055 code-list responsible agency — `332` (DVGW), `9` (GS1),
37 /// `305` (ETSO/EIC).
38 pub agency: Option<String>,
39}
40
41impl Party {
42 /// `true` when this party was coded under the DVGW agency (`332`).
43 #[must_use]
44 pub fn is_dvgw_coded(&self) -> bool {
45 self.agency.as_deref() == Some(crate::document::DVGW_AGENCY_CODE)
46 }
47}
48
49/// A reference from an `RFF` segment.
50#[derive(Debug, Clone, PartialEq, Eq)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
53pub struct Reference {
54 /// C506 DE 1153 reference qualifier — `Z13`, `ANX`, `AGO`, …
55 pub qualifier: String,
56 /// C506 DE 1154 reference value.
57 pub value: String,
58}
59
60/// `RFF` qualifiers the DVGW Nachrichtenbeschreibungen define.
61pub mod rff {
62 /// `Z13` — Prüfidentifikator. Present in every DVGW message.
63 pub const PRUEFIDENTIFIKATOR: &str = "Z13";
64 /// `ANX` — Clearingnummer (ALOCAT).
65 pub const CLEARINGNUMMER: &str = "ANX";
66 /// `AGO` — Referenz auf die Original-Nominierung (NOMINT).
67 ///
68 /// This — not `Z13` — is the back-reference that correlates a re-nomination
69 /// to the nomination it corrects.
70 pub const ORIGINAL_NOMINIERUNG: &str = "AGO";
71}
72
73/// `QTY` C186 DE 6063 qualifiers the DVGW Nachrichtenbeschreibungen define.
74pub mod qty {
75 /// `Z02` — Einspeisung (ALOCAT, NOMINT, NOMRES).
76 pub const EINSPEISUNG: &str = "Z02";
77 /// `Z03` — Ausspeisung (ALOCAT, NOMINT, NOMRES).
78 pub const AUSSPEISUNG: &str = "Z03";
79 /// `ZY0` — Mehrmenge (SSQNOT).
80 pub const MEHRMENGE: &str = "ZY0";
81 /// `ZY2` — Mindermenge (SSQNOT).
82 pub const MINDERMENGE: &str = "ZY2";
83}
84
85/// `QTY` C186 DE 6411 units the DVGW Nachrichtenbeschreibungen define.
86pub mod unit {
87 /// `KW1` — Kilowattstunden pro Stunde (kWh/h): a rate.
88 pub const KWH_PER_HOUR: &str = "KW1";
89 /// `KW2` — Kilowattstunden pro Tag (kWh/d): a rate (ALOCAT).
90 pub const KWH_PER_DAY: &str = "KW2";
91 /// `KWH` — Kilowattstunden: an energy (NOMINT, NOMRES, SSQNOT).
92 pub const KWH: &str = "KWH";
93}
94
95/// `STS` DE 9015 codes the DVGW Nachrichtenbeschreibungen define.
96pub mod sts {
97 /// `A1G` — SLP: the Mehr-/Mindermenge was determined by Standardlastprofil (SSQNOT).
98 pub const SLP: &str = "A1G";
99 /// `A2G` — RLM: registrierende Leistungsmessung (SSQNOT; Zeiträume before
100 /// 1.10.2015 only, Hinweis \[501\]).
101 pub const RLM: &str = "A2G";
102 /// `09G` — Lastprofil (SLP) synthetisch (ALOCAT Zeitreihentyp).
103 pub const SLP_SYNTHETISCH: &str = "09G";
104 /// `14G` — Gemessen (RLM) Tagesregime (ALOCAT Zeitreihentyp).
105 pub const RLM_TAGESREGIME: &str = "14G";
106 /// `15G` — Lastprofil (SLP) analytisch (ALOCAT Zeitreihentyp).
107 pub const SLP_ANALYTISCH: &str = "15G";
108 /// `18G` — Gemessen (RLM) Stundenregime (ALOCAT Zeitreihentyp).
109 pub const RLM_STUNDENREGIME: &str = "18G";
110}
111
112/// `NAD` party function qualifiers the DVGW Nachrichtenbeschreibungen define.
113pub mod nad {
114 /// `MS` — Absender der Nachricht.
115 pub const ABSENDER: &str = "MS";
116 /// `MR` — Empfänger der Nachricht.
117 pub const EMPFAENGER: &str = "MR";
118 /// `ZSY` — zusätzlicher Bilanzkreisverantwortlicher (NOMINT header).
119 pub const ZUSAETZLICHER_BKV: &str = "ZSY";
120 /// `ZEU` — Bilanzkreis des internen Transportkunden.
121 pub const BILANZKREIS_INTERN: &str = "ZEU";
122 /// `ZES` — Bilanzkreis des externen Transportkunden.
123 pub const BILANZKREIS_EXTERN: &str = "ZES";
124 /// `ZSZ` — Netzkontonummer.
125 pub const NETZKONTO: &str = "ZSZ";
126 /// `ZSO` — Netzbetreibercode.
127 pub const NETZBETREIBER: &str = "ZSO";
128 /// `ZSH` — Netzkontonummer (ALOCAT `ZO-T3`; the SSQNOT position party).
129 pub const NETZKONTO_ZO_T3: &str = "ZSH";
130 /// `ZET` — vorgelagerter Netzbetreiber (Netzkopplungspunktmeldung).
131 pub const VORGELAGERTER_NETZBETREIBER: &str = "ZET";
132 /// `VHP` — Virtueller Handelspunkt.
133 pub const VIRTUELLER_HANDELSPUNKT: &str = "VHP";
134}
135
136/// A `QTY` segment together with the period and status that qualify it.
137#[derive(Debug, Clone, PartialEq)]
138#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
139#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
140pub struct Quantity {
141 /// C186 DE 6063 quantity qualifier — `Z02` (Einspeisung), `Z03` (Ausspeisung).
142 pub qualifier: String,
143 /// C186 DE 6060 value, parsed exactly.
144 ///
145 /// Gas quantities are settled to at least three decimal places, so this is a
146 /// [`Decimal`]; binary floating point cannot hold those fractions exactly.
147 /// `None` when the wire value is not a number — the raw text is kept in
148 /// [`raw_value`](Self::raw_value) so the defect is reportable.
149 pub value: Option<Decimal>,
150 /// The value exactly as it appeared on the wire.
151 pub raw_value: String,
152 /// C186 DE 6411 measurement unit — `KW1` (kWh/h), `KW2` (kWh/d) or `KWH`;
153 /// see [`unit`](mod@unit).
154 pub unit: Option<String>,
155 /// The period from the `DTM+2` in effect for this quantity.
156 ///
157 /// `None` only when the message omitted it, which the Segmentlayout does not
158 /// permit — DVGW marks the `DTM` inside the `LOC` group `R` (Erforderlich).
159 /// It is **not** defaulted to the message's `DTM+Z01`: a quantity is a rate,
160 /// so substituting the whole Gültigkeitszeitraum for a missing hourly period
161 /// would multiply that hour's rate across the entire gas day.
162 /// `DVGW-DTM-2-REQUIRED` reports the omission instead.
163 pub period: Option<DvgwPeriod>,
164 /// `STS` DE 9015 codes attached to this quantity — the Zeitreihentyp of
165 /// an ALOCAT (`09G` SLP synthetisch, `14G` RLM, …), the Verfahren of a
166 /// SSQNOT (`A1G` SLP, `A2G` RLM); see [`sts`].
167 pub status: Vec<String>,
168}
169
170/// One `LOC` group: a location plus the quantity time series reported for it.
171#[derive(Debug, Clone, PartialEq)]
172#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
173#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
174pub struct LocationGroup {
175 /// DE 3227 place qualifier — `Z19` (Netzpunkt), `Z99` (keine Ortsangabe).
176 pub qualifier: String,
177 /// C517 DE 3225 location identifier.
178 ///
179 /// `None` for `LOC+Z99`, which ALOCAT sends when the message needs no
180 /// specific place. An absent code is normal, not a reason to drop the group.
181 pub code: Option<String>,
182 /// C517 DE 3055 code-list responsible agency.
183 pub agency: Option<String>,
184 /// The quantities reported for this location, in wire order.
185 pub quantities: Vec<Quantity>,
186}
187
188/// An `IMD` description — NOMRES uses it to say which side of the match a
189/// position reports.
190#[derive(Debug, Clone, PartialEq, Eq)]
191#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
192#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
193pub struct ItemDescription {
194 /// DE 7081 item characteristic — `05G`.
195 pub characteristic: Option<String>,
196 /// C273 DE 7009 description code — `17G` nominiert, `18G` Gegenseite,
197 /// `16G` gematcht.
198 pub code: Option<String>,
199}
200
201/// `IMD` DE 7009 codes NOMRES uses to label a position (NOMRES 4.7 §3.2).
202pub mod imd {
203 /// `12G` — Akzeptiert vom Netzbetreiber.
204 pub const AKZEPTIERT_NB: &str = "12G";
205 /// `13G` — Akzeptiert vom benachbarten Netzbetreiber.
206 pub const AKZEPTIERT_NACHBAR_NB: &str = "13G";
207 /// `14G` — Verarbeitet vom Netzbetreiber.
208 pub const VERARBEITET_NB: &str = "14G";
209 /// `15G` — Verarbeitet vom benachbarten Netzbetreiber.
210 pub const VERARBEITET_NACHBAR_NB: &str = "15G";
211 /// `16G` — Bestätigt: die gematchten Mengen.
212 pub const GEMATCHT: &str = "16G";
213 /// `17G` — Nominiert vom Empfänger des Dokumentes (eigene Seite).
214 pub const NOMINIERT: &str = "17G";
215 /// `18G` — Nominiert vom Geschäftspartner (Gegenseite).
216 pub const GEGENSEITE: &str = "18G";
217}
218
219/// One `LIN` loop — a position of the message.
220#[derive(Debug, Clone, PartialEq)]
221#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
222#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
223pub struct LineItem {
224 /// DE 1082 Positionsnummer.
225 pub number: Option<String>,
226 /// C212 DE 7143 item type — the Zeitreihentyp in ALOCAT (`LIN+1++:Z01::332`).
227 pub item_type: Option<String>,
228 /// `IMD` descriptions (NOMRES).
229 pub descriptions: Vec<ItemDescription>,
230 /// The `LOC` groups of this position, in wire order.
231 pub locations: Vec<LocationGroup>,
232 /// Position-level parties — Bilanzkreis, Netzkonto, VHP, Netzbetreiber.
233 pub parties: Vec<Party>,
234}
235
236impl Quantity {
237 /// The energy this quantity represents, in kWh.
238 ///
239 /// A `KW1` (kWh/h) or `KW2` (kWh/d) `QTY` is a **rate** over the period its
240 /// own `DTM+2` names, so the energy is rate × duration; summing the raw
241 /// values of a profile adds rates together and yields a number in no unit
242 /// at all — the single most tempting way to get a gas quantity wrong. A
243 /// `KWH` `QTY` is the energy itself.
244 ///
245 /// Returns `None` when the value is not numeric, when a rate has no period
246 /// to integrate over, or when the unit is not one this can convert.
247 #[must_use]
248 pub fn energy_kwh(&self) -> Option<Decimal> {
249 let value = self.value?;
250 // A unit this does not know is not assumed to be a rate — silently
251 // treating one as kWh/h is how a wrong figure becomes an invoice.
252 let per_seconds = match self.unit.as_deref() {
253 Some(unit::KWH) => return Some(value),
254 Some(unit::KWH_PER_HOUR) => Decimal::from(3600),
255 Some(unit::KWH_PER_DAY) => Decimal::from(86_400),
256 _ => return None,
257 };
258 let period = self.period?;
259 let seconds = Decimal::from(period.duration().whole_seconds());
260 if seconds <= Decimal::ZERO {
261 return None;
262 }
263 // rate × (duration / the rate's own period). Seconds keep a
264 // sub-hourly period exact.
265 Some(value * seconds / per_seconds)
266 }
267
268 /// The first `STS` DE 9015 code attached to this quantity, if any.
269 #[must_use]
270 pub fn status_code(&self) -> Option<&str> {
271 self.status.first().map(String::as_str)
272 }
273}
274
275impl LineItem {
276 /// The first position-level party with the given `NAD` role.
277 #[must_use]
278 pub fn party(&self, role: &str) -> Option<&Party> {
279 self.parties.iter().find(|p| p.role == role)
280 }
281
282 /// Every quantity of this position, flattened across its `LOC` groups.
283 pub fn quantities(&self) -> impl Iterator<Item = &Quantity> {
284 self.locations.iter().flat_map(|l| l.quantities.iter())
285 }
286
287 /// The `IMD` DE 7009 code of this position, when it carries one.
288 #[must_use]
289 pub fn description_code(&self) -> Option<&str> {
290 self.descriptions.iter().find_map(|d| d.code.as_deref())
291 }
292
293 /// The `STS` DE 9015 code of this position's first quantity — the
294 /// Zeitreihentyp of an ALOCAT position, the Verfahren of a SSQNOT one.
295 #[must_use]
296 pub fn status_code(&self) -> Option<&str> {
297 self.quantities().find_map(Quantity::status_code)
298 }
299}
300
301/// Energy totals per `QTY` DE 6063 qualifier, in kWh.
302///
303/// Kept per qualifier because the qualifier is the **direction**: `Z02` is
304/// Einspeisung and `Z03` Ausspeisung, and a message may carry both (a
305/// Virtueller-Handelspunkt nomination states a purchase and a sale in one
306/// interchange). One scalar across them is a difference dressed up as a total.
307pub type EnergyByQualifier = std::collections::BTreeMap<String, Decimal>;
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use rust_decimal::prelude::FromPrimitive;
313
314 fn qty(value: i64) -> Quantity {
315 Quantity {
316 qualifier: "Z03".into(),
317 value: Decimal::from_i64(value),
318 raw_value: value.to_string(),
319 unit: Some("KW1".into()),
320 period: None,
321 status: Vec::new(),
322 }
323 }
324
325 #[test]
326 fn energy_follows_the_unit() {
327 use crate::datetime::DvgwPeriod;
328 use time::macros::datetime;
329 let day = DvgwPeriod {
330 start: datetime!(2026-03-01 05:00 UTC),
331 end: datetime!(2026-03-02 05:00 UTC),
332 };
333 let q = |unit: &str| Quantity {
334 qualifier: "Z03".into(),
335 value: Decimal::from_i64(100),
336 raw_value: "100".into(),
337 unit: Some(unit.into()),
338 period: Some(day),
339 status: Vec::new(),
340 };
341 // 100 kWh/h over a day, 100 kWh/d over a day, 100 kWh.
342 assert_eq!(q("KW1").energy_kwh().unwrap().to_string(), "2400");
343 assert_eq!(q("KW2").energy_kwh().unwrap().to_string(), "100");
344 assert_eq!(q("KWH").energy_kwh().unwrap().to_string(), "100");
345 assert_eq!(
346 q("MWH").energy_kwh(),
347 None,
348 "an unknown unit is not guessed"
349 );
350 }
351
352 #[test]
353 fn a_position_flattens_quantities_across_its_location_groups() {
354 let item = LineItem {
355 number: Some("1".into()),
356 item_type: Some("Z01".into()),
357 descriptions: Vec::new(),
358 locations: vec![
359 LocationGroup {
360 qualifier: "Z99".into(),
361 code: None,
362 agency: None,
363 quantities: vec![qty(100), qty(200)],
364 },
365 LocationGroup {
366 qualifier: "Z19".into(),
367 code: Some("ABCD1234".into()),
368 agency: Some("332".into()),
369 quantities: vec![qty(300)],
370 },
371 ],
372 parties: vec![Party {
373 role: nad::BILANZKREIS_INTERN.into(),
374 id: "THE0BFH000000001".into(),
375 agency: Some("332".into()),
376 }],
377 };
378 assert_eq!(item.quantities().count(), 3, "the time series must survive");
379 assert_eq!(
380 item.party(nad::BILANZKREIS_INTERN).unwrap().id,
381 "THE0BFH000000001"
382 );
383 assert!(item.party(nad::BILANZKREIS_EXTERN).is_none());
384 assert!(item.parties[0].is_dvgw_coded());
385 }
386}