use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use powerio_dist::{DistNetwork, DistTargetFormat};
use powerio_matrix::{
Network, TargetFormat, parse_matpower_file, parse_str as parse_transmission_str,
write_as as write_transmission_as,
};
const REPORT_ENV: &str = "POWERIO_CONVERSION_MATRIX_REPORT";
const DETAILS_ENV: &str = "POWERIO_CONVERSION_MATRIX_DETAILS";
const REPORT_MARKER: &str = "<!-- powerio-conversion-matrix-report -->";
const DETAILS_MARKER: &str = "<!-- powerio-conversion-matrix-details -->";
const MAX_WARNING_DETAILS_PER_PAIR: usize = 6;
const SOURCE_PARSE: &str = "source parse";
const TARGET_WRITE: &str = "target write";
const TARGET_READBACK: &str = "target readback";
static DSS_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn conversion_matrix_report_matches_baseline() {
let report = build_report();
assert_no_report_path_leaks(&report.markdown);
assert_no_report_path_leaks(&report.details_markdown);
write_env_report(REPORT_ENV, &report.markdown);
write_env_report(DETAILS_ENV, &report.details_markdown);
assert!(
report.failures.is_empty(),
"{}\n\n{}\n\n{}",
report.failures.join("\n"),
report.markdown,
report.details_markdown
);
}
fn write_env_report(env: &str, markdown: &str) {
let Ok(path) = std::env::var(env) else {
return;
};
let path = PathBuf::from(path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, markdown).unwrap();
}
fn assert_no_report_path_leaks(markdown: &str) {
let temp_dir = std::env::temp_dir().to_string_lossy().into_owned();
assert!(
!markdown.contains(&temp_dir),
"report leaked temp dir: {temp_dir}"
);
assert!(
!markdown.contains(env!("CARGO_MANIFEST_DIR")),
"report leaked cargo manifest dir"
);
assert!(
!markdown.contains("powerio-conversion-matrix/"),
"report leaked generated temp file path"
);
}
struct Report {
markdown: String,
details_markdown: String,
failures: Vec<String>,
}
fn build_report() -> Report {
let transmission = run_transmission_matrix();
let distribution = run_distribution_matrix();
let mut failures = Vec::new();
failures.extend(transmission.failures.clone());
failures.extend(distribution.failures.clone());
let mut markdown = String::new();
writeln!(markdown, "{REPORT_MARKER}").unwrap();
writeln!(markdown).unwrap();
writeln!(markdown, "## Conversion Matrix").unwrap();
writeln!(markdown).unwrap();
writeln!(markdown, "### Legend").unwrap();
writeln!(markdown).unwrap();
writeln!(
markdown,
"Cells show `X/Y`: observed warnings / expected warnings. Counts include source parse, target write, and target readback."
)
.unwrap();
writeln!(markdown).unwrap();
writeln!(
markdown,
"- 🟢 `0/0`: no warnings and checked invariants held."
)
.unwrap();
writeln!(
markdown,
"- 🟡 `X=Y`: observed warnings match the reviewed expected count, and that count is nonzero."
)
.unwrap();
writeln!(
markdown,
"- 🔴 `X!=Y` or invariant failure: behavior changed. If warnings decreased because fidelity improved, update the expected counts in the same PR."
)
.unwrap();
writeln!(
markdown,
"- Expected counts are the `*_WARNING_BASELINE` arrays in `powerio-cli/tests/conversion_matrix_report.rs`; accept an intentional change by editing the matching source/target entry in the same PR."
)
.unwrap();
writeln!(markdown).unwrap();
write_matrix_section(&mut markdown, "Transmission", &transmission);
writeln!(markdown).unwrap();
write_matrix_section(&mut markdown, "Distribution", &distribution);
let mut details_markdown = String::new();
writeln!(details_markdown, "{DETAILS_MARKER}").unwrap();
writeln!(details_markdown).unwrap();
writeln!(details_markdown, "## Conversion Matrix Warning Details").unwrap();
writeln!(details_markdown).unwrap();
writeln!(
details_markdown,
"This file is generated by the conversion matrix workflow. It records warning text by source to target pair for the same run that posted the PR comment."
)
.unwrap();
writeln!(
details_markdown,
"Warning lines are tagged by phase: source parse, target write, or target readback."
)
.unwrap();
writeln!(
details_markdown,
"Expected counts live in `powerio-cli/tests/conversion_matrix_report.rs`; update the matching baseline value in the same PR when warning count changes are intentional."
)
.unwrap();
writeln!(details_markdown).unwrap();
write_warning_summary(&mut details_markdown, "Transmission", &transmission);
writeln!(details_markdown).unwrap();
write_warning_summary(&mut details_markdown, "Distribution", &distribution);
Report {
markdown,
details_markdown,
failures,
}
}
#[derive(Clone)]
struct MatrixReport {
formats: Vec<&'static str>,
case_count: usize,
cells: Vec<Vec<Cell>>,
failures: Vec<String>,
}
#[derive(Clone)]
struct Cell {
observed_warnings: usize,
baseline_warnings: usize,
failures: Vec<String>,
warning_counts: BTreeMap<(String, String), usize>,
}
impl Cell {
fn new(baseline_warnings: usize) -> Self {
Self {
observed_warnings: 0,
baseline_warnings,
failures: Vec::new(),
warning_counts: BTreeMap::new(),
}
}
fn ok(&self) -> bool {
self.failures.is_empty() && self.observed_warnings == self.baseline_warnings
}
fn parity(&self) -> bool {
self.ok() && self.observed_warnings == 0
}
fn record_warnings(&mut self, phase: &str, warnings: &[String]) {
self.observed_warnings += warnings.len();
for warning in warnings {
*self
.warning_counts
.entry((phase.to_string(), sanitize_report_text(warning)))
.or_default() += 1;
}
}
}
fn write_matrix_section(markdown: &mut String, title: &str, report: &MatrixReport) {
writeln!(markdown, "### {title}").unwrap();
writeln!(markdown).unwrap();
writeln!(markdown, "{} cases.", report.case_count).unwrap();
writeln!(markdown).unwrap();
write!(markdown, "| Source ↓ / target → |").unwrap();
for format in &report.formats {
write!(markdown, " {format} |").unwrap();
}
writeln!(markdown).unwrap();
write!(markdown, "| --- |").unwrap();
for _ in &report.formats {
write!(markdown, " --- |").unwrap();
}
writeln!(markdown).unwrap();
for (source, row) in report.formats.iter().zip(&report.cells) {
write!(markdown, "| {source} |").unwrap();
for cell in row {
write!(markdown, " {} |", cell_summary(cell)).unwrap();
}
writeln!(markdown).unwrap();
}
}
fn cell_summary(cell: &Cell) -> String {
let icon = if cell.parity() {
"🟢"
} else if cell.ok() {
"🟡"
} else {
"🔴"
};
format!(
"{icon} {}/{}",
cell.observed_warnings, cell.baseline_warnings
)
}
fn write_warning_summary(markdown: &mut String, title: &str, report: &MatrixReport) {
writeln!(markdown, "#### {title} Warning Details").unwrap();
writeln!(markdown).unwrap();
writeln!(
markdown,
"Rows list only source to target pairs that produced warnings or failed an invariant."
)
.unwrap();
writeln!(markdown).unwrap();
let mut wrote_row = false;
for (source, row) in report.formats.iter().zip(&report.cells) {
for (target, cell) in report.formats.iter().zip(row) {
if cell.observed_warnings == 0 && cell.failures.is_empty() {
continue;
}
wrote_row = true;
writeln!(
markdown,
"- **{source} → {target}** (`{}/{}`)",
cell.observed_warnings, cell.baseline_warnings
)
.unwrap();
write_cell_details(markdown, cell);
}
}
if !wrote_row {
writeln!(markdown, "No warnings observed.").unwrap();
}
}
fn write_cell_details(markdown: &mut String, cell: &Cell) {
let mut details = Vec::new();
for failure in &cell.failures {
details.push(format!("failure: {}", sanitize_report_text(failure)));
}
let mut warnings: Vec<_> = cell.warning_counts.iter().collect();
warnings.sort_by(
|((phase_a, warning_a), count_a), ((phase_b, warning_b), count_b)| {
count_b
.cmp(count_a)
.then_with(|| phase_order(phase_a).cmp(&phase_order(phase_b)))
.then_with(|| warning_a.cmp(warning_b))
},
);
for ((phase, warning), count) in warnings.iter().take(MAX_WARNING_DETAILS_PER_PAIR) {
details.push(format!("{phase}: {count}x {warning}"));
}
let omitted = warnings.len().saturating_sub(MAX_WARNING_DETAILS_PER_PAIR);
if omitted > 0 {
details.push(format!("{omitted} more warning texts"));
}
if details.is_empty() {
writeln!(markdown, " - No warning text recorded.").unwrap();
return;
}
for detail in details {
writeln!(markdown, " - {}", markdown_list_text(&detail)).unwrap();
}
}
fn phase_order(phase: &str) -> usize {
match phase {
SOURCE_PARSE => 0,
TARGET_WRITE => 1,
TARGET_READBACK => 2,
_ => 3,
}
}
fn markdown_list_text(text: &str) -> String {
text.replace('\n', " ")
}
fn sanitize_report_text(text: &str) -> String {
const GENERATED_DSS_DIR: &str = "powerio-conversion-matrix/";
if let Some(dir_idx) = text.find(GENERATED_DSS_DIR) {
let prefix = &text[..dir_idx];
let path_start = prefix
.char_indices()
.rev()
.find(|(_, c)| c.is_whitespace())
.map_or(0, |(idx, c)| idx + c.len_utf8());
let prelude = prefix[..path_start].trim_end();
let suffix = &text[dir_idx + GENERATED_DSS_DIR.len()..];
if prelude.is_empty() {
return format!("generated DSS {suffix}");
}
return format!("{prelude} generated DSS {suffix}");
}
let temp_dir = std::env::temp_dir().to_string_lossy().into_owned();
let text = text
.replace(&temp_dir, "<tmp>")
.replace(env!("CARGO_MANIFEST_DIR"), "<crate>");
code_object_name(&text)
}
#[test]
fn sanitize_report_text_handles_multibyte_whitespace_before_generated_dss_dir() {
let text = "wrote\u{a0}/tmp/xyz/powerio-conversion-matrix/case.dss";
let sanitized = sanitize_report_text(text);
assert_eq!(sanitized, "wrote generated DSS case.dss");
}
fn code_object_name(text: &str) -> String {
const OBJECT_PREFIXES: &[&str] = &[
"voltage source",
"vsource",
"load",
"capacitor",
"reactor",
"generator",
"shunt",
"transformer",
"switch",
"linecode",
"line",
"bus",
];
for prefix in OBJECT_PREFIXES {
let Some(rest) = text.strip_prefix(prefix).and_then(|s| s.strip_prefix(' ')) else {
continue;
};
let Some((name, suffix)) = rest.split_once(':') else {
continue;
};
if name.contains(char::is_whitespace) || name.starts_with('`') {
continue;
}
return format!("{prefix} `{name}`:{suffix}");
}
text.to_string()
}
#[derive(Clone, Copy)]
struct TransmissionFormat {
name: &'static str,
token: &'static str,
target: TargetFormat,
}
const TRANSMISSION_FORMATS: [TransmissionFormat; 9] = [
TransmissionFormat {
name: "MATPOWER .m",
token: "matpower",
target: TargetFormat::Matpower,
},
TransmissionFormat {
name: "PowerIO JSON",
token: "powerio-json",
target: TargetFormat::PowerioJson,
},
TransmissionFormat {
name: "PowerModels JSON",
token: "powermodels-json",
target: TargetFormat::PowerModelsJson,
},
TransmissionFormat {
name: "PSS/E .raw",
token: "psse",
target: TargetFormat::Psse { rev: 33 },
},
TransmissionFormat {
name: "PowerWorld .aux",
token: "powerworld",
target: TargetFormat::PowerWorld,
},
TransmissionFormat {
name: "egret JSON",
token: "egret-json",
target: TargetFormat::EgretJson,
},
TransmissionFormat {
name: "pandapower JSON",
token: "pandapower-json",
target: TargetFormat::PandapowerJson,
},
TransmissionFormat {
name: "Surge JSON",
token: "surge-json",
target: TargetFormat::SurgeJson,
},
TransmissionFormat {
name: "PSLF .epc",
token: "pslf",
target: TargetFormat::Pslf,
},
];
const TRANSMISSION_WARNING_BASELINE: [[usize; 9]; 9] = [
[0, 0, 0, 8, 8, 0, 5, 0, 11],
[6, 0, 1, 14, 14, 1, 11, 24, 16],
[6, 0, 0, 14, 14, 1, 11, 24, 16],
[13, 0, 1, 0, 1, 1, 3, 1, 10],
[12, 0, 0, 0, 0, 0, 2, 0, 5],
[0, 0, 0, 8, 8, 0, 5, 0, 11],
[6, 0, 0, 11, 11, 5, 0, 0, 11],
[2, 1, 2, 10, 10, 2, 7, 2, 17],
[17, 4, 5, 5, 5, 5, 7, 5, 8],
];
const TRANSMISSION_CASES: [(&str, &str); 6] = [
("case9", "case9.m"),
("case14", "case14.m"),
("case30", "case30.m"),
("dcline", "t_case9_dcline.m"),
("out of service", "t_case9_oos.m"),
("PGLib 5", "pglib/pglib_opf_case5_pjm.m"),
];
struct TransmissionPayload {
label: &'static str,
network: Network,
parse_warnings: Vec<String>,
core: TransmissionCore,
}
#[derive(Debug, PartialEq, Eq)]
struct TransmissionCore {
buses: usize,
branches: usize,
generators: usize,
loads: usize,
shunts: usize,
load_p: i64,
load_q: i64,
gen_p: i64,
base_mva: i64,
}
fn run_transmission_matrix() -> MatrixReport {
let formats = TRANSMISSION_FORMATS.iter().map(|fmt| fmt.name).collect();
let mut cells = Vec::new();
let mut failures = Vec::new();
for (source_idx, source) in TRANSMISSION_FORMATS.iter().enumerate() {
let payloads = transmission_payloads(*source);
let mut row = Vec::new();
for (target_idx, target) in TRANSMISSION_FORMATS.iter().enumerate() {
let mut cell = Cell::new(TRANSMISSION_WARNING_BASELINE[source_idx][target_idx]);
match &payloads {
Ok(payloads) => {
for payload in payloads {
cell.record_warnings(SOURCE_PARSE, &payload.parse_warnings);
validate_transmission_pair(payload, *target, &mut cell);
}
}
Err(err) => cell.failures.push(err.clone()),
}
if !cell.ok() {
failures.push(format!(
"transmission {} -> {}: observed {} warnings, baseline {}; {}",
source.name,
target.name,
cell.observed_warnings,
cell.baseline_warnings,
cell.failures.join("; ")
));
}
row.push(cell);
}
cells.push(row);
}
MatrixReport {
formats,
case_count: TRANSMISSION_CASES.len(),
cells,
failures,
}
}
fn transmission_payloads(format: TransmissionFormat) -> Result<Vec<TransmissionPayload>, String> {
TRANSMISSION_CASES
.iter()
.map(|(label, rel)| {
let mut base =
parse_matpower_file(data(rel)).map_err(|err| format!("parse {rel}: {err}"))?;
base.source = None;
let rendered = write_transmission_as(&base, format.target)
.map_err(|err| format!("write {rel} as {}: {err}", format.name))?;
let parsed = parse_transmission_str(&rendered.text, format.token)
.map_err(|err| format!("read generated {rel} as {}: {err}", format.name))?;
let core = transmission_core(&parsed.network);
Ok(TransmissionPayload {
label,
network: parsed.network,
parse_warnings: parsed.warnings,
core,
})
})
.collect()
}
fn validate_transmission_pair(
payload: &TransmissionPayload,
target: TransmissionFormat,
cell: &mut Cell,
) {
match write_transmission_as(&payload.network, target.target) {
Ok(conversion) => {
cell.record_warnings(TARGET_WRITE, &conversion.warnings);
match parse_transmission_str(&conversion.text, target.token) {
Ok(parsed) => {
cell.record_warnings(TARGET_READBACK, &parsed.warnings);
let actual = transmission_core(&parsed.network);
if actual != payload.core {
cell.failures.push(format!(
"{} core changed for {}: before {:?}, after {:?}",
payload.label, target.name, payload.core, actual
));
}
}
Err(err) => cell.failures.push(format!(
"{} output did not parse as {}: {err}",
payload.label, target.name
)),
}
}
Err(err) => cell.failures.push(format!(
"{} did not write as {}: {err}",
payload.label, target.name
)),
}
}
fn transmission_core(net: &Network) -> TransmissionCore {
let rounded = |x: f64| (x * 1e3).round() as i64;
TransmissionCore {
buses: net.buses.len(),
branches: net.branches.len(),
generators: net.generators.len(),
loads: net.loads.len(),
shunts: net.shunts.len(),
load_p: rounded(net.loads.iter().map(|load| load.p).sum()),
load_q: rounded(net.loads.iter().map(|load| load.q).sum()),
gen_p: rounded(net.generators.iter().map(|generator| generator.pg).sum()),
base_mva: rounded(net.base_mva),
}
}
fn data(rel: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../tests/data")
.join(rel)
}
#[derive(Clone, Copy)]
struct DistributionFormat {
name: &'static str,
token: &'static str,
target: DistTargetFormat,
}
const DISTRIBUTION_FORMATS: [DistributionFormat; 3] = [
DistributionFormat {
name: "OpenDSS .dss",
token: "dss",
target: DistTargetFormat::Dss,
},
DistributionFormat {
name: "BMOPF JSON",
token: "bmopf-json",
target: DistTargetFormat::BmopfJson,
},
DistributionFormat {
name: "PMD JSON",
token: "pmd-json",
target: DistTargetFormat::PmdJson,
},
];
const DISTRIBUTION_WARNING_BASELINE: [[usize; 3]; 3] = [[0, 152, 84], [1, 0, 0], [22, 117, 0]];
const DISTRIBUTION_CASES: [(&str, &str, DistributionFormat); 7] = [
(
"single phase transformer",
"dist/micro/xfmr_single_phase.dss",
DISTRIBUTION_FORMATS[0],
),
(
"center tap transformer",
"dist/micro/xfmr_center_tap.dss",
DISTRIBUTION_FORMATS[0],
),
(
"switch states",
"dist/micro/switch.dss",
DISTRIBUTION_FORMATS[0],
),
(
"four wire linecode",
"dist/micro/fourwire_linecode.dss",
DISTRIBUTION_FORMATS[0],
),
(
"ten conductor linecode",
"dist/micro/linecode_10x10.dss",
DISTRIBUTION_FORMATS[0],
),
(
"BMOPF IEEE 13",
"dist/bmopf/example_ieee13.json",
DISTRIBUTION_FORMATS[1],
),
(
"PMD four wire",
"dist/pmd/fourwire_linecode.json",
DISTRIBUTION_FORMATS[2],
),
];
struct DistributionPayload {
label: &'static str,
network: DistNetwork,
parse_warnings: Vec<String>,
core: DistributionCore,
}
#[derive(Debug, PartialEq, Eq)]
struct DistributionCore {
buses: usize,
loads: usize,
generators: usize,
shunts: usize,
load_p: i64,
load_q: i64,
}
fn run_distribution_matrix() -> MatrixReport {
let formats = DISTRIBUTION_FORMATS.iter().map(|fmt| fmt.name).collect();
let mut cells = Vec::new();
let mut failures = Vec::new();
for (source_idx, source) in DISTRIBUTION_FORMATS.iter().enumerate() {
let payloads = distribution_payloads(*source);
let mut row = Vec::new();
for (target_idx, target) in DISTRIBUTION_FORMATS.iter().enumerate() {
let mut cell = Cell::new(DISTRIBUTION_WARNING_BASELINE[source_idx][target_idx]);
match &payloads {
Ok(payloads) => {
for payload in payloads {
cell.record_warnings(SOURCE_PARSE, &payload.parse_warnings);
validate_distribution_pair(payload, *target, &mut cell);
}
}
Err(err) => cell.failures.push(err.clone()),
}
if !cell.ok() {
failures.push(format!(
"distribution {} -> {}: observed {} warnings, baseline {}; {}",
source.name,
target.name,
cell.observed_warnings,
cell.baseline_warnings,
cell.failures.join("; ")
));
}
row.push(cell);
}
cells.push(row);
}
MatrixReport {
formats,
case_count: DISTRIBUTION_CASES.len(),
cells,
failures,
}
}
fn distribution_payloads(format: DistributionFormat) -> Result<Vec<DistributionPayload>, String> {
DISTRIBUTION_CASES
.iter()
.map(|(label, rel, native_format)| {
let mut base = powerio_dist::parse_file(data(rel), Some(native_format.token))
.map_err(|err| format!("parse {rel}: {err}"))?;
base.source = None;
base.source_format = None;
let rendered = base.to_format(format.target);
let mut parsed = parse_distribution_text(&rendered.text, format)
.map_err(|err| format!("read generated {rel} as {}: {err}", format.name))?;
let warnings = std::mem::take(&mut parsed.warnings);
let core = distribution_core(&parsed);
Ok(DistributionPayload {
label,
network: parsed,
parse_warnings: warnings,
core,
})
})
.collect()
}
fn validate_distribution_pair(
payload: &DistributionPayload,
target: DistributionFormat,
cell: &mut Cell,
) {
let conversion = payload.network.to_format(target.target);
cell.record_warnings(TARGET_WRITE, &conversion.warnings);
match parse_distribution_text(&conversion.text, target) {
Ok(mut parsed) => {
cell.record_warnings(TARGET_READBACK, &parsed.warnings);
parsed.warnings.clear();
let actual = distribution_core(&parsed);
if actual != payload.core {
cell.failures.push(format!(
"{} core changed for {}: before {:?}, after {:?}",
payload.label, target.name, payload.core, actual
));
}
}
Err(err) => cell.failures.push(format!(
"{} output did not parse as {}: {err}",
payload.label, target.name
)),
}
}
fn parse_distribution_text(
text: &str,
format: DistributionFormat,
) -> powerio_dist::Result<DistNetwork> {
if format.target != DistTargetFormat::Dss {
return powerio_dist::parse_str(text, format.token);
}
let dir = std::env::temp_dir().join("powerio-conversion-matrix");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!(
"case-{}.dss",
DSS_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
std::fs::write(&path, text).unwrap();
let parsed = powerio_dist::parse_file(&path, Some(format.token));
let _ = std::fs::remove_file(path);
parsed
}
fn distribution_core(net: &DistNetwork) -> DistributionCore {
let rounded = |x: f64| (x * 1e3).round() as i64;
DistributionCore {
buses: net.buses.len(),
loads: net.loads.len(),
generators: net.generators.len(),
shunts: net.shunts.len(),
load_p: rounded(
net.loads
.iter()
.flat_map(|load| load.p_nom.iter())
.sum::<f64>(),
),
load_q: rounded(
net.loads
.iter()
.flat_map(|load| load.q_nom.iter())
.sum::<f64>(),
),
}
}