use std::collections::HashMap;
use surge_network::Network;
use thiserror::Error;
pub use super::Error as CgmesError;
#[derive(Error, Debug)]
pub enum IoError {
#[error("CGMES base parse error: {0}")]
Cgmes(#[from] CgmesError),
#[error("XML parse error: {0}")]
Xml(String),
#[error("missing required attribute: {0}")]
MissingAttr(String),
}
#[derive(Debug, Clone, Default)]
pub struct ScProfile {
pub r0_pu: Option<f64>,
pub x0_pu: Option<f64>,
pub r2_pu: Option<f64>,
pub x2_pu: Option<f64>,
pub ikss_ka: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct DyProfile {
pub machine_mrid: String,
pub governor_type: Option<String>,
pub exciter_type: Option<String>,
pub pss_type: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GlProfile {
pub substation_mrid: String,
pub latitude: f64,
pub longitude: f64,
}
#[derive(Debug, Clone)]
pub struct TpbdProfile {
pub boundary_point_mrid: String,
pub bus_a_mrid: String,
pub bus_b_mrid: String,
pub voltage_level_kv: f64,
}
pub struct CgmesExtDataset {
pub network: Network,
pub sc_data: HashMap<String, ScProfile>,
pub dy_data: Vec<DyProfile>,
pub dynamic_model: Option<surge_network::dynamics::DynamicModel>,
pub gl_data: Vec<GlProfile>,
pub tpbd_data: Vec<TpbdProfile>,
}
fn extract_rdf_id(line: &str) -> Option<String> {
for attr in &["rdf:ID=\"", "rdf:about=\"", "rdf:ID='", "rdf:about='"] {
if let Some(pos) = line.find(attr) {
let start = pos + attr.len();
let rest = &line[start..];
let quote_char = if attr.ends_with('"') { '"' } else { '\'' };
if let Some(end) = rest.find(quote_char) {
let id = rest[..end].trim_start_matches('#').to_string();
if !id.is_empty() {
return Some(id);
}
}
}
}
None
}
fn extract_text<'a>(line: &'a str, tag: &str) -> Option<&'a str> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
if let (Some(s), Some(e)) = (line.find(&open), line.find(&close)) {
let value_start = s + open.len();
if value_start <= e {
return Some(line[value_start..e].trim());
}
}
None
}
fn parse_f64(s: &str) -> Option<f64> {
s.trim().parse::<f64>().ok()
}
fn extract_resource(line: &str) -> Option<String> {
for attr in &["rdf:resource=\"", "rdf:resource='"] {
if let Some(pos) = line.find(attr) {
let start = pos + attr.len();
let rest = &line[start..];
let quote_char = if attr.ends_with('"') { '"' } else { '\'' };
if let Some(end) = rest.find(quote_char) {
let id = rest[..end].trim_start_matches('#').to_string();
if !id.is_empty() {
return Some(id);
}
}
}
}
None
}
pub fn parse_sc_profile(xml: &str) -> Result<HashMap<String, ScProfile>, IoError> {
let mut map: HashMap<String, ScProfile> = HashMap::new();
let mut current_mrid: Option<String> = None;
for line in xml.lines() {
let trimmed = line.trim();
if (trimmed.starts_with("<cim:ACLineSegment")
|| trimmed.starts_with("<cim:SynchronousMachine")
|| trimmed.starts_with("<cim:ExternalNetworkInjection")
|| trimmed.starts_with("<cim:PowerTransformerEnd"))
&& let Some(mrid) = extract_rdf_id(trimmed)
{
current_mrid = Some(mrid.clone());
map.entry(mrid).or_default();
}
if let Some(val) = extract_text(trimmed, "cim:ACLineSegment.r0")
.or_else(|| extract_text(trimmed, "cim:SynchronousMachine.r0"))
.or_else(|| extract_text(trimmed, "cim:PowerTransformerEnd.r0"))
&& let (Some(mrid), Some(f)) = (¤t_mrid, parse_f64(val))
{
map.entry(mrid.clone()).or_default().r0_pu = Some(f);
}
if let Some(val) = extract_text(trimmed, "cim:ACLineSegment.x0")
.or_else(|| extract_text(trimmed, "cim:SynchronousMachine.x0"))
.or_else(|| extract_text(trimmed, "cim:PowerTransformerEnd.x0"))
&& let (Some(mrid), Some(f)) = (¤t_mrid, parse_f64(val))
{
map.entry(mrid.clone()).or_default().x0_pu = Some(f);
}
if let Some(val) = extract_text(trimmed, "cim:ACLineSegment.r2")
.or_else(|| extract_text(trimmed, "cim:SynchronousMachine.r2"))
&& let (Some(mrid), Some(f)) = (¤t_mrid, parse_f64(val))
{
map.entry(mrid.clone()).or_default().r2_pu = Some(f);
}
if let Some(val) = extract_text(trimmed, "cim:ACLineSegment.x2")
.or_else(|| extract_text(trimmed, "cim:SynchronousMachine.x2"))
&& let (Some(mrid), Some(f)) = (¤t_mrid, parse_f64(val))
{
map.entry(mrid.clone()).or_default().x2_pu = Some(f);
}
if let Some(val) = extract_text(trimmed, "cim:ACLineSegment.Ikss")
.or_else(|| extract_text(trimmed, "cim:SynchronousMachine.ikss"))
.or_else(|| extract_text(trimmed, "sc:ACLineSegment.ikss"))
&& let (Some(mrid), Some(f)) = (¤t_mrid, parse_f64(val))
{
map.entry(mrid.clone()).or_default().ikss_ka = Some(f);
}
}
Ok(map)
}
pub fn parse_dy_profile(xml: &str) -> Result<Vec<DyProfile>, IoError> {
const GOV_PREFIXES: &[&str] = &[
"cim:GovSteamEU",
"cim:GovGAST",
"cim:GovHydro",
"cim:GovSteamFV",
"cim:GovCT",
"cim:GovSteam",
"cim:GovDum",
];
const EXC_PREFIXES: &[&str] = &[
"cim:ExcIEEE",
"cim:ExcANS",
"cim:ExcBBC",
"cim:ExcST",
"cim:ExcAC",
"cim:ExcDC",
"cim:ExcELIN",
"cim:ExcHU",
"cim:ExcOEX3T",
"cim:ExcPIC",
"cim:ExcRQB",
"cim:ExcSK",
];
const PSS_PREFIXES: &[&str] = &[
"cim:Pss2",
"cim:PssIEEE",
"cim:PssSB",
"cim:PssWECC",
"cim:PssPTIST",
"cim:PssELIN",
];
struct Block {
kind: String, type_name: String,
machine_mrid: Option<String>,
}
let mut blocks: Vec<Block> = Vec::new();
let mut current: Option<Block> = None;
for line in xml.lines() {
let trimmed = line.trim();
let tag_trimmed = trimmed.trim_start_matches('<');
let mut matched_kind: Option<(&str, String)> = None;
for prefix in GOV_PREFIXES {
if tag_trimmed.starts_with(prefix) {
let type_name = trimmed
.split_whitespace()
.next()
.unwrap_or(prefix)
.trim_start_matches('<')
.trim_end_matches('>')
.to_string();
matched_kind = Some(("gov", type_name));
break;
}
}
if matched_kind.is_none() {
for prefix in EXC_PREFIXES {
if tag_trimmed.starts_with(prefix) {
let type_name = trimmed
.split_whitespace()
.next()
.unwrap_or(prefix)
.trim_start_matches('<')
.trim_end_matches('>')
.to_string();
matched_kind = Some(("exc", type_name));
break;
}
}
}
if matched_kind.is_none() {
for prefix in PSS_PREFIXES {
if tag_trimmed.starts_with(prefix) {
let type_name = trimmed
.split_whitespace()
.next()
.unwrap_or(prefix)
.trim_start_matches('<')
.trim_end_matches('>')
.to_string();
matched_kind = Some(("pss", type_name));
break;
}
}
}
if let Some((kind, type_name)) = matched_kind {
if let Some(blk) = current.take() {
blocks.push(blk);
}
current = Some(Block {
kind: kind.to_string(),
type_name,
machine_mrid: None,
});
continue;
}
if (trimmed.contains("SynchronousMachineDynamics")
|| trimmed.contains("RotatingMachineDynamics")
|| trimmed.contains("GeneratingUnit"))
&& let (Some(blk), Some(mrid)) = (&mut current, extract_resource(trimmed))
{
blk.machine_mrid = Some(mrid);
}
if (trimmed.starts_with("</cim:Gov")
|| trimmed.starts_with("</cim:Exc")
|| trimmed.starts_with("</cim:Pss")
|| trimmed.starts_with("</cim:Turbine"))
&& let Some(blk) = current.take()
{
blocks.push(blk);
}
}
if let Some(blk) = current.take() {
blocks.push(blk);
}
let mut profiles: HashMap<String, DyProfile> = HashMap::new();
for blk in blocks {
let mrid = blk
.machine_mrid
.clone()
.unwrap_or_else(|| format!("unknown_{}", blk.type_name));
let entry = profiles.entry(mrid.clone()).or_insert_with(|| DyProfile {
machine_mrid: mrid,
governor_type: None,
exciter_type: None,
pss_type: None,
});
match blk.kind.as_str() {
"gov" => entry.governor_type = Some(blk.type_name),
"exc" => entry.exciter_type = Some(blk.type_name),
"pss" => entry.pss_type = Some(blk.type_name),
_ => {}
}
}
Ok(profiles.into_values().collect())
}
pub fn parse_gl_profile(xml: &str) -> Result<Vec<GlProfile>, IoError> {
let mut profiles: Vec<GlProfile> = Vec::new();
let mut current_mrid: Option<String> = None;
let mut current_lon: Option<f64> = None;
let mut current_lat: Option<f64> = None;
for line in xml.lines() {
let trimmed = line.trim();
if trimmed.starts_with("<cim:Substation")
|| trimmed.starts_with("<cim:Location")
|| trimmed.starts_with("<cim:SubGeographicalRegion")
{
if let (Some(mrid), Some(lat), Some(lon)) =
(current_mrid.take(), current_lat.take(), current_lon.take())
{
profiles.push(GlProfile {
substation_mrid: mrid,
latitude: lat,
longitude: lon,
});
} else {
current_mrid = None;
current_lat = None;
current_lon = None;
}
if let Some(mrid) = extract_rdf_id(trimmed) {
current_mrid = Some(mrid);
}
continue;
}
if let Some(val) = extract_text(trimmed, "cim:CoordinatePair.xPosition")
.or_else(|| extract_text(trimmed, "cim:PositionPoint.xPosition"))
{
current_lon = parse_f64(val);
}
if let Some(val) = extract_text(trimmed, "cim:CoordinatePair.yPosition")
.or_else(|| extract_text(trimmed, "cim:PositionPoint.yPosition"))
{
current_lat = parse_f64(val);
}
if (trimmed.starts_with("</cim:Substation>")
|| trimmed.starts_with("</cim:Location>")
|| trimmed.starts_with("</cim:SubGeographicalRegion>"))
&& let (Some(mrid), Some(lat), Some(lon)) =
(current_mrid.take(), current_lat.take(), current_lon.take())
{
profiles.push(GlProfile {
substation_mrid: mrid,
latitude: lat,
longitude: lon,
});
}
}
if let (Some(mrid), Some(lat), Some(lon)) = (current_mrid, current_lat, current_lon) {
profiles.push(GlProfile {
substation_mrid: mrid,
latitude: lat,
longitude: lon,
});
}
Ok(profiles)
}
pub fn parse_tpbd_profile(xml: &str) -> Result<Vec<TpbdProfile>, IoError> {
let mut profiles: Vec<TpbdProfile> = Vec::new();
let mut current_mrid: Option<String> = None;
let mut bus_a: Option<String> = None;
let mut bus_b: Option<String> = None;
let mut voltage_kv: Option<f64> = None;
for line in xml.lines() {
let trimmed = line.trim();
if trimmed.starts_with("<tp-bd:BoundaryPoint") || trimmed.starts_with("<cim:BoundaryPoint")
{
if let Some(mrid) = current_mrid.take() {
profiles.push(TpbdProfile {
boundary_point_mrid: mrid,
bus_a_mrid: bus_a.take().unwrap_or_default(),
bus_b_mrid: bus_b.take().unwrap_or_default(),
voltage_level_kv: voltage_kv.take().unwrap_or(0.0),
});
} else {
bus_a = None;
bus_b = None;
voltage_kv = None;
}
current_mrid = extract_rdf_id(trimmed);
continue;
}
if trimmed.contains("BoundaryPoint.fromEndNameTso")
|| trimmed.contains("BoundaryPoint.fromEnd")
{
if let Some(res) = extract_resource(trimmed) {
bus_a = Some(res);
} else if let Some(val) = extract_text(trimmed, "tp-bd:BoundaryPoint.fromEndNameTso")
.or_else(|| extract_text(trimmed, "cim:BoundaryPoint.fromEndNameTso"))
{
bus_a = Some(val.to_string());
}
}
if trimmed.contains("BoundaryPoint.toEndNameTso") || trimmed.contains("BoundaryPoint.toEnd")
{
if let Some(res) = extract_resource(trimmed) {
bus_b = Some(res);
} else if let Some(val) = extract_text(trimmed, "tp-bd:BoundaryPoint.toEndNameTso")
.or_else(|| extract_text(trimmed, "cim:BoundaryPoint.toEndNameTso"))
{
bus_b = Some(val.to_string());
}
}
if let Some(val) = extract_text(trimmed, "tp-bd:BoundaryPoint.nominalVoltage")
.or_else(|| extract_text(trimmed, "cim:BoundaryPoint.nominalVoltage"))
{
voltage_kv = parse_f64(val);
}
if (trimmed.starts_with("</tp-bd:BoundaryPoint>")
|| trimmed.starts_with("</cim:BoundaryPoint>"))
&& let Some(mrid) = current_mrid.take()
{
profiles.push(TpbdProfile {
boundary_point_mrid: mrid,
bus_a_mrid: bus_a.take().unwrap_or_default(),
bus_b_mrid: bus_b.take().unwrap_or_default(),
voltage_level_kv: voltage_kv.take().unwrap_or(0.0),
});
}
}
if let Some(mrid) = current_mrid {
profiles.push(TpbdProfile {
boundary_point_mrid: mrid,
bus_a_mrid: bus_a.unwrap_or_default(),
bus_b_mrid: bus_b.unwrap_or_default(),
voltage_level_kv: voltage_kv.unwrap_or(0.0),
});
}
Ok(profiles)
}
pub fn parse_cgmes_extended(
profiles: &HashMap<String, String>,
) -> Result<CgmesExtDataset, IoError> {
let mut base_xml = String::new();
for key in &["EQ", "SSH", "TP", "SV"] {
if let Some(xml) = profiles.get(*key) {
base_xml.push_str(xml);
base_xml.push('\n');
}
}
let sm_bus_map = if !base_xml.trim().is_empty() {
use super::{ObjMap, build_sm_bus_map, collect_objects};
let mut objects = ObjMap::new();
let _ = collect_objects(&base_xml, &mut objects); build_sm_bus_map(&objects)
} else {
std::collections::HashMap::new()
};
let network = if !base_xml.trim().is_empty() {
super::loads(&base_xml).unwrap_or_else(|_| Network::new("cgmes_ext"))
} else {
Network::new("cgmes_ext")
};
let sc_data = if let Some(xml) = profiles.get("SC") {
parse_sc_profile(xml)?
} else {
HashMap::new()
};
let dy_data = if let Some(xml) = profiles.get("DY") {
parse_dy_profile(xml)?
} else {
Vec::new()
};
let dynamic_model = if let Some(xml) = profiles.get("DY") {
match super::dynamics::parse_cgmes_dy(&[xml.as_str()], &sm_bus_map) {
Ok(dm) => {
tracing::info!(
generators = dm.generators.len(),
exciters = dm.exciters.len(),
governors = dm.governors.len(),
pss = dm.pss.len(),
"CGMES DY profile parsed"
);
Some(dm)
}
Err(e) => {
tracing::warn!(error = %e, "CGMES DY profile parse failed — dynamic_model will be None");
None
}
}
} else {
None
};
let gl_data = if let Some(xml) = profiles.get("GL") {
parse_gl_profile(xml)?
} else {
Vec::new()
};
let tpbd_data = if let Some(xml) = profiles.get("TPBD") {
parse_tpbd_profile(xml)?
} else {
Vec::new()
};
Ok(CgmesExtDataset {
network,
sc_data,
dy_data,
dynamic_model,
gl_data,
tpbd_data,
})
}
pub fn write_cgmes_sc_profile(network: &Network, sc_data: &HashMap<String, ScProfile>) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str("<rdf:RDF\n");
out.push_str(" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"\n");
out.push_str(" xmlns:cim=\"http://iec.ch/TC57/2013/CIM-schema-cim16#\"\n");
out.push_str(" xmlns:sc=\"http://iec.ch/TC57/2013/CIM-schema-cim16-SC#\">\n");
out.push_str(&format!(
" <!-- SC Profile generated by Surge — network: {} -->\n",
network.name
));
for (mrid, sc) in sc_data {
out.push_str(&format!(" <cim:ACLineSegment rdf:ID=\"{}\">\n", mrid));
if let Some(v) = sc.r0_pu {
out.push_str(&format!(
" <cim:ACLineSegment.r0>{v}</cim:ACLineSegment.r0>\n"
));
}
if let Some(v) = sc.x0_pu {
out.push_str(&format!(
" <cim:ACLineSegment.x0>{v}</cim:ACLineSegment.x0>\n"
));
}
if let Some(v) = sc.r2_pu {
out.push_str(&format!(
" <cim:ACLineSegment.r2>{v}</cim:ACLineSegment.r2>\n"
));
}
if let Some(v) = sc.x2_pu {
out.push_str(&format!(
" <cim:ACLineSegment.x2>{v}</cim:ACLineSegment.x2>\n"
));
}
if let Some(v) = sc.ikss_ka {
out.push_str(&format!(
" <cim:ACLineSegment.Ikss>{v}</cim:ACLineSegment.Ikss>\n"
));
}
out.push_str(" </cim:ACLineSegment>\n");
}
out.push_str("</rdf:RDF>\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc_profile_parse() {
let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cim="http://iec.ch/TC57/2013/CIM-schema-cim16#">
<cim:ACLineSegment rdf:ID="_line1">
<cim:ACLineSegment.r0>0.05</cim:ACLineSegment.r0>
<cim:ACLineSegment.x0>0.15</cim:ACLineSegment.x0>
<cim:ACLineSegment.r2>0.04</cim:ACLineSegment.r2>
<cim:ACLineSegment.x2>0.12</cim:ACLineSegment.x2>
<cim:ACLineSegment.Ikss>12.5</cim:ACLineSegment.Ikss>
</cim:ACLineSegment>
</rdf:RDF>"##;
let map = parse_sc_profile(xml).expect("SC parse should succeed");
assert!(map.contains_key("_line1"), "Expected _line1 key in map");
let sc = &map["_line1"];
assert!(
(sc.r0_pu.unwrap() - 0.05).abs() < 1e-10,
"r0 mismatch: {:?}",
sc.r0_pu
);
assert!(
(sc.x0_pu.unwrap() - 0.15).abs() < 1e-10,
"x0 mismatch: {:?}",
sc.x0_pu
);
assert!(
(sc.r2_pu.unwrap() - 0.04).abs() < 1e-10,
"r2 mismatch: {:?}",
sc.r2_pu
);
assert!(
(sc.x2_pu.unwrap() - 0.12).abs() < 1e-10,
"x2 mismatch: {:?}",
sc.x2_pu
);
assert!(
(sc.ikss_ka.unwrap() - 12.5).abs() < 1e-10,
"ikss mismatch: {:?}",
sc.ikss_ka
);
}
#[test]
fn test_dy_profile_parse() {
let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cim="http://iec.ch/TC57/2013/CIM-schema-cim16#">
<cim:GovGAST2 rdf:ID="_gov1">
<cim:TurbineGovernorDynamics.SynchronousMachineDynamics rdf:resource="#_gen1"/>
</cim:GovGAST2>
<cim:ExcIEEEST1A rdf:ID="_exc1">
<cim:ExcitationSystemDynamics.SynchronousMachineDynamics rdf:resource="#_gen1"/>
</cim:ExcIEEEST1A>
</rdf:RDF>"##;
let profiles = parse_dy_profile(xml).expect("DY parse should succeed");
assert!(!profiles.is_empty(), "Expected at least one DY profile");
let gen1 = profiles
.iter()
.find(|p| p.machine_mrid == "_gen1")
.expect("Expected DyProfile for _gen1");
assert_eq!(
gen1.governor_type.as_deref(),
Some("cim:GovGAST2"),
"governor_type mismatch: {:?}",
gen1.governor_type
);
}
#[test]
fn test_gl_coordinates() {
let xml = r##"<?xml version="1.0" encoding="UTF-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cim="http://iec.ch/TC57/2013/CIM-schema-cim16#">
<cim:Substation rdf:ID="_sub1">
<cim:CoordinatePair.xPosition>-97.5</cim:CoordinatePair.xPosition>
<cim:CoordinatePair.yPosition>32.8</cim:CoordinatePair.yPosition>
</cim:Substation>
</rdf:RDF>"##;
let profiles = parse_gl_profile(xml).expect("GL parse should succeed");
assert!(!profiles.is_empty(), "Expected at least one GL profile");
let sub1 = profiles
.iter()
.find(|p| p.substation_mrid == "_sub1")
.expect("Expected GlProfile for _sub1");
assert!(
(sub1.longitude - (-97.5)).abs() < 1e-10,
"longitude mismatch: {}",
sub1.longitude
);
assert!(
(sub1.latitude - 32.8).abs() < 1e-10,
"latitude mismatch: {}",
sub1.latitude
);
}
#[test]
fn test_write_sc_profile_round_trip() {
use surge_network::Network;
use surge_network::network::{Bus, BusType};
let mut net = Network::new("test_sc");
net.buses.push(Bus::new(1, BusType::Slack, 345.0));
let mut sc_data = HashMap::new();
sc_data.insert(
"_line42".to_string(),
ScProfile {
r0_pu: Some(0.03),
x0_pu: Some(0.09),
r2_pu: None,
x2_pu: None,
ikss_ka: Some(8.0),
},
);
let xml = write_cgmes_sc_profile(&net, &sc_data);
assert!(xml.contains("_line42"), "MRID should appear in output");
assert!(xml.contains("<cim:ACLineSegment.r0>0.03</cim:ACLineSegment.r0>"));
assert!(xml.contains("<cim:ACLineSegment.x0>0.09</cim:ACLineSegment.x0>"));
assert!(xml.contains("<cim:ACLineSegment.Ikss>8</cim:ACLineSegment.Ikss>"));
let reparsed = parse_sc_profile(&xml).unwrap();
let sc = reparsed
.get("_line42")
.expect("_line42 should be present after re-parse");
assert!((sc.r0_pu.unwrap() - 0.03).abs() < 1e-10);
assert!((sc.ikss_ka.unwrap() - 8.0).abs() < 1e-10);
}
}