Skip to main content

edi_energy/messages/
mscons.rs

1use edifact_rs::{
2    EdifactDeserialize, EdifactSerialize, EventEmitter, OwnedSegment, ProfileRulePack,
3    ValidationIssue, ValidationSeverity,
4};
5
6use crate::{
7    MessageType,
8    messages::{
9        core::MessageCore,
10        segments::{
11            Bgm, Cci, Dtm, Lin, Loc, Nad, Pia, Qty, Rff, Sts, collect_dtm, find_bgm, find_nad,
12            try_deserialize,
13        },
14    },
15};
16
17// ── Segment group types ───────────────────────────────────────────────────────
18
19/// A header-section reference group (MSCONS SG1: RFF + optional DTM).
20///
21/// Carries the Pruefidentifikator, MMMA reference, and similar header
22/// reference codes before the section delimiter (`UNS+D`).
23#[derive(Debug, Clone)]
24#[non_exhaustive]
25pub struct MsconsReference {
26    /// RFF — reference qualifier and identifier.
27    pub rff: Rff,
28    /// DTM — date/version for this reference (optional in SG1).
29    pub dtm: Vec<Dtm>,
30}
31
32/// A delivery / receipt point group (MSCONS SG5: NAD + SG6 sub-groups).
33///
34/// Each instance represents one metering location or delivery point
35/// described by a `NAD` segment after the `UNS+D` section delimiter.
36#[derive(Debug, Clone)]
37#[non_exhaustive]
38pub struct MsconsDeliveryPoint {
39    /// NAD — location / delivery-point identification.
40    pub nad: Nad,
41    /// SG6 — one or more time-series / measurement objects for this location.
42    pub time_series: Vec<MsconsTimeSeries>,
43}
44
45/// A measurement-object time series (MSCONS SG6: LOC + nested SG7/SG8/SG9).
46///
47/// Identified by a `LOC` segment (balance zone, measurement point, etc.).
48#[derive(Debug, Clone)]
49#[non_exhaustive]
50pub struct MsconsTimeSeries {
51    /// LOC — identifies the measurement object or balance zone.
52    pub loc: Loc,
53    /// DTM — delivery-period dates for this time series (SG6 level).
54    pub dtm: Vec<Dtm>,
55    /// SG7 — references for this measurement object (e.g. device number).
56    pub references: Vec<Rff>,
57    /// SG8 — time-series type (Zeitreihentyp), from `CCI`.
58    pub time_series_type: Option<Cci>,
59    /// SG9 — line items (metered interval values) for this time series.
60    pub items: Vec<MsconsLineItem>,
61}
62
63/// A line-item group (MSCONS SG9: LIN + PIA + SG10).
64///
65/// One `LIN` segment plus optional OBIS code (`PIA`) and one or more
66/// quantity readings (`SG10`).
67#[derive(Debug, Clone)]
68#[non_exhaustive]
69pub struct MsconsLineItem {
70    /// LIN — sequential line-item number.
71    pub lin: Lin,
72    /// PIA — OBIS code or other product identification (optional).
73    pub pia: Option<Pia>,
74    /// SG10 — quantity readings for this line item.
75    pub quantities: Vec<MsconsQuantity>,
76}
77
78/// A quantity reading (MSCONS SG10: QTY + DTM + STS).
79///
80/// The leaf of the hierarchy: one metered value with its period and status.
81#[derive(Debug, Clone)]
82#[non_exhaustive]
83pub struct MsconsQuantity {
84    /// QTY — metered quantity value and unit.
85    pub qty: Qty,
86    /// DTM — begin/end of the measurement interval.
87    pub dtm: Vec<Dtm>,
88    /// STS — quality / validation status codes for this reading.
89    pub status: Vec<Sts>,
90}
91
92// ── MsconsMessage ─────────────────────────────────────────────────────────────
93
94/// MSCONS — Metered Services Consumption Report.
95///
96/// Transmits meter readings and consumption values between grid operators
97/// and balance-group managers in the German energy market.
98///
99/// # Typed access
100///
101/// | Field      | Segment | Meaning                                       |
102/// |------------|---------|-----------------------------------------------|
103/// | `bgm`             | BGM     | Document code and Pruefidentifikator          |
104/// | `dtm`             | DTM     | Message-level DTM segments (e.g. DTM+137)     |
105/// | `sender`          | NAD+MS  | Message sender                                |
106/// | `receiver`        | NAD+MR  | Message recipient                             |
107/// | `references`      | SG1/RFF | Header references (Pruefidentifikator, MMMA)  |
108/// | `delivery_points` | SG5/NAD | Metering / delivery point groups              |
109///
110/// The delivery-point hierarchy provides fully typed access to the metered
111/// values: `delivery_points[i].time_series[j].items[k].quantities[l].qty`.
112#[derive(Debug, Clone)]
113pub struct MsconsMessage {
114    pub(crate) core: MessageCore,
115    /// BGM — beginning of message.
116    bgm: Option<Bgm>,
117    /// DTM — message-level date/time segments (before UNS).
118    dtm: Vec<Dtm>,
119    /// NAD+MS — message sender.
120    sender: Option<Nad>,
121    /// NAD+MR — message recipient.
122    receiver: Option<Nad>,
123    /// SG1 — header references (Pruefidentifikator, MMMA allocation list, etc.).
124    references: Vec<MsconsReference>,
125    /// SG5 — delivery / metering point groups (after `UNS+D`).
126    delivery_points: Vec<MsconsDeliveryPoint>,
127}
128
129impl MsconsMessage {
130    pub(crate) fn from_parts(
131        segments: Vec<OwnedSegment>,
132        message_ref: impl Into<Box<str>>,
133        assoc_code: impl Into<Box<str>>,
134        pruefidentifikator: Option<u32>,
135    ) -> Self {
136        let (bgm, dtm, sender, receiver, references, delivery_points) = {
137            let borrowed: Vec<edifact_rs::Segment<'_>> =
138                segments.iter().map(|s| s.as_borrowed()).collect();
139            (
140                find_bgm(&borrowed),
141                collect_dtm_header(&borrowed),
142                find_nad(&borrowed, "MS"),
143                find_nad(&borrowed, "MR"),
144                parse_references(&borrowed),
145                parse_delivery_points(&borrowed),
146            )
147        };
148        Self {
149            core: MessageCore::new(
150                segments,
151                message_ref,
152                assoc_code,
153                pruefidentifikator,
154                MessageType::Mscons,
155            ),
156            bgm,
157            dtm,
158            sender,
159            receiver,
160            references,
161            delivery_points,
162        }
163    }
164
165    /// The EDI@Energy release / association code from UNH (DE 0057).
166    #[must_use]
167    pub fn assoc_code(&self) -> &str {
168        &self.core.assoc_code
169    }
170
171    /// Raw parsed segments (authoritative for validation and serialization).
172    #[must_use]
173    pub fn segments(&self) -> &[OwnedSegment] {
174        &self.core.segments
175    }
176
177    /// BGM — beginning of message.  Returns `None` when absent or malformed.
178    #[must_use]
179    pub fn bgm(&self) -> Option<&Bgm> {
180        self.bgm.as_ref()
181    }
182
183    /// DTM — message-level date/time segments.
184    #[must_use]
185    pub fn dtm(&self) -> &[Dtm] {
186        &self.dtm
187    }
188
189    /// NAD+MS — message sender.  Returns `None` when absent or malformed.
190    #[must_use]
191    pub fn sender(&self) -> Option<&Nad> {
192        self.sender.as_ref()
193    }
194
195    /// NAD+MR — message recipient.  Returns `None` when absent or malformed.
196    #[must_use]
197    pub fn receiver(&self) -> Option<&Nad> {
198        self.receiver.as_ref()
199    }
200
201    /// SG1 — header references (Pruefidentifikator, MMMA allocation list, etc.).
202    #[must_use]
203    pub fn references(&self) -> &[MsconsReference] {
204        &self.references
205    }
206
207    /// SG5 — delivery / metering point groups (after `UNS+D`).
208    #[must_use]
209    pub fn delivery_points(&self) -> &[MsconsDeliveryPoint] {
210        &self.delivery_points
211    }
212}
213
214// ── EdifactDeserialize ────────────────────────────────────────────────────────
215
216impl EdifactDeserialize for MsconsMessage {
217    fn edifact_deserialize(
218        segments: &[edifact_rs::Segment<'_>],
219    ) -> Result<Self, edifact_rs::EdifactError> {
220        let (message_ref, assoc_code) = MessageCore::extract_unh_fields(segments)?;
221        let pid = MessageCore::extract_bgm_pid(segments);
222        let owned: Vec<OwnedSegment> = segments.iter().cloned().map(OwnedSegment::from).collect();
223        Ok(Self::from_parts(owned, message_ref, assoc_code, pid))
224    }
225}
226
227// ── EdifactSerialize ──────────────────────────────────────────────────────────
228
229impl EdifactSerialize for MsconsMessage {
230    fn edifact_serialize<E: EventEmitter>(
231        &self,
232        emitter: &mut E,
233    ) -> Result<(), edifact_rs::EdifactError> {
234        self.core.emit_segments(emitter)
235    }
236}
237impl_edi_energy_message!(MsconsMessage, sem = mscons_semantic_pack());
238
239// ── segment group parsers ─────────────────────────────────────────────────────
240
241/// Collect DTM segments from the header section only (before `UNS`).
242fn collect_dtm_header(segments: &[edifact_rs::Segment<'_>]) -> Vec<Dtm> {
243    let end = segments
244        .iter()
245        .position(|s| s.tag == "UNS")
246        .unwrap_or(segments.len());
247    collect_dtm(&segments[..end])
248}
249
250/// Parse SG1 reference groups (RFF + optional DTM) from the header section.
251fn parse_references(segments: &[edifact_rs::Segment<'_>]) -> Vec<MsconsReference> {
252    let end = segments
253        .iter()
254        .position(|s| s.tag == "UNS")
255        .unwrap_or(segments.len());
256    let header = &segments[..end];
257
258    let mut result = Vec::new();
259    let mut i = 0;
260    while i < header.len() {
261        if header[i].tag != "RFF" {
262            i += 1;
263            continue;
264        }
265        let Some(rff) = try_deserialize::<Rff>(&header[i]) else {
266            i += 1;
267            continue;
268        };
269        let mut dtm = Vec::new();
270        let mut j = i + 1;
271        while j < header.len() && header[j].tag == "DTM" {
272            if let Some(d) = try_deserialize::<Dtm>(&header[j]) {
273                dtm.push(d);
274            }
275            j += 1;
276        }
277        result.push(MsconsReference { rff, dtm });
278        i = j;
279    }
280    result
281}
282
283/// Parse SG5 delivery-point groups (NAD + SG6 time-series) from the detail
284/// section (after `UNS+D`).
285fn parse_delivery_points(segments: &[edifact_rs::Segment<'_>]) -> Vec<MsconsDeliveryPoint> {
286    let start = match segments.iter().position(|s| s.tag == "UNS") {
287        Some(pos) => pos + 1,
288        None => return Vec::new(),
289    };
290    let detail = &segments[start..];
291
292    let mut result = Vec::new();
293    let mut i = 0;
294
295    while i < detail.len() {
296        if detail[i].tag != "NAD" {
297            i += 1;
298            continue;
299        }
300        let Some(nad) = try_deserialize::<Nad>(&detail[i]) else {
301            i += 1;
302            continue;
303        };
304        i += 1;
305
306        // Collect SG6 groups (LOC-headed) that belong to this NAD.
307        let (time_series, next_i) = parse_sg6_groups(detail, i);
308        i = next_i;
309
310        result.push(MsconsDeliveryPoint { nad, time_series });
311    }
312    result
313}
314
315/// Segment tags that terminate any SG6 (LOC-headed) group.
316const SG6_TERMINATORS: &[&str] = &["NAD", "UNT"];
317/// Segment tags that terminate any SG9 (LIN-headed) group.
318const SG9_TERMINATORS: &[&str] = &["LIN", "LOC", "NAD", "UNT"];
319/// Segment tags that terminate any SG10 (QTY-headed) group.
320const SG10_TERMINATORS: &[&str] = &["QTY", "LIN", "LOC", "NAD", "UNT"];
321
322/// Parse all SG6 time-series groups from `detail[from..]` until a top-level
323/// boundary (NAD or UNT).  Returns `(groups, next_index)`.
324fn parse_sg6_groups(
325    detail: &[edifact_rs::Segment<'_>],
326    from: usize,
327) -> (Vec<MsconsTimeSeries>, usize) {
328    let mut series = Vec::new();
329    let mut i = from;
330
331    while i < detail.len() {
332        if SG6_TERMINATORS.iter().any(|t| &detail[i].tag == t) {
333            break;
334        }
335        if detail[i].tag != "LOC" {
336            i += 1;
337            continue;
338        }
339        let Some(loc) = try_deserialize::<Loc>(&detail[i]) else {
340            i += 1;
341            continue;
342        };
343        i += 1;
344
345        let mut dtm = Vec::new();
346        let mut references = Vec::new();
347        let mut time_series_type: Option<Cci> = None;
348
349        // Consume DTM / RFF (SG7) / CCI (SG8) before any LIN.
350        while i < detail.len() && !SG6_TERMINATORS.iter().any(|t| &detail[i].tag == t) {
351            match detail[i].tag {
352                "DTM" => {
353                    if let Some(d) = try_deserialize::<Dtm>(&detail[i]) {
354                        dtm.push(d);
355                    }
356                    i += 1;
357                }
358                "RFF" => {
359                    if let Some(r) = try_deserialize::<Rff>(&detail[i]) {
360                        references.push(r);
361                    }
362                    i += 1;
363                }
364                "CCI" => {
365                    time_series_type = try_deserialize::<Cci>(&detail[i]);
366                    i += 1;
367                }
368                "LIN" | "LOC" => break, // next SG6 group starts
369                _ => {
370                    i += 1;
371                }
372            }
373        }
374
375        // Consume SG9 (LIN-headed) line items.
376        let (items, next_i) = parse_sg9_items(detail, i);
377        i = next_i;
378
379        series.push(MsconsTimeSeries {
380            loc,
381            dtm,
382            references,
383            time_series_type,
384            items,
385        });
386    }
387
388    (series, i)
389}
390
391/// Parse all SG9 line-item groups from `detail[from..]` until an SG6 boundary.
392/// Returns `(items, next_index)`.
393fn parse_sg9_items(
394    detail: &[edifact_rs::Segment<'_>],
395    from: usize,
396) -> (Vec<MsconsLineItem>, usize) {
397    let mut items = Vec::new();
398    let mut i = from;
399
400    while i < detail.len() {
401        if SG9_TERMINATORS[1..].iter().any(|t| &detail[i].tag == t) {
402            // LOC / NAD / UNT — SG9 section ends.
403            break;
404        }
405        if detail[i].tag != "LIN" {
406            i += 1;
407            continue;
408        }
409        let Some(lin) = try_deserialize::<Lin>(&detail[i]) else {
410            i += 1;
411            continue;
412        };
413        i += 1;
414
415        // Optional PIA immediately after LIN.
416        let pia = if i < detail.len() && detail[i].tag == "PIA" {
417            let p = try_deserialize::<Pia>(&detail[i]);
418            i += 1;
419            p
420        } else {
421            None
422        };
423
424        // SG10 quantity groups.
425        let (quantities, next_i) = parse_sg10_quantities(detail, i);
426        i = next_i;
427
428        items.push(MsconsLineItem {
429            lin,
430            pia,
431            quantities,
432        });
433    }
434
435    (items, i)
436}
437
438/// Parse all SG10 quantity groups from `detail[from..]` until an SG9 boundary.
439/// Returns `(quantities, next_index)`.
440fn parse_sg10_quantities(
441    detail: &[edifact_rs::Segment<'_>],
442    from: usize,
443) -> (Vec<MsconsQuantity>, usize) {
444    let mut quantities = Vec::new();
445    let mut i = from;
446
447    while i < detail.len() {
448        if SG10_TERMINATORS[1..].iter().any(|t| &detail[i].tag == t) {
449            break;
450        }
451        if detail[i].tag != "QTY" {
452            i += 1;
453            continue;
454        }
455        let Some(qty) = try_deserialize::<Qty>(&detail[i]) else {
456            i += 1;
457            continue;
458        };
459        i += 1;
460
461        let mut dtm = Vec::new();
462        let mut status = Vec::new();
463
464        while i < detail.len() && !SG10_TERMINATORS.iter().any(|t| &detail[i].tag == t) {
465            match detail[i].tag {
466                "DTM" => {
467                    if let Some(d) = try_deserialize::<Dtm>(&detail[i]) {
468                        dtm.push(d);
469                    }
470                }
471                "STS" => {
472                    if let Some(s) = try_deserialize::<Sts>(&detail[i]) {
473                        status.push(s);
474                    }
475                }
476                _ => {}
477            }
478            i += 1;
479        }
480
481        quantities.push(MsconsQuantity { qty, dtm, status });
482    }
483
484    (quantities, i)
485}
486
487// ── Layer 5: MSCONS semantic rule pack ───────────────────────────────────────
488
489/// Build the MSCONS semantic rule pack (Layer 5).
490///
491/// Rules:
492/// - [`rule_sem_location_format`]: the `LOC+172` Meldepunkt must carry either an
493///   11-character Marktlokations-ID or a 33-character Messlokations-ID.
494/// - [`rule_sem_period_order`]: when both a start-of-period (`DTM 163`) and an
495///   end-of-period (`DTM 164`) are present, the start must not be after the end.
496/// - [`rule_sem_unit_unknown`]: the unit-of-measure code in `QTY C186` component 2
497///   must be from the EDI@Energy approved set.
498fn mscons_semantic_pack() -> ProfileRulePack {
499    ProfileRulePack::new("MSCONS-SEM")
500        .for_message_type("MSCONS")
501        .with_stateless_rule_fn(rule_sem_location_format)
502        .with_stateless_rule_fn(rule_sem_period_order)
503        .with_stateless_rule_fn(rule_sem_unit_unknown)
504}
505
506/// `SEM-MSCONS-LOCATION-FORMAT` — the Meldepunkt in `LOC+172` must carry either
507/// a Marktlokations-ID (`[A-Z0-9]{11}`) or a Messlokations-ID (33 characters).
508///
509/// `LOC+172` is the *Meldepunkt*, not a MaLo-only field: the MSCONS AHB
510/// describes SG6 LOC as "ID der Messlokation oder ID der Marktlokation oder ID
511/// des Netzkopplungspunktes", so the qualifier fixes the role of the point while
512/// the value may follow either ID scheme.
513fn rule_sem_location_format(
514    segments: &[edifact_rs::Segment<'_>],
515    issues: &mut Vec<ValidationIssue>,
516) {
517    for seg in segments.iter().filter(|s| s.tag == "LOC") {
518        // LOC: element[0] = 3227 (location qualifier), element[1] = C517 composite.
519        // C517 component[0] = 3225 (location id code).
520        let qualifier = seg.element_str(0).unwrap_or("");
521        if qualifier != "172" {
522            // 237 carries a Bilanzkreis EIC code, not a location ID.
523            continue;
524        }
525        let id = seg
526            .get_element(1)
527            .and_then(|e| e.get_component(0))
528            .unwrap_or("");
529        if id.is_empty() {
530            continue;
531        }
532        if !super::common::is_valid_location_id(id) {
533            issues.push(
534                ValidationIssue::new(
535                    ValidationSeverity::Error,
536                    "LOC+172 element 3225 (C517 component 0): value is neither a \
537                     Marktlokations-ID ([A-Z0-9]{11}) nor a Messlokations-ID (33 characters)"
538                        .to_owned(),
539                )
540                .with_span(seg.span)
541                .with_rule_id("SEM-MSCONS-LOCATION-FORMAT")
542                .with_segment("LOC")
543                .with_suggestion(
544                    "The Meldepunkt in LOC+172 must be either an 11-character \
545                     Marktlokations-ID matching [A-Z0-9]{11} or a 33-character \
546                     Messlokations-ID starting with an ISO 3166-1 country code",
547                ),
548            );
549        }
550    }
551}
552
553/// `SEM-MSCONS-PERIOD-ORDER` — When both a period-start (`DTM+163`) and a
554/// period-end (`DTM+164`) are present in the message, the start date must not
555/// be lexicographically greater than the end date.
556///
557/// Date strings are in `YYYYMMDD` (format 102) or `YYYYMMDDHHmm` (format 203),
558/// both of which sort chronologically as strings.
559fn rule_sem_period_order(segments: &[edifact_rs::Segment<'_>], issues: &mut Vec<ValidationIssue>) {
560    // Track both value and source span so we can point diagnostics at the
561    // out-of-order start segment.
562    let mut start: Option<(&str, edifact_rs::Span)> = None;
563    let mut end: Option<(&str, edifact_rs::Span)> = None;
564
565    for seg in segments.iter().filter(|s| s.tag == "DTM") {
566        // DTM element[0] = C507 composite:
567        //   component[0] = 2005 (date/time qualifier)
568        //   component[1] = date/time value
569        let Some(c507) = seg.get_element(0) else {
570            continue;
571        };
572        let qualifier = c507.get_component(0).unwrap_or("");
573        let value = c507.get_component(1).unwrap_or("");
574        match qualifier {
575            "163" => start = Some((value, seg.span)),
576            "164" => end = Some((value, seg.span)),
577            _ => {}
578        }
579    }
580
581    if let (Some((start_val, start_span)), Some((end_val, _))) = (start, end) {
582        if !start_val.is_empty() && !end_val.is_empty() && start_val > end_val {
583            issues.push(
584                ValidationIssue::new(
585                    ValidationSeverity::Error,
586                    "DTM: period-start (qualifier 163) is after period-end (qualifier 164)"
587                        .to_owned(),
588                )
589                .with_span(start_span)
590                .with_rule_id("SEM-MSCONS-PERIOD-ORDER")
591                .with_segment("DTM")
592                .with_suggestion(
593                    "Ensure DTM+163 (Beginn Lieferzeitraum) is not later than \
594                     DTM+164 (Ende Lieferzeitraum) — date values must be in \
595                     ascending chronological order",
596                ),
597            );
598        }
599    }
600}
601
602/// EDI@Energy approved unit-of-measure codes for MSCONS metering values.
603///
604/// Source: BDEW MSCONS Application Handbook, Appendix A — Code List 6411.
605/// DE 6411 codes MSCONS admits.
606///
607/// `KWT` (Kilowatt), `D54` (Watt pro Quadratmeter) and `MTS` (Meter pro
608/// Sekunde) are the codes MIG 2.5 lists for SG10 QTY; `D54` and `MTS` carry the
609/// meteorological values of Redispatch 2.0 PID 13021, and `KWT` carries a power
610/// maximum. Omitting them rejects messages the MIG defines.
611const APPROVED_UNITS: &[&str] = &[
612    "KWH", "MWH", "GWH", "KW", "KWT", "MW", "GW", "KVA", "MVA", "KVAR", "MVAR", "M3", "M3H", "HM3",
613    "GJ", "MJ", "J", "D54", "MTS", "Z03", "Z12", "Z14",
614];
615
616/// `SEM-MSCONS-UNIT-UNKNOWN` — The unit-of-measure code in `QTY C186`
617/// component 2 (data element 6411) must be from the EDI@Energy approved set.
618fn rule_sem_unit_unknown(segments: &[edifact_rs::Segment<'_>], issues: &mut Vec<ValidationIssue>) {
619    for seg in segments.iter().filter(|s| s.tag == "QTY") {
620        // QTY element[0] = C186 composite:
621        //   component[0] = 6063 (quantity type qualifier)
622        //   component[1] = quantity value
623        //   component[2] = 6411 (measure unit code)
624        let unit = seg
625            .get_element(0)
626            .and_then(|e| e.get_component(2))
627            .unwrap_or("");
628        if unit.is_empty() {
629            continue; // Unit is optional for some qualifier types.
630        }
631        if !APPROVED_UNITS.contains(&unit) {
632            issues.push(
633                ValidationIssue::new(
634                    ValidationSeverity::Error,
635                    "QTY C186 component 2 (DE 6411): unit-of-measure code is not \
636                     in the EDI@Energy approved set for MSCONS"
637                        .to_owned(),
638                )
639                .with_span(seg.span)
640                .with_rule_id("SEM-MSCONS-UNIT-UNKNOWN")
641                .with_segment("QTY")
642                .with_suggestion(
643                    "Use one of the EDI@Energy MSCONS approved units (Code List 6411): \
644                     KWH MWH GWH KW KWT MW GW KVA MVA KVAR MVAR M3 M3H HM3 GJ MJ J \
645                     D54 MTS Z03 Z12 Z14",
646                ),
647            );
648        }
649    }
650}