1use std::collections::HashMap;
17
18use surge_network::Network;
19use thiserror::Error;
20
21pub use super::Error as CgmesError;
25
26#[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#[derive(Debug, Clone, Default)]
48pub struct ScProfile {
49 pub r0_pu: Option<f64>,
51 pub x0_pu: Option<f64>,
53 pub r2_pu: Option<f64>,
55 pub x2_pu: Option<f64>,
57 pub ikss_ka: Option<f64>,
59}
60
61#[derive(Debug, Clone)]
63pub struct DyProfile {
64 pub machine_mrid: String,
66 pub governor_type: Option<String>,
68 pub exciter_type: Option<String>,
70 pub pss_type: Option<String>,
72}
73
74#[derive(Debug, Clone)]
76pub struct GlProfile {
77 pub substation_mrid: String,
79 pub latitude: f64,
81 pub longitude: f64,
83}
84
85#[derive(Debug, Clone)]
87pub struct TpbdProfile {
88 pub boundary_point_mrid: String,
90 pub bus_a_mrid: String,
92 pub bus_b_mrid: String,
94 pub voltage_level_kv: f64,
96}
97
98pub struct CgmesExtDataset {
100 pub network: Network,
102 pub sc_data: HashMap<String, ScProfile>,
104 pub dy_data: Vec<DyProfile>,
106 pub dynamic_model: Option<surge_network::dynamics::DynamicModel>,
108 pub gl_data: Vec<GlProfile>,
110 pub tpbd_data: Vec<TpbdProfile>,
112}
113
114fn 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
142fn 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
159fn parse_f64(s: &str) -> Option<f64> {
161 s.trim().parse::<f64>().ok()
162}
163
164fn 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
186pub 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 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 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)) = (¤t_mrid, parse_f64(val))
218 {
219 map.entry(mrid.clone()).or_default().r0_pu = Some(f);
220 }
221
222 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)) = (¤t_mrid, parse_f64(val))
227 {
228 map.entry(mrid.clone()).or_default().x0_pu = Some(f);
229 }
230
231 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)) = (¤t_mrid, parse_f64(val))
235 {
236 map.entry(mrid.clone()).or_default().r2_pu = Some(f);
237 }
238
239 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)) = (¤t_mrid, parse_f64(val))
243 {
244 map.entry(mrid.clone()).or_default().x2_pu = Some(f);
245 }
246
247 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)) = (¤t_mrid, parse_f64(val))
252 {
253 map.entry(mrid.clone()).or_default().ikss_ka = Some(f);
254 }
255 }
256
257 Ok(map)
258}
259
260pub fn parse_dy_profile(xml: &str) -> Result<Vec<DyProfile>, IoError> {
274 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 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 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 struct Block {
311 kind: String, 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 let tag_trimmed = trimmed.trim_start_matches('<');
323
324 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 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 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 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 if let Some(blk) = current.take() {
406 blocks.push(blk);
407 }
408
409 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
434pub 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 if trimmed.starts_with("<cim:Substation")
460 || trimmed.starts_with("<cim:Location")
461 || trimmed.starts_with("<cim:SubGeographicalRegion")
462 {
463 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 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 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 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 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
525pub 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 if trimmed.starts_with("<tp-bd:BoundaryPoint") || trimmed.starts_with("<cim:BoundaryPoint")
552 {
553 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 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 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 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 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 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
629pub fn parse_cgmes_extended(
650 profiles: &HashMap<String, String>,
651) -> Result<CgmesExtDataset, IoError> {
652 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 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); 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 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 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
736pub 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#[cfg(test)]
794mod tests {
795 use super::*;
796
797 #[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 #[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 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 #[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 #[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 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}