Skip to main content

cimvalidation/sparql/
ssh.rs

1use cimmodel::base::FastMap as HashMap;
2use cimmodel::CimDataset;
3use crate::Violation;
4use super::Fields;
5
6/// The rules of this profile's constraint files that read another profile's
7/// data, run on the merged dataset (`super::across_groups`).
8/// The CsConverter ranges compare SSH's operating mode with EQ's limits.
9pub fn validate_across(dataset: &CimDataset) -> Vec<Violation> {
10    check_cs_converter_value_range(dataset)
11}
12
13pub fn validate(dataset: &CimDataset) -> Vec<Violation> {
14    let mut v = Vec::new();
15    v.extend(check_energy_source_active_power_consumer(dataset));
16    v.extend(check_regulating_control_target_deadband_applicability(dataset));
17    v.extend(check_cs_converter_p_pcc_control(dataset));
18    v.extend(check_vs_converter_p_pcc_control(dataset));
19    v.extend(check_vs_converter_q_pcc_control(dataset));
20    // EnergySource-EnergySourcePQ is an `sh:and` node shape, which the CGMES
21    // shape table runs.
22    // The `sshn456:` rules below are the NotSolvedMAS file's, run by
23    // `ssh_not_solved_mas::validate` alone.
24    v
25}
26
27fn check_energy_source_active_power_consumer(dataset: &CimDataset) -> Vec<Violation> {
28    let mut v = Vec::new();
29    for mrid in dataset.by_type.get("EnergySource").into_iter().flatten() {
30        let entry = &dataset.entries[mrid];
31        if let Some(es) = Fields::of_class(entry, "EnergySource")
32            && es.f64("EnergySource.activePower").is_some_and(|p| p > 0.0) {
33                v.push(Violation {
34                    object_id:   mrid.clone(),
35                    rule_id:     "sshu:EnergySource.activePower-consumer".into(),
36                    name:        "C:301:SSH:EnergySource.activePower:consumer".into(),
37                    class:       "EnergySource".into(),
38                    property:    "EnergySource.activePower".into(),
39                    message:     "EnergySource that is a consumer (activePower > 0).".into(),
40                    severity:    "sh:Warning".into(),
41                    description: String::new(),
42                });
43            }
44    }
45    v
46}
47
48fn check_regulating_control_target_deadband_applicability(dataset: &CimDataset) -> Vec<Violation> {
49    let mut v = Vec::new();
50    // `discrete` is the path, so required; the deadband is `bound` or not —
51    // a deadband of 0 is a deadband.
52    let check = |mrid: &str, class: &str, has_deadband: bool, discrete: Option<bool>| -> Option<Violation> {
53        let discrete = discrete?;
54        if (has_deadband && !discrete) || (!has_deadband && discrete) {
55            Some(Violation {
56                object_id:   mrid.to_string(),
57                rule_id:     "sshu:RegulatingControl.targetDeadband-applicability".into(),
58                name:        "C:301:SSH:RegulatingControl.targetDeadband:applicability".into(),
59                class:       class.to_string(),
60                property:    "RegulatingControl.discrete".into(),
61                message:     "Either RegulatingControl.targetDeadband is provided for a continuous control or it is not provided for a discrete control.".into(),
62                severity:    "sh:Violation".into(),
63                description: String::new(),
64            })
65        } else {
66            None
67        }
68    };
69    for mrid in dataset.by_type.get("RegulatingControl").into_iter().flatten() {
70        let entry = &dataset.entries[mrid];
71        if let Some(rc) = Fields::of_class(entry, "RegulatingControl")
72            && let Some(viol) = check(mrid, "RegulatingControl", rc.has("RegulatingControl.targetDeadband"), rc.bool("RegulatingControl.discrete")) {
73                v.push(viol);
74            }
75    }
76    for mrid in dataset.by_type.get("TapChangerControl").into_iter().flatten() {
77        let entry = &dataset.entries[mrid];
78        if let Some(tcc) = Fields::of_class(entry, "TapChangerControl")
79            && let Some(viol) = check(mrid, "TapChangerControl", tcc.has("RegulatingControl.targetDeadband"), tcc.bool("RegulatingControl.discrete")) {
80                v.push(viol);
81            }
82    }
83    v
84}
85
86fn check_cs_converter_value_range(dataset: &CimDataset) -> Vec<Violation> {
87    let rectifier = "CsOperatingModeKind.rectifier";
88    let inverter  = "CsOperatingModeKind.inverter";
89    let mut v = Vec::new();
90    for mrid in dataset.by_type.get("CsConverter").into_iter().flatten() {
91        let entry = &dataset.entries[mrid];
92        if let Some(csc) = Fields::of_class(entry, "CsConverter") {
93            let mode = match csc.enumeration("CsConverter.operatingMode") { Some(r) => r, None => continue };
94            if mode == rectifier {
95                // As the SPARQL: a bound value is required, and an absent
96                // maximum leaves only the lower bound (`?value > ?max` is an
97                // error, so false, when ?max is unbound).
98                if csc.f64("CsConverter.maxAlpha").is_some_and(|max| max > 18.0) {
99                    v.push(Violation {
100                        object_id: mrid.clone(), rule_id: "sshu:CsConverter.maxAlpha-valueRangeTypical".into(),
101                        name: "C:301:EQ:CsConverter.maxAlpha:valueRangeTypical".into(), class: "CsConverter".into(),
102                        property: "CsConverter.maxAlpha".into(), message: "The maxAlpha value is greater than 18 for a rectifier.".into(),
103                        severity: "sh:Warning".into(), description: String::new(),
104                    });
105                }
106                if let Some(min_a) = csc.f64("CsConverter.minAlpha")
107                    && (min_a < 10.0 || csc.f64("CsConverter.maxAlpha").is_some_and(|max_a| min_a > max_a))
108                {
109                    v.push(Violation {
110                        object_id: mrid.clone(), rule_id: "sshu:CsConverter.minAlpha-valueRangeTypical".into(),
111                        name: "C:301:SV:CsConverter.minAlpha:valueRangeTypical".into(), class: "CsConverter".into(),
112                        property: "CsConverter.minAlpha".into(), message: "The minAlpha value is less than 10 or greater than CsConverter.maxAlpha for a rectifier.".into(),
113                        severity: "sh:Warning".into(), description: String::new(),
114                    });
115                }
116            } else if mode == inverter {
117                if csc.f64("CsConverter.maxGamma").is_some_and(|max| max > 20.0) {
118                    v.push(Violation {
119                        object_id: mrid.clone(), rule_id: "sshu:CsConverter.maxGamma-valueRangeTypical".into(),
120                        name: "C:301:EQ:CsConverter.maxGamma:valueRangeTypical".into(), class: "CsConverter".into(),
121                        property: "CsConverter.maxGamma".into(), message: "The maxGamma value is greater than 20 for an inverter.".into(),
122                        severity: "sh:Warning".into(), description: String::new(),
123                    });
124                }
125                if let Some(min_g) = csc.f64("CsConverter.minGamma")
126                    && (min_g < 17.0 || csc.f64("CsConverter.maxGamma").is_some_and(|max_g| min_g > max_g))
127                {
128                    v.push(Violation {
129                        object_id: mrid.clone(), rule_id: "sshu:CsConverter.minGamma-valueRangeTypical".into(),
130                        name: "C:301:SV:CsConverter.minGamma:valueRangeTypical".into(), class: "CsConverter".into(),
131                        property: "CsConverter.minGamma".into(), message: "The minGamma value is less than 17 or greater than CsConverter.maxGamma for an inverter.".into(),
132                        severity: "sh:Warning".into(), description: String::new(),
133                    });
134                }
135            }
136        }
137    }
138    v
139}
140
141fn check_cs_converter_p_pcc_control(dataset: &CimDataset) -> Vec<Violation> {
142    let dc_current   = "CsPpccControlKind.dcCurrent";
143    let dc_voltage   = "CsPpccControlKind.dcVoltage";
144    let active_power = "CsPpccControlKind.activePower";
145    let mut v = Vec::new();
146    for mrid in dataset.by_type.get("CsConverter").into_iter().flatten() {
147        let entry = &dataset.entries[mrid];
148        if let Some(csc) = Fields::of_class(entry, "CsConverter") {
149            let ctrl = match csc.enumeration("CsConverter.pPccControl") { Some(r) => r, None => continue };
150            // The target must be given (`!bound` fails); 0 is a target.
151            if ctrl == dc_current && !csc.has("CsConverter.targetIdc") {
152                v.push(Violation { object_id: mrid.clone(), rule_id: "sshu:CsConverter.pPccControl-targetValueIdc".into(),
153                    name: "C:301:SSH:CsPpccControlKind.dcCurrent:targetValueIdc".into(), class: "CsConverter".into(),
154                    property: "CsConverter.pPccControl".into(),
155                    message: "CsConverter.targetIdc is not provided for a converter with CsPpccControlKind.dcCurrent.".into(),
156                    severity: "sh:Violation".into(), description: String::new() });
157            } else if ctrl == dc_voltage && !csc.has("ACDCConverter.targetUdc") {
158                v.push(Violation { object_id: mrid.clone(), rule_id: "sshu:CsConverter.pPccControl-targetValueUdc".into(),
159                    name: "C:301:SSH:CsPpccControlKind.dcVoltage:targetValueUdc".into(), class: "CsConverter".into(),
160                    property: "CsConverter.pPccControl".into(),
161                    message: "ACDCConverter.targetUdc is not provided for a converter with CsPpccControlKind.dcVoltage.".into(),
162                    severity: "sh:Violation".into(), description: String::new() });
163            } else if ctrl == active_power && !csc.has("ACDCConverter.targetPpcc") {
164                v.push(Violation { object_id: mrid.clone(), rule_id: "sshu:CsConverter.pPccControl-targetValuePpcc".into(),
165                    name: "C:301:SSH:CsPpccControlKind.activePower:targetValuePpcc".into(), class: "CsConverter".into(),
166                    property: "CsConverter.pPccControl".into(),
167                    message: "ACDCConverter.targetPpcc is not provided for a converter with CsPpccControlKind.activePower.".into(),
168                    severity: "sh:Violation".into(), description: String::new() });
169            }
170        }
171    }
172    v
173}
174
175fn check_vs_converter_p_pcc_control(dataset: &CimDataset) -> Vec<Violation> {
176    let prefix = "VsPpccControlKind.";
177    let mut v = Vec::new();
178    for mrid in dataset.by_type.get("VsConverter").into_iter().flatten() {
179        let entry = &dataset.entries[mrid];
180        if let Some(vsc) = Fields::of_class(entry, "VsConverter") {
181            let ctrl = match vsc.enumeration("VsConverter.pPccControl") { Some(r) => r, None => continue };
182            // Each target missing (`!bound`); 0 is a target.
183            let ppcc      = !vsc.has("ACDCConverter.targetPpcc");
184            let udc       = !vsc.has("ACDCConverter.targetUdc");
185            let droop     = !vsc.has("VsConverter.droop");
186            let droopcomp = !vsc.has("VsConverter.droopCompensation");
187            let phase_pcc = !vsc.has("VsConverter.targetPhasePcc");
188            let (rule_id, name, msg): (&str, &str, Option<&str>) =
189                if ctrl == format!("{prefix}pPccAndUdcDroop") {
190                    ("sshu:VsConverter.pPccControl-targetValuepPccAndUdcDroop",
191                     "C:301:SSH:VsPpccControlKind.pPccAndUdcDroop:targetValuepPccAndUdcDroop",
192                     if ppcc || udc || droop {
193                         Some("One or all among ACDCConverter.targetPpcc, ACDCConverter.targetUdc and VsConverter.droop are not provided for VsPpccControlKind.pPccAndUdcDroop.")
194                     } else { None })
195                } else if ctrl == format!("{prefix}pPccAndUdcDroopWithCompensation") {
196                    ("sshu:VsConverter.pPccControl-targetValuepPccAndUdcDroopWithCompensation",
197                     "C:301:SSH:VsPpccControlKind.pPccAndUdcDroopWithCompensation:targetValuepPccAndUdcDroopWithCompensation",
198                     if ppcc || udc || droop || droopcomp {
199                         Some("One or all among ACDCConverter.targetPpcc, ACDCConverter.targetUdc, VsConverter.droop and VsConverter.droopCompensation are not provided for VsPpccControlKind.pPccAndUdcDroopWithCompensation.")
200                     } else { None })
201                } else if ctrl == format!("{prefix}pPccAndUdcDroopPilot") {
202                    ("sshu:VsConverter.pPccControl-targetValuepPccAndUdcDroopPilot",
203                     "C:301:SSH:VsPpccControlKind.pPccAndUdcDroopPilot:targetValuepPccAndUdcDroopPilot",
204                     if ppcc || udc || droop {
205                         Some("One or all among ACDCConverter.targetPpcc, ACDCConverter.targetUdc and VsConverter.droop are not provided for VsPpccControlKind.pPccAndUdcDroopPilot.")
206                     } else { None })
207                } else if ctrl == format!("{prefix}udc") {
208                    ("sshu:VsConverter.pPccControl-targetValueUdc",
209                     "C:301:SSH:VsPpccControlKind.udc:targetValueUdc",
210                     if udc { Some("ACDCConverter.targetUdc is not provided for VsPpccControlKind.udc.") } else { None })
211                } else if ctrl == format!("{prefix}pPcc") {
212                    ("sshu:VsConverter.pPccControl-targetValuePpcc",
213                     "C:301:SSH:VsPpccControlKind.pPcc:targetValuePpcc",
214                     if ppcc { Some("ACDCConverter.targetPpcc is not provided for VsPpccControlKind.pPcc.") } else { None })
215                } else if ctrl == format!("{prefix}phasePcc") {
216                    ("sshu:VsConverter.pPccControl-targetValuephasePcc",
217                     "C:301:SSH:VsPpccControlKind.phasePcc:targetValuephasePcc",
218                     if phase_pcc { Some("VsConverter.targetPhasePcc is not provided for VsPpccControlKind.phasePcc.") } else { None })
219                } else {
220                    continue;
221                };
222            if let Some(msg) = msg {
223                v.push(Violation { object_id: mrid.clone(), rule_id: rule_id.into(),
224                    name: name.into(), class: "VsConverter".into(),
225                    property: "VsConverter.pPccControl".into(), message: msg.into(),
226                    severity: "sh:Violation".into(), description: String::new() });
227            }
228        }
229    }
230    v
231}
232
233fn check_vs_converter_q_pcc_control(dataset: &CimDataset) -> Vec<Violation> {
234    let prefix = "VsQpccControlKind.";
235    let mut v = Vec::new();
236    for mrid in dataset.by_type.get("VsConverter").into_iter().flatten() {
237        let entry = &dataset.entries[mrid];
238        if let Some(vsc) = Fields::of_class(entry, "VsConverter") {
239            let ctrl = match vsc.enumeration("VsConverter.qPccControl") { Some(r) => r, None => continue };
240            let pf        = !vsc.has("VsConverter.targetPowerFactorPcc");
241            let pwm       = !vsc.has("VsConverter.targetPWMfactor");
242            let phase_pcc = !vsc.has("VsConverter.targetPhasePcc");
243            let qpcc      = !vsc.has("VsConverter.targetQpcc");
244            let upcc      = !vsc.has("VsConverter.targetUpcc");
245            let (rule_id, name, msg): (&str, &str, Option<&str>) =
246                if ctrl == format!("{prefix}powerFactorPcc") {
247                    ("sshu:VsConverter.qPccControl-targetValuepowerFactorPcc",
248                     "C:301:SSH:VsQpccControlKind.powerFactorPcc:targetValuepowerFactorPcc",
249                     if pf { Some("VsConverter.targetPowerFactorPcc is not provided for VsQpccControlKind.powerFactorPcc.") } else { None })
250                } else if ctrl == format!("{prefix}pulseWidthModulation") {
251                    ("sshu:VsConverter.qPccControl-targetValuepulseWidthModulation",
252                     "C:301:SSH:VsQpccControlKind.pulseWidthModulation:targetValuepulseWidthModulation",
253                     if pwm || phase_pcc {
254                         Some("VsConverter.targetPWMfactor and/or VsConverter.targetPhasePcc are not provided for VsQpccControlKind.pulseWidthModulation.")
255                     } else { None })
256                } else if ctrl == format!("{prefix}reactivePcc") {
257                    ("sshu:VsConverter.qPccControl-targetValuereactivePcc",
258                     "C:301:SSH:VsQpccControlKind.reactivePcc:targetValuereactivePcc",
259                     if qpcc { Some("VsConverter.targetQpcc is not provided for VsQpccControlKind.reactivePcc.") } else { None })
260                } else if ctrl == format!("{prefix}voltagePcc") {
261                    ("sshu:VsConverter.qPccControl-targetValuevoltagePcc",
262                     "C:301:SSH:VsQpccControlKind.voltagePcc:targetValuevoltagePcc",
263                     if upcc { Some("VsConverter.targetUpcc is not provided for VsQpccControlKind.voltagePcc.") } else { None })
264                } else {
265                    continue;
266                };
267            if let Some(msg) = msg {
268                v.push(Violation { object_id: mrid.clone(), rule_id: rule_id.into(),
269                    name: name.into(), class: "VsConverter".into(),
270                    property: "VsConverter.qPccControl".into(), message: msg.into(),
271                    severity: "sh:Violation".into(), description: String::new() });
272            }
273        }
274    }
275    v
276}
277
278pub(super) fn check_synchronous_machine_operating_mode_match(dataset: &CimDataset) -> Vec<Violation> {
279    let mut v = Vec::new();
280    for mrid in dataset.by_type.get("SynchronousMachine").into_iter().flatten() {
281        let entry = &dataset.entries[mrid];
282        if let Some(sm) = Fields::of_class(entry, "SynchronousMachine") {
283            let mode = match sm.enumeration("SynchronousMachine.operatingMode") { Some(r) => r, None => continue };
284            let kind = match sm.enumeration("SynchronousMachine.type") { Some(r) => r, None => continue };
285            let valid = if mode.ends_with("motor") {
286                kind.ends_with("motor") || kind.ends_with("generatorOrMotor") || kind.ends_with("motorOrCondenser") || kind.ends_with("generatorOrCondenserOrMotor")
287            } else if mode.ends_with("condenser") {
288                kind.ends_with("condenser") || kind.ends_with("generatorOrCondenser") || kind.ends_with("motorOrCondenser") || kind.ends_with("generatorOrCondenserOrMotor")
289            } else if mode.ends_with("generator") {
290                kind.ends_with("generator") || kind.ends_with("generatorOrMotor") || kind.ends_with("generatorOrCondenser") || kind.ends_with("generatorOrCondenserOrMotor")
291            } else {
292                // Another mode is no combination the SPARQL tests.
293                true
294            };
295            if !valid {
296                v.push(Violation {
297                    object_id: mrid.clone(), rule_id: "sshn456:SynchronousMachine.operatingMode-matchType".into(),
298                    name: "C:456:SSH:SynchronousMachine.operatingMode:matchType".into(), class: "SynchronousMachine".into(),
299                    property: "SynchronousMachine.operatingMode".into(),
300                    message: format!("SynchronousMachine.operatingMode ({}) is not consistent with SynchronousMachine.type ({}).", mode, kind),
301                    severity: "sh:Violation".into(), description: String::new(),
302                });
303            }
304        }
305    }
306    v
307}
308
309/// `C:456:SSH:NA:singleActivePowerSlack`: exactly one GeneratingUnit holds the
310/// highest `normalPF`, and that is not 0 — over the dataset, as the SPARQL
311/// counts it (its `MAX` and grouping run over untyped literals; the numbers
312/// are compared here). Like the SPARQL's single row, one finding, on the
313/// first unit holding the highest value.
314pub(super) fn check_generating_unit_single_active_power_slack(dataset: &CimDataset) -> Vec<Violation> {
315    let units: Vec<(&String, f64)> = dataset
316        .by_type
317        .iter()
318        .filter(|(class, _)| class.ends_with("GeneratingUnit") && !class.contains(':'))
319        .flat_map(|(_, mrids)| mrids)
320        .filter_map(|m| Fields::of(&dataset.entries[m]).f64("GeneratingUnit.normalPF").map(|pf| (m, pf)))
321        .collect();
322    let Some(max) = units.iter().map(|(_, pf)| *pf).reduce(f64::max) else { return Vec::new() };
323    let at_max: Vec<&String> = units.iter().filter(|(_, pf)| *pf == max).map(|(m, _)| *m).collect();
324    if max != 0.0 && at_max.len() == 1 {
325        return Vec::new();
326    }
327    let first = at_max.iter().min().expect("the maximum is held");
328    vec![Violation {
329        object_id: (*first).clone(), rule_id: "sshn456:GeneratingUnit-singleActivePowerSlack".into(),
330        name: "C:456:SSH:NA:singleActivePowerSlack".into(), class: dataset.entries[*first].type_name().into(),
331        property: "GeneratingUnit.normalPF".into(),
332        message: if max == 0.0 {
333            "The highest GeneratingUnit.normalPF is 0: no unit is the active power slack.".into()
334        } else {
335            format!("GeneratingUnit.normalPF {max} is the highest, and {} units have it.", at_max.len())
336        },
337        severity: "sh:Violation".into(), description: String::new(),
338    }]
339}
340
341pub(super) fn check_external_network_injection_limits(dataset: &CimDataset) -> Vec<Violation> {
342    let mut v = Vec::new();
343    for mrid in dataset.by_type.get("ExternalNetworkInjection").into_iter().flatten() {
344        let entry = &dataset.entries[mrid];
345        if let Some(eni) = Fields::of_class(entry, "ExternalNetworkInjection") {
346            // The value and both limits are required patterns; the SPARQL has
347            // no in-service condition.
348            if let (Some(p), Some(min_p), Some(max_p)) =
349                (eni.f64("ExternalNetworkInjection.p"), eni.f64("ExternalNetworkInjection.minP"), eni.f64("ExternalNetworkInjection.maxP"))
350                && let neg_p = if p == 0.0 { 0.0 } else { -p }
351                && (neg_p < min_p || neg_p > max_p) {
352                v.push(Violation {
353                    object_id: mrid.clone(), rule_id: "sshn456:ExternalNetworkInjection.p-limits".into(),
354                    name: "C:456:SSH:ExternalNetworkInjection.p:limits".into(), class: "ExternalNetworkInjection".into(),
355                    property: "p".into(),
356                    message: format!("Negated active power ({}) is outside of the range [Min:{}, Max:{}].", neg_p, min_p, max_p),
357                    severity: "sh:Violation".into(), description: String::new(),
358                });
359            }
360            if let (Some(q), Some(min_q), Some(max_q)) =
361                (eni.f64("ExternalNetworkInjection.q"), eni.f64("ExternalNetworkInjection.minQ"), eni.f64("ExternalNetworkInjection.maxQ"))
362                && let neg_q = if q == 0.0 { 0.0 } else { -q }
363                && (neg_q < min_q || neg_q > max_q) {
364                v.push(Violation {
365                    object_id: mrid.clone(), rule_id: "sshn456:ExternalNetworkInjection.q-limits".into(),
366                    name: "C:456:SSH:ExternalNetworkInjection.q:limits".into(), class: "ExternalNetworkInjection".into(),
367                    property: "q".into(),
368                    message: format!("Negated reactive power ({}) is outside of the range [Min:{}, Max:{}].", neg_q, min_q, max_q),
369                    severity: "sh:Violation".into(), description: String::new(),
370                });
371            }
372        }
373    }
374    v
375}
376
377pub(super) fn check_equivalent_injection_limits(dataset: &CimDataset) -> Vec<Violation> {
378    let mut v = Vec::new();
379    for mrid in dataset.by_type.get("EquivalentInjection").into_iter().flatten() {
380        let entry = &dataset.entries[mrid];
381        if let Some(ei) = Fields::of_class(entry, "EquivalentInjection") {
382            // The value and both limits are required patterns; the SPARQL has
383            // no in-service condition.
384            if let (Some(p), Some(min_p), Some(max_p)) =
385                (ei.f64("EquivalentInjection.p"), ei.f64("EquivalentInjection.minP"), ei.f64("EquivalentInjection.maxP"))
386                && let neg_p = if p == 0.0 { 0.0 } else { -p }
387                && (neg_p < min_p || neg_p > max_p) {
388                v.push(Violation {
389                    object_id: mrid.clone(), rule_id: "sshn456:EquivalentInjection.p-limits".into(),
390                    name: "C:456:SSH:EquivalentInjection.p:limits".into(), class: "EquivalentInjection".into(),
391                    property: "p".into(),
392                    message: format!("Negated active power ({}) is outside of the range [Min:{}, Max:{}].", neg_p, min_p, max_p),
393                    severity: "sh:Violation".into(), description: String::new(),
394                });
395            }
396            if let (Some(q), Some(min_q), Some(max_q)) =
397                (ei.f64("EquivalentInjection.q"), ei.f64("EquivalentInjection.minQ"), ei.f64("EquivalentInjection.maxQ"))
398                && let neg_q = if q == 0.0 { 0.0 } else { -q }
399                && (neg_q < min_q || neg_q > max_q) {
400                v.push(Violation {
401                    object_id: mrid.clone(), rule_id: "sshn456:EquivalentInjection.q-limits".into(),
402                    name: "C:456:SSH:EquivalentInjection.q:limits".into(), class: "EquivalentInjection".into(),
403                    property: "q".into(),
404                    message: format!("Negated reactive power ({}) is outside of the range [Min:{}, Max:{}].", neg_q, min_q, max_q),
405                    severity: "sh:Violation".into(), description: String::new(),
406                });
407            }
408        }
409    }
410    v
411}
412
413pub(super) fn check_rotating_machine_curve_limits(dataset: &CimDataset) -> Vec<Violation> {
414    // Curve MRID → (x, y1, y2) points. Built once instead of rescanning all CurveData per
415    // SynchronousMachine below.
416    // A point counts for P with its x value given, for Q with both y values.
417    type Point = (Option<f64>, Option<(f64, f64)>);
418    let mut curve_points: HashMap<String, Vec<Point>> = HashMap::default();
419    for cd_mrid in dataset.by_type.get("CurveData").into_iter().flatten() {
420        let cd_entry = &dataset.entries[cd_mrid];
421        if let Some(cd) = Fields::of_class(cd_entry, "CurveData")
422            && let Some(r) = &cd.reference("CurveData.Curve") {
423                let curve_id = r.trim_start_matches('#').to_string();
424                let ys = cd.f64("CurveData.y1value").zip(cd.f64("CurveData.y2value"));
425                curve_points.entry(curve_id).or_default().push((cd.f64("CurveData.xvalue"), ys));
426            }
427    }
428
429    let mut v = Vec::new();
430    for mrid in dataset.by_type.get("SynchronousMachine").into_iter().flatten() {
431        let entry = &dataset.entries[mrid];
432        if let Some(sm) = Fields::of_class(entry, "SynchronousMachine") {
433            // No in-service condition, as the SPARQL.
434            let rcc_id = match sm.reference("SynchronousMachine.InitialReactiveCapabilityCurve") {
435                Some(r) => r.trim_start_matches('#').to_string(),
436                None => continue,
437            };
438            let points = match curve_points.get(&rcc_id) { Some(p) => p, None => continue };
439            let xvals: Vec<f64> = points.iter().filter_map(|(x, _)| *x).collect();
440            let yvals: Vec<(f64, f64)> = points.iter().filter_map(|(_, y)| *y).collect();
441            let neg = |x: f64| if x == 0.0 { 0.0 } else { -x };
442            if let Some(p) = sm.f64("RotatingMachine.p")
443                && !xvals.is_empty()
444                && let min_x = xvals.iter().cloned().fold(f64::INFINITY, f64::min)
445                && let max_x = xvals.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
446                && let neg_p = neg(p)
447                && (neg_p < min_x || neg_p > max_x) {
448                v.push(Violation {
449                    object_id: mrid.clone(), rule_id: "sshn456:RotatingMachine-pAndQcapabilityCurveP".into(),
450                    name: "C:456:SSH:RotatingMachine:pAndQcapabilityCurve".into(), class: "SynchronousMachine".into(),
451                    property: "RotatingMachine.p".into(),
452                    message: format!("Negated active power ({}) is outside of curve x-range [{}, {}].", neg_p, min_x, max_x),
453                    severity: "sh:Violation".into(), description: String::new(),
454                });
455            }
456            if let Some(q) = sm.f64("RotatingMachine.q")
457                && !yvals.is_empty()
458                && let min_y1 = yvals.iter().map(|(y1, _)| *y1).fold(f64::INFINITY, f64::min)
459                && let max_y2 = yvals.iter().map(|(_, y2)| *y2).fold(f64::NEG_INFINITY, f64::max)
460                && let neg_q = neg(q)
461                && (neg_q < min_y1 || neg_q > max_y2) {
462                v.push(Violation {
463                    object_id: mrid.clone(), rule_id: "sshn456:RotatingMachine-pAndQcapabilityCurveQ".into(),
464                    name: "C:456:SSH:RotatingMachine:pAndQcapabilityCurve".into(), class: "SynchronousMachine".into(),
465                    property: "RotatingMachine.q".into(),
466                    message: format!("Negated reactive power ({}) is outside of curve y-range [{}, {}].", neg_q, min_y1, max_y2),
467                    severity: "sh:Violation".into(), description: String::new(),
468                });
469            }
470        }
471    }
472    v
473}
474
475pub(super) fn check_regulating_control_target_value_positive(dataset: &CimDataset) -> Vec<Violation> {
476    let mut v = Vec::new();
477    for class in ["RegulatingControl", "TapChangerControl"] {
478    for mrid in dataset.by_type.get(class).into_iter().flatten() {
479        let entry = &dataset.entries[mrid];
480        if let Some(rc) = Fields::of_class(entry, class)
481            && rc.enumeration("RegulatingControl.mode") == Some("RegulatingControlModeKind.voltage")
482                && rc.f64("RegulatingControl.targetValue").is_some_and(|t| t <= 0.0) {
483                    v.push(Violation {
484                        object_id: mrid.clone(), rule_id: "sshn456:RegulatingControl.targetValue-value".into(),
485                        name: "C:456:SSH:RegulatingControl.targetValue:value".into(), class: class.into(),
486                        property: "targetValue".into(),
487                        message: "RegulatingControl.targetValue shall be positive value in cases where the RegulatingControl.mode is set to voltage.".into(),
488                        severity: "sh:Violation".into(), description: String::new(),
489                    });
490                }
491    }
492    }
493    v
494}