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