use std::path::Path;
use surge_network::Network;
use surge_network::market::CostCurve;
use surge_network::network::{
Branch, BranchType, Bus, BusType, DcBranch, DcBus, DcConverter, DcConverterStation, FuelParams,
GenType, Generator, GeneratorTechnology, Load,
};
use thiserror::Error;
fn bus_type_from_matpower(code: u32) -> BusType {
match code {
2 => BusType::PV,
3 => BusType::Slack,
4 => BusType::Isolated,
_ => BusType::PQ,
}
}
#[derive(Error, Debug)]
pub enum MatpowerError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error on line {line}: {message}")]
Parse { line: usize, message: String },
#[error("missing required section: {0}")]
MissingSection(String),
#[error("insufficient columns in {section} row {row}: expected {expected}, got {got}")]
InsufficientColumns {
section: String,
row: usize,
expected: usize,
got: usize,
},
#[error("invalid gencost: {0}")]
InvalidGencost(String),
#[error("invalid float value: {0}")]
InvalidFloat(String),
#[error("non-finite value in {section} row {row}: {field} = {value}")]
NonFiniteValue {
section: &'static str,
row: usize,
field: &'static str,
value: f64,
},
#[error("expression nesting too deep: {0}")]
ExpressionTooDeep(String),
#[error("file too large: {0} bytes exceeds limit of {1} bytes")]
FileTooLarge(u64, u64),
}
pub fn parse_file(path: &Path) -> Result<Network, MatpowerError> {
const MAX_FILE_SIZE: u64 = 500 * 1024 * 1024; let metadata = std::fs::metadata(path)?;
if metadata.len() > MAX_FILE_SIZE {
return Err(MatpowerError::FileTooLarge(metadata.len(), MAX_FILE_SIZE));
}
let content = std::fs::read_to_string(path)?;
parse_str(&content)
}
pub fn parse_str(content: &str) -> Result<Network, MatpowerError> {
let mut name = String::from("unknown");
let mut base_mva = 100.0;
let mut bus_rows: Vec<Vec<f64>> = Vec::new();
let mut gen_rows: Vec<Vec<f64>> = Vec::new();
let mut branch_rows: Vec<Vec<f64>> = Vec::new();
let mut gencost_rows: Vec<Vec<f64>> = Vec::new();
let mut bus_name_rows: Vec<String> = Vec::new();
let mut gentype_rows: Vec<String> = Vec::new();
let mut genfuel_rows: Vec<String> = Vec::new();
let mut dc_bus_rows: Vec<Vec<f64>> = Vec::new();
let mut dc_conv_rows: Vec<Vec<f64>> = Vec::new();
let mut dc_branch_rows: Vec<Vec<f64>> = Vec::new();
#[derive(PartialEq)]
enum Section {
None,
Bus,
Gen,
Branch,
GenCost,
BusName,
GenType,
GenFuel,
DcBus,
DcConv,
DcBranch,
Other,
}
let mut section = Section::None;
for (line_idx, raw_line) in content.lines().enumerate() {
let line_num = line_idx + 1;
let line = match raw_line.find('%') {
Some(idx) => &raw_line[..idx],
None => raw_line,
};
let line = line.trim();
if line.is_empty() {
continue;
}
if line.contains("};")
&& matches!(
section,
Section::BusName | Section::GenType | Section::GenFuel
)
{
section = Section::None;
continue;
}
let is_bracket_end = line.contains("];");
let is_brace_end = line.contains("};");
if is_bracket_end || is_brace_end {
if section == Section::Bus
|| section == Section::Gen
|| section == Section::Branch
|| section == Section::GenCost
|| section == Section::DcBus
|| section == Section::DcConv
|| section == Section::DcBranch
{
let delim = if is_bracket_end { "];" } else { "};" };
let data_part = line.split(delim).next().unwrap_or("").trim();
let data_part = data_part
.trim_end_matches(';')
.trim_end_matches(']')
.trim_end_matches('}')
.trim();
if !data_part.is_empty()
&& let Some(row) = parse_numeric_row(data_part)
{
match section {
Section::Bus => bus_rows.push(row),
Section::Gen => gen_rows.push(row),
Section::Branch => branch_rows.push(row),
Section::GenCost => gencost_rows.push(row),
Section::DcBus => dc_bus_rows.push(row),
Section::DcConv => dc_conv_rows.push(row),
Section::DcBranch => dc_branch_rows.push(row),
_ => {}
}
}
}
section = Section::None;
continue;
}
if line.starts_with("function") {
if let Some(eq_pos) = line.find('=') {
name = line[eq_pos + 1..]
.trim()
.trim_end_matches(';')
.trim()
.to_string();
}
continue;
}
if line.contains("baseMVA") && line.contains('=') {
if let Some(eq_pos) = line.find('=') {
let lhs = line[..eq_pos].trim();
if lhs == "mpc.baseMVA" || lhs == "baseMVA" {
let val_str = line[eq_pos + 1..].trim().trim_end_matches(';').trim();
base_mva = eval_simple_expr(val_str).ok_or_else(|| MatpowerError::Parse {
line: line_num,
message: format!("invalid baseMVA value: '{val_str}'"),
})?;
}
}
continue;
}
if is_section_start(line, "bus") {
section = Section::Bus;
try_parse_inline_data(line, &mut bus_rows);
continue;
}
if is_section_start(line, "gencost") {
section = Section::GenCost;
try_parse_inline_data(line, &mut gencost_rows);
continue;
}
if is_section_start(line, "gen") {
section = Section::Gen;
try_parse_inline_data(line, &mut gen_rows);
continue;
}
if is_section_start(line, "branch") {
section = Section::Branch;
try_parse_inline_data(line, &mut branch_rows);
continue;
}
if is_section_start(line, "busdc") || is_section_start(line, "dcbus") {
section = Section::DcBus;
try_parse_inline_data(line, &mut dc_bus_rows);
continue;
}
if is_section_start(line, "convdc") || is_section_start(line, "dcconv") {
section = Section::DcConv;
try_parse_inline_data(line, &mut dc_conv_rows);
continue;
}
if is_section_start(line, "branchdc") || is_section_start(line, "dcbranch") {
section = Section::DcBranch;
try_parse_inline_data(line, &mut dc_branch_rows);
continue;
}
if line.contains("mpc.bus_name") && line.contains('=') && line.contains('{') {
section = Section::BusName;
continue;
}
if line.contains("mpc.gentype") && line.contains('=') && line.contains('{') {
section = Section::GenType;
continue;
}
if line.contains("mpc.genfuel") && line.contains('=') && line.contains('{') {
section = Section::GenFuel;
continue;
}
if line.contains("mpc.") && line.contains('=') {
if section != Section::Bus
&& section != Section::Gen
&& section != Section::Branch
&& section != Section::DcBus
&& section != Section::DcConv
&& section != Section::DcBranch
{
section = Section::Other;
}
continue;
}
match section {
Section::Bus
| Section::Gen
| Section::Branch
| Section::GenCost
| Section::DcBus
| Section::DcConv
| Section::DcBranch => {
let row_str = line.trim_end_matches(';').trim();
if let Some(row) = parse_numeric_row(row_str) {
match section {
Section::Bus => bus_rows.push(row),
Section::Gen => gen_rows.push(row),
Section::Branch => branch_rows.push(row),
Section::GenCost => gencost_rows.push(row),
Section::DcBus => dc_bus_rows.push(row),
Section::DcConv => dc_conv_rows.push(row),
Section::DcBranch => dc_branch_rows.push(row),
_ => {}
}
}
}
Section::BusName => {
if line.contains('\'') {
bus_name_rows.push(parse_cell_array_entry(line));
}
}
Section::GenType => {
if line.contains('\'') {
gentype_rows.push(parse_cell_array_entry(line));
}
}
Section::GenFuel => {
if line.contains('\'') {
genfuel_rows.push(parse_cell_array_entry(line));
}
}
_ => {}
}
}
if bus_rows.is_empty() {
return Err(MatpowerError::MissingSection("bus".into()));
}
if branch_rows.is_empty() {
return Err(MatpowerError::MissingSection("branch".into()));
}
let conversions = detect_conversions(content);
let mut network = Network::new(&name);
network.base_mva = base_mva;
let mut bus_pd_qd: Vec<(u32, f64, f64)> = Vec::new();
for (i, row) in bus_rows.iter().enumerate() {
if row.len() < 13 {
return Err(MatpowerError::InsufficientColumns {
section: "bus".into(),
row: i + 1,
expected: 13,
got: row.len(),
});
}
network.buses.push(Bus {
number: row[0] as u32,
name: String::new(),
bus_type: bus_type_from_matpower(row[1] as u32),
shunt_conductance_mw: row[4],
shunt_susceptance_mvar: row[5],
area: row[6] as u32,
voltage_magnitude_pu: row[7],
voltage_angle_rad: row[8].to_radians(), base_kv: row[9],
zone: row[10] as u32,
voltage_max_pu: row[11],
voltage_min_pu: row[12],
island_id: 0,
latitude: None, longitude: None,
..Bus::new(0, BusType::PQ, 0.0)
});
bus_pd_qd.push((row[0] as u32, row[2], row[3]));
let row_num = i + 1;
check_finite(row[2], "bus", row_num, "pd")?;
check_finite(row[3], "bus", row_num, "qd")?;
check_finite(row[4], "bus", row_num, "gs")?;
check_finite(row[5], "bus", row_num, "bs")?;
check_finite(row[7], "bus", row_num, "vm")?;
check_finite(row[8], "bus", row_num, "va")?;
check_finite(row[9], "bus", row_num, "base_kv")?;
check_finite(row[11], "bus", row_num, "vmax")?;
check_finite(row[12], "bus", row_num, "vmin")?;
}
let bus_set: std::collections::HashSet<u32> = network.buses.iter().map(|b| b.number).collect();
let mut gen_row_to_network_idx: Vec<Option<usize>> = Vec::with_capacity(gen_rows.len());
for (i, row) in gen_rows.iter().enumerate() {
if row.len() < 10 {
return Err(MatpowerError::InsufficientColumns {
section: "gen".into(),
row: i + 1,
expected: 10,
got: row.len(),
});
}
let gen_bus_number = row[0] as u32;
if !bus_set.contains(&gen_bus_number) {
return Err(MatpowerError::Parse {
line: i + 1,
message: format!("generator at bus {gen_bus_number} references missing bus"),
});
}
let reg_ramp_up_curve: Vec<(f64, f64)> = row
.get(16)
.copied()
.filter(|&v| v.abs() > 1e-20)
.map(|v| vec![(0.0, v)])
.unwrap_or_default();
let ramp_up_curve: Vec<(f64, f64)> = row
.get(17)
.copied()
.filter(|&v| v.abs() > 1e-20)
.map(|v| vec![(0.0, v / 10.0)])
.or_else(|| {
row.get(18)
.copied()
.filter(|&v| v.abs() > 1e-20)
.map(|v| vec![(0.0, v / 30.0)])
})
.unwrap_or_default();
let ramping = if !reg_ramp_up_curve.is_empty() || !ramp_up_curve.is_empty() {
Some(surge_network::network::RampingParams {
reg_ramp_up_curve,
ramp_up_curve,
..Default::default()
})
} else {
None
};
let reactive_capability = {
let pc1_val = row.get(10).copied();
let pc2_val = row.get(11).copied();
let qc1min_val = row.get(12).copied();
let qc1max_val = row.get(13).copied();
let qc2min_val = row.get(14).copied();
let qc2max_val = row.get(15).copied();
let pc1_f = pc1_val.unwrap_or(0.0);
let pc2_f = pc2_val.unwrap_or(0.0);
let pq_curve = match (row.get(12), row.get(13), row.get(14), row.get(15)) {
(Some(&qc1min), Some(&qc1max), Some(&qc2min), Some(&qc2max))
if (pc2_f - pc1_f).abs() > 1e-6 =>
{
let inv = 1.0 / base_mva;
if pc1_f <= pc2_f {
vec![
(pc1_f * inv, qc1max * inv, qc1min * inv),
(pc2_f * inv, qc2max * inv, qc2min * inv),
]
} else {
vec![
(pc2_f * inv, qc2max * inv, qc2min * inv),
(pc1_f * inv, qc1max * inv, qc1min * inv),
]
}
}
_ => vec![],
};
let has_any = pc1_val.is_some()
|| pc2_val.is_some()
|| qc1min_val.is_some()
|| qc1max_val.is_some()
|| qc2min_val.is_some()
|| qc2max_val.is_some()
|| !pq_curve.is_empty();
if has_any {
Some(surge_network::network::ReactiveCapability {
pc1: pc1_val,
pc2: pc2_val,
qc1min: qc1min_val,
qc1max: qc1max_val,
qc2min: qc2min_val,
qc2max: qc2max_val,
pq_curve,
pq_linear_equality: None,
pq_linear_upper: None,
pq_linear_lower: None,
})
} else {
None
}
};
network.generators.push(Generator {
bus: gen_bus_number,
machine_id: None,
p: row[1],
q: row[2],
qmax: row[3],
qmin: row[4],
voltage_setpoint_pu: row[5],
reg_bus: None,
machine_base_mva: if row[6].is_finite() && row[6].abs() > 1e-10 {
row[6]
} else {
network.base_mva
},
in_service: row[7] as i32 > 0,
pmax: row[8],
pmin: row[9],
cost: None,
ramping,
reactive_capability,
agc_participation_factor: row.get(20).copied(),
forced_outage_rate: None,
h_inertia_s: None,
pfr_eligible: true,
..Generator::new(0, 0.0, 1.0)
});
gen_row_to_network_idx.push(Some(network.generators.len() - 1));
let row_num = i + 1;
check_finite(row[1], "gen", row_num, "pg")?;
check_finite(row[2], "gen", row_num, "qg")?;
if row[3].is_nan() {
return Err(MatpowerError::NonFiniteValue {
section: "gen",
row: row_num,
field: "qmax",
value: row[3],
});
}
if row[4].is_nan() {
return Err(MatpowerError::NonFiniteValue {
section: "gen",
row: row_num,
field: "qmin",
value: row[4],
});
}
check_finite(row[5], "gen", row_num, "vs")?;
}
for (i, row) in gencost_rows.iter().enumerate() {
if i >= gen_rows.len() {
break; }
let Some(gen_idx) = gen_row_to_network_idx.get(i).copied().flatten() else {
continue;
};
if row.len() < 4 {
continue;
}
let cost_type = row[0] as i32;
let startup = row[1];
let shutdown = row[2];
let n_raw = row[3];
if !(0.0..=1000.0).contains(&n_raw) || !n_raw.is_finite() {
return Err(MatpowerError::InvalidGencost(format!(
"gencost row {} has n={} breakpoints, must be 0–1000",
i + 1,
n_raw
)));
}
let n = n_raw as usize;
let expected_len = 4 + if cost_type == 1 { 2 * n } else { n };
if row.len() < expected_len {
return Err(MatpowerError::InvalidGencost(format!(
"gencost row {} has {} fields, expected at least {}",
i + 1,
row.len(),
expected_len
)));
}
let cost = match cost_type {
2 => {
Some(CostCurve::Polynomial {
startup,
shutdown,
coeffs: row[4..4 + n].to_vec(),
})
}
1 => {
let points: Vec<(f64, f64)> = (0..n)
.map(|j| (row[4 + 2 * j], row[4 + 2 * j + 1]))
.collect();
Some(CostCurve::PiecewiseLinear {
startup,
shutdown,
points,
})
}
_ => None,
};
if let Some(c) = cost {
network.generators[gen_idx].cost = Some(c);
}
}
for (i, raw_code) in gentype_rows.iter().enumerate() {
let Some(gen_idx) = gen_row_to_network_idx.get(i).copied().flatten() else {
continue;
};
let raw_code = raw_code.trim();
if raw_code.is_empty() {
continue;
}
let generator = &mut network.generators[gen_idx];
generator.source_technology_code = Some(raw_code.to_string());
generator.technology = Some(matpower_technology_from_code(raw_code));
if let Some(class) = matpower_electrical_class_from_code(raw_code) {
generator.gen_type = class;
}
}
for (i, raw_fuel) in genfuel_rows.iter().enumerate() {
let Some(gen_idx) = gen_row_to_network_idx.get(i).copied().flatten() else {
continue;
};
let raw_fuel = raw_fuel.trim();
if raw_fuel.is_empty() {
continue;
}
let generator = &mut network.generators[gen_idx];
generator
.fuel
.get_or_insert_with(FuelParams::default)
.fuel_type = Some(raw_fuel.to_string());
if generator.technology.is_none() {
generator.technology = matpower_technology_from_fuel(raw_fuel);
}
if generator.gen_type == GenType::Unknown
&& let Some(class) = matpower_electrical_class_from_fuel(raw_fuel)
{
generator.gen_type = class;
}
}
for (i, row) in branch_rows.iter().enumerate() {
if row.len() < 11 {
return Err(MatpowerError::InsufficientColumns {
section: "branch".into(),
row: i + 1,
expected: 11,
got: row.len(),
});
}
let tap = if row[8] == 0.0 { 1.0 } else { row[8] };
let rate_a = if row[5].is_infinite() { 0.0 } else { row[5] };
let rate_b = if row[6].is_infinite() { 0.0 } else { row[6] };
let rate_c = if row[7].is_infinite() { 0.0 } else { row[7] };
network.branches.push(Branch {
from_bus: row[0] as u32,
to_bus: row[1] as u32,
circuit: "1".to_string(),
r: row[2],
x: row[3],
b: row[4],
rating_a_mva: rate_a,
rating_b_mva: rate_b,
rating_c_mva: rate_c,
tap,
phase_shift_rad: row[9].to_radians(),
in_service: row[10] as i32 > 0,
angle_diff_min_rad: row.get(11).copied().map(f64::to_radians),
angle_diff_max_rad: row.get(12).copied().map(f64::to_radians),
g_pi: 0.0,
g_mag: 0.0,
b_mag: 0.0,
tab: None,
branch_type: if (tap - 1.0).abs() > 1e-6 || row[9].abs() > 1e-6 {
BranchType::Transformer
} else {
BranchType::Line
},
..Branch::default()
});
let row_num = i + 1;
check_finite(row[2], "branch", row_num, "r")?;
check_finite(row[3], "branch", row_num, "x")?;
check_finite(row[4], "branch", row_num, "b")?;
}
{
let mut counts: std::collections::HashMap<(u32, u32), u32> =
std::collections::HashMap::new();
for branch in &mut network.branches {
let key = (branch.from_bus, branch.to_bus);
let n = counts.entry(key).or_insert(0);
*n += 1;
branch.circuit = n.to_string();
}
}
apply_conversions(&mut network, &conversions, &mut bus_pd_qd);
for &(bus_num, pd, qd) in &bus_pd_qd {
if pd.abs() > 1e-10 || qd.abs() > 1e-10 {
network.loads.push(Load::new(bus_num, pd, qd));
}
}
for (i, name) in bus_name_rows.iter().enumerate() {
if let Some(bus) = network.buses.get_mut(i)
&& !name.is_empty()
{
bus.name = name.clone();
}
}
{
let bus_map = network.bus_index_map();
for g in &network.generators {
if !g.in_service {
continue;
}
if let Some(&idx) = bus_map.get(&g.bus) {
let bt = network.buses[idx].bus_type;
if bt == BusType::PV || bt == BusType::Slack {
network.buses[idx].voltage_magnitude_pu = g.voltage_setpoint_pu;
}
}
}
}
for (row_idx, row) in dc_bus_rows.iter().enumerate() {
if row.len() < 7 {
return Err(MatpowerError::InsufficientColumns {
section: "busdc".to_string(),
row: row_idx,
expected: 7,
got: row.len(),
});
}
network
.hvdc
.ensure_dc_grid(row[1] as u32, None)
.buses
.push(DcBus {
bus_id: row[0] as u32,
p_dc_mw: row[2],
v_dc_pu: row[3],
base_kv_dc: row[4],
v_dc_max: row[5],
v_dc_min: row[6],
cost: row.get(7).copied().unwrap_or(0.0),
g_shunt_siemens: 0.0,
r_ground_ohm: 0.0,
});
}
for (row_idx, row) in dc_conv_rows.iter().enumerate() {
if row.len() < 22 {
return Err(MatpowerError::InsufficientColumns {
section: "convdc".to_string(),
row: row_idx,
expected: 22,
got: row.len(),
});
}
if let Some(dc_grid) = network.hvdc.find_dc_grid_by_bus_mut(row[0] as u32) {
dc_grid
.converters
.push(DcConverter::Vsc(DcConverterStation {
id: String::new(),
dc_bus: row[0] as u32,
ac_bus: row[1] as u32,
control_type_dc: row[2] as u32,
control_type_ac: row[3] as u32,
active_power_mw: row[4],
reactive_power_mvar: row[5],
is_lcc: row[6] as u32 != 0,
voltage_setpoint_pu: row[7],
transformer_r_pu: row[8],
transformer_x_pu: row[9],
transformer: row[10] as u32 != 0,
tap_ratio: row[11],
filter_susceptance_pu: row[12],
filter: row[13] as u32 != 0,
reactor_r_pu: row[14],
reactor_x_pu: row[15],
reactor: row[16] as u32 != 0,
base_kv_ac: row.get(17).copied().unwrap_or(0.0),
voltage_max_pu: row.get(18).copied().unwrap_or(1.1),
voltage_min_pu: row.get(19).copied().unwrap_or(0.9),
current_max_pu: row.get(20).copied().unwrap_or(0.0),
status: row.get(21).map(|&v| v as u32 != 0).unwrap_or(true),
loss_constant_mw: row.get(22).copied().unwrap_or(0.0),
loss_linear: row.get(23).copied().unwrap_or(0.0),
loss_quadratic_rectifier: row.get(24).copied().unwrap_or(0.0),
loss_quadratic_inverter: row.get(25).copied().unwrap_or(0.0),
droop: row.get(26).copied().unwrap_or(0.0),
power_dc_setpoint_mw: row.get(27).copied().unwrap_or(0.0),
voltage_dc_setpoint_pu: row.get(28).copied().unwrap_or(1.0),
active_power_ac_max_mw: if row.len() >= 34 {
row.get(30).copied().unwrap_or(f64::MAX)
} else {
row.get(29).copied().unwrap_or(f64::MAX)
},
active_power_ac_min_mw: if row.len() >= 34 {
row.get(31).copied().unwrap_or(f64::MIN)
} else {
row.get(30).copied().unwrap_or(f64::MIN)
},
reactive_power_ac_max_mvar: if row.len() >= 34 {
row.get(32).copied().unwrap_or(f64::MAX)
} else {
row.get(31).copied().unwrap_or(f64::MAX)
},
reactive_power_ac_min_mvar: if row.len() >= 34 {
row.get(33).copied().unwrap_or(f64::MIN)
} else {
row.get(32).copied().unwrap_or(f64::MIN)
},
}));
}
}
for (row_idx, row) in dc_branch_rows.iter().enumerate() {
if row.len() < 6 {
return Err(MatpowerError::InsufficientColumns {
section: "branchdc".to_string(),
row: row_idx,
expected: 6,
got: row.len(),
});
}
if let Some(dc_grid) = network.hvdc.find_dc_grid_by_bus_mut(row[0] as u32) {
dc_grid.branches.push(DcBranch {
id: format!(
"dc_grid_{}_branch_{}",
dc_grid.id,
dc_grid.branches.len() + 1
),
from_bus: row[0] as u32,
to_bus: row[1] as u32,
r_ohm: row[2],
l_mh: row[3],
c_uf: row[4],
rating_a_mva: row[5],
rating_b_mva: row.get(6).copied().unwrap_or(0.0),
rating_c_mva: row.get(7).copied().unwrap_or(0.0),
status: row.get(8).map(|&v| v as u32 != 0).unwrap_or(true),
});
}
}
Ok(network)
}
fn parse_cell_array_entry(line: &str) -> String {
let line = line.trim();
if let Some(start) = line.find('\'')
&& let Some(end) = line[start + 1..].find('\'')
{
return line[start + 1..start + 1 + end].trim_end().to_string();
}
String::new()
}
fn matpower_technology_from_code(code: &str) -> GeneratorTechnology {
match code.trim().to_ascii_uppercase().as_str() {
"ST" => GeneratorTechnology::SteamTurbine,
"GT" | "JE" => GeneratorTechnology::CombustionTurbine,
"CC" => GeneratorTechnology::CombinedCycle,
"IC" => GeneratorTechnology::InternalCombustion,
"HY" | "HB" | "HR" => GeneratorTechnology::Hydro,
"PS" => GeneratorTechnology::PumpedStorage,
"HK" => GeneratorTechnology::Hydrokinetic,
"NB" | "NU" => GeneratorTechnology::Nuclear,
"GE" => GeneratorTechnology::Geothermal,
"WT" | "WS" | "W1" | "W2" | "W3" | "W4" => GeneratorTechnology::Wind,
"PV" => GeneratorTechnology::SolarPv,
"CP" => GeneratorTechnology::SolarThermal,
"BA" | "ES" => GeneratorTechnology::BatteryStorage,
"CE" => GeneratorTechnology::CompressedAirStorage,
"FW" => GeneratorTechnology::FlywheelStorage,
"FC" => GeneratorTechnology::FuelCell,
"SC" => GeneratorTechnology::SynchronousCondenser,
"SV" => GeneratorTechnology::StaticVarCompensator,
"DL" => GeneratorTechnology::DispatchableLoad,
"DC" => GeneratorTechnology::DcTie,
"OT" => GeneratorTechnology::Other,
_ => GeneratorTechnology::Other,
}
}
fn matpower_electrical_class_from_code(code: &str) -> Option<GenType> {
match code.trim().to_ascii_uppercase().as_str() {
"ST" | "GT" | "JE" | "CC" | "IC" | "HY" | "HB" | "HR" | "PS" | "NB" | "NU" | "GE"
| "SC" => Some(GenType::Synchronous),
"W1" | "W2" => Some(GenType::Asynchronous),
"PV" | "W3" | "W4" | "BA" | "ES" | "FW" | "FC" | "SV" => Some(GenType::InverterBased),
"DL" => Some(GenType::Hybrid),
"WT" | "WS" | "CP" | "CE" | "DC" | "OT" => Some(GenType::Unknown),
_ => None,
}
}
fn matpower_technology_from_fuel(fuel: &str) -> Option<GeneratorTechnology> {
match fuel.trim().to_ascii_lowercase().as_str() {
"wind" => Some(GeneratorTechnology::Wind),
"solar" => Some(GeneratorTechnology::SolarPv),
"hydro" => Some(GeneratorTechnology::Hydro),
"hydrops" => Some(GeneratorTechnology::PumpedStorage),
"nuclear" => Some(GeneratorTechnology::Nuclear),
"geothermal" => Some(GeneratorTechnology::Geothermal),
"battery" | "ess" => Some(GeneratorTechnology::BatteryStorage),
"dl" => Some(GeneratorTechnology::DispatchableLoad),
_ => None,
}
}
fn matpower_electrical_class_from_fuel(fuel: &str) -> Option<GenType> {
match fuel.trim().to_ascii_lowercase().as_str() {
"solar" | "battery" | "ess" => Some(GenType::InverterBased),
"hydro" | "hydrops" | "nuclear" | "coal" | "oil" | "gas" | "ng" => {
Some(GenType::Synchronous)
}
_ => None,
}
}
fn is_section_start(line: &str, section: &str) -> bool {
let pattern = format!("mpc.{section}");
if let Some(pos) = line.find(&pattern) {
let end = pos + pattern.len();
if end >= line.len() {
return false;
}
let next_byte = line.as_bytes()[end];
if next_byte.is_ascii_alphanumeric() || next_byte == b'_' {
return false;
}
let rest = &line[end..];
rest.contains('=') && (rest.contains('[') || rest.contains('{'))
} else {
false
}
}
fn try_parse_inline_data(line: &str, rows: &mut Vec<Vec<f64>>) {
let bracket_pos = line.find('[').or_else(|| line.find('{'));
if let Some(pos) = bracket_pos {
let rest = &line[pos + 1..];
let rest = rest
.trim_end_matches(';')
.trim_end_matches(']')
.trim_end_matches('}')
.trim();
if !rest.is_empty()
&& let Some(row) = parse_numeric_row(rest)
{
rows.push(row);
}
}
}
fn check_finite(
val: f64,
section: &'static str,
row: usize,
field: &'static str,
) -> Result<(), MatpowerError> {
if !val.is_finite() {
return Err(MatpowerError::NonFiniteValue {
section,
row,
field,
value: val,
});
}
Ok(())
}
fn parse_finite_f64(s: &str) -> Option<f64> {
let val: f64 = s.trim().parse().ok()?;
if val.is_nan() {
return None;
}
Some(val)
}
fn parse_numeric_row(s: &str) -> Option<Vec<f64>> {
let s = s.trim();
if s.is_empty() || s.starts_with(']') {
return None;
}
let values: Result<Vec<f64>, _> = s
.split_whitespace()
.filter(|t| !t.is_empty() && *t != ";")
.map(|t| {
let t = t.trim_end_matches(';');
parse_finite_f64(t)
.or_else(|| eval_simple_expr(t))
.ok_or(())
})
.collect();
match values {
Ok(v) if !v.is_empty() => Some(v),
_ => None,
}
}
struct Conversions {
kw_to_mw: bool,
ohms_to_pu: bool,
power_factor: Option<f64>,
}
fn detect_conversions(content: &str) -> Conversions {
let code_content: String = content
.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.starts_with('%') && !trimmed.is_empty()
})
.collect::<Vec<_>>()
.join("\n");
let kw_to_mw = code_content.contains("/ 1e3") && code_content.contains("PD, QD");
let ohms_to_pu =
code_content.contains("Vbase^2 / Sbase") || code_content.contains("Vbase^2/Sbase");
let power_factor = if code_content.contains("* pf") || code_content.contains("*pf") {
code_content
.lines()
.find(|l| {
let t = l.trim();
t.starts_with("pf ") || t.starts_with("pf=")
})
.and_then(|l| {
l.find('=').and_then(|eq| {
l[eq + 1..]
.trim()
.trim_end_matches(';')
.trim()
.parse::<f64>()
.ok()
})
})
} else {
None
};
Conversions {
kw_to_mw,
ohms_to_pu,
power_factor,
}
}
fn apply_conversions(network: &mut Network, conv: &Conversions, bus_pd_qd: &mut [(u32, f64, f64)]) {
if conv.kw_to_mw {
for entry in bus_pd_qd.iter_mut() {
entry.1 /= 1000.0;
entry.2 /= 1000.0;
}
}
if conv.ohms_to_pu {
let bus_kv: std::collections::HashMap<u32, f64> = network
.buses
.iter()
.map(|b| (b.number, b.base_kv))
.collect();
let base_mva = network.base_mva;
if base_mva > 0.0 {
for branch in &mut network.branches {
let base_kv = bus_kv.get(&branch.from_bus).copied().unwrap_or(1.0);
if base_kv > 0.0 {
let z_base = base_kv * base_kv / base_mva;
branch.r /= z_base;
branch.x /= z_base;
}
}
}
}
if let Some(pf) = conv.power_factor {
let sin_phi = (1.0 - pf * pf).sqrt();
for entry in bus_pd_qd.iter_mut() {
let apparent = entry.1;
entry.2 = apparent * sin_phi;
entry.1 = apparent * pf;
}
}
}
fn eval_simple_expr(s: &str) -> Option<f64> {
eval_expr_depth(s, 0)
}
fn eval_expr_depth(s: &str, depth: usize) -> Option<f64> {
if depth > 100 {
return None;
}
let s = s.trim();
if let Ok(v) = s.parse::<f64>() {
return Some(v);
}
if s.starts_with("sqrt(") && s.ends_with(')') {
let inner = &s[5..s.len() - 1];
return eval_expr_depth(inner, depth + 1).map(|v| v.sqrt());
}
let bytes = s.as_bytes();
for i in (1..bytes.len()).rev() {
if bytes[i] == b'+' || bytes[i] == b'-' {
let left = eval_expr_depth(&s[..i], depth + 1)?;
let right = eval_expr_depth(&s[i + 1..], depth + 1)?;
return Some(if bytes[i] == b'+' {
left + right
} else {
left - right
});
}
}
for i in (1..bytes.len()).rev() {
if bytes[i] == b'*' || bytes[i] == b'/' {
let left = eval_expr_depth(&s[..i], depth + 1)?;
let right = eval_expr_depth(&s[i + 1..], depth + 1)?;
return Some(if bytes[i] == b'*' {
left * right
} else {
left / right
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use surge_network::network::{GenType, GeneratorTechnology};
#[allow(dead_code)]
fn data_available() -> bool {
if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
return std::path::Path::new(&p).exists();
}
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("tests/data")
.exists()
}
#[allow(dead_code)]
fn test_data_dir() -> std::path::PathBuf {
if let Ok(p) = std::env::var("SURGE_TEST_DATA") {
return std::path::PathBuf::from(p);
}
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("tests/data")
}
#[test]
fn test_parse_case9() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case9.m");
let net = parse_file(&path).expect("failed to parse case9");
assert_eq!(net.name, "case9");
assert_eq!(net.base_mva, 100.0);
assert_eq!(net.n_buses(), 9);
assert_eq!(net.n_branches(), 9);
assert_eq!(net.generators.len(), 3);
let slack = net.buses.iter().find(|b| b.is_slack()).unwrap();
assert_eq!(slack.number, 1);
let pv_buses: Vec<u32> = net
.buses
.iter()
.filter(|b| b.is_pv())
.map(|b| b.number)
.collect();
assert_eq!(pv_buses, vec![2, 3]);
let bus5_pd: f64 = net
.loads
.iter()
.filter(|l| l.bus == 5)
.map(|l| l.active_power_demand_mw)
.sum();
let bus5_qd: f64 = net
.loads
.iter()
.filter(|l| l.bus == 5)
.map(|l| l.reactive_power_demand_mvar)
.sum();
assert!((bus5_pd - 90.0).abs() < 1e-10);
assert!((bus5_qd - 30.0).abs() < 1e-10);
let bus7_pd: f64 = net
.loads
.iter()
.filter(|l| l.bus == 7)
.map(|l| l.active_power_demand_mw)
.sum();
assert!((bus7_pd - 100.0).abs() < 1e-10);
let bus9_pd: f64 = net
.loads
.iter()
.filter(|l| l.bus == 9)
.map(|l| l.active_power_demand_mw)
.sum();
assert!((bus9_pd - 125.0).abs() < 1e-10);
let gen1 = &net.generators[0];
assert_eq!(gen1.bus, 1);
assert!((gen1.p - 72.3).abs() < 1e-10);
assert!((gen1.voltage_setpoint_pu - 1.04).abs() < 1e-10);
assert!(gen1.in_service);
let br0 = &net.branches[0];
assert_eq!(br0.from_bus, 1);
assert_eq!(br0.to_bus, 4);
assert!((br0.r - 0.0).abs() < 1e-10);
assert!((br0.x - 0.0576).abs() < 1e-10);
assert!((br0.tap - 1.0).abs() < 1e-10); assert!(br0.in_service);
assert!((net.total_generation_mw() - 320.3).abs() < 1e-10);
assert!((net.total_load_mw() - 315.0).abs() < 1e-10);
}
#[test]
fn test_parse_case14() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case14.m");
let net = parse_file(&path).expect("failed to parse case14");
assert_eq!(net.name, "case14");
assert_eq!(net.base_mva, 100.0);
assert_eq!(net.n_buses(), 14);
assert_eq!(net.n_branches(), 20);
assert_eq!(net.generators.len(), 5);
let br_4_7 = net
.branches
.iter()
.find(|b| b.from_bus == 4 && b.to_bus == 7)
.unwrap();
assert!((br_4_7.tap - 0.978).abs() < 1e-10);
let br_4_9 = net
.branches
.iter()
.find(|b| b.from_bus == 4 && b.to_bus == 9)
.unwrap();
assert!((br_4_9.tap - 0.969).abs() < 1e-10);
let bus9 = net.buses.iter().find(|b| b.number == 9).unwrap();
assert!((bus9.shunt_susceptance_mvar - 19.0).abs() < 1e-10);
let bus1 = net.buses.iter().find(|b| b.number == 1).unwrap();
assert!(
(bus1.base_kv - 132.0).abs() < 1e-6,
"bus 1 should be 132 kV HV"
);
let bus5 = net.buses.iter().find(|b| b.number == 5).unwrap();
assert!(
(bus5.base_kv - 132.0).abs() < 1e-6,
"bus 5 should be 132 kV HV"
);
let bus6 = net.buses.iter().find(|b| b.number == 6).unwrap();
assert!(
(bus6.base_kv - 33.0).abs() < 1e-6,
"bus 6 should be 33 kV LV"
);
let bus8 = net.buses.iter().find(|b| b.number == 8).unwrap();
assert!(
(bus8.base_kv - 132.0).abs() < 1e-6,
"bus 8 should be 132 kV HV"
);
let bus9_kv = net.buses.iter().find(|b| b.number == 9).unwrap();
assert!(
(bus9_kv.base_kv - 33.0).abs() < 1e-6,
"bus 9 should be 33 kV LV"
);
for bus in &net.buses {
assert!(
bus.base_kv > 0.0,
"bus {} has zero baseKV — fault analysis will fail",
bus.number
);
}
}
#[test]
fn test_parse_case30() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case30.m");
let net = parse_file(&path).expect("failed to parse case30");
assert_eq!(net.n_buses(), 30);
assert_eq!(net.generators.len(), 6);
}
#[test]
fn test_parse_case118() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case118.m");
let net = parse_file(&path).expect("failed to parse case118");
assert_eq!(net.n_buses(), 118);
assert_eq!(net.generators.len(), 54);
}
#[test]
fn test_parse_string_minimal() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let case = r#"
function mpc = testcase
mpc.version = '2';
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10 0 0 0 0 0 0 0 0 0 0 0;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
"#;
let net = parse_str(case).expect("failed to parse minimal case");
assert_eq!(net.name, "testcase");
assert_eq!(net.n_buses(), 2);
assert_eq!(net.generators.len(), 1);
assert_eq!(net.n_branches(), 1);
let bus_pd = net.bus_load_p_mw();
assert!((bus_pd[1] - 50.0).abs() < 1e-10);
}
#[test]
fn test_parse_generator_classification_sections() {
let case = r#"
function mpc = testcase
mpc.version = '2';
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 2 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
2 40 0 100 -50 1.0 100 1 80 0;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
mpc.gentype = {
'W2';
'PV';
};
mpc.genfuel = {
'wind';
'solar';
};
"#;
let net = parse_str(case).expect("failed to parse classification case");
assert_eq!(net.generators.len(), 2);
assert_eq!(
net.generators[0].source_technology_code.as_deref(),
Some("W2")
);
assert_eq!(
net.generators[0].technology,
Some(GeneratorTechnology::Wind)
);
assert_eq!(net.generators[0].gen_type, GenType::Asynchronous);
assert_eq!(
net.generators[0]
.fuel
.as_ref()
.and_then(|fuel| fuel.fuel_type.as_deref()),
Some("wind")
);
assert_eq!(
net.generators[1].source_technology_code.as_deref(),
Some("PV")
);
assert_eq!(
net.generators[1].technology,
Some(GeneratorTechnology::SolarPv)
);
assert_eq!(net.generators[1].gen_type, GenType::InverterBased);
}
#[test]
fn test_parse_gencost_case9() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case9.m");
let net = parse_file(&path).expect("failed to parse case9");
assert_eq!(net.generators.len(), 3);
for g in &net.generators {
assert!(
g.cost.is_some(),
"generator at bus {} should have cost",
g.bus
);
}
let cost0 = net.generators[0].cost.as_ref().unwrap();
match cost0 {
CostCurve::Polynomial {
startup,
shutdown,
coeffs,
} => {
assert!((startup - 1500.0).abs() < 1e-10);
assert!((shutdown - 0.0).abs() < 1e-10);
assert_eq!(coeffs.len(), 3);
assert!((coeffs[0] - 0.11).abs() < 1e-10);
assert!((coeffs[1] - 5.0).abs() < 1e-10);
assert!((coeffs[2] - 150.0).abs() < 1e-10);
}
_ => panic!("expected polynomial cost"),
}
let cost1 = net.generators[1].cost.as_ref().unwrap();
match cost1 {
CostCurve::Polynomial {
startup, coeffs, ..
} => {
assert!((startup - 2000.0).abs() < 1e-10);
assert_eq!(coeffs.len(), 3);
assert!((coeffs[0] - 0.085).abs() < 1e-10);
assert!((coeffs[1] - 1.2).abs() < 1e-10);
assert!((coeffs[2] - 600.0).abs() < 1e-10);
}
_ => panic!("expected polynomial cost"),
}
let cost2 = net.generators[2].cost.as_ref().unwrap();
match cost2 {
CostCurve::Polynomial {
startup, coeffs, ..
} => {
assert!((startup - 3000.0).abs() < 1e-10);
assert_eq!(coeffs.len(), 3);
assert!((coeffs[0] - 0.1225).abs() < 1e-10);
assert!((coeffs[1] - 1.0).abs() < 1e-10);
assert!((coeffs[2] - 335.0).abs() < 1e-10);
}
_ => panic!("expected polynomial cost"),
}
}
#[test]
fn test_parse_gencost_rejects_invalid_generator_rows() {
let case = r#"
function mpc = testcase
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
99 10 0 200 -100 1.0 100 1 200 0;
2 40 0 150 -75 1.0 100 1 150 0;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
mpc.gencost = [
2 0 0 3 1 2 3;
2 0 0 3 4 5 6;
];
"#;
let err = parse_str(case).expect_err("invalid generator row should be rejected");
assert!(
matches!(err, MatpowerError::Parse { .. }),
"unexpected error: {err:?}"
);
}
#[test]
fn test_parse_gencost_case118() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case118.m");
let net = parse_file(&path).expect("failed to parse case118");
let with_cost = net.generators.iter().filter(|g| g.cost.is_some()).count();
assert_eq!(with_cost, 54, "all 54 generators should have cost data");
}
#[test]
fn test_parse_no_gencost() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let case = r#"
function mpc = testcase
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
"#;
let net = parse_str(case).expect("failed to parse");
assert!(net.generators[0].cost.is_none());
}
#[test]
fn test_parse_gencost_piecewise_linear() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let case = r#"
function mpc = testcase
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
mpc.gencost = [
1 0 0 3 0 0 100 1000 200 3000;
];
"#;
let net = parse_str(case).expect("failed to parse");
let cost = net.generators[0].cost.as_ref().unwrap();
match cost {
CostCurve::PiecewiseLinear { points, .. } => {
assert_eq!(points.len(), 3);
assert!((points[0].0 - 0.0).abs() < 1e-10);
assert!((points[0].1 - 0.0).abs() < 1e-10);
assert!((points[1].0 - 100.0).abs() < 1e-10);
assert!((points[1].1 - 1000.0).abs() < 1e-10);
assert!((points[2].0 - 200.0).abs() < 1e-10);
assert!((points[2].1 - 3000.0).abs() < 1e-10);
}
_ => panic!("expected piecewise-linear cost"),
}
}
#[test]
fn test_parse_ramp_rates_rts_gmlc() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case_RTS_GMLC.m");
let net = parse_file(&path).expect("failed to parse RTS-GMLC");
let g0 = &net.generators[0];
assert_eq!(g0.ramp_agc_mw_per_min(), Some(3.0));
assert!(
(g0.ramp_up_mw_per_min().unwrap() - 0.3).abs() < 1e-10,
"ramp_up={:?}",
g0.ramp_up_mw_per_min()
);
let g2 = &net.generators[2];
assert_eq!(g2.ramp_agc_mw_per_min(), Some(2.0));
assert!(
(g2.ramp_up_mw_per_min().unwrap() - 0.2).abs() < 1e-10,
"ramp_up={:?}",
g2.ramp_up_mw_per_min()
);
}
#[test]
fn test_parse_ramp_rates_case9_none() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case9.m");
let net = parse_file(&path).expect("failed to parse case9");
for g in &net.generators {
assert!(
g.ramp_up_mw_per_min().is_none(),
"case9 should have no ramp_up"
);
assert!(
g.ramp_agc_mw_per_min().is_none(),
"case9 should have no ramp_agc"
);
}
}
#[test]
fn test_parse_bus_name_case14() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case14.m");
let net = parse_file(&path).expect("failed to parse case14");
assert_eq!(net.n_buses(), 14);
assert_eq!(net.buses[0].name, "Bus 1 HV");
assert_eq!(net.buses[5].name, "Bus 6 LV");
assert_eq!(net.buses[13].name, "Bus 14 LV");
}
#[test]
fn test_parse_bus_name_case118() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case118.m");
let net = parse_file(&path).expect("failed to parse case118");
assert_eq!(net.n_buses(), 118);
assert_eq!(net.buses[0].name, "Riversde V2");
assert_eq!(net.buses[1].name, "Pokagon V2");
assert_eq!(net.buses[4].name, "Olive V2");
for bus in &net.buses {
assert!(
!bus.name.is_empty(),
"bus {} should have a name",
bus.number
);
}
}
#[test]
fn test_parse_bus_name_inline() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let case = r#"
function mpc = testcase
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
3 2 30 10 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
2 3 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
mpc.bus_name = {
'SUBSTATION A';
'SUBSTATION B';
'SUBSTATION C';
};
"#;
let net = parse_str(case).expect("failed to parse");
assert_eq!(net.buses[0].name, "SUBSTATION A");
assert_eq!(net.buses[1].name, "SUBSTATION B");
assert_eq!(net.buses[2].name, "SUBSTATION C");
}
#[test]
fn test_parse_bus_name_activsg2000() {
if !data_available() {
eprintln!("SKIP: SURGE_TEST_DATA not set and tests/data not present");
return;
}
let path = test_data_dir().join("case_ACTIVSg2000.m");
let net = parse_file(&path).expect("failed to parse ACTIVSg2000");
assert_eq!(net.n_buses(), 2000);
assert_eq!(net.buses[0].name, "ODESSA 2 0");
assert_eq!(net.buses[1].name, "PRESIDIO 2 0");
let unnamed = net.buses.iter().filter(|b| b.name.is_empty()).count();
assert_eq!(
unnamed, 0,
"all 2000 buses should have names from mpc.bus_name"
);
}
#[test]
fn test_bus_angles_converted_to_radians() {
let case = r#"
function mpc = test
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 45.0 345 1 1.1 0.9;
];
mpc.gen = [
1 0 0 300 -300 1.0 100 1 250 10 0 0 0 0 0 0 0 0 0 0 0;
];
mpc.branch = [
1 1 0.01 0.1 0 100 100 100 0 0 1 -360 360;
];
"#;
let net = parse_str(case).expect("failed to parse");
let expected_radians = 45.0_f64.to_radians();
assert!((net.buses[0].voltage_angle_rad - expected_radians).abs() < 1e-10);
}
#[test]
fn test_branch_angmin_angmax_converted_to_radians() {
let case = r#"
function mpc = test
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -30 30;
];
"#;
let net = parse_str(case).expect("failed to parse");
let br = &net.branches[0];
let expected_min = (-30.0_f64).to_radians();
let expected_max = 30.0_f64.to_radians();
assert!(
(br.angle_diff_min_rad.unwrap() - expected_min).abs() < 1e-12,
"angmin should be converted to radians: got {}, expected {}",
br.angle_diff_min_rad.unwrap(),
expected_min
);
assert!(
(br.angle_diff_max_rad.unwrap() - expected_max).abs() < 1e-12,
"angmax should be converted to radians: got {}, expected {}",
br.angle_diff_max_rad.unwrap(),
expected_max
);
}
#[test]
fn test_branch_angmin_angmax_default_360_converted() {
let case = r#"
function mpc = test
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
"#;
let net = parse_str(case).expect("failed to parse");
let br = &net.branches[0];
let expected_min = (-360.0_f64).to_radians(); let expected_max = 360.0_f64.to_radians(); assert!(
(br.angle_diff_min_rad.unwrap() - expected_min).abs() < 1e-12,
"angmin -360 deg -> -2*pi rad"
);
assert!(
(br.angle_diff_max_rad.unwrap() - expected_max).abs() < 1e-12,
"angmax +360 deg -> +2*pi rad"
);
}
#[test]
fn test_branch_angmin_angmax_none_when_missing() {
let case = r#"
function mpc = test
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1;
];
"#;
let net = parse_str(case).expect("failed to parse");
let br = &net.branches[0];
assert!(
br.angle_diff_min_rad.is_none(),
"angmin should be None when column absent"
);
assert!(
br.angle_diff_max_rad.is_none(),
"angmax should be None when column absent"
);
}
#[test]
fn test_parse_dc_busdc() {
let content = r#"
function mpc = case_acdc
mpc.version = '2';
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
mpc.busdc = [
1 1 0 1.0 345 1.1 0.9;
2 1 0 1.0 345 1.1 0.9;
];
mpc.convdc = [
1 1 1 1 0 0 0 1.0 0.01 0.01 1 1.0 0.01 1 0.01 0.01 1 345 1.1 0.9 1.1 1;
2 2 2 1 0 0 0 1.0 0.01 0.01 1 1.0 0.01 1 0.01 0.01 1 345 1.1 0.9 1.1 1;
];
mpc.branchdc = [
1 2 0.052 0 0 100 0 0 1;
];
"#;
let net = parse_str(content).expect("Should parse DC network format");
let dc_buses: Vec<_> = net.hvdc.dc_buses().collect();
let dc_converters: Vec<_> = net
.hvdc
.dc_converters()
.filter_map(|c| c.as_vsc())
.collect();
let dc_branches: Vec<_> = net.hvdc.dc_branches().collect();
assert_eq!(dc_buses.len(), 2);
assert_eq!(dc_converters.len(), 2);
assert_eq!(dc_branches.len(), 1);
assert_eq!(net.hvdc.dc_grids.len(), 1);
assert_eq!(dc_buses[0].bus_id, 1);
assert!((dc_buses[0].v_dc_pu - 1.0).abs() < 1e-10);
assert_eq!(dc_converters[0].dc_bus, 1);
assert_eq!(dc_converters[0].ac_bus, 1);
assert_eq!(dc_converters[0].control_type_dc, 1);
assert_eq!(dc_converters[1].control_type_dc, 2);
assert_eq!(dc_branches[0].from_bus, 1);
assert_eq!(dc_branches[0].to_bus, 2);
assert!((dc_branches[0].r_ohm - 0.052).abs() < 1e-10);
}
#[test]
fn test_parse_dc_alternate_keys() {
let content = r#"
function mpc = case_pglib
mpc.version = '2';
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
mpc.dcbus = [
1 1 0 1.0 345 1.1 0.9;
];
mpc.dcconv = [
1 1 1 1 0 0 0 1.0 0.01 0.01 1 1.0 0.01 1 0.01 0.01 1 345 1.1 0.9 1.1 1;
];
mpc.dcbranch = [
1 1 0.1 0 0 100 0 0 1;
];
"#;
let net = parse_str(content).expect("Should parse pglib alternate keys");
assert_eq!(net.hvdc.dc_bus_count(), 1);
assert_eq!(net.hvdc.dc_converter_count(), 1);
assert_eq!(net.hvdc.dc_branch_count(), 1);
}
#[test]
fn test_parse_dc_converter_loss_params() {
let content = r#"
function mpc = case_loss
mpc.version = '2';
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
mpc.busdc = [
1 1 0 1.0 345 1.1 0.9;
];
mpc.convdc = [
1 1 1 1 50 10 0 1.0 0.01 0.05 1 1.01 0.02 1 0.005 0.08 1 345 1.1 0.9 1.5 1 1.103 0.887 2.885 4.371 0.005 50.0 1.0 100 -100 50 -50;
];
mpc.branchdc = [
1 1 0.1 0 0 100 0 0 1;
];
"#;
let net = parse_str(content).expect("Should parse converter loss params");
let dc_converters: Vec<_> = net
.hvdc
.dc_converters()
.filter_map(|c| c.as_vsc())
.collect();
let conv = dc_converters[0];
assert!((conv.loss_constant_mw - 1.103).abs() < 1e-10);
assert!((conv.loss_linear - 0.887).abs() < 1e-10);
assert!((conv.loss_quadratic_rectifier - 2.885).abs() < 1e-10);
assert!((conv.loss_quadratic_inverter - 4.371).abs() < 1e-10);
assert!((conv.droop - 0.005).abs() < 1e-10);
assert!((conv.power_dc_setpoint_mw - 50.0).abs() < 1e-10);
assert!((conv.active_power_mw - 50.0).abs() < 1e-10);
assert!((conv.reactive_power_mvar - 10.0).abs() < 1e-10);
}
#[test]
fn test_parse_dc_curly_brace_syntax() {
let content = r#"
function mpc = case_curly
mpc.version = '2';
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 500 1 1.1 0.9;
2 1 50 20 0 0 1 1.0 0 500 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 2 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
mpc.busdc = {
1 1 0 1.0 400 1.1 0.9 0;
2 1 0 1.0 400 1.1 0.9 0;
};
mpc.convdc = {
1 1 1 1 50 10 0 1 0.00086 0.03 1 1 0.01 0 0.0005 0.015 1 500 1.1 0.9 500.0 1 0.5517 3.031 0.0175 0.0 0.005 -58.6 1.008 0 500 -500 500 -500;
2 2 2 1 -50 -10 0 1 0.00086 0.03 1 1 0.01 0 0.0005 0.015 1 500 1.1 0.9 500.0 1 0.5517 3.031 0.0175 0.0 0.007 21.9 1.000 0 500 -500 500 -500;
};
mpc.branchdc = {
1 2 0.001 0 0 510 500 500 1;
};
"#;
let net = parse_str(content).expect("Should parse curly-brace DC network sections");
let dc_buses: Vec<_> = net.hvdc.dc_buses().collect();
let dc_converters: Vec<_> = net
.hvdc
.dc_converters()
.filter_map(|c| c.as_vsc())
.collect();
let dc_branches: Vec<_> = net.hvdc.dc_branches().collect();
assert_eq!(dc_buses.len(), 2);
assert_eq!(dc_buses[0].bus_id, 1);
assert!((dc_buses[0].base_kv_dc - 400.0).abs() < 1e-10);
assert_eq!(dc_converters.len(), 2);
assert_eq!(dc_converters[0].dc_bus, 1);
assert_eq!(dc_converters[0].ac_bus, 1);
assert!((dc_converters[0].loss_constant_mw - 0.5517).abs() < 1e-10);
assert!((dc_converters[0].loss_linear - 3.031).abs() < 1e-10);
assert!((dc_converters[0].active_power_ac_max_mw - 500.0).abs() < 1e-10);
assert!((dc_converters[0].active_power_ac_min_mw - (-500.0)).abs() < 1e-10);
assert_eq!(dc_branches.len(), 1);
assert!((dc_branches[0].r_ohm - 0.001).abs() < 1e-10);
}
#[test]
fn test_parse_dc_no_dc_sections() {
let content = r#"
function mpc = case_plain
mpc.version = '2';
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 300 -300 1.0 100 1 250 10;
];
mpc.branch = [
1 1 0.01 0.1 0.02 100 100 100 0 0 1 -360 360;
];
"#;
let net = parse_str(content).expect("Standard MATPOWER should still parse");
assert_eq!(net.hvdc.dc_bus_count(), 0);
assert_eq!(net.hvdc.dc_converter_count(), 0);
assert_eq!(net.hvdc.dc_branch_count(), 0);
}
#[test]
fn test_pq_curve_from_pc_fields() {
let case_equal = r#"
function mpc = test_equal_pc
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 80 30 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 100 0 200 -100 1.0 100 1 200 0 100 100 -30 80 -20 60;
];
mpc.branch = [
1 2 0.01 0.1 0.02 300 300 300 0 0 1;
];
mpc.gencost = [
2 0 0 3 0 1 0;
];
"#;
let net = parse_str(case_equal).expect("failed to parse equal-pc case");
assert!(
net.generators[0]
.reactive_capability
.as_ref()
.is_none_or(|r| r.pq_curve.is_empty()),
"equal pc1=pc2=100 → degenerate → empty pq_curve"
);
let case_curve = r#"
function mpc = test_dcurve
mpc.baseMVA = 100;
mpc.bus = [
1 3 0 0 0 0 1 1.0 0 345 1 1.1 0.9;
2 1 80 30 0 0 1 1.0 0 345 1 1.1 0.9;
];
mpc.gen = [
1 150 0 200 -100 1.0 100 1 200 0 50 200 -50 150 -20 80;
];
mpc.branch = [
1 2 0.01 0.1 0.02 300 300 300 0 0 1;
];
mpc.gencost = [
2 0 0 3 0 1 0;
];
"#;
let net2 = parse_str(case_curve).expect("failed to parse D-curve case");
let g = &net2.generators[0];
let pq_curve = &g
.reactive_capability
.as_ref()
.expect("should have reactive_capability")
.pq_curve;
assert_eq!(
pq_curve.len(),
2,
"two distinct pc breakpoints → 2 pq_curve points"
);
let (p1_pu, qmax1_pu, qmin1_pu) = pq_curve[0];
let (p2_pu, qmax2_pu, qmin2_pu) = pq_curve[1];
assert!(
(p1_pu - 0.5).abs() < 1e-10,
"p1 should be 0.5 pu, got {p1_pu}"
);
assert!(
(qmax1_pu - 1.5).abs() < 1e-10,
"qmax1 should be 1.5 pu, got {qmax1_pu}"
);
assert!(
(qmin1_pu - (-0.5)).abs() < 1e-10,
"qmin1 should be -0.5 pu, got {qmin1_pu}"
);
assert!(
(p2_pu - 2.0).abs() < 1e-10,
"p2 should be 2.0 pu, got {p2_pu}"
);
assert!(
(qmax2_pu - 0.8).abs() < 1e-10,
"qmax2 should be 0.8 pu, got {qmax2_pu}"
);
assert!(
(qmin2_pu - (-0.2)).abs() < 1e-10,
"qmin2 should be -0.2 pu, got {qmin2_pu}"
);
}
}