Skip to main content

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}