use std::path::PathBuf;
use std::process::{Command, Output};
fn document() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/data/pgm/state_estimation/transmission-case/input.json")
}
fn estimate(extra: &[&str]) -> Output {
let path = document();
let mut args = vec!["estimate", path.to_str().unwrap()];
args.extend_from_slice(extra);
Command::new(env!("CARGO_BIN_EXE_gridoxide"))
.args(&args)
.output()
.expect("failed to run the gridoxide binary")
}
fn stdout_of(out: &Output) -> String {
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn it_estimates_symmetrically_and_names_the_domain() {
let text = stdout_of(&estimate(&[]));
assert!(text.contains("symmetric, one per node"), "{text}");
assert!(text.contains("Converged in"), "{text}");
assert!(text.contains("Estimated voltages:"), "{text}");
assert!(text.contains("node 1: |V| ="), "{text}");
assert!(!text.contains("phase"), "{text}");
assert!(text.contains("Observability: rank"), "{text}");
assert!(text.contains("Bad data: chi-squared"), "{text}");
assert!(text.contains("not rejected at 5%"), "{text}");
}
#[test]
fn the_asymmetric_flag_estimates_in_the_phase_domain() {
let text = stdout_of(&estimate(&["--asymmetric"]));
assert!(text.contains("phase domain, three per node"), "{text}");
assert!(text.contains("Converged in"), "{text}");
for phase in ["a", "b", "c"] {
assert!(
text.contains(&format!("node 1 phase {phase}: |V| =")),
"{text}"
);
}
assert!(text.contains("36 bus(es)"), "{text}");
assert!(text.contains("Observability: rank"), "{text}");
assert!(text.contains("Bad data: chi-squared"), "{text}");
assert!(text.contains("not rejected at 5%"), "{text}");
}
#[test]
fn the_two_domains_agree_about_a_balanced_network() {
let symmetric = stdout_of(&estimate(&[]));
let asymmetric = stdout_of(&estimate(&["--asymmetric"]));
let magnitude = |text: &str, prefix: &str| -> f64 {
let line = text
.lines()
.find(|l| l.trim_start().starts_with(prefix))
.unwrap_or_else(|| panic!("no line starting {prefix:?} in:\n{text}"));
let after = line.split("|V| = ").nth(1).unwrap();
after.split_whitespace().next().unwrap().parse().unwrap()
};
let angle = |text: &str, prefix: &str| -> f64 {
let line = text
.lines()
.find(|l| l.trim_start().starts_with(prefix))
.unwrap();
let after = line.split("angle = ").nth(1).unwrap();
after.split_whitespace().next().unwrap().parse().unwrap()
};
for node in [1u32, 2, 3] {
let sym_mag = magnitude(&symmetric, &format!("node {node}: "));
let sym_ang = angle(&symmetric, &format!("node {node}: "));
for (phase, rotation) in [("a", 0.0), ("b", -120.0), ("c", 120.0)] {
let prefix = format!("node {node} phase {phase}: ");
assert!(
(magnitude(&asymmetric, &prefix) - sym_mag).abs() < 1e-5,
"node {node} phase {phase} magnitude"
);
assert!(
(angle(&asymmetric, &prefix) - (sym_ang + rotation)).abs() < 1e-4,
"node {node} phase {phase} angle"
);
}
}
}
#[test]
fn a_document_without_sensors_is_refused() {
let raw = std::fs::read_to_string(document()).expect("the fixture is committed");
let mut doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let data = doc.get_mut("data").unwrap().as_object_mut().unwrap();
for key in data.keys().cloned().collect::<Vec<_>>() {
if key.ends_with("_sensor") {
data.insert(key, serde_json::json!([]));
}
}
let dir = std::env::temp_dir().join("gridoxide-estimate-no-sensors");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("input.json");
std::fs::write(&path, serde_json::to_string(&doc).unwrap()).unwrap();
for extra in [vec![], vec!["--asymmetric"]] {
let mut args = vec!["estimate", path.to_str().unwrap()];
args.extend_from_slice(&extra);
let out = Command::new(env!("CARGO_BIN_EXE_gridoxide"))
.args(&args)
.output()
.expect("runs");
assert!(!out.status.success(), "{extra:?} should have been refused");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("no usable sensors"), "{extra:?}: {err}");
}
std::fs::remove_dir_all(&dir).ok();
}