Skip to main content

automapper_validation/
consistency.rs

1//! What a message must keep consistent within itself, beyond what its AHB
2//! conditions state: the APERAK errors Z34 and Z21.
3//!
4//! - **Z34, "Zeitintervall negativ oder Null":** a period whose end is not
5//!   after its beginning. A period is a pair of DTM qualifiers that name its
6//!   two ends ([`INTERVALS`]), sent side by side in one segment group.
7//! - **Z21, "Geschäftsvorfallinterne Referenzierung fehlerhaft":** a UTILMD
8//!   SG8 or SG12 `RFF` that refers to a location of its transaction
9//!   ("Zur Referenzierung auf eine ID einer Messlokation vom SG5 LOC+Z17",
10//!   UTILMD MIG) naming none ([`REFERENCES`], [`REFERENCES_GAS`]).
11//!
12//! Both are judged only where the message itself settles them. A period
13//! whose ends are sent in different formats, and a reference in a
14//! transaction without any location of the kind it refers to, are left
15//! alone: no error is reported rather than a wrong one.
16
17use mig_types::segment::OwnedSegment;
18
19use crate::validator::{IssueKind, SegmentPosition, Severity, ValidationIssue};
20
21/// DTM 2005 pairs naming a period's beginning and end, and whether the
22/// period may be empty (end = beginning).
23///
24/// "Frühestes … spätestes" (469/472) bound a window that may close on the
25/// day it opens; the others are periods, empty if they end as they begin.
26pub const INTERVALS: &[(&str, &str, bool)] = &[
27    ("92", "93", false),   // Vertragsbeginn / Vertragsende
28    ("155", "156", false), // Rechnungsperiode Beginn / Ende
29    ("158", "159", false), // Bilanzierungsbeginn / -ende
30    ("163", "164", false), // Verarbeitung Beginn / Ende
31    ("Z15", "Z16", false), // Bilanzierungsbeginn / -ende der beteiligten Marktrolle
32    ("Z25", "Z26", false), // Verwendung der Daten ab / bis
33    ("Z34", "Z35", false), // Gültigkeitsbeginn / -ende
34    ("Z42", "Z43", false), // vorläufiger Abrechnungszeitraum Beginn / Ende
35    ("469", "472", true),  // frühester / spätester Zeitpunkt
36];
37
38/// UTILMD Strom RFF 1153 qualifiers that refer to a location of the
39/// transaction, and the LOC 3227 qualifiers they may name (UTILMD MIG Strom,
40/// RFF descriptions: "Zur Referenzierung auf eine ID einer Messlokation vom
41/// SG5 LOC+Z17").
42pub const REFERENCES: &[(&str, &[&str])] = &[
43    ("Z18", &["Z16", "Z21", "Z22"]), // Marktlokation, Tranche, ruhende Marktlokation
44    ("Z19", &["Z17"]),               // Messlokation
45    ("Z32", &["Z18"]),               // Netzlokation
46    ("Z37", &["Z20"]),               // Technische Ressource
47    ("Z38", &["Z19"]),               // Steuerbare Ressource
48];
49
50/// The same for UTILMD Gas, whose transactions name one location kind, the
51/// Meldepunkt: "Zur Referenzierung auf eine ID einer Messlokation vom SG5
52/// LOC+172" (UTILMD MIG Gas). Its Marktlokation reference (RFF+Z18, "die im
53/// Vorgang behandelte Marktlokation") names no LOC, so it is not judged.
54pub const REFERENCES_GAS: &[(&str, &[&str])] = &[("Z19", &["172"])];
55
56/// The consistency errors of one message (`segments`, UNH…UNT) of
57/// `message_type` (a variant, `UTILMD_Strom`, or a message type).
58pub fn check_consistency(segments: &[OwnedSegment], message_type: &str) -> Vec<ValidationIssue> {
59    let mut out = intervals(segments);
60    match message_type {
61        "UTILMD_Strom" => out.extend(references(segments, REFERENCES)),
62        "UTILMD_Gas" => out.extend(references(segments, REFERENCES_GAS)),
63        _ => {}
64    }
65    out.sort_by_key(|i| i.segment_position.map(|p| p.segment_number));
66    out
67}
68
69/// A DTM value as minutes since 0000-03-01 UTC, for formats 102
70/// (`CCYYMMDD`) and 303 (`CCYYMMDDHHMMZZZ`); `None` for anything else.
71fn instant(value: &str, format: &str) -> Option<i64> {
72    let digits = |s: &str| -> Option<i64> {
73        if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
74            return None;
75        }
76        s.parse().ok()
77    };
78    let (date, minutes, offset) = match format {
79        "102" if value.len() == 8 => (value, 0, 0),
80        "303" if value.len() == 15 => {
81            let hours = digits(&value[8..10])?;
82            let mins = digits(&value[10..12])?;
83            let sign = match &value[12..13] {
84                "+" => 1,
85                "-" => -1,
86                _ => return None,
87            };
88            (
89                &value[..8],
90                hours * 60 + mins,
91                sign * digits(&value[13..15])? * 60,
92            )
93        }
94        _ => return None,
95    };
96    let (y, m, d) = (
97        digits(&date[..4])?,
98        digits(&date[4..6])?,
99        digits(&date[6..8])?,
100    );
101    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
102        return None;
103    }
104    // Days since 0000-03-01 (H. Hinnant's days_from_civil, shifted).
105    let y = if m <= 2 { y - 1 } else { y };
106    let era = y.div_euclid(400);
107    let yoe = y - era * 400;
108    let mp = (m + 9) % 12;
109    let doy = (153 * mp + 2) / 5 + d - 1;
110    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
111    let days = era * 146_097 + doe;
112    Some(days * 24 * 60 + minutes - offset)
113}
114
115fn intervals(segments: &[OwnedSegment]) -> Vec<ValidationIssue> {
116    let mut out = Vec::new();
117    // DTMs sent side by side belong to one group; a period's two ends are
118    // always among them.
119    for run in segments.split(|s| s.id != "DTM") {
120        for &(begin, end, may_be_empty) in INTERVALS {
121            let find = |q: &str| run.iter().find(|s| s.get_component(0, 0) == q);
122            let (Some(b), Some(e)) = (find(begin), find(end)) else {
123                continue;
124            };
125            let format = b.get_component(0, 2);
126            if format != e.get_component(0, 2) {
127                continue;
128            }
129            let (Some(from), Some(to)) = (
130                instant(b.get_component(0, 1), format),
131                instant(e.get_component(0, 1), format),
132            ) else {
133                continue;
134            };
135            if to < from || (to == from && !may_be_empty) {
136                out.push(
137                    ValidationIssue::new(
138                        Severity::Error,
139                        IssueKind::IntervalNotPositive {
140                            begin: format!("DTM+{begin}"),
141                            end: format!("DTM+{end}"),
142                        },
143                    )
144                    .with_position(SegmentPosition::at(e.segment_number))
145                    .with_field_path("DTM/C507/2380")
146                    .with_field_position(2, Some(2))
147                    .with_actual(e.get_component(0, 1))
148                    .with_expected(format!("> {}", b.get_component(0, 1))),
149                );
150            }
151        }
152    }
153    out
154}
155
156fn references(segments: &[OwnedSegment], table: &[(&str, &[&str])]) -> Vec<ValidationIssue> {
157    let mut out = Vec::new();
158    // One transaction (SG4) after another: its LOCs (SG5), then the
159    // references of its SG8/SG12 groups.
160    for transaction in segments.split(|s| s.id == "IDE").skip(1) {
161        let mut locs: Vec<(&str, &str)> = Vec::new();
162        let mut in_sg8_or_sg12 = false;
163        for seg in transaction {
164            match seg.id.as_str() {
165                "LOC" if !in_sg8_or_sg12 => {
166                    locs.push((seg.get_element(0), seg.get_component(1, 0)));
167                }
168                "SEQ" | "NAD" => in_sg8_or_sg12 = true,
169                "RFF" if in_sg8_or_sg12 => {
170                    let qualifier = seg.get_component(0, 0);
171                    let id = seg.get_component(0, 1);
172                    let Some((_, kinds)) = table.iter().find(|(q, _)| *q == qualifier) else {
173                        continue;
174                    };
175                    let named: Vec<&str> = locs
176                        .iter()
177                        .filter(|(k, v)| kinds.contains(k) && !v.is_empty())
178                        .map(|(_, v)| *v)
179                        .collect();
180                    if id.is_empty() || named.is_empty() || named.contains(&id) {
181                        continue;
182                    }
183                    out.push(
184                        ValidationIssue::new(
185                            Severity::Error,
186                            IssueKind::ReferenceNotFound {
187                                reference: format!("RFF+{qualifier}"),
188                                value: id.to_string(),
189                                targets: kinds.iter().map(|k| format!("LOC+{k}")).collect(),
190                            },
191                        )
192                        .with_position(SegmentPosition::at(seg.segment_number))
193                        .with_field_path("RFF/C506/1154")
194                        .with_field_position(2, Some(2))
195                        .with_actual(id)
196                        .with_expected(named.join(", ")),
197                    );
198                }
199                _ => {}
200            }
201        }
202    }
203    out
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn message(text: &str) -> Vec<OwnedSegment> {
211        mig_assembly::tokenize::parse_to_segments(text.as_bytes()).unwrap()
212    }
213
214    #[test]
215    fn instants_compare_across_offsets() {
216        let a = instant("202501010000+00", "303").unwrap();
217        let b = instant("202501010100+01", "303").unwrap();
218        assert_eq!(a, b);
219        assert!(instant("20250301", "102").unwrap() > instant("20250228", "102").unwrap());
220        assert_eq!(instant("2025013", "102"), None);
221        assert_eq!(instant("202501010000+00", "610"), None);
222    }
223
224    #[test]
225    fn a_period_ending_as_or_before_it_begins_is_z34() {
226        let issues = |dtm: &str| {
227            check_consistency(
228                &message(&format!(
229                    "UNH+1+MSCONS:D:04B:UN:2.5'QTY+220:1'{dtm}UNT+5+1'"
230                )),
231                "MSCONS",
232            )
233        };
234        let backwards = issues("DTM+163:202501020000?+00:303'DTM+164:202501010000?+00:303'");
235        assert_eq!(backwards.len(), 1, "{backwards:?}");
236        assert_eq!(backwards[0].code(), "CON001");
237        assert_eq!(
238            backwards[0].segment_position.map(|p| p.segment_number),
239            Some(4)
240        );
241        assert_eq!(
242            issues("DTM+163:202501010000?+00:303'DTM+164:202501010000?+00:303'").len(),
243            1
244        );
245        assert!(issues("DTM+163:202501010000?+00:303'DTM+164:202501020000?+00:303'").is_empty());
246        // Different formats, other groups: not judged.
247        assert!(issues("DTM+163:20250102:102'DTM+164:202501010000?+00:303'").is_empty());
248        assert!(
249            issues("DTM+163:202501020000?+00:303'QTY+220:1'DTM+164:202501010000?+00:303'")
250                .is_empty()
251        );
252        // A window may close as it opens.
253        assert!(issues("DTM+469:202501010000?+00:303'DTM+472:202501010000?+00:303'").is_empty());
254    }
255
256    #[test]
257    fn a_reference_to_no_location_of_its_transaction_is_z21() {
258        let issues = |rff: &str| {
259            check_consistency(
260                &message(&format!(
261                    "UNH+1+UTILMD:D:11A:UN:S2.2'IDE+24+X'LOC+Z17+DE00MELO'LOC+Z18+E1814774964'\
262                     SEQ+Z03'{rff}UNT+7+1'"
263                )),
264                "UTILMD_Strom",
265            )
266        };
267        let wrong = issues("RFF+Z19:DE00ANDERE'");
268        assert_eq!(wrong.len(), 1, "{wrong:?}");
269        assert_eq!(wrong[0].code(), "CON002");
270        assert!(issues("RFF+Z19:DE00MELO'").is_empty());
271        // No Technische Ressource in the transaction: not judged.
272        assert!(issues("RFF+Z37:C123'").is_empty());
273        // Gas: a Messlokation is a Meldepunkt (LOC+172).
274        let gas = |rff: &str| {
275            check_consistency(
276                &message(&format!(
277                    "UNH+1+UTILMD:D:11A:UN:G1.2'IDE+24+X'LOC+172+DE00MELO'SEQ+Z03'{rff}UNT+6+1'"
278                )),
279                "UTILMD_Gas",
280            )
281        };
282        assert_eq!(gas("RFF+Z19:DE00ANDERE'").len(), 1);
283        assert!(gas("RFF+Z19:DE00MELO'").is_empty());
284        // Another message type: not judged.
285        assert!(check_consistency(
286            &message("UNH+1+UTILTS:D:18A:UN:1.1e'IDE+24+X'LOC+Z17+A'SEQ+Z03'RFF+Z19:B'UNT+6+1'"),
287            "UTILTS"
288        )
289        .is_empty());
290    }
291}