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