Skip to main content

powerio_tx/format/cgmes/
mod.rs

1//! CGMES (IEC 61970-600) CIMXML import and emission for
2//! [`BalancedNetwork`](crate::network::BalancedNetwork).
3//!
4//! A CGMES case is a set of RDF/XML instance files, one per profile: EQ
5//! (Equipment), TP (Topology), SSH (Steady State Hypothesis), SV (State
6//! Variables), plus boundary, diagram, geography, and dynamics parts, tied together
7//! by `md:FullModel` headers. The reader takes a directory (or an explicit
8//! file list), classifies each file by its header profile URIs, merges the
9//! object descriptions across files (`rdf:ID` defines, `rdf:about` extends),
10//! and maps equipment, hierarchy, detailed connectivity, and the bus branch
11//! calculation view onto `BalancedNetwork`.
12//!
13//! Both CGMES 2.4.15 (CIM16, `http://iec.ch/TC57/2013/CIM-schema-cim16#`,
14//! ENTSO-E extensions under `entsoe:`) and CGMES 3.0 (CIM100,
15//! `http://iec.ch/TC57/CIM100#`, `eu:` extensions) parse; the version is
16//! detected from the `cim` namespace, tolerating vendor spellings of the CIM16
17//! URI year. Fresh emission writes a deterministic CGMES 3.0 EQ, TP, SSH,
18//! and SV profile set. The shared format dispatcher reads a profile directory,
19//! a directory containing profile ZIP files, or one ZIP archive.
20//!
21//! CGMES has no system MVA base (values are MW/MVAr/kV/ohm); the reader
22//! normalizes onto 100 MVA. The base frequency comes from the EQ
23//! `BaseFrequency` record when present, else 50 Hz (the ENTSO-E default),
24//! reported as an assumption.
25
26mod 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;
43/// Fixed bomb guard on each archive entry's declared expansion; the inflated
44/// stream is then held to the declared size. Real CGMES profiles compress
45/// between 4.6:1 and 28:1.
46const MAX_COMPRESSION_RATIO: u64 = 200;
47/// The variable that sets the expansion budget, named in size refusals.
48const ACQUISITION_LIMIT_VARIABLE: &str = "POWERIO_MAX_REFERENCED_BYTES";
49/// The modeling authority set fresh CGMES output states. A header carrying it
50/// was synthesized by this writer, so its identity, version, creation time,
51/// and profile dependencies state nothing a source document stated.
52const 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
161/// Read the documents, handing every diagnostic to `diagnostics` whether or
162/// not the read succeeds, so a coded refusal reaches the caller with its error.
163fn 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
196/// Hold one CGMES document the content sniff did not recognise, which the
197/// text reader passes to [`parse_text`], to the byte limit every other CGMES
198/// route counts in [`push_xml`].
199pub(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        // The probe reads at most the declared size. An XML entry carries it
333        // into the exact length read below, so a nested archive cannot hide
334        // behind a short declared size; other entries are never inflated.
335        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
369/// Open an archive after bounding its end records and the entry counts they
370/// declare, then refuse entries that disagree with the first count, leave
371/// the entry data, or share bytes.
372///
373/// zip 8.6 examines end records from the last. It reserves its entry table
374/// from the count of the first record it accepts and indexes every central
375/// record before `ZipArchive::new` returns; when that fails, it falls back to
376/// an earlier record and reserves again. So before opening, the number of
377/// end records is bounded ([`MAX_END_RECORDS`]), and so is every Zip64 count
378/// any of them reaches, since a Zip64 count may claim one entry per 47
379/// archive bytes. A 16 bit count reserves at most 65,535 entries, and only
380/// the first is bounded, because an earlier signature may be chance bytes in
381/// compressed data. Duplicate entry names collapse in the reader's index,
382/// which the count comparison also refuses.
383fn 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        // An entry spans its local header through its compressed data.
421        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    // Entries that share compressed bytes expand without growing the archive
438    // (the overlapping entry bomb), so any shared byte is refused.
439    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
461/// Most end records an archive may hold: its end of central directory
462/// signatures, plus the Zip64 end records zip 8.6 reads for their locators.
463/// An honest archive holds one or two, and a four byte signature occurs by
464/// chance in compressed data about once per 4 GiB. Each record can cost the
465/// reader, and the scan in [`declared_entries`], one search of the archive,
466/// so the cap keeps both linear in its size.
467const MAX_END_RECORDS: usize = 8;
468
469/// The entry counts an archive's end records declare.
470#[derive(Debug, PartialEq, Eq)]
471struct DeclaredEntries {
472    /// The count of the end record zip 8.6 examines first: the last end
473    /// record signature whose comment fits in the archive, or the Zip64
474    /// record its locator names when a 16 bit field is saturated.
475    first: u64,
476    /// The largest count of any Zip64 record an end record's locator names,
477    /// zero when there is none.
478    largest_zip64: u64,
479}
480
481/// Why an archive's end records were refused before opening it.
482#[derive(Debug, PartialEq, Eq)]
483enum EndRecordRefusal {
484    /// The reader would accept no end record.
485    Missing,
486    /// The archive holds more than [`MAX_END_RECORDS`] end records.
487    TooMany,
488}
489
490/// What one end record declares.
491enum EndCount {
492    /// The record's own 16 bit count.
493    Zip32(u64),
494    /// The largest count of the Zip64 records its locator names.
495    Zip64(u64),
496}
497
498/// Read every end record zip 8.6 may examine, last first.
499///
500/// These checks are a subset of those the reader applies before it reserves
501/// a table, so it may reject a record accepted here and fall back to an
502/// earlier one. The count comparison in [`open_archive`] then refuses the
503/// archive unless the counts agree, and every Zip64 count the reader could
504/// reach that way is in `largest_zip64`.
505fn declared_entries(bytes: &[u8]) -> std::result::Result<DeclaredEntries, EndRecordRefusal> {
506    // memchr searches forward several times faster than in reverse.
507    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
559/// The largest count among the Zip64 records the reader may accept for the
560/// locator at `locator`, adding to `examined` each record it would read.
561fn 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    // Prepended bytes move the record forward, so the reader searches forward
569    // from the declared offset up to the locator and takes the first
570    // consistent record. The largest count is kept here, so that order never
571    // matters.
572    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        // The reader reads a record whole, extensible data included, once
580        // its stated size fits before the locator.
581        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/// Why an entry's inflated stream was refused.
617#[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        // zip 9.x rejects an inflater that produces more bytes than the
628        // central directory declared before our one-byte overrun probe runs.
629        // Preserve the reader's stable malformed-archive diagnostic instead
630        // of leaking the ZIP implementation's internal error text.
631        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
655/// Inflate exactly the `size` bytes an entry declares, after the `prefix`
656/// already read from it. One more byte is requested to confirm that the
657/// stream ends there, which is also when the reader checks the CRC. A stream
658/// longer or shorter than declared is refused, so the declared size the
659/// ratio and budget checks used is the size produced, and the buffer is
660/// reserved once at that size.
661fn 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/// The CGMES release family a file set declares, from its `cim` namespace.
821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
822#[non_exhaustive]
823pub enum CgmesVersion {
824    /// CGMES 2.4.15 on CIM16 (`.../CIM-schema-cim16#`, any vintage year).
825    V2_4_15,
826    /// CGMES 3.0 on CIM100 (`.../CIM100#`).
827    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)] // the fixture names every linked topology table explicitly
1166    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    /// A CGMES BaseVoltage states a positive nominal voltage and a per-unit
2763    /// only case states none, so the substituted kilovolt is declared. It is
2764    /// the value PowSybl's own MATPOWER importer uses for a bus row with
2765    /// `BASE_KV = 0`, and it returns the same per-unit model because a reader
2766    /// divides by the same nominal voltage the writer multiplied by.
2767    #[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)] // the fixture names every linked DC equipment table explicitly
4294    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 &amp; B &lt;North&gt;"));
6006        assert!(!eq.contains("&amp;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; // CP437 é in both local and central names.
6367        }
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    /// An end of central directory record without a comment.
6397    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    /// An end record whose 16 and 32 bit fields defer to Zip64 records.
6407    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    /// Restate an archive's end of central directory as Zip64 records.
6430    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        // Understating the size in both headers passes the ratio check, so the
6462        // inflated stream itself must stop at the declared size.
6463        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        // At most one byte past the declared size is ever pulled, and an
6490        // honest entry fills a buffer reserved once at its declared size.
6491        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        // Zip64 end records state the same counts.
6526        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        // A forged count is refused before the reader reserves a table for it.
6537        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        // The last end record declares one entry, but no central record
6547        // follows its directory offset, so zip 8.6 rejects it and falls back
6548        // to the earlier Zip64 record, reserving a table from that count.
6549        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        // Signatures inside entry data are read but tolerated up to the cap.
6570        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        // Each saturated end record below sends a search over the archive
6593        // from the offset its locator names, so past the cap none is searched.
6594        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        // Every Zip64 record the reader would read whole counts as well; only
6610        // the last of these fills the room before its locator exactly.
6611        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        // Both directory records name the first local header and its data.
6653        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        // Two directory records under one name collapse in the reader's index.
6667        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        // Hexadecimal noise deflates about two to one, so the archive fits the
6692        // limit while the entry it holds does not.
6693        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)] // the fixture exercises each diagnostic family explicitly
6976    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    /// The document without its ConnectivityNode elements and without the
7366    /// terminal references to them.
7367    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        // A CGMES 3.0 EQ carries ConnectivityNodes, so it reads without TP
7389        // through calculated buses; stripping those nodes leaves nothing to
7390        // calculate from.
7391        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        // Identities derive from the joined ConnectivityNode mRIDs: valid
7526        // UUIDs, never a node's own mRID, and identical on every read.
7527        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        // N2 sits in a Bay, which resolves to VL1 like N1.
7552        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}