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}