use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_powerio")
}
fn repo_file(path: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(path)
}
fn run(args: &[&str]) -> Output {
Command::new(bin()).args(args).output().unwrap()
}
fn run_with_stdin(args: &[&str], stdin: &[u8]) -> Output {
use std::io::Write as _;
use std::process::Stdio;
let mut child = Command::new(bin())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(stdin).unwrap();
child.wait_with_output().unwrap()
}
fn json_diagnostics(out: &Output) -> Vec<serde_json::Value> {
let stderr = String::from_utf8_lossy(&out.stderr);
let lines: Vec<&str> = stderr.lines().filter(|line| !line.is_empty()).collect();
assert_eq!(lines.len(), 1, "stderr is not one JSON line:\n{stderr}");
serde_json::from_str(lines[0]).unwrap_or_else(|error| panic!("{error}:\n{stderr}"))
}
const IR_DIAGNOSTIC_FIELDS: [&str; 9] = [
"id",
"severity",
"code",
"message",
"target",
"spans",
"related",
"details",
"suggested_action",
];
#[test]
fn convert_reads_a_case_from_stdin_with_a_declared_format() {
let case = std::fs::read(repo_file("tests/data/case9.m")).unwrap();
let out = run_with_stdin(
&[
"convert", "-", "--from", "matpower", "--to", "psse", "-o", "-",
],
&case,
);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("powerio export: case9"), "{stdout}");
assert!(
stdout.contains("/ powerio export") || stdout.contains("BEGIN"),
"{stdout}"
);
let out = run_with_stdin(&["summary", "-", "--from", "matpower"], &case);
assert_success(&out);
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(summary["elements"]["buses"], 9, "{summary}");
let out = run_with_stdin(&["serialize", "-", "--from", "matpower"], &case);
assert_success(&out);
let document: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(document["schema"], powerio::IR_SCHEMA_NAME, "{document}");
assert_eq!(document["version"], powerio::IR_VERSION, "{document}");
assert_eq!(
document["value"]["type"], "powerio.BalancedNetwork",
"{document}"
);
}
#[test]
fn stdin_without_a_declared_format_is_a_request_failure() {
let case = std::fs::read(repo_file("tests/data/case9.m")).unwrap();
let out = run_with_stdin(&["convert", "-", "--to", "psse", "-o", "-"], &case);
assert_failure(&out);
assert_eq!(out.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("REQUEST.CLI.FORMAT_REQUIRED"), "{stderr}");
assert!(stderr.contains("--from"), "{stderr}");
assert!(out.stdout.is_empty(), "nothing is written on failure");
let out = run_with_stdin(
&[
"convert", "-", "--from", "gridfm", "--to", "psse", "-o", "-",
],
&case,
);
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn an_unknown_format_is_a_usage_failure() {
let case = repo_file("tests/data/case9.m");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"not-a-format",
"-o",
"-",
]);
assert_eq!(out.status.code(), Some(2));
let out = run(&[
"convert",
case.to_str().unwrap(),
"--from",
"not-a-format",
"--to",
"psse",
"-o",
"-",
]);
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn a_missing_input_exits_with_the_io_status_and_json_records() {
let out = run(&[
"--diagnostics-format",
"json",
"convert",
"does-not-exist.m",
"--to",
"psse",
"-o",
"-",
]);
assert_eq!(out.status.code(), Some(3));
let diagnostics = json_diagnostics(&out);
let record = &diagnostics[0];
assert_eq!(record["code"], "READ.IO.OPEN", "{record}");
assert_eq!(record["severity"], "error", "{record}");
let primary_id = record["id"].as_str().unwrap().to_owned();
assert!(
record["message"]
.as_str()
.unwrap()
.contains("does-not-exist.m"),
"{record}"
);
let notes: Vec<&serde_json::Value> = diagnostics
.iter()
.filter(|d| d["severity"] == "note")
.collect();
assert!(
notes.iter().any(|note| {
note["message"]
.as_str()
.unwrap()
.contains("No such file or directory")
}),
"{diagnostics:?}"
);
for note in ¬es {
assert_eq!(note["code"], "READ.IO.OPEN", "{note}");
assert_eq!(note["related"], serde_json::json!([primary_id]), "{note}");
}
for record in &diagnostics {
for key in record.as_object().unwrap().keys() {
assert!(
IR_DIAGNOSTIC_FIELDS.contains(&key.as_str()),
"unexpected field {key} in {record}"
);
}
}
let out = run(&[
"convert",
"does-not-exist.m",
"--to",
"psse",
"-o",
"-",
"--diagnostics-format",
"json",
]);
assert_eq!(out.status.code(), Some(3));
assert_eq!(json_diagnostics(&out)[0]["code"], "READ.IO.OPEN");
}
#[test]
fn json_format_leaves_only_the_diagnostics_array_on_stderr() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let output = std::env::temp_dir().join(format!("powerio-cli-json-{stamp}.raw"));
let case = repo_file("tests/data/case9.m");
let out = run(&[
"--diagnostics-format",
"json",
"convert",
case.to_str().unwrap(),
"--to",
"psse",
"-o",
output.to_str().unwrap(),
]);
assert_success(&out);
assert!(output.is_file(), "{} was not written", output.display());
let _ = std::fs::remove_file(&output);
let diagnostics = json_diagnostics(&out);
for record in &diagnostics {
assert_ne!(record["severity"], "error", "{record}");
}
let bundle = std::env::temp_dir().join(format!("powerio-cli-json-bundle-{stamp}"));
let out = run(&[
"--diagnostics-format",
"json",
"dcopf",
case.to_str().unwrap(),
"-o",
bundle.to_str().unwrap(),
]);
assert_success(&out);
assert!(bundle.is_dir(), "{} was not written", bundle.display());
let _ = std::fs::remove_dir_all(&bundle);
let diagnostics = json_diagnostics(&out);
for record in &diagnostics {
assert_ne!(record["severity"], "error", "{record}");
}
}
#[test]
fn dcopf_writes_the_angle_difference_bounds_it_was_asked_for() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let case = repo_file("tests/data/case9.m");
let bounds = |choice: Option<&str>| {
let output = std::env::temp_dir().join(format!(
"powerio-cli-angle-{stamp}-{}",
choice.unwrap_or("default")
));
let mut args = vec![
"dcopf",
case.to_str().unwrap(),
"-o",
output.to_str().unwrap(),
];
if let Some(choice) = choice {
args.extend(["--angle-difference-bounds", choice]);
}
assert_success(&run(&args));
let bundle = output.join("case9_dcopf");
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(bundle.join("dcopf_meta.json")).unwrap())
.unwrap();
let angle_max: Vec<f64> = std::fs::read_to_string(bundle.join("angle_max.mtx"))
.unwrap()
.lines()
.filter(|line| !line.starts_with('%'))
.skip(1)
.map(|line| line.trim().parse().unwrap())
.collect();
let _ = std::fs::remove_dir_all(&output);
(
manifest["build_options"]["angle_difference_bounds"].clone(),
angle_max,
)
};
let pad = powerio_tx::POWER_MODELS_ANGLE_BOUND_PAD;
let (padded, padded_max) = bounds(None);
assert_eq!(padded, "power_models_pad");
assert!(padded_max.iter().all(|&max| (max - pad).abs() < 1e-12));
let (stated, stated_max) = bounds(Some("stated"));
assert_eq!(stated, "stated");
assert!(stated_max.iter().all(|&max| max == f64::INFINITY));
let (centered, centered_max) = bounds(Some("shift-centered-pad"));
assert_eq!(centered, "shift_centered_pad");
assert_eq!(centered_max, padded_max);
let (none, none_max) = bounds(Some("none"));
assert_eq!(none, "none");
assert!(none_max.iter().all(|&max| max == f64::INFINITY));
}
#[test]
fn a_malformed_case_exits_with_the_parse_status() {
let out = run_with_stdin(
&[
"--diagnostics-format",
"json",
"convert",
"-",
"--from",
"matpower",
"--to",
"psse",
"-o",
"-",
],
b"function mpc = broken\nmpc.bus = [\n\t1\t3\n",
);
assert_eq!(out.status.code(), Some(4));
let diagnostics = json_diagnostics(&out);
assert!(!diagnostics.is_empty(), "{diagnostics:?}");
assert!(
diagnostics
.iter()
.any(|d| d["code"].as_str().unwrap().starts_with("PARSE.") && d["severity"] == "error"),
"{diagnostics:?}"
);
let out = run_with_stdin(
&[
"convert", "-", "--from", "matpower", "--to", "psse", "-o", "-",
],
b"function mpc = broken\nmpc.bus = [\n\t1\t3\n",
);
assert_eq!(out.status.code(), Some(4));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.starts_with("Error: "), "{stderr}");
assert!(stderr.contains("PARSE."), "{stderr}");
}
fn assert_success(out: &Output) {
assert!(
out.status.success(),
"expected success\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
fn assert_failure(out: &Output) {
assert!(
!out.status.success(),
"expected failure\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn help_lists_user_facing_commands() {
let out = run(&["--help"]);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Convert a case file"), "{stdout}");
assert!(stdout.contains("summary"), "{stdout}");
assert!(stdout.contains("gridfm"), "{stdout}");
}
#[test]
fn convert_to_stdout_keeps_text_on_stdout() {
let case = repo_file("tests/data/case9.m");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"matpower",
"-o",
"-",
]);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("mpc.bus = ["), "{stdout}");
assert!(
!String::from_utf8_lossy(&out.stderr).contains("wrote "),
"stdout target should not report a file write: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn convert_goc3_problem_and_solution_source_emits_official_solution() {
let source = repo_file("tests/data/goc3");
let out = run(&[
"convert",
source.to_str().unwrap(),
"--to",
"goc3-json",
"-o",
"-",
]);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let output = &value["time_series_output"];
assert!(
output.is_object(),
"{}",
String::from_utf8_lossy(&out.stdout)
);
assert_eq!(output["bus"].as_array().unwrap().len(), 2);
assert_eq!(
output["simple_dispatchable_device"]
.as_array()
.unwrap()
.len(),
2
);
}
#[test]
fn convert_goc3_solution_without_problem_reports_missing_problem() {
let solution = repo_file("tests/data/goc3/goc3_small_solution.json");
let out = run(&[
"convert",
solution.to_str().unwrap(),
"--to",
"goc3-json",
"-o",
"-",
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("requires the matching problem file"),
"{stderr}"
);
}
#[test]
fn convert_goc3_problem_to_matpower_uses_the_typed_instance() {
let problem = repo_file("tests/data/goc3/goc3_small.json");
let out = run(&[
"convert",
problem.to_str().unwrap(),
"--from",
"goc3",
"--to",
"matpower",
"-o",
"-",
]);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stdout.contains("mpc.bus = ["), "{stdout}");
assert!(stderr.contains("EMIT.CALCULATION.DATA_OMITTED"), "{stderr}");
assert!(stderr.contains("powerio.AcScucInstance"), "{stderr}");
}
#[test]
fn convert_goc3_problem_and_solution_source_emits_cgmes_network() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let output = std::env::temp_dir().join(format!("powerio-cli-goc3-cgmes-{stamp}"));
let source = repo_file("tests/data/goc3");
let out = run(&[
"convert",
source.to_str().unwrap(),
"--to",
"cgmes",
"-o",
output.to_str().unwrap(),
]);
assert_success(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("EMIT.SOLUTION.DATA_OMITTED"), "{stderr}");
assert!(stderr.contains("powerio.AcScucSolution"), "{stderr}");
let names: Vec<_> = std::fs::read_dir(&output)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names.len(), 4, "{names:?}");
assert!(names.iter().any(|name| name.ends_with("_EQ.xml")));
assert!(names.iter().any(|name| name.ends_with("_TP.xml")));
assert!(names.iter().any(|name| name.ends_with("_SSH.xml")));
assert!(names.iter().any(|name| name.ends_with("_SV.xml")));
std::fs::remove_dir_all(output).unwrap();
}
#[test]
fn convert_goc3_solution_ir_uses_the_same_typed_path() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let ir = std::env::temp_dir().join(format!("powerio-cli-goc3-{stamp}.pio.json"));
let source = repo_file("tests/data/goc3");
let serialized = run(&[
"serialize",
source.to_str().unwrap(),
"-o",
ir.to_str().unwrap(),
]);
assert_success(&serialized);
let converted = run(&[
"convert",
ir.to_str().unwrap(),
"--to",
"matpower",
"-o",
"-",
]);
assert_success(&converted);
let stdout = String::from_utf8_lossy(&converted.stdout);
let stderr = String::from_utf8_lossy(&converted.stderr);
assert!(stdout.contains("mpc.bus = ["), "{stdout}");
assert!(stderr.contains("EMIT.SOLUTION.DATA_OMITTED"), "{stderr}");
assert!(stderr.contains("powerio.AcScucSolution"), "{stderr}");
std::fs::remove_file(ir).unwrap();
}
#[test]
fn convert_refuses_an_existing_text_output() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let out_path = std::env::temp_dir().join(format!("powerio-cli-convert-{stamp}.m"));
std::fs::write(&out_path, "sentinel").unwrap();
let case = repo_file("tests/data/case9.m");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"matpower",
"-o",
out_path.to_str().unwrap(),
]);
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("already exists"), "{stderr}");
assert_eq!(std::fs::read_to_string(&out_path).unwrap(), "sentinel");
let fresh = std::env::temp_dir().join(format!("powerio-cli-convert-fresh-{stamp}.m"));
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"matpower",
"-o",
fresh.to_str().unwrap(),
]);
assert_success(&out);
let text = std::fs::read_to_string(&fresh).unwrap();
assert!(text.contains("mpc.bus = ["), "{text}");
let _ = std::fs::remove_file(&fresh);
let _ = std::fs::remove_file(out_path);
}
#[test]
fn convert_writes_a_cgmes_profile_directory() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let output = std::env::temp_dir().join(format!("powerio-cli-cgmes-{stamp}"));
let case = repo_file("tests/data/case9.m");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"cgmes",
"-o",
output.to_str().unwrap(),
]);
assert_success(&out);
let names: Vec<_> = std::fs::read_dir(&output)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names.len(), 4, "{names:?}");
assert!(names.iter().any(|name| name.ends_with("_EQ.xml")));
assert!(names.iter().any(|name| name.ends_with("_TP.xml")));
assert!(names.iter().any(|name| name.ends_with("_SSH.xml")));
assert!(names.iter().any(|name| name.ends_with("_SV.xml")));
std::fs::remove_dir_all(output).unwrap();
}
#[test]
fn convert_writes_jiidm_and_reads_it_back() {
let case = repo_file("tests/data/case9.m");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"jiidm",
"-o",
"-",
]);
assert_success(&out);
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.starts_with("{\n \"version\" : \"1.17\","), "{text}");
let out = run_with_stdin(&["summary", "-", "--from", "jiidm"], text.as_bytes());
assert_success(&out);
let summary = String::from_utf8_lossy(&out.stdout);
assert!(
summary.contains("\"source_format\": \"jiidm\""),
"{summary}"
);
assert!(summary.contains("\"buses\": 9"), "{summary}");
}
#[test]
fn convert_reserves_iidm_and_rawx_for_input() {
let case = repo_file("tests/data/case9.m");
for (input_spelling, canonical) in [("iidm", "xiidm"), ("rawx", "psse-rawx")] {
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
input_spelling,
"-o",
"-",
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("accepted for input only"), "{stderr}");
assert!(stderr.contains(canonical), "{stderr}");
}
}
#[test]
fn summary_outputs_machine_readable_json() {
let case = repo_file("tests/data/case9.m");
let out = run(&["summary", case.to_str().unwrap()]);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(value["schema"], "powerio.summary");
assert_eq!(value["domain"], "transmission");
assert_eq!(value["elements"]["buses"], 9);
assert_eq!(value["topology"]["connected_components"], 1);
}
#[test]
fn summary_routes_json_inputs_through_the_classifier() {
let case = repo_file("tests/data/egret/case9.json");
let out = run(&["summary", case.to_str().unwrap()]);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(value["domain"], "transmission");
assert_eq!(value["source_format"], "egret-json");
assert_eq!(value["elements"]["buses"], 9);
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dist_json = std::env::temp_dir().join(format!("powerio-cli-summary-{stamp}.json"));
let dss = repo_file("tests/data/dist/micro/xfmr_single_phase.dss");
let out = run(&[
"convert",
dss.to_str().unwrap(),
"--to",
"bmopf-json",
"-o",
dist_json.to_str().unwrap(),
]);
assert_success(&out);
let out = run(&["summary", dist_json.to_str().unwrap()]);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(value["domain"], "distribution");
assert_eq!(value["elements"]["buses"], 3);
let _ = std::fs::remove_file(dist_json);
}
#[test]
fn serialize_refuses_an_existing_output_file() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let out_path = std::env::temp_dir().join(format!("powerio-cli-package-{stamp}.pio.json"));
std::fs::write(&out_path, "sentinel").unwrap();
let case = repo_file("tests/data/case9.m");
let out = run(&[
"serialize",
case.to_str().unwrap(),
"-o",
out_path.to_str().unwrap(),
]);
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("already exists"), "{stderr}");
assert_eq!(std::fs::read_to_string(&out_path).unwrap(), "sentinel");
let fresh = std::env::temp_dir().join(format!("powerio-cli-package-fresh-{stamp}.pio.json"));
let out = run(&[
"serialize",
case.to_str().unwrap(),
"-o",
fresh.to_str().unwrap(),
]);
assert_success(&out);
let text = std::fs::read_to_string(&fresh).unwrap();
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value["schema"], powerio::IR_SCHEMA_NAME);
assert_eq!(value["version"], powerio::IR_VERSION);
assert_eq!(value["producer"]["version"], powerio::VERSION);
let _ = std::fs::remove_file(&fresh);
let _ = std::fs::remove_file(out_path);
}
#[test]
fn batch_writes_requested_matrices_rhs_and_metadata() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let out_dir = std::env::temp_dir().join(format!("powerio-cli-batch-{stamp}"));
let case = repo_file("tests/data/case9.m");
let out = run(&[
"batch",
"-i",
case.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
"--matrices",
"ybus_real,ybus_imag",
"--rhs",
"injection",
"--scheme",
"xb",
]);
assert_success(&out);
for name in [
"case9_ybus_real.mtx",
"case9_ybus_imag.mtx",
"case9_ybus_real_rhs.mtx",
"case9_ybus_imag_rhs.mtx",
"case9_shunt.mtx",
"case9_meta.json",
] {
assert!(out_dir.join(name).is_file(), "missing {name}");
}
let meta: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out_dir.join("case9_meta.json")).unwrap())
.unwrap();
assert_eq!(meta["case_name"], "case9");
assert_eq!(meta["n_buses"], 9);
assert_eq!(meta["n_branches"], 9);
assert_eq!(meta["matrices"].as_array().unwrap().len(), 2);
assert_eq!(meta["matrices"][0]["kind"], "ybus_real");
assert_eq!(meta["matrices"][1]["kind"], "ybus_imag");
assert_eq!(meta["source_sha256"].as_str().unwrap().len(), 64);
let _ = std::fs::remove_dir_all(out_dir);
}
#[test]
fn sensitivities_write_solver_metadata() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let out_dir = std::env::temp_dir().join(format!("powerio-cli-sensitivities-{stamp}"));
let case = repo_file("tests/data/case9.m");
let out = run(&[
"sensitivities",
case.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
"--solver",
"sparse",
"--formula",
"reactance-only",
"--drop-tolerance",
"1e-10",
]);
assert_success(&out);
for name in [
"case9_ptdf.mtx",
"case9_lodf.mtx",
"case9_sensitivity_meta.json",
] {
assert!(out_dir.join(name).is_file(), "missing {name}");
}
let meta: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(out_dir.join("case9_sensitivity_meta.json")).unwrap(),
)
.unwrap();
assert_eq!(meta["case"], "case9");
assert_eq!(meta["branch_susceptance_formula"], "reactance_only");
assert!(meta.get("convention").is_none());
assert_eq!(meta["sensitivity"]["requested_solver"], "sparse");
assert_eq!(meta["sensitivity"]["solver_path"], "sparse_cholesky");
assert_eq!(meta["sensitivity"]["drop_tolerance"], 1e-10);
assert_eq!(meta["sensitivity"]["ptdf"]["rows"], 9);
assert_eq!(meta["sensitivity"]["ptdf"]["cols"], 9);
assert_eq!(meta["sensitivity"]["lodf"]["rows"], 9);
assert_eq!(meta["sensitivity"]["lodf"]["cols"], 9);
let _ = std::fs::remove_dir_all(out_dir);
}
#[test]
fn pypsa_directory_target_requires_output_directory() {
let case = repo_file("tests/data/case9.m");
let out = run(&["convert", case.to_str().unwrap(), "--to", "pypsa-csv"]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("requires `-o <output-dir>`"),
"stderr:\n{stderr}"
);
}
#[test]
fn family_mismatch_exits_before_writing_output() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let out_path = std::env::temp_dir().join(format!("powerio-cli-family-{stamp}.m"));
let case = repo_file("tests/data/dist/micro/xfmr_single_phase.dss");
let out = run(&[
"convert",
case.to_str().unwrap(),
"--to",
"matpower",
"-o",
out_path.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("no conversion path"), "stderr:\n{stderr}");
assert!(
!out_path.exists(),
"failed conversion wrote {}",
out_path.display()
);
}
#[test]
fn batch_scans_directory_recursively_and_skips_unparseable_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let nested = root.join("a").join("b").join("c");
std::fs::create_dir_all(&nested).unwrap();
std::fs::copy(repo_file("tests/data/case9.m"), nested.join("case9.m")).unwrap();
std::fs::copy(
repo_file("tests/data/dist/micro/fourwire_linecode.dss"),
root.join("feeder.dss"),
)
.unwrap();
std::fs::write(root.join("junk.json"), r#"{"not": "a case"}"#).unwrap();
let hidden = root.join(".hidden");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::copy(repo_file("tests/data/case14.m"), hidden.join("case14.m")).unwrap();
let out_dir = root.join("out");
std::fs::create_dir_all(&out_dir).unwrap();
std::fs::copy(repo_file("tests/data/case30.m"), out_dir.join("case30.m")).unwrap();
let out = run(&[
"batch",
"-i",
root.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
]);
assert_success(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out_dir.join("case9_bprime.mtx").is_file(), "{stderr}");
assert!(
out_dir.join("fourwire_bprime.mtx").is_file(),
"lowered dss case missing:\n{stderr}"
);
assert!(stderr.contains("junk.json"), "no skip warning:\n{stderr}");
assert!(
!out_dir.join("case14_bprime.mtx").exists(),
"hidden directory was scanned:\n{stderr}"
);
assert!(
!out_dir.join("case30_bprime.mtx").exists(),
"output directory was scanned:\n{stderr}"
);
}
#[test]
fn batch_scan_discovers_bmopf_json() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let json_path = root.join("sub").join("feeder.json");
std::fs::create_dir_all(json_path.parent().unwrap()).unwrap();
let dss = repo_file("tests/data/dist/micro/fourwire_linecode.dss");
let out = run(&[
"convert",
dss.to_str().unwrap(),
"--to",
"bmopf-json",
"-o",
json_path.to_str().unwrap(),
]);
assert_success(&out);
let out_dir = root.join("out");
let out = run(&[
"batch",
"-i",
root.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
]);
assert_success(&out);
assert!(
out_dir.join("fourwire_bprime.mtx").is_file(),
"bmopf json case missing:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn batch_scan_skips_unlowerable_dss_but_explicit_input_fails() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dss = repo_file("tests/data/dist/micro/xfmr_single_phase.dss");
std::fs::copy(&dss, root.join("xfmr.dss")).unwrap();
std::fs::copy(repo_file("tests/data/case9.m"), root.join("case9.m")).unwrap();
let out_dir = root.join("out");
let out = run(&[
"batch",
"-i",
root.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
]);
assert_success(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out_dir.join("case9_bprime.mtx").is_file(), "{stderr}");
assert!(stderr.contains("xfmr.dss"), "no skip warning:\n{stderr}");
let out = run(&[
"batch",
"-i",
dss.to_str().unwrap(),
"-o",
out_dir.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("lower") && stderr.contains("balanced"),
"expected a lowering diagnostic:\n{stderr}"
);
}
#[test]
fn batch_scan_with_no_case_files_reports_supported_extensions() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("notes.txt"), "nothing here").unwrap();
let out = run(&[
"batch",
"-i",
root.to_str().unwrap(),
"-o",
root.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no case files") && stderr.contains(".epc"),
"expected the extension list:\n{stderr}"
);
}
#[test]
fn batch_scan_where_nothing_loads_fails() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("junk.json"), r#"{"not": "a case"}"#).unwrap();
let out = run(&[
"batch",
"-i",
root.to_str().unwrap(),
"-o",
root.join("out").to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("loaded as case files"),
"expected the zero-loaded error:\n{stderr}"
);
}
#[test]
fn convert_exits_nonzero_on_a_refused_include() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let case_dir = dir.join("case");
std::fs::create_dir_all(&case_dir).unwrap();
std::fs::write(
dir.join("shared.dss"),
"New Linecode.lc1 nphases=3 r1=0.1 x1=0.2\n",
)
.unwrap();
let master = case_dir.join("master.dss");
std::fs::write(
&master,
"New Circuit.c1\nRedirect ../shared.dss\nNew Line.l1 bus1=a bus2=b linecode=lc1\n",
)
.unwrap();
let out_path = dir.join("out.json");
let out = run(&[
"convert",
master.to_str().unwrap(),
"--to",
"bmopf",
"-o",
out_path.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("READ.DSS.INCLUDE_REFUSED"),
"stderr:\n{stderr}"
);
assert!(
stderr.contains("READ.DSS.LINE_UNRESOLVED"),
"stderr:\n{stderr}"
);
assert!(
!out_path.exists(),
"an invalid snapshot must not be emitted"
);
}
#[cfg(unix)]
#[test]
fn convert_exits_nonzero_on_an_include_refused_through_a_symbolic_link() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let case_dir = dir.join("case");
std::fs::create_dir_all(&case_dir).unwrap();
std::fs::write(
dir.join("shared.dss"),
"New Linecode.lc1 nphases=3 r1=0.1 x1=0.2\n",
)
.unwrap();
std::os::unix::fs::symlink("../shared.dss", case_dir.join("link.dss")).unwrap();
let master = case_dir.join("master.dss");
std::fs::write(
&master,
"New Circuit.c1\nRedirect link.dss\nNew Line.l1 bus1=a bus2=b linecode=lc1\n",
)
.unwrap();
let out_path = dir.join("out.json");
let out = run(&[
"convert",
master.to_str().unwrap(),
"--to",
"bmopf",
"-o",
out_path.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("READ.DSS.INCLUDE_REFUSED"),
"stderr:\n{stderr}"
);
assert!(
stderr.contains("symbolic link"),
"the refusal must name the route:\n{stderr}"
);
assert!(
stderr.contains("READ.DSS.LINE_UNRESOLVED"),
"the linked definition must stay unavailable:\n{stderr}"
);
assert!(
!out_path.exists(),
"an invalid snapshot must not be emitted"
);
}
#[test]
fn serialize_exits_nonzero_on_a_refused_include() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let case_dir = dir.join("case");
std::fs::create_dir_all(&case_dir).unwrap();
std::fs::write(
dir.join("shared.dss"),
"New Linecode.lc1 nphases=3 r1=0.1 x1=0.2\n",
)
.unwrap();
let master = case_dir.join("master.dss");
std::fs::write(
&master,
"New Circuit.c1\nRedirect ../shared.dss\nNew Line.l1 bus1=a bus2=b linecode=lc1\n",
)
.unwrap();
let out_path = dir.join("out.pio.json");
let out = run(&[
"serialize",
master.to_str().unwrap(),
"-o",
out_path.to_str().unwrap(),
]);
assert_failure(&out);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("READ.DSS.INCLUDE_REFUSED"),
"stderr:\n{stderr}"
);
assert!(
!out_path.exists(),
"an invalid snapshot must not be serialized"
);
}
#[test]
fn an_unreadable_include_is_a_warning_not_a_containment_refusal() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let unreadable = dir.join("shared.dss");
std::fs::write(&unreadable, "New Linecode.lc1 nphases=3 r1=0.1 x1=0.2\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap();
}
let master = dir.join("master.dss");
std::fs::write(
&master,
"New Circuit.c1\nRedirect shared.dss\nNew Line.l1 bus1=a bus2=b r1=0.1 x1=0.2\n",
)
.unwrap();
let out_path = dir.join("out.json");
let out = run(&[
"convert",
master.to_str().unwrap(),
"--to",
"bmopf",
"-o",
out_path.to_str().unwrap(),
]);
let stderr = String::from_utf8_lossy(&out.stderr);
#[cfg(unix)]
if !nix_running_as_root() {
assert!(
out.status.success(),
"an unreadable include is not an escape attempt:\n{stderr}"
);
assert!(
!stderr.contains("READ.DSS.INCLUDE_REFUSED"),
"stderr:\n{stderr}"
);
}
}
#[cfg(unix)]
fn nix_running_as_root() -> bool {
std::fs::File::open("/proc/1/mem").is_ok() || std::env::var("USER").is_ok_and(|u| u == "root")
}
#[test]
fn convert_writes_a_deserialized_distribution_module_through_the_writer() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let ir = std::env::temp_dir().join(format!("powerio-cli-dss-{stamp}.pio.json"));
let source = repo_file("tests/data/dist/micro/switch.dss");
let serialized = run(&[
"serialize",
source.to_str().unwrap(),
"-o",
ir.to_str().unwrap(),
]);
assert_success(&serialized);
for target in ["dss", "bmopf-json"] {
let converted = run(&["convert", ir.to_str().unwrap(), "--to", target, "-o", "-"]);
assert_success(&converted);
let stdout = String::from_utf8_lossy(&converted.stdout);
assert!(
!stdout.contains("\"pio-ir\""),
"{target} echoed the IR:\n{stdout}"
);
}
let dss = run(&["convert", ir.to_str().unwrap(), "--to", "dss", "-o", "-"]);
let stdout = String::from_utf8_lossy(&dss.stdout).to_ascii_lowercase();
assert!(stdout.contains("new circuit"), "{stdout}");
std::fs::remove_file(ir).unwrap();
}
#[test]
fn verify_stated_state_refuses_then_merges_a_zero_impedance_branch() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let case = std::env::temp_dir().join(format!("powerio-cli-stated-state-{stamp}.m"));
std::fs::write(
&case,
"function mpc = stated\n\
mpc.version = '2';\n\
mpc.baseMVA = 100;\n\
mpc.bus = [\n\
1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;\n\
2 1 10 5 0 0 1 1 0 230 1 1.1 0.9;\n\
];\n\
mpc.gen = [\n\
1 10 5 100 -100 1 100 1 100 0 0 0 0 0 0 0 0 0 0 0 0;\n\
];\n\
mpc.branch = [\n\
1 2 0 0 0 100 100 100 0 0 1 -360 360;\n\
];\n",
)
.unwrap();
let path = case.to_str().unwrap();
let refused = run(&[
"--diagnostics-format",
"json",
"verify",
path,
"--stated-state",
]);
assert_eq!(refused.status.code(), Some(5));
let records = json_diagnostics(&refused);
assert_eq!(
records[0]["code"], "BUILD.BRANCH.ZERO_IMPEDANCE",
"{records:?}"
);
let merged = run(&["verify", path, "--stated-state", "--merge-buses", "exact"]);
assert!(
merged.status.success(),
"{}",
String::from_utf8_lossy(&merged.stderr)
);
let stdout = String::from_utf8_lossy(&merged.stdout);
assert!(stdout.contains("1 buses in 1 islands"), "{stdout}");
assert!(stdout.contains("max |dS| = 0.000 MVA"), "{stdout}");
assert!(stdout.contains("merged_group"), "{stdout}");
std::fs::remove_file(case).unwrap();
}
#[test]
fn a_typed_library_failure_exits_with_its_category_and_code() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let case = std::env::temp_dir().join(format!("powerio-cli-zero-impedance-{stamp}.m"));
std::fs::write(
&case,
"function mpc = zero\n\
mpc.version = '2';\n\
mpc.baseMVA = 100;\n\
mpc.bus = [\n\
1 3 0 0 0 0 1 1 0 230 1 1.1 0.9;\n\
2 1 10 5 0 0 1 1 0 230 1 1.1 0.9;\n\
];\n\
mpc.gen = [\n\
1 10 5 100 -100 1 100 1 100 0 0 0 0 0 0 0 0 0 0 0 0;\n\
];\n\
mpc.branch = [\n\
1 2 0 0 0 100 100 100 0 0 1 -360 360;\n\
];\n",
)
.unwrap();
let out = run(&[
"--diagnostics-format",
"json",
"verify",
case.to_str().unwrap(),
"--kind",
"bdoubleprime",
]);
assert_eq!(
out.status.code(),
Some(5),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let records = json_diagnostics(&out);
let code = records[0]["code"].as_str().unwrap();
assert_ne!(code, "BIND.CLI.UNCLASSIFIED", "{records:?}");
assert!(code.contains("ZERO_IMPEDANCE"), "{records:?}");
std::fs::remove_file(case).unwrap();
}
#[test]
fn sincal_balanced_cli_parses_converts_and_echoes_without_inference() {
let path = repo_file("tests/data/sincal/1-LV-rural1--0-sw.sinx");
let path = path.to_str().unwrap();
let refused = run(&["--diagnostics-format", "json", "summary", path]);
assert_failure(&refused);
assert!(
json_diagnostics(&refused)
.iter()
.any(|d| d["code"] == "REQUEST.SINCAL.PROFILE_REQUIRED")
);
let summary = run(&["summary", path, "--from", "sincal-balanced"]);
assert_success(&summary);
let summary: serde_json::Value = serde_json::from_slice(&summary.stdout).unwrap();
assert_eq!(summary["elements"]["buses"], 15);
assert_eq!(summary["elements"]["branches"], 14);
let converted = run(&[
"--diagnostics-format",
"json",
"convert",
path,
"--from",
"sincal-balanced",
"--to",
"matpower",
]);
assert_success(&converted);
assert!(String::from_utf8_lossy(&converted.stdout).contains("mpc.bus"));
assert!(
json_diagnostics(&converted)
.iter()
.any(|d| d["code"] == "EMIT.SINCAL.RETAINED_SOURCE_OMITTED")
);
let tmp = tempfile::tempdir().unwrap();
let copy = tmp.path().join("copy.sinx");
let output = run(&[
"convert",
path,
"--from",
"sincal-balanced",
"--to",
"sincal-balanced",
"-o",
copy.to_str().unwrap(),
]);
assert_success(&output);
assert_eq!(std::fs::read(copy).unwrap(), std::fs::read(path).unwrap());
let native = std::fs::read(path).unwrap();
let stdin = run_with_stdin(&["summary", "-", "--from", "sincal-balanced"], &native);
assert_success(&stdin);
}
#[test]
fn sincal_multiconductor_cli_preserves_family_and_binary_echo() {
let tmp = tempfile::tempdir().unwrap();
let case = tmp.path().join("case.db");
let connection = rusqlite::Connection::open(&case).unwrap();
connection
.execute_batch(include_str!(
"../../tests/data/sincal/synthetic-multiconductor.sql"
))
.unwrap();
drop(connection);
let path = case.to_str().unwrap();
let summary = run(&["summary", path, "--from", "sincal-multiconductor"]);
assert_success(&summary);
let converted = run(&[
"convert",
path,
"--from",
"sincal-multiconductor",
"--to",
"pmd-json",
]);
assert_success(&converted);
let json: serde_json::Value = serde_json::from_slice(&converted.stdout).unwrap();
assert_eq!(json["data_model"], "ENGINEERING");
let copy = tmp.path().join("copy.db");
let echo = run(&[
"convert",
path,
"--from",
"sincal-multiconductor",
"--to",
"sincal-multiconductor",
"-o",
copy.to_str().unwrap(),
]);
assert_success(&echo);
assert_eq!(std::fs::read(copy).unwrap(), std::fs::read(&case).unwrap());
assert_failure(&run(&[
"convert",
path,
"--from",
"sincal-multiconductor",
"--to",
"sincal-balanced",
]));
}
#[test]
fn sincal_cli_selection_reaches_the_reader_and_requires_its_profile() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("case.db");
let db = rusqlite::Connection::open(&path).unwrap();
db.execute_batch(include_str!(
"../../tests/data/sincal/synthetic-multiconductor.sql"
))
.unwrap();
drop(db);
for command in ["summary", "serialize", "convert"] {
let mut args = vec![
command,
path.to_str().unwrap(),
"--from",
"sincal-multiconductor",
"--sincal-variant",
"1",
"--sincal-snapshot-hours",
"6",
];
if command == "convert" {
args.extend(["--to", "pmd-json"]);
}
assert_success(&run(&args));
let position = args.iter().position(|arg| *arg == "1").unwrap();
args[position] = "999";
assert_failure(&run(&args));
}
for from in ["matpower", "dss"] {
let output = run(&[
"--diagnostics-format",
"json",
"summary",
path.to_str().unwrap(),
"--from",
from,
"--sincal-snapshot-hours",
"6",
]);
assert_failure(&output);
assert!(
json_diagnostics(&output)
.iter()
.any(|d| d["code"] == "REQUEST.CLI.OPTION_INVALID")
);
}
for hours in ["NaN", "inf", "-1"] {
assert_failure(&run(&[
"summary",
path.to_str().unwrap(),
"--from",
"sincal-multiconductor",
"--sincal-snapshot-hours",
hours,
]));
}
assert_failure(&run(&[
"summary",
path.to_str().unwrap(),
"--from",
"sincal-multiconductor",
"--sincal-acquired-tables",
"missing.json",
]));
}
#[test]
fn balanced_sincal_cli_selections_reach_the_balanced_reader() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../tests/data/sincal/1-LV-rural1--0-sw.sinx");
for command in ["summary", "serialize", "convert"] {
let mut args = vec![
command,
path.to_str().unwrap(),
"--from",
"sincal-balanced",
"--sincal-variant",
"1",
];
if command == "convert" {
args.extend(["--to", "matpower"]);
}
assert_success(&run(&args));
args[5] = "999";
assert_failure(&run(&args));
}
let output = run(&[
"--diagnostics-format",
"json",
"summary",
path.to_str().unwrap(),
"--from",
"sincal-balanced",
"--sincal-snapshot-hours",
"6",
]);
assert_failure(&output);
assert!(
json_diagnostics(&output)
.iter()
.any(|d| d["code"] == "PARSE.SINCAL.MALFORMED")
);
}
#[test]
fn merge_buses_applies_the_psse_rule_before_summary_convert_and_verify() {
let case = repo_file("tests/data/psse/merge_v35.raw");
let case = case.to_str().unwrap();
let out = run(&["summary", case, "--merge-buses", "psse"]);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(value["elements"]["buses"], 2);
let merge = &value["bus_merge"];
assert_eq!(merge["merged_buses"], 3);
assert_eq!(merge["removed_branches"]["zero_impedance"], 2);
assert_eq!(merge["removed_switches"], 1);
let warnings = value["warnings"].as_array().unwrap();
assert!(warnings.iter().any(|w| {
w.as_str()
.unwrap()
.starts_with("CANONICALIZE.MERGE.CLOSED_SWITCH")
}));
let out = run(&["summary", case]);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(value["elements"]["buses"], 5);
assert!(value.get("bus_merge").is_none());
let out = run(&[
"convert",
case,
"--merge-buses",
"psse=1e-4",
"--to",
"matpower",
"-o",
"-",
]);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
let bus_rows = stdout
.split("mpc.bus = [")
.nth(1)
.unwrap()
.split("];")
.next()
.unwrap()
.lines()
.filter(|line| !line.trim().is_empty())
.count();
assert_eq!(bus_rows, 2, "{stdout}");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("CANONICALIZE.MERGE.ZERO_IMPEDANCE"),
"{stderr}"
);
let out = run(&["verify", case, "--merge-buses", "exact"]);
assert_success(&out);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("n=4"),
"exact merges only the switch: {stdout}"
);
}
#[test]
fn merge_buses_refuses_an_unknown_rule_and_a_missing_threshold() {
let case = repo_file("tests/data/psse/merge_v35.raw");
let out = run(&[
"--diagnostics-format",
"json",
"summary",
case.to_str().unwrap(),
"--merge-buses",
"nearby",
]);
assert_eq!(out.status.code(), Some(2), "a request failure");
assert_eq!(
json_diagnostics(&out)[0]["code"],
"REQUEST.CLI.OPTION_INVALID"
);
let case = repo_file("tests/data/case9.m");
let out = run(&[
"--diagnostics-format",
"json",
"summary",
case.to_str().unwrap(),
"--merge-buses",
"psse",
]);
assert_eq!(out.status.code(), Some(5), "a data failure");
assert_eq!(
json_diagnostics(&out)[0]["code"],
"CANONICALIZE.MERGE.INVALID_RULE"
);
}
const THREE_ISLANDS: &str = "function mpc = islands
mpc.version = '2';
mpc.baseMVA = 100;
mpc.bus = [
\t1\t3\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
\t2\t1\t50\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
\t3\t1\t0\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
\t4\t1\t30\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
\t5\t1\t20\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
\t6\t1\t10\t0\t0\t0\t1\t1\t0\t230\t1\t1.1\t0.9;
];
mpc.gen = [
\t1\t50\t0\t100\t-100\t1\t100\t1\t200\t0;
\t3\t30\t0\t100\t-100\t1\t100\t1\t150\t0;
];
mpc.branch = [
\t1\t2\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
\t3\t4\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
\t5\t6\t0.01\t0.1\t0\t0\t0\t0\t0\t0\t1\t-360\t360;
];
mpc.dcline = [
\t1\t5\t1\t10\t9.5\t0\t0\t1\t1\t0\t20\t-10\t10\t-10\t10\t0\t0;
];
";
#[test]
fn summary_lists_the_islands() {
let out = run_with_stdin(
&["summary", "-", "--from", "matpower"],
THREE_ISLANDS.as_bytes(),
);
assert_success(&out);
let value: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let islands = &value["topology"]["islands"];
assert_eq!(islands["count"], 3);
assert_eq!(islands["unsupplied"], 1);
assert_eq!(islands["unsupplied_hvdc_fed"], 1);
assert_eq!(islands["without_reference"], 2);
assert_eq!(
islands["islands"][0]["reference_buses"],
serde_json::json!([1])
);
assert_eq!(islands["islands"][2]["generators"], 0);
assert_eq!(islands["islands"][2]["hvdc_ties"], 1);
assert_eq!(islands["islands"][1]["hvdc_ties"], 0);
}