1use cimmodel::base::FastMap as HashMap;
2use cimmodel::CimDataset;
3use crate::Violation;
4use super::Fields;
5
6pub 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 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 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 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 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 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 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
309pub(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 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 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 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 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}