use std::collections::HashMap;
use surge_network::Network;
use surge_network::network::{Branch, BranchType, Bus, BusType, Generator};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PscadError {
#[error("XML parse error: {0}")]
Xml(String),
#[error("missing required attribute '{0}' on element")]
MissingAttr(String),
}
#[derive(Debug, Clone)]
pub struct PscadComponent {
pub comp_type: String,
pub name: String,
pub parameters: HashMap<String, String>,
pub connections: Vec<String>,
}
impl PscadComponent {
fn get_param_f64(&self, key: &str) -> Option<f64> {
self.parameters.get(key)?.parse::<f64>().ok()
}
fn get_param_str(&self, key: &str) -> Option<&str> {
self.parameters.get(key).map(String::as_str)
}
}
pub struct PscadProject {
pub components: Vec<PscadComponent>,
pub study_name: String,
pub frequency_hz: f64,
}
pub struct PscadConversionResult {
pub network: Network,
pub unmapped_components: Vec<PscadComponent>,
pub warnings: Vec<String>,
}
fn attr_value<'a>(line: &'a str, attr: &str) -> Option<&'a str> {
for sep in &[format!("{attr}=\""), format!("{attr}='")] {
if let Some(pos) = line.find(sep.as_str()) {
let start = pos + sep.len();
let rest = &line[start..];
let quote_char = if sep.ends_with('"') { '"' } else { '\'' };
if let Some(end) = rest.find(quote_char) {
return Some(&rest[..end]);
}
}
}
None
}
fn text_content<'a>(line: &'a str, open: &str, close: &str) -> Option<&'a str> {
if let (Some(s), Some(e)) = (line.find(open), line.find(close)) {
let start = s + open.len();
if start <= e {
return Some(line[start..e].trim());
}
}
None
}
pub fn parse_pscx(xml_content: &str) -> Result<PscadProject, PscadError> {
let mut study_name = String::from("unnamed");
let mut frequency_hz = 60.0_f64;
let mut components: Vec<PscadComponent> = Vec::new();
let mut current_comp: Option<PscadComponent> = None;
let mut in_components = false;
for line in xml_content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("<project") {
if let Some(name) = attr_value(trimmed, "name") {
study_name = name.to_string();
}
if let Some(freq) = attr_value(trimmed, "frequency")
&& let Ok(f) = freq.parse::<f64>()
{
frequency_hz = f;
}
continue;
}
if trimmed == "<components>" {
in_components = true;
continue;
}
if trimmed == "</components>" {
in_components = false;
if let Some(comp) = current_comp.take() {
components.push(comp);
}
continue;
}
if !in_components {
continue;
}
if trimmed.starts_with("<component") && !trimmed.starts_with("</component") {
if let Some(comp) = current_comp.take() {
components.push(comp);
}
let comp_type = attr_value(trimmed, "type")
.map(str::to_string)
.unwrap_or_default();
let name = attr_value(trimmed, "name")
.map(str::to_string)
.unwrap_or_default();
current_comp = Some(PscadComponent {
comp_type,
name,
parameters: HashMap::new(),
connections: Vec::new(),
});
continue;
}
if trimmed == "</component>" {
if let Some(mut comp) = current_comp.take() {
for key in &["from", "to", "bus", "node", "node1", "node2"] {
if let Some(val) = comp.parameters.get(*key) {
let v = val.clone();
if !comp.connections.contains(&v) {
comp.connections.push(v);
}
}
}
components.push(comp);
}
continue;
}
if trimmed.starts_with("<param")
&& let Some(comp) = &mut current_comp
&& let Some(param_name) = attr_value(trimmed, "name")
{
let value = text_content(trimmed, ">", "</param>")
.or_else(|| attr_value(trimmed, "value"))
.unwrap_or("")
.to_string();
comp.parameters.insert(param_name.to_string(), value);
}
}
if let Some(comp) = current_comp.take() {
components.push(comp);
}
Ok(PscadProject {
components,
study_name,
frequency_hz,
})
}
pub fn pscad_to_network(project: &PscadProject) -> PscadConversionResult {
let mut network = Network::new(&project.study_name);
network.base_mva = 100.0;
let mut unmapped: Vec<PscadComponent> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut bus_name_to_id: HashMap<String, u32> = HashMap::new();
let mut next_bus_id: u32 = 1;
for comp in &project.components {
if comp.comp_type == "BUS" && !bus_name_to_id.contains_key(&comp.name) {
bus_name_to_id.insert(comp.name.clone(), next_bus_id);
next_bus_id += 1;
}
}
for comp in &project.components {
for conn in &comp.connections {
if !bus_name_to_id.contains_key(conn) && !conn.is_empty() {
bus_name_to_id.insert(conn.clone(), next_bus_id);
next_bus_id += 1;
}
}
}
for comp in &project.components {
if comp.comp_type == "BUS" {
let bus_id = bus_name_to_id[&comp.name];
let base_kv = comp.get_param_f64("BaseKV").unwrap_or(1.0);
let bus = Bus::new(bus_id, BusType::PQ, base_kv);
network.buses.push(bus);
}
}
for (name, &id) in &bus_name_to_id {
if !network.buses.iter().any(|b| b.number == id) {
let bus = Bus::new(id, BusType::PQ, 1.0);
network.buses.push(bus);
warnings.push(format!(
"Bus '{}' (id={}) inferred from connection — no BUS component found",
name, id
));
}
}
network.buses.sort_by_key(|b| b.number);
let _next_branch = 0usize;
for comp in &project.components {
match comp.comp_type.as_str() {
"BUS" => {
}
"TLine" => {
let from_name = comp.get_param_str("from").unwrap_or("");
let to_name = comp.get_param_str("to").unwrap_or("");
if from_name.is_empty() || to_name.is_empty() {
warnings.push(format!("TLine '{}' missing from/to — skipped", comp.name));
continue;
}
let from_id = match bus_name_to_id.get(from_name) {
Some(&id) => id,
None => {
warnings.push(format!(
"TLine '{}': unknown bus '{}' — skipped",
comp.name, from_name
));
continue;
}
};
let to_id = match bus_name_to_id.get(to_name) {
Some(&id) => id,
None => {
warnings.push(format!(
"TLine '{}': unknown bus '{}' — skipped",
comp.name, to_name
));
continue;
}
};
let r = comp.get_param_f64("R1").unwrap_or(0.01);
let x = comp.get_param_f64("X1").unwrap_or(0.1);
let b = comp.get_param_f64("B1").unwrap_or(0.0);
network
.branches
.push(Branch::new_line(from_id, to_id, r, x, b));
}
"Transformer" => {
let from_name = comp
.get_param_str("from")
.or_else(|| comp.get_param_str("bus1"))
.unwrap_or("");
let to_name = comp
.get_param_str("to")
.or_else(|| comp.get_param_str("bus2"))
.unwrap_or("");
if from_name.is_empty() || to_name.is_empty() {
warnings.push(format!(
"Transformer '{}' missing from/to — skipped",
comp.name
));
continue;
}
let from_id = match bus_name_to_id.get(from_name) {
Some(&id) => id,
None => {
warnings.push(format!(
"Transformer '{}': unknown bus '{}' — skipped",
comp.name, from_name
));
continue;
}
};
let to_id = match bus_name_to_id.get(to_name) {
Some(&id) => id,
None => {
warnings.push(format!(
"Transformer '{}': unknown bus '{}' — skipped",
comp.name, to_name
));
continue;
}
};
let r = comp.get_param_f64("R").unwrap_or(0.005);
let x = comp.get_param_f64("X").unwrap_or(0.05);
let tap = comp.get_param_f64("Tap").unwrap_or(1.0);
let mut branch = Branch::new_line(from_id, to_id, r, x, 0.0);
branch.tap = tap;
branch.branch_type = BranchType::Transformer;
network.branches.push(branch);
}
"3Phase_Source" => {
let bus_name = comp.get_param_str("bus").unwrap_or("");
if bus_name.is_empty() {
warnings.push(format!(
"3Phase_Source '{}' has no 'bus' param — skipped",
comp.name
));
continue;
}
let bus_id = match bus_name_to_id.get(bus_name) {
Some(&id) => id,
None => {
warnings.push(format!(
"3Phase_Source '{}': unknown bus '{}' — skipped",
comp.name, bus_name
));
continue;
}
};
if let Some(bus) = network.buses.iter_mut().find(|b| b.number == bus_id) {
bus.bus_type = BusType::Slack;
}
let pg = comp.get_param_f64("P").unwrap_or(0.0) / network.base_mva;
let vm = comp
.get_param_f64("V")
.and_then(|v| {
let base_kv = network
.buses
.iter()
.find(|b| b.number == bus_id)
.map(|b| b.base_kv)
.unwrap_or(1.0);
if base_kv > 0.0 {
Some(v / base_kv)
} else {
None
}
})
.unwrap_or(1.0);
let mut generator = Generator::new(bus_id, pg * network.base_mva, vm);
generator.p = pg * network.base_mva;
network.generators.push(generator);
}
_ => {
unmapped.push(comp.clone());
}
}
}
PscadConversionResult {
network,
unmapped_components: unmapped,
warnings,
}
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL_PSCX_2BUS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<project name="test_2bus" frequency="60">
<components>
<component type="BUS" name="BUS_A">
<param name="BaseKV">13.8</param>
</component>
<component type="TLine" name="LINE_AB">
<param name="R1">0.01</param>
<param name="X1">0.1</param>
<param name="from">BUS_A</param>
<param name="to">BUS_B</param>
</component>
</components>
</project>"#;
const PSCX_3BUS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<project name="three_bus" frequency="60">
<components>
<component type="BUS" name="BUS_1">
<param name="BaseKV">138.0</param>
</component>
<component type="BUS" name="BUS_2">
<param name="BaseKV">138.0</param>
</component>
<component type="BUS" name="BUS_3">
<param name="BaseKV">138.0</param>
</component>
<component type="TLine" name="LINE_12">
<param name="R1">0.02</param>
<param name="X1">0.06</param>
<param name="from">BUS_1</param>
<param name="to">BUS_2</param>
</component>
<component type="3Phase_Source" name="SLACK_SRC">
<param name="bus">BUS_1</param>
<param name="V">138.0</param>
<param name="P">100.0</param>
</component>
</components>
</project>"#;
const PSCX_50HZ: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<project name="eu_grid" frequency="50">
<components>
</components>
</project>"#;
#[test]
fn test_pscx_parse_minimal() {
let project = parse_pscx(MINIMAL_PSCX_2BUS).expect("parse_pscx should succeed");
assert!(
project.components.len() >= 2,
"Expected at least 2 components, got {}",
project.components.len()
);
assert_eq!(project.study_name, "test_2bus");
assert!(
project.components.iter().any(|c| c.comp_type == "BUS"),
"Expected at least one BUS component"
);
assert!(
project.components.iter().any(|c| c.comp_type == "TLine"),
"Expected at least one TLine component"
);
}
#[test]
fn test_pscad_to_network_buses() {
let project = parse_pscx(PSCX_3BUS).expect("parse_pscx should succeed");
let result = pscad_to_network(&project);
assert_eq!(
result.network.n_buses(),
3,
"Expected 3 buses, got {}",
result.network.n_buses()
);
assert!(
result.warnings.iter().all(|w| !w.contains("unknown bus")),
"Unexpected 'unknown bus' warnings: {:?}",
result.warnings
);
}
#[test]
fn test_pscx_frequency() {
let project = parse_pscx(PSCX_50HZ).expect("parse_pscx should succeed");
assert!(
(project.frequency_hz - 50.0).abs() < 1e-10,
"Expected 50 Hz, got {}",
project.frequency_hz
);
}
#[test]
fn test_pscad_source_promotes_slack() {
let project = parse_pscx(PSCX_3BUS).unwrap();
let result = pscad_to_network(&project);
let slack_count = result
.network
.buses
.iter()
.filter(|b| b.bus_type == BusType::Slack)
.count();
assert_eq!(
slack_count, 1,
"Expected exactly 1 slack bus, got {slack_count}"
);
}
#[test]
fn test_pscad_unmapped_components() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project name="misc" frequency="60">
<components>
<component type="BUS" name="BUS_A">
<param name="BaseKV">1.0</param>
</component>
<component type="SVC" name="SVC_1">
<param name="bus">BUS_A</param>
</component>
</components>
</project>"#;
let project = parse_pscx(xml).unwrap();
let result = pscad_to_network(&project);
assert!(
result
.unmapped_components
.iter()
.any(|c| c.comp_type == "SVC"),
"Expected SVC in unmapped_components"
);
}
}