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