1mod read;
27mod write;
28mod xml;
29
30use std::collections::BTreeSet;
31use std::io::{Cursor, Read};
32use std::path::{Component, Path};
33
34use powerio_core::{ArtifactPath, DiagnosticInfo, MemoryArtifact, Source};
35
36use crate::diagnostics::{Diagnostics, codes};
37use crate::network::BalancedNetwork;
38use crate::{Error, Result};
39
40const MAX_FILES: usize = 4_096;
41const MAX_BYTES: u64 = 64 << 20;
42const MAX_COMPRESSION_RATIO: u64 = 200;
43const POWERIO_MODELING_AUTHORITY_SET: &str = "http://powerio.dev/cgmes";
47const CGMES_CLASS_PROPERTY: &str = "cgmes_class";
48const CGMES_SV_STATUS_PROPERTY: &str = "SvStatus.inService";
49const CGMES_GENERATING_UNIT_PROPERTY: &str = "RotatingMachine.GeneratingUnit";
50const CGMES_REGULATING_CONTROL_PROPERTY: &str = "RegulatingCondEq.RegulatingControl";
51const CGMES_SV_VOLTAGE_AUTHORITY_MISMATCH_PROPERTY: &str =
52 "powerio.cgmes.sv_voltage_authority_mismatch";
53
54#[derive(Debug, Clone)]
55struct CgmesDiagnostic {
56 info: &'static DiagnosticInfo,
57 message: String,
58}
59
60impl std::ops::Deref for CgmesDiagnostic {
61 type Target = str;
62
63 fn deref(&self) -> &Self::Target {
64 &self.message
65 }
66}
67
68#[derive(Debug, Clone)]
69struct CgmesDiagnostics {
70 default_info: &'static DiagnosticInfo,
71 records: Vec<CgmesDiagnostic>,
72}
73
74impl CgmesDiagnostics {
75 fn new(default_info: &'static DiagnosticInfo) -> Self {
76 Self {
77 default_info,
78 records: Vec::new(),
79 }
80 }
81
82 fn push(&mut self, message: impl Into<String>) {
83 self.push_as(self.default_info, message);
84 }
85
86 fn push_as(&mut self, info: &'static DiagnosticInfo, message: impl Into<String>) {
87 self.records.push(CgmesDiagnostic {
88 info,
89 message: message.into(),
90 });
91 }
92
93 fn extend(&mut self, other: Self) {
94 self.records.extend(other.records);
95 }
96}
97
98impl std::ops::Deref for CgmesDiagnostics {
99 type Target = [CgmesDiagnostic];
100
101 fn deref(&self) -> &Self::Target {
102 &self.records
103 }
104}
105
106impl IntoIterator for CgmesDiagnostics {
107 type Item = CgmesDiagnostic;
108 type IntoIter = std::vec::IntoIter<CgmesDiagnostic>;
109
110 fn into_iter(self) -> Self::IntoIter {
111 self.records.into_iter()
112 }
113}
114
115pub(crate) fn looks_like_profile_set(source: &Source) -> bool {
116 if source.is_directory() {
117 return source.entry_names().is_ok_and(|entries| {
118 entries.iter().any(|entry| match extension(entry.as_str()) {
119 Some("zip") => true,
120 Some("xml") => source
121 .buffer(entry)
122 .is_ok_and(|buffer| looks_like_cgmes_xml(buffer.content_bytes())),
123 _ => false,
124 })
125 });
126 }
127 match extension(source.name()) {
128 Some("zip") => true,
129 Some("xml") => source
130 .primary_buffer()
131 .is_ok_and(|buffer| looks_like_cgmes_xml(buffer.content_bytes())),
132 _ => false,
133 }
134}
135
136fn looks_like_cgmes_xml(bytes: &[u8]) -> bool {
137 let head = &bytes[..bytes.len().min(16_384)];
138 let text = String::from_utf8_lossy(head);
139 text.contains("rdf:RDF")
140 && (text.contains("CIM-schema-cim16") || text.contains("CIM100#"))
141 && text.contains("FullModel")
142}
143
144pub(crate) fn parse_source(
145 source: &Source,
146 diagnostics: &mut Diagnostics,
147) -> Result<BalancedNetwork> {
148 let documents = acquire_documents(source)?;
149 let name = Path::new(source.name())
150 .file_stem()
151 .and_then(|stem| stem.to_str());
152 read_documents(documents, name, diagnostics)
153}
154
155fn read_documents(
158 documents: Vec<(String, String)>,
159 name: Option<&str>,
160 diagnostics: &mut Diagnostics,
161) -> Result<BalancedNetwork> {
162 let mut warnings = CgmesDiagnostics::new(&codes::READ_CGMES_RECORD_UNMAPPED);
163 let result = read::read_cgmes_documents_into(documents, name, &mut warnings);
164 for diagnostic in warnings {
165 diagnostics.push(diagnostic.info, diagnostic.message);
166 }
167 result
168}
169
170pub(crate) fn parse_text(
171 name: &str,
172 text: &str,
173 diagnostics: &mut Diagnostics,
174) -> Result<BalancedNetwork> {
175 reject_unsafe_xml(text.as_bytes())?;
176 read_documents(
177 vec![(name.to_string(), text.to_string())],
178 None,
179 diagnostics,
180 )
181}
182
183fn acquire_documents(source: &Source) -> Result<Vec<(String, String)>> {
184 let mut documents = Vec::new();
185 let mut names = BTreeSet::new();
186 let mut total = 0_u64;
187 if source.is_directory() {
188 for name in source.entry_names().map_err(|error| source_error(&error))? {
189 match extension(name.as_str()) {
190 Some("xml") => {
191 let buffer = source.buffer(&name).map_err(|error| source_error(&error))?;
192 push_xml(
193 name.as_str(),
194 name.as_str(),
195 buffer.content_bytes(),
196 &mut documents,
197 &mut names,
198 &mut total,
199 )?;
200 }
201 Some("zip") => {
202 let buffer = source.buffer(&name).map_err(|error| source_error(&error))?;
203 push_zip(
204 name.as_str(),
205 buffer.content_bytes(),
206 &mut documents,
207 &mut names,
208 &mut total,
209 )?;
210 }
211 _ => {}
212 }
213 }
214 } else {
215 let buffer = source
216 .primary_buffer()
217 .map_err(|error| source_error(&error))?;
218 if extension(source.name()) == Some("zip")
219 || buffer.content_bytes().starts_with(b"PK\x03\x04")
220 {
221 push_zip(
222 source.name(),
223 buffer.content_bytes(),
224 &mut documents,
225 &mut names,
226 &mut total,
227 )?;
228 } else {
229 push_xml(
230 source.name(),
231 source.name(),
232 buffer.content_bytes(),
233 &mut documents,
234 &mut names,
235 &mut total,
236 )?;
237 }
238 }
239 if documents.is_empty() {
240 return Err(format_error(
241 "the source contains no CGMES XML profile documents",
242 ));
243 }
244 Ok(documents)
245}
246
247fn push_zip(
248 archive_name: &str,
249 bytes: &[u8],
250 documents: &mut Vec<(String, String)>,
251 names: &mut BTreeSet<String>,
252 total: &mut u64,
253) -> Result<()> {
254 if bytes.len() as u64 > MAX_BYTES {
255 return Err(format_error(format!(
256 "archive {archive_name} exceeds the {MAX_BYTES} byte input limit"
257 )));
258 }
259 let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
260 .map_err(|error| format_error(format!("cannot read archive {archive_name}: {error}")))?;
261 if archive.len() > MAX_FILES {
262 return Err(format_error(format!(
263 "archive {archive_name} contains more than {MAX_FILES} entries"
264 )));
265 }
266 for index in 0..archive.len() {
267 let mut file = archive.by_index(index).map_err(|error| {
268 format_error(format!(
269 "cannot read entry {index} from {archive_name}: {error}"
270 ))
271 })?;
272 if file.is_dir() {
273 continue;
274 }
275 let raw_name = file.name().to_string();
276 let path = strict_archive_path(&raw_name)?;
277 if file
278 .unix_mode()
279 .is_some_and(|mode| mode & 0o170_000 == 0o120_000)
280 {
281 return Err(format_error(format!(
282 "archive {archive_name} contains symbolic link {raw_name}"
283 )));
284 }
285 let size = file.size();
286 let compressed = file.compressed_size();
287 if size > MAX_BYTES || exceeds_compression_ratio(size, compressed) {
288 return Err(format_error(format!(
289 "archive entry {raw_name} exceeds the CGMES decompression limits"
290 )));
291 }
292 let prefix_length = usize::try_from(size.min(4)).expect("four bytes fit usize");
293 let mut prefix = vec![0_u8; prefix_length];
294 file.read_exact(&mut prefix)
295 .map_err(|error| format_error(format!("cannot decompress {raw_name}: {error}")))?;
296 if is_zip_signature(&prefix) {
297 return Err(format_error(format!(
298 "archive {archive_name} contains nested archive {raw_name}"
299 )));
300 }
301 if extension(path.as_str()) != Some("xml") {
302 continue;
303 }
304 let remaining = MAX_BYTES.saturating_sub(*total);
305 if size > remaining {
306 return Err(format_error(
307 "CGMES profile data exceeds the 64 MiB input limit",
308 ));
309 }
310 let mut content = Vec::with_capacity(usize::try_from(size).unwrap_or(0));
311 content.extend_from_slice(&prefix);
312 file.by_ref()
313 .take(remaining.saturating_sub(prefix.len() as u64) + 1)
314 .read_to_end(&mut content)
315 .map_err(|error| format_error(format!("cannot decompress {raw_name}: {error}")))?;
316 push_xml(
317 &format!("{archive_name}/{raw_name}"),
318 path.as_str(),
319 &content,
320 documents,
321 names,
322 total,
323 )?;
324 }
325 Ok(())
326}
327
328fn exceeds_compression_ratio(size: u64, compressed: u64) -> bool {
329 size > 0 && (compressed == 0 || size > compressed.saturating_mul(MAX_COMPRESSION_RATIO))
330}
331
332fn is_zip_signature(bytes: &[u8]) -> bool {
333 matches!(
334 bytes,
335 [b'P', b'K', 0x03, 0x04, ..] | [b'P', b'K', 0x05, 0x06, ..] | [b'P', b'K', 0x07, 0x08, ..]
336 )
337}
338
339fn push_xml(
340 name: &str,
341 normalized_name: &str,
342 bytes: &[u8],
343 documents: &mut Vec<(String, String)>,
344 names: &mut BTreeSet<String>,
345 total: &mut u64,
346) -> Result<()> {
347 if documents.len() >= MAX_FILES {
348 return Err(format_error(format!(
349 "CGMES profile set contains more than {MAX_FILES} XML documents"
350 )));
351 }
352 let size = bytes.len() as u64;
353 *total = total
354 .checked_add(size)
355 .filter(|sum| *sum <= MAX_BYTES)
356 .ok_or_else(|| format_error("CGMES profile data exceeds the 64 MiB input limit"))?;
357 reject_unsafe_xml(bytes)?;
358 let key = normalized_name.replace('\\', "/").to_ascii_lowercase();
359 if !names.insert(key) {
360 return Err(format_error(format!(
361 "CGMES profile set contains duplicate normalized name {normalized_name}"
362 )));
363 }
364 let text = std::str::from_utf8(bytes)
365 .map_err(|error| format_error(format!("{name} is not UTF-8 XML: {error}")))?;
366 documents.push((name.to_string(), text.to_string()));
367 Ok(())
368}
369
370fn strict_archive_path(name: &str) -> Result<ArtifactPath> {
371 if name.contains('\\') || name.contains('\0') || name.contains(':') {
372 return Err(format_error(format!("unsafe archive entry name {name}")));
373 }
374 let path = Path::new(name);
375 if path.is_absolute()
376 || path
377 .components()
378 .any(|component| !matches!(component, Component::Normal(_)))
379 {
380 return Err(format_error(format!("unsafe archive entry name {name}")));
381 }
382 ArtifactPath::new(name.to_string()).map_err(|error| source_error(&error))
383}
384
385fn reject_unsafe_xml(bytes: &[u8]) -> Result<()> {
386 let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
387 if text.contains("<!doctype") || text.contains("<!entity") {
388 return Err(format_error(
389 "CGMES XML must not contain a DTD or entity declaration",
390 ));
391 }
392 Ok(())
393}
394
395fn extension(name: &str) -> Option<&'static str> {
396 let extension = Path::new(name)
397 .extension()
398 .and_then(|extension| extension.to_str())?;
399 if extension.eq_ignore_ascii_case("xml") {
400 Some("xml")
401 } else if extension.eq_ignore_ascii_case("zip") {
402 Some("zip")
403 } else {
404 None
405 }
406}
407
408fn source_error(error: &powerio_core::Error) -> Error {
409 format_error(error.to_string())
410}
411
412fn format_error(message: impl Into<String>) -> Error {
413 Error::FormatRead {
414 format: "CGMES",
415 message: message.into(),
416 }
417}
418
419pub(crate) fn artifacts(network: &BalancedNetwork) -> Result<(Vec<MemoryArtifact>, Diagnostics)> {
420 let output = write::write_cgmes(network, CgmesVersion::V3_0)?;
421 let artifacts = output
422 .files
423 .into_iter()
424 .map(|(name, text)| {
425 MemoryArtifact::new(
426 ArtifactPath::new(name).expect("CGMES writer emits portable fixed names"),
427 text.into_bytes(),
428 )
429 })
430 .collect();
431 let mut diagnostics = Diagnostics::new();
432 for diagnostic in output.warnings {
433 diagnostics.push(diagnostic.info, diagnostic.message);
434 }
435 Ok((artifacts, diagnostics))
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440#[non_exhaustive]
441pub enum CgmesVersion {
442 V2_4_15,
444 V3_0,
446}
447
448impl CgmesVersion {
449 pub(crate) fn from_namespace(ns: &str) -> Option<Self> {
450 if ns.contains("CIM100") {
451 Some(CgmesVersion::V3_0)
452 } else if ns.contains("CIM-schema-cim16") {
453 Some(CgmesVersion::V2_4_15)
454 } else {
455 None
456 }
457 }
458
459 #[must_use]
460 pub fn label(self) -> &'static str {
461 match self {
462 CgmesVersion::V2_4_15 => "CGMES 2.4.15 (CIM16)",
463 CgmesVersion::V3_0 => "CGMES 3.0 (CIM100)",
464 }
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use std::fmt::Write as _;
471 use std::io::{Cursor, Write};
472 use std::sync::Arc;
473
474 use powerio_core::{ComponentId, Destination, EmittedOutput, PioModule, Source};
475
476 use super::*;
477 use crate::TargetFormat;
478 use crate::network::{
479 AcDcConverterControlMode, ActivePowerControl, Area, BoundaryLine, Branch,
480 BranchCurrentRatings, BranchSolution, Bus, BusBreakerBus, BusId, BusType, BusbarSection,
481 CalculatedBus, CaseMetadata, ComponentMetadata, ConnectivityNode, CurveStyle, DcBusbar,
482 DcConverterOperatingMode, DcConverterUnit, DcGround, DcLine, DcNode, DcPolarity,
483 DcSeriesDevice, DcSwitch, DcSwitchKind, DcTerminal, DcTopologicalNode,
484 DetailedConnectivity, EquipmentReactiveLimits, ExternalIdentifier, Generator,
485 GeneratorEnergySource, Hvdc, Impedance, Junction, LineCommutatedConverter,
486 LineCommutatedConverterOperatingMode, LineCommutatedConverterReactiveModel, Load,
487 LoadVoltageModel, LoadingLimits, MinMaxReactiveLimits, OmittedField, OmittedFieldName,
488 OperationalLimitGroup, ReactiveCapabilityCurve, ReactiveCapabilityCurvePoint,
489 ReactiveLimits, Shunt, ShuntBlock, StaticVarCompensator,
490 StaticVarCompensatorRegulationMode, Subnetwork, Substation, Switch, SwitchKind,
491 SwitchedShuntControl, SwitchedShuntMode, TapChanger, TapChangerKind,
492 TapChangerRegulationMode, TapChangerStep, TemporaryLimit, Terminal, TerminalReference,
493 TieLine, TopologyEndpoint, TopologyKind, TopologySwitch, Transformer3W, VoltageLevel,
494 VoltageSourceConverter, Winding,
495 };
496
497 fn component(component_type: &str, local_id: &str) -> ComponentId {
498 ComponentId::new(component_type, local_id).unwrap()
499 }
500
501 fn network() -> BalancedNetwork {
502 let mut network = BalancedNetwork::new("cgmes-test", 100.0);
503 let mut first = Bus::new(BusId(1), BusType::Ref, 230.0);
504 first.uid = Some("bus-1".into());
505 let mut second = Bus::new(BusId(2), BusType::Pq, 230.0);
506 second.uid = Some("bus-2".into());
507 *network.buses_mut() = vec![first, second];
508 network.loads_mut().push(Load::new(BusId(2), 20.0, 5.0));
509 let mut generator = Generator::new(BusId(1));
510 generator.pg = 20.0;
511 generator.pmax = 100.0;
512 generator.qmin = -50.0;
513 generator.qmax = 50.0;
514 network.generators_mut().push(generator);
515 network
516 .branches_mut()
517 .push(Branch::new(BusId(1), BusId(2), 0.01, 0.1));
518 network.assign_missing_component_ids();
519 network
520 }
521
522 #[test]
523 fn derived_cgmes_limits_report_separate_current_ratings() {
524 let mut network = network();
525 network.branches_mut()[0].rate_a = 100.0;
526 network.branches_mut()[0].current_ratings =
527 Some(BranchCurrentRatings::new(500.0, 600.0, 700.0));
528
529 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
530 assert!(output.warnings.iter().any(|warning| {
531 warning.info.code == codes::EMIT_CGMES.field_dropped.code
532 && warning.contains("current rating record dropped")
533 }));
534 }
535
536 #[test]
537 fn a_v2415_permanent_limit_states_no_acceptable_duration() {
538 let mut network = network();
539 network.branches_mut()[0].rate_a = 100.0;
540
541 let output = write::write_cgmes(&network, CgmesVersion::V2_4_15).unwrap();
542 let xml = output
543 .files
544 .iter()
545 .map(|(_, text)| text.as_str())
546 .collect::<Vec<_>>()
547 .join("\n");
548 let permanent = xml
549 .split("<cim:OperationalLimitType ")
550 .nth(1)
551 .and_then(|text| text.split("</cim:OperationalLimitType>").next())
552 .expect("a positive rate_a emits one limit type");
553 assert!(permanent.contains("<cim:IdentifiedObject.name>patl</"));
554 assert!(!permanent.contains("acceptableDuration"), "{permanent}");
555 }
556
557 fn append_vs_converter_records(
558 records: &mut String,
559 id_prefix: &str,
560 dc_unit_id: &str,
561 rated_voltages_kv: &[f64],
562 ) {
563 for (index, value) in rated_voltages_kv.iter().enumerate() {
564 let _ = write!(
565 records,
566 r##" <cim:VsConverter rdf:ID="_{id_prefix}converter-{index}">
567 <cim:Equipment.EquipmentContainer rdf:resource="#{dc_unit_id}"/>
568 <cim:ACDCConverter.ratedUdc>{value}</cim:ACDCConverter.ratedUdc>
569 </cim:VsConverter>
570"##
571 );
572 }
573 }
574
575 fn insert_profile_records(
576 documents: Vec<(String, String)>,
577 equipment: &str,
578 topology: Option<&str>,
579 steady_state_hypothesis: Option<&str>,
580 ) -> Vec<(String, String)> {
581 documents
582 .into_iter()
583 .map(|(name, text)| {
584 let records = if name.ends_with("_EQ.xml") {
585 Some(equipment)
586 } else if name.ends_with("_TP.xml") {
587 topology
588 } else if name.ends_with("_SSH.xml") {
589 steady_state_hypothesis
590 } else {
591 None
592 };
593 match records {
594 Some(records) => (
595 name,
596 text.replace("</rdf:RDF>", &format!("{records}</rdf:RDF>")),
597 ),
598 None => (name, text),
599 }
600 })
601 .collect()
602 }
603
604 fn voltage_limit_documents(
605 version: CgmesVersion,
606 voltage_level_limits: (f64, f64),
607 operational_limits: (f64, f64),
608 ) -> Vec<(String, String)> {
609 const VOLTAGE_LEVEL_MRID: &str = "10000000-0000-4000-8000-000000000001";
610 const BUSBAR_MRID: &str = "10000000-0000-4000-8000-000000000002";
611
612 let mut network = detailed_network();
613 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
614 detailed.voltage_levels[0].low_voltage_limit_kv = None;
615 detailed.voltage_levels[0].high_voltage_limit_kv = None;
616 for (component, mrid) in [
617 (&detailed.voltage_levels[0].component, VOLTAGE_LEVEL_MRID),
618 (&detailed.busbar_sections[0].component, BUSBAR_MRID),
619 ] {
620 detailed
621 .component_metadata
622 .iter_mut()
623 .find(|metadata| metadata.component == *component)
624 .unwrap()
625 .external_identifiers = vec![ExternalIdentifier {
626 value: mrid.into(),
627 authority: Some("CGMES".into()),
628 }];
629 }
630
631 let (kind_property, kind_namespace, value_property) = match version {
632 CgmesVersion::V2_4_15 => (
633 "entsoe:OperationalLimitType.limitType",
634 "http://entsoe.eu/CIM/SchemaExtension/3/1#LimitTypeKind",
635 "VoltageLimit.value",
636 ),
637 CgmesVersion::V3_0 => (
638 "eu:OperationalLimitType.kind",
639 "http://iec.ch/TC57/CIM100-European#LimitKind",
640 "VoltageLimit.normalValue",
641 ),
642 };
643 let (level_low, level_high) = voltage_level_limits;
644 let (operational_low, operational_high) = operational_limits;
645 let records = format!(
646 r##" <cim:VoltageLevel rdf:about="#_{VOLTAGE_LEVEL_MRID}">
647 <cim:VoltageLevel.lowVoltageLimit>{level_low}</cim:VoltageLevel.lowVoltageLimit>
648 <cim:VoltageLevel.highVoltageLimit>{level_high}</cim:VoltageLevel.highVoltageLimit>
649 </cim:VoltageLevel>
650 <cim:OperationalLimitSet rdf:ID="_voltage-limit-set">
651 <cim:OperationalLimitSet.Equipment rdf:resource="#_{BUSBAR_MRID}"/>
652 </cim:OperationalLimitSet>
653 <cim:OperationalLimitType rdf:ID="_low-voltage-limit-type">
654 <{kind_property} rdf:resource="{kind_namespace}.lowVoltage"/>
655 </cim:OperationalLimitType>
656 <cim:OperationalLimitType rdf:ID="_high-voltage-limit-type">
657 <{kind_property} rdf:resource="{kind_namespace}.highVoltage"/>
658 </cim:OperationalLimitType>
659 <cim:VoltageLimit rdf:ID="_low-voltage-limit">
660 <cim:OperationalLimit.OperationalLimitSet rdf:resource="#_voltage-limit-set"/>
661 <cim:OperationalLimit.OperationalLimitType rdf:resource="#_low-voltage-limit-type"/>
662 <cim:{value_property}>{operational_low}</cim:{value_property}>
663 </cim:VoltageLimit>
664 <cim:VoltageLimit rdf:ID="_high-voltage-limit">
665 <cim:OperationalLimit.OperationalLimitSet rdf:resource="#_voltage-limit-set"/>
666 <cim:OperationalLimit.OperationalLimitType rdf:resource="#_high-voltage-limit-type"/>
667 <cim:{value_property}>{operational_high}</cim:{value_property}>
668 </cim:VoltageLimit>
669"##
670 );
671 let documents = write::write_cgmes(&network, version).unwrap().files;
672 insert_profile_records(documents, &records, None, None)
673 }
674
675 #[allow(clippy::too_many_lines)] fn detailed_network() -> BalancedNetwork {
677 let mut network = network();
678 let substation = component("substation", "sub-A");
679 let voltage_level = component("voltage_level", "vl-A");
680 let first_node = component("connectivity_node", "node-A");
681 let second_node = component("connectivity_node", "node-B");
682 let first_bus = component("bus", "tn-A");
683 let second_bus = component("bus", "tn-B");
684 let busbar = component("busbar_section", "bbs-A");
685 let switch = component("switch", "breaker-A");
686 let load = component("load", network.loads()[0].uid.as_deref().unwrap());
687 let generator = component("generator", network.generators()[0].uid.as_deref().unwrap());
688 let branch = component("branch", network.branches()[0].uid.as_deref().unwrap());
689 let terminal =
690 |equipment: ComponentId, number: u8, bus: ComponentId, node: ComponentId| Terminal {
691 component: None,
692 equipment,
693 terminal: number,
694 voltage_level: voltage_level.clone(),
695 bus: Some(bus.clone()),
696 connectable_bus: Some(bus),
697 node: Some(node),
698 connected: true,
699 active_power_mw: None,
700 reactive_power_mvar: None,
701 };
702 let named_components = [
703 (&substation, "North substation"),
704 (&voltage_level, "North 230 kV"),
705 (&first_node, "North node A"),
706 (&second_node, "North node B"),
707 (&first_bus, "North bus A"),
708 (&second_bus, "North bus B"),
709 (&busbar, "North busbar"),
710 (&switch, "North breaker"),
711 ];
712 let detailed = DetailedConnectivity {
713 omitted_fields: Vec::new(),
714 component_metadata: named_components
715 .into_iter()
716 .map(|(component, name)| ComponentMetadata {
717 component: component.clone(),
718 name: Some(name.into()),
719 equipment_container: None,
720 aliases: Vec::new(),
721 external_identifiers: Vec::new(),
722 properties: std::collections::BTreeMap::new(),
723 fictitious: false,
724 })
725 .collect(),
726 subnetworks: Vec::new(),
727 substations: vec![Substation {
728 component: substation.clone(),
729 country: None,
730 operator: None,
731 geographical_tags: Vec::new(),
732 }],
733 voltage_levels: vec![VoltageLevel {
734 component: voltage_level.clone(),
735 substation: Some(substation),
736 nominal_kv: 230.0,
737 low_voltage_limit_kv: Some(210.0),
738 high_voltage_limit_kv: Some(250.0),
739 topology_kind: TopologyKind::NodeBreaker,
740 buses: vec![BusId(1), BusId(2)],
741 }],
742 bus_breaker_buses: vec![
743 BusBreakerBus {
744 component: first_bus.clone(),
745 voltage_level: voltage_level.clone(),
746 calculated_bus: Some(BusId(1)),
747 voltage_kv: None,
748 angle_degrees: None,
749 },
750 BusBreakerBus {
751 component: second_bus.clone(),
752 voltage_level: voltage_level.clone(),
753 calculated_bus: Some(BusId(2)),
754 voltage_kv: None,
755 angle_degrees: None,
756 },
757 ],
758 calculated_buses: Vec::new(),
759 connectivity_nodes: vec![
760 ConnectivityNode {
761 component: first_node.clone(),
762 voltage_level: voltage_level.clone(),
763 node_number: None,
764 calculated_bus: Some(BusId(1)),
765 },
766 ConnectivityNode {
767 component: second_node.clone(),
768 voltage_level: voltage_level.clone(),
769 node_number: None,
770 calculated_bus: Some(BusId(2)),
771 },
772 ],
773 busbar_sections: vec![BusbarSection {
774 component: busbar.clone(),
775 voltage_level: voltage_level.clone(),
776 node: first_node.clone(),
777 }],
778 junctions: Vec::new(),
779 terminals: vec![
780 terminal(busbar, 1, first_bus.clone(), first_node.clone()),
781 terminal(load, 1, second_bus.clone(), second_node.clone()),
782 terminal(generator, 1, first_bus.clone(), first_node.clone()),
783 terminal(branch.clone(), 1, first_bus.clone(), first_node.clone()),
784 terminal(branch, 2, second_bus.clone(), second_node.clone()),
785 terminal(switch.clone(), 1, first_bus.clone(), first_node.clone()),
786 terminal(switch.clone(), 2, second_bus.clone(), second_node.clone()),
787 ],
788 switches: vec![TopologySwitch {
789 component: switch,
790 voltage_level,
791 kind: SwitchKind::Breaker,
792 endpoint1: TopologyEndpoint::Node(first_node),
793 endpoint2: TopologyEndpoint::Node(second_node),
794 open: true,
795 retained: true,
796 }],
797 internal_connections: Vec::new(),
798 operational_limit_groups: Vec::new(),
799 tap_changers: Vec::new(),
800 equipment_reactive_limits: Vec::new(),
801 boundary_lines: Vec::new(),
802 tie_lines: Vec::new(),
803 dc_converter_units: Vec::new(),
804 dc_topological_nodes: Vec::new(),
805 dc_nodes: Vec::new(),
806 dc_grounds: Vec::new(),
807 dc_lines: Vec::new(),
808 dc_switches: Vec::new(),
809 dc_busbars: Vec::new(),
810 dc_series_devices: Vec::new(),
811 voltage_source_converters: Vec::new(),
812 line_commutated_converters: Vec::new(),
813 };
814 *network.detailed_connectivity_mut() = Some(Arc::new(detailed));
815 network
816 }
817
818 fn mixed_topology_network() -> BalancedNetwork {
819 let mut network = detailed_network();
820 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
821 let second_level = component("voltage_level", "vl-B");
822 let second_bus = component("bus", "tn-B");
823 let second_node = component("connectivity_node", "node-B");
824 let switch = component("switch", "breaker-A");
825 let substation = detailed.voltage_levels[0].substation.clone();
826 detailed.voltage_levels[0]
827 .buses
828 .retain(|bus| *bus != BusId(2));
829 detailed.voltage_levels.push(VoltageLevel {
830 component: second_level.clone(),
831 substation,
832 nominal_kv: 230.0,
833 low_voltage_limit_kv: Some(210.0),
834 high_voltage_limit_kv: Some(250.0),
835 topology_kind: TopologyKind::BusBreaker,
836 buses: vec![BusId(2)],
837 });
838 detailed.component_metadata.push(ComponentMetadata {
839 component: second_level.clone(),
840 name: Some("South 230 kV".into()),
841 equipment_container: None,
842 aliases: Vec::new(),
843 external_identifiers: Vec::new(),
844 properties: std::collections::BTreeMap::new(),
845 fictitious: false,
846 });
847 detailed.bus_breaker_buses[1].voltage_level = second_level.clone();
848 detailed
849 .connectivity_nodes
850 .retain(|node| node.component != second_node);
851 detailed.switches.clear();
852 detailed
853 .terminals
854 .retain(|terminal| terminal.equipment != switch);
855 for terminal in detailed
856 .terminals
857 .iter_mut()
858 .filter(|terminal| terminal.bus.as_ref() == Some(&second_bus))
859 {
860 terminal.voltage_level = second_level.clone();
861 terminal.node = None;
862 }
863 network.validate().unwrap();
864 network
865 }
866
867 fn dc_ground_documents(
868 version: CgmesVersion,
869 converter_rated_voltages_kv: &[f64],
870 ground_rated_voltage_kv: Option<f64>,
871 ) -> Vec<(String, String)> {
872 let namespace = match version {
873 CgmesVersion::V2_4_15 => "http://iec.ch/TC57/2013/CIM-schema-cim16#",
874 CgmesVersion::V3_0 => "http://iec.ch/TC57/CIM100#",
875 };
876 let mut converters = String::new();
877 append_vs_converter_records(&mut converters, "", "_dc-unit", converter_rated_voltages_kv);
878 let ground_voltage = ground_rated_voltage_kv.map_or_else(String::new, |value| {
879 format!(
880 " <cim:DCConductingEquipment.ratedUdc>{value}</cim:DCConductingEquipment.ratedUdc>\n"
881 )
882 });
883 let records = format!(
884 r##" <cim:DCConverterUnit rdf:ID="_dc-unit">
885 <cim:DCConverterUnit.operationMode rdf:resource="{namespace}DCConverterOperatingModeKind.bipolar"/>
886 </cim:DCConverterUnit>
887 <cim:DCNode rdf:ID="_dc-node">
888 <cim:DCNode.DCEquipmentContainer rdf:resource="#_dc-unit"/>
889 </cim:DCNode>
890 <cim:DCGround rdf:ID="_dc-ground">
891 <cim:Equipment.EquipmentContainer rdf:resource="#_dc-unit"/>
892{ground_voltage} </cim:DCGround>
893 <cim:DCTerminal rdf:ID="_dc-ground-terminal">
894 <cim:DCTerminal.DCConductingEquipment rdf:resource="#_dc-ground"/>
895 <cim:DCBaseTerminal.DCNode rdf:resource="#_dc-node"/>
896 <cim:ACDCTerminal.sequenceNumber>1</cim:ACDCTerminal.sequenceNumber>
897 </cim:DCTerminal>
898{converters}"##
899 );
900 let documents = write::write_cgmes(&network(), version).unwrap().files;
901 insert_profile_records(documents, &records, None, None)
902 }
903
904 fn dc_line_documents(
905 version: CgmesVersion,
906 first_unit_converter_voltages_kv: &[f64],
907 second_unit_converter_voltages_kv: &[f64],
908 line_rated_voltage_kv: Option<f64>,
909 ) -> Vec<(String, String)> {
910 let namespace = match version {
911 CgmesVersion::V2_4_15 => "http://iec.ch/TC57/2013/CIM-schema-cim16#",
912 CgmesVersion::V3_0 => "http://iec.ch/TC57/CIM100#",
913 };
914 let mut converters = String::new();
915 append_vs_converter_records(
916 &mut converters,
917 "first-",
918 "_first-dc-unit",
919 first_unit_converter_voltages_kv,
920 );
921 append_vs_converter_records(
922 &mut converters,
923 "second-",
924 "_second-dc-unit",
925 second_unit_converter_voltages_kv,
926 );
927 let line_voltage = line_rated_voltage_kv.map_or_else(String::new, |value| {
928 format!(
929 " <cim:DCConductingEquipment.ratedUdc>{value}</cim:DCConductingEquipment.ratedUdc>\n"
930 )
931 });
932 let records = format!(
933 r##" <cim:DCConverterUnit rdf:ID="_first-dc-unit">
934 <cim:DCConverterUnit.operationMode rdf:resource="{namespace}DCConverterOperatingModeKind.bipolar"/>
935 </cim:DCConverterUnit>
936 <cim:DCConverterUnit rdf:ID="_second-dc-unit">
937 <cim:DCConverterUnit.operationMode rdf:resource="{namespace}DCConverterOperatingModeKind.bipolar"/>
938 </cim:DCConverterUnit>
939 <cim:DCNode rdf:ID="_first-dc-node">
940 <cim:DCNode.DCEquipmentContainer rdf:resource="#_first-dc-unit"/>
941 </cim:DCNode>
942 <cim:DCNode rdf:ID="_second-dc-node">
943 <cim:DCNode.DCEquipmentContainer rdf:resource="#_second-dc-unit"/>
944 </cim:DCNode>
945 <cim:DCLine rdf:ID="_dc-line-container">
946 <cim:IdentifiedObject.name>DC line container</cim:IdentifiedObject.name>
947 </cim:DCLine>
948 <cim:DCLineSegment rdf:ID="_dc-line">
949 <cim:Equipment.EquipmentContainer rdf:resource="#_dc-line-container"/>
950{line_voltage} <cim:DCLineSegment.resistance>1.25</cim:DCLineSegment.resistance>
951 <cim:DCLineSegment.inductance>0.02</cim:DCLineSegment.inductance>
952 <cim:DCLineSegment.capacitance>0.000003</cim:DCLineSegment.capacitance>
953 </cim:DCLineSegment>
954 <cim:DCTerminal rdf:ID="_dc-line-terminal-1">
955 <cim:DCTerminal.DCConductingEquipment rdf:resource="#_dc-line"/>
956 <cim:DCBaseTerminal.DCNode rdf:resource="#_first-dc-node"/>
957 <cim:ACDCTerminal.sequenceNumber>1</cim:ACDCTerminal.sequenceNumber>
958 </cim:DCTerminal>
959 <cim:DCTerminal rdf:ID="_dc-line-terminal-2">
960 <cim:DCTerminal.DCConductingEquipment rdf:resource="#_dc-line"/>
961 <cim:DCBaseTerminal.DCNode rdf:resource="#_second-dc-node"/>
962 <cim:ACDCTerminal.sequenceNumber>2</cim:ACDCTerminal.sequenceNumber>
963 </cim:DCTerminal>
964{converters}"##
965 );
966 let topology_records = r##" <cim:DCTopologicalNode rdf:ID="_first-dc-topological-node">
967 <cim:DCTopologicalNode.DCEquipmentContainer rdf:resource="#_first-dc-unit"/>
968 </cim:DCTopologicalNode>
969 <cim:DCTopologicalNode rdf:ID="_second-dc-topological-node">
970 <cim:DCTopologicalNode.DCEquipmentContainer rdf:resource="#_second-dc-unit"/>
971 </cim:DCTopologicalNode>
972 <cim:DCNode rdf:about="#_first-dc-node">
973 <cim:DCNode.DCTopologicalNode rdf:resource="#_first-dc-topological-node"/>
974 </cim:DCNode>
975 <cim:DCNode rdf:about="#_second-dc-node">
976 <cim:DCNode.DCTopologicalNode rdf:resource="#_second-dc-topological-node"/>
977 </cim:DCNode>
978 <cim:DCTerminal rdf:about="#_dc-line-terminal-1">
979 <cim:DCBaseTerminal.DCTopologicalNode rdf:resource="#_first-dc-topological-node"/>
980 </cim:DCTerminal>
981 <cim:DCTerminal rdf:about="#_dc-line-terminal-2">
982 <cim:DCBaseTerminal.DCTopologicalNode rdf:resource="#_second-dc-topological-node"/>
983 </cim:DCTerminal>
984"##;
985 let steady_state_records = r##" <cim:DCTerminal rdf:about="#_dc-line-terminal-1">
986 <cim:ACDCTerminal.connected>true</cim:ACDCTerminal.connected>
987 </cim:DCTerminal>
988 <cim:DCTerminal rdf:about="#_dc-line-terminal-2">
989 <cim:ACDCTerminal.connected>true</cim:ACDCTerminal.connected>
990 </cim:DCTerminal>
991"##;
992 let documents = write::write_cgmes(&network(), version).unwrap().files;
993 insert_profile_records(
994 documents,
995 &records,
996 Some(topology_records),
997 Some(steady_state_records),
998 )
999 }
1000
1001 #[test]
1002 fn fresh_output_is_four_cgmes_3_profiles_and_reparses() {
1003 let module = PioModule::new(network());
1004 let result = crate::format::emit(
1005 &module,
1006 TargetFormat::Cgmes,
1007 Destination::memory("cgmes").unwrap(),
1008 )
1009 .unwrap();
1010 let EmittedOutput::Memory { artifacts } = result.into_output() else {
1011 panic!("memory destination returned paths");
1012 };
1013 assert_eq!(artifacts.len(), 4);
1014 let documents = artifacts
1015 .iter()
1016 .map(|artifact| {
1017 let text = std::str::from_utf8(artifact.bytes()).unwrap();
1018 assert!(text.contains("http://iec.ch/TC57/CIM100#"));
1019 (artifact.name().to_string(), text.to_string())
1020 })
1021 .collect();
1022 let parsed = read::read_cgmes_documents(documents, Some("case")).unwrap();
1023 assert_eq!(parsed.network.buses().len(), 2);
1024 assert_eq!(parsed.network.branches().len(), 1);
1025 assert_eq!(parsed.network.loads().len(), 1);
1026 assert_eq!(parsed.network.generators().len(), 1);
1027 assert_eq!(
1028 parsed
1029 .network
1030 .detailed_connectivity()
1031 .as_deref()
1032 .unwrap()
1033 .substations
1034 .len(),
1035 2
1036 );
1037 assert!(parsed.network.case_metadata().source_model_format.is_none());
1038 assert!(parsed.warnings.is_empty(), "{:#?}", parsed.warnings);
1039
1040 let emitted_again = write::write_cgmes(&parsed.network, CgmesVersion::V3_0).unwrap();
1041 let parsed_again = read::read_cgmes_documents(emitted_again.files, Some("case")).unwrap();
1042 assert_eq!(
1043 serde_json::to_value(&parsed.network).unwrap(),
1044 serde_json::to_value(&parsed_again.network).unwrap()
1045 );
1046 assert!(
1047 parsed_again.warnings.is_empty(),
1048 "{:#?}",
1049 parsed_again.warnings
1050 );
1051 }
1052
1053 #[test]
1054 fn simple_hvdc_reports_the_missing_physical_cgmes_data() {
1055 let mut network = detailed_network();
1056 let mut line = Hvdc::new(BusId(1), BusId(2));
1057 line.uid = Some("dc-link".into());
1058 line.resistance_ohm = Some(2.5);
1059 line.nominal_voltage_kv = Some(320.0);
1060 network.hvdc_mut().push(line);
1061
1062 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1063 assert!(output.warnings.iter().any(|warning| {
1064 warning.contains("HVDC line `dc-link` is a two terminal calculation record")
1065 && warning.contains("DCConverterUnit operating mode and containment")
1066 && warning.contains("DC node and terminal polarity identities")
1067 && warning.contains("without inventing data")
1068 }));
1069 }
1070
1071 #[test]
1072 fn synchronous_machine_emits_the_required_generator_kind() {
1073 for (version, namespace) in [
1074 (
1075 CgmesVersion::V2_4_15,
1076 "http://iec.ch/TC57/2013/CIM-schema-cim16#",
1077 ),
1078 (CgmesVersion::V3_0, "http://iec.ch/TC57/CIM100#"),
1079 ] {
1080 let output = write::write_cgmes(&network(), version).unwrap();
1081 let eq = output
1082 .files
1083 .iter()
1084 .find(|(name, _)| name.ends_with("_EQ.xml"))
1085 .map(|(_, text)| text)
1086 .unwrap();
1087 assert!(eq.contains(&format!(
1088 "<cim:SynchronousMachine.type rdf:resource=\"{namespace}SynchronousMachineKind.generator\"/>"
1089 )));
1090 }
1091 }
1092
1093 #[test]
1094 fn synchronous_machine_reactive_capability_curve_round_trips() {
1095 let mut network = detailed_network();
1096 network.generators_mut()[0].pg = 50.0;
1097 network.generators_mut()[0].qmin = -1.0;
1098 network.generators_mut()[0].qmax = 1.0;
1099 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1100 let generator = detailed
1101 .terminals
1102 .iter()
1103 .find(|terminal| terminal.equipment.component_type() == "generator")
1104 .map(|terminal| terminal.equipment.clone())
1105 .unwrap();
1106 let limits = ReactiveLimits::CapabilityCurve(ReactiveCapabilityCurve {
1107 curve_style: CurveStyle::StraightLineYValues,
1108 properties: std::collections::BTreeMap::new(),
1109 points: vec![
1110 ReactiveCapabilityCurvePoint {
1111 active_power_mw: -100.0,
1112 minimum_reactive_power_mvar: -20.0,
1113 maximum_reactive_power_mvar: 20.0,
1114 properties: std::collections::BTreeMap::new(),
1115 },
1116 ReactiveCapabilityCurvePoint {
1117 active_power_mw: 100.0,
1118 minimum_reactive_power_mvar: -40.0,
1119 maximum_reactive_power_mvar: 40.0,
1120 properties: std::collections::BTreeMap::new(),
1121 },
1122 ],
1123 });
1124 detailed
1125 .equipment_reactive_limits
1126 .push(EquipmentReactiveLimits {
1127 equipment: generator,
1128 limits: limits.clone(),
1129 });
1130
1131 let first = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1132 let second = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1133 assert_eq!(first.files, second.files);
1134 let eq = first
1135 .files
1136 .iter()
1137 .find(|(name, _)| name.ends_with("_EQ.xml"))
1138 .map(|(_, xml)| xml)
1139 .unwrap();
1140 assert!(eq.contains("SynchronousMachine.InitialReactiveCapabilityCurve"));
1141 assert!(eq.contains("<cim:ReactiveCapabilityCurve rdf:ID="));
1142 assert_eq!(eq.matches("<cim:CurveData rdf:ID=").count(), 2);
1143 assert!(eq.contains("<cim:CurveData.xvalue>-100</cim:CurveData.xvalue>"));
1144 assert!(eq.contains("<cim:CurveData.y1value>-40</cim:CurveData.y1value>"));
1145 assert!(eq.contains("<cim:SynchronousMachine.minQ>-35</cim:SynchronousMachine.minQ>"));
1146 assert!(eq.contains("<cim:SynchronousMachine.maxQ>35</cim:SynchronousMachine.maxQ>"));
1147 assert!(first.warnings.iter().any(|warning| {
1148 warning.info.code == crate::diagnostics::codes::EMIT_CGMES.value_substituted.code
1149 && warning.contains("typed reactive limits evaluate to minQ=-35")
1150 && warning.contains("balanced generator row contains minQ=-1")
1151 }));
1152
1153 let parsed = read::read_cgmes_documents(first.files, Some("machine-curve")).unwrap();
1154 let machine = &parsed.network.generators()[0];
1155 assert!((machine.qmin - -35.0).abs() < 1e-12);
1156 assert!((machine.qmax - 35.0).abs() < 1e-12);
1157 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
1158 assert_eq!(detailed.equipment_reactive_limits.len(), 1);
1159 assert_eq!(detailed.equipment_reactive_limits[0].limits, limits);
1160 assert!(!parsed.warnings.iter().any(|warning| {
1161 warning.contains("ReactiveCapabilityCurve object(s)")
1162 || warning.contains("CurveData object(s)")
1163 }));
1164 }
1165
1166 #[test]
1167 fn typed_min_max_reactive_limits_override_the_balanced_generator_projection() {
1168 let mut network = detailed_network();
1169 network.generators_mut()[0].qmin = -1.0;
1170 network.generators_mut()[0].qmax = 1.0;
1171 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1172 let generator = detailed
1173 .terminals
1174 .iter()
1175 .find(|terminal| terminal.equipment.component_type() == "generator")
1176 .map(|terminal| terminal.equipment.clone())
1177 .unwrap();
1178 let mut properties = std::collections::BTreeMap::new();
1179 properties.insert("source_extension".into(), "retained".into());
1180 detailed
1181 .equipment_reactive_limits
1182 .push(EquipmentReactiveLimits {
1183 equipment: generator,
1184 limits: ReactiveLimits::MinMax(MinMaxReactiveLimits {
1185 minimum_reactive_power_mvar: -25.0,
1186 maximum_reactive_power_mvar: 30.0,
1187 properties,
1188 }),
1189 });
1190 detailed
1191 .equipment_reactive_limits
1192 .push(EquipmentReactiveLimits {
1193 equipment: component("storage", "not-emitted"),
1194 limits: ReactiveLimits::MinMax(MinMaxReactiveLimits {
1195 minimum_reactive_power_mvar: -5.0,
1196 maximum_reactive_power_mvar: 5.0,
1197 properties: std::collections::BTreeMap::new(),
1198 }),
1199 });
1200
1201 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1202 let eq = output
1203 .files
1204 .iter()
1205 .find(|(name, _)| name.ends_with("_EQ.xml"))
1206 .map(|(_, xml)| xml)
1207 .unwrap();
1208 assert!(eq.contains("<cim:SynchronousMachine.minQ>-25</cim:SynchronousMachine.minQ>"));
1209 assert!(eq.contains("<cim:SynchronousMachine.maxQ>30</cim:SynchronousMachine.maxQ>"));
1210 assert!(output.warnings.iter().any(|warning| {
1211 warning.info.code == crate::diagnostics::codes::EMIT_CGMES.value_substituted.code
1212 && warning.contains("typed reactive limits evaluate to minQ=-25")
1213 }));
1214 assert!(output.warnings.iter().any(|warning| {
1215 warning.info.code == crate::diagnostics::codes::EMIT_CGMES.field_dropped.code
1216 && warning.contains("min/max reactive limits properties")
1217 }));
1218 assert!(output.warnings.iter().any(|warning| {
1219 warning.info.code == crate::diagnostics::codes::EMIT_CGMES.record_dropped.code
1220 && warning.contains("storage/not-emitted")
1221 && warning.contains("no CGMES record states the storage")
1222 }));
1223
1224 let parsed = read::read_cgmes_documents(output.files, Some("machine-min-max")).unwrap();
1225 assert!((parsed.network.generators()[0].qmin + 25.0).abs() < 1e-12);
1226 assert!((parsed.network.generators()[0].qmax - 30.0).abs() < 1e-12);
1227 }
1228
1229 #[test]
1230 fn sv_status_overrides_ssh_and_round_trips() {
1231 let mut network = detailed_network();
1232 network.loads_mut()[0].in_service = false;
1233 network.generators_mut()[0].in_service = false;
1234 network.branches_mut()[0].in_service = false;
1235 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1236 let busbar = detailed.busbar_sections[0].component.clone();
1237 detailed
1238 .component_metadata
1239 .iter_mut()
1240 .find(|metadata| metadata.component == busbar)
1241 .unwrap()
1242 .properties
1243 .insert(CGMES_SV_STATUS_PROPERTY.into(), "false".into());
1244
1245 let mut output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1246 let sv = output
1247 .files
1248 .iter()
1249 .find(|(name, _)| name.ends_with("_SV.xml"))
1250 .map(|(_, xml)| xml)
1251 .unwrap();
1252 assert_eq!(sv.matches("<cim:SvStatus rdf:ID=").count(), 4);
1253 assert_eq!(
1254 sv.matches("<cim:SvStatus.inService>false</cim:SvStatus.inService>")
1255 .count(),
1256 4
1257 );
1258
1259 let ssh = output
1260 .files
1261 .iter_mut()
1262 .find(|(name, _)| name.ends_with("_SSH.xml"))
1263 .map(|(_, xml)| xml)
1264 .unwrap();
1265 *ssh = ssh.replace(
1266 "<cim:Equipment.inService>false</cim:Equipment.inService>",
1267 "<cim:Equipment.inService>true</cim:Equipment.inService>",
1268 );
1269 let parsed = read::read_cgmes_documents(output.files.clone(), Some("sv-status")).unwrap();
1270 assert!(!parsed.network.loads()[0].in_service);
1271 assert!(!parsed.network.generators()[0].in_service);
1272 assert!(!parsed.network.branches()[0].in_service);
1273 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
1274 assert!(detailed.component_metadata.iter().any(|metadata| {
1275 metadata.component.component_type() == "busbar_section"
1276 && metadata
1277 .properties
1278 .get(CGMES_SV_STATUS_PROPERTY)
1279 .is_some_and(|value| value == "false")
1280 }));
1281 assert!(
1282 !parsed
1283 .warnings
1284 .iter()
1285 .any(|warning| warning.contains("SvStatus object(s)"))
1286 );
1287
1288 let sv = output
1289 .files
1290 .iter_mut()
1291 .find(|(name, _)| name.ends_with("_SV.xml"))
1292 .map(|(_, xml)| xml)
1293 .unwrap();
1294 *sv = sv.replacen(
1295 "SvStatus.ConductingEquipment",
1296 "SvStatus.MissingConductingEquipment",
1297 1,
1298 );
1299 let Err(error) = read::read_cgmes_documents(output.files, Some("invalid-sv-status")) else {
1300 panic!("SvStatus without a conducting equipment reference parsed");
1301 };
1302 assert!(
1303 error
1304 .to_string()
1305 .contains("has no SvStatus.ConductingEquipment reference")
1306 );
1307 }
1308
1309 const MAPPED_GENERATING_UNIT_MRID: &str = "11111111-1111-4111-8111-111111111111";
1310
1311 fn network_with_mapped_equipment_metadata() -> BalancedNetwork {
1312 let mut network = detailed_network();
1313 let load = network.loads()[0].uid.clone().unwrap();
1314 let generator = network.generators()[0].uid.clone().unwrap();
1315 let branch = network.branches()[0].uid.clone().unwrap();
1316 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1317 let voltage_level = detailed.voltage_levels[0].component.clone();
1318 let substation = detailed.substations[0].component.clone();
1319 for (component_type, local_id, name) in [
1320 ("load", load.as_str(), "Retained load name"),
1321 ("generator", generator.as_str(), "Retained generator name"),
1322 ("branch", branch.as_str(), "Retained line name"),
1323 ] {
1324 detailed.component_metadata.push(ComponentMetadata {
1325 component: component(component_type, local_id),
1326 name: Some(name.into()),
1327 equipment_container: Some(voltage_level.clone()),
1328 aliases: Vec::new(),
1329 external_identifiers: Vec::new(),
1330 properties: std::collections::BTreeMap::new(),
1331 fictitious: false,
1332 });
1333 }
1334 detailed
1335 .component_metadata
1336 .iter_mut()
1337 .find(|metadata| {
1338 metadata.component.component_type() == "generator"
1339 && metadata.component.local_id() == generator
1340 })
1341 .unwrap()
1342 .properties
1343 .insert(
1344 CGMES_GENERATING_UNIT_PROPERTY.into(),
1345 MAPPED_GENERATING_UNIT_MRID.into(),
1346 );
1347 detailed.component_metadata.push(ComponentMetadata {
1348 component: component("cgmes_object", MAPPED_GENERATING_UNIT_MRID),
1349 name: Some("Retained generating unit name".into()),
1350 equipment_container: Some(substation),
1351 aliases: Vec::new(),
1352 external_identifiers: vec![ExternalIdentifier {
1353 value: MAPPED_GENERATING_UNIT_MRID.into(),
1354 authority: Some("CGMES".into()),
1355 }],
1356 properties: std::collections::BTreeMap::from([
1357 (
1358 "IdentifiedObject.description".into(),
1359 "Retained unit description".into(),
1360 ),
1361 ("Equipment.aggregate".into(), "false".into()),
1362 (
1363 "GeneratingUnit.genControlSource".into(),
1364 "GeneratorControlSource.offAGC".into(),
1365 ),
1366 ("GeneratingUnit.nominalP".into(), "125".into()),
1367 ]),
1368 fictitious: false,
1369 });
1370 network.validate().unwrap();
1371 network
1372 }
1373
1374 #[test]
1375 fn mapped_equipment_names_and_containers_round_trip() {
1376 let network = network_with_mapped_equipment_metadata();
1377 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1378 let equipment = output
1379 .files
1380 .iter()
1381 .find(|(name, _)| name.ends_with("_EQ.xml"))
1382 .map(|(_, xml)| xml)
1383 .unwrap();
1384 assert!(equipment.contains(&format!(
1385 "<cim:GeneratingUnit rdf:ID=\"_{MAPPED_GENERATING_UNIT_MRID}\">"
1386 )));
1387 assert!(equipment.contains(
1388 "<cim:IdentifiedObject.name>Retained generating unit name</cim:IdentifiedObject.name>"
1389 ));
1390 assert!(equipment.contains(
1391 "<cim:IdentifiedObject.description>Retained unit description</cim:IdentifiedObject.description>"
1392 ));
1393 assert!(
1394 equipment.contains("<cim:GeneratingUnit.nominalP>125</cim:GeneratingUnit.nominalP>")
1395 );
1396 let reparsed = read::read_cgmes_documents(output.files, Some("mapped-metadata")).unwrap();
1397 let detailed = reparsed.network.detailed_connectivity().as_deref().unwrap();
1398 for name in [
1399 "Retained load name",
1400 "Retained generator name",
1401 "Retained line name",
1402 ] {
1403 let metadata = detailed
1404 .component_metadata
1405 .iter()
1406 .find(|metadata| metadata.name.as_deref() == Some(name))
1407 .unwrap();
1408 let container = metadata.equipment_container.as_ref().unwrap();
1409 assert!(detailed.voltage_levels.iter().any(|level| {
1410 level.component == *container
1411 && detailed.component_metadata.iter().any(|metadata| {
1412 metadata.component == level.component
1413 && metadata.name.as_deref() == Some("North 230 kV")
1414 })
1415 }));
1416 }
1417 let unit = detailed
1418 .component_metadata
1419 .iter()
1420 .find(|metadata| metadata.name.as_deref() == Some("Retained generating unit name"))
1421 .unwrap();
1422 let container = unit.equipment_container.as_ref().unwrap();
1423 assert!(detailed.substations.iter().any(|substation| {
1424 substation.component == *container
1425 && detailed.component_metadata.iter().any(|metadata| {
1426 metadata.component == substation.component
1427 && metadata.name.as_deref() == Some("North substation")
1428 })
1429 }));
1430 }
1431
1432 #[test]
1433 fn generating_unit_classes_round_trip_as_generator_energy_sources() {
1434 for (energy_source, class) in [
1435 (GeneratorEnergySource::Hydro, "HydroGeneratingUnit"),
1436 (GeneratorEnergySource::Nuclear, "NuclearGeneratingUnit"),
1437 (GeneratorEnergySource::Wind, "WindGeneratingUnit"),
1438 (GeneratorEnergySource::Thermal, "ThermalGeneratingUnit"),
1439 (GeneratorEnergySource::Solar, "SolarGeneratingUnit"),
1440 (GeneratorEnergySource::Other, "GeneratingUnit"),
1441 ] {
1442 let mut network = network();
1443 network.generators_mut()[0].energy_source = energy_source;
1444 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1445 let equipment = output
1446 .files
1447 .iter()
1448 .find(|(name, _)| name.ends_with("_EQ.xml"))
1449 .map(|(_, xml)| xml)
1450 .unwrap();
1451 let steady_state_hypothesis = output
1452 .files
1453 .iter()
1454 .find(|(name, _)| name.ends_with("_SSH.xml"))
1455 .map(|(_, xml)| xml)
1456 .unwrap();
1457 assert!(equipment.contains(&format!("<cim:{class} rdf:ID=")));
1458 assert!(steady_state_hypothesis.contains(&format!("<cim:{class} rdf:about=")));
1459
1460 let parsed = read::read_cgmes_documents(output.files, Some("energy-source")).unwrap();
1461 assert_eq!(parsed.network.generators()[0].energy_source, energy_source);
1462 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
1463 assert!(detailed.component_metadata.iter().any(|metadata| {
1464 metadata
1465 .properties
1466 .get(CGMES_CLASS_PROPERTY)
1467 .is_some_and(|value| value == class)
1468 }));
1469 }
1470 }
1471
1472 #[test]
1473 fn dangling_component_metadata_container_is_rejected() {
1474 let mut network = detailed_network();
1475 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1476 detailed.component_metadata[0].equipment_container =
1477 Some(component("voltage_level", "missing"));
1478 let error = network.validate().unwrap_err();
1479 assert!(
1480 error
1481 .to_string()
1482 .contains("references unknown equipment container `voltage_level/missing`")
1483 );
1484 }
1485
1486 #[test]
1487 fn fresh_emission_replaces_an_unsupported_container_with_the_terminal_voltage_level() {
1488 let mut network = detailed_network();
1489 let load = component("load", network.loads()[0].uid.as_deref().unwrap());
1490 let unsupported_container = component("cgmes_object", "bay-A");
1491 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1492 detailed.component_metadata.push(ComponentMetadata {
1493 component: unsupported_container.clone(),
1494 name: Some("Bay A".into()),
1495 equipment_container: None,
1496 aliases: Vec::new(),
1497 external_identifiers: Vec::new(),
1498 properties: std::collections::BTreeMap::new(),
1499 fictitious: false,
1500 });
1501 detailed.component_metadata.push(ComponentMetadata {
1502 component: load,
1503 name: Some("North load".into()),
1504 equipment_container: Some(unsupported_container),
1505 aliases: Vec::new(),
1506 external_identifiers: Vec::new(),
1507 properties: std::collections::BTreeMap::new(),
1508 fictitious: false,
1509 });
1510 network.validate().unwrap();
1511
1512 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1513 assert!(output.warnings.iter().any(|warning| {
1514 warning.info.code == crate::diagnostics::codes::EMIT_CGMES.value_substituted.code
1515 && warning.contains("load/")
1516 && warning.contains("cgmes_object/bay-A")
1517 && warning.contains("terminal's VoltageLevel")
1518 }));
1519
1520 let parsed = read::read_cgmes_documents(output.files, Some("container-fallback")).unwrap();
1521 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
1522 let load = detailed
1523 .component_metadata
1524 .iter()
1525 .find(|metadata| metadata.name.as_deref() == Some("North load"))
1526 .unwrap();
1527 let container = load.equipment_container.as_ref().unwrap();
1528 assert_eq!(container.component_type(), "voltage_level");
1529 assert!(
1530 detailed
1531 .voltage_levels
1532 .iter()
1533 .any(|level| level.component == *container)
1534 );
1535 }
1536
1537 #[test]
1538 fn dc_only_detailed_connectivity_emits_declared_substations() {
1539 let substation = component("substation", "dc-substation");
1540 let converter_unit = component("dc_converter_unit", "dc-unit");
1541 let mut network = BalancedNetwork::new("dc-only", 100.0);
1542 *network.detailed_connectivity_mut() = Some(Arc::new(DetailedConnectivity {
1543 component_metadata: vec![ComponentMetadata {
1544 component: substation.clone(),
1545 name: Some("DC substation".into()),
1546 equipment_container: None,
1547 aliases: Vec::new(),
1548 external_identifiers: Vec::new(),
1549 properties: std::collections::BTreeMap::new(),
1550 fictitious: false,
1551 }],
1552 substations: vec![Substation {
1553 component: substation,
1554 country: None,
1555 operator: None,
1556 geographical_tags: Vec::new(),
1557 }],
1558 dc_converter_units: vec![DcConverterUnit {
1559 component: converter_unit,
1560 substation: Some(component("substation", "dc-substation")),
1561 operation_mode: DcConverterOperatingMode::Bipolar,
1562 }],
1563 ..DetailedConnectivity::default()
1564 }));
1565
1566 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1567 let equipment = output
1568 .files
1569 .iter()
1570 .find(|(name, _)| name.ends_with("_EQ.xml"))
1571 .map(|(_, xml)| xml)
1572 .unwrap();
1573 assert!(equipment.contains("<cim:Substation rdf:ID="));
1574 assert!(equipment.contains("<cim:IdentifiedObject.name>DC substation"));
1575 assert!(equipment.contains("<cim:DCConverterUnit.Substation rdf:resource="));
1576 }
1577
1578 #[test]
1579 fn busbar_containment_conflicts_are_rejected_and_switch_terminals_may_cross_containers() {
1580 let add_second_level = |network: &mut BalancedNetwork| {
1581 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1582 let second_level = component("voltage_level", "vl-B");
1583 let second_node = component("connectivity_node", "node-C");
1584 detailed.voltage_levels.push(VoltageLevel {
1585 component: second_level.clone(),
1586 substation: detailed.voltage_levels[0].substation.clone(),
1587 nominal_kv: 230.0,
1588 low_voltage_limit_kv: None,
1589 high_voltage_limit_kv: None,
1590 topology_kind: TopologyKind::NodeBreaker,
1591 buses: Vec::new(),
1592 });
1593 detailed.connectivity_nodes.push(ConnectivityNode {
1594 component: second_node.clone(),
1595 voltage_level: second_level,
1596 node_number: None,
1597 calculated_bus: None,
1598 });
1599 second_node
1600 };
1601
1602 let mut busbar = detailed_network();
1603 let second_node = add_second_level(&mut busbar);
1604 Arc::make_mut(busbar.detailed_connectivity_mut().as_mut().unwrap()).busbar_sections[0]
1605 .node = second_node;
1606 assert!(
1607 busbar
1608 .validate()
1609 .unwrap_err()
1610 .to_string()
1611 .contains("busbar section")
1612 );
1613
1614 let mut switch = detailed_network();
1615 let second_node = add_second_level(&mut switch);
1616 Arc::make_mut(switch.detailed_connectivity_mut().as_mut().unwrap()).switches[0].endpoint2 =
1617 TopologyEndpoint::Node(second_node);
1618 switch.validate().unwrap();
1619 }
1620
1621 #[test]
1622 fn cim_junction_round_trips_with_name_container_and_terminal() {
1623 const JUNCTION_MRID: &str = "5249a78f-6642-4fc5-968f-06e2ed18fab7";
1624 let mut network = detailed_network();
1625 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1626 let junction = component("junction", JUNCTION_MRID);
1627 let container = detailed.voltage_levels[0].component.clone();
1628 detailed.component_metadata.push(ComponentMetadata {
1629 component: junction.clone(),
1630 name: Some("Junction XJ1".into()),
1631 equipment_container: Some(container.clone()),
1632 aliases: Vec::new(),
1633 external_identifiers: vec![ExternalIdentifier {
1634 value: JUNCTION_MRID.into(),
1635 authority: Some("CGMES".into()),
1636 }],
1637 properties: std::collections::BTreeMap::new(),
1638 fictitious: false,
1639 });
1640 detailed.junctions.push(Junction {
1641 component: junction.clone(),
1642 });
1643 let mut terminal = detailed.terminals[0].clone();
1644 terminal.equipment = junction;
1645 terminal.terminal = 1;
1646 detailed.terminals.push(terminal);
1647 network.validate().unwrap();
1648
1649 let network = crate::network::serde_round_trip(&network);
1650 for version in [CgmesVersion::V2_4_15, CgmesVersion::V3_0] {
1651 let output = write::write_cgmes(&network, version).unwrap();
1652 let equipment = output
1653 .files
1654 .iter()
1655 .find(|(name, _)| name.ends_with("_EQ.xml"))
1656 .map(|(_, xml)| xml)
1657 .unwrap();
1658 assert!(equipment.contains(&format!("<cim:Junction rdf:ID=\"_{JUNCTION_MRID}\">")));
1659 assert!(
1660 equipment.contains(
1661 "<cim:IdentifiedObject.name>Junction XJ1</cim:IdentifiedObject.name>"
1662 )
1663 );
1664
1665 let parsed = read::read_cgmes_documents(output.files, Some("junction")).unwrap();
1666 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
1667 assert_eq!(detailed.junctions.len(), 1);
1668 let junction = &detailed.junctions[0].component;
1669 assert!(
1670 detailed
1671 .terminals
1672 .iter()
1673 .any(|terminal| { terminal.equipment == *junction && terminal.terminal == 1 })
1674 );
1675 let metadata = detailed
1676 .component_metadata
1677 .iter()
1678 .find(|metadata| metadata.component == *junction)
1679 .unwrap();
1680 assert_eq!(metadata.name.as_deref(), Some("Junction XJ1"));
1681 let container = metadata.equipment_container.as_ref().unwrap();
1682 assert!(detailed.component_metadata.iter().any(|metadata| {
1683 metadata.component == *container && metadata.name.as_deref() == Some("North 230 kV")
1684 }));
1685 }
1686 }
1687
1688 #[test]
1689 fn v2415_busbar_without_connectivity_node_is_preserved() {
1690 let network = detailed_network();
1691 let mut output = write::write_cgmes(&network, CgmesVersion::V2_4_15).unwrap();
1692 let eq = output
1693 .files
1694 .iter_mut()
1695 .find(|(name, _)| name.ends_with("_EQ.xml"))
1696 .map(|(_, xml)| xml)
1697 .unwrap();
1698 let connection = eq
1699 .lines()
1700 .find(|line| line.contains("<cim:Terminal.ConnectivityNode "))
1701 .unwrap()
1702 .to_owned();
1703 *eq = eq.replacen(&format!("{connection}\n"), "", 1);
1704
1705 let parsed = read::read_cgmes_documents(output.files, Some("v2415-busbar")).unwrap();
1706 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
1707 assert_eq!(detailed.busbar_sections.len(), 1);
1708 let busbar = &detailed.busbar_sections[0];
1709 let node = detailed
1710 .connectivity_nodes
1711 .iter()
1712 .find(|node| node.component == busbar.node)
1713 .unwrap();
1714 assert!(node.component.local_id().starts_with("terminal-"));
1715 assert_eq!(node.calculated_bus, Some(BusId(1)));
1716
1717 let fresh = write::write_cgmes(&parsed.network, CgmesVersion::V3_0).unwrap();
1718 let eq = fresh
1719 .files
1720 .iter()
1721 .find(|(name, _)| name.ends_with("_EQ.xml"))
1722 .map(|(_, xml)| xml)
1723 .unwrap();
1724 assert!(eq.contains("<cim:IdentifiedObject.name>North busbar</cim:IdentifiedObject.name>"));
1725 assert!(eq.contains("<cim:Equipment.EquipmentContainer rdf:resource="));
1726 }
1727
1728 #[test]
1729 fn generating_unit_normal_pf_maps_to_active_power_control() {
1730 let mut network = network();
1731 let mut control = ActivePowerControl::new(true);
1732 control.participation_factor = Some(0.35);
1733 network.generators_mut()[0].active_power_control = Some(control.clone());
1734 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1735 let ssh = output
1736 .files
1737 .iter()
1738 .find(|(name, _)| name.ends_with("_SSH.xml"))
1739 .map(|(_, xml)| xml)
1740 .unwrap();
1741 assert!(ssh.contains("<cim:GeneratingUnit.normalPF>0.35</cim:GeneratingUnit.normalPF>"));
1742 assert!(
1743 !output
1744 .warnings
1745 .iter()
1746 .any(|warning| warning.contains("active power control"))
1747 );
1748
1749 let parsed = read::read_cgmes_documents(output.files.clone(), Some("normal-pf")).unwrap();
1750 assert_eq!(
1751 parsed.network.generators()[0].active_power_control,
1752 Some(control)
1753 );
1754
1755 let invalid = output
1756 .files
1757 .into_iter()
1758 .map(|(name, xml)| {
1759 (
1760 name,
1761 xml.replace(
1762 "<cim:GeneratingUnit.normalPF>0.35</cim:GeneratingUnit.normalPF>",
1763 "<cim:GeneratingUnit.normalPF>-0.1</cim:GeneratingUnit.normalPF>",
1764 ),
1765 )
1766 })
1767 .collect();
1768 assert!(read::read_cgmes_documents(invalid, Some("invalid-normal-pf")).is_err());
1769
1770 network.generators_mut()[0]
1771 .active_power_control
1772 .as_mut()
1773 .unwrap()
1774 .participate = false;
1775 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1776 let ssh = output
1777 .files
1778 .iter()
1779 .find(|(name, _)| name.ends_with("_SSH.xml"))
1780 .map(|(_, xml)| xml)
1781 .unwrap();
1782 assert!(!ssh.contains("GeneratingUnit.normalPF"));
1783 assert!(
1784 output.warnings.iter().any(|warning| {
1785 warning.contains("participate=false with a participation factor")
1786 })
1787 );
1788 }
1789
1790 #[test]
1791 fn source_line_topology_container_remains_a_line() {
1792 const TOPOLOGICAL_NODE_MRID: &str = "11111111-1111-4111-8111-111111111111";
1793 const CONNECTIVITY_NODE_MRID: &str = "22222222-2222-4222-8222-222222222222";
1794 const SOURCE_LINE_MRID: &str = "33333333-3333-4333-8333-333333333333";
1795
1796 let mut network = detailed_network();
1797 let source_container = component("line", SOURCE_LINE_MRID);
1798 let topological_node = component("bus", TOPOLOGICAL_NODE_MRID);
1799 let connectivity_node = component("connectivity_node", CONNECTIVITY_NODE_MRID);
1800 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1801 let old_topological_node = detailed.bus_breaker_buses[0].component.clone();
1802 let old_connectivity_node = detailed.connectivity_nodes[0].component.clone();
1803
1804 detailed.bus_breaker_buses[0].component = topological_node.clone();
1805 detailed.bus_breaker_buses[0].voltage_level = source_container.clone();
1806 detailed.connectivity_nodes[0].component = connectivity_node.clone();
1807 detailed.connectivity_nodes[0].voltage_level = source_container.clone();
1808 detailed.busbar_sections[0].node = connectivity_node.clone();
1809 for terminal in &mut detailed.terminals {
1810 if terminal.bus.as_ref() == Some(&old_topological_node) {
1811 terminal.bus = Some(topological_node.clone());
1812 }
1813 if terminal.connectable_bus.as_ref() == Some(&old_topological_node) {
1814 terminal.connectable_bus = Some(topological_node.clone());
1815 }
1816 if terminal.node.as_ref() == Some(&old_connectivity_node) {
1817 terminal.node = Some(connectivity_node.clone());
1818 terminal.voltage_level = source_container.clone();
1819 }
1820 }
1821 if let TopologyEndpoint::Node(node) = &mut detailed.switches[0].endpoint1
1822 && *node == old_connectivity_node
1823 {
1824 *node = connectivity_node.clone();
1825 }
1826 for (component, mrid, name) in [
1827 (
1828 source_container.clone(),
1829 SOURCE_LINE_MRID,
1830 "Boundary line container",
1831 ),
1832 (
1833 topological_node.clone(),
1834 TOPOLOGICAL_NODE_MRID,
1835 "Boundary topological node",
1836 ),
1837 (
1838 connectivity_node.clone(),
1839 CONNECTIVITY_NODE_MRID,
1840 "Boundary connectivity node",
1841 ),
1842 ] {
1843 detailed.component_metadata.push(ComponentMetadata {
1844 component,
1845 name: Some(name.into()),
1846 equipment_container: None,
1847 aliases: Vec::new(),
1848 external_identifiers: vec![ExternalIdentifier {
1849 value: mrid.into(),
1850 authority: Some("CGMES".into()),
1851 }],
1852 properties: std::collections::BTreeMap::new(),
1853 fictitious: false,
1854 });
1855 }
1856
1857 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1858 assert!(!output.warnings.iter().any(|warning| {
1859 warning.contains(&source_container.to_string())
1860 && warning.contains("generated VoltageLevel")
1861 }));
1862
1863 let all_xml = output
1864 .files
1865 .iter()
1866 .map(|(_, xml)| xml.as_str())
1867 .collect::<String>();
1868 assert!(all_xml.contains(&format!("rdf:ID=\"_{TOPOLOGICAL_NODE_MRID}\"")));
1869 assert!(all_xml.contains(&format!("rdf:ID=\"_{CONNECTIVITY_NODE_MRID}\"")));
1870 assert!(all_xml.contains(&format!("<cim:Line rdf:ID=\"_{SOURCE_LINE_MRID}\"")));
1871 assert!(all_xml.contains(&format!("rdf:resource=\"#_{SOURCE_LINE_MRID}\"")));
1872
1873 let parsed = read::read_cgmes_documents(output.files, Some("boundary-line")).unwrap();
1874 let identifiers = parsed
1875 .network
1876 .detailed_connectivity()
1877 .as_deref()
1878 .unwrap()
1879 .component_metadata
1880 .iter()
1881 .flat_map(|metadata| &metadata.external_identifiers)
1882 .map(|identifier| identifier.value.as_str())
1883 .collect::<Vec<_>>();
1884 assert!(identifiers.contains(&TOPOLOGICAL_NODE_MRID));
1885 assert!(identifiers.contains(&CONNECTIVITY_NODE_MRID));
1886 assert!(identifiers.contains(&SOURCE_LINE_MRID));
1887 let parsed_detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
1888 assert!(parsed_detailed.connectivity_nodes.iter().any(|node| {
1889 node.component.local_id() == CONNECTIVITY_NODE_MRID
1890 && node.voltage_level.component_type() == "line"
1891 && node.voltage_level.local_id() == SOURCE_LINE_MRID
1892 }));
1893 }
1894
1895 #[test]
1896 fn disconnected_terminal_with_a_calculated_bus_omits_its_tp_association() {
1897 const TERMINAL_MRID: &str = "89000000-0000-4000-8000-000000000001";
1898 let mut network = detailed_network();
1899 let terminal_component = component("terminal", TERMINAL_MRID);
1900 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1901 let terminal = detailed
1902 .terminals
1903 .iter_mut()
1904 .find(|terminal| terminal.equipment.component_type() == "load")
1905 .unwrap();
1906 assert!(terminal.node.is_some());
1907 terminal.component = Some(terminal_component.clone());
1908 terminal.connected = false;
1909 detailed.component_metadata.push(ComponentMetadata {
1910 component: terminal_component,
1911 name: Some("Disconnected load terminal".into()),
1912 equipment_container: None,
1913 aliases: Vec::new(),
1914 external_identifiers: vec![ExternalIdentifier {
1915 value: TERMINAL_MRID.into(),
1916 authority: Some("CGMES".into()),
1917 }],
1918 properties: std::collections::BTreeMap::new(),
1919 fictitious: false,
1920 });
1921
1922 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
1923 let profile = |suffix: &str| {
1924 output
1925 .files
1926 .iter()
1927 .find(|(name, _)| name.ends_with(suffix))
1928 .map(|(_, text)| text.as_str())
1929 .unwrap()
1930 };
1931 let terminal_id = format!("_{TERMINAL_MRID}");
1932 assert!(profile("_EQ.xml").contains(&format!("rdf:ID=\"{terminal_id}\"")));
1933 assert!(!profile("_TP.xml").contains(&format!("rdf:about=\"#{terminal_id}\"")));
1934 assert!(profile("_SSH.xml").contains(&format!("rdf:about=\"#{terminal_id}\"")));
1935 assert!(
1936 profile("_SSH.xml")
1937 .contains("<cim:ACDCTerminal.connected>false</cim:ACDCTerminal.connected>")
1938 );
1939
1940 let parsed =
1941 read::read_cgmes_documents(output.files, Some("disconnected-terminal")).unwrap();
1942 let terminal = parsed
1943 .network
1944 .detailed_connectivity()
1945 .as_deref()
1946 .unwrap()
1947 .terminals
1948 .iter()
1949 .find(|terminal| {
1950 terminal
1951 .component
1952 .as_ref()
1953 .is_some_and(|component| component.local_id() == TERMINAL_MRID)
1954 })
1955 .unwrap();
1956 assert!(!terminal.connected);
1957 assert!(terminal.bus.is_none());
1958 assert!(terminal.node.is_some());
1959 }
1960
1961 #[test]
1962 #[allow(clippy::too_many_lines)]
1963 fn disconnected_detailed_equipment_round_trips_without_a_topological_node() {
1964 let mut network = detailed_network();
1965 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
1966 let voltage_level = detailed.voltage_levels[0].component.clone();
1967 let first_node = component("connectivity_node", "81000000-0000-4000-8000-000000000001");
1968 let second_node = component("connectivity_node", "81000000-0000-4000-8000-000000000002");
1969 let busbar = component("busbar_section", "82000000-0000-4000-8000-000000000001");
1970 let junction = component("junction", "83000000-0000-4000-8000-000000000001");
1971 let switch = component("switch", "84000000-0000-4000-8000-000000000001");
1972 let terminal_ids = [
1973 component("terminal", "85000000-0000-4000-8000-000000000001"),
1974 component("terminal", "85000000-0000-4000-8000-000000000002"),
1975 component("terminal", "85000000-0000-4000-8000-000000000003"),
1976 component("terminal", "85000000-0000-4000-8000-000000000004"),
1977 ];
1978
1979 detailed.connectivity_nodes.extend([
1980 ConnectivityNode {
1981 component: first_node.clone(),
1982 voltage_level: voltage_level.clone(),
1983 node_number: None,
1984 calculated_bus: None,
1985 },
1986 ConnectivityNode {
1987 component: second_node.clone(),
1988 voltage_level: voltage_level.clone(),
1989 node_number: None,
1990 calculated_bus: None,
1991 },
1992 ]);
1993 detailed.busbar_sections.push(BusbarSection {
1994 component: busbar.clone(),
1995 voltage_level: voltage_level.clone(),
1996 node: first_node.clone(),
1997 });
1998 detailed.junctions.push(Junction {
1999 component: junction.clone(),
2000 });
2001 detailed.switches.push(TopologySwitch {
2002 component: switch.clone(),
2003 voltage_level: voltage_level.clone(),
2004 kind: SwitchKind::Breaker,
2005 endpoint1: TopologyEndpoint::Node(first_node.clone()),
2006 endpoint2: TopologyEndpoint::Node(second_node.clone()),
2007 open: true,
2008 retained: true,
2009 });
2010 let disconnected_terminal =
2011 |component: ComponentId, equipment: ComponentId, terminal: u8, node: ComponentId| {
2012 Terminal {
2013 component: Some(component),
2014 equipment,
2015 terminal,
2016 voltage_level: voltage_level.clone(),
2017 bus: None,
2018 connectable_bus: None,
2019 node: Some(node),
2020 connected: false,
2021 active_power_mw: None,
2022 reactive_power_mvar: None,
2023 }
2024 };
2025 detailed.terminals.extend([
2026 disconnected_terminal(
2027 terminal_ids[0].clone(),
2028 busbar.clone(),
2029 1,
2030 first_node.clone(),
2031 ),
2032 disconnected_terminal(
2033 terminal_ids[1].clone(),
2034 junction.clone(),
2035 1,
2036 second_node.clone(),
2037 ),
2038 disconnected_terminal(terminal_ids[2].clone(), switch.clone(), 1, first_node),
2039 disconnected_terminal(terminal_ids[3].clone(), switch.clone(), 2, second_node),
2040 ]);
2041 for value in [&busbar, &junction, &switch]
2042 .into_iter()
2043 .chain(terminal_ids.iter())
2044 {
2045 detailed.component_metadata.push(ComponentMetadata {
2046 component: value.clone(),
2047 name: Some(value.local_id().into()),
2048 equipment_container: None,
2049 aliases: Vec::new(),
2050 external_identifiers: vec![ExternalIdentifier {
2051 value: value.local_id().into(),
2052 authority: Some("CGMES".into()),
2053 }],
2054 properties: std::collections::BTreeMap::new(),
2055 fictitious: false,
2056 });
2057 }
2058
2059 network.validate().unwrap();
2060 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2061 assert!(
2062 !output
2063 .warnings
2064 .iter()
2065 .any(|warning| warning.contains("not emitted"))
2066 );
2067 let all_xml = output
2068 .files
2069 .iter()
2070 .map(|(_, xml)| xml.as_str())
2071 .collect::<String>();
2072 for value in [&busbar, &junction, &switch] {
2073 assert!(all_xml.contains(value.local_id()));
2074 }
2075 assert_eq!(
2076 all_xml.matches("<cim:ACDCTerminal.connected>false").count(),
2077 4
2078 );
2079
2080 let parsed = read::read_cgmes_documents(output.files, Some("disconnected")).unwrap();
2081 let parsed = parsed.network.detailed_connectivity().as_deref().unwrap();
2082 assert!(
2083 parsed
2084 .busbar_sections
2085 .iter()
2086 .any(|value| value.component.local_id() == busbar.local_id())
2087 );
2088 assert!(
2089 parsed
2090 .junctions
2091 .iter()
2092 .any(|value| value.component.local_id() == junction.local_id())
2093 );
2094 assert!(
2095 parsed
2096 .switches
2097 .iter()
2098 .any(|value| value.component.local_id() == switch.local_id())
2099 );
2100 for id in &terminal_ids {
2101 assert!(parsed.terminals.iter().any(|terminal| {
2102 terminal
2103 .component
2104 .as_ref()
2105 .is_some_and(|value| value.local_id() == id.local_id())
2106 && !terminal.connected
2107 }));
2108 }
2109 }
2110
2111 #[test]
2112 fn unrelated_boundary_connectivity_node_and_line_are_omitted() {
2113 const CONNECTIVITY_NODE_MRID: &str = "44444444-4444-4444-8444-444444444444";
2114 const SOURCE_LINE_MRID: &str = "55555555-5555-4555-8555-555555555555";
2115
2116 let mut network = detailed_network();
2117 let source_line = component("line", SOURCE_LINE_MRID);
2118 let connectivity_node = component("connectivity_node", CONNECTIVITY_NODE_MRID);
2119 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
2120 detailed.connectivity_nodes.push(ConnectivityNode {
2121 component: connectivity_node.clone(),
2122 voltage_level: source_line.clone(),
2123 node_number: None,
2124 calculated_bus: None,
2125 });
2126 for (component, name) in [
2127 (source_line.clone(), "Unrelated boundary line"),
2128 (connectivity_node.clone(), "Unrelated boundary node"),
2129 ] {
2130 detailed.component_metadata.push(ComponentMetadata {
2131 external_identifiers: vec![ExternalIdentifier {
2132 value: component.local_id().into(),
2133 authority: Some("CGMES".into()),
2134 }],
2135 component,
2136 name: Some(name.into()),
2137 equipment_container: None,
2138 aliases: Vec::new(),
2139 properties: std::collections::BTreeMap::new(),
2140 fictitious: false,
2141 });
2142 }
2143
2144 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2145 assert!(output.warnings.iter().any(|warning| {
2146 warning.contains(&connectivity_node.to_string())
2147 && warning.contains(&source_line.to_string())
2148 && warning.contains("not connected to a calculated bus or retained equipment")
2149 && warning.contains("omitted from fresh CGMES emission")
2150 }));
2151 let all_xml = output
2152 .files
2153 .iter()
2154 .map(|(_, xml)| xml.as_str())
2155 .collect::<String>();
2156 assert!(!all_xml.contains(CONNECTIVITY_NODE_MRID));
2157 assert!(!all_xml.contains(SOURCE_LINE_MRID));
2158 }
2159
2160 #[test]
2161 fn emitted_terminal_without_node_number_has_a_matching_connectivity_node() {
2162 const UNRELATED_NODE_MRID: &str = "66666666-6666-4666-8666-666666666666";
2163 const UNRELATED_LINE_MRID: &str = "77777777-7777-4777-8777-777777777777";
2164
2165 let mut network = detailed_network();
2166 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
2167 let load_terminal = detailed
2168 .terminals
2169 .iter_mut()
2170 .find(|terminal| terminal.equipment.component_type() == "load")
2171 .unwrap();
2172 assert_eq!(
2173 detailed
2174 .voltage_levels
2175 .iter()
2176 .find(|level| level.component == load_terminal.voltage_level)
2177 .unwrap()
2178 .topology_kind,
2179 TopologyKind::NodeBreaker
2180 );
2181 load_terminal.node = None;
2182
2183 let unrelated_line = component("line", UNRELATED_LINE_MRID);
2184 let unrelated_node = component("connectivity_node", UNRELATED_NODE_MRID);
2185 detailed.connectivity_nodes.push(ConnectivityNode {
2186 component: unrelated_node.clone(),
2187 voltage_level: unrelated_line.clone(),
2188 node_number: None,
2189 calculated_bus: None,
2190 });
2191 for (component, name) in [
2192 (unrelated_line, "Unrelated boundary line"),
2193 (unrelated_node, "Unrelated boundary node"),
2194 ] {
2195 detailed.component_metadata.push(ComponentMetadata {
2196 external_identifiers: vec![ExternalIdentifier {
2197 value: component.local_id().into(),
2198 authority: Some("CGMES".into()),
2199 }],
2200 component,
2201 name: Some(name.into()),
2202 equipment_container: None,
2203 aliases: Vec::new(),
2204 properties: std::collections::BTreeMap::new(),
2205 fictitious: false,
2206 });
2207 }
2208
2209 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2210 let all_xml = output
2211 .files
2212 .iter()
2213 .map(|(_, xml)| xml.as_str())
2214 .collect::<String>();
2215 assert!(!all_xml.contains(UNRELATED_NODE_MRID));
2216 assert!(!all_xml.contains(UNRELATED_LINE_MRID));
2217
2218 let reparsed =
2219 read::read_cgmes_documents(output.files, Some("terminal-connectivity")).unwrap();
2220 let detailed = reparsed.network.detailed_connectivity().as_deref().unwrap();
2221 let load_terminal = detailed
2222 .terminals
2223 .iter()
2224 .find(|terminal| terminal.equipment.component_type() == "load")
2225 .unwrap();
2226 let emitted_node = load_terminal.node.as_ref().unwrap();
2227 assert!(
2228 detailed
2229 .connectivity_nodes
2230 .iter()
2231 .any(|node| node.component == *emitted_node)
2232 );
2233 }
2234
2235 #[test]
2236 fn missing_balanced_terminal_records_get_generated_connectivity_nodes() {
2237 for (equipment_type, terminal_number) in [("load", 1), ("branch", 2)] {
2238 let mut network = detailed_network();
2239 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
2240 detailed.terminals.retain(|terminal| {
2241 terminal.equipment.component_type() != equipment_type
2242 || usize::from(terminal.terminal) != terminal_number
2243 });
2244 network.validate().unwrap();
2245
2246 for version in [CgmesVersion::V2_4_15, CgmesVersion::V3_0] {
2247 let output = write::write_cgmes(&network, version).unwrap();
2248 assert!(output.warnings.iter().any(|warning| {
2249 warning.contains("balanced equipment terminal at bus 2")
2250 && warning.contains("generated ConnectivityNode")
2251 }));
2252 let parsed =
2253 read::read_cgmes_documents(output.files, Some("missing-balanced-terminal"))
2254 .unwrap();
2255 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
2256 let terminal = detailed
2257 .terminals
2258 .iter()
2259 .find(|terminal| {
2260 terminal.equipment.component_type() == equipment_type
2261 && usize::from(terminal.terminal) == terminal_number
2262 })
2263 .unwrap();
2264 let node = terminal.node.as_ref().unwrap();
2265 assert!(detailed.connectivity_nodes.iter().any(|candidate| {
2266 candidate.component == *node && candidate.calculated_bus == Some(BusId(2))
2267 }));
2268 }
2269 }
2270 }
2271
2272 #[test]
2278 fn emission_substitutes_and_declares_an_unstated_voltage_base() {
2279 let mut unstated_bus = network();
2280 unstated_bus.buses_mut()[0].base_kv = 0.0;
2281 let files = write::write_cgmes(&unstated_bus, CgmesVersion::V3_0).unwrap();
2282 let messages = files
2283 .warnings
2284 .iter()
2285 .map(|record| record.to_string())
2286 .collect::<Vec<_>>()
2287 .join("\n");
2288 assert!(
2289 messages.contains("1 bus(es) state no nominal voltage"),
2290 "{messages}"
2291 );
2292 assert!(messages.contains("bus 1"), "{messages}");
2293 let eq = files
2294 .files
2295 .iter()
2296 .find(|(name, _)| name.contains("_EQ"))
2297 .map(|(_, text)| text.as_str())
2298 .unwrap();
2299 assert!(eq.contains("<cim:BaseVoltage.nominalVoltage>1</cim:BaseVoltage.nominalVoltage>"));
2300
2301 let mut missing_topological_node_base = detailed_network();
2302 let detailed = Arc::make_mut(
2303 missing_topological_node_base
2304 .detailed_connectivity_mut()
2305 .as_mut()
2306 .unwrap(),
2307 );
2308 let topological_node = detailed.bus_breaker_buses[0].component.clone();
2309 let source_container = component("voltage_level", "untyped-topology-container");
2310 detailed.bus_breaker_buses[0].voltage_level = source_container.clone();
2311 detailed.bus_breaker_buses[0].calculated_bus = None;
2312 let error =
2313 write::write_cgmes(&missing_topological_node_base, CgmesVersion::V3_0).unwrap_err();
2314 let message = error.to_string();
2315 assert!(message.contains(&source_container.to_string()));
2316 assert!(message.contains("TopologicalNode"));
2317 assert!(message.contains(&topological_node.to_string()));
2318 assert!(message.contains("has no calculated bus"));
2319
2320 let mut missing_connectivity_node_base = detailed_network();
2321 let detailed = Arc::make_mut(
2322 missing_connectivity_node_base
2323 .detailed_connectivity_mut()
2324 .as_mut()
2325 .unwrap(),
2326 );
2327 let connectivity_node = detailed.connectivity_nodes[0].component.clone();
2328 let source_container = component("voltage_level", "untyped-connectivity-container");
2329 detailed.connectivity_nodes[0].voltage_level = source_container.clone();
2330 detailed.connectivity_nodes[0].calculated_bus = None;
2331 let error =
2332 write::write_cgmes(&missing_connectivity_node_base, CgmesVersion::V3_0).unwrap_err();
2333 let message = error.to_string();
2334 assert!(message.contains(&source_container.to_string()));
2335 assert!(message.contains("ConnectivityNode"));
2336 assert!(message.contains(&connectivity_node.to_string()));
2337 assert!(message.contains("has no calculated bus"));
2338 }
2339
2340 #[test]
2341 fn three_winding_transformer_round_trips_without_star_lowering() {
2342 let mut network = network();
2343 let mut third = Bus::new(BusId(3), BusType::Pq, 115.0);
2344 third.uid = Some("bus-3".into());
2345 network.buses_mut().push(third);
2346
2347 let mut winding1 = Winding::new(BusId(1));
2348 winding1.nominal_kv = 230.0;
2349 winding1.tap = 1.03;
2350 winding1.shift = 2.5;
2351 winding1.rate_a = 120.0;
2352 winding1.rate_b = 135.0;
2353 winding1.rate_c = 150.0;
2354 let mut winding2 = Winding::new(BusId(2));
2355 winding2.nominal_kv = 230.0;
2356 winding2.rate_a = 100.0;
2357 let mut winding3 = Winding::new(BusId(3));
2358 winding3.nominal_kv = 115.0;
2359 winding3.rate_a = 80.0;
2360 let mut transformer = Transformer3W::new(
2361 [winding1, winding2, winding3],
2362 [
2363 Impedance::new(0.01, 0.10, 100.0),
2364 Impedance::new(0.02, 0.20, 100.0),
2365 Impedance::new(0.03, 0.30, 100.0),
2366 ],
2367 );
2368 transformer.mag_g = 0.001;
2369 transformer.mag_b = -0.01;
2370 transformer.name = Some("three winding transformer".into());
2371 transformer.uid = Some("three-winding-1".into());
2372 network.transformers_3w_mut().push(transformer);
2373
2374 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2375 assert!(
2376 output
2377 .files
2378 .iter()
2379 .find(|(name, _)| name.ends_with("_EQ.xml"))
2380 .is_some_and(|(_, text)| text.matches("<cim:PowerTransformerEnd").count() >= 3)
2381 );
2382 let parsed = read::read_cgmes_documents(output.files, Some("three-winding")).unwrap();
2383 assert_eq!(parsed.network.transformers_3w().len(), 1);
2384 let parsed = &parsed.network.transformers_3w()[0];
2385 assert_eq!(parsed.windings[2].bus, BusId(3));
2386 assert!((parsed.windings[0].tap - 1.03).abs() < 1e-10);
2387 assert!((parsed.windings[0].shift - 2.5).abs() < 1e-10);
2388 assert!((parsed.windings[0].rate_a - 120.0).abs() < 1e-10);
2389 assert!((parsed.windings[0].rate_b - 135.0).abs() < 1e-10);
2390 assert!((parsed.windings[0].rate_c - 150.0).abs() < 1e-10);
2391 for (actual, expected) in parsed.z.iter().zip([0.10, 0.20, 0.30]) {
2392 assert!((actual.x - expected).abs() < 1e-10);
2393 }
2394 assert!((parsed.mag_g - 0.001).abs() < 1e-10);
2395 assert!((parsed.mag_b + 0.01).abs() < 1e-10);
2396 }
2397
2398 #[test]
2399 fn imported_power_transformer_end_identities_round_trip_exactly() {
2400 let mut network = network();
2401 network.branches_mut()[0].tap = 1.05;
2402
2403 let mut third = Bus::new(BusId(3), BusType::Pq, 115.0);
2404 third.uid = Some("bus-3".into());
2405 network.buses_mut().push(third);
2406 let mut winding1 = Winding::new(BusId(1));
2407 winding1.nominal_kv = 230.0;
2408 let mut winding2 = Winding::new(BusId(2));
2409 winding2.nominal_kv = 230.0;
2410 let mut winding3 = Winding::new(BusId(3));
2411 winding3.nominal_kv = 115.0;
2412 let mut transformer = Transformer3W::new(
2413 [winding1, winding2, winding3],
2414 [
2415 Impedance::new(0.01, 0.10, 100.0),
2416 Impedance::new(0.02, 0.20, 100.0),
2417 Impedance::new(0.03, 0.30, 100.0),
2418 ],
2419 );
2420 transformer.uid = Some("three-winding-end-id-test".into());
2421 network.transformers_3w_mut().push(transformer);
2422
2423 let mut files = write::write_cgmes(&network, CgmesVersion::V3_0)
2424 .unwrap()
2425 .files;
2426 let eq = files
2427 .iter()
2428 .find(|(name, _)| name.ends_with("_EQ.xml"))
2429 .map(|(_, xml)| xml)
2430 .unwrap();
2431 let generated = eq
2432 .lines()
2433 .filter_map(|line| {
2434 line.trim()
2435 .strip_prefix("<cim:PowerTransformerEnd rdf:ID=\"_")
2436 .and_then(|tail| tail.split('\"').next())
2437 .map(str::to_owned)
2438 })
2439 .collect::<Vec<_>>();
2440 assert_eq!(generated.len(), 5);
2441 let retained = [
2442 "10000000-0000-4000-8000-000000000001",
2443 "10000000-0000-4000-8000-000000000002",
2444 "10000000-0000-4000-8000-000000000003",
2445 "10000000-0000-4000-8000-000000000004",
2446 "10000000-0000-4000-8000-000000000005",
2447 ];
2448 for (generated, retained) in generated.iter().zip(retained) {
2449 for (_, xml) in &mut files {
2450 *xml = xml.replace(&format!("_{generated}"), &format!("_{retained}"));
2451 }
2452 }
2453
2454 let parsed = read::read_cgmes_documents(files, Some("retained-transformer-ends")).unwrap();
2455 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
2456 for retained in retained {
2457 let metadata = detailed
2458 .component_metadata
2459 .iter()
2460 .find(|metadata| {
2461 metadata.external_identifiers.iter().any(|identifier| {
2462 identifier.authority.as_deref() == Some("CGMES")
2463 && identifier.value == retained
2464 })
2465 })
2466 .unwrap();
2467 assert_eq!(
2468 metadata
2469 .properties
2470 .get(CGMES_CLASS_PROPERTY)
2471 .map(String::as_str),
2472 Some("PowerTransformerEnd")
2473 );
2474 assert!(
2475 metadata
2476 .properties
2477 .contains_key("PowerTransformerEnd.PowerTransformer")
2478 );
2479 assert!(metadata.properties.contains_key("TransformerEnd.endNumber"));
2480 }
2481
2482 let fresh = write::write_cgmes(&parsed.network, CgmesVersion::V3_0).unwrap();
2483 let eq = fresh
2484 .files
2485 .iter()
2486 .find(|(name, _)| name.ends_with("_EQ.xml"))
2487 .map(|(_, xml)| xml)
2488 .unwrap();
2489 for retained in retained {
2490 assert!(
2491 eq.contains(&format!("<cim:PowerTransformerEnd rdf:ID=\"_{retained}\">")),
2492 "missing retained transformer end {retained}"
2493 );
2494 }
2495 }
2496
2497 #[test]
2498 fn every_transformer_end_emits_zero_susceptance_when_it_has_none() {
2499 let mut network = network();
2500 network.branches_mut()[0].tap = 1.05;
2501
2502 let mut third = Bus::new(BusId(3), BusType::Pq, 115.0);
2503 third.uid = Some("bus-3".into());
2504 network.buses_mut().push(third);
2505 let mut winding1 = Winding::new(BusId(1));
2506 winding1.nominal_kv = 230.0;
2507 let mut winding2 = Winding::new(BusId(2));
2508 winding2.nominal_kv = 230.0;
2509 let mut winding3 = Winding::new(BusId(3));
2510 winding3.nominal_kv = 115.0;
2511 network.transformers_3w_mut().push(Transformer3W::new(
2512 [winding1, winding2, winding3],
2513 [
2514 Impedance::new(0.01, 0.10, 100.0),
2515 Impedance::new(0.02, 0.20, 100.0),
2516 Impedance::new(0.03, 0.30, 100.0),
2517 ],
2518 ));
2519
2520 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2521 let eq = output
2522 .files
2523 .iter()
2524 .find(|(name, _)| name.ends_with("_EQ.xml"))
2525 .map(|(_, text)| text)
2526 .unwrap();
2527 assert_eq!(eq.matches("<cim:PowerTransformerEnd ").count(), 5);
2528 assert_eq!(
2529 eq.matches("<cim:PowerTransformerEnd.b>0</cim:PowerTransformerEnd.b>")
2530 .count(),
2531 5
2532 );
2533
2534 let reparsed = read::read_cgmes_documents(output.files, Some("zero-end-b")).unwrap();
2535 assert_eq!(
2536 reparsed
2537 .network
2538 .branches()
2539 .iter()
2540 .filter(|branch| branch.is_transformer())
2541 .count(),
2542 1
2543 );
2544 assert_eq!(reparsed.network.transformers_3w().len(), 1);
2545 }
2546
2547 #[test]
2548 fn missing_transformer_rated_voltage_uses_the_connected_bus_with_a_diagnostic() {
2549 let mut network = network();
2550 network.branches_mut()[0].tap = 1.05;
2551 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2552 let files = output
2553 .files
2554 .into_iter()
2555 .map(|(name, text)| {
2556 if !name.ends_with("_EQ.xml") {
2557 return (name, text);
2558 }
2559 let mut filtered = text
2560 .lines()
2561 .filter(|line| !line.contains("PowerTransformerEnd.ratedU"))
2562 .collect::<Vec<_>>()
2563 .join("\n");
2564 filtered.push('\n');
2565 (name, filtered)
2566 })
2567 .collect();
2568 let parsed = read::read_cgmes_documents(files, Some("missing-rated-voltage")).unwrap();
2569 let actual = &parsed.network.branches()[0];
2570 assert!(actual.r.is_finite() && actual.r > 0.0 && actual.r < 1.0);
2571 assert!(actual.x.is_finite() && actual.x > 0.0 && actual.x < 1.0);
2572 let warnings = parsed
2573 .warnings
2574 .iter()
2575 .filter(|warning| {
2576 warning.info.code == crate::diagnostics::codes::READ_CGMES_VALUE_DEFAULTED.code
2577 && warning.contains("PowerTransformerEnd.ratedU is absent")
2578 })
2579 .count();
2580 assert_eq!(warnings, 2);
2581 }
2582
2583 #[test]
2584 fn nonlinear_shunt_sections_round_trip_with_conductance() {
2585 let mut network = network();
2586 let mut shunt = Shunt::new(BusId(2), 0.3, 5.0);
2587 shunt.uid = Some("nonlinear-shunt-1".into());
2588 shunt.section_count = Some(2);
2589 shunt.control = Some(SwitchedShuntControl {
2590 mode: SwitchedShuntMode::Discrete,
2591 vhigh: 1.04,
2592 vlow: 0.96,
2593 control_bus: None,
2594 regulating_terminal: None,
2595 rmpct: 100.0,
2596 blocks: vec![
2597 ShuntBlock::with_admittance(1, 0.1, 2.0),
2598 ShuntBlock::with_admittance(1, 0.2, 3.0),
2599 ShuntBlock::with_admittance(1, 0.4, 4.0),
2600 ],
2601 });
2602 network.shunts_mut().push(shunt);
2603
2604 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2605 let eq = output
2606 .files
2607 .iter()
2608 .find(|(name, _)| name.ends_with("_EQ.xml"))
2609 .map(|(_, text)| text)
2610 .unwrap();
2611 assert!(eq.contains("<cim:NonlinearShuntCompensator "));
2612 assert_eq!(
2613 eq.matches("<cim:NonlinearShuntCompensatorPoint ").count(),
2614 3
2615 );
2616 let sv = output
2617 .files
2618 .iter()
2619 .find(|(name, _)| name.ends_with("_SV.xml"))
2620 .map(|(_, text)| text)
2621 .unwrap();
2622 assert!(sv.contains("<cim:SvShuntCompensatorSections "));
2623 assert!(sv.contains(
2624 "<cim:SvShuntCompensatorSections.sections>2</cim:SvShuntCompensatorSections.sections>"
2625 ));
2626
2627 let parsed = read::read_cgmes_documents(output.files, Some("nonlinear-shunt")).unwrap();
2628 let shunt = &parsed.network.shunts()[0];
2629 assert!((shunt.g - 0.3).abs() < 1e-10);
2630 assert!((shunt.b - 5.0).abs() < 1e-10);
2631 assert_eq!(shunt.section_count, Some(2));
2632 assert!(shunt.in_service);
2633 let control = shunt.control.as_ref().unwrap();
2634 assert_eq!(control.mode, SwitchedShuntMode::Discrete);
2635 assert_eq!(control.blocks.len(), 3);
2636 assert!((control.blocks[2].g - 0.4).abs() < 1e-10);
2637 assert!((control.blocks[2].b - 4.0).abs() < 1e-10);
2638 }
2639
2640 #[test]
2641 fn load_response_characteristics_round_trip() {
2642 let mut network = network();
2643 network.loads_mut()[0].voltage_model = Some(LoadVoltageModel::Zip {
2644 p_constant_power: 10.0,
2645 q_constant_power: 2.5,
2646 p_constant_current: 4.0,
2647 q_constant_current: 1.0,
2648 p_constant_impedance: 6.0,
2649 q_constant_impedance: 1.5,
2650 v_nom: None,
2651 load_type: None,
2652 scaling: None,
2653 });
2654 let mut exponential = Load::new(BusId(2), 8.0, 3.0);
2655 exponential.uid = Some("9db15afd-90c9-5aad-b2a0-0e0ac3a64772".into());
2656 exponential.voltage_model = Some(LoadVoltageModel::Exponential {
2657 p: 8.0,
2658 q: 3.0,
2659 v_nom: None,
2660 gamma_p: 1.2,
2661 gamma_q: 2.1,
2662 });
2663 network.loads_mut().push(exponential);
2664
2665 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2666 let eq = output
2667 .files
2668 .iter()
2669 .find(|(name, _)| name.ends_with("_EQ.xml"))
2670 .map(|(_, text)| text)
2671 .unwrap();
2672 assert!(eq.contains("<cim:EnergyConsumer.LoadResponse "));
2673 assert!(eq.contains("<cim:LoadResponseCharacteristic.exponentModel>"));
2674
2675 let parsed = read::read_cgmes_documents(output.files, Some("load-response")).unwrap();
2676 let zip = parsed
2677 .network
2678 .loads()
2679 .iter()
2680 .find_map(|load| match &load.voltage_model {
2681 Some(LoadVoltageModel::Zip {
2682 p_constant_power,
2683 q_constant_power,
2684 p_constant_current,
2685 q_constant_current,
2686 p_constant_impedance,
2687 q_constant_impedance,
2688 ..
2689 }) => Some([
2690 *p_constant_power,
2691 *q_constant_power,
2692 *p_constant_current,
2693 *q_constant_current,
2694 *p_constant_impedance,
2695 *q_constant_impedance,
2696 ]),
2697 _ => None,
2698 })
2699 .unwrap();
2700 for (actual, expected) in zip.into_iter().zip([10.0, 2.5, 4.0, 1.0, 6.0, 1.5]) {
2701 assert!((actual - expected).abs() < 1e-12);
2702 }
2703 let exponential = parsed
2704 .network
2705 .loads()
2706 .iter()
2707 .find_map(|load| match &load.voltage_model {
2708 Some(LoadVoltageModel::Exponential {
2709 gamma_p, gamma_q, ..
2710 }) => Some((*gamma_p, *gamma_q)),
2711 _ => None,
2712 })
2713 .unwrap();
2714 assert!((exponential.0 - 1.2).abs() < 1e-12);
2715 assert!((exponential.1 - 2.1).abs() < 1e-12);
2716 }
2717
2718 #[test]
2719 fn detailed_hierarchy_and_connectivity_round_trip() {
2720 let output = write::write_cgmes(&detailed_network(), CgmesVersion::V3_0).unwrap();
2721 let parsed = read::read_cgmes_documents(output.files, Some("detailed")).unwrap();
2722 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
2723 assert_eq!(detailed.substations.len(), 1);
2724 assert_eq!(detailed.voltage_levels.len(), 1);
2725 assert_eq!(detailed.connectivity_nodes.len(), 2);
2726 assert_eq!(detailed.busbar_sections.len(), 1);
2727 assert_eq!(detailed.switches.len(), 1);
2728 assert!(
2729 detailed
2730 .component_metadata
2731 .iter()
2732 .any(|metadata| { metadata.name.as_deref() == Some("North substation") })
2733 );
2734 }
2735
2736 #[test]
2737 fn busbar_and_switch_terminals_round_trip_exactly() {
2738 let mut network = detailed_network();
2739 let terminal_ids = [
2740 "10000000-0000-4000-8000-000000000001",
2741 "10000000-0000-4000-8000-000000000002",
2742 "10000000-0000-4000-8000-000000000003",
2743 ];
2744 let expected = [
2745 ("busbar_section", 1_u8, terminal_ids[0], false, 1.25, -0.5),
2746 ("switch", 1_u8, terminal_ids[1], false, 2.0, 3.0),
2747 ("switch", 2_u8, terminal_ids[2], true, -2.0, -3.0),
2748 ];
2749 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
2750 for (equipment_type, number, id, connected, p, q) in expected {
2751 let terminal = detailed
2752 .terminals
2753 .iter_mut()
2754 .find(|terminal| {
2755 terminal.equipment.component_type() == equipment_type
2756 && terminal.terminal == number
2757 })
2758 .unwrap();
2759 let component = component("terminal", id);
2760 terminal.component = Some(component.clone());
2761 terminal.connected = connected;
2762 terminal.active_power_mw = Some(p);
2763 terminal.reactive_power_mvar = Some(q);
2764 detailed.component_metadata.push(ComponentMetadata {
2765 component,
2766 name: None,
2767 equipment_container: None,
2768 aliases: Vec::new(),
2769 external_identifiers: vec![ExternalIdentifier {
2770 value: id.into(),
2771 authority: Some("CGMES".into()),
2772 }],
2773 properties: std::collections::BTreeMap::new(),
2774 fictitious: false,
2775 });
2776 }
2777
2778 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2779 let parsed = read::read_cgmes_documents(output.files, Some("terminal-identity")).unwrap();
2780 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
2781 for (equipment_type, number, id, connected, p, q) in expected {
2782 let terminal = detailed
2783 .terminals
2784 .iter()
2785 .find(|terminal| {
2786 terminal.equipment.component_type() == equipment_type
2787 && terminal.terminal == number
2788 })
2789 .unwrap();
2790 assert_eq!(terminal.component.as_ref().unwrap().local_id(), id);
2791 assert_eq!(terminal.connected, connected);
2792 assert_eq!(terminal.active_power_mw, Some(p));
2793 assert_eq!(terminal.reactive_power_mvar, Some(q));
2794 }
2795 }
2796
2797 #[test]
2798 fn voltage_level_buses_seed_topological_nodes() {
2799 let mut network = detailed_network();
2800 network.loads_mut().clear();
2801 network.generators_mut().clear();
2802 network.branches_mut().clear();
2803 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
2804 detailed.bus_breaker_buses.clear();
2805 detailed.calculated_buses.clear();
2806 detailed.connectivity_nodes.clear();
2807 detailed.busbar_sections.clear();
2808 detailed.terminals.clear();
2809 detailed.switches.clear();
2810
2811 network.validate().unwrap();
2812 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2813 let topology = output
2814 .files
2815 .iter()
2816 .find(|(name, _)| name.ends_with("_TP.xml"))
2817 .map(|(_, text)| text)
2818 .unwrap();
2819 assert_eq!(topology.matches("<cim:TopologicalNode ").count(), 2);
2820 assert_eq!(
2821 topology
2822 .matches("<cim:TopologicalNode.ConnectivityNodeContainer ")
2823 .count(),
2824 2
2825 );
2826
2827 let reparsed =
2828 read::read_cgmes_documents(output.files, Some("voltage-level-buses")).unwrap();
2829 assert_eq!(reparsed.network.buses().len(), 2);
2830 }
2831
2832 #[test]
2833 fn partial_detailed_topology_preserves_every_balanced_bus() {
2834 let mut network = detailed_network();
2835 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
2836 let second_bus = component("bus", "tn-B");
2837 let second_node = component("connectivity_node", "node-B");
2838 detailed.voltage_levels[0]
2839 .buses
2840 .retain(|bus| *bus != BusId(2));
2841 detailed
2842 .bus_breaker_buses
2843 .retain(|bus| bus.component != second_bus);
2844 detailed
2845 .connectivity_nodes
2846 .retain(|node| node.component != second_node);
2847 detailed.terminals.retain(|terminal| {
2848 terminal.bus.as_ref() != Some(&second_bus)
2849 && terminal.node.as_ref() != Some(&second_node)
2850 });
2851 detailed.switches.clear();
2852 network.validate().unwrap();
2853
2854 for version in [CgmesVersion::V2_4_15, CgmesVersion::V3_0] {
2855 let output = write::write_cgmes(&network, version).unwrap();
2856 assert!(output.warnings.iter().any(|warning| {
2857 warning.contains("balanced bus 2 is absent from detailed connectivity")
2858 && warning.contains("generated VoltageLevel and ConnectivityNode")
2859 }));
2860 let topology = output
2861 .files
2862 .iter()
2863 .find(|(name, _)| name.ends_with("_TP.xml"))
2864 .map(|(_, text)| text)
2865 .unwrap();
2866 assert_eq!(topology.matches("<cim:TopologicalNode ").count(), 2);
2867
2868 let parsed =
2869 read::read_cgmes_documents(output.files, Some("partial-topology")).unwrap();
2870 assert_eq!(parsed.network.buses().len(), 2);
2871 assert!(parsed.network.buses().iter().any(|bus| bus.id == BusId(2)));
2872 assert_eq!(parsed.network.loads().len(), 1);
2873 assert_eq!(parsed.network.branches().len(), 1);
2874 }
2875 }
2876
2877 #[test]
2878 fn xiidm_node_breaker_calculated_bus_is_emitted_as_a_cgmes_topological_node() {
2879 let source = r#"<?xml version="1.0" encoding="UTF-8"?>
2880<iidm:network xmlns:iidm="http://www.powsybl.org/schema/iidm/1_17" id="nodes" caseDate="2025-01-01T00:00:00Z" forecastDistance="0" sourceFormat="test" minimumValidationLevel="STEADY_STATE_HYPOTHESIS">
2881 <iidm:substation id="S">
2882 <iidm:voltageLevel id="VL" nominalV="110" topologyKind="NODE_BREAKER">
2883 <iidm:nodeBreakerTopology>
2884 <iidm:bus v="110" angle="0" nodes="0,1,2"/>
2885 <iidm:busbarSection id="BBS" node="2"/>
2886 <iidm:switch id="BR" kind="BREAKER" open="false" node1="1" node2="2"/>
2887 <iidm:internalConnection node1="0" node2="1"/>
2888 </iidm:nodeBreakerTopology>
2889 <iidm:generator id="G" energySource="OTHER" minP="0" maxP="10" voltageRegulatorOn="true" targetP="5" node="0">
2890 <iidm:minMaxReactiveLimits minQ="-2" maxQ="2"/>
2891 </iidm:generator>
2892 </iidm:voltageLevel>
2893 </iidm:substation>
2894</iidm:network>"#;
2895 let mut xiidm_diagnostics = Diagnostics::new();
2896 let mut network =
2897 crate::format::xiidm::parse_xiidm_source(source, &mut xiidm_diagnostics).unwrap();
2898 let detailed = network.detailed_connectivity().as_deref().unwrap();
2899 assert!(detailed.bus_breaker_buses.is_empty());
2900 assert_eq!(detailed.calculated_buses.len(), 1);
2901 assert_eq!(detailed.calculated_buses[0].nodes.len(), 3);
2902 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
2903 for node in &mut detailed.connectivity_nodes {
2904 node.calculated_bus = None;
2905 }
2906 assert!(
2907 detailed
2908 .connectivity_nodes
2909 .iter()
2910 .all(|node| node.calculated_bus.is_none())
2911 );
2912
2913 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
2914 let topology = output
2915 .files
2916 .iter()
2917 .find(|(name, _)| name.ends_with("_TP.xml"))
2918 .map(|(_, text)| text)
2919 .unwrap();
2920 assert_eq!(topology.matches("<cim:TopologicalNode ").count(), 1);
2921 assert_eq!(
2922 topology
2923 .matches("<cim:ConnectivityNode.TopologicalNode ")
2924 .count(),
2925 3
2926 );
2927
2928 let reparsed = read::read_cgmes_documents(output.files, Some("node-breaker")).unwrap();
2929 assert_eq!(reparsed.network.buses().len(), 1);
2930 let reparsed_detailed = reparsed.network.detailed_connectivity().as_deref().unwrap();
2931 assert_eq!(reparsed_detailed.bus_breaker_buses.len(), 1);
2932 assert_eq!(reparsed_detailed.connectivity_nodes.len(), 3);
2933 assert!(
2934 reparsed_detailed
2935 .connectivity_nodes
2936 .iter()
2937 .all(|node| node.calculated_bus == Some(BusId::new(1)))
2938 );
2939 }
2940
2941 #[test]
2942 fn calculated_bus_node_conflicts_are_rejected() {
2943 let mut network = detailed_network();
2944 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
2945 let voltage_level = detailed.voltage_levels[0].component.clone();
2946 let node = detailed.connectivity_nodes[1].component.clone();
2947 detailed.calculated_buses.push(CalculatedBus {
2948 voltage_level: voltage_level.clone(),
2949 calculated_bus: BusId(1),
2950 nodes: vec![node.clone()],
2951 voltage_kv: None,
2952 angle_degrees: None,
2953 });
2954
2955 let error = network.validate().unwrap_err().to_string();
2956 assert!(error.contains(&node.to_string()), "{error}");
2957 assert!(error.contains('1') && error.contains('2'), "{error}");
2958
2959 let mut network = detailed_network();
2960 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
2961 let node = detailed.connectivity_nodes[0].component.clone();
2962 for connectivity_node in &mut detailed.connectivity_nodes {
2963 connectivity_node.calculated_bus = None;
2964 }
2965 detailed.calculated_buses.extend([
2966 CalculatedBus {
2967 voltage_level: voltage_level.clone(),
2968 calculated_bus: BusId(1),
2969 nodes: vec![node.clone()],
2970 voltage_kv: None,
2971 angle_degrees: None,
2972 },
2973 CalculatedBus {
2974 voltage_level,
2975 calculated_bus: BusId(2),
2976 nodes: vec![node.clone()],
2977 voltage_kv: None,
2978 angle_degrees: None,
2979 },
2980 ]);
2981 let error = network.validate().unwrap_err().to_string();
2982 assert!(error.contains(&node.to_string()), "{error}");
2983 assert!(error.contains('1') && error.contains('2'), "{error}");
2984 }
2985
2986 #[test]
2987 fn mixed_voltage_level_topologies_emit_complete_node_breaker_connectivity() {
2988 let network = mixed_topology_network();
2989 let original = network.detailed_connectivity().as_deref().unwrap();
2990 assert_eq!(
2991 original
2992 .voltage_levels
2993 .iter()
2994 .filter(|level| level.topology_kind == TopologyKind::NodeBreaker)
2995 .count(),
2996 1
2997 );
2998 assert_eq!(
2999 original
3000 .voltage_levels
3001 .iter()
3002 .filter(|level| level.topology_kind == TopologyKind::BusBreaker)
3003 .count(),
3004 1
3005 );
3006 let expected_counts = (
3007 network.buses().len(),
3008 network.branches().len(),
3009 network.generators().len(),
3010 network.loads().len(),
3011 );
3012
3013 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
3014 assert!(output.warnings.iter().any(|warning| {
3015 warning.contains("contains both node breaker and bus breaker VoltageLevels")
3016 && warning.contains("promotes 1 bus breaker VoltageLevel(s)")
3017 && warning.contains("one ConnectivityNode per TopologicalNode")
3018 }));
3019 let parsed = read::read_cgmes_documents(output.files, Some("mixed-topology")).unwrap();
3020 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
3021 let generator_terminal = detailed
3022 .terminals
3023 .iter()
3024 .find(|terminal| terminal.equipment.component_type() == "generator")
3025 .unwrap();
3026 let load_terminal = detailed
3027 .terminals
3028 .iter()
3029 .find(|terminal| terminal.equipment.component_type() == "load")
3030 .unwrap();
3031 assert!(generator_terminal.node.is_some());
3032 assert!(load_terminal.node.is_some());
3033 assert_eq!(
3034 detailed
3035 .voltage_levels
3036 .iter()
3037 .find(|level| level.component == generator_terminal.voltage_level)
3038 .map(|level| level.topology_kind),
3039 Some(TopologyKind::NodeBreaker)
3040 );
3041 assert_eq!(
3042 detailed
3043 .voltage_levels
3044 .iter()
3045 .find(|level| level.component == load_terminal.voltage_level)
3046 .map(|level| level.topology_kind),
3047 Some(TopologyKind::NodeBreaker)
3048 );
3049 assert_eq!(
3050 (
3051 parsed.network.buses().len(),
3052 parsed.network.branches().len(),
3053 parsed.network.generators().len(),
3054 parsed.network.loads().len(),
3055 ),
3056 expected_counts
3057 );
3058 }
3059
3060 #[test]
3061 fn projected_converter_only_transformer_connection_is_diagnosed() {
3062 let mut network = mixed_topology_network();
3063 network.branches_mut()[0].tap = 1.0;
3064 network.loads_mut().clear();
3065 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
3066 detailed
3067 .terminals
3068 .retain(|terminal| terminal.equipment.component_type() != "load");
3069
3070 let converter = component("voltage_source_converter", "converter-A");
3071 let dc_node_1 = component("dc_node", "dc-node-1");
3072 let dc_node_2 = component("dc_node", "dc-node-2");
3073 let dc_terminal = |node: &ComponentId| DcTerminal {
3074 component: None,
3075 sequence_number: None,
3076 dc_node: Some(node.clone()),
3077 dc_topological_node: None,
3078 polarity: None,
3079 connected: Some(true),
3080 active_power_mw: None,
3081 current_a: None,
3082 };
3083 detailed.dc_series_devices.push(DcSeriesDevice {
3084 component: component("dc_series_device", "series-A"),
3085 equipment_container: None,
3086 dc_terminal1: dc_terminal(&dc_node_1),
3087 dc_terminal2: dc_terminal(&dc_node_2),
3088 rated_dc_voltage_kv: None,
3089 resistance_ohm: None,
3090 inductance_h: None,
3091 });
3092 detailed.voltage_source_converters.push(
3093 serde_json::from_value(serde_json::json!({
3094 "component": converter,
3095 "dc_terminal1": dc_terminal(&dc_node_1),
3096 "dc_terminal2": dc_terminal(&dc_node_2),
3097 }))
3098 .unwrap(),
3099 );
3100 detailed.terminals.push(Terminal {
3101 component: None,
3102 equipment: converter,
3103 terminal: 1,
3104 voltage_level: component("voltage_level", "vl-B"),
3105 bus: Some(component("bus", "tn-B")),
3106 connectable_bus: Some(component("bus", "tn-B")),
3107 node: None,
3108 connected: true,
3109 active_power_mw: None,
3110 reactive_power_mvar: None,
3111 });
3112
3113 let detailed = network.detailed_connectivity().as_deref().unwrap();
3114 let mut warnings =
3115 CgmesDiagnostics::new(&crate::diagnostics::codes::EMIT_CGMES.record_dropped);
3116 write::warn_pow_sybl_projected_transformer_connections(&network, detailed, &mut warnings);
3117 let branch = &network.branches()[0];
3118 let branch_id = branch.uid.as_deref().unwrap();
3119 assert_eq!(warnings.len(), 1);
3120 assert!(warnings[0].contains(&format!("transformer `branch/{branch_id}` terminal 2")));
3121 assert!(warnings[0].contains("configured bus `bus/tn-B`"));
3122 assert!(warnings[0].contains("voltage_source_converter/converter-A"));
3123 assert!(warnings[0].contains("unsupported BACK_TO_BACK"));
3124 assert!(warnings[0].contains("reloads this transformer terminal as disconnected"));
3125 }
3126
3127 const PROJECTED_VOLTAGE_LEVEL_MRID: &str = "0f268ca2-545f-4acf-b01d-b223a0c4e30d";
3128 const PROJECTED_BUSBAR_MRID: &str = "9fa5c795-e6b1-4226-a9fc-e506b4fe68f4";
3129
3130 fn mixed_topology_network_with_busbar() -> BalancedNetwork {
3131 let mut network = mixed_topology_network();
3132 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
3133 let voltage_level = component("voltage_level", "vl-B");
3134 let bus = component("bus", "tn-B");
3135 let node = component("connectivity_node", "terminal-busbar-b");
3136 let busbar = component("busbar_section", "bbs-B");
3137 detailed
3138 .component_metadata
3139 .iter_mut()
3140 .find(|metadata| metadata.component == voltage_level)
3141 .unwrap()
3142 .external_identifiers
3143 .push(ExternalIdentifier {
3144 value: PROJECTED_VOLTAGE_LEVEL_MRID.into(),
3145 authority: Some("CGMES".into()),
3146 });
3147 detailed.component_metadata.push(ComponentMetadata {
3148 component: busbar.clone(),
3149 name: Some("South busbar".into()),
3150 equipment_container: Some(voltage_level.clone()),
3151 aliases: Vec::new(),
3152 external_identifiers: vec![ExternalIdentifier {
3153 value: PROJECTED_BUSBAR_MRID.into(),
3154 authority: Some("CGMES".into()),
3155 }],
3156 properties: std::collections::BTreeMap::new(),
3157 fictitious: false,
3158 });
3159 detailed.connectivity_nodes.push(ConnectivityNode {
3160 component: node.clone(),
3161 voltage_level: voltage_level.clone(),
3162 node_number: None,
3163 calculated_bus: Some(BusId(2)),
3164 });
3165 detailed.busbar_sections.push(BusbarSection {
3166 component: busbar.clone(),
3167 voltage_level: voltage_level.clone(),
3168 node: node.clone(),
3169 });
3170 detailed.terminals.push(Terminal {
3171 component: None,
3172 equipment: busbar,
3173 terminal: 1,
3174 voltage_level,
3175 bus: Some(bus.clone()),
3176 connectable_bus: Some(bus),
3177 node: Some(node),
3178 connected: true,
3179 active_power_mw: None,
3180 reactive_power_mvar: None,
3181 });
3182 assert_eq!(
3183 detailed
3184 .voltage_levels
3185 .iter()
3186 .find(|level| level.component == component("voltage_level", "vl-B"))
3187 .map(|level| level.topology_kind),
3188 Some(TopologyKind::BusBreaker)
3189 );
3190 network
3191 }
3192
3193 #[test]
3194 fn bus_breaker_busbar_is_retained_during_mixed_topology_projection() {
3195 let network = mixed_topology_network_with_busbar();
3196 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
3197 let eq = output
3198 .files
3199 .iter()
3200 .find(|(name, _)| name.ends_with("_EQ.xml"))
3201 .map(|(_, xml)| xml)
3202 .unwrap();
3203 assert!(eq.contains("<cim:IdentifiedObject.name>South busbar</"));
3204 assert!(eq.contains(&format!(
3205 "<cim:BusbarSection rdf:ID=\"_{PROJECTED_BUSBAR_MRID}\">"
3206 )));
3207 assert!(eq.contains(&format!(
3208 "<cim:Equipment.EquipmentContainer rdf:resource=\"#_{PROJECTED_VOLTAGE_LEVEL_MRID}\"/>"
3209 )));
3210 assert!(eq.contains("<cim:ConductingEquipment.BaseVoltage rdf:resource=\"#"));
3211 assert!(!output.warnings.iter().any(|warning| {
3212 warning.contains("BusbarSection `busbar_section/bbs-B`")
3213 && warning.contains("was omitted")
3214 }));
3215 assert!(output.warnings.iter().any(|warning| {
3216 warning.contains("contains both node breaker and bus breaker VoltageLevels")
3217 && warning.contains("one ConnectivityNode per TopologicalNode")
3218 }));
3219
3220 let parsed = read::read_cgmes_documents(output.files, Some("bus-breaker-busbar")).unwrap();
3221 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
3222 let level_component = detailed
3223 .component_metadata
3224 .iter()
3225 .find(|metadata| {
3226 metadata.external_identifiers.iter().any(|identifier| {
3227 identifier.authority.as_deref() == Some("CGMES")
3228 && identifier.value == PROJECTED_VOLTAGE_LEVEL_MRID
3229 })
3230 })
3231 .map(|metadata| &metadata.component)
3232 .unwrap();
3233 let level = detailed
3234 .voltage_levels
3235 .iter()
3236 .find(|level| level.component == *level_component)
3237 .unwrap();
3238 assert_eq!(level.topology_kind, TopologyKind::NodeBreaker);
3239 assert!((level.nominal_kv - 230.0).abs() < 1e-12);
3240
3241 let busbar_metadata = detailed
3242 .component_metadata
3243 .iter()
3244 .find(|metadata| {
3245 metadata.external_identifiers.iter().any(|identifier| {
3246 identifier.authority.as_deref() == Some("CGMES")
3247 && identifier.value == PROJECTED_BUSBAR_MRID
3248 })
3249 })
3250 .unwrap();
3251 assert_eq!(busbar_metadata.name.as_deref(), Some("South busbar"));
3252 assert_eq!(
3253 busbar_metadata.equipment_container.as_ref(),
3254 Some(level_component)
3255 );
3256 let parsed_busbar = detailed
3257 .busbar_sections
3258 .iter()
3259 .find(|record| record.component == busbar_metadata.component)
3260 .unwrap();
3261 assert_eq!(&parsed_busbar.voltage_level, level_component);
3262 let parsed_terminal = detailed
3263 .terminals
3264 .iter()
3265 .find(|terminal| terminal.equipment == parsed_busbar.component)
3266 .unwrap();
3267 assert_eq!(parsed_terminal.node.as_ref(), Some(&parsed_busbar.node));
3268 let parsed_bus = detailed
3269 .bus_breaker_buses
3270 .iter()
3271 .find(|record| record.calculated_bus == Some(BusId(2)))
3272 .unwrap();
3273 assert_eq!(parsed_terminal.bus.as_ref(), Some(&parsed_bus.component));
3274 let parsed_node = detailed
3275 .connectivity_nodes
3276 .iter()
3277 .find(|record| record.component == parsed_busbar.node)
3278 .unwrap();
3279 assert_eq!(&parsed_node.voltage_level, level_component);
3280 assert_eq!(parsed_node.calculated_bus, Some(BusId(2)));
3281 }
3282
3283 #[test]
3284 fn exact_sv_observations_round_trip_without_filling_absent_values() {
3285 let mut network = detailed_network();
3286 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
3287 detailed.bus_breaker_buses[0].voltage_kv = Some(231.25);
3288 detailed.bus_breaker_buses[0].angle_degrees = Some(-2.75);
3289 let expected_flows = [
3290 ("load", 1, 20.25, 5.5),
3291 ("generator", 1, -20.5, -4.75),
3292 ("branch", 1, 18.0, 3.0),
3293 ("branch", 2, -17.75, -2.5),
3294 ];
3295 for (component_type, sequence, active, reactive) in expected_flows {
3296 let terminal = detailed
3297 .terminals
3298 .iter_mut()
3299 .find(|terminal| {
3300 terminal.equipment.component_type() == component_type
3301 && terminal.terminal == sequence
3302 })
3303 .unwrap();
3304 terminal.active_power_mw = Some(active);
3305 terminal.reactive_power_mvar = Some(reactive);
3306 }
3307
3308 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
3309 let sv = output
3310 .files
3311 .iter()
3312 .find(|(name, _)| name.ends_with("_SV.xml"))
3313 .map(|(_, xml)| xml)
3314 .unwrap();
3315 assert_eq!(sv.matches("<cim:SvPowerFlow rdf:ID=").count(), 4);
3316 assert_eq!(sv.matches("<cim:SvVoltage rdf:ID=").count(), 1);
3317
3318 let parsed = read::read_cgmes_documents(output.files, Some("exact-sv")).unwrap();
3319 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
3320 for (component_type, sequence, active, reactive) in expected_flows {
3321 let terminal = detailed
3322 .terminals
3323 .iter()
3324 .find(|terminal| {
3325 terminal.equipment.component_type() == component_type
3326 && terminal.terminal == sequence
3327 })
3328 .unwrap();
3329 assert_eq!(terminal.active_power_mw, Some(active));
3330 assert_eq!(terminal.reactive_power_mvar, Some(reactive));
3331 }
3332 let first = detailed
3333 .bus_breaker_buses
3334 .iter()
3335 .find(|bus| bus.calculated_bus == Some(BusId(1)))
3336 .unwrap();
3337 let second = detailed
3338 .bus_breaker_buses
3339 .iter()
3340 .find(|bus| bus.calculated_bus == Some(BusId(2)))
3341 .unwrap();
3342 assert_eq!(first.voltage_kv, Some(231.25));
3343 assert_eq!(first.angle_degrees, Some(-2.75));
3344 assert_eq!(second.voltage_kv, None);
3345 assert_eq!(second.angle_degrees, None);
3346 }
3347
3348 #[test]
3349 fn partial_sv_power_flow_is_diagnosed_instead_of_silently_dropped() {
3350 let mut network = detailed_network();
3351 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
3352 let load_terminal = detailed
3353 .terminals
3354 .iter_mut()
3355 .find(|terminal| terminal.equipment.component_type() == "load")
3356 .unwrap();
3357 load_terminal.active_power_mw = Some(12.5);
3358 load_terminal.reactive_power_mvar = None;
3359
3360 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
3361 assert!(output.warnings.iter().any(|warning| {
3362 warning.contains("retained active power field 12.5 MW without reactive power")
3363 && warning.contains("CGMES SvPowerFlow requires both p and q")
3364 }));
3365 let sv = output
3366 .files
3367 .iter()
3368 .find(|(name, _)| name.ends_with("_SV.xml"))
3369 .map(|(_, xml)| xml)
3370 .unwrap();
3371 assert!(!sv.contains("<cim:SvPowerFlow rdf:ID="));
3372
3373 let mut network = detailed_network();
3374 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
3375 detailed.bus_breaker_buses[0].voltage_kv = Some(231.0);
3376 detailed.bus_breaker_buses[0].angle_degrees = None;
3377 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
3378 assert!(output.warnings.iter().any(|warning| {
3379 warning.contains("retained voltage field 231 kV without an angle")
3380 && warning.contains("CGMES SvVoltage requires both v and angle")
3381 }));
3382 let sv = output
3383 .files
3384 .iter()
3385 .find(|(name, _)| name.ends_with("_SV.xml"))
3386 .map(|(_, xml)| xml)
3387 .unwrap();
3388 assert!(!sv.contains("<cim:SvVoltage rdf:ID="));
3389 }
3390
3391 #[test]
3392 fn flat_branch_and_switch_solution_values_emit_as_sv_power_flow() {
3393 let mut network = network();
3394 network.branches_mut()[0].solution = Some(BranchSolution {
3395 pf: 18.0,
3396 qf: 3.0,
3397 pt: -17.75,
3398 qt: -2.5,
3399 });
3400 let mut switch = Switch::new(BusId(1), BusId(2), false);
3401 switch.uid = Some("coupler".into());
3402 switch.current_rating = Some(500.0);
3403 switch.thermal_rating = Some(100.0);
3404 switch.pf = Some(5.0);
3405 switch.qf = Some(1.0);
3406 switch.pt = Some(-4.9);
3407 switch.qt = Some(-0.9);
3408 network.switches_mut().push(switch);
3409
3410 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
3411 assert!(output.warnings.iter().any(|warning| {
3412 warning.info.code == crate::diagnostics::codes::EMIT_CGMES.field_dropped.code
3413 && warning.contains("switch `coupler` thermal rating 100 MVA")
3414 && warning.contains("rated current")
3415 }));
3416 let parsed = read::read_cgmes_documents(output.files, Some("flat-solutions")).unwrap();
3417 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
3418 assert_eq!(parsed.network.branches().len(), 1);
3419 assert_eq!(parsed.network.switches().len(), 1);
3420 let branch_id = parsed.network.branches()[0].uid.as_deref().unwrap();
3421 let switch_id = parsed.network.switches()[0].uid.as_deref().unwrap();
3422 for (component_type, local_id, side, active, reactive) in [
3423 ("branch", branch_id, 1, 18.0, 3.0),
3424 ("branch", branch_id, 2, -17.75, -2.5),
3425 ("switch", switch_id, 1, 5.0, 1.0),
3426 ("switch", switch_id, 2, -4.9, -0.9),
3427 ] {
3428 let terminal = detailed
3429 .terminals
3430 .iter()
3431 .find(|terminal| {
3432 terminal.equipment.component_type() == component_type
3433 && terminal.equipment.local_id() == local_id
3434 && terminal.terminal == side
3435 })
3436 .unwrap();
3437 assert_eq!(terminal.active_power_mw, Some(active));
3438 assert_eq!(terminal.reactive_power_mvar, Some(reactive));
3439 }
3440 }
3441
3442 #[test]
3443 fn detailed_switch_uses_balanced_solution_values_when_terminal_values_are_absent() {
3444 let mut network = detailed_network();
3445 let mut switch = Switch::new(BusId(1), BusId(2), false);
3446 switch.uid = Some("breaker-A".into());
3447 switch.current_rating = Some(500.0);
3448 switch.thermal_rating = Some(100.0);
3449 switch.pf = Some(5.0);
3450 switch.qf = Some(1.0);
3451 switch.pt = Some(-4.9);
3452 switch.qt = Some(-0.9);
3453 network.switches_mut().push(switch);
3454
3455 let check = |network: &BalancedNetwork, label: &str| {
3456 let output = write::write_cgmes(network, CgmesVersion::V3_0).unwrap();
3457 assert!(output.warnings.iter().any(|warning| {
3458 warning.info.code == crate::diagnostics::codes::EMIT_CGMES.field_dropped.code
3459 && warning.contains("switch `breaker-A` thermal rating 100 MVA")
3460 }));
3461 let parsed = read::read_cgmes_documents(output.files, Some(label)).unwrap();
3462 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
3463 assert_eq!(parsed.network.switches().len(), 1);
3464 let switch_id = parsed.network.switches()[0].uid.as_deref().unwrap();
3465 for (side, active, reactive) in [(1, 5.0, 1.0), (2, -4.9, -0.9)] {
3466 let terminal = detailed
3467 .terminals
3468 .iter()
3469 .find(|terminal| {
3470 terminal.equipment.component_type() == "switch"
3471 && terminal.equipment.local_id() == switch_id
3472 && terminal.terminal == side
3473 })
3474 .unwrap();
3475 assert_eq!(terminal.active_power_mw, Some(active));
3476 assert_eq!(terminal.reactive_power_mvar, Some(reactive));
3477 }
3478 };
3479
3480 check(&network, "detailed-switch-solution");
3481
3482 let mut without_topology_switch = network;
3483 Arc::make_mut(
3484 without_topology_switch
3485 .detailed_connectivity_mut()
3486 .as_mut()
3487 .unwrap(),
3488 )
3489 .switches
3490 .clear();
3491 check(
3492 &without_topology_switch,
3493 "detailed-terminals-with-balanced-switch",
3494 );
3495 }
3496
3497 #[test]
3498 fn generator_voltage_control_round_trips_with_exact_remote_terminal() {
3499 const TERMINAL_MRID: &str = "de305d54-75b4-431b-adb2-eb6b9e546099";
3500 let mut network = detailed_network();
3501 let regulating_terminal = TerminalReference {
3502 equipment: component("load", network.loads()[0].uid.as_deref().unwrap()),
3503 terminal: 1,
3504 };
3505 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
3506 let terminal_component = component("terminal", "load-terminal");
3507 detailed
3508 .terminals
3509 .iter_mut()
3510 .find(|terminal| {
3511 terminal.equipment == regulating_terminal.equipment && terminal.terminal == 1
3512 })
3513 .unwrap()
3514 .component = Some(terminal_component.clone());
3515 detailed.component_metadata.push(ComponentMetadata {
3516 component: terminal_component,
3517 name: Some("Load terminal".into()),
3518 equipment_container: None,
3519 aliases: Vec::new(),
3520 external_identifiers: vec![ExternalIdentifier {
3521 value: TERMINAL_MRID.into(),
3522 authority: Some("CGMES".into()),
3523 }],
3524 properties: std::collections::BTreeMap::new(),
3525 fictitious: false,
3526 });
3527 let generator = &mut network.generators_mut()[0];
3528 generator.voltage_regulation_on = false;
3529 generator.regulated_bus = Some(BusId(2));
3530 generator.regulating_terminal = Some(regulating_terminal.clone());
3531
3532 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
3533 let eq = output
3534 .files
3535 .iter()
3536 .find(|(name, _)| name.ends_with("_EQ.xml"))
3537 .map(|(_, text)| text)
3538 .unwrap();
3539 let ssh = output
3540 .files
3541 .iter()
3542 .find(|(name, _)| name.ends_with("_SSH.xml"))
3543 .map(|(_, text)| text)
3544 .unwrap();
3545 assert!(eq.contains(&format!("<cim:Terminal rdf:ID=\"_{TERMINAL_MRID}\">")));
3546 assert!(eq.contains(&format!(
3547 "<cim:RegulatingControl.Terminal rdf:resource=\"#_{TERMINAL_MRID}\"/>"
3548 )));
3549 assert!(
3550 ssh.contains("<cim:RegulatingControl.enabled>false</cim:RegulatingControl.enabled>")
3551 );
3552 assert!(
3553 ssh.contains("<cim:RegulatingControl.discrete>false</cim:RegulatingControl.discrete>")
3554 );
3555 assert!(ssh.contains(
3556 "<cim:RegulatingControl.targetValueUnitMultiplier rdf:resource=\"http://iec.ch/TC57/CIM100#UnitMultiplier.k\"/>"
3557 ));
3558 assert!(ssh.contains(
3559 "<cim:RegulatingCondEq.controlEnabled>false</cim:RegulatingCondEq.controlEnabled>"
3560 ));
3561 assert!(ssh.contains(
3562 "<cim:SynchronousMachine.operatingMode rdf:resource=\"http://iec.ch/TC57/CIM100#SynchronousMachineOperatingMode.generator\"/>"
3563 ));
3564
3565 let parsed = read::read_cgmes_documents(output.files, Some("generator-control")).unwrap();
3566 let generator = &parsed.network.generators()[0];
3567 assert!(!generator.voltage_regulation_on);
3568 assert_eq!(generator.regulated_bus, Some(BusId(2)));
3569 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
3570 let terminal = detailed
3571 .terminals
3572 .iter()
3573 .find(|terminal| {
3574 terminal
3575 .component
3576 .as_ref()
3577 .is_some_and(|component| component.local_id() == TERMINAL_MRID)
3578 })
3579 .unwrap();
3580 assert_eq!(terminal.terminal, 1);
3581 assert_eq!(
3582 terminal.equipment.local_id(),
3583 parsed.network.loads()[0].uid.as_deref().unwrap()
3584 );
3585 let component = terminal.component.as_ref().unwrap();
3586 let metadata = detailed
3587 .component_metadata
3588 .iter()
3589 .find(|metadata| metadata.component == *component)
3590 .unwrap();
3591 assert!(metadata.external_identifiers.iter().any(|identifier| {
3592 identifier.value == TERMINAL_MRID && identifier.authority.as_deref() == Some("CGMES")
3593 }));
3594 let regulating_terminal = generator.regulating_terminal.as_ref().unwrap();
3595 assert_eq!(regulating_terminal.terminal, 1);
3596 assert_eq!(
3597 regulating_terminal.equipment.local_id(),
3598 parsed.network.loads()[0].uid.as_deref().unwrap()
3599 );
3600 }
3601
3602 #[test]
3603 fn series_compensator_terminals_survive_fresh_emission() {
3604 let documents = write::write_cgmes(&detailed_network(), CgmesVersion::V3_0)
3605 .unwrap()
3606 .files
3607 .into_iter()
3608 .map(|(name, text)| {
3609 let mut text = text
3610 .replace("ACLineSegment", "SeriesCompensator")
3611 .replace(
3612 " <cim:SeriesCompensator.bch>0</cim:SeriesCompensator.bch>\n",
3613 "",
3614 );
3615 if name.ends_with("_EQ.xml") {
3616 text = text.replace(
3617 "</cim:SeriesCompensator>",
3618 " <cim:SeriesCompensator.r0>0.2</cim:SeriesCompensator.r0>\n\
3619 <cim:SeriesCompensator.x0>0.3</cim:SeriesCompensator.x0>\n\
3620 <cim:SeriesCompensator.varistorPresent>true</cim:SeriesCompensator.varistorPresent>\n\
3621 <cim:SeriesCompensator.varistorRatedCurrent>500</cim:SeriesCompensator.varistorRatedCurrent>\n\
3622 <cim:SeriesCompensator.varistorVoltageThreshold>250</cim:SeriesCompensator.varistorVoltageThreshold>\n\
3623 </cim:SeriesCompensator>",
3624 );
3625 }
3626 (name, text)
3627 })
3628 .collect();
3629
3630 let parsed = read::read_cgmes_documents(documents, Some("series-compensator")).unwrap();
3631 assert!(
3632 parsed
3633 .warnings
3634 .iter()
3635 .all(|warning| !warning.contains("SeriesCompensator")),
3636 "SeriesCompensator is mapped electrical equipment"
3637 );
3638 let branch_terminal_nodes = |network: &BalancedNetwork| {
3639 let branch = network.branches().first().unwrap();
3640 let branch = component("branch", branch.uid.as_deref().unwrap());
3641 let detailed = network.detailed_connectivity().as_deref().unwrap();
3642 let terminals = detailed
3643 .terminals
3644 .iter()
3645 .filter(|terminal| terminal.equipment == branch)
3646 .collect::<Vec<_>>();
3647 assert_eq!(terminals.len(), 2);
3648 [
3649 terminals[0].node.clone().unwrap(),
3650 terminals[1].node.clone().unwrap(),
3651 ]
3652 };
3653 let source_nodes = branch_terminal_nodes(&parsed.network);
3654 assert_ne!(source_nodes[0], source_nodes[1]);
3655
3656 let fresh = write::write_cgmes(&parsed.network, CgmesVersion::V3_0).unwrap();
3657 assert!(
3658 fresh
3659 .warnings
3660 .iter()
3661 .all(|warning| !warning.contains("SeriesCompensator")),
3662 "a retained SeriesCompensator should emit without projection"
3663 );
3664 let eq = fresh
3665 .files
3666 .iter()
3667 .find(|(name, _)| name.ends_with("_EQ.xml"))
3668 .map(|(_, text)| text)
3669 .unwrap();
3670 assert!(eq.contains("<cim:SeriesCompensator "));
3671 assert!(!eq.contains("<cim:ACLineSegment "));
3672 for field in [
3673 "SeriesCompensator.r0",
3674 "SeriesCompensator.x0",
3675 "SeriesCompensator.varistorPresent",
3676 "SeriesCompensator.varistorRatedCurrent",
3677 "SeriesCompensator.varistorVoltageThreshold",
3678 ] {
3679 assert!(eq.contains(&format!("<cim:{field}>")), "missing `{field}`");
3680 }
3681 let reparsed = read::read_cgmes_documents(fresh.files, Some("fresh")).unwrap();
3682 assert_eq!(branch_terminal_nodes(&reparsed.network), source_nodes);
3683 }
3684
3685 #[test]
3686 #[allow(clippy::too_many_lines)] fn physical_dc_equipment_round_trips_through_official_cgmes_classes() {
3688 const DC_LINE_CONTAINER_MRID: &str = "44444444-4444-4444-8444-444444444444";
3689 const DC_BUSBAR_MRID: &str = "55555555-5555-4555-8555-555555555555";
3690 const DC_SERIES_DEVICE_MRID: &str = "66666666-6666-4666-8666-666666666666";
3691
3692 let mut network = detailed_network();
3693 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
3694 let converter_unit = component("dc_converter_unit", "dc-unit-1");
3695 let dc_line_container = component("dc_line_container", "dc-line-container-1");
3696 let dc_busbar = component("dc_busbar", "dc-busbar-1");
3697 let dc_series_device = component("dc_series_device", "dc-series-device-1");
3698 let first_dc_node = component("dc_node", "dc-node-1");
3699 let second_dc_node = component("dc_node", "dc-node-2");
3700 let third_dc_node = component("dc_node", "dc-node-3");
3701 let first_dc_topological_node = component("dc_topological_node", "dc-tn-1");
3702 let second_dc_topological_node = component("dc_topological_node", "dc-tn-2");
3703 let third_dc_topological_node = component("dc_topological_node", "dc-tn-3");
3704 for (component, mrid, name) in [
3705 (
3706 dc_line_container.clone(),
3707 DC_LINE_CONTAINER_MRID,
3708 "Retained DC line container",
3709 ),
3710 (dc_busbar.clone(), DC_BUSBAR_MRID, "North DC busbar"),
3711 (
3712 dc_series_device.clone(),
3713 DC_SERIES_DEVICE_MRID,
3714 "North DC series device",
3715 ),
3716 ] {
3717 detailed.component_metadata.push(ComponentMetadata {
3718 component,
3719 name: Some(name.into()),
3720 equipment_container: None,
3721 aliases: Vec::new(),
3722 external_identifiers: vec![ExternalIdentifier {
3723 value: mrid.into(),
3724 authority: Some("CGMES".into()),
3725 }],
3726 properties: std::collections::BTreeMap::new(),
3727 fictitious: false,
3728 });
3729 }
3730 detailed.dc_converter_units.push(DcConverterUnit {
3731 component: converter_unit.clone(),
3732 substation: Some(component("substation", "sub-A")),
3733 operation_mode: DcConverterOperatingMode::Bipolar,
3734 });
3735 detailed.dc_topological_nodes = vec![
3736 DcTopologicalNode {
3737 component: first_dc_topological_node.clone(),
3738 dc_converter_unit: Some(converter_unit.clone()),
3739 },
3740 DcTopologicalNode {
3741 component: second_dc_topological_node.clone(),
3742 dc_converter_unit: Some(converter_unit.clone()),
3743 },
3744 DcTopologicalNode {
3745 component: third_dc_topological_node.clone(),
3746 dc_converter_unit: Some(converter_unit.clone()),
3747 },
3748 ];
3749 detailed.dc_nodes = vec![
3750 DcNode {
3751 component: first_dc_node.clone(),
3752 nominal_voltage_kv: Some(320.0),
3753 dc_converter_unit: Some(converter_unit.clone()),
3754 dc_topological_node: Some(first_dc_topological_node.clone()),
3755 voltage_kv: Some(320.0),
3756 },
3757 DcNode {
3758 component: second_dc_node.clone(),
3759 nominal_voltage_kv: Some(320.0),
3760 dc_converter_unit: Some(converter_unit.clone()),
3761 dc_topological_node: Some(second_dc_topological_node.clone()),
3762 voltage_kv: Some(0.0),
3763 },
3764 DcNode {
3765 component: third_dc_node.clone(),
3766 nominal_voltage_kv: Some(320.0),
3767 dc_converter_unit: Some(converter_unit.clone()),
3768 dc_topological_node: Some(third_dc_topological_node.clone()),
3769 voltage_kv: Some(-320.0),
3770 },
3771 ];
3772 let dc_terminal = |id: &str,
3773 sequence_number: u32,
3774 node: &ComponentId,
3775 topological_node: &ComponentId,
3776 polarity: Option<DcPolarity>,
3777 connected: bool,
3778 current_a: Option<f64>| DcTerminal {
3779 component: Some(component("dc_terminal", id)),
3780 sequence_number: Some(sequence_number),
3781 dc_node: Some(node.clone()),
3782 dc_topological_node: Some(topological_node.clone()),
3783 polarity,
3784 connected: Some(connected),
3785 active_power_mw: None,
3786 current_a,
3787 };
3788 detailed.dc_grounds.push(DcGround {
3789 component: component("dc_ground", "dc-ground-1"),
3790 equipment_container: Some(converter_unit.clone()),
3791 dc_terminal: dc_terminal(
3792 "ground-t1",
3793 1,
3794 &first_dc_node,
3795 &first_dc_topological_node,
3796 None,
3797 true,
3798 None,
3799 ),
3800 rated_dc_voltage_kv: Some(320.0),
3801 resistance_ohm: Some(0.8),
3802 inductance_h: Some(0.01),
3803 });
3804 detailed.dc_lines.push(DcLine {
3805 component: component("dc_line", "dc-line-1"),
3806 equipment_container: Some(dc_line_container.clone()),
3807 dc_terminal1: dc_terminal(
3808 "line-t1",
3809 1,
3810 &first_dc_node,
3811 &first_dc_topological_node,
3812 None,
3813 true,
3814 None,
3815 ),
3816 dc_terminal2: dc_terminal(
3817 "line-t2",
3818 2,
3819 &second_dc_node,
3820 &second_dc_topological_node,
3821 None,
3822 true,
3823 None,
3824 ),
3825 rated_dc_voltage_kv: Some(320.0),
3826 resistance_ohm: Some(1.25),
3827 inductance_h: Some(0.02),
3828 capacitance_f: Some(3.0e-6),
3829 length_km: Some(100.0),
3830 });
3831 detailed.dc_busbars.push(DcBusbar {
3832 component: dc_busbar,
3833 equipment_container: Some(converter_unit.clone()),
3834 dc_terminal: dc_terminal(
3835 "busbar-t1",
3836 1,
3837 &first_dc_node,
3838 &first_dc_topological_node,
3839 None,
3840 true,
3841 None,
3842 ),
3843 rated_dc_voltage_kv: Some(320.0),
3844 });
3845 detailed.dc_series_devices.push(DcSeriesDevice {
3846 component: dc_series_device,
3847 equipment_container: Some(converter_unit.clone()),
3848 dc_terminal1: dc_terminal(
3849 "series-t1",
3850 1,
3851 &second_dc_node,
3852 &second_dc_topological_node,
3853 None,
3854 true,
3855 None,
3856 ),
3857 dc_terminal2: dc_terminal(
3858 "series-t2",
3859 2,
3860 &third_dc_node,
3861 &third_dc_topological_node,
3862 None,
3863 true,
3864 None,
3865 ),
3866 rated_dc_voltage_kv: Some(315.0),
3867 resistance_ohm: Some(0.75),
3868 inductance_h: Some(0.015),
3869 });
3870 detailed.dc_switches.push(DcSwitch {
3871 component: component("dc_switch", "dc-breaker-1"),
3872 equipment_container: Some(converter_unit.clone()),
3873 dc_terminal1: dc_terminal(
3874 "switch-t1",
3875 1,
3876 &second_dc_node,
3877 &second_dc_topological_node,
3878 None,
3879 true,
3880 None,
3881 ),
3882 dc_terminal2: dc_terminal(
3883 "switch-t2",
3884 2,
3885 &third_dc_node,
3886 &third_dc_topological_node,
3887 None,
3888 true,
3889 None,
3890 ),
3891 kind: DcSwitchKind::Breaker,
3892 rated_dc_voltage_kv: Some(320.0),
3893 open: Some(true),
3894 resistance_ohm: None,
3895 });
3896
3897 let voltage_source = component("voltage_source_converter", "vsc-1");
3898 let line_commutated = component("line_commutated_converter", "lcc-1");
3899 let voltage_level = component("voltage_level", "vl-A");
3900 let first_bus = component("bus", "tn-A");
3901 let second_bus = component("bus", "tn-B");
3902 let first_node = component("connectivity_node", "node-A");
3903 let second_node = component("connectivity_node", "node-B");
3904 detailed.terminals.extend([
3905 Terminal {
3906 component: None,
3907 equipment: voltage_source.clone(),
3908 terminal: 1,
3909 voltage_level: voltage_level.clone(),
3910 bus: Some(first_bus.clone()),
3911 connectable_bus: Some(first_bus),
3912 node: Some(first_node),
3913 connected: false,
3914 active_power_mw: Some(150.0),
3915 reactive_power_mvar: Some(20.0),
3916 },
3917 Terminal {
3918 component: None,
3919 equipment: line_commutated.clone(),
3920 terminal: 1,
3921 voltage_level,
3922 bus: Some(second_bus.clone()),
3923 connectable_bus: Some(second_bus),
3924 node: Some(second_node),
3925 connected: true,
3926 active_power_mw: Some(-145.0),
3927 reactive_power_mvar: Some(35.0),
3928 },
3929 ]);
3930 detailed
3931 .voltage_source_converters
3932 .push(VoltageSourceConverter {
3933 component: voltage_source.clone(),
3934 dc_converter_unit: Some(converter_unit.clone()),
3935 dc_terminal1: dc_terminal(
3936 "vsc-t1",
3937 1,
3938 &first_dc_node,
3939 &first_dc_topological_node,
3940 Some(DcPolarity::Positive),
3941 true,
3942 Some(470.0),
3943 ),
3944 dc_terminal2: dc_terminal(
3945 "vsc-t2",
3946 2,
3947 &second_dc_node,
3948 &second_dc_topological_node,
3949 Some(DcPolarity::Negative),
3950 true,
3951 Some(-470.0),
3952 ),
3953 base_apparent_power_mva: Some(200.0),
3954 minimum_active_power_mw: Some(-200.0),
3955 maximum_active_power_mw: Some(200.0),
3956 minimum_dc_voltage_kv: Some(280.0),
3957 maximum_dc_voltage_kv: Some(350.0),
3958 rated_dc_voltage_kv: Some(320.0),
3959 valve_u0_kv: Some(0.5),
3960 number_of_valves: Some(6),
3961 idle_loss_mw: Some(0.5),
3962 switching_loss_mw_per_ampere: Some(0.001),
3963 resistive_loss_ohm: Some(0.05),
3964 control_mode: Some(AcDcConverterControlMode::DcVoltage),
3965 target_active_power_mw: Some(150.0),
3966 target_dc_voltage_kv: Some(320.0),
3967 active_power_at_pcc_mw: Some(150.0),
3968 reactive_power_at_pcc_mvar: Some(20.0),
3969 pcc_terminal: Some(TerminalReference {
3970 equipment: voltage_source,
3971 terminal: 1,
3972 }),
3973 droop_curve: None,
3974 droop: None,
3975 droop_compensation: None,
3976 q_share: None,
3977 maximum_modulation_index: Some(1.1),
3978 maximum_valve_current_a: Some(1_000.0),
3979 voltage_regulator_on: Some(false),
3980 voltage_setpoint_kv: Some(230.0),
3981 reactive_power_setpoint_mvar: Some(20.0),
3982 reactive_limits: Some(ReactiveLimits::CapabilityCurve(ReactiveCapabilityCurve {
3983 curve_style: CurveStyle::StraightLineYValues,
3984 properties: std::collections::BTreeMap::default(),
3985 points: vec![ReactiveCapabilityCurvePoint {
3986 active_power_mw: 150.0,
3987 minimum_reactive_power_mvar: -80.0,
3988 maximum_reactive_power_mvar: 90.0,
3989 properties: std::collections::BTreeMap::default(),
3990 }],
3991 })),
3992 pole_loss_active_power_mw: Some(0.981),
3993 dc_current_a: Some(470.0),
3994 ac_voltage_kv: Some(230.0),
3995 dc_voltage_kv: Some(320.0),
3996 delta_degrees: Some(2.0),
3997 uf_kv: None,
3998 uv_kv: Some(231.0),
3999 });
4000 detailed
4001 .line_commutated_converters
4002 .push(LineCommutatedConverter {
4003 component: line_commutated.clone(),
4004 dc_converter_unit: Some(converter_unit),
4005 dc_terminal1: dc_terminal(
4006 "lcc-t1",
4007 1,
4008 &second_dc_node,
4009 &second_dc_topological_node,
4010 Some(DcPolarity::Positive),
4011 true,
4012 Some(450.0),
4013 ),
4014 dc_terminal2: dc_terminal(
4015 "lcc-t2",
4016 2,
4017 &third_dc_node,
4018 &third_dc_topological_node,
4019 Some(DcPolarity::Negative),
4020 true,
4021 Some(-450.0),
4022 ),
4023 base_apparent_power_mva: Some(200.0),
4024 minimum_active_power_mw: Some(-200.0),
4025 maximum_active_power_mw: Some(200.0),
4026 minimum_dc_voltage_kv: Some(280.0),
4027 maximum_dc_voltage_kv: Some(350.0),
4028 rated_dc_voltage_kv: Some(320.0),
4029 valve_u0_kv: Some(0.6),
4030 number_of_valves: Some(12),
4031 idle_loss_mw: Some(0.6),
4032 switching_loss_mw_per_ampere: Some(0.002),
4033 resistive_loss_ohm: Some(0.06),
4034 control_mode: Some(AcDcConverterControlMode::ActivePowerAtPcc),
4035 target_active_power_mw: Some(-145.0),
4036 target_dc_voltage_kv: Some(320.0),
4037 active_power_at_pcc_mw: Some(-145.0),
4038 reactive_power_at_pcc_mvar: Some(35.0),
4039 pcc_terminal: Some(TerminalReference {
4040 equipment: line_commutated,
4041 terminal: 1,
4042 }),
4043 droop_curve: None,
4044 reactive_model: Some(LineCommutatedConverterReactiveModel::CalculatedPowerFactor),
4045 power_factor: Some(0.972),
4046 operating_mode: Some(LineCommutatedConverterOperatingMode::Inverter),
4047 rated_dc_current_a: Some(1_000.0),
4048 minimum_alpha_degrees: Some(5.0),
4049 maximum_alpha_degrees: Some(25.0),
4050 minimum_gamma_degrees: Some(10.0),
4051 maximum_gamma_degrees: Some(30.0),
4052 target_alpha_degrees: Some(12.0),
4053 target_gamma_degrees: Some(18.0),
4054 target_dc_current_a: Some(450.0),
4055 pole_loss_active_power_mw: Some(1.512),
4056 dc_current_a: Some(450.0),
4057 ac_voltage_kv: Some(230.0),
4058 dc_voltage_kv: Some(320.0),
4059 alpha_degrees: Some(12.5),
4060 gamma_degrees: Some(18.5),
4061 });
4062
4063 let mut assignment_only = network.clone();
4064 let assignment_detailed = Arc::make_mut(
4065 assignment_only
4066 .detailed_connectivity_mut()
4067 .as_mut()
4068 .unwrap(),
4069 );
4070 for terminal in assignment_detailed.terminals.iter_mut().filter(|terminal| {
4071 matches!(
4072 terminal.equipment.component_type(),
4073 "voltage_source_converter" | "line_commutated_converter"
4074 )
4075 }) {
4076 terminal.active_power_mw = None;
4077 terminal.reactive_power_mvar = None;
4078 }
4079 let assignment_output = write::write_cgmes(&assignment_only, CgmesVersion::V3_0).unwrap();
4080 let assignment_parsed =
4081 read::read_cgmes_documents(assignment_output.files, Some("converter-assignment"))
4082 .unwrap();
4083 let assignment_detailed = assignment_parsed
4084 .network
4085 .detailed_connectivity()
4086 .as_deref()
4087 .unwrap();
4088 assert_eq!(
4089 assignment_detailed.voltage_source_converters[0].active_power_at_pcc_mw,
4090 Some(150.0)
4091 );
4092 assert_eq!(
4093 assignment_detailed.voltage_source_converters[0].reactive_power_at_pcc_mvar,
4094 Some(20.0)
4095 );
4096 assert!(
4097 assignment_detailed
4098 .terminals
4099 .iter()
4100 .filter(|terminal| {
4101 matches!(
4102 terminal.equipment.component_type(),
4103 "voltage_source_converter" | "line_commutated_converter"
4104 )
4105 })
4106 .all(|terminal| terminal.active_power_mw.is_none()
4107 && terminal.reactive_power_mvar.is_none())
4108 );
4109
4110 let mut generic_converter_limits = network.clone();
4111 let generic_detailed = Arc::make_mut(
4112 generic_converter_limits
4113 .detailed_connectivity_mut()
4114 .as_mut()
4115 .unwrap(),
4116 );
4117 let converter_component = generic_detailed.voltage_source_converters[0]
4118 .component
4119 .clone();
4120 let limits = generic_detailed.voltage_source_converters[0]
4121 .reactive_limits
4122 .take()
4123 .unwrap();
4124 generic_detailed
4125 .equipment_reactive_limits
4126 .push(EquipmentReactiveLimits {
4127 equipment: converter_component,
4128 limits,
4129 });
4130 let generic_output =
4131 write::write_cgmes(&generic_converter_limits, CgmesVersion::V3_0).unwrap();
4132 assert!(
4133 generic_output
4134 .files
4135 .iter()
4136 .any(|(_, xml)| xml.contains("<cim:VsCapabilityCurve "))
4137 );
4138
4139 let mut conflicting_converter_limits = network.clone();
4140 let conflicting_detailed = Arc::make_mut(
4141 conflicting_converter_limits
4142 .detailed_connectivity_mut()
4143 .as_mut()
4144 .unwrap(),
4145 );
4146 let converter_component = conflicting_detailed.voltage_source_converters[0]
4147 .component
4148 .clone();
4149 conflicting_detailed
4150 .equipment_reactive_limits
4151 .push(EquipmentReactiveLimits {
4152 equipment: converter_component,
4153 limits: ReactiveLimits::MinMax(MinMaxReactiveLimits {
4154 minimum_reactive_power_mvar: -1.0,
4155 maximum_reactive_power_mvar: 1.0,
4156 properties: std::collections::BTreeMap::new(),
4157 }),
4158 });
4159 let error =
4160 write::write_cgmes(&conflicting_converter_limits, CgmesVersion::V3_0).unwrap_err();
4161 assert!(
4162 error
4163 .to_string()
4164 .contains("has conflicting direct and equipment reactive limits records")
4165 );
4166
4167 let mut missing_line_value = network.clone();
4168 Arc::make_mut(
4169 missing_line_value
4170 .detailed_connectivity_mut()
4171 .as_mut()
4172 .unwrap(),
4173 )
4174 .dc_lines[0]
4175 .capacitance_f = None;
4176 let error = write::write_cgmes(&missing_line_value, CgmesVersion::V3_0).unwrap_err();
4177 assert!(error.to_string().contains("DCLineSegment.capacitance"));
4178
4179 let mut missing_line_container = network.clone();
4180 Arc::make_mut(
4181 missing_line_container
4182 .detailed_connectivity_mut()
4183 .as_mut()
4184 .unwrap(),
4185 )
4186 .dc_lines[0]
4187 .equipment_container = None;
4188 let error = write::write_cgmes(&missing_line_container, CgmesVersion::V3_0).unwrap_err();
4189 assert!(error.to_string().contains("dc-line-1"));
4190 assert!(error.to_string().contains("Equipment.EquipmentContainer"));
4191
4192 let mut missing_busbar_voltage = network.clone();
4193 Arc::make_mut(
4194 missing_busbar_voltage
4195 .detailed_connectivity_mut()
4196 .as_mut()
4197 .unwrap(),
4198 )
4199 .dc_busbars[0]
4200 .rated_dc_voltage_kv = None;
4201 let error = write::write_cgmes(&missing_busbar_voltage, CgmesVersion::V3_0).unwrap_err();
4202 assert!(error.to_string().contains("DCConductingEquipment.ratedUdc"));
4203 assert!(error.to_string().contains("dc-busbar-1"));
4204
4205 let mut missing_series_resistance = network.clone();
4206 Arc::make_mut(
4207 missing_series_resistance
4208 .detailed_connectivity_mut()
4209 .as_mut()
4210 .unwrap(),
4211 )
4212 .dc_series_devices[0]
4213 .resistance_ohm = None;
4214 let error = write::write_cgmes(&missing_series_resistance, CgmesVersion::V3_0).unwrap_err();
4215 assert!(error.to_string().contains("DCSeriesDevice.resistance"));
4216
4217 let mut missing_series_container = network.clone();
4218 Arc::make_mut(
4219 missing_series_container
4220 .detailed_connectivity_mut()
4221 .as_mut()
4222 .unwrap(),
4223 )
4224 .dc_series_devices[0]
4225 .equipment_container = None;
4226 let error = write::write_cgmes(&missing_series_container, CgmesVersion::V3_0).unwrap_err();
4227 assert!(error.to_string().contains("Equipment.EquipmentContainer"));
4228
4229 let mut version_2_network = network.clone();
4230 let version_2_detailed = Arc::make_mut(
4231 version_2_network
4232 .detailed_connectivity_mut()
4233 .as_mut()
4234 .unwrap(),
4235 );
4236 version_2_detailed.voltage_source_converters.clear();
4237 version_2_detailed.line_commutated_converters.clear();
4238 let version_2 = write::write_cgmes(&version_2_network, CgmesVersion::V2_4_15).unwrap();
4239 let version_2_xml = version_2
4240 .files
4241 .iter()
4242 .map(|(_, xml)| xml.as_str())
4243 .collect::<String>();
4244 assert!(version_2_xml.contains("<cim:DCSeriesDevice.ratedUdc>315"));
4245 assert!(!version_2_xml.contains("<cim:DCConductingEquipment.ratedUdc>315"));
4246 let mut missing_version_2_series_voltage = version_2_network;
4247 Arc::make_mut(
4248 missing_version_2_series_voltage
4249 .detailed_connectivity_mut()
4250 .as_mut()
4251 .unwrap(),
4252 )
4253 .dc_series_devices[0]
4254 .rated_dc_voltage_kv = None;
4255 let error = write::write_cgmes(&missing_version_2_series_voltage, CgmesVersion::V2_4_15)
4256 .unwrap_err();
4257 assert!(error.to_string().contains("DCSeriesDevice.ratedUdc"));
4258
4259 let mut missing_converter_voltage = network.clone();
4260 Arc::make_mut(
4261 missing_converter_voltage
4262 .detailed_connectivity_mut()
4263 .as_mut()
4264 .unwrap(),
4265 )
4266 .voltage_source_converters[0]
4267 .rated_dc_voltage_kv = None;
4268 let error = write::write_cgmes(&missing_converter_voltage, CgmesVersion::V3_0).unwrap_err();
4269 assert!(error.to_string().contains("ACDCConverter.ratedUdc"));
4270
4271 let mut incomplete_sv = network.clone();
4272 Arc::make_mut(incomplete_sv.detailed_connectivity_mut().as_mut().unwrap())
4273 .voltage_source_converters[0]
4274 .uv_kv = None;
4275 let incomplete_output = write::write_cgmes(&incomplete_sv, CgmesVersion::V3_0).unwrap();
4276 assert!(
4277 incomplete_output
4278 .warnings
4279 .iter()
4280 .any(|warning| warning.contains("VsConverter.uv"))
4281 );
4282 let incomplete_sv_xml = incomplete_output
4283 .files
4284 .iter()
4285 .find(|(name, _)| name.ends_with("_SV.xml"))
4286 .map(|(_, xml)| xml)
4287 .unwrap();
4288 assert!(!incomplete_sv_xml.contains("<cim:VsConverter"));
4289
4290 let mut droop_control = network.clone();
4291 let vsc = &mut Arc::make_mut(droop_control.detailed_connectivity_mut().as_mut().unwrap())
4292 .voltage_source_converters[0];
4293 vsc.control_mode = Some(AcDcConverterControlMode::ActivePowerAtPccAndDcVoltageDroop);
4294 vsc.droop = Some(0.125);
4295 vsc.droop_compensation = Some(0.25);
4296 let droop_output = write::write_cgmes(&droop_control, CgmesVersion::V3_0).unwrap();
4297 let droop_xml = droop_output
4298 .files
4299 .iter()
4300 .find(|(name, _)| name.ends_with("_SSH.xml"))
4301 .map(|(_, xml)| xml)
4302 .unwrap();
4303 assert!(droop_xml.contains("VsPpccControlKind.pPccAndUdcDroop"));
4304 assert!(!droop_xml.contains("VsPpccControlKind.pPccAndUdcDroopWithCompensation"));
4305 assert!(droop_xml.contains("<cim:VsConverter.droop>0.125"));
4306
4307 let mut compensated_control = network.clone();
4308 let vsc = &mut Arc::make_mut(
4309 compensated_control
4310 .detailed_connectivity_mut()
4311 .as_mut()
4312 .unwrap(),
4313 )
4314 .voltage_source_converters[0];
4315 vsc.control_mode =
4316 Some(AcDcConverterControlMode::ActivePowerAtPccAndDcVoltageDroopWithCompensation);
4317 vsc.droop = Some(0.125);
4318 vsc.droop_compensation = Some(0.25);
4319 let compensated = write::write_cgmes(&compensated_control, CgmesVersion::V3_0).unwrap();
4320 let compensated_xml = compensated
4321 .files
4322 .iter()
4323 .find(|(name, _)| name.ends_with("_SSH.xml"))
4324 .map(|(_, xml)| xml)
4325 .unwrap();
4326 assert!(compensated_xml.contains("VsPpccControlKind.pPccAndUdcDroopWithCompensation"));
4327 assert!(compensated_xml.contains("<cim:VsConverter.droopCompensation>0.25"));
4328
4329 let mut pilot_control = network.clone();
4330 let vsc = &mut Arc::make_mut(pilot_control.detailed_connectivity_mut().as_mut().unwrap())
4331 .voltage_source_converters[0];
4332 vsc.control_mode = Some(AcDcConverterControlMode::ActivePowerAtPccAndDcVoltageDroopPilot);
4333 vsc.droop = Some(0.125);
4334 let pilot = write::write_cgmes(&pilot_control, CgmesVersion::V3_0).unwrap();
4335 let pilot_xml = pilot
4336 .files
4337 .iter()
4338 .find(|(name, _)| name.ends_with("_SSH.xml"))
4339 .map(|(_, xml)| xml)
4340 .unwrap();
4341 assert!(pilot_xml.contains("VsPpccControlKind.pPccAndUdcDroopPilot"));
4342
4343 let mut missing_compensation = compensated_control.clone();
4344 Arc::make_mut(
4345 missing_compensation
4346 .detailed_connectivity_mut()
4347 .as_mut()
4348 .unwrap(),
4349 )
4350 .voltage_source_converters[0]
4351 .droop_compensation = None;
4352 let error = write::write_cgmes(&missing_compensation, CgmesVersion::V3_0).unwrap_err();
4353 assert!(error.to_string().contains("VsConverter.droopCompensation"));
4354
4355 let mut xiidm_curve_control = network.clone();
4356 Arc::make_mut(
4357 xiidm_curve_control
4358 .detailed_connectivity_mut()
4359 .as_mut()
4360 .unwrap(),
4361 )
4362 .voltage_source_converters[0]
4363 .control_mode = Some(AcDcConverterControlMode::ActivePowerAtPccAndDcVoltageDroopCurve);
4364 let error = write::write_cgmes(&xiidm_curve_control, CgmesVersion::V3_0).unwrap_err();
4365 assert!(error.to_string().contains("XIIDM piecewise droop curve"));
4366 assert!(error.to_string().contains("no CGMES VsPpccControlKind"));
4367
4368 let mut constant_network = network.clone();
4369 let constant_vsc = &mut Arc::make_mut(
4370 constant_network
4371 .detailed_connectivity_mut()
4372 .as_mut()
4373 .unwrap(),
4374 )
4375 .voltage_source_converters[0];
4376 let Some(ReactiveLimits::CapabilityCurve(constant_curve)) =
4377 constant_vsc.reactive_limits.as_mut()
4378 else {
4379 panic!("expected reactive capability curve");
4380 };
4381 constant_curve.curve_style = CurveStyle::ConstantYValue;
4382 let constant_output = write::write_cgmes(&constant_network, CgmesVersion::V3_0).unwrap();
4383 let constant_xml = constant_output
4384 .files
4385 .iter()
4386 .map(|(_, xml)| xml.as_str())
4387 .collect::<String>();
4388 assert!(constant_xml.contains("CurveStyle.constantYValue"));
4389 assert!(!constant_xml.contains("CurveStyle.straightLineYValues"));
4390 let constant_parsed =
4391 read::read_cgmes_documents(constant_output.files, Some("constant-curve")).unwrap();
4392 let constant_vsc = &constant_parsed
4393 .network
4394 .detailed_connectivity()
4395 .as_deref()
4396 .unwrap()
4397 .voltage_source_converters[0];
4398 let Some(ReactiveLimits::CapabilityCurve(constant_curve)) = &constant_vsc.reactive_limits
4399 else {
4400 panic!("expected reactive capability curve");
4401 };
4402 assert_eq!(constant_curve.curve_style, CurveStyle::ConstantYValue);
4403
4404 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
4405 let all_xml = output
4406 .files
4407 .iter()
4408 .map(|(_, xml)| xml.as_str())
4409 .collect::<String>();
4410 for class in [
4411 "DCConverterUnit",
4412 "DCNode",
4413 "DCTopologicalNode",
4414 "DCGround",
4415 "DCBusbar",
4416 "DCLine",
4417 "DCLineSegment",
4418 "DCSeriesDevice",
4419 "DCBreaker",
4420 "VsConverter",
4421 "CsConverter",
4422 "DCTerminal",
4423 "ACDCConverterDCTerminal",
4424 ] {
4425 assert!(
4426 all_xml.contains(&format!("<cim:{class}")),
4427 "missing {class}"
4428 );
4429 }
4430 assert!(all_xml.contains("DCConverterOperatingModeKind.bipolar"));
4431 assert!(!all_xml.contains("PowerIO DC converter unit"));
4432 assert!(all_xml.contains("DCPolarityKind.positive"));
4433 assert!(all_xml.contains("DCPolarityKind.negative"));
4434 assert!(all_xml.contains("<cim:DCLineSegment.inductance>0.02"));
4435 assert!(all_xml.contains("<cim:DCLineSegment.capacitance>0.000003"));
4436 assert!(all_xml.contains(&format!("<cim:DCLine rdf:ID=\"_{DC_LINE_CONTAINER_MRID}\"")));
4437 assert!(all_xml.contains(&format!("<cim:DCBusbar rdf:ID=\"_{DC_BUSBAR_MRID}\"")));
4438 assert!(all_xml.contains(&format!(
4439 "<cim:DCSeriesDevice rdf:ID=\"_{DC_SERIES_DEVICE_MRID}\""
4440 )));
4441 assert!(all_xml.contains(
4442 "<cim:IdentifiedObject.name>Retained DC line container</cim:IdentifiedObject.name>"
4443 ));
4444 assert!(
4445 all_xml
4446 .contains("<cim:IdentifiedObject.name>North DC busbar</cim:IdentifiedObject.name>")
4447 );
4448 assert!(all_xml.contains(
4449 "<cim:IdentifiedObject.name>North DC series device</cim:IdentifiedObject.name>"
4450 ));
4451 assert!(all_xml.contains(&format!(
4452 "<cim:Equipment.EquipmentContainer rdf:resource=\"#_{DC_LINE_CONTAINER_MRID}\""
4453 )));
4454 assert!(
4455 all_xml.find("<cim:DCLine rdf:ID").unwrap()
4456 < all_xml.find("<cim:DCLineSegment rdf:ID").unwrap()
4457 );
4458 assert!(all_xml.contains("<cim:DCConductingEquipment.ratedUdc>315"));
4459 assert!(all_xml.contains("<cim:DCSeriesDevice.resistance>0.75"));
4460 assert!(all_xml.contains("<cim:DCSeriesDevice.inductance>0.015"));
4461 assert!(all_xml.contains("<cim:Switch.open>true</cim:Switch.open>"));
4462 assert!(all_xml.contains("<cim:CsConverter.targetAlpha>12"));
4463 assert!(all_xml.contains("<cim:CsConverter.targetGamma>18"));
4464 assert!(all_xml.contains("<cim:CsConverter.targetIdc>450"));
4465 assert_eq!(
4466 all_xml
4467 .matches("<cim:ACDCTerminal.connected>false</cim:ACDCTerminal.connected>")
4468 .count(),
4469 1
4470 );
4471 assert!(!all_xml.contains("<cim:VsConverter.droop>0</cim:VsConverter.droop>"));
4472 assert!(!all_xml.contains("<cim:ACDCConverter.ratedUdc>1</"));
4473 assert!(all_xml.contains("CurveStyle.straightLineYValues"));
4474 assert!(all_xml.contains(
4475 "<cim:Curve.xUnit rdf:resource=\"http://iec.ch/TC57/CIM100#UnitSymbol.W\"/>"
4476 ));
4477 assert!(all_xml.contains(
4478 "<cim:Curve.y1Unit rdf:resource=\"http://iec.ch/TC57/CIM100#UnitSymbol.VAr\"/>"
4479 ));
4480 assert!(all_xml.contains(
4481 "<cim:Curve.y2Unit rdf:resource=\"http://iec.ch/TC57/CIM100#UnitSymbol.VAr\"/>"
4482 ));
4483 assert_eq!(all_xml.matches("<cim:SvPowerFlow rdf:ID=").count(), 2);
4484 assert!(all_xml.contains("<cim:SvPowerFlow.p>150</cim:SvPowerFlow.p>"));
4485 assert!(all_xml.contains("<cim:SvPowerFlow.q>20</cim:SvPowerFlow.q>"));
4486 assert!(all_xml.contains("<cim:SvPowerFlow.p>-145</cim:SvPowerFlow.p>"));
4487 assert!(all_xml.contains("<cim:SvPowerFlow.q>35</cim:SvPowerFlow.q>"));
4488
4489 let mut unknown_style_files = output.files.clone();
4490 let eq = &mut unknown_style_files
4491 .iter_mut()
4492 .find(|(name, _)| name.ends_with("_EQ.xml"))
4493 .unwrap()
4494 .1;
4495 *eq = eq.replacen(
4496 "CurveStyle.straightLineYValues",
4497 "CurveStyle.unsupported",
4498 1,
4499 );
4500 let unknown_style =
4501 read::read_cgmes_documents(unknown_style_files, Some("unknown-curve-style")).unwrap();
4502 let unknown_style_vsc = &unknown_style
4503 .network
4504 .detailed_connectivity()
4505 .as_deref()
4506 .unwrap()
4507 .voltage_source_converters[0];
4508 assert!(unknown_style_vsc.reactive_limits.is_none());
4509 assert!(unknown_style.warnings.iter().any(|warning| {
4510 warning.contains("Curve.curveStyle `unsupported` is unknown")
4511 && warning.contains("reactive limits were not assigned")
4512 }));
4513
4514 for (property, expected, unsupported) in [
4515 ("Curve.xUnit", "W", "V"),
4516 ("Curve.y1Unit", "VAr", "A"),
4517 ("Curve.y2Unit", "VAr", "A"),
4518 ] {
4519 let mut unsupported_unit_files = output.files.clone();
4520 let eq = &mut unsupported_unit_files
4521 .iter_mut()
4522 .find(|(name, _)| name.ends_with("_EQ.xml"))
4523 .unwrap()
4524 .1;
4525 let supported = format!(
4526 "<cim:{property} rdf:resource=\"http://iec.ch/TC57/CIM100#UnitSymbol.{expected}\"/>"
4527 );
4528 let unsupported_element = format!(
4529 "<cim:{property} rdf:resource=\"http://iec.ch/TC57/CIM100#UnitSymbol.{unsupported}\"/>"
4530 );
4531 assert!(eq.contains(&supported));
4532 *eq = eq.replacen(&supported, &unsupported_element, 1);
4533 let parsed =
4534 read::read_cgmes_documents(unsupported_unit_files, Some("unsupported-curve-unit"))
4535 .unwrap();
4536 let vsc = &parsed
4537 .network
4538 .detailed_connectivity()
4539 .as_deref()
4540 .unwrap()
4541 .voltage_source_converters[0];
4542 assert!(vsc.reactive_limits.is_none());
4543 assert!(
4544 parsed.warnings.iter().any(|warning| {
4545 warning.contains(&format!(
4546 "{property} `UnitSymbol.{unsupported}` is unsupported"
4547 )) && warning.contains(&format!("expected `UnitSymbol.{expected}`"))
4548 && warning.contains("reactive limits were not assigned")
4549 }),
4550 "warnings for {property}: {:?}",
4551 parsed.warnings
4552 );
4553 }
4554
4555 let parsed = read::read_cgmes_documents(output.files, Some("physical-dc")).unwrap();
4556 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
4557 assert_eq!(detailed.dc_converter_units.len(), 1);
4558 assert_eq!(
4559 detailed.dc_converter_units[0].operation_mode,
4560 DcConverterOperatingMode::Bipolar
4561 );
4562 assert_eq!(detailed.dc_nodes.len(), 3);
4563 assert_eq!(detailed.dc_grounds.len(), 1);
4564 assert_eq!(detailed.dc_busbars.len(), 1);
4565 assert_eq!(detailed.dc_lines.len(), 1);
4566 assert_eq!(detailed.dc_series_devices.len(), 1);
4567 assert_eq!(detailed.dc_switches.len(), 1);
4568 assert_eq!(detailed.voltage_source_converters.len(), 1);
4569 assert_eq!(detailed.line_commutated_converters.len(), 1);
4570 assert!((detailed.dc_lines[0].resistance_ohm.unwrap() - 1.25).abs() < 1e-12);
4571 assert!((detailed.dc_lines[0].inductance_h.unwrap() - 0.02).abs() < 1e-12);
4572 assert!((detailed.dc_lines[0].capacitance_f.unwrap() - 3.0e-6).abs() < 1e-12);
4573 assert_eq!(
4574 detailed.dc_lines[0]
4575 .equipment_container
4576 .as_ref()
4577 .unwrap()
4578 .component_type(),
4579 "dc_line_container"
4580 );
4581 assert!((detailed.dc_busbars[0].rated_dc_voltage_kv.unwrap() - 320.0).abs() < 1e-12);
4582 assert_eq!(detailed.dc_busbars[0].dc_terminal.sequence_number, Some(1));
4583 assert!((detailed.dc_series_devices[0].rated_dc_voltage_kv.unwrap() - 315.0).abs() < 1e-12);
4584 assert!((detailed.dc_series_devices[0].resistance_ohm.unwrap() - 0.75).abs() < 1e-12);
4585 assert!((detailed.dc_series_devices[0].inductance_h.unwrap() - 0.015).abs() < 1e-12);
4586 assert_eq!(
4587 detailed.dc_series_devices[0]
4588 .dc_terminal2
4589 .dc_topological_node
4590 .as_ref()
4591 .unwrap(),
4592 detailed.dc_nodes[2].dc_topological_node.as_ref().unwrap()
4593 );
4594 for (component_type, mrid, name) in [
4595 (
4596 "dc_line_container",
4597 DC_LINE_CONTAINER_MRID,
4598 "Retained DC line container",
4599 ),
4600 ("dc_busbar", DC_BUSBAR_MRID, "North DC busbar"),
4601 (
4602 "dc_series_device",
4603 DC_SERIES_DEVICE_MRID,
4604 "North DC series device",
4605 ),
4606 ] {
4607 let metadata = detailed
4608 .component_metadata
4609 .iter()
4610 .find(|metadata| metadata.component.component_type() == component_type)
4611 .unwrap();
4612 assert_eq!(metadata.name.as_deref(), Some(name));
4613 assert!(metadata.external_identifiers.iter().any(|identifier| {
4614 identifier.value == mrid && identifier.authority.as_deref() == Some("CGMES")
4615 }));
4616 }
4617 assert_eq!(detailed.dc_switches[0].open, Some(true));
4618 assert_eq!(
4619 detailed.voltage_source_converters[0].control_mode,
4620 Some(AcDcConverterControlMode::DcVoltage)
4621 );
4622 let Some(ReactiveLimits::CapabilityCurve(curve)) =
4623 &detailed.voltage_source_converters[0].reactive_limits
4624 else {
4625 panic!("expected reactive capability curve");
4626 };
4627 assert_eq!(curve.curve_style, CurveStyle::StraightLineYValues);
4628 assert_eq!(detailed.line_commutated_converters[0].reactive_model, None);
4629 assert_eq!(detailed.line_commutated_converters[0].power_factor, None);
4630 assert_eq!(
4631 detailed.line_commutated_converters[0].operating_mode,
4632 Some(LineCommutatedConverterOperatingMode::Inverter)
4633 );
4634 let voltage_source_terminal = detailed
4635 .terminals
4636 .iter()
4637 .find(|terminal| terminal.equipment.component_type() == "voltage_source_converter")
4638 .unwrap();
4639 assert!(!voltage_source_terminal.connected);
4640 assert!(voltage_source_terminal.bus.is_none());
4641 assert!(voltage_source_terminal.node.is_some());
4642 assert_eq!(voltage_source_terminal.active_power_mw, Some(150.0));
4643 assert_eq!(voltage_source_terminal.reactive_power_mvar, Some(20.0));
4644 let line_commutated_terminal = detailed
4645 .terminals
4646 .iter()
4647 .find(|terminal| terminal.equipment.component_type() == "line_commutated_converter")
4648 .unwrap();
4649 assert_eq!(line_commutated_terminal.active_power_mw, Some(-145.0));
4650 assert_eq!(line_commutated_terminal.reactive_power_mvar, Some(35.0));
4651
4652 let mut closed_network = network;
4653 Arc::make_mut(closed_network.detailed_connectivity_mut().as_mut().unwrap()).dc_switches
4654 [0]
4655 .open = Some(false);
4656 let closed = write::write_cgmes(&closed_network, CgmesVersion::V3_0).unwrap();
4657 let closed_xml = closed
4658 .files
4659 .iter()
4660 .map(|(_, xml)| xml.as_str())
4661 .collect::<String>();
4662 assert!(closed_xml.contains("<cim:Switch.open>false</cim:Switch.open>"));
4663 let reparsed = read::read_cgmes_documents(closed.files, Some("closed-dc-switch")).unwrap();
4664 assert_eq!(
4665 reparsed
4666 .network
4667 .detailed_connectivity()
4668 .as_deref()
4669 .unwrap()
4670 .dc_switches[0]
4671 .open,
4672 Some(false)
4673 );
4674 }
4675
4676 #[test]
4677 fn static_var_compensator_round_trips() {
4678 let mut network = network();
4679 let mut svc = StaticVarCompensator::new(BusId(2), -0.02, 0.04);
4680 svc.voltage_setpoint_kv = 228.0;
4681 svc.reactive_power_setpoint_mvar = 12.5;
4682 svc.regulation_mode = StaticVarCompensatorRegulationMode::ReactivePower;
4683 svc.regulating = true;
4684 svc.regulating_terminal = Some(TerminalReference {
4685 equipment: component("load", network.loads()[0].uid.as_deref().unwrap()),
4686 terminal: 1,
4687 });
4688 svc.p = 1.25;
4689 svc.q = 12.5;
4690 svc.uid = Some("68fe2cdf-43b4-5549-a7ce-95f3425edb19".into());
4691 network.static_var_compensators_mut().push(svc);
4692
4693 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
4694 let parsed = read::read_cgmes_documents(output.files, Some("svc")).unwrap();
4695 let svc = &parsed.network.static_var_compensators()[0];
4696 assert_eq!(
4697 svc.regulation_mode,
4698 StaticVarCompensatorRegulationMode::ReactivePower
4699 );
4700 assert!(svc.regulating);
4701 assert_eq!(svc.regulating_terminal.as_ref().unwrap().terminal, 1);
4702 assert!((svc.b_min_siemens + 0.02).abs() < 1e-12);
4703 assert!((svc.b_max_siemens - 0.04).abs() < 1e-12);
4704 assert!((svc.voltage_setpoint_kv - 228.0).abs() < 1e-12);
4705 assert!((svc.reactive_power_setpoint_mvar - 12.5).abs() < 1e-12);
4706 assert!((svc.p - 1.25).abs() < 1e-12);
4707 assert!((svc.q - 12.5).abs() < 1e-12);
4708 }
4709
4710 #[test]
4711 fn operational_limit_group_round_trips_without_collapsing_limit_types() {
4712 let mut network = detailed_network();
4713 let branch = component("branch", network.branches()[0].uid.as_deref().unwrap());
4714 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
4715 detailed
4716 .operational_limit_groups
4717 .push(OperationalLimitGroup {
4718 equipment: branch,
4719 terminal: 1,
4720 id: "5600bceb-a97b-5060-80d4-d546bbc85741".into(),
4721 properties: std::collections::BTreeMap::new(),
4722 selected: false,
4723 current_limits: Some(LoadingLimits {
4724 permanent_limit: Some(1000.0),
4725 permanent_limit_name: Some("permanent current".into()),
4726 temporary_limits: vec![TemporaryLimit {
4727 name: "five minute current".into(),
4728 value: 1200.0,
4729 acceptable_duration_seconds: 300,
4730 fictitious: true,
4731 }],
4732 }),
4733 active_power_limits: Some(LoadingLimits {
4734 permanent_limit: Some(80.0),
4735 permanent_limit_name: Some("permanent active power".into()),
4736 temporary_limits: Vec::new(),
4737 }),
4738 apparent_power_limits: Some(LoadingLimits {
4739 permanent_limit: Some(90.0),
4740 permanent_limit_name: Some("permanent apparent power".into()),
4741 temporary_limits: Vec::new(),
4742 }),
4743 });
4744
4745 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
4746 let parsed = read::read_cgmes_documents(output.files, Some("limits")).unwrap();
4747 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
4748 let group = detailed
4749 .operational_limit_groups
4750 .iter()
4751 .find(|value| value.id == "5600bceb-a97b-5060-80d4-d546bbc85741")
4752 .unwrap();
4753 assert_eq!(group.terminal, 1);
4754 let current = group.current_limits.as_ref().unwrap();
4755 assert_eq!(current.permanent_limit, Some(1000.0));
4756 assert_eq!(
4757 current.permanent_limit_name.as_deref(),
4758 Some("permanent current")
4759 );
4760 assert_eq!(current.temporary_limits.len(), 1);
4761 assert_eq!(current.temporary_limits[0].acceptable_duration_seconds, 300);
4762 assert!(current.temporary_limits[0].fictitious);
4763 assert_eq!(
4764 group
4765 .active_power_limits
4766 .as_ref()
4767 .and_then(|limits| limits.permanent_limit),
4768 Some(80.0)
4769 );
4770 assert_eq!(
4771 group
4772 .apparent_power_limits
4773 .as_ref()
4774 .and_then(|limits| limits.permanent_limit),
4775 Some(90.0)
4776 );
4777 }
4778
4779 #[test]
4780 fn retained_load_and_switch_classes_survive_fresh_emission() {
4781 let files = write::write_cgmes(&detailed_network(), CgmesVersion::V3_0)
4782 .unwrap()
4783 .files
4784 .into_iter()
4785 .map(|(name, text)| {
4786 (
4787 name,
4788 text.replace("cim:EnergyConsumer", "cim:ConformLoad")
4789 .replace("cim:Breaker", "cim:Fuse"),
4790 )
4791 })
4792 .collect();
4793 let parsed = read::read_cgmes_documents(files, Some("retained-classes")).unwrap();
4794 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
4795 assert!(detailed.component_metadata.iter().any(|metadata| {
4796 metadata.component.component_type() == "load"
4797 && metadata
4798 .properties
4799 .get(CGMES_CLASS_PROPERTY)
4800 .map(String::as_str)
4801 == Some("ConformLoad")
4802 }));
4803 assert!(detailed.component_metadata.iter().any(|metadata| {
4804 metadata.component.component_type() == "switch"
4805 && metadata
4806 .properties
4807 .get(CGMES_CLASS_PROPERTY)
4808 .map(String::as_str)
4809 == Some("Fuse")
4810 }));
4811
4812 let fresh = write::write_cgmes(&parsed.network, CgmesVersion::V3_0).unwrap();
4813 let equipment = &fresh
4814 .files
4815 .iter()
4816 .find(|(name, _)| name.ends_with("_EQ.xml"))
4817 .unwrap()
4818 .1;
4819 assert!(equipment.contains("<cim:ConformLoad "));
4820 assert!(equipment.contains("<cim:Fuse "));
4821 assert!(!equipment.contains("<cim:EnergyConsumer "));
4822 assert!(!equipment.contains("<cim:Breaker "));
4823 }
4824
4825 #[test]
4826 fn external_network_injection_substitution_is_explicit() {
4827 let files = write::write_cgmes(&detailed_network(), CgmesVersion::V3_0)
4828 .unwrap()
4829 .files;
4830 let topological_node = files
4831 .iter()
4832 .find(|(name, _)| name.ends_with("_TP.xml"))
4833 .and_then(|(_, text)| {
4834 text.lines()
4835 .find(|line| line.contains("<cim:TopologicalNode rdf:ID="))
4836 })
4837 .and_then(|line| line.split("rdf:ID=\"").nth(1))
4838 .and_then(|value| value.split('"').next())
4839 .unwrap()
4840 .trim_start_matches('_')
4841 .to_string();
4842 let equipment = r##" <cim:ExternalNetworkInjection rdf:ID="_external-1">
4843 <cim:ExternalNetworkInjection.maxP>100</cim:ExternalNetworkInjection.maxP>
4844 <cim:ExternalNetworkInjection.minP>-100</cim:ExternalNetworkInjection.minP>
4845 <cim:ExternalNetworkInjection.maxQ>50</cim:ExternalNetworkInjection.maxQ>
4846 <cim:ExternalNetworkInjection.minQ>-50</cim:ExternalNetworkInjection.minQ>
4847 </cim:ExternalNetworkInjection>
4848 <cim:Terminal rdf:ID="_external-terminal">
4849 <cim:Terminal.ConductingEquipment rdf:resource="#_external-1"/>
4850 <cim:ACDCTerminal.sequenceNumber>1</cim:ACDCTerminal.sequenceNumber>
4851 </cim:Terminal>
4852"##;
4853 let topology = format!(
4854 r##" <cim:Terminal rdf:about="#_external-terminal">
4855 <cim:Terminal.TopologicalNode rdf:resource="#_{topological_node}"/>
4856 </cim:Terminal>
4857"##
4858 );
4859 let ssh = r##" <cim:ExternalNetworkInjection rdf:about="#_external-1">
4860 <cim:ExternalNetworkInjection.p>-30</cim:ExternalNetworkInjection.p>
4861 <cim:ExternalNetworkInjection.q>-5</cim:ExternalNetworkInjection.q>
4862 </cim:ExternalNetworkInjection>
4863"##;
4864 let files = insert_profile_records(files, equipment, Some(&topology), Some(ssh));
4865 let parsed = read::read_cgmes_documents(files, Some("external-injection")).unwrap();
4866 let generator = parsed
4867 .network
4868 .generators()
4869 .iter()
4870 .find(|generator| generator.uid.as_deref() == Some("external-1"))
4871 .unwrap();
4872 assert!((generator.pg - 30.0).abs() <= f64::EPSILON);
4873 assert!((generator.qg - 5.0).abs() <= f64::EPSILON);
4874 assert!(parsed.warnings.iter().any(|warning| {
4875 warning.info.code == crate::diagnostics::codes::READ_CGMES_VALUE_APPROXIMATED.code
4876 && warning.contains("ExternalNetworkInjection `external-1`")
4877 && warning.contains("fresh CGMES output emits a SynchronousMachine")
4878 }));
4879 let fresh = write::write_cgmes(&parsed.network, CgmesVersion::V3_0).unwrap();
4880 let equipment = &fresh
4881 .files
4882 .iter()
4883 .find(|(name, _)| name.ends_with("_EQ.xml"))
4884 .unwrap()
4885 .1;
4886 assert!(equipment.contains("<cim:SynchronousMachine "));
4887 assert!(!equipment.contains("<cim:ExternalNetworkInjection "));
4888 }
4889
4890 #[test]
4891 fn conflicting_solution_observations_are_rejected() {
4892 fn reference_in_first_block(text: &str, class: &str, property: &str) -> String {
4893 let start = text.find(&format!("<cim:{class} ")).unwrap();
4894 let end = text[start..]
4895 .find(&format!("</cim:{class}>"))
4896 .map(|offset| start + offset)
4897 .unwrap();
4898 text[start..end]
4899 .lines()
4900 .find(|line| line.contains(&format!("<cim:{property} ")))
4901 .and_then(|line| line.split("rdf:resource=\"#").nth(1))
4902 .and_then(|value| value.split('"').next())
4903 .unwrap()
4904 .to_string()
4905 }
4906 fn append_sv(files: &mut [(String, String)], record: &str) {
4907 let (_, text) = files
4908 .iter_mut()
4909 .find(|(name, _)| name.ends_with("_SV.xml"))
4910 .unwrap();
4911 *text = text.replace("</rdf:RDF>", &format!("{record}</rdf:RDF>"));
4912 }
4913
4914 let mut voltage_network = detailed_network();
4915 let detailed = Arc::make_mut(
4916 voltage_network
4917 .detailed_connectivity_mut()
4918 .as_mut()
4919 .unwrap(),
4920 );
4921 detailed.bus_breaker_buses[0].voltage_kv = Some(230.0);
4922 detailed.bus_breaker_buses[0].angle_degrees = Some(0.0);
4923 let base = write::write_cgmes(&voltage_network, CgmesVersion::V3_0)
4924 .unwrap()
4925 .files;
4926 let sv = &base
4927 .iter()
4928 .find(|(name, _)| name.ends_with("_SV.xml"))
4929 .unwrap()
4930 .1;
4931 let node = reference_in_first_block(sv, "SvVoltage", "SvVoltage.TopologicalNode");
4932 let mut voltage_files = base.clone();
4933 append_sv(
4934 &mut voltage_files,
4935 &format!(
4936 r##" <cim:SvVoltage rdf:ID="_conflicting-voltage">
4937 <cim:SvVoltage.TopologicalNode rdf:resource="#{node}"/>
4938 <cim:SvVoltage.v>999</cim:SvVoltage.v>
4939 <cim:SvVoltage.angle>99</cim:SvVoltage.angle>
4940 </cim:SvVoltage>
4941"##
4942 ),
4943 );
4944 let message = read::read_cgmes_documents(voltage_files, Some("conflicting-voltage"))
4945 .err()
4946 .unwrap()
4947 .to_string();
4948 assert!(message.contains("conflicting SvVoltage observations"));
4949
4950 let mut flow_network = detailed_network();
4951 let detailed = Arc::make_mut(flow_network.detailed_connectivity_mut().as_mut().unwrap());
4952 detailed.terminals[0].active_power_mw = Some(1.0);
4953 detailed.terminals[0].reactive_power_mvar = Some(2.0);
4954 let mut flow_files = write::write_cgmes(&flow_network, CgmesVersion::V3_0)
4955 .unwrap()
4956 .files;
4957 let sv = &flow_files
4958 .iter()
4959 .find(|(name, _)| name.ends_with("_SV.xml"))
4960 .unwrap()
4961 .1;
4962 let terminal = reference_in_first_block(sv, "SvPowerFlow", "SvPowerFlow.Terminal");
4963 append_sv(
4964 &mut flow_files,
4965 &format!(
4966 r##" <cim:SvPowerFlow rdf:ID="_conflicting-flow">
4967 <cim:SvPowerFlow.Terminal rdf:resource="#{terminal}"/>
4968 <cim:SvPowerFlow.p>3</cim:SvPowerFlow.p>
4969 <cim:SvPowerFlow.q>4</cim:SvPowerFlow.q>
4970 </cim:SvPowerFlow>
4971"##
4972 ),
4973 );
4974 let message = read::read_cgmes_documents(flow_files, Some("conflicting-flow"))
4975 .err()
4976 .unwrap()
4977 .to_string();
4978 assert!(message.contains("conflicting SvPowerFlow observations"));
4979 }
4980
4981 #[test]
4982 fn flat_network_voltage_limits_and_all_area_records_are_reported() {
4983 let mut network = network();
4984 network.buses_mut()[0].vmin = 0.91;
4985 network.buses_mut()[0].vmax = 1.09;
4986 network.areas_mut().push(Area::new(1));
4987 network.areas_mut().push(Area::new(2));
4988 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
4989 let equipment = &output
4990 .files
4991 .iter()
4992 .find(|(name, _)| name.ends_with("_EQ.xml"))
4993 .unwrap()
4994 .1;
4995 assert!(equipment.contains("<cim:VoltageLevel.lowVoltageLimit>209.3"));
4996 assert!(equipment.contains("<cim:VoltageLevel.highVoltageLimit>250.7"));
4997 assert!(output.warnings.iter().any(|warning| {
4998 warning.contains("2 area record(s) dropped: a CGMES ControlArea states")
4999 }));
5000 }
5001
5002 #[test]
5003 fn voltage_limits_use_the_most_restrictive_valid_voltage_level_range() {
5004 const VOLTAGE_LEVEL_MRID: &str = "10000000-0000-4000-8000-000000000001";
5005 let cases = [
5006 (
5007 "operational-tighter",
5008 (380.0, 420.0),
5009 (390.0, 410.0),
5010 (390.0, 410.0),
5011 ),
5012 (
5013 "voltage-level-tighter",
5014 (380.0, 420.0),
5015 (370.0, 430.0),
5016 (380.0, 420.0),
5017 ),
5018 (
5019 "inconsistent-voltage-level",
5020 (420.0, 380.0),
5021 (380.0, 420.0),
5022 (380.0, 420.0),
5023 ),
5024 ];
5025 for version in [CgmesVersion::V2_4_15, CgmesVersion::V3_0] {
5026 for (name, voltage_level_limits, operational_limits, expected) in cases {
5027 let parsed = read::read_cgmes_documents(
5028 voltage_limit_documents(version, voltage_level_limits, operational_limits),
5029 Some(name),
5030 )
5031 .unwrap();
5032 let level = parsed
5033 .network
5034 .detailed_connectivity()
5035 .as_deref()
5036 .unwrap()
5037 .voltage_levels
5038 .iter()
5039 .find(|level| level.component.local_id() == VOLTAGE_LEVEL_MRID)
5040 .unwrap();
5041 assert_eq!(
5042 level.low_voltage_limit_kv,
5043 Some(expected.0),
5044 "{version:?} {name}"
5045 );
5046 assert_eq!(
5047 level.high_voltage_limit_kv,
5048 Some(expected.1),
5049 "{version:?} {name}"
5050 );
5051 assert!(parsed.warnings.iter().any(|warning| {
5052 warning.info.code
5053 == crate::diagnostics::codes::READ_CGMES_VALUE_APPROXIMATED.code
5054 && warning.contains("most restrictive valid")
5055 && warning.contains("low-voltage-limit")
5056 && warning.contains("high-voltage-limit")
5057 }));
5058 if name == "inconsistent-voltage-level" {
5059 assert!(parsed.warnings.iter().any(|warning| {
5060 warning.info.code
5061 == crate::diagnostics::codes::READ_CGMES_VALUE_APPROXIMATED.code
5062 && warning
5063 .contains("both inconsistent VoltageLevel limits were ignored")
5064 }));
5065 }
5066
5067 let fresh = write::write_cgmes(&parsed.network, CgmesVersion::V3_0).unwrap();
5068 let equipment = &fresh
5069 .files
5070 .iter()
5071 .find(|(name, _)| name.ends_with("_EQ.xml"))
5072 .unwrap()
5073 .1;
5074 assert!(equipment.contains("<cim:VoltageLevel.lowVoltageLimit>"));
5075 assert!(equipment.contains("<cim:VoltageLevel.highVoltageLimit>"));
5076 assert!(!equipment.contains("<cim:VoltageLimit"));
5077 let reparsed =
5078 read::read_cgmes_documents(fresh.files, Some("fresh-limits")).unwrap();
5079 let reparsed_level = reparsed
5080 .network
5081 .detailed_connectivity()
5082 .as_deref()
5083 .unwrap()
5084 .voltage_levels
5085 .iter()
5086 .find(|level| level.component.local_id() == VOLTAGE_LEVEL_MRID)
5087 .unwrap();
5088 assert_eq!(reparsed_level.low_voltage_limit_kv, Some(expected.0));
5089 assert_eq!(reparsed_level.high_voltage_limit_kv, Some(expected.1));
5090 }
5091 }
5092 }
5093
5094 #[test]
5095 fn unmappable_voltage_limit_has_a_specific_diagnostic() {
5096 let documents = voltage_limit_documents(CgmesVersion::V3_0, (380.0, 420.0), (390.0, 410.0));
5097 let documents = insert_profile_records(
5098 documents,
5099 r##" <cim:OperationalLimitSet rdf:ID="_orphan-voltage-limit-set"/>
5100 <cim:VoltageLimit rdf:ID="_orphan-voltage-limit">
5101 <cim:OperationalLimit.OperationalLimitSet rdf:resource="#_orphan-voltage-limit-set"/>
5102 <cim:OperationalLimit.OperationalLimitType rdf:resource="#_low-voltage-limit-type"/>
5103 <cim:VoltageLimit.normalValue>395</cim:VoltageLimit.normalValue>
5104 </cim:VoltageLimit>
5105"##,
5106 None,
5107 None,
5108 );
5109 let parsed = read::read_cgmes_documents(documents, Some("orphan-voltage-limit")).unwrap();
5110 assert!(parsed.warnings.iter().any(|warning| {
5111 warning.info.code == crate::diagnostics::codes::READ_CGMES_RECORD_UNMAPPED.code
5112 && warning.contains("VoltageLimit `orphan-voltage-limit`")
5113 && warning.contains("does not target equipment or a terminal in one VoltageLevel")
5114 && warning.contains("was not mapped")
5115 }));
5116 }
5117
5118 #[test]
5119 #[allow(clippy::too_many_lines)]
5120 fn real_cgmes_tap_associations_and_table_steps_round_trip() {
5121 const RATIO_TAP_MRID: &str = "de305d54-75b4-431b-adb2-eb6b9e546077";
5122 let mut network = detailed_network();
5123 network.branches_mut()[0].tap = 1.05;
5124 network.branches_mut()[0].shift = 2.0;
5125 let transformer = component("branch", network.branches()[0].uid.as_deref().unwrap());
5126 let step = |position, rho, alpha_degrees| TapChangerStep {
5127 position,
5128 rho,
5129 alpha_degrees,
5130 resistance_deviation_percent: 0.0,
5131 reactance_deviation_percent: 0.0,
5132 conductance_deviation_percent: 0.0,
5133 susceptance_deviation_percent: 0.0,
5134 };
5135 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
5136 let ratio_tap_component = component("tap_changer", "source-ratio-tap");
5137 detailed.tap_changers = vec![
5138 TapChanger {
5139 component: Some(ratio_tap_component.clone()),
5140 transformer: transformer.clone(),
5141 winding: 1,
5142 kind: TapChangerKind::Ratio,
5143 tap_position: Some(1),
5144 solved_tap_position: Some(0),
5145 low_tap_position: -1,
5146 neutral_tap_position: Some(0),
5147 normal_tap_position: Some(0),
5148 voltage_step_increment_percent: Some(5.0),
5149 load_tap_changing_capabilities: true,
5150 regulating: true,
5151 regulation_mode: Some(TapChangerRegulationMode::Voltage),
5152 regulation_value: Some(228.0),
5153 target_deadband: Some(2.0),
5154 regulation_terminal: Some(TerminalReference {
5155 equipment: transformer.clone(),
5156 terminal: 2,
5157 }),
5158 steps: vec![step(-1, 0.95, 0.0), step(0, 1.0, 0.0), step(1, 1.05, 0.0)],
5159 },
5160 TapChanger {
5161 component: None,
5162 transformer,
5163 winding: 1,
5164 kind: TapChangerKind::Phase,
5165 tap_position: Some(1),
5166 solved_tap_position: Some(-1),
5167 low_tap_position: -1,
5168 neutral_tap_position: Some(0),
5169 normal_tap_position: Some(0),
5170 voltage_step_increment_percent: None,
5171 load_tap_changing_capabilities: true,
5172 regulating: true,
5173 regulation_mode: Some(TapChangerRegulationMode::ActivePower),
5174 regulation_value: Some(25.0),
5175 target_deadband: Some(1.0),
5176 regulation_terminal: None,
5177 steps: vec![step(-1, 1.0, -2.0), step(0, 1.0, 0.0), step(1, 1.0, 2.0)],
5178 },
5179 ];
5180 detailed.component_metadata.push(ComponentMetadata {
5181 component: ratio_tap_component,
5182 name: Some("Imported ratio tap".into()),
5183 equipment_container: None,
5184 aliases: Vec::new(),
5185 external_identifiers: vec![ExternalIdentifier {
5186 value: RATIO_TAP_MRID.into(),
5187 authority: Some("CGMES".into()),
5188 }],
5189 properties: std::collections::BTreeMap::new(),
5190 fictitious: false,
5191 });
5192
5193 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
5194 let eq = output
5195 .files
5196 .iter()
5197 .find(|(name, _)| name.ends_with("_EQ.xml"))
5198 .map(|(_, text)| text)
5199 .unwrap();
5200 assert!(eq.contains("<cim:RatioTapChanger.TransformerEnd "));
5201 assert!(eq.contains(&format!(
5202 "<cim:RatioTapChanger rdf:ID=\"_{RATIO_TAP_MRID}\">"
5203 )));
5204 assert!(eq.contains("<cim:PhaseTapChanger.TransformerEnd "));
5205 assert!(eq.contains("<cim:TapChangerTablePoint.step>"));
5206 assert!(eq.contains("<cim:TapChangerTablePoint.ratio>"));
5207 assert!(eq.contains("<cim:PhaseTapChangerTablePoint.angle>"));
5208 assert!(!eq.contains("<cim:TransformerEnd.RatioTapChanger "));
5209 assert!(!eq.contains("<cim:TransformerEnd.PhaseTapChanger "));
5210
5211 let parsed = read::read_cgmes_documents(output.files, Some("tap-table")).unwrap();
5212 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
5213 assert_eq!(detailed.tap_changers.len(), 2);
5214 let ratio = detailed
5215 .tap_changers
5216 .iter()
5217 .find(|tap| tap.kind == TapChangerKind::Ratio)
5218 .unwrap();
5219 assert_eq!(ratio.tap_position, Some(1));
5220 assert_eq!(
5221 ratio.component.as_ref().map(ComponentId::local_id),
5222 Some(RATIO_TAP_MRID)
5223 );
5224 assert_eq!(ratio.solved_tap_position, Some(0));
5225 assert_eq!(ratio.steps.len(), 3);
5226 assert!((ratio.steps[0].rho - 0.95).abs() < 1e-12);
5227 let phase = detailed
5228 .tap_changers
5229 .iter()
5230 .find(|tap| tap.kind == TapChangerKind::Phase)
5231 .unwrap();
5232 assert_eq!(phase.solved_tap_position, Some(-1));
5233 assert!((phase.steps[2].alpha_degrees - 2.0).abs() < 1e-12);
5234 assert!((parsed.network.branches()[0].tap - 1.05).abs() < 1e-12);
5235 assert!((parsed.network.branches()[0].shift - 2.0).abs() < 1e-12);
5236 }
5237
5238 #[test]
5239 fn tap_sv_requires_a_solved_position_and_unknown_control_terminal_is_diagnosed() {
5240 const TAP_MRID: &str = "20000000-0000-4000-8000-000000000001";
5241 let mut network = detailed_network();
5242 let transformer = component("branch", network.branches()[0].uid.as_deref().unwrap());
5243 let tap_component = component("tap_changer", TAP_MRID);
5244 let step = |position, rho| TapChangerStep {
5245 position,
5246 rho,
5247 alpha_degrees: 0.0,
5248 resistance_deviation_percent: 0.0,
5249 reactance_deviation_percent: 0.0,
5250 conductance_deviation_percent: 0.0,
5251 susceptance_deviation_percent: 0.0,
5252 };
5253 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
5254 detailed.tap_changers.push(TapChanger {
5255 component: Some(tap_component.clone()),
5256 transformer: transformer.clone(),
5257 winding: 1,
5258 kind: TapChangerKind::Ratio,
5259 tap_position: Some(1),
5260 solved_tap_position: None,
5261 low_tap_position: -1,
5262 neutral_tap_position: Some(0),
5263 normal_tap_position: Some(0),
5264 voltage_step_increment_percent: Some(5.0),
5265 load_tap_changing_capabilities: true,
5266 regulating: true,
5267 regulation_mode: Some(TapChangerRegulationMode::Voltage),
5268 regulation_value: Some(228.0),
5269 target_deadband: Some(2.0),
5270 regulation_terminal: Some(TerminalReference {
5271 equipment: transformer,
5272 terminal: 2,
5273 }),
5274 steps: vec![step(-1, 0.95), step(0, 1.0), step(1, 1.05)],
5275 });
5276 detailed.component_metadata.push(ComponentMetadata {
5277 component: tap_component,
5278 name: None,
5279 equipment_container: None,
5280 aliases: Vec::new(),
5281 external_identifiers: vec![ExternalIdentifier {
5282 value: TAP_MRID.into(),
5283 authority: Some("CGMES".into()),
5284 }],
5285 properties: std::collections::BTreeMap::new(),
5286 fictitious: false,
5287 });
5288
5289 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
5290 let ssh = output
5291 .files
5292 .iter()
5293 .find(|(name, _)| name.ends_with("_SSH.xml"))
5294 .map(|(_, text)| text)
5295 .unwrap();
5296 let sv = output
5297 .files
5298 .iter()
5299 .find(|(name, _)| name.ends_with("_SV.xml"))
5300 .map(|(_, text)| text)
5301 .unwrap();
5302 assert!(ssh.contains("<cim:TapChanger.step>1</cim:TapChanger.step>"));
5303 assert!(!sv.contains("<cim:SvTapStep "));
5304
5305 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
5306 detailed.tap_changers[0].solved_tap_position = Some(-1);
5307 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
5308 let sv = output
5309 .files
5310 .iter()
5311 .find(|(name, _)| name.ends_with("_SV.xml"))
5312 .map(|(_, text)| text)
5313 .unwrap();
5314 assert!(sv.contains("<cim:SvTapStep.position>-1</cim:SvTapStep.position>"));
5315
5316 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
5317 detailed.tap_changers[0].regulation_terminal = Some(TerminalReference {
5318 equipment: component("branch", "missing-transformer"),
5319 terminal: 1,
5320 });
5321 let error = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap_err();
5322 let message = error.to_string();
5323 assert!(message.contains("regulates unknown equipment terminal"));
5324 assert!(message.contains("branch/missing-transformer"));
5325 }
5326
5327 #[test]
5328 fn invalid_operational_limits_are_diagnosed() {
5329 let mut network = detailed_network();
5330 let branch = component("branch", network.branches()[0].uid.as_deref().unwrap());
5331 Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap())
5332 .operational_limit_groups
5333 .push(OperationalLimitGroup {
5334 equipment: branch,
5335 terminal: 1,
5336 id: "invalid-limits".into(),
5337 properties: std::collections::BTreeMap::new(),
5338 selected: false,
5339 current_limits: Some(LoadingLimits {
5340 permanent_limit: Some(-1.0),
5341 permanent_limit_name: Some("invalid permanent".into()),
5342 temporary_limits: vec![
5343 TemporaryLimit {
5344 name: "invalid temporary".into(),
5345 value: f64::NAN,
5346 acceptable_duration_seconds: 300,
5347 fictitious: false,
5348 },
5349 TemporaryLimit {
5350 name: "valid temporary".into(),
5351 value: 1200.0,
5352 acceptable_duration_seconds: 60,
5353 fictitious: false,
5354 },
5355 ],
5356 }),
5357 active_power_limits: None,
5358 apparent_power_limits: None,
5359 });
5360
5361 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
5362 let dropped = output
5363 .warnings
5364 .iter()
5365 .filter(|warning| {
5366 warning.info.code == crate::diagnostics::codes::EMIT_CGMES.record_dropped.code
5367 })
5368 .collect::<Vec<_>>();
5369 assert!(dropped.iter().any(|warning| {
5370 warning.contains("permanent limit `-1`")
5371 && warning.contains("must be positive and finite")
5372 }));
5373 assert!(dropped.iter().any(|warning| {
5374 warning.contains("temporary limit `invalid temporary`")
5375 && warning.contains("must be positive and finite")
5376 }));
5377 let all_xml = output
5378 .files
5379 .iter()
5380 .map(|(_, text)| text.as_str())
5381 .collect::<String>();
5382 assert!(!all_xml.contains("invalid permanent"));
5383 assert!(!all_xml.contains("invalid temporary"));
5384 assert!(all_xml.contains("valid temporary"));
5385 }
5386
5387 #[test]
5388 fn names_are_xml_escaped_once_and_round_trip() {
5389 let mut network = network();
5390 *network.name_mut() = "A & B <North>".into();
5391 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
5392 let eq = output
5393 .files
5394 .iter()
5395 .find(|(name, _)| name.ends_with("_EQ.xml"))
5396 .map(|(_, text)| text)
5397 .unwrap();
5398 assert!(eq.contains("A & B <North>"));
5399 assert!(!eq.contains("&amp;"));
5400
5401 let parsed = read::read_cgmes_documents(output.files, Some("escaped-name")).unwrap();
5402 assert_eq!(parsed.network.name(), "A & B <North>");
5403 }
5404
5405 #[test]
5406 fn fresh_cgmes_rejects_duplicate_mrids() {
5407 const SHARED_MRID: &str = "30000000-0000-4000-8000-000000000001";
5408 let mut network = network();
5409 network.loads_mut()[0].uid = Some(SHARED_MRID.into());
5410 network.generators_mut()[0].uid = Some(SHARED_MRID.into());
5411 let error = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap_err();
5412 let message = error.to_string();
5413 assert!(message.contains(SHARED_MRID));
5414 assert!(message.contains("defines mRID"));
5415 assert!(message.contains("more than once"));
5416 }
5417
5418 #[test]
5419 fn rdf_graph_validation_ignores_marker_text() {
5420 let xml = r##"<rdf:RDF
5421 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
5422 xmlns:md="http://iec.ch/TC57/61970-552/ModelDescription/1#"
5423 xmlns:cim="http://iec.ch/TC57/CIM100#">
5424 <md:FullModel rdf:about="urn:uuid:10000000-0000-4000-8000-000000000001"/>
5425 <cim:BaseVoltage rdf:ID="_20000000-0000-4000-8000-000000000001">
5426 <cim:IdentifiedObject.name>literal rdf:ID="_not-an-object" rdf:resource="#_missing"</cim:IdentifiedObject.name>
5427 </cim:BaseVoltage>
5428 </rdf:RDF>"##;
5429 write::validate_rdf_graph(&[("markers.xml".into(), xml.into())]).unwrap();
5430 }
5431
5432 #[test]
5433 fn rdf_graph_validation_separates_full_model_and_fragment_identifiers() {
5434 const SHARED_UUID: &str = "30000000-0000-4000-8000-000000000001";
5435 let xml = format!(
5436 r##"<rdf:RDF
5437 xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
5438 xmlns:model="http://iec.ch/TC57/61970-552/ModelDescription/1#"
5439 xmlns:cim="http://iec.ch/TC57/CIM100#">
5440 <model:FullModel rdf:about="urn:uuid:{SHARED_UUID}">
5441 <model:Model.DependentOn rdf:resource="urn:uuid:{SHARED_UUID}"/>
5442 </model:FullModel>
5443 <cim:BaseVoltage rdf:ID="_{SHARED_UUID}"/>
5444 <cim:BaseVoltage rdf:about="#_{SHARED_UUID}"/>
5445 </rdf:RDF>"##
5446 );
5447 write::validate_rdf_graph(&[("shared-uuid.xml".into(), xml.clone())]).unwrap();
5448
5449 let dangling = xml.replace(
5450 &format!("rdf:about=\"#_{SHARED_UUID}\""),
5451 "rdf:about=\"#_missing\"",
5452 );
5453 let error = write::validate_rdf_graph(&[("dangling.xml".into(), dangling)]).unwrap_err();
5454 assert!(error.to_string().contains("dangling rdf:about reference"));
5455 assert!(error.to_string().contains("missing"));
5456 }
5457
5458 #[test]
5459 fn short_names_and_fictitious_flags_round_trip() {
5460 let mut network = detailed_network();
5461 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
5462 let voltage_level = detailed.voltage_levels[0].component.clone();
5463 let metadata = detailed
5464 .component_metadata
5465 .iter_mut()
5466 .find(|metadata| metadata.component == voltage_level)
5467 .unwrap();
5468 metadata.aliases = vec![
5469 crate::network::ComponentAlias {
5470 value: "N230".into(),
5471 alias_type: Some("short_name".into()),
5472 },
5473 crate::network::ComponentAlias {
5474 value: "legacy-vl".into(),
5475 alias_type: Some("legacy".into()),
5476 },
5477 ];
5478 metadata.fictitious = true;
5479
5480 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
5481 assert!(output.warnings.iter().any(|warning| {
5482 warning.contains("alias `legacy-vl` of type `legacy`")
5483 && warning.contains("has no CGMES IdentifiedObject.shortName mapping")
5484 }));
5485 let eq = output
5486 .files
5487 .iter()
5488 .find(|(name, _)| name.ends_with("_EQ.xml"))
5489 .map(|(_, text)| text)
5490 .unwrap();
5491 assert!(
5492 eq.contains("<cim:IdentifiedObject.shortName>N230</cim:IdentifiedObject.shortName>")
5493 );
5494 assert!(eq.contains(
5495 "<cim:IdentifiedObject.isFictitious>true</cim:IdentifiedObject.isFictitious>"
5496 ));
5497
5498 let reparsed = read::read_cgmes_documents(output.files, Some("metadata")).unwrap();
5499 let detailed = reparsed.network.detailed_connectivity().as_deref().unwrap();
5500 let metadata = detailed
5501 .component_metadata
5502 .iter()
5503 .find(|metadata| metadata.aliases.iter().any(|alias| alias.value == "N230"))
5504 .unwrap();
5505 assert!(metadata.fictitious);
5506 assert!(metadata.aliases.iter().any(|alias| {
5507 alias.value == "N230" && alias.alias_type.as_deref() == Some("short_name")
5508 }));
5509 }
5510
5511 #[test]
5512 fn artifact_names_are_bounded_deterministic_and_independent_of_model_description() {
5513 let descriptions = [
5514 ("CGMES model legal notice: ".to_string() + &"all rights and conditions; ".repeat(40))
5515 .trim_end()
5516 .to_string(),
5517 r#"../../north\west:*?\"<>| & grid"#.to_string(),
5518 ];
5519 for description in descriptions {
5520 let mut network = network();
5521 *network.name_mut() = description.clone();
5522 let mut expected_names = write::write_cgmes(&network, CgmesVersion::V3_0)
5523 .unwrap()
5524 .files
5525 .into_iter()
5526 .map(|(name, _)| name)
5527 .collect::<Vec<_>>();
5528 let repeated_names = write::write_cgmes(&network, CgmesVersion::V3_0)
5529 .unwrap()
5530 .files
5531 .into_iter()
5532 .map(|(name, _)| name)
5533 .collect::<Vec<_>>();
5534 assert_eq!(expected_names, repeated_names);
5535 expected_names.sort();
5536
5537 let result = crate::format::emit(
5538 &PioModule::new(network),
5539 TargetFormat::Cgmes,
5540 Destination::memory("cgmes").unwrap(),
5541 )
5542 .unwrap();
5543 let EmittedOutput::Memory { artifacts } = result.into_output() else {
5544 panic!("memory destination returned paths");
5545 };
5546 let names = artifacts
5547 .iter()
5548 .map(|artifact| {
5549 artifact
5550 .name()
5551 .as_str()
5552 .strip_prefix("cgmes/")
5553 .unwrap()
5554 .to_string()
5555 })
5556 .collect::<Vec<_>>();
5557 assert_eq!(names, expected_names);
5558 assert!(names.iter().all(|name| {
5559 name.starts_with("powerio_")
5560 && name.len() <= 52
5561 && name.chars().all(|character| {
5562 character.is_ascii_alphanumeric() || "_-.".contains(character)
5563 })
5564 }));
5565
5566 let documents = artifacts
5567 .iter()
5568 .map(|artifact| {
5569 (
5570 artifact.name().to_string(),
5571 std::str::from_utf8(artifact.bytes()).unwrap().to_string(),
5572 )
5573 })
5574 .collect();
5575 let parsed = read::read_cgmes_documents(documents, Some("bounded-name")).unwrap();
5576 assert_eq!(parsed.network.name().as_str(), description);
5577 }
5578 }
5579
5580 #[test]
5581 fn cim100_equipment_extensions_merge_into_concrete_eq_objects() {
5582 let output = write::write_cgmes(&network(), CgmesVersion::V3_0).unwrap();
5583 let mut documents: Vec<_> = output
5584 .files
5585 .into_iter()
5586 .map(|(name, text)| {
5587 if name.ends_with("_SSH.xml") {
5588 (
5589 name,
5590 text.replace(
5591 "<cim:EnergyConsumer rdf:about=",
5592 "<cim:Equipment rdf:about=",
5593 )
5594 .replace("</cim:EnergyConsumer>", "</cim:Equipment>"),
5595 )
5596 } else {
5597 (name, text)
5598 }
5599 })
5600 .collect();
5601 documents.reverse();
5602 let parsed = read::read_cgmes_documents(documents, Some("generic-equipment")).unwrap();
5603 assert_eq!(parsed.network.loads().len(), 1);
5604 assert!(parsed.network.loads()[0].in_service);
5605 }
5606
5607 #[test]
5608 fn zip_profile_set_parses_and_unsafe_entries_are_refused() {
5609 let output = write::write_cgmes(&network(), CgmesVersion::V3_0).unwrap();
5610 let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
5611 for (name, text) in output.files {
5612 writer
5613 .start_file(name, zip::write::SimpleFileOptions::default())
5614 .unwrap();
5615 writer.write_all(text.as_bytes()).unwrap();
5616 }
5617 let bytes = writer.finish().unwrap().into_inner();
5618 let source = Source::from_memory("case.zip", bytes.clone()).unwrap();
5619 let parsed = parse_source(&source, &mut Diagnostics::new()).unwrap();
5620 assert_eq!(parsed.buses().len(), 2);
5621
5622 let module = crate::format::parse(source.clone()).unwrap();
5623 let result = crate::format::emit(
5624 &module,
5625 TargetFormat::Cgmes,
5626 powerio_core::Destination::memory("copy").unwrap(),
5627 )
5628 .unwrap();
5629 assert_eq!(result.fidelity(), powerio_core::Fidelity::ExactSameFormat);
5630 let powerio_core::EmittedOutput::Memory { artifacts } = result.output() else {
5631 panic!("a memory destination returned paths")
5632 };
5633 assert_eq!(artifacts.len(), 1);
5634 assert_eq!(artifacts[0].bytes(), bytes);
5635
5636 let directory = tempfile::tempdir().unwrap();
5637 std::fs::write(
5638 directory.path().join("profiles.zip"),
5639 source.primary_buffer().unwrap().bytes(),
5640 )
5641 .unwrap();
5642 let source = Source::open(directory.path()).unwrap();
5643 let parsed = parse_source(&source, &mut Diagnostics::new()).unwrap();
5644 assert_eq!(parsed.branches().len(), 1);
5645
5646 let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
5647 writer
5648 .start_file("../EQ.xml", zip::write::SimpleFileOptions::default())
5649 .unwrap();
5650 writer.write_all(b"<rdf:RDF/>").unwrap();
5651 let bytes = writer.finish().unwrap().into_inner();
5652 let source = Source::from_memory("unsafe.zip", bytes).unwrap();
5653 assert!(parse_source(&source, &mut Diagnostics::new()).is_err());
5654 }
5655
5656 #[test]
5657 fn duplicate_xml_names_across_archives_are_refused() {
5658 let archive = || {
5659 let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
5660 writer
5661 .start_file("profiles/EQ.xml", zip::write::SimpleFileOptions::default())
5662 .unwrap();
5663 writer.write_all(b"<rdf:RDF/>").unwrap();
5664 writer.finish().unwrap().into_inner()
5665 };
5666 let directory = tempfile::tempdir().unwrap();
5667 std::fs::write(directory.path().join("first.zip"), archive()).unwrap();
5668 std::fs::write(directory.path().join("second.zip"), archive()).unwrap();
5669 let source = Source::open(directory.path()).unwrap();
5670 let error = acquire_documents(&source).unwrap_err();
5671 assert!(error.to_string().contains("duplicate normalized name"));
5672 }
5673
5674 #[test]
5675 fn renamed_nested_archives_and_fractional_ratio_overflow_are_refused() {
5676 let mut nested = zip::ZipWriter::new(Cursor::new(Vec::new()));
5677 nested
5678 .start_file("EQ.xml", zip::write::SimpleFileOptions::default())
5679 .unwrap();
5680 nested.write_all(b"<rdf:RDF/>").unwrap();
5681 let nested = nested.finish().unwrap().into_inner();
5682
5683 let mut outer = zip::ZipWriter::new(Cursor::new(Vec::new()));
5684 outer
5685 .start_file("renamed.xml", zip::write::SimpleFileOptions::default())
5686 .unwrap();
5687 outer.write_all(&nested).unwrap();
5688 let bytes = outer.finish().unwrap().into_inner();
5689 let source = Source::from_memory("nested.zip", bytes).unwrap();
5690 let error = acquire_documents(&source).unwrap_err();
5691 assert!(error.to_string().contains("nested archive"));
5692
5693 assert!(!exceeds_compression_ratio(20_000, 100));
5694 assert!(exceeds_compression_ratio(20_001, 100));
5695 assert!(exceeds_compression_ratio(1, 0));
5696 }
5697
5698 #[test]
5699 fn xml_declarations_that_enable_entities_are_refused() {
5700 for xml in [
5701 "<!DOCTYPE rdf:RDF SYSTEM \"file:///etc/passwd\"><rdf:RDF/>",
5702 "<!DOCTYPE rdf:RDF [<!ENTITY x \"expanded\">]><rdf:RDF>&x;</rdf:RDF>",
5703 ] {
5704 assert!(reject_unsafe_xml(xml.as_bytes()).is_err());
5705 }
5706 }
5707
5708 #[test]
5709 fn cim16_profile_set_parses() {
5710 let output = write::write_cgmes(&network(), CgmesVersion::V2_4_15).unwrap();
5711 let parsed = read::read_cgmes_documents(output.files, Some("cim16")).unwrap();
5712 assert_eq!(parsed.network.source_format(), crate::SourceFormat::Cgmes);
5713 assert_eq!(parsed.network.buses().len(), 2);
5714 }
5715
5716 #[test]
5717 fn topological_nodes_require_an_exact_positive_base_voltage() {
5718 let files = write::write_cgmes(&network(), CgmesVersion::V3_0)
5719 .unwrap()
5720 .files;
5721
5722 let without_reference = files
5723 .iter()
5724 .cloned()
5725 .map(|(name, text)| {
5726 if !name.ends_with("_TP.xml") {
5727 return (name, text);
5728 }
5729 let line = text
5730 .lines()
5731 .find(|line| line.contains("<cim:TopologicalNode.BaseVoltage "))
5732 .unwrap();
5733 (name, text.replacen(&format!("{line}\n"), "", 1))
5734 })
5735 .collect();
5736 let error = read::read_cgmes_documents(without_reference, Some("missing-base-reference"))
5737 .err()
5738 .unwrap();
5739 assert!(
5740 error
5741 .to_string()
5742 .contains("has no TopologicalNode.BaseVoltage reference")
5743 );
5744
5745 for replacement in [None, Some("0"), Some("-230")] {
5746 let changed = files
5747 .iter()
5748 .cloned()
5749 .map(|(name, text)| {
5750 if !name.ends_with("_EQ.xml") {
5751 return (name, text);
5752 }
5753 let line = text
5754 .lines()
5755 .find(|line| line.contains("<cim:BaseVoltage.nominalVoltage>"))
5756 .unwrap();
5757 let new_line = replacement.map(|replacement| {
5758 let value_start = line.find('>').unwrap() + 1;
5759 let value_end = line.rfind('<').unwrap();
5760 format!(
5761 "{}{replacement}{}",
5762 &line[..value_start],
5763 &line[value_end..]
5764 )
5765 });
5766 let text = new_line.map_or_else(
5767 || text.replacen(&format!("{line}\n"), "", 1),
5768 |new_line| text.replacen(line, &new_line, 1),
5769 );
5770 (name, text)
5771 })
5772 .collect();
5773 let error = read::read_cgmes_documents(changed, Some("invalid-base-voltage"))
5774 .err()
5775 .unwrap();
5776 let message = error.to_string();
5777 if let Some(replacement) = replacement {
5778 assert!(message.contains("nonpositive nominal voltage"));
5779 assert!(message.contains(replacement));
5780 } else {
5781 assert!(message.contains("without BaseVoltage.nominalVoltage"));
5782 }
5783 }
5784 }
5785
5786 #[test]
5787 fn sv_power_flow_supplies_missing_or_partial_ssh_assignments() {
5788 let mut network = detailed_network();
5789 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
5790 let load_terminal = detailed
5791 .terminals
5792 .iter_mut()
5793 .find(|terminal| terminal.equipment.component_type() == "load")
5794 .unwrap();
5795 load_terminal.active_power_mw = Some(21.0);
5796 load_terminal.reactive_power_mvar = Some(6.0);
5797 let files = write::write_cgmes(&network, CgmesVersion::V2_4_15)
5798 .unwrap()
5799 .files;
5800
5801 let without_ssh = files
5802 .iter()
5803 .filter(|(name, _)| !name.ends_with("_SSH.xml"))
5804 .cloned()
5805 .collect();
5806 let parsed = read::read_cgmes_documents(without_ssh, Some("sv-only")).unwrap();
5807 assert!((parsed.network.loads()[0].p - 21.0).abs() < 1e-12);
5808 assert!((parsed.network.loads()[0].q - 6.0).abs() < 1e-12);
5809 assert!(parsed.warnings.iter().any(|warning| {
5810 warning.contains("has no SSH p or q assignment")
5811 && warning.contains("p=21 MW")
5812 && warning.contains("q=6 MVAr")
5813 }));
5814
5815 let partial_ssh = files
5816 .into_iter()
5817 .map(|(name, text)| {
5818 if !name.ends_with("_SSH.xml") {
5819 return (name, text);
5820 }
5821 let line = text
5822 .lines()
5823 .find(|line| line.contains("<cim:EnergyConsumer.q>"))
5824 .unwrap();
5825 (name, text.replacen(&format!("{line}\n"), "", 1))
5826 })
5827 .collect();
5828 let parsed = read::read_cgmes_documents(partial_ssh, Some("partial-ssh")).unwrap();
5829 assert!((parsed.network.loads()[0].p - 20.0).abs() < 1e-12);
5830 assert!((parsed.network.loads()[0].q - 6.0).abs() < 1e-12);
5831 assert!(parsed.warnings.iter().any(|warning| {
5832 warning.contains("has SSH p=20 MW but no SSH q assignment")
5833 && warning.contains("used q=6 MVAr")
5834 }));
5835 }
5836
5837 #[test]
5838 fn fresh_cgmes_preserves_absent_xiidm_assignments() {
5839 let mut network = detailed_network();
5840 let mut shunt = Shunt::new(BusId(2), 0.0, -0.01);
5841 shunt.uid = Some("omitted-shunt".into());
5842 network.shunts_mut().push(shunt);
5843 let load = component("load", network.loads()[0].uid.as_deref().unwrap());
5844 let generator = component("generator", network.generators()[0].uid.as_deref().unwrap());
5845 let shunt = component("shunt", "omitted-shunt");
5846 {
5847 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
5848 detailed.terminals.push(Terminal {
5849 component: None,
5850 equipment: shunt.clone(),
5851 terminal: 1,
5852 voltage_level: detailed.voltage_levels[0].component.clone(),
5853 bus: Some(detailed.bus_breaker_buses[1].component.clone()),
5854 connectable_bus: Some(detailed.bus_breaker_buses[1].component.clone()),
5855 node: Some(detailed.connectivity_nodes[1].component.clone()),
5856 connected: true,
5857 active_power_mw: None,
5858 reactive_power_mvar: None,
5859 });
5860 }
5861
5862 let explicit = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
5863 let explicit_xml = explicit
5864 .files
5865 .iter()
5866 .map(|(_, text)| text.as_str())
5867 .collect::<String>();
5868 for property in [
5869 "EnergyConsumer.p",
5870 "EnergyConsumer.q",
5871 "RotatingMachine.p",
5872 "RotatingMachine.q",
5873 "RegulatingControl.targetValue",
5874 "LinearShuntCompensator.gPerSection",
5875 ] {
5876 assert!(explicit_xml.contains(&format!("<cim:{property}>")));
5877 }
5878
5879 Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap()).omitted_fields = vec![
5880 OmittedField::new(load.clone(), OmittedFieldName::ActivePower),
5881 OmittedField::new(load, OmittedFieldName::ReactivePower),
5882 OmittedField::new(generator.clone(), OmittedFieldName::ActivePower),
5883 OmittedField::new(generator.clone(), OmittedFieldName::ReactivePower),
5884 OmittedField::new(generator, OmittedFieldName::VoltageSetpoint),
5885 OmittedField::new(shunt, OmittedFieldName::ShuntConductancePerSection),
5886 ];
5887 let omitted = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
5888 let omitted_xml = omitted
5889 .files
5890 .iter()
5891 .map(|(_, text)| text.as_str())
5892 .collect::<String>();
5893 for property in [
5894 "EnergyConsumer.p",
5895 "EnergyConsumer.q",
5896 "RotatingMachine.p",
5897 "RotatingMachine.q",
5898 "RegulatingControl.targetValue",
5899 "LinearShuntCompensator.gPerSection",
5900 ] {
5901 assert!(!omitted_xml.contains(&format!("<cim:{property}>")));
5902 }
5903 assert!(
5904 omitted
5905 .warnings
5906 .iter()
5907 .all(|warning| !warning.contains("omitted field record")),
5908 "supported omission records must be consumed by the writer"
5909 );
5910 }
5911
5912 #[test]
5913 #[allow(clippy::too_many_lines)] fn fresh_output_diagnoses_boundary_case_and_metadata_projection() {
5915 let mut network = detailed_network();
5916 network.case_metadata_mut().forecast_distance = Some(2);
5917 network.case_metadata_mut().source_model_format = Some("XIIDM".into());
5918 network.case_metadata_mut().minimum_validation_level =
5919 Some("STEADY_STATE_HYPOTHESIS".into());
5920
5921 let load = component("load", network.loads()[0].uid.as_deref().unwrap());
5922 let generator = component("generator", network.generators()[0].uid.as_deref().unwrap());
5923 let branch = component("branch", network.branches()[0].uid.as_deref().unwrap());
5924 let first_boundary = component("boundary_line", "boundary-a");
5925 let second_boundary = component("boundary_line", "boundary-b");
5926 let detailed = Arc::make_mut(network.detailed_connectivity_mut().as_mut().unwrap());
5927 detailed.connectivity_nodes[0].node_number = Some(17);
5928 detailed.boundary_lines = vec![
5929 BoundaryLine {
5930 component: first_boundary.clone(),
5931 voltage_level: detailed.voltage_levels[0].component.clone(),
5932 active_power_setpoint_mw: 10.0,
5933 reactive_power_setpoint_mvar: 2.0,
5934 resistance_ohm: 0.1,
5935 reactance_ohm: 1.0,
5936 conductance_siemens: 0.0,
5937 susceptance_siemens: 0.0,
5938 pairing_key: Some("pair-a".into()),
5939 generation: None,
5940 calculation_load: Some(load.clone()),
5941 calculation_generator: Some(generator.clone()),
5942 },
5943 BoundaryLine {
5944 component: second_boundary.clone(),
5945 voltage_level: detailed.voltage_levels[0].component.clone(),
5946 active_power_setpoint_mw: -10.0,
5947 reactive_power_setpoint_mvar: -2.0,
5948 resistance_ohm: 0.1,
5949 reactance_ohm: 1.0,
5950 conductance_siemens: 0.0,
5951 susceptance_siemens: 0.0,
5952 pairing_key: Some("pair-a".into()),
5953 generation: None,
5954 calculation_load: None,
5955 calculation_generator: None,
5956 },
5957 ];
5958 detailed.tie_lines.push(TieLine {
5959 component: component("tie_line", "tie-a"),
5960 boundary_line1: first_boundary,
5961 boundary_line2: second_boundary,
5962 calculation_branch: Some(branch.clone()),
5963 });
5964 detailed.subnetworks.push(Subnetwork {
5965 component: component("subnetwork", "child-a"),
5966 parent: component("network", "root"),
5967 case_metadata: CaseMetadata {
5968 case_date: Some("2025-01-02T03:04:05Z".into()),
5969 forecast_distance: Some(1),
5970 source_model_format: Some("XIIDM".into()),
5971 minimum_validation_level: Some("EQUIPMENT".into()),
5972 },
5973 components: vec![load.clone(), generator],
5974 });
5975 detailed
5976 .component_metadata
5977 .iter_mut()
5978 .find(|metadata| metadata.component.component_type() == "voltage_level")
5979 .unwrap()
5980 .properties
5981 .insert("vendor.unmappedProperty".into(), "retained".into());
5982 detailed
5983 .operational_limit_groups
5984 .push(OperationalLimitGroup {
5985 equipment: branch,
5986 terminal: 1,
5987 id: "limit-group-with-metadata".into(),
5988 properties: std::collections::BTreeMap::from([(
5989 "vendor.limitProperty".into(),
5990 "retained".into(),
5991 )]),
5992 selected: false,
5993 current_limits: None,
5994 active_power_limits: None,
5995 apparent_power_limits: None,
5996 });
5997 detailed
5998 .omitted_fields
5999 .push(OmittedField::new(load, OmittedFieldName::VoltageSetpoint));
6000
6001 let output = write::write_cgmes(&network, CgmesVersion::V3_0).unwrap();
6002 let has = |code: &str, text: &str| {
6003 output
6004 .warnings
6005 .iter()
6006 .any(|warning| warning.info.code == code && warning.contains(text))
6007 };
6008 assert!(has(
6009 "EMIT.CGMES.RECORD_DROPPED",
6010 "BoundaryLine `boundary_line/boundary-a`"
6011 ));
6012 assert!(has("EMIT.CGMES.RECORD_DROPPED", "TieLine `tie_line/tie-a`"));
6013 assert!(has("EMIT.CGMES.FIELD_DROPPED", "source node number 17"));
6014 assert!(has(
6015 "EMIT.CGMES.VALUE_COLLAPSED",
6016 "subnetwork `subnetwork/child-a`"
6017 ));
6018 assert!(has("EMIT.CGMES.FIELD_DROPPED", "forecast_distance=2"));
6019 assert!(has(
6020 "EMIT.CGMES.FIELD_DROPPED",
6021 "source_model_format=`XIIDM`"
6022 ));
6023 assert!(has(
6024 "EMIT.CGMES.FIELD_DROPPED",
6025 "minimum_validation_level=`STEADY_STATE_HYPOTHESIS`"
6026 ));
6027 assert!(has("EMIT.CGMES.FIELD_DROPPED", "vendor.unmappedProperty"));
6028 assert!(has("EMIT.CGMES.FIELD_DROPPED", "vendor.limitProperty"));
6029 assert!(has("EMIT.CGMES.FIELD_DROPPED", "field `voltage_setpoint`"));
6030 }
6031
6032 #[test]
6033 fn dc_projection_fields_receive_exact_emission_diagnostics() {
6034 let network = network();
6035 let mut detailed = DetailedConnectivity::default();
6036 let dc_node = component("dc_node", "dc-node-with-voltage");
6037 detailed.dc_nodes.push(DcNode {
6038 component: dc_node.clone(),
6039 nominal_voltage_kv: Some(320.0),
6040 dc_converter_unit: None,
6041 dc_topological_node: None,
6042 voltage_kv: Some(318.0),
6043 });
6044 let dc_terminal = DcTerminal {
6045 component: Some(component("dc_terminal", "ground-terminal")),
6046 sequence_number: Some(1),
6047 dc_node: Some(dc_node),
6048 dc_topological_node: None,
6049 polarity: Some(DcPolarity::Positive),
6050 connected: Some(true),
6051 active_power_mw: None,
6052 current_a: None,
6053 };
6054 detailed.dc_grounds.push(DcGround {
6055 component: component("dc_ground", "ground-with-polarity"),
6056 equipment_container: None,
6057 dc_terminal: dc_terminal.clone(),
6058 rated_dc_voltage_kv: None,
6059 resistance_ohm: None,
6060 inductance_h: None,
6061 });
6062 detailed
6063 .voltage_source_converters
6064 .push(VoltageSourceConverter {
6065 component: component("voltage_source_converter", "vsc-conflicting-voltage"),
6066 dc_converter_unit: None,
6067 dc_terminal1: dc_terminal.clone(),
6068 dc_terminal2: dc_terminal,
6069 base_apparent_power_mva: None,
6070 minimum_active_power_mw: None,
6071 maximum_active_power_mw: None,
6072 minimum_dc_voltage_kv: None,
6073 maximum_dc_voltage_kv: None,
6074 rated_dc_voltage_kv: None,
6075 valve_u0_kv: None,
6076 number_of_valves: None,
6077 idle_loss_mw: None,
6078 switching_loss_mw_per_ampere: None,
6079 resistive_loss_ohm: None,
6080 control_mode: None,
6081 active_power_at_pcc_mw: None,
6082 reactive_power_at_pcc_mvar: None,
6083 target_active_power_mw: None,
6084 target_dc_voltage_kv: None,
6085 pcc_terminal: None,
6086 droop_curve: None,
6087 droop: None,
6088 droop_compensation: None,
6089 q_share: None,
6090 maximum_modulation_index: None,
6091 maximum_valve_current_a: None,
6092 voltage_regulator_on: None,
6093 voltage_setpoint_kv: None,
6094 reactive_power_setpoint_mvar: None,
6095 reactive_limits: None,
6096 pole_loss_active_power_mw: None,
6097 dc_current_a: None,
6098 ac_voltage_kv: None,
6099 dc_voltage_kv: None,
6100 delta_degrees: None,
6101 uf_kv: Some(229.0),
6102 uv_kv: Some(231.0),
6103 });
6104 let mut warnings =
6105 CgmesDiagnostics::new(&crate::diagnostics::codes::EMIT_CGMES.record_dropped);
6106 write::warn_unemitted_detailed_fields(
6107 &network,
6108 &detailed,
6109 CgmesVersion::V3_0,
6110 &mut warnings,
6111 );
6112
6113 let has = |code: &str, text: &str| {
6114 warnings
6115 .iter()
6116 .any(|warning| warning.info.code == code && warning.contains(text))
6117 };
6118 assert!(has("EMIT.CGMES.FIELD_DROPPED", "nominal_voltage_kv=320"));
6119 assert!(has("EMIT.CGMES.FIELD_DROPPED", "voltage_kv=318"));
6120 assert!(has("EMIT.CGMES.FIELD_DROPPED", "polarity `positive`"));
6121 assert!(has(
6122 "EMIT.CGMES.VALUE_SUBSTITUTED",
6123 "uf=229 kV and uv=231 kV"
6124 ));
6125 assert!(has(
6126 "EMIT.CGMES.VALUE_SUBSTITUTED",
6127 "writes VsConverter.uv=231 kV"
6128 ));
6129 }
6130
6131 #[test]
6132 fn cim16_ground_voltage_uses_the_units_unique_converter_voltage() {
6133 let parsed = read::read_cgmes_documents(
6134 dc_ground_documents(CgmesVersion::V2_4_15, &[320.0, 320.0], None),
6135 Some("cim16-dc-ground"),
6136 )
6137 .unwrap();
6138 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
6139 assert_eq!(detailed.dc_grounds.len(), 1);
6140 assert_eq!(detailed.dc_grounds[0].rated_dc_voltage_kv, Some(320.0));
6141 assert!(parsed.warnings.iter().any(|warning| {
6142 warning.contains("DCGround `dc-ground`")
6143 && warning.contains("DCConverterUnit `dc-unit`")
6144 && warning.contains("derived 320 kV")
6145 && warning.contains("unique positive ACDCConverter.ratedUdc")
6146 }));
6147 }
6148
6149 #[test]
6150 fn cim16_ground_voltage_rejects_missing_or_conflicting_converter_values() {
6151 let missing = read::read_cgmes_documents(
6152 dc_ground_documents(CgmesVersion::V2_4_15, &[], None),
6153 Some("cim16-dc-ground-missing"),
6154 )
6155 .err()
6156 .unwrap();
6157 assert!(missing.to_string().contains("DCGround `dc-ground`"));
6158 assert!(missing.to_string().contains("DCConverterUnit `dc-unit`"));
6159 assert!(
6160 missing
6161 .to_string()
6162 .contains("no explicit positive ACDCConverter.ratedUdc")
6163 );
6164
6165 let conflicting = read::read_cgmes_documents(
6166 dc_ground_documents(CgmesVersion::V2_4_15, &[320.0, 400.0], None),
6167 Some("cim16-dc-ground-conflicting"),
6168 )
6169 .err()
6170 .unwrap();
6171 assert!(conflicting.to_string().contains("DCGround `dc-ground`"));
6172 assert!(
6173 conflicting
6174 .to_string()
6175 .contains("DCConverterUnit `dc-unit`")
6176 );
6177 assert!(
6178 conflicting
6179 .to_string()
6180 .contains("conflicting ACDCConverter.ratedUdc values: 320, 400")
6181 );
6182 }
6183
6184 #[test]
6185 fn cim100_ground_voltage_is_not_derived_from_converter_voltage() {
6186 let parsed = read::read_cgmes_documents(
6187 dc_ground_documents(CgmesVersion::V3_0, &[320.0], None),
6188 Some("cim100-dc-ground"),
6189 )
6190 .unwrap();
6191 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
6192 assert_eq!(detailed.dc_grounds.len(), 1);
6193 assert_eq!(detailed.dc_grounds[0].rated_dc_voltage_kv, None);
6194 assert!(!parsed.warnings.iter().any(|warning| {
6195 warning.contains("DCGround `dc-ground`") && warning.contains("derived")
6196 }));
6197 }
6198
6199 #[test]
6200 fn cim16_dc_line_voltage_uses_matching_endpoint_unit_converter_voltages() {
6201 let parsed = read::read_cgmes_documents(
6202 dc_line_documents(CgmesVersion::V2_4_15, &[320.0], &[320.0, 320.0], None),
6203 Some("cim16-dc-line"),
6204 )
6205 .unwrap();
6206 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
6207 assert_eq!(detailed.dc_lines.len(), 1);
6208 assert_eq!(detailed.dc_lines[0].rated_dc_voltage_kv, Some(320.0));
6209 assert!(parsed.warnings.iter().any(|warning| {
6210 warning.contains("DCLineSegment `dc-line`")
6211 && warning.contains("first-dc-unit")
6212 && warning.contains("second-dc-unit")
6213 && warning.contains("derived 320 kV")
6214 && warning.contains("both units have the same unique positive")
6215 }));
6216
6217 let source_dc = parsed
6218 .network
6219 .detailed_connectivity()
6220 .as_deref()
6221 .unwrap()
6222 .clone();
6223 let mut emitted_network = detailed_network();
6224 let emitted_dc = Arc::make_mut(
6225 emitted_network
6226 .detailed_connectivity_mut()
6227 .as_mut()
6228 .unwrap(),
6229 );
6230 emitted_dc
6231 .component_metadata
6232 .extend(source_dc.component_metadata.clone());
6233 emitted_dc.dc_converter_units = source_dc.dc_converter_units;
6234 emitted_dc.dc_topological_nodes = source_dc.dc_topological_nodes;
6235 emitted_dc.dc_nodes = source_dc.dc_nodes;
6236 emitted_dc.dc_lines = source_dc.dc_lines;
6237 let output = write::write_cgmes(&emitted_network, CgmesVersion::V3_0).unwrap();
6238 let eq = output
6239 .files
6240 .iter()
6241 .find(|(name, _)| name.ends_with("_EQ.xml"))
6242 .map(|(_, text)| text)
6243 .unwrap();
6244 assert!(eq.contains("<cim:DCConductingEquipment.ratedUdc>320"));
6245 let reparsed = read::read_cgmes_documents(output.files, Some("cim100-dc-line")).unwrap();
6246 assert_eq!(
6247 reparsed
6248 .network
6249 .detailed_connectivity()
6250 .as_deref()
6251 .unwrap()
6252 .dc_lines[0]
6253 .rated_dc_voltage_kv,
6254 Some(320.0)
6255 );
6256 }
6257
6258 #[test]
6259 fn cim16_dc_line_voltage_rejects_missing_or_conflicting_endpoint_values() {
6260 let missing = read::read_cgmes_documents(
6261 dc_line_documents(CgmesVersion::V2_4_15, &[], &[320.0], None),
6262 Some("cim16-dc-line-missing"),
6263 )
6264 .err()
6265 .unwrap();
6266 assert!(missing.to_string().contains("DCLineSegment `dc-line`"));
6267 assert!(missing.to_string().contains("first-dc-unit"));
6268 assert!(
6269 missing
6270 .to_string()
6271 .contains("no explicit positive ACDCConverter.ratedUdc")
6272 );
6273
6274 let conflicting = read::read_cgmes_documents(
6275 dc_line_documents(CgmesVersion::V2_4_15, &[320.0], &[400.0], None),
6276 Some("cim16-dc-line-conflicting"),
6277 )
6278 .err()
6279 .unwrap();
6280 let message = conflicting.to_string();
6281 assert!(message.contains("DCLineSegment `dc-line`"));
6282 assert!(message.contains("first-dc-unit"));
6283 assert!(message.contains("320 kV"));
6284 assert!(message.contains("second-dc-unit"));
6285 assert!(message.contains("400 kV"));
6286 }
6287
6288 #[test]
6289 fn cim100_dc_line_voltage_is_not_derived_from_endpoint_converter_voltages() {
6290 let parsed = read::read_cgmes_documents(
6291 dc_line_documents(CgmesVersion::V3_0, &[320.0], &[320.0], None),
6292 Some("cim100-dc-line"),
6293 )
6294 .unwrap();
6295 let detailed = parsed.network.detailed_connectivity().as_deref().unwrap();
6296 assert_eq!(detailed.dc_lines.len(), 1);
6297 assert_eq!(detailed.dc_lines[0].rated_dc_voltage_kv, None);
6298 assert!(!parsed.warnings.iter().any(|warning| {
6299 warning.contains("DCLineSegment `dc-line`") && warning.contains("derived")
6300 }));
6301 }
6302
6303 fn strip_connectivity_nodes(text: &str) -> String {
6306 let mut stripped = String::new();
6307 let mut inside_node = false;
6308 for line in text.lines() {
6309 if line.contains("<cim:ConnectivityNode rdf:ID=") {
6310 inside_node = true;
6311 }
6312 if !inside_node && !line.contains("Terminal.ConnectivityNode") {
6313 stripped.push_str(line);
6314 stripped.push('\n');
6315 }
6316 if line.contains("</cim:ConnectivityNode>") {
6317 inside_node = false;
6318 }
6319 }
6320 stripped
6321 }
6322
6323 #[test]
6324 fn missing_profiles_malformed_xml_and_dangling_references_are_refused() {
6325 let output = write::write_cgmes(&network(), CgmesVersion::V3_0).unwrap();
6326 let eq_only: Vec<(String, String)> = output
6330 .files
6331 .iter()
6332 .filter(|(name, _)| name.ends_with("_EQ.xml"))
6333 .cloned()
6334 .collect();
6335 let calculated = read::read_cgmes_documents(eq_only.clone(), Some("missing-tp")).unwrap();
6336 assert_eq!(calculated.network.buses().len(), 2);
6337 assert!(calculated.warnings.iter().any(|warning| {
6338 warning.info.code == crate::diagnostics::codes::READ_CGMES_TOPOLOGY_CALCULATED.code
6339 }));
6340 let without_nodes = eq_only
6341 .into_iter()
6342 .map(|(name, text)| (name, strip_connectivity_nodes(&text)))
6343 .collect();
6344 let error = read::read_cgmes_documents(without_nodes, Some("missing-tp"))
6345 .err()
6346 .unwrap()
6347 .to_string();
6348 assert!(
6349 error.contains("the set declares no TP profile data"),
6350 "{error}"
6351 );
6352
6353 assert!(xml::parse_cimxml("<rdf:RDF><cim:TopologicalNode>").is_err());
6354
6355 let dangling = output
6356 .files
6357 .into_iter()
6358 .map(|(name, text)| {
6359 if name.ends_with("_TP.xml") {
6360 (
6361 name,
6362 text.replacen("rdf:resource=\"#_", "rdf:resource=\"#_missing-", 1),
6363 )
6364 } else {
6365 (name, text)
6366 }
6367 })
6368 .collect();
6369 assert!(read::read_cgmes_documents(dangling, Some("dangling")).is_err());
6370
6371 let duplicate = write::write_cgmes(&network(), CgmesVersion::V3_0)
6372 .unwrap()
6373 .files
6374 .into_iter()
6375 .map(|(name, text)| {
6376 if name.ends_with("_EQ.xml") {
6377 (
6378 name,
6379 text.replace(
6380 "</rdf:RDF>",
6381 " <cim:Junction rdf:ID=\"_duplicate\"><cim:IdentifiedObject.name>first</cim:IdentifiedObject.name></cim:Junction>\n <cim:Junction rdf:ID=\"_duplicate\"><cim:IdentifiedObject.name>second</cim:IdentifiedObject.name></cim:Junction>\n</rdf:RDF>",
6382 ),
6383 )
6384 } else {
6385 (name, text)
6386 }
6387 })
6388 .collect();
6389 assert!(read::read_cgmes_documents(duplicate, Some("duplicate")).is_err());
6390 }
6391
6392 const NODE_BREAKER_EQ: &str =
6393 include_str!("../../../../tests/data/cgmes/node-breaker/NodeBreaker_EQ.xml");
6394 const NODE_BREAKER_SSH: &str =
6395 include_str!("../../../../tests/data/cgmes/node-breaker/NodeBreaker_SSH.xml");
6396
6397 fn node_breaker_documents() -> Vec<(String, String)> {
6398 vec![
6399 (
6400 "NodeBreaker_EQ.xml".to_string(),
6401 NODE_BREAKER_EQ.to_string(),
6402 ),
6403 (
6404 "NodeBreaker_SSH.xml".to_string(),
6405 NODE_BREAKER_SSH.to_string(),
6406 ),
6407 ]
6408 }
6409
6410 fn bus_named<'a>(network: &'a BalancedNetwork, name: &str) -> &'a Bus {
6411 network
6412 .buses()
6413 .iter()
6414 .find(|bus| bus.name.as_deref() == Some(name))
6415 .unwrap_or_else(|| panic!("no bus named {name}"))
6416 }
6417
6418 fn load_with_uid<'a>(network: &'a BalancedNetwork, uid: &str) -> &'a Load {
6419 network
6420 .loads()
6421 .iter()
6422 .find(|load| load.uid.as_deref() == Some(uid))
6423 .unwrap_or_else(|| panic!("no load with the requested identifier"))
6424 }
6425
6426 #[test]
6427 fn node_breaker_set_without_tp_calculates_buses_from_switch_positions() {
6428 let parsed =
6429 read::read_cgmes_documents(node_breaker_documents(), Some("node-breaker")).unwrap();
6430 let network = &parsed.network;
6431 assert_eq!(network.buses().len(), 3);
6432 let bb1 = bus_named(network, "BB1");
6433 let n3 = bus_named(network, "N3");
6434 let bb2 = bus_named(network, "BB2");
6435 assert_eq!(bb1.kind, BusType::Ref);
6436 assert!(
6437 network
6438 .buses()
6439 .iter()
6440 .all(|bus| (bus.base_kv - 110.0).abs() < 1e-12)
6441 );
6442
6443 assert_eq!(
6444 load_with_uid(network, "ec000000-0000-4000-8000-000000000001").bus,
6445 bb1.id
6446 );
6447 assert_eq!(
6448 load_with_uid(network, "ec000000-0000-4000-8000-000000000002").bus,
6449 n3.id
6450 );
6451 let disconnected = load_with_uid(network, "ec000000-0000-4000-8000-000000000003");
6452 assert_eq!(disconnected.bus, bb2.id);
6453 assert!(!disconnected.in_service);
6454 assert_eq!(network.generators()[0].bus, bb1.id);
6455 assert_eq!(
6456 (network.branches()[0].from, network.branches()[0].to),
6457 (bb1.id, bb2.id)
6458 );
6459 assert_eq!(network.switches().len(), 1);
6460 let open = &network.switches()[0];
6461 assert_eq!((open.from, open.to, open.closed), (bb1.id, n3.id, false));
6462
6463 for bus in network.buses() {
6466 let uid = bus.uid.as_deref().unwrap();
6467 assert!(uuid::Uuid::parse_str(uid).is_ok());
6468 assert!(!uid.starts_with("c0000000"));
6469 }
6470 let again =
6471 read::read_cgmes_documents(node_breaker_documents(), Some("node-breaker")).unwrap();
6472 let uids = |network: &BalancedNetwork| {
6473 network
6474 .buses()
6475 .iter()
6476 .map(|bus| bus.uid.clone())
6477 .collect::<Vec<_>>()
6478 };
6479 assert_eq!(uids(network), uids(&again.network));
6480
6481 let detailed = network.detailed_connectivity().as_deref().unwrap();
6482 assert!(detailed.bus_breaker_buses.is_empty());
6483 assert_eq!(detailed.calculated_buses.len(), 3);
6484 let joined = detailed
6485 .calculated_buses
6486 .iter()
6487 .find(|bus| bus.calculated_bus == bb1.id)
6488 .unwrap();
6489 assert_eq!(joined.nodes.len(), 2);
6491 assert_eq!(
6492 joined.voltage_level.local_id(),
6493 "a1000000-0000-4000-8000-000000000001"
6494 );
6495 assert!(
6496 detailed
6497 .voltage_levels
6498 .iter()
6499 .all(|level| level.topology_kind == TopologyKind::NodeBreaker)
6500 );
6501 let vl1 = detailed
6502 .voltage_levels
6503 .iter()
6504 .find(|level| level.component.local_id() == "a1000000-0000-4000-8000-000000000001")
6505 .unwrap();
6506 assert_eq!(vl1.buses, vec![bb1.id, n3.id]);
6507 assert!(parsed.warnings.iter().any(|warning| {
6508 warning.info.code == crate::diagnostics::codes::READ_CGMES_TOPOLOGY_CALCULATED.code
6509 && warning.contains(
6510 "3 calculated bus(es) joined 5 ConnectivityNode(s) through 2 closed switch(es); 1 open switch(es)",
6511 )
6512 }));
6513 }
6514
6515 #[test]
6516 fn node_breaker_ssh_switch_position_closes_the_disconnector() {
6517 let closed = node_breaker_documents()
6518 .into_iter()
6519 .map(|(name, text)| {
6520 if name.ends_with("_SSH.xml") {
6521 (
6522 name,
6523 text.replace(
6524 "<cim:Switch.open>true</cim:Switch.open>",
6525 "<cim:Switch.open>false</cim:Switch.open>",
6526 ),
6527 )
6528 } else {
6529 (name, text)
6530 }
6531 })
6532 .collect();
6533 let parsed = read::read_cgmes_documents(closed, Some("closed")).unwrap();
6534 assert_eq!(parsed.network.buses().len(), 2);
6535 assert!(parsed.network.switches().is_empty());
6536 let bb1 = bus_named(&parsed.network, "BB1").id;
6537 assert_eq!(
6538 parsed
6539 .network
6540 .loads()
6541 .iter()
6542 .filter(|load| load.bus == bb1)
6543 .count(),
6544 2
6545 );
6546 }
6547
6548 #[test]
6549 fn node_breaker_set_without_connectivity_is_refused_with_the_missing_data_named() {
6550 let bus_branch = node_breaker_documents()
6551 .into_iter()
6552 .map(|(name, text)| {
6553 (
6554 name,
6555 text.replace(
6556 " <md:Model.profile>http://entsoe.eu/CIM/EquipmentOperation/3/1</md:Model.profile>\n",
6557 "",
6558 ),
6559 )
6560 })
6561 .collect();
6562 let mut warnings =
6563 CgmesDiagnostics::new(&crate::diagnostics::codes::READ_CGMES_RECORD_UNMAPPED);
6564 let error = read::read_cgmes_documents_into(bus_branch, Some("bus-branch"), &mut warnings)
6565 .err()
6566 .unwrap()
6567 .to_string();
6568 assert!(error.contains("the set declares no TP profile data"));
6569 assert!(error.contains("bus branch equipment"));
6570 assert!(warnings.iter().any(|warning| {
6571 warning.info.code
6572 == crate::diagnostics::codes::READ_CGMES_CONNECTIVITY_INSUFFICIENT.code
6573 }));
6574
6575 let without_nodes = node_breaker_documents()
6576 .into_iter()
6577 .map(|(name, text)| {
6578 if name.ends_with("_EQ.xml") {
6579 (name, strip_connectivity_nodes(&text))
6580 } else {
6581 (name, text)
6582 }
6583 })
6584 .collect();
6585 let error = read::read_cgmes_documents(without_nodes, Some("no-nodes"))
6586 .err()
6587 .unwrap()
6588 .to_string();
6589 assert!(error.contains("define no ConnectivityNode records"));
6590 }
6591}