Skip to main content

surge_io/cgmes/
ext.rs

1// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0
2//! CGMES extension profile reader — SC, DY, GL, TPBD profiles.
3//!
4//! Extends the base CGMES/CIM parser (cim.rs) with additional profile support:
5//! - **SC**   — Short-Circuit: fault parameters (R0, X0, R2, X2)
6//! - **DY**   — Dynamics: dynamic model references (governor, exciter, PSS)
7//! - **GL**   — Geographical Location: substation coordinates
8//! - **TPBD** — Topology Boundary: boundary points for multi-area exchange
9//!
10//! ## Architecture
11//! EQ/SSH profiles are delegated to the existing `cim` module.  The extension
12//! profiles are parsed here with lightweight string-scanning over the RDF/XML.
13//! CGMES RDF/XML is regular enough that targeted tag-matching is reliable and
14//! avoids a full ontology dependency.
15
16use std::collections::HashMap;
17
18use surge_network::Network;
19use thiserror::Error;
20
21// ---------------------------------------------------------------------------
22// Re-export base parser error so callers only need one import.
23// ---------------------------------------------------------------------------
24pub use super::Error as CgmesError;
25
26// ---------------------------------------------------------------------------
27// Error
28// ---------------------------------------------------------------------------
29
30#[derive(Error, Debug)]
31pub enum IoError {
32    #[error("CGMES base parse error: {0}")]
33    Cgmes(#[from] CgmesError),
34    #[error("XML parse error: {0}")]
35    Xml(String),
36    #[error("missing required attribute: {0}")]
37    MissingAttr(String),
38}
39
40// ---------------------------------------------------------------------------
41// Data structures
42// ---------------------------------------------------------------------------
43
44/// Short-circuit parameters from the SC (Short-Circuit) profile.
45///
46/// All impedance values are in per-unit on the network base MVA (100 MVA).
47#[derive(Debug, Clone, Default)]
48pub struct ScProfile {
49    /// Zero-sequence resistance (pu)
50    pub r0_pu: Option<f64>,
51    /// Zero-sequence reactance (pu)
52    pub x0_pu: Option<f64>,
53    /// Negative-sequence resistance (pu)
54    pub r2_pu: Option<f64>,
55    /// Negative-sequence reactance (pu)
56    pub x2_pu: Option<f64>,
57    /// Initial symmetrical short-circuit current (kA)
58    pub ikss_ka: Option<f64>,
59}
60
61/// Dynamic model reference from the DY (Dynamics) profile.
62#[derive(Debug, Clone)]
63pub struct DyProfile {
64    /// MRID of the associated SynchronousMachine or ExternalNetworkInjection
65    pub machine_mrid: String,
66    /// Governor model type, e.g. "GovSteamEU", "GovGAST2"
67    pub governor_type: Option<String>,
68    /// Excitation system type, e.g. "ExcIEEEST1A", "ExcANS"
69    pub exciter_type: Option<String>,
70    /// Power System Stabiliser type, e.g. "Pss2A", "PssSB4"
71    pub pss_type: Option<String>,
72}
73
74/// Geographic coordinate record from the GL (Geographical Location) profile.
75#[derive(Debug, Clone)]
76pub struct GlProfile {
77    /// MRID of the Substation whose location this describes
78    pub substation_mrid: String,
79    /// WGS-84 latitude (degrees)
80    pub latitude: f64,
81    /// WGS-84 longitude (degrees)
82    pub longitude: f64,
83}
84
85/// Boundary point from the TPBD (Topology Boundary) profile.
86#[derive(Debug, Clone)]
87pub struct TpbdProfile {
88    /// MRID of the BoundaryPoint element
89    pub boundary_point_mrid: String,
90    /// MRID of the bus in area A
91    pub bus_a_mrid: String,
92    /// MRID of the bus in area B
93    pub bus_b_mrid: String,
94    /// Nominal voltage of the tie-line (kV)
95    pub voltage_level_kv: f64,
96}
97
98/// CGMES extended dataset combining all profiles.
99pub struct CgmesExtDataset {
100    /// Assembled network from EQ + SSH profiles
101    pub network: Network,
102    /// Short-circuit data keyed by equipment MRID
103    pub sc_data: HashMap<String, ScProfile>,
104    /// Dynamic model references (one per generating unit) — type-name-only summary
105    pub dy_data: Vec<DyProfile>,
106    /// Full dynamic model parsed from the DY profile (None if DY profile absent or parse failed)
107    pub dynamic_model: Option<surge_network::dynamics::DynamicModel>,
108    /// Substation coordinates
109    pub gl_data: Vec<GlProfile>,
110    /// Tie-line boundary points
111    pub tpbd_data: Vec<TpbdProfile>,
112}
113
114// ---------------------------------------------------------------------------
115// Helpers — lightweight XML scanning
116// ---------------------------------------------------------------------------
117
118/// Extract the `rdf:about` or `rdf:ID` attribute value from a tag line.
119///
120/// CGMES RDF/XML marks each described resource with one of:
121/// ```xml
122/// <cim:ACLineSegment rdf:ID="_abc123">
123/// <cim:ACLineSegment rdf:about="#_abc123">
124/// ```
125fn extract_rdf_id(line: &str) -> Option<String> {
126    for attr in &["rdf:ID=\"", "rdf:about=\"", "rdf:ID='", "rdf:about='"] {
127        if let Some(pos) = line.find(attr) {
128            let start = pos + attr.len();
129            let rest = &line[start..];
130            let quote_char = if attr.ends_with('"') { '"' } else { '\'' };
131            if let Some(end) = rest.find(quote_char) {
132                let id = rest[..end].trim_start_matches('#').to_string();
133                if !id.is_empty() {
134                    return Some(id);
135                }
136            }
137        }
138    }
139    None
140}
141
142/// Extract the text content from a simple single-line XML element.
143///
144/// ```text
145/// <cim:ACLineSegment.r0>0.01</cim:ACLineSegment.r0>
146/// ```
147fn extract_text<'a>(line: &'a str, tag: &str) -> Option<&'a str> {
148    let open = format!("<{tag}>");
149    let close = format!("</{tag}>");
150    if let (Some(s), Some(e)) = (line.find(&open), line.find(&close)) {
151        let value_start = s + open.len();
152        if value_start <= e {
153            return Some(line[value_start..e].trim());
154        }
155    }
156    None
157}
158
159/// Parse an f64 from a text node, returning `None` on failure.
160fn parse_f64(s: &str) -> Option<f64> {
161    s.trim().parse::<f64>().ok()
162}
163
164/// Extract the `rdf:resource` attribute value (MRID reference) from a tag line.
165///
166/// ```xml
167/// <cim:TurbineGovernorDynamics.SynchronousMachineDynamics rdf:resource="#_gen1"/>
168/// ```
169fn extract_resource(line: &str) -> Option<String> {
170    for attr in &["rdf:resource=\"", "rdf:resource='"] {
171        if let Some(pos) = line.find(attr) {
172            let start = pos + attr.len();
173            let rest = &line[start..];
174            let quote_char = if attr.ends_with('"') { '"' } else { '\'' };
175            if let Some(end) = rest.find(quote_char) {
176                let id = rest[..end].trim_start_matches('#').to_string();
177                if !id.is_empty() {
178                    return Some(id);
179                }
180            }
181        }
182    }
183    None
184}
185
186// ---------------------------------------------------------------------------
187// SC profile parser
188// ---------------------------------------------------------------------------
189
190/// Parse the SC (Short-Circuit) profile XML and return a map of equipment
191/// MRID → `ScProfile`.
192///
193/// Handles both `ACLineSegment` and `SynchronousMachine` elements with
194/// zero/negative sequence impedance children.
195pub fn parse_sc_profile(xml: &str) -> Result<HashMap<String, ScProfile>, IoError> {
196    let mut map: HashMap<String, ScProfile> = HashMap::new();
197    let mut current_mrid: Option<String> = None;
198
199    for line in xml.lines() {
200        let trimmed = line.trim();
201
202        // Opening element that carries an rdf:ID / rdf:about
203        if (trimmed.starts_with("<cim:ACLineSegment")
204            || trimmed.starts_with("<cim:SynchronousMachine")
205            || trimmed.starts_with("<cim:ExternalNetworkInjection")
206            || trimmed.starts_with("<cim:PowerTransformerEnd"))
207            && let Some(mrid) = extract_rdf_id(trimmed)
208        {
209            current_mrid = Some(mrid.clone());
210            map.entry(mrid).or_default();
211        }
212
213        // Zero-sequence resistance
214        if let Some(val) = extract_text(trimmed, "cim:ACLineSegment.r0")
215            .or_else(|| extract_text(trimmed, "cim:SynchronousMachine.r0"))
216            .or_else(|| extract_text(trimmed, "cim:PowerTransformerEnd.r0"))
217            && let (Some(mrid), Some(f)) = (&current_mrid, parse_f64(val))
218        {
219            map.entry(mrid.clone()).or_default().r0_pu = Some(f);
220        }
221
222        // Zero-sequence reactance
223        if let Some(val) = extract_text(trimmed, "cim:ACLineSegment.x0")
224            .or_else(|| extract_text(trimmed, "cim:SynchronousMachine.x0"))
225            .or_else(|| extract_text(trimmed, "cim:PowerTransformerEnd.x0"))
226            && let (Some(mrid), Some(f)) = (&current_mrid, parse_f64(val))
227        {
228            map.entry(mrid.clone()).or_default().x0_pu = Some(f);
229        }
230
231        // Negative-sequence resistance
232        if let Some(val) = extract_text(trimmed, "cim:ACLineSegment.r2")
233            .or_else(|| extract_text(trimmed, "cim:SynchronousMachine.r2"))
234            && let (Some(mrid), Some(f)) = (&current_mrid, parse_f64(val))
235        {
236            map.entry(mrid.clone()).or_default().r2_pu = Some(f);
237        }
238
239        // Negative-sequence reactance
240        if let Some(val) = extract_text(trimmed, "cim:ACLineSegment.x2")
241            .or_else(|| extract_text(trimmed, "cim:SynchronousMachine.x2"))
242            && let (Some(mrid), Some(f)) = (&current_mrid, parse_f64(val))
243        {
244            map.entry(mrid.clone()).or_default().x2_pu = Some(f);
245        }
246
247        // Initial short-circuit current (kA)
248        if let Some(val) = extract_text(trimmed, "cim:ACLineSegment.Ikss")
249            .or_else(|| extract_text(trimmed, "cim:SynchronousMachine.ikss"))
250            .or_else(|| extract_text(trimmed, "sc:ACLineSegment.ikss"))
251            && let (Some(mrid), Some(f)) = (&current_mrid, parse_f64(val))
252        {
253            map.entry(mrid.clone()).or_default().ikss_ka = Some(f);
254        }
255    }
256
257    Ok(map)
258}
259
260// ---------------------------------------------------------------------------
261// DY profile parser
262// ---------------------------------------------------------------------------
263
264/// Parse the DY (Dynamics) profile XML and return a list of `DyProfile`
265/// records — one per turbine-governor / exciter block found.
266///
267/// CGMES DY uses typed elements such as:
268/// ```xml
269/// <cim:GovGAST2 rdf:ID="_gov1">
270///   <cim:TurbineGovernorDynamics.SynchronousMachineDynamics rdf:resource="#_smd1"/>
271/// </cim:GovGAST2>
272/// ```
273pub fn parse_dy_profile(xml: &str) -> Result<Vec<DyProfile>, IoError> {
274    // Known governor prefixes
275    const GOV_PREFIXES: &[&str] = &[
276        "cim:GovSteamEU",
277        "cim:GovGAST",
278        "cim:GovHydro",
279        "cim:GovSteamFV",
280        "cim:GovCT",
281        "cim:GovSteam",
282        "cim:GovDum",
283    ];
284    // Known exciter prefixes
285    const EXC_PREFIXES: &[&str] = &[
286        "cim:ExcIEEE",
287        "cim:ExcANS",
288        "cim:ExcBBC",
289        "cim:ExcST",
290        "cim:ExcAC",
291        "cim:ExcDC",
292        "cim:ExcELIN",
293        "cim:ExcHU",
294        "cim:ExcOEX3T",
295        "cim:ExcPIC",
296        "cim:ExcRQB",
297        "cim:ExcSK",
298    ];
299    // Known PSS prefixes
300    const PSS_PREFIXES: &[&str] = &[
301        "cim:Pss2",
302        "cim:PssIEEE",
303        "cim:PssSB",
304        "cim:PssWECC",
305        "cim:PssPTIST",
306        "cim:PssELIN",
307    ];
308
309    // Intermediate state
310    struct Block {
311        kind: String, // "gov" | "exc" | "pss"
312        type_name: String,
313        machine_mrid: Option<String>,
314    }
315
316    let mut blocks: Vec<Block> = Vec::new();
317    let mut current: Option<Block> = None;
318
319    for line in xml.lines() {
320        let trimmed = line.trim();
321        // Strip leading '<' so prefixes like "cim:GovGAST2" match "<cim:GovGAST2 …>" lines
322        let tag_trimmed = trimmed.trim_start_matches('<');
323
324        // Check if this line opens a known dynamic model element
325        let mut matched_kind: Option<(&str, String)> = None;
326
327        for prefix in GOV_PREFIXES {
328            if tag_trimmed.starts_with(prefix) {
329                let type_name = trimmed
330                    .split_whitespace()
331                    .next()
332                    .unwrap_or(prefix)
333                    .trim_start_matches('<')
334                    .trim_end_matches('>')
335                    .to_string();
336                matched_kind = Some(("gov", type_name));
337                break;
338            }
339        }
340        if matched_kind.is_none() {
341            for prefix in EXC_PREFIXES {
342                if tag_trimmed.starts_with(prefix) {
343                    let type_name = trimmed
344                        .split_whitespace()
345                        .next()
346                        .unwrap_or(prefix)
347                        .trim_start_matches('<')
348                        .trim_end_matches('>')
349                        .to_string();
350                    matched_kind = Some(("exc", type_name));
351                    break;
352                }
353            }
354        }
355        if matched_kind.is_none() {
356            for prefix in PSS_PREFIXES {
357                if tag_trimmed.starts_with(prefix) {
358                    let type_name = trimmed
359                        .split_whitespace()
360                        .next()
361                        .unwrap_or(prefix)
362                        .trim_start_matches('<')
363                        .trim_end_matches('>')
364                        .to_string();
365                    matched_kind = Some(("pss", type_name));
366                    break;
367                }
368            }
369        }
370
371        if let Some((kind, type_name)) = matched_kind {
372            // Flush previous block
373            if let Some(blk) = current.take() {
374                blocks.push(blk);
375            }
376            current = Some(Block {
377                kind: kind.to_string(),
378                type_name,
379                machine_mrid: None,
380            });
381            continue;
382        }
383
384        // Machine reference link
385        if (trimmed.contains("SynchronousMachineDynamics")
386            || trimmed.contains("RotatingMachineDynamics")
387            || trimmed.contains("GeneratingUnit"))
388            && let (Some(blk), Some(mrid)) = (&mut current, extract_resource(trimmed))
389        {
390            blk.machine_mrid = Some(mrid);
391        }
392
393        // Closing tag — flush
394        if (trimmed.starts_with("</cim:Gov")
395            || trimmed.starts_with("</cim:Exc")
396            || trimmed.starts_with("</cim:Pss")
397            || trimmed.starts_with("</cim:Turbine"))
398            && let Some(blk) = current.take()
399        {
400            blocks.push(blk);
401        }
402    }
403
404    // Flush final block
405    if let Some(blk) = current.take() {
406        blocks.push(blk);
407    }
408
409    // Collate blocks by machine_mrid into DyProfile records
410    let mut profiles: HashMap<String, DyProfile> = HashMap::new();
411
412    for blk in blocks {
413        let mrid = blk
414            .machine_mrid
415            .clone()
416            .unwrap_or_else(|| format!("unknown_{}", blk.type_name));
417        let entry = profiles.entry(mrid.clone()).or_insert_with(|| DyProfile {
418            machine_mrid: mrid,
419            governor_type: None,
420            exciter_type: None,
421            pss_type: None,
422        });
423        match blk.kind.as_str() {
424            "gov" => entry.governor_type = Some(blk.type_name),
425            "exc" => entry.exciter_type = Some(blk.type_name),
426            "pss" => entry.pss_type = Some(blk.type_name),
427            _ => {}
428        }
429    }
430
431    Ok(profiles.into_values().collect())
432}
433
434// ---------------------------------------------------------------------------
435// GL profile parser
436// ---------------------------------------------------------------------------
437
438/// Parse the GL (Geographical Location) profile XML and return a list of
439/// `GlProfile` records with substation coordinates.
440///
441/// CGMES GL uses:
442/// ```xml
443/// <cim:SubGeographicalRegion rdf:ID="_sub1">
444///   <cim:CoordinatePair.xPosition>-97.5</cim:CoordinatePair.xPosition>
445///   <cim:CoordinatePair.yPosition>32.8</cim:CoordinatePair.yPosition>
446/// </cim:SubGeographicalRegion>
447/// ```
448/// or the newer `Location` / `PositionPoint` pattern.
449pub fn parse_gl_profile(xml: &str) -> Result<Vec<GlProfile>, IoError> {
450    let mut profiles: Vec<GlProfile> = Vec::new();
451    let mut current_mrid: Option<String> = None;
452    let mut current_lon: Option<f64> = None;
453    let mut current_lat: Option<f64> = None;
454
455    for line in xml.lines() {
456        let trimmed = line.trim();
457
458        // Opening: Substation or Location element
459        if trimmed.starts_with("<cim:Substation")
460            || trimmed.starts_with("<cim:Location")
461            || trimmed.starts_with("<cim:SubGeographicalRegion")
462        {
463            // Flush previous
464            if let (Some(mrid), Some(lat), Some(lon)) =
465                (current_mrid.take(), current_lat.take(), current_lon.take())
466            {
467                profiles.push(GlProfile {
468                    substation_mrid: mrid,
469                    latitude: lat,
470                    longitude: lon,
471                });
472            } else {
473                current_mrid = None;
474                current_lat = None;
475                current_lon = None;
476            }
477
478            if let Some(mrid) = extract_rdf_id(trimmed) {
479                current_mrid = Some(mrid);
480            }
481            continue;
482        }
483
484        // xPosition → longitude
485        if let Some(val) = extract_text(trimmed, "cim:CoordinatePair.xPosition")
486            .or_else(|| extract_text(trimmed, "cim:PositionPoint.xPosition"))
487        {
488            current_lon = parse_f64(val);
489        }
490
491        // yPosition → latitude
492        if let Some(val) = extract_text(trimmed, "cim:CoordinatePair.yPosition")
493            .or_else(|| extract_text(trimmed, "cim:PositionPoint.yPosition"))
494        {
495            current_lat = parse_f64(val);
496        }
497
498        // Closing tag
499        if (trimmed.starts_with("</cim:Substation>")
500            || trimmed.starts_with("</cim:Location>")
501            || trimmed.starts_with("</cim:SubGeographicalRegion>"))
502            && let (Some(mrid), Some(lat), Some(lon)) =
503                (current_mrid.take(), current_lat.take(), current_lon.take())
504        {
505            profiles.push(GlProfile {
506                substation_mrid: mrid,
507                latitude: lat,
508                longitude: lon,
509            });
510        }
511    }
512
513    // Flush tail
514    if let (Some(mrid), Some(lat), Some(lon)) = (current_mrid, current_lat, current_lon) {
515        profiles.push(GlProfile {
516            substation_mrid: mrid,
517            latitude: lat,
518            longitude: lon,
519        });
520    }
521
522    Ok(profiles)
523}
524
525// ---------------------------------------------------------------------------
526// TPBD profile parser
527// ---------------------------------------------------------------------------
528
529/// Parse the TPBD (Topology Boundary) profile XML and return a list of
530/// `TpbdProfile` boundary-point records.
531///
532/// ```xml
533/// <tp-bd:BoundaryPoint rdf:ID="_bp1">
534///   <tp-bd:BoundaryPoint.fromEndIsoCode>A</tp-bd:BoundaryPoint.fromEndIsoCode>
535///   <tp-bd:BoundaryPoint.toEndIsoCode>B</tp-bd:BoundaryPoint.toEndIsoCode>
536///   <tp-bd:BoundaryPoint.nominalVoltage>400</tp-bd:BoundaryPoint.nominalVoltage>
537/// </tp-bd:BoundaryPoint>
538/// ```
539pub fn parse_tpbd_profile(xml: &str) -> Result<Vec<TpbdProfile>, IoError> {
540    let mut profiles: Vec<TpbdProfile> = Vec::new();
541
542    let mut current_mrid: Option<String> = None;
543    let mut bus_a: Option<String> = None;
544    let mut bus_b: Option<String> = None;
545    let mut voltage_kv: Option<f64> = None;
546
547    for line in xml.lines() {
548        let trimmed = line.trim();
549
550        // Opening BoundaryPoint element (tp-bd or cim namespace)
551        if trimmed.starts_with("<tp-bd:BoundaryPoint") || trimmed.starts_with("<cim:BoundaryPoint")
552        {
553            // Flush previous
554            if let Some(mrid) = current_mrid.take() {
555                profiles.push(TpbdProfile {
556                    boundary_point_mrid: mrid,
557                    bus_a_mrid: bus_a.take().unwrap_or_default(),
558                    bus_b_mrid: bus_b.take().unwrap_or_default(),
559                    voltage_level_kv: voltage_kv.take().unwrap_or(0.0),
560                });
561            } else {
562                bus_a = None;
563                bus_b = None;
564                voltage_kv = None;
565            }
566            current_mrid = extract_rdf_id(trimmed);
567            continue;
568        }
569
570        // Area A bus reference (from-end)
571        if trimmed.contains("BoundaryPoint.fromEndNameTso")
572            || trimmed.contains("BoundaryPoint.fromEnd")
573        {
574            if let Some(res) = extract_resource(trimmed) {
575                bus_a = Some(res);
576            } else if let Some(val) = extract_text(trimmed, "tp-bd:BoundaryPoint.fromEndNameTso")
577                .or_else(|| extract_text(trimmed, "cim:BoundaryPoint.fromEndNameTso"))
578            {
579                bus_a = Some(val.to_string());
580            }
581        }
582
583        // Area B bus reference (to-end)
584        if trimmed.contains("BoundaryPoint.toEndNameTso") || trimmed.contains("BoundaryPoint.toEnd")
585        {
586            if let Some(res) = extract_resource(trimmed) {
587                bus_b = Some(res);
588            } else if let Some(val) = extract_text(trimmed, "tp-bd:BoundaryPoint.toEndNameTso")
589                .or_else(|| extract_text(trimmed, "cim:BoundaryPoint.toEndNameTso"))
590            {
591                bus_b = Some(val.to_string());
592            }
593        }
594
595        // Nominal voltage
596        if let Some(val) = extract_text(trimmed, "tp-bd:BoundaryPoint.nominalVoltage")
597            .or_else(|| extract_text(trimmed, "cim:BoundaryPoint.nominalVoltage"))
598        {
599            voltage_kv = parse_f64(val);
600        }
601
602        // Closing tag
603        if (trimmed.starts_with("</tp-bd:BoundaryPoint>")
604            || trimmed.starts_with("</cim:BoundaryPoint>"))
605            && let Some(mrid) = current_mrid.take()
606        {
607            profiles.push(TpbdProfile {
608                boundary_point_mrid: mrid,
609                bus_a_mrid: bus_a.take().unwrap_or_default(),
610                bus_b_mrid: bus_b.take().unwrap_or_default(),
611                voltage_level_kv: voltage_kv.take().unwrap_or(0.0),
612            });
613        }
614    }
615
616    // Flush tail
617    if let Some(mrid) = current_mrid {
618        profiles.push(TpbdProfile {
619            boundary_point_mrid: mrid,
620            bus_a_mrid: bus_a.unwrap_or_default(),
621            bus_b_mrid: bus_b.unwrap_or_default(),
622            voltage_level_kv: voltage_kv.unwrap_or(0.0),
623        });
624    }
625
626    Ok(profiles)
627}
628
629// ---------------------------------------------------------------------------
630// Unified entry point
631// ---------------------------------------------------------------------------
632
633/// Parse a CGMES dataset from a map of profile name → XML string.
634///
635/// Supported profile keys: `"EQ"`, `"SSH"`, `"SC"`, `"DY"`, `"GL"`, `"TPBD"`.
636/// The `"EQ"` key is required for network assembly; all others are optional.
637///
638/// # Example
639/// ```no_run
640/// # use std::collections::HashMap;
641/// # use surge_io::cgmes::ext::parse_cgmes_extended;
642/// # let eq_xml = String::new();
643/// # let sc_xml = String::new();
644/// let mut profiles = HashMap::new();
645/// profiles.insert("EQ".to_string(), eq_xml);
646/// profiles.insert("SC".to_string(), sc_xml);
647/// let dataset = parse_cgmes_extended(&profiles).unwrap();
648/// ```
649pub fn parse_cgmes_extended(
650    profiles: &HashMap<String, String>,
651) -> Result<CgmesExtDataset, IoError> {
652    // --- Base network: delegate to existing CIM parser ---
653    // Build a combined ObjMap for EQ+SSH so we can also extract the SM bus map
654    // without re-parsing. Collect combined XML for the string-based entry point.
655    let mut base_xml = String::new();
656    for key in &["EQ", "SSH", "TP", "SV"] {
657        if let Some(xml) = profiles.get(*key) {
658            base_xml.push_str(xml);
659            base_xml.push('\n');
660        }
661    }
662
663    // Build ObjMap for SM bus map extraction (used by DY parser).
664    let sm_bus_map = if !base_xml.trim().is_empty() {
665        use super::{ObjMap, build_sm_bus_map, collect_objects};
666        let mut objects = ObjMap::new();
667        let _ = collect_objects(&base_xml, &mut objects); // ignore parse errors here
668        build_sm_bus_map(&objects)
669    } else {
670        std::collections::HashMap::new()
671    };
672
673    let network = if !base_xml.trim().is_empty() {
674        super::loads(&base_xml).unwrap_or_else(|_| Network::new("cgmes_ext"))
675    } else {
676        Network::new("cgmes_ext")
677    };
678
679    // --- Extension profiles ---
680    let sc_data = if let Some(xml) = profiles.get("SC") {
681        parse_sc_profile(xml)?
682    } else {
683        HashMap::new()
684    };
685
686    let dy_data = if let Some(xml) = profiles.get("DY") {
687        parse_dy_profile(xml)?
688    } else {
689        Vec::new()
690    };
691
692    // Full dynamic model from DY profile using the new parameter-aware parser.
693    let dynamic_model = if let Some(xml) = profiles.get("DY") {
694        match super::dynamics::parse_cgmes_dy(&[xml.as_str()], &sm_bus_map) {
695            Ok(dm) => {
696                tracing::info!(
697                    generators = dm.generators.len(),
698                    exciters = dm.exciters.len(),
699                    governors = dm.governors.len(),
700                    pss = dm.pss.len(),
701                    "CGMES DY profile parsed"
702                );
703                Some(dm)
704            }
705            Err(e) => {
706                tracing::warn!(error = %e, "CGMES DY profile parse failed — dynamic_model will be None");
707                None
708            }
709        }
710    } else {
711        None
712    };
713
714    let gl_data = if let Some(xml) = profiles.get("GL") {
715        parse_gl_profile(xml)?
716    } else {
717        Vec::new()
718    };
719
720    let tpbd_data = if let Some(xml) = profiles.get("TPBD") {
721        parse_tpbd_profile(xml)?
722    } else {
723        Vec::new()
724    };
725
726    Ok(CgmesExtDataset {
727        network,
728        sc_data,
729        dy_data,
730        dynamic_model,
731        gl_data,
732        tpbd_data,
733    })
734}
735
736// ---------------------------------------------------------------------------
737// SC profile writer
738// ---------------------------------------------------------------------------
739
740/// Serialise short-circuit data to a CGMES SC profile RDF/XML string.
741///
742/// Produces a minimal but valid RDF/XML document suitable for exchange.
743pub fn write_cgmes_sc_profile(network: &Network, sc_data: &HashMap<String, ScProfile>) -> String {
744    let mut out = String::new();
745    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
746    out.push_str("<rdf:RDF\n");
747    out.push_str("  xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n");
748    out.push_str("  xmlns:cim=\"http://iec.ch/TC57/2013/CIM-schema-cim16#\"\n");
749    out.push_str("  xmlns:sc=\"http://iec.ch/TC57/2013/CIM-schema-cim16-SC#\">\n");
750    out.push_str(&format!(
751        "  <!-- SC Profile generated by Surge — network: {} -->\n",
752        network.name
753    ));
754
755    for (mrid, sc) in sc_data {
756        out.push_str(&format!("  <cim:ACLineSegment rdf:ID=\"{}\">\n", mrid));
757        if let Some(v) = sc.r0_pu {
758            out.push_str(&format!(
759                "    <cim:ACLineSegment.r0>{v}</cim:ACLineSegment.r0>\n"
760            ));
761        }
762        if let Some(v) = sc.x0_pu {
763            out.push_str(&format!(
764                "    <cim:ACLineSegment.x0>{v}</cim:ACLineSegment.x0>\n"
765            ));
766        }
767        if let Some(v) = sc.r2_pu {
768            out.push_str(&format!(
769                "    <cim:ACLineSegment.r2>{v}</cim:ACLineSegment.r2>\n"
770            ));
771        }
772        if let Some(v) = sc.x2_pu {
773            out.push_str(&format!(
774                "    <cim:ACLineSegment.x2>{v}</cim:ACLineSegment.x2>\n"
775            ));
776        }
777        if let Some(v) = sc.ikss_ka {
778            out.push_str(&format!(
779                "    <cim:ACLineSegment.Ikss>{v}</cim:ACLineSegment.Ikss>\n"
780            ));
781        }
782        out.push_str("  </cim:ACLineSegment>\n");
783    }
784
785    out.push_str("</rdf:RDF>\n");
786    out
787}
788
789// ---------------------------------------------------------------------------
790// Tests
791// ---------------------------------------------------------------------------
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    // -----------------------------------------------------------------------
798    // PLAN-082 / P5-048 — SC profile
799    // -----------------------------------------------------------------------
800
801    #[test]
802    fn test_sc_profile_parse() {
803        let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
804<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
805         xmlns:cim="http://iec.ch/TC57/2013/CIM-schema-cim16#">
806  <cim:ACLineSegment rdf:ID="_line1">
807    <cim:ACLineSegment.r0>0.05</cim:ACLineSegment.r0>
808    <cim:ACLineSegment.x0>0.15</cim:ACLineSegment.x0>
809    <cim:ACLineSegment.r2>0.04</cim:ACLineSegment.r2>
810    <cim:ACLineSegment.x2>0.12</cim:ACLineSegment.x2>
811    <cim:ACLineSegment.Ikss>12.5</cim:ACLineSegment.Ikss>
812  </cim:ACLineSegment>
813</rdf:RDF>"##;
814
815        let map = parse_sc_profile(xml).expect("SC parse should succeed");
816        assert!(map.contains_key("_line1"), "Expected _line1 key in map");
817        let sc = &map["_line1"];
818        assert!(
819            (sc.r0_pu.unwrap() - 0.05).abs() < 1e-10,
820            "r0 mismatch: {:?}",
821            sc.r0_pu
822        );
823        assert!(
824            (sc.x0_pu.unwrap() - 0.15).abs() < 1e-10,
825            "x0 mismatch: {:?}",
826            sc.x0_pu
827        );
828        assert!(
829            (sc.r2_pu.unwrap() - 0.04).abs() < 1e-10,
830            "r2 mismatch: {:?}",
831            sc.r2_pu
832        );
833        assert!(
834            (sc.x2_pu.unwrap() - 0.12).abs() < 1e-10,
835            "x2 mismatch: {:?}",
836            sc.x2_pu
837        );
838        assert!(
839            (sc.ikss_ka.unwrap() - 12.5).abs() < 1e-10,
840            "ikss mismatch: {:?}",
841            sc.ikss_ka
842        );
843    }
844
845    // -----------------------------------------------------------------------
846    // DY profile
847    // -----------------------------------------------------------------------
848
849    #[test]
850    fn test_dy_profile_parse() {
851        let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
852<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
853         xmlns:cim="http://iec.ch/TC57/2013/CIM-schema-cim16#">
854  <cim:GovGAST2 rdf:ID="_gov1">
855    <cim:TurbineGovernorDynamics.SynchronousMachineDynamics rdf:resource="#_gen1"/>
856  </cim:GovGAST2>
857  <cim:ExcIEEEST1A rdf:ID="_exc1">
858    <cim:ExcitationSystemDynamics.SynchronousMachineDynamics rdf:resource="#_gen1"/>
859  </cim:ExcIEEEST1A>
860</rdf:RDF>"##;
861
862        let profiles = parse_dy_profile(xml).expect("DY parse should succeed");
863        assert!(!profiles.is_empty(), "Expected at least one DY profile");
864
865        // Find the record for _gen1
866        let gen1 = profiles
867            .iter()
868            .find(|p| p.machine_mrid == "_gen1")
869            .expect("Expected DyProfile for _gen1");
870
871        assert_eq!(
872            gen1.governor_type.as_deref(),
873            Some("cim:GovGAST2"),
874            "governor_type mismatch: {:?}",
875            gen1.governor_type
876        );
877    }
878
879    // -----------------------------------------------------------------------
880    // GL profile
881    // -----------------------------------------------------------------------
882
883    #[test]
884    fn test_gl_coordinates() {
885        let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
886<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
887         xmlns:cim="http://iec.ch/TC57/2013/CIM-schema-cim16#">
888  <cim:Substation rdf:ID="_sub1">
889    <cim:CoordinatePair.xPosition>-97.5</cim:CoordinatePair.xPosition>
890    <cim:CoordinatePair.yPosition>32.8</cim:CoordinatePair.yPosition>
891  </cim:Substation>
892</rdf:RDF>"##;
893
894        let profiles = parse_gl_profile(xml).expect("GL parse should succeed");
895        assert!(!profiles.is_empty(), "Expected at least one GL profile");
896
897        let sub1 = profiles
898            .iter()
899            .find(|p| p.substation_mrid == "_sub1")
900            .expect("Expected GlProfile for _sub1");
901
902        assert!(
903            (sub1.longitude - (-97.5)).abs() < 1e-10,
904            "longitude mismatch: {}",
905            sub1.longitude
906        );
907        assert!(
908            (sub1.latitude - 32.8).abs() < 1e-10,
909            "latitude mismatch: {}",
910            sub1.latitude
911        );
912    }
913
914    // -----------------------------------------------------------------------
915    // write_cgmes_sc_profile round-trip
916    // -----------------------------------------------------------------------
917
918    #[test]
919    fn test_write_sc_profile_round_trip() {
920        use surge_network::Network;
921        use surge_network::network::{Bus, BusType};
922
923        let mut net = Network::new("test_sc");
924        net.buses.push(Bus::new(1, BusType::Slack, 345.0));
925
926        let mut sc_data = HashMap::new();
927        sc_data.insert(
928            "_line42".to_string(),
929            ScProfile {
930                r0_pu: Some(0.03),
931                x0_pu: Some(0.09),
932                r2_pu: None,
933                x2_pu: None,
934                ikss_ka: Some(8.0),
935            },
936        );
937
938        let xml = write_cgmes_sc_profile(&net, &sc_data);
939        assert!(xml.contains("_line42"), "MRID should appear in output");
940        assert!(xml.contains("<cim:ACLineSegment.r0>0.03</cim:ACLineSegment.r0>"));
941        assert!(xml.contains("<cim:ACLineSegment.x0>0.09</cim:ACLineSegment.x0>"));
942        assert!(xml.contains("<cim:ACLineSegment.Ikss>8</cim:ACLineSegment.Ikss>"));
943
944        // Re-parse the written XML to verify round-trip
945        let reparsed = parse_sc_profile(&xml).unwrap();
946        let sc = reparsed
947            .get("_line42")
948            .expect("_line42 should be present after re-parse");
949        assert!((sc.r0_pu.unwrap() - 0.03).abs() < 1e-10);
950        assert!((sc.ikss_ka.unwrap() - 8.0).abs() < 1e-10);
951    }
952}