use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use powerio::BalancedNetwork;
use powerio_cli::invariants::{
DistributionCore, YbusUnavailable, distribution_core, distribution_value,
electrical_change_up_to_relabeling, injection_change_within, model_diffs, transmission_core,
transmission_value, ybus_change_within,
};
use powerio_dist::{DistTargetFormat, MulticonductorNetwork};
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);
struct TextEmission {
text: String,
diagnostics: Vec<powerio_core::Diagnostic>,
}
impl TextEmission {
fn render_diagnostics(&self) -> Vec<String> {
powerio_tx::diagnostics::render_diagnostics(&self.diagnostics)
}
}
fn text_emission(
result: powerio_core::EmitResult,
primary_suffix: Option<&str>,
) -> Result<TextEmission, String> {
let diagnostics = result.diagnostics().to_vec();
let powerio_core::EmittedOutput::Memory { artifacts } = result.into_output() else {
return Err("memory destination returned path output".to_owned());
};
let mut primary = None;
for artifact in artifacts {
let is_primary =
primary_suffix.is_none_or(|suffix| artifact.name().as_str().ends_with(suffix));
if is_primary {
if primary.is_some() {
return Err("emission returned several primary artifacts".to_owned());
}
primary = Some(
String::from_utf8(artifact.into_bytes())
.map_err(|err| format!("emitted text is not UTF-8: {err}"))?,
);
}
}
Ok(TextEmission {
text: primary.ok_or_else(|| "emission returned no primary artifact".to_owned())?,
diagnostics,
})
}
enum Emitted {
Document(String),
Directory(tempfile::TempDir),
}
struct TransmissionEmission {
emitted: Emitted,
diagnostics: Vec<String>,
}
fn emit_transmission(
network: &BalancedNetwork,
format: TransmissionFormat,
) -> Result<TransmissionEmission, String> {
let module = powerio_core::PioModule::new(network.clone());
if format.emission == Emission::Directory {
let directory = tempfile::tempdir().map_err(|err| err.to_string())?;
let result = powerio::emit(
&module,
format.token,
powerio_core::Destination::path(directory.path().join(DIRECTORY_CASE_NAME)),
)
.map_err(|err| err.to_string())?;
let diagnostics = powerio_core::render_diagnostics(result.diagnostics());
return Ok(TransmissionEmission {
emitted: Emitted::Directory(directory),
diagnostics,
});
}
let destination = powerio_core::Destination::memory("case").map_err(|err| err.to_string())?;
let result =
powerio::emit(&module, format.token, destination).map_err(|err| err.to_string())?;
let diagnostics = powerio_core::render_diagnostics(result.diagnostics());
let powerio_core::EmittedOutput::Memory { mut artifacts } = result.into_output() else {
return Err("a memory destination returned path output".to_owned());
};
let artifact = artifacts
.pop()
.filter(|_| artifacts.is_empty())
.ok_or_else(|| "a one document format returned several artifacts".to_owned())?;
let text = String::from_utf8(artifact.into_bytes())
.map_err(|err| format!("emitted text is not UTF-8: {err}"))?;
Ok(TransmissionEmission {
emitted: Emitted::Document(text),
diagnostics,
})
}
const DIRECTORY_CASE_NAME: &str = "case";
fn parse_transmission_emitted(
emitted: &Emitted,
format: TransmissionFormat,
) -> Result<ParsedTransmission, String> {
match emitted {
Emitted::Document(text) => {
let source = powerio_core::Source::from_memory("<memory>", text.as_bytes().to_vec())
.map_err(|err| err.to_string())?;
parse_transmission_source(source, format.token)
}
Emitted::Directory(directory) => {
let source = powerio_core::Source::open(directory.path().join(DIRECTORY_CASE_NAME))
.map_err(|err| err.to_string())?;
parse_transmission_source(source, format.token)
}
}
}
fn emit_distribution_module(
module: &powerio_core::PioModule<MulticonductorNetwork>,
target: DistTargetFormat,
) -> Result<TextEmission, String> {
let destination = powerio_core::Destination::memory("case").map_err(|err| err.to_string())?;
let result = powerio_dist::emit(module, target, destination).map_err(|err| err.to_string())?;
let suffix = (target == DistTargetFormat::Dss).then_some("case.dss");
text_emission(result, suffix)
}
fn emit_distribution_value(
network: &MulticonductorNetwork,
target: DistTargetFormat,
) -> Result<TextEmission, String> {
emit_distribution_module(&powerio_core::PioModule::new(network.clone()), target)
}
#[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 geo = run_geo_layer_matrix();
let mut failures = Vec::new();
failures.extend(transmission.failures.clone());
failures.extend(distribution.failures.clone());
failures.extend(geo.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,
"A nonzero cell means the target format cannot carry what the source stated, and every warning names the field or record it cannot carry. A write or readback warning names a field of the target format; a source parse warning, which every cell of that row shares, names what the source document itself leaves unstated. A warning that named a writer PowerIO could extend instead of a limit of the format would be a defect in this table."
)
.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);
writeln!(markdown).unwrap();
write_matrix_section(&mut markdown, "Geographic Layer", &geo);
writeln!(markdown).unwrap();
writeln!(
markdown,
"A geographic layer is a `powerio.GeoLayer`, not a network: no grid exchange format states a standalone layer, so the layer has its own table rather than a row of the network matrix. Its source is the substation coordinates of a vendored PowerWorld auxiliary file."
)
.unwrap();
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);
writeln!(details_markdown).unwrap();
write_warning_summary(&mut details_markdown, "Geographic Layer", &geo);
Report {
markdown,
details_markdown,
failures,
}
}
#[derive(Clone)]
struct MatrixReport {
sources: Vec<&'static str>,
targets: Vec<&'static str>,
groups: Vec<(&'static str, Vec<usize>)>,
coverage: &'static str,
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>,
silent_model_diffs: Vec<String>,
}
impl Cell {
fn new(baseline_warnings: usize) -> Self {
Self {
observed_warnings: 0,
baseline_warnings,
failures: Vec::new(),
warning_counts: BTreeMap::new(),
silent_model_diffs: Vec::new(),
}
}
fn ok(&self) -> bool {
self.failures.is_empty()
&& self.observed_warnings == self.baseline_warnings
&& !self.claims_silent_loss()
}
fn claims_silent_loss(&self) -> bool {
self.observed_warnings == 0 && !self.silent_model_diffs.is_empty()
}
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 silent_loss_note(cell: &Cell) -> String {
if !cell.claims_silent_loss() {
return String::new();
}
format!(
" [claims lossless but the model changed: {}]",
cell.silent_model_diffs.join("; ")
)
}
fn write_matrix_section(markdown: &mut String, title: &str, report: &MatrixReport) {
writeln!(markdown, "### {title}").unwrap();
writeln!(markdown).unwrap();
writeln!(markdown, "{}", report.coverage).unwrap();
for (group, columns) in &report.groups {
if columns.is_empty() {
continue;
}
writeln!(markdown).unwrap();
writeln!(markdown, "#### {group}").unwrap();
writeln!(markdown).unwrap();
write!(markdown, "| Source ↓ / target → |").unwrap();
for column in columns {
write!(markdown, " {} |", report.targets[*column]).unwrap();
}
writeln!(markdown).unwrap();
write!(markdown, "| --- |").unwrap();
for _ in columns {
write!(markdown, " --- |").unwrap();
}
writeln!(markdown).unwrap();
for (source, row) in report.sources.iter().zip(&report.cells) {
write!(markdown, "| {source} |").unwrap();
for column in columns {
write!(markdown, " {} |", cell_summary(&row[*column])).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.sources.iter().zip(&report.cells) {
for (target, cell) in report.targets.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 {
let noun = if omitted == 1 { "text" } else { "texts" };
details.push(format!("{omitted} more warning {noun}"));
}
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, PartialEq, Eq)]
enum Family {
Case,
Exchange,
Dataset,
}
impl Family {
const ALL: [Self; 3] = [Self::Case, Self::Exchange, Self::Dataset];
fn title(self) -> &'static str {
match self {
Self::Case => "Into a case format",
Self::Exchange => "Into an exchange format",
Self::Dataset => "Into a dataset directory",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Emission {
Document,
Directory,
ReadOnly,
}
#[derive(Clone, Copy)]
struct TransmissionFormat {
name: &'static str,
token: &'static str,
emission: Emission,
family: Family,
fixture: Option<&'static str>,
}
const fn transmission(
name: &'static str,
token: &'static str,
emission: Emission,
family: Family,
) -> TransmissionFormat {
TransmissionFormat {
name,
token,
emission,
family,
fixture: None,
}
}
const fn read_only(
name: &'static str,
token: &'static str,
family: Family,
fixture: &'static str,
) -> TransmissionFormat {
TransmissionFormat {
name,
token,
emission: Emission::ReadOnly,
family,
fixture: Some(fixture),
}
}
const TRANSMISSION_FORMATS: [TransmissionFormat; 19] = [
transmission("MATPOWER .m", "matpower", Emission::Document, Family::Case),
transmission(
"PowerModels JSON",
"powermodels-json",
Emission::Document,
Family::Case,
),
transmission("PSS/E .raw 33", "psse", Emission::Document, Family::Case),
transmission(
"PSS/E RAWX 35",
"psse-rawx",
Emission::Document,
Family::Case,
),
transmission(
"PowerWorld .aux",
"powerworld",
Emission::Document,
Family::Case,
),
transmission("egret JSON", "egret-json", Emission::Document, Family::Case),
transmission(
"pandapower JSON",
"pandapower-json",
Emission::Document,
Family::Case,
),
transmission("Surge JSON", "surge-json", Emission::Document, Family::Case),
transmission("PSLF .epc", "pslf", Emission::Document, Family::Case),
transmission("XIIDM 1.17", "xiidm", Emission::Document, Family::Exchange),
transmission("JIIDM 1.17", "jiidm", Emission::Document, Family::Exchange),
transmission("CGMES 3.0", "cgmes", Emission::Directory, Family::Exchange),
transmission(
"UCTE-DEF .uct",
"ucte",
Emission::Document,
Family::Exchange,
),
transmission(
"PyPSA CSV",
"pypsa-csv",
Emission::Directory,
Family::Dataset,
),
transmission(
"GridFM Parquet",
"gridfm",
Emission::Directory,
Family::Dataset,
),
read_only(
"PSS/E .raw 32",
"psse",
Family::Case,
"psse/ExampleVersion32_exported.raw",
),
read_only(
"IEEE CDF",
"ieee-cdf",
Family::Case,
"ieee-cdf/ieee14cdf.txt",
),
read_only(
"GO Challenge 3 JSON",
"goc3-json",
Family::Case,
"goc3/goc3_small.json",
),
read_only(
"DeepMind OPFData JSON",
"opfdata-json",
Family::Dataset,
"opfdataset/example_0.json",
),
];
const TRANSMISSION_TARGETS: usize = 15;
fn transmission_targets() -> impl Iterator<Item = (usize, TransmissionFormat)> {
TRANSMISSION_FORMATS
.into_iter()
.enumerate()
.filter(|(_, format)| format.emission != Emission::ReadOnly)
}
const TRANSMISSION_WARNING_BASELINE: [[usize; TRANSMISSION_TARGETS]; 19] = [
[0, 1, 15, 15, 18, 7, 14, 23, 14, 70, 70, 57, 42, 15, 31], [0, 0, 15, 15, 17, 6, 13, 22, 13, 69, 69, 56, 41, 14, 30], [1, 1, 0, 0, 2, 1, 3, 1, 2, 32, 32, 12, 29, 9, 28], [1, 1, 0, 0, 2, 1, 3, 1, 2, 32, 32, 12, 29, 9, 28], [1, 0, 6, 6, 0, 0, 8, 0, 6, 54, 54, 30, 33, 13, 27], [0, 0, 9, 9, 12, 0, 7, 1, 7, 68, 68, 36, 36, 9, 25], [0, 0, 7, 7, 8, 0, 0, 0, 7, 55, 55, 35, 52, 8, 18], [0, 0, 9, 9, 12, 0, 6, 0, 7, 67, 67, 36, 36, 9, 25], [0, 0, 1, 1, 1, 0, 4, 3, 0, 38, 38, 11, 28, 8, 27], [9, 7, 33, 33, 8, 7, 9, 7, 8, 0, 0, 179, 60, 15, 53], [9, 7, 33, 33, 8, 7, 9, 7, 8, 0, 0, 179, 60, 15, 53], [8, 6, 67, 42, 6, 6, 7, 6, 6, 44, 44, 0, 51, 13, 51], [18, 12, 6, 6, 18, 12, 6, 12, 18, 36, 36, 12, 0, 12, 36], [0, 6, 14, 14, 14, 6, 9, 6, 12, 70, 70, 38, 38, 0, 15], [6, 0, 14, 14, 14, 6, 9, 0, 12, 64, 64, 32, 40, 12, 0], [5, 4, 2, 2, 4, 4, 5, 4, 6, 15, 15, 9, 10, 6, 12], [8, 8, 6, 6, 8, 8, 9, 8, 8, 13, 13, 8, 14, 9, 11], [6, 5, 7, 7, 9, 5, 7, 5, 7, 8, 8, 7, 14, 9, 11], [3, 2, 5, 5, 5, 3, 4, 2, 4, 32, 32, 8, 32, 5, 4], ];
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: BalancedNetwork,
parse_warnings: Vec<String>,
}
fn run_transmission_matrix() -> MatrixReport {
let sources = TRANSMISSION_FORMATS.iter().map(|fmt| fmt.name).collect();
let targets = transmission_targets().map(|(_, fmt)| fmt.name).collect();
let groups = Family::ALL
.into_iter()
.map(|family| {
let columns = transmission_targets()
.enumerate()
.filter(|(_, (_, format))| format.family == family)
.map(|(column, _)| column)
.collect();
(family.title(), columns)
})
.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 (column, (target_idx, target)) in transmission_targets().enumerate() {
let mut cell = Cell::new(TRANSMISSION_WARNING_BASELINE[source_idx][target_idx]);
debug_assert_eq!(column, 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("; "),
silent_loss_note(&cell),
));
}
row.push(cell);
}
cells.push(row);
}
MatrixReport {
sources,
targets,
groups,
coverage: "Six generated cases per writable source row; one vendored case per read-only source row.",
cells,
failures,
}
}
fn transmission_payloads(format: TransmissionFormat) -> Result<Vec<TransmissionPayload>, String> {
if let Some(fixture) = format.fixture {
let source = powerio_core::Source::open(data(fixture))
.map_err(|err| format!("open {fixture}: {err}"))?;
let parsed = parse_transmission_source(source, format.token)
.map_err(|err| format!("parse {fixture}: {err}"))?;
return Ok(vec![TransmissionPayload {
label: fixture,
network: parsed.network,
parse_warnings: parsed.warnings,
}]);
}
TRANSMISSION_CASES
.iter()
.map(|(label, rel)| {
let base =
parse_matpower_file(data(rel)).map_err(|err| format!("parse {rel}: {err}"))?;
let rendered = emit_transmission(&base, format)
.map_err(|err| format!("write {rel} as {}: {err}", format.name))?;
let parsed = parse_transmission_emitted(&rendered.emitted, format)
.map_err(|err| format!("read generated {rel} as {}: {err}", format.name))?;
Ok(TransmissionPayload {
label,
network: parsed.network,
parse_warnings: parsed.warnings,
})
})
.collect()
}
fn validate_transmission_pair(
payload: &TransmissionPayload,
target: TransmissionFormat,
cell: &mut Cell,
) {
let conversion = match emit_transmission(&payload.network, target) {
Ok(conversion) => conversion,
Err(err) => {
cell.failures.push(format!(
"{} did not write as {}: {err}",
payload.label, target.name
));
return;
}
};
cell.record_warnings(TARGET_WRITE, &conversion.diagnostics);
let parsed = match parse_transmission_emitted(&conversion.emitted, target) {
Ok(parsed) => parsed,
Err(err) => {
cell.failures.push(format!(
"{} output did not parse as {}: {err}",
payload.label, target.name
));
return;
}
};
cell.record_warnings(TARGET_READBACK, &parsed.warnings);
let held = network_a_target_holds(&payload.network, target);
let tolerance = electrical_tolerance(target, &held);
let actual = transmission_core(&parsed.network);
let expected = transmission_core(&held);
if !expected.agrees_within(&actual, tolerance) {
cell.failures.push(format!(
"{} core changed for {}: before {expected:?}, after {actual:?}",
payload.label, target.name
));
}
record_model_diffs(
payload.label,
&transmission_value(&payload.network),
&transmission_value(&parsed.network),
cell,
);
let numbered = match ybus_change_within(&held, &parsed.network, tolerance) {
Ok(Some(diff)) => Some(format!("Y_bus changed: {diff}")),
Ok(None) => injection_change_within(&held, &parsed.network, tolerance)
.map(|diff| format!("bus injections moved: {diff}")),
Err(side) => {
cell.failures.push(format!(
"{} Y_bus could not be built for {} ({})",
payload.label,
target.name,
match side {
YbusUnavailable::Before => "source",
YbusUnavailable::After => "result",
}
));
return;
}
};
if numbered.is_none() {
return;
}
if states_impedances_the_target_cannot(&held, target) {
return;
}
if let Some(diff) = electrical_change_up_to_relabeling(&held, &parsed.network, tolerance) {
cell.failures.push(format!(
"{} electrical problem changed for {}: {diff}",
payload.label, target.name
));
}
}
fn states_impedances_the_target_cannot(
network: &BalancedNetwork,
target: TransmissionFormat,
) -> bool {
const LOWEST_UCTE_LEVEL_KV: f64 = 27.0;
target.token == "ucte"
&& network
.buses()
.iter()
.any(|bus| bus.base_kv > 0.0 && bus.base_kv < LOWEST_UCTE_LEVEL_KV)
}
fn network_iidm_holds(network: &BalancedNetwork) -> BalancedNetwork {
let Some(detailed) = network.detailed_connectivity().as_deref() else {
return network.clone();
};
let levels_with_switches = detailed
.switches
.iter()
.map(|switch| switch.voltage_level.clone())
.collect::<std::collections::HashSet<_>>();
let unstated = |component_type: &str, identity: Option<&str>| {
let Some(identity) = identity else {
return false;
};
detailed.terminals.iter().any(|terminal| {
terminal.node.is_some()
&& !terminal.connected
&& !levels_with_switches.contains(&terminal.voltage_level)
&& terminal.equipment.component_type() == component_type
&& terminal.equipment.local_id() == identity
})
};
let mut held = network.clone();
for branch in held.branches_mut() {
if unstated("branch", branch.uid.as_deref()) {
branch.in_service = true;
}
}
for generator in held.generators_mut() {
if unstated("generator", generator.uid.as_deref()) {
generator.in_service = true;
}
}
for load in held.loads_mut() {
if unstated("load", load.uid.as_deref()) {
load.in_service = true;
}
}
for shunt in held.shunts_mut() {
if unstated("shunt", shunt.uid.as_deref()) {
shunt.in_service = true;
}
}
for storage in held.storage_mut() {
if unstated("storage", storage.uid.as_deref()) {
storage.in_service = true;
}
}
held
}
fn electrical_tolerance(target: TransmissionFormat, network: &BalancedNetwork) -> f64 {
const LOWEST_UCTE_LEVEL_KV: f64 = 27.0;
if target.token != "ucte" {
return 1e-8;
}
let below_the_lowest_level = network
.buses()
.iter()
.any(|bus| bus.base_kv > 0.0 && bus.base_kv < LOWEST_UCTE_LEVEL_KV);
if below_the_lowest_level { 5e-2 } else { 1e-3 }
}
fn network_a_target_holds(
network: &BalancedNetwork,
target: TransmissionFormat,
) -> BalancedNetwork {
if matches!(target.token, "xiidm" | "jiidm") {
return network_iidm_holds(network);
}
if target.token == "matpower" || target.token == "gridfm" {
let mut held = network.clone();
let mut merged = BTreeMap::<powerio_tx::BusId, powerio_tx::Shunt>::new();
for shunt in held.shunts().iter().filter(|shunt| shunt.in_service) {
let aggregate = merged
.entry(shunt.bus)
.or_insert_with(|| powerio_tx::Shunt::new(shunt.bus, 0.0, 0.0));
aggregate.g += shunt.g;
aggregate.b += shunt.b;
}
*held.shunts_mut() = merged.into_values().collect();
return held;
}
if target.token == "pandapower-json" {
let mut held = network.clone();
let generator_buses = held
.generators()
.iter()
.map(|generator| generator.bus)
.collect::<std::collections::HashSet<_>>();
let empty_references = held
.buses()
.iter()
.filter(|bus| {
bus.kind == powerio_tx::BusType::Ref && !generator_buses.contains(&bus.id)
})
.map(|bus| bus.id)
.collect::<Vec<_>>();
for bus in empty_references {
held.generators_mut().push(powerio_tx::Generator::new(bus));
}
return held;
}
if target.token != "ucte" {
return network.clone();
}
let mut held = network.clone();
held.shunts_mut().clear();
held.branches_mut()
.retain(|branch| branch.from != branch.to);
for generator in held.generators_mut() {
if !generator.in_service {
generator.in_service = true;
generator.pg = 0.0;
generator.qg = 0.0;
}
}
let mut merged: Vec<powerio_tx::Generator> = Vec::new();
for generator in held.generators() {
match merged
.iter_mut()
.find(|existing| existing.bus == generator.bus)
{
Some(existing) => {
existing.pg += generator.pg;
existing.qg += generator.qg;
}
None => merged.push(generator.clone()),
}
}
*held.generators_mut() = merged;
let generator_buses = held
.generators()
.iter()
.map(|generator| generator.bus)
.collect::<std::collections::HashSet<_>>();
let empty_references = held
.buses()
.iter()
.filter(|bus| bus.kind == powerio_tx::BusType::Ref && !generator_buses.contains(&bus.id))
.map(|bus| bus.id)
.collect::<Vec<_>>();
for bus in empty_references {
held.generators_mut().push(powerio_tx::Generator::new(bus));
}
held
}
fn record_model_diffs(
label: &str,
before: &serde_json::Value,
after: &serde_json::Value,
cell: &mut Cell,
) {
cell.silent_model_diffs.extend(
model_diffs(before, after)
.iter()
.map(|d| format!("{label}: {d}")),
);
}
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, 66, 88], [21, 0, 16], [27, 44, 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: MulticonductorNetwork,
module: powerio_core::PioModule<MulticonductorNetwork>,
parse_warnings: Vec<String>,
core: DistributionCore,
}
fn run_geo_layer_matrix() -> MatrixReport {
const SOURCE: &str = "PowerWorld .aux substation coordinates";
const TARGET: &str = "geo-json";
let mut cell = Cell::new(GEO_LAYER_WARNING_BASELINE);
let mut failures = Vec::new();
match geo_layer_cell(&mut cell) {
Ok(()) => {}
Err(err) => cell.failures.push(err),
}
if !cell.ok() {
failures.push(format!(
"geo layer {SOURCE} -> {TARGET}: observed {} warnings, baseline {}; {}{}",
cell.observed_warnings,
cell.baseline_warnings,
cell.failures.join("; "),
silent_loss_note(&cell),
));
}
MatrixReport {
sources: vec![SOURCE],
targets: vec![TARGET],
groups: vec![("Into the geographic layer document", vec![0])],
coverage: "One case per source row.",
cells: vec![vec![cell]],
failures,
}
}
const GEO_LAYER_WARNING_BASELINE: usize = 0;
fn geo_layer_cell(cell: &mut Cell) -> Result<(), String> {
let text = std::fs::read_to_string(data("powerworld/ACTIVSg200.aux"))
.map_err(|err| format!("read the aux fixture: {err}"))?;
let layer = powerio::to_geo_layer_from_aux_text(&text)
.map_err(|err| format!("read the aux substation coordinates: {err}"))?;
if layer.features.is_empty() {
return Err("the aux fixture states no substation coordinates".to_owned());
}
let module = powerio_core::PioModule::new(powerio::PioValue::GeoLayer(layer.clone()));
let destination = powerio_core::Destination::memory("layer").map_err(|err| err.to_string())?;
let result = powerio::emit(&module, "geo-json", destination).map_err(|err| err.to_string())?;
cell.record_warnings(
TARGET_WRITE,
&powerio_core::render_diagnostics(result.diagnostics()),
);
let powerio_core::EmittedOutput::Memory { mut artifacts } = result.into_output() else {
return Err("a memory destination returned path output".to_owned());
};
let artifact = artifacts
.pop()
.filter(|_| artifacts.is_empty())
.ok_or_else(|| "geo layer emission returned several artifacts".to_owned())?;
let source = powerio_core::Source::from_memory("layer.geo.json", artifact.into_bytes())
.map_err(|err| err.to_string())?;
let options = powerio::ParseOptions::default()
.format("geo-json")
.map_err(|err| err.to_string())?;
let back = powerio::parse_with_options(source, &options).map_err(|err| err.to_string())?;
cell.record_warnings(
TARGET_READBACK,
&powerio_core::render_diagnostics(back.diagnostics()),
);
let powerio::PioValue::GeoLayer(read_back) = &back.value() else {
return Err(format!(
"the geo layer document parsed as {}",
back.value().type_name()
));
};
record_model_diffs(
"aux substation coordinates",
&serde_json::to_value(&layer).map_err(|err| err.to_string())?,
&serde_json::to_value(read_back).map_err(|err| err.to_string())?,
cell,
);
Ok(())
}
fn run_distribution_matrix() -> MatrixReport {
let formats: Vec<_> = 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("; "),
silent_loss_note(&cell),
));
}
row.push(cell);
}
cells.push(row);
}
let groups = vec![("Into a distribution format", (0..formats.len()).collect())];
MatrixReport {
sources: formats.clone(),
targets: formats,
groups,
coverage: "Seven cases per source row.",
cells,
failures,
}
}
fn distribution_payloads(format: DistributionFormat) -> Result<Vec<DistributionPayload>, String> {
DISTRIBUTION_CASES
.iter()
.map(|(label, rel, native_format)| {
let mut base = load_distribution_case(data(rel), native_format.token)
.map_err(|err| format!("parse {rel}: {err}"))?;
*base.network.source_format_mut() = None;
let rendered = emit_distribution_value(&base.network, format.target)?;
let parsed = parse_distribution_text(&rendered.text, format)
.map_err(|err| format!("read generated {rel} as {}: {err}", format.name))?;
let core = distribution_core(&parsed.network);
Ok(DistributionPayload {
label,
network: parsed.network,
module: parsed.module,
parse_warnings: parsed.warnings,
core,
})
})
.collect()
}
fn validate_distribution_pair(
payload: &DistributionPayload,
target: DistributionFormat,
cell: &mut Cell,
) {
let conversion = match emit_distribution_module(&payload.module, target.target) {
Ok(conversion) => conversion,
Err(err) => {
cell.failures
.push(format!("{} emit as {}: {err}", payload.label, target.name));
return;
}
};
cell.record_warnings(TARGET_WRITE, &conversion.render_diagnostics());
match parse_distribution_text(&conversion.text, target) {
Ok(parsed) => {
cell.record_warnings(TARGET_READBACK, &parsed.warnings);
let actual = distribution_core(&parsed.network);
if !core_survives(&payload.core, &actual, target.target) {
cell.failures.push(format!(
"{} core changed for {}: before {:?}, after {:?}",
payload.label, target.name, payload.core, actual
));
}
record_model_diffs(
payload.label,
&distribution_value(&payload.network),
&distribution_value(&parsed.network),
cell,
);
}
Err(err) => cell.failures.push(format!(
"{} output did not parse as {}: {err}",
payload.label, target.name
)),
}
}
struct DistParsed {
network: MulticonductorNetwork,
warnings: Vec<String>,
module: powerio_core::PioModule<MulticonductorNetwork>,
}
fn dist_module_to_parsed(module: powerio_core::PioModule<MulticonductorNetwork>) -> DistParsed {
DistParsed {
warnings: powerio_dist::diagnostics::render_diagnostics(module.diagnostics()),
network: module.value().clone(),
module,
}
}
fn load_distribution_case(
path: impl AsRef<std::path::Path>,
from: &str,
) -> Result<DistParsed, powerio_core::Error> {
let source = powerio_core::Source::open(path.as_ref())?.with_format(
powerio_core::FormatId::new(from.to_ascii_lowercase().replace('_', "-"))?,
);
powerio_dist::parse(source).map(dist_module_to_parsed)
}
fn parse_distribution_text(
text: &str,
format: DistributionFormat,
) -> Result<DistParsed, powerio_core::Error> {
if format.target != DistTargetFormat::Dss {
let source = powerio_core::Source::from_memory("<memory>", text.as_bytes().to_vec())?
.with_format(powerio_core::FormatId::new(
format.token.to_ascii_lowercase().replace('_', "-"),
)?);
return powerio_dist::parse(source).map(dist_module_to_parsed);
}
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 = load_distribution_case(&path, format.token);
let _ = std::fs::remove_file(path);
parsed
}
fn core_survives(
before: &DistributionCore,
after: &DistributionCore,
target: DistTargetFormat,
) -> bool {
if before == after {
return true;
}
target == DistTargetFormat::Dss
&& after.loads >= before.loads
&& after.buses == before.buses
&& after.generators == before.generators
&& after.shunts == before.shunts
&& after.load_p == before.load_p
&& after.load_q == before.load_q
}
#[test]
fn every_fixture_round_trips_through_ir() {
let mut failures: Vec<String> = Vec::new();
for format in TRANSMISSION_FORMATS {
let payloads = match transmission_payloads(format) {
Ok(payloads) => payloads,
Err(err) => {
failures.push(err);
continue;
}
};
for payload in payloads {
let where_ = format!("{} as {}", payload.label, format.name);
if let Err(err) = ir_preserves_network(payload.network) {
failures.push(format!("{where_}: {err}"));
}
}
}
for format in DISTRIBUTION_FORMATS {
let payloads = match distribution_payloads(format) {
Ok(payloads) => payloads,
Err(err) => {
failures.push(err);
continue;
}
};
for payload in payloads {
let module = powerio_core::PioModule::new(powerio::PioValue::MulticonductorNetwork(
payload.network,
));
if let Err(err) = ir_round_trip(&module) {
failures.push(format!("{} as {}: {err}", payload.label, format.name));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn ir_preserves_network(net: BalancedNetwork) -> Result<(), String> {
let before = transmission_value(&net);
let module = powerio_core::PioModule::new(powerio::PioValue::BalancedNetwork(net));
let back = ir_round_trip(&module)?;
let powerio::PioValue::BalancedNetwork(net_back) = &back.value() else {
return Err("the module came back with a different value kind".to_owned());
};
let after = transmission_value(net_back);
if before != after {
return Err(format!(
"the PowerIO IR round trip changed the model: {:?}",
model_diffs(&before, &after)
));
}
Ok(())
}
fn ir_round_trip(
module: &powerio_core::PioModule<powerio::PioValue>,
) -> Result<powerio_core::PioModule<powerio::PioValue>, String> {
let first = serialize_ir(module)?;
let source = powerio::Source::from_memory("module.pio.json", first.as_bytes().to_vec())
.map_err(|err| format!("create IR source: {err}"))?;
let back = powerio::deserialize(source).map_err(|err| format!("deserialize: {err}"))?;
let second = serialize_ir(&back)?;
if first != second {
return Err("the PowerIO IR document is not serialization stable".to_owned());
}
Ok(back)
}
fn serialize_ir(module: &powerio_core::PioModule<powerio::PioValue>) -> Result<String, String> {
let destination = powerio::Destination::memory("module.pio.json")
.map_err(|err| format!("create IR destination: {err}"))?;
let result =
powerio::serialize(module, destination).map_err(|err| format!("serialize: {err}"))?;
let powerio::EmittedOutput::Memory { mut artifacts } = result.into_output() else {
return Err("PowerIO IR memory destination returned path output".to_owned());
};
let artifact = artifacts
.pop()
.filter(|_| artifacts.is_empty())
.ok_or_else(|| "PowerIO IR serialization returned more than one artifact".to_owned())?;
String::from_utf8(artifact.into_bytes())
.map_err(|err| format!("PowerIO IR is not valid UTF-8: {err}"))
}
fn parse_matpower_file(
path: impl AsRef<std::path::Path>,
) -> Result<powerio_matrix::BalancedNetwork, powerio_core::Error> {
let source = powerio_core::Source::open(path.as_ref())?
.with_format(powerio_core::FormatId::new("matpower")?);
powerio_tx::format::parse(source).map(powerio_core::PioModule::into_value)
}
struct ParsedTransmission {
network: BalancedNetwork,
warnings: Vec<String>,
}
fn parse_transmission_source(
source: powerio_core::Source,
from: &str,
) -> Result<ParsedTransmission, String> {
let format = powerio_core::FormatId::new(from.to_ascii_lowercase().replace('_', "-"))
.map_err(|err| err.to_string())?;
let options = powerio::ParseOptions::default().format_id(format);
let module = powerio::parse_with_options(source, &options).map_err(|err| err.to_string())?;
let warnings = module
.diagnostics()
.iter()
.map(|d| format!("{}: {}", d.code(), d.message()))
.collect();
let network = balanced_network(module.value())
.ok_or_else(|| format!("{from} parsed as {}", module.value().type_name()))?
.clone();
Ok(ParsedTransmission { network, warnings })
}
fn balanced_network(value: &powerio::PioValue) -> Option<&BalancedNetwork> {
match value {
powerio::PioValue::BalancedNetwork(network) => Some(network),
powerio::PioValue::ScenarioSet(scenarios) => scenarios.get_at(0).and_then(balanced_network),
powerio::PioValue::TimeSeries(series) => series.get(0).and_then(balanced_network),
powerio::PioValue::BalancedOperatingPoint(point) => Some(point.network()),
powerio::PioValue::AcOpfSolution(solution) => Some(solution.network()),
powerio::PioValue::AcPfSolution(solution) => Some(solution.network()),
powerio::PioValue::AcScucInstance(instance) => Some(instance.network()),
_ => None,
}
}