Skip to main content

redispatch_xml/validation/
semantic.rs

1//! Semantic validation — cross-field rules from the BDEW AWT.
2//!
3//! These rules require context from more than one field and cannot be derived
4//! from the XSD alone.
5
6use super::{ValidationError, ValidationResult};
7use crate::documents::activation::ActivationDocType;
8use crate::parse::Document;
9
10/// Run semantic checks on any [`Document`] variant.
11pub fn validate(doc: &Document, result: &mut ValidationResult) {
12    match doc {
13        Document::Activation(d) => {
14            // ACO (A96) and ACR (A41) documents must carry at least one time series.
15            match d.document_type.v {
16                ActivationDocType::RedispatchActivation | ActivationDocType::ActivationResponse => {
17                    if d.time_series.is_empty() {
18                        result.errors.push(ValidationError::Semantic(
19                            "ACO/ACR ActivationDocument must contain at least one ActivationTimeSeries"
20                                .to_string(),
21                        ));
22                    }
23                }
24                // AAR (A42) may have zero time series (tender reduction).
25                ActivationDocType::TenderReduction => {}
26            }
27        }
28        Document::Kostenblatt(d) => {
29            if d.time_series.is_empty() {
30                result.errors.push(ValidationError::Semantic(
31                    "Kostenblatt must contain at least one CostTimeSeries".to_string(),
32                ));
33            }
34        }
35        Document::PlannedResourceSchedule(d) => {
36            if d.time_series.is_empty() {
37                result.errors.push(ValidationError::Semantic(
38                    "PlannedResourceScheduleDocument must contain at least one PlannedResourceTimeSeries"
39                        .to_string(),
40                ));
41            }
42        }
43        Document::Stammdaten(d) => {
44            // A Stammdaten document must describe at least one SR_Objekt
45            // (controllable resource) unless it is a deactivation/withdrawal.
46            use crate::documents::stammdaten::{Bilanzierungsmodell, Meldungsstatus};
47            if d.meldungsstatus != Meldungsstatus::Deactivation && d.sr_objekte.is_empty() {
48                result.errors.push(ValidationError::Semantic(
49                    "Stammdaten (creation/update) must contain at least one SR_Objekt".to_string(),
50                ));
51            }
52            for (i, sr) in d.sr_objekte.iter().enumerate() {
53                // BilAReM Kap. 6.1.5: „Eine SR setzt sich aus mindestens einer
54                // TR zusammen." The XSD says minOccurs="1"; an SR with none is
55                // a resource nothing can be dispatched against.
56                if sr.enthaltene_tr.is_empty() {
57                    result.errors.push(ValidationError::Semantic(format!(
58                        "SR_Objekt[{i}] contains no Enthaltene_TR — BilAReM Kap. 6.1.5 \
59                         requires at least one Technische Ressource per Steuerbare Ressource"
60                    )));
61                }
62
63                // The Individuelle_Quote shares are percentages of one
64                // bilanzieller Ausgleich, so they have to add up. A short set
65                // books less than the Maßnahme caused and an over-long one
66                // books more, and neither is visible downstream: each Fahrplan
67                // on its own looks well-formed.
68                if let Some(q) = &sr.individuelle_quote {
69                    let summe: f64 = q.quoten.iter().map(|x| x.wert.value()).sum();
70                    // Decimal3 is three fractional digits, so anything further
71                    // from 100 than half a unit in the last place is a real
72                    // discrepancy rather than binary rounding.
73                    if (summe - 100.0).abs() > 0.000_5 {
74                        result.errors.push(ValidationError::Semantic(format!(
75                            "SR_Objekt[{i}] Individuelle_Quote sums to {summe} %, not 100 %"
76                        )));
77                    }
78                }
79
80                // BilAReM Kap. 2.3.2 lists the Redispatch-Bilanzkreis among the
81                // three things a Planwertmodell Zuordnung must name. Without it
82                // the LF and EIV learn that an SR moved into the Planwertmodell
83                // but not where the Ausgleich will be booked.
84                let nennt_bilanzkreis = d.bilanzkreis_ausgleichsfahrplan_anf_nb.is_some()
85                    || sr
86                        .individuelle_quote
87                        .as_ref()
88                        .is_some_and(|q| !q.quoten.is_empty());
89                if sr.bilanzierungsmodell == Bilanzierungsmodell::Planwert
90                    && d.meldungsstatus != Meldungsstatus::Deactivation
91                    && !nennt_bilanzkreis
92                {
93                    result.errors.push(ValidationError::Semantic(format!(
94                        "SR_Objekt[{i}] is in the Planwertmodell but the document names no \
95                         Redispatch-Bilanzkreis (neither Individuelle_Quote nor \
96                         Bilanzkreis_Ausgleichsfahrplan_anfNB) — BilAReM Kap. 2.3.2"
97                    )));
98                }
99            }
100        }
101        Document::NetworkConstraint(d) => {
102            // A NetworkConstraintDocument without a withdrawal status must carry
103            // at least one time series.
104            if d.doc_status.is_none() && d.time_series.is_empty() {
105                result.errors.push(ValidationError::Semantic(
106                    "NetworkConstraintDocument must contain at least one NetworkConstraintTimeSeries \
107                     (or carry a DocStatus withdrawal)"
108                        .to_string(),
109                ));
110            }
111        }
112        Document::Unavailability(d) => {
113            // An unavailability document without a docStatus must carry at least
114            // one TimeSeries.
115            if d.doc_status.is_none() && d.time_series.is_empty() {
116                result.errors.push(ValidationError::Semantic(
117                    "Unavailability_MarketDocument must contain at least one TimeSeries \
118                     (or carry a docStatus withdrawal)"
119                        .to_string(),
120                ));
121            }
122        }
123        // Acknowledgement, StatusRequest, Kaskade: no additional semantic rules.
124        _ => {}
125    }
126}