use crate::network::{branch::Branch, bus::Bus, PowerNetwork};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatpowerExportConfig {
pub case_name: String,
pub base_mva: f64,
pub include_gencost: bool,
pub include_results: bool,
pub header_comment: Option<String>,
}
impl Default for MatpowerExportConfig {
fn default() -> Self {
Self {
case_name: "oxigrid_export".to_string(),
base_mva: 100.0,
include_gencost: false,
include_results: false,
header_comment: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MatpowerBusType {
PQ = 1,
PV = 2,
Slack = 3,
Isolated = 4,
}
impl MatpowerBusType {
pub fn from_bus(bus: &Bus) -> Self {
use crate::network::bus::BusType;
match bus.bus_type {
BusType::Slack => MatpowerBusType::Slack,
BusType::PV => MatpowerBusType::PV,
BusType::PQ => MatpowerBusType::PQ,
}
}
}
pub fn export_matpower(network: &PowerNetwork, config: &MatpowerExportConfig) -> String {
let mut out = String::with_capacity(4096);
if let Some(ref comment) = config.header_comment {
for line in comment.lines() {
out.push_str(&format!("%% {line}\n"));
}
out.push('\n');
}
out.push_str(&format!("function mpc = {}\n", config.case_name));
out.push_str("%% MATPOWER Case Format : Version 2\n");
out.push_str("%% Generated by OxiGrid\n\n");
out.push_str("mpc.version = '2';\n");
out.push_str(&format!("mpc.baseMVA = {};\n\n", config.base_mva));
out.push_str("%% bus data\n");
out.push_str("% bus_i\ttype\tPd\tQd\tGs\tBs\tarea\tVm\tVa\tbaseKV\tzone\tVmax\tVmin\n");
out.push_str("mpc.bus = [\n");
for (idx, bus) in network.buses.iter().enumerate() {
let bus_type = MatpowerBusType::from_bus(bus) as i32;
let pd_mw = bus.pd.0;
let qd_mvar = bus.qd.0;
let vm = 1.0_f64; let va = 0.0_f64;
let base_kv = 132.0_f64; let v_max = 1.1_f64;
let v_min = 0.9_f64;
out.push_str(&format!(
"\t{}\t{}\t{:.4}\t{:.4}\t0\t0\t1\t{:.4}\t{:.4}\t{:.1}\t1\t{:.4}\t{:.4};\n",
idx + 1,
bus_type,
pd_mw,
qd_mvar,
vm,
va,
base_kv,
v_max,
v_min
));
}
out.push_str("];\n\n");
out.push_str("%% generator data\n");
out.push_str("% bus\tPg\tQg\tQmax\tQmin\tVg\tmBase\tstatus\tPmax\tPmin\n");
out.push_str("mpc.gen = [\n");
for gen in &network.generators {
let pg_mw = gen.pg;
let qg_mvar = gen.qg;
let q_max = gen.qmax;
let q_min = gen.qmin;
let p_max = gen.pmax;
let p_min = gen.pmin;
let v_g = gen.vg;
let bus_i = gen.bus_id;
out.push_str(&format!(
"\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.1}\t1\t{:.4}\t{:.4};\n",
bus_i, pg_mw, qg_mvar, q_max, q_min, v_g, config.base_mva, p_max, p_min
));
}
out.push_str("];\n\n");
out.push_str("%% branch data\n");
out.push_str(
"% fbus\ttbus\tr\tx\tb\trateA\trateB\trateC\tratio\tangle\tstatus\tangmin\tangmax\n",
);
out.push_str("mpc.branch = [\n");
for branch in &network.branches {
let from = branch.from_bus + 1;
let to = branch.to_bus + 1;
let r = branch.r;
let x = branch.x;
let b = branch.b;
let rate_a = if branch.rate_a > 0.0 {
branch.rate_a
} else {
9999.0
};
let tap_ratio = branch.effective_tap();
let angle = branch.shift;
out.push_str(&format!(
"\t{}\t{}\t{:.6}\t{:.6}\t{:.6}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t1\t-360\t360;\n",
from, to, r, x, b, rate_a, rate_a, rate_a, tap_ratio, angle
));
}
out.push_str("];\n\n");
if config.include_gencost {
out.push_str("%% generator cost data\n");
out.push_str("% 2\tstartup\tshutdown\tncost\tc(n-1)\t...\tc0\n");
out.push_str("mpc.gencost = [\n");
for _ in &network.generators {
out.push_str("\t2\t0\t0\t3\t0\t30.0\t0;\n");
}
out.push_str("];\n\n");
}
out
}
pub struct MatpowerExportStats {
pub n_buses: usize,
pub n_gens: usize,
pub n_branches: usize,
pub base_mva: f64,
pub case_name: String,
}
pub fn parse_export_stats(content: &str) -> MatpowerExportStats {
let mut n_buses = 0;
let mut n_gens = 0;
let mut n_branches = 0;
let mut base_mva = 100.0;
let mut case_name = String::new();
let mut in_bus = false;
let mut in_gen = false;
let mut in_branch = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with("function mpc =") {
case_name = line.split('=').nth(1).unwrap_or("").trim().to_string();
} else if line.contains("baseMVA") {
if let Some(val) = line.split('=').nth(1) {
base_mva = val
.trim()
.trim_end_matches(';')
.trim()
.parse()
.unwrap_or(100.0);
}
} else if line.starts_with("mpc.bus = [") {
in_bus = true;
in_gen = false;
in_branch = false;
} else if line.starts_with("mpc.gen = [") {
in_gen = true;
in_bus = false;
in_branch = false;
} else if line.starts_with("mpc.branch = [") {
in_branch = true;
in_bus = false;
in_gen = false;
} else if line == "];" {
in_bus = false;
in_gen = false;
in_branch = false;
} else if in_bus && line.ends_with(';') && !line.starts_with('%') {
n_buses += 1;
} else if in_gen && line.ends_with(';') && !line.starts_with('%') {
n_gens += 1;
} else if in_branch && line.ends_with(';') && !line.starts_with('%') {
n_branches += 1;
}
}
MatpowerExportStats {
n_buses,
n_gens,
n_branches,
base_mva,
case_name,
}
}
pub trait BranchMatpowerExt {
fn max_apparent_power_mva(&self) -> Option<f64>;
fn tap_ratio(&self) -> f64;
fn phase_shift_deg(&self) -> f64;
}
impl BranchMatpowerExt for Branch {
fn max_apparent_power_mva(&self) -> Option<f64> {
if self.rate_a > 0.0 {
Some(self.rate_a)
} else {
None
}
}
fn tap_ratio(&self) -> f64 {
self.effective_tap()
}
fn phase_shift_deg(&self) -> f64 {
self.shift
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::PowerNetwork;
fn make_test_network() -> PowerNetwork {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/ieee14.m");
PowerNetwork::from_matpower(path).expect("IEEE 14 load failed")
}
#[test]
fn test_export_contains_function_header() {
let net = make_test_network();
let config = MatpowerExportConfig {
case_name: "test_case".to_string(),
..Default::default()
};
let content = export_matpower(&net, &config);
assert!(
content.contains("function mpc = test_case"),
"Should have function header"
);
}
#[test]
fn test_export_contains_base_mva() {
let net = make_test_network();
let config = MatpowerExportConfig::default();
let content = export_matpower(&net, &config);
assert!(content.contains("mpc.baseMVA"), "Should have baseMVA");
assert!(content.contains("100"), "baseMVA should be 100");
}
#[test]
fn test_export_bus_count_matches() {
let net = make_test_network();
let config = MatpowerExportConfig::default();
let content = export_matpower(&net, &config);
let stats = parse_export_stats(&content);
assert_eq!(
stats.n_buses,
net.buses.len(),
"Exported bus count should match: {} vs {}",
stats.n_buses,
net.buses.len()
);
}
#[test]
fn test_export_gen_count_matches() {
let net = make_test_network();
let config = MatpowerExportConfig::default();
let content = export_matpower(&net, &config);
let stats = parse_export_stats(&content);
assert_eq!(
stats.n_gens,
net.generators.len(),
"Exported gen count should match: {} vs {}",
stats.n_gens,
net.generators.len()
);
}
#[test]
fn test_export_branch_count_matches() {
let net = make_test_network();
let config = MatpowerExportConfig::default();
let content = export_matpower(&net, &config);
let stats = parse_export_stats(&content);
assert_eq!(
stats.n_branches,
net.branches.len(),
"Exported branch count: {} vs {}",
stats.n_branches,
net.branches.len()
);
}
#[test]
fn test_export_with_gencost() {
let net = make_test_network();
let config = MatpowerExportConfig {
include_gencost: true,
..Default::default()
};
let content = export_matpower(&net, &config);
assert!(
content.contains("mpc.gencost"),
"Should include gencost when requested"
);
}
#[test]
fn test_export_without_gencost() {
let net = make_test_network();
let config = MatpowerExportConfig {
include_gencost: false,
..Default::default()
};
let content = export_matpower(&net, &config);
assert!(
!content.contains("mpc.gencost"),
"Should not include gencost when not requested"
);
}
#[test]
fn test_export_valid_utf8() {
let net = make_test_network();
let config = MatpowerExportConfig::default();
let content = export_matpower(&net, &config);
assert!(
std::str::from_utf8(content.as_bytes()).is_ok(),
"Export should be valid UTF-8"
);
}
#[test]
fn test_export_with_header_comment() {
let net = make_test_network();
let config = MatpowerExportConfig {
header_comment: Some("OxiGrid export\nTimestamp: 2026".to_string()),
..Default::default()
};
let content = export_matpower(&net, &config);
assert!(content.contains("%% OxiGrid export"));
assert!(content.contains("%% Timestamp: 2026"));
}
#[test]
fn test_roundtrip_parse_stats() {
let net = make_test_network();
let config = MatpowerExportConfig {
case_name: "ieee14_roundtrip".to_string(),
base_mva: 100.0,
..Default::default()
};
let content = export_matpower(&net, &config);
let stats = parse_export_stats(&content);
assert!((stats.base_mva - 100.0).abs() < 1e-9);
assert!(stats.case_name.contains("ieee14_roundtrip"));
}
#[test]
fn test_branch_matpower_ext_defaults() {
let net = make_test_network();
for branch in &net.branches {
let tap = branch.tap_ratio();
let angle = branch.phase_shift_deg();
assert!(tap > 0.0, "Tap ratio should be positive: {:.4}", tap);
assert!(angle.is_finite());
}
}
}