mod common;
use std::collections::HashMap;
use std::path::PathBuf;
use num_complex::Complex;
use gridoxide::branch_flow::Terminal;
use gridoxide::measurement::Target;
use gridoxide::network::{build_ybus_3ph, stamp_shunts_3ph, stamp_transformers_3ph};
use gridoxide::pgm::{node_id_to_idx, pgm_shunts_3ph, pgm_to_3ph_network, pgm_transformers_3ph};
use gridoxide::se::SeNetwork;
const S_BASE_VA: f64 = 1e6;
fn fixture(path: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path)
}
fn load_3ph(path: &str) -> (SeNetwork, usize) {
let dir = fixture(path);
let input = common::load_pgm_input(&dir.join("input.json"));
let id_to_idx = node_id_to_idx(&input);
let transformers = pgm_transformers_3ph(&input, &id_to_idx, S_BASE_VA);
let shunts = pgm_shunts_3ph(&input, &id_to_idx, S_BASE_VA);
let input2 = common::load_pgm_input(&dir.join("input.json"));
let (buses, lines, _) = pgm_to_3ph_network(input2, S_BASE_VA, 50.0);
let n_nodes = buses.len() / 3;
let mut ybus = build_ybus_3ph(n_nodes, &lines);
stamp_transformers_3ph(&mut ybus, &transformers);
stamp_shunts_3ph(&mut ybus, &shunts);
let n_doc_lines = lines.len() - input.data.source.iter().filter(|s| s.status != 0).count();
let source_branch_idx: HashMap<u64, usize> = input
.data
.source
.iter()
.filter(|s| s.status != 0)
.enumerate()
.map(|(i, s)| (s.id, n_doc_lines + i))
.collect();
let se_net = SeNetwork::from_3ph(
ybus.finish(),
&lines,
&transformers,
&shunts,
&source_branch_idx,
&vec![false; n_nodes],
);
(se_net, n_nodes)
}
#[test]
fn three_phase_terminal_functionals_agree_with_the_ybus() {
for path in [
"tests/data/pgm/powerflow/asymmetric/line",
"tests/data/pgm/powerflow/asymmetric/transformer",
"tests/data/pgm/powerflow/asymmetric/transmission-case",
"tests/data/pgm/state_estimation/dummy-test-sym",
] {
let (net, n_nodes) = load_3ph(path);
let n = net.ybus.n();
let v: Vec<Complex<f64>> = (0..n)
.map(|i| {
let phase = (i % 3) as f64;
Complex::from_polar(
1.0 + 0.03 * ((i / 3) as f64 % 5.0) - 0.02 * phase,
-phase * std::f64::consts::TAU / 3.0 + 0.05 * ((i / 3) as f64 % 7.0),
)
})
.collect();
let mut incident: Vec<Vec<(usize, Terminal)>> = vec![Vec::new(); n];
for b in 0..net.terminals.len() {
for t in [Terminal::From, Terminal::To] {
incident[net.terminals[b][t as usize].at].push((b, t));
}
}
let mut compared = 0;
for bus in 0..n {
if net.shunt_y[bus].norm() > 0.0 || incident[bus].is_empty() {
continue;
}
let from_terminals: Complex<f64> = incident[bus]
.iter()
.map(|&(b, t)| {
net.functional(Target::BranchTerminal {
branch: b,
terminal: t,
})
.expect("terminal resolves")
.current(&v)
})
.sum();
let from_ybus: Complex<f64> = net.ybus.row(bus).iter().map(|&(k, y)| y * v[k]).sum();
let scale = from_ybus.norm().max(1.0);
assert!(
(from_terminals - from_ybus).norm() <= 1e-12 * scale,
"{path} bus {bus} (node {}, phase {}): terminals give {from_terminals}, \
the Y-bus gives {from_ybus}",
bus / 3,
bus % 3
);
compared += 1;
}
assert!(
compared >= n_nodes,
"{path}: expected to compare at least one bus per node, got {compared}"
);
}
}
#[test]
fn a_three_phase_terminal_couples_its_phases() {
let (net, _) = load_3ph("tests/data/pgm/powerflow/asymmetric/transmission-case");
let mut coupled = 0;
for b in 0..net.terminals.len() {
let f = &net.terminals[b][Terminal::From as usize];
assert_eq!(
f.coefficients.len(),
6,
"branch {b}: three phases at each of two ends"
);
if f.coefficients
.iter()
.any(|&(k, c)| k % 3 != f.at % 3 && c.norm() > 1e-12)
{
coupled += 1;
}
}
assert!(
coupled > 0,
"no branch couples its phases — the conversion has produced three \
independent single-phase networks rather than a three-phase one"
);
}
#[test]
fn only_the_global_rotation_is_a_symmetry_of_the_phase_domain() {
use gridoxide::measurement::{Measurement, MeasurementKind};
use gridoxide::se::measurement_functions;
use gridoxide::types::{Bus, BusType};
let (net, _) = load_3ph("tests/data/pgm/powerflow/asymmetric/transmission-case");
let n = net.ybus.n();
let state = |rotate: &dyn Fn(usize) -> f64| -> Vec<Bus> {
(0..n)
.map(|i| Bus {
idx: i,
bus_type: BusType::PQ,
voltage_mag: 1.0 + 0.02 * ((i / 3) as f64 % 4.0),
voltage_ang: -((i % 3) as f64) * std::f64::consts::TAU / 3.0
+ 0.03 * ((i / 3) as f64 % 5.0)
+ rotate(i),
p_spec: 0.0,
q_spec: 0.0,
q_min: f64::NEG_INFINITY,
q_max: f64::INFINITY,
u_rated: 10000.0,
zip_terms: Vec::new(),
})
.collect()
};
let mut measurements: Vec<Measurement> = (0..n)
.map(|b| Measurement {
kind: MeasurementKind::VoltageMagnitude,
target: Target::Bus(b),
value: 0.0,
sigma: 0.01,
})
.collect();
for b in 0..net.terminals.len() {
for t in [Terminal::From, Terminal::To] {
for kind in [MeasurementKind::ActivePower, MeasurementKind::ReactivePower] {
measurements.push(Measurement {
kind,
target: Target::BranchTerminal {
branch: b,
terminal: t,
},
value: 0.0,
sigma: 0.01,
});
}
}
}
const ALPHA: f64 = 0.17;
let base = measurement_functions(&measurements, &state(&|_| 0.0), &net);
let global = measurement_functions(&measurements, &state(&|_| ALPHA), &net);
let phase_a = measurement_functions(
&measurements,
&state(&|i| if i % 3 == 0 { ALPHA } else { 0.0 }),
&net,
);
let worst = |other: &[f64]| {
base.iter()
.zip(other)
.map(|(a, b)| (a - b).abs() / a.abs().max(1.0))
.fold(0.0f64, f64::max)
};
assert!(
worst(&global) < 1e-8,
"rotating every phase together must leave the measurements untouched — it is the \
symmetry `StateLayout` pins a reference to remove; worst change {}",
worst(&global)
);
assert!(
worst(&phase_a) > 1e-3,
"rotating phase a alone must change the measurements. If it does not, the three \
phases are independent circuits with three separate rotational symmetries, and \
`StateLayout` removes only one of them — leaving two undetermined directions that \
no structural check would catch. Worst change {}",
worst(&phase_a)
);
}
#[test]
fn estimates_transmission_case_in_the_phase_domain() {
use gridoxide::pgm::measurement::measurements_from_pgm_3ph;
use gridoxide::pgm::pgm_3ph_maps;
use gridoxide::se::nr::{SeOptions, SeStatus, estimate, linear_start};
let dir = fixture("tests/data/pgm/state_estimation/transmission-case");
let input = common::load_pgm_input(&dir.join("input.json"));
let expected = common::load_json(&dir.join("asym_output.json"));
let maps = pgm_3ph_maps(&input);
let id_to_idx = node_id_to_idx(&input);
let transformers = pgm_transformers_3ph(&input, &id_to_idx, S_BASE_VA);
let shunts = pgm_shunts_3ph(&input, &id_to_idx, S_BASE_VA);
let (buses, lines, _) = pgm_to_3ph_network(
common::load_pgm_input(&dir.join("input.json")),
S_BASE_VA,
50.0,
);
let mut ybus = build_ybus_3ph(buses.len() / 3, &lines);
stamp_transformers_3ph(&mut ybus, &transformers);
stamp_shunts_3ph(&mut ybus, &shunts);
let se_net = SeNetwork::from_3ph(
ybus.finish(),
&lines,
&transformers,
&shunts,
&maps.source_branch_idx,
&maps.zero_injection,
);
let u_rated = |bus: usize| buses[bus].u_rated;
let measurements =
measurements_from_pgm_3ph(&input, &maps, S_BASE_VA, &u_rated).expect("measurements");
assert!(
measurements.len() > 100,
"11 voltage and 24 power sensors over three phases should give a large set, got {}",
measurements.len()
);
let mut state = buses.clone();
linear_start(&mut state, &se_net, &measurements);
let report = estimate(
&measurements,
&mut state,
&se_net,
&SeOptions {
max_iter: 40,
..SeOptions::default()
},
);
assert_eq!(report.status, SeStatus::Converged, "{report:?}");
let mut offsets = Vec::new();
let mut checked = 0;
for node in expected["data"]["node"].as_array().expect("node output") {
let id = node["id"].as_u64().expect("node id");
let k = maps.node_idx[&id];
let u_pu = node["u_pu"].as_array().expect("per-phase u_pu");
let u_angle = node["u_angle"].as_array().expect("per-phase u_angle");
for p in 0..3 {
let want = u_pu[p].as_f64().expect("u_pu");
let got = state[3 * k + p].voltage_mag;
assert!(
(got - want).abs() < 1e-6,
"node {id} phase {p}: |V| = {got}, PGM says {want}"
);
offsets.push((
id,
p,
state[3 * k + p].voltage_ang - u_angle[p].as_f64().expect("angle"),
));
checked += 1;
}
}
assert_eq!(checked, 33, "11 nodes times three phases");
let (ref_id, ref_p, reference) = offsets[0];
for &(id, p, offset) in &offsets {
assert!(
(offset - reference).abs() < 1e-6,
"node {id} phase {p}: angle offset {offset} differs from node {ref_id} phase \
{ref_p}'s {reference} — a uniform offset is a reference convention, a varying \
one is a wrong estimate"
);
}
}
#[test]
fn estimates_from_an_asymmetric_voltage_phasor_per_phase() {
use gridoxide::pgm::measurement::measurements_from_pgm_3ph;
use gridoxide::pgm::pgm_3ph_maps;
use gridoxide::se::nr::{SeOptions, SeStatus, estimate, linear_start};
let name = "single-node-source-asym-voltage-sensor";
let dir = fixture("tests/data/pgm/state_estimation").join(name);
let input = common::load_pgm_input(&dir.join("input.json"));
let expected = common::load_json(&dir.join("asym_output.json"));
let maps = pgm_3ph_maps(&input);
let id_to_idx = node_id_to_idx(&input);
let transformers = pgm_transformers_3ph(&input, &id_to_idx, S_BASE_VA);
let shunts = pgm_shunts_3ph(&input, &id_to_idx, S_BASE_VA);
let (buses, lines, _) = pgm_to_3ph_network(
common::load_pgm_input(&dir.join("input.json")),
S_BASE_VA,
50.0,
);
let mut ybus = build_ybus_3ph(buses.len() / 3, &lines);
stamp_transformers_3ph(&mut ybus, &transformers);
stamp_shunts_3ph(&mut ybus, &shunts);
let se_net = SeNetwork::from_3ph(
ybus.finish(),
&lines,
&transformers,
&shunts,
&maps.source_branch_idx,
&maps.zero_injection,
);
let u_rated = |bus: usize| buses[bus].u_rated;
let measurements =
measurements_from_pgm_3ph(&input, &maps, S_BASE_VA, &u_rated).expect("measurements");
assert_eq!(
measurements.len(),
6,
"three phases, each a magnitude and an angle"
);
let mut state = buses.clone();
linear_start(&mut state, &se_net, &measurements);
let report = estimate(
&measurements,
&mut state,
&se_net,
&SeOptions {
max_iter: 40,
..SeOptions::default()
},
);
assert_eq!(report.status, SeStatus::Converged, "{name}: {report:?}");
for node in expected["data"]["node"].as_array().expect("node output") {
let k = maps.node_idx[&node["id"].as_u64().expect("node id")];
let u = node["u"].as_array().expect("per-phase u");
let u_angle = node["u_angle"].as_array().expect("per-phase u_angle");
let base = buses[3 * k].u_rated / 3.0f64.sqrt();
for p in 0..3 {
let want = u[p].as_f64().expect("u") / base;
assert!(
(state[3 * k + p].voltage_mag - want).abs() < 1e-6,
"{name} phase {p}: |V| = {}, PGM says {want}",
state[3 * k + p].voltage_mag
);
let want_ang = u_angle[p].as_f64().expect("angle");
assert!(
(state[3 * k + p].voltage_ang - want_ang).abs() < 1e-6,
"{name} phase {p}: angle = {}, PGM says {want_ang}",
state[3 * k + p].voltage_ang
);
}
}
}
#[test]
fn magnitudes_alone_leave_the_phase_relationship_undetermined() {
use gridoxide::pgm::measurement::measurements_from_pgm_3ph;
use gridoxide::pgm::pgm_3ph_maps;
use gridoxide::se::nr::{SeOptions, SeStatus, estimate, linear_start};
let dir = fixture("tests/data/pgm/state_estimation")
.join("single-node-source-asym-voltage-sensor-no-angle");
let input = common::load_pgm_input(&dir.join("input.json"));
let maps = pgm_3ph_maps(&input);
let id_to_idx = node_id_to_idx(&input);
let transformers = pgm_transformers_3ph(&input, &id_to_idx, S_BASE_VA);
let shunts = pgm_shunts_3ph(&input, &id_to_idx, S_BASE_VA);
let (buses, lines, _) = pgm_to_3ph_network(
common::load_pgm_input(&dir.join("input.json")),
S_BASE_VA,
50.0,
);
let mut ybus = build_ybus_3ph(buses.len() / 3, &lines);
stamp_transformers_3ph(&mut ybus, &transformers);
stamp_shunts_3ph(&mut ybus, &shunts);
let se_net = SeNetwork::from_3ph(
ybus.finish(),
&lines,
&transformers,
&shunts,
&maps.source_branch_idx,
&maps.zero_injection,
);
let u_rated = |bus: usize| buses[bus].u_rated;
let measurements =
measurements_from_pgm_3ph(&input, &maps, S_BASE_VA, &u_rated).expect("measurements");
assert_eq!(measurements.len(), 3, "three magnitudes, no angle anywhere");
let mut state = buses.clone();
linear_start(&mut state, &se_net, &measurements);
let report = estimate(
&measurements,
&mut state,
&se_net,
&SeOptions {
max_iter: 40,
..SeOptions::default()
},
);
assert_eq!(
report.status,
SeStatus::Singular,
"expected the phase relationship to be undetermined here. If this now converges, \
something has supplied the two missing directions — check it is the balanced-source \
constraint described above and not an accident"
);
assert!(
report.unconstrained.is_empty(),
"the deficiency is rank, not an untouched column: every unknown is reached by some \
row, and two combinations of them are still undetermined"
);
}
fn case_3ph(path: &str) -> gridoxide::se::case::SeCase {
let input = common::load_pgm_input(&fixture(path).join("input.json"));
gridoxide::pgm::se::se_case_from_pgm_3ph(&input, S_BASE_VA, 50.0)
.expect("this fixture uses no unsupported component")
}
fn case_1ph(path: &str) -> gridoxide::se::case::SeCase {
let input = common::load_pgm_input(&fixture(path).join("input.json"));
gridoxide::pgm::se::se_case_from_pgm(&input, S_BASE_VA, 50.0).expect("builds")
}
#[test]
fn the_entry_point_agrees_with_the_assembly() {
let dir = fixture("tests/data/pgm/state_estimation/transmission-case");
let input = common::load_pgm_input(&dir.join("input.json"));
let maps = gridoxide::pgm::pgm_3ph_maps(&input);
let id_to_idx = node_id_to_idx(&input);
let transformers = pgm_transformers_3ph(&input, &id_to_idx, S_BASE_VA);
let shunts = pgm_shunts_3ph(&input, &id_to_idx, S_BASE_VA);
let (buses, lines, _) = pgm_to_3ph_network(input.clone(), S_BASE_VA, 50.0);
let mut ybus = build_ybus_3ph(buses.len() / 3, &lines);
stamp_transformers_3ph(&mut ybus, &transformers);
stamp_shunts_3ph(&mut ybus, &shunts);
let by_hand = SeNetwork::from_3ph(
ybus.finish(),
&lines,
&transformers,
&shunts,
&maps.source_branch_idx,
&maps.zero_injection,
);
let case = case_3ph("tests/data/pgm/state_estimation/transmission-case");
assert_eq!(case.phases, 3);
assert_eq!(case.buses.len(), buses.len());
assert_eq!(case.network.ybus.n(), by_hand.ybus.n());
assert_eq!(case.network.terminals.len(), by_hand.terminals.len());
assert_eq!(case.network.zero_injection, by_hand.zero_injection);
assert_eq!(case.network.energized, by_hand.energized);
assert_eq!(case.network.source_branches, by_hand.source_branches);
for (a, b) in case.network.shunt_y.iter().zip(&by_hand.shunt_y) {
assert!((a - b).norm() < 1e-15);
}
let u_rated = |bus: usize| buses[bus].u_rated;
let expected =
gridoxide::pgm::measurement::measurements_from_pgm_3ph(&input, &maps, S_BASE_VA, &u_rated)
.unwrap();
assert_eq!(case.measurements.len(), expected.len());
let (id, idx) = case.nodes()[0];
for phase in 0..3 {
assert_eq!(case.bus_of(id, phase), Some(3 * idx + phase));
}
}
#[test]
fn observability_carries_over_to_the_phase_domain() {
use gridoxide::se::constraints::Constraints;
use gridoxide::se::jacobian::StateLayout;
use gridoxide::se::observability;
let case = case_3ph("tests/data/pgm/state_estimation/transmission-case");
let layout = StateLayout::new(&case.buses, &case.measurements, &case.network);
let constraints = Constraints::new(&case.network);
let report = observability::analyze(
&case.measurements,
&case.buses,
&case.network,
&layout,
&constraints,
);
assert!(report.n_unknowns > 0);
assert!(report.rank <= report.n_unknowns);
let n_physical = 3 * case.nodes().len();
for unknown in report
.unobservable
.iter()
.chain(&report.structurally_unmeasured)
{
assert!(
unknown.bus >= n_physical,
"physical bus {} ({:?}) came back undetermined",
unknown.bus,
unknown.quantity
);
}
let sym = case_1ph("tests/data/pgm/state_estimation/transmission-case");
let sym_layout = StateLayout::new(&sym.buses, &sym.measurements, &sym.network);
let sym_report = observability::analyze(
&sym.measurements,
&sym.buses,
&sym.network,
&sym_layout,
&Constraints::new(&sym.network),
);
for unknown in sym_report
.unobservable
.iter()
.chain(&sym_report.structurally_unmeasured)
{
assert!(unknown.bus >= sym.nodes().len());
}
}
#[test]
fn bad_data_detection_finds_the_corrupted_measurement() {
use gridoxide::se::bad_data::{self, Candidates};
use gridoxide::se::constraints::Constraints;
use gridoxide::se::jacobian::StateLayout;
use gridoxide::se::nr::{SeOptions, SeStatus, estimate, linear_start};
let mut case = case_3ph("tests/data/pgm/state_estimation/transmission-case");
let victim = case
.measurements
.iter()
.position(|m| {
matches!(
m.kind,
gridoxide::measurement::MeasurementKind::VoltageMagnitude
)
})
.expect("this fixture has voltage sensors");
let sigma = case.measurements[victim].sigma;
case.measurements[victim].value += 15.0 * sigma;
let mut buses = case.buses.clone();
linear_start(&mut buses, &case.network, &case.measurements);
let report = estimate(
&case.measurements,
&mut buses,
&case.network,
&SeOptions {
max_iter: 40,
..SeOptions::default()
},
);
assert_eq!(
report.status,
SeStatus::Converged,
"objective {:.3e}",
report.objective
);
let layout = StateLayout::new(&buses, &case.measurements, &case.network);
let bad = bad_data::analyze(
&case.measurements,
&report.residuals,
&buses,
&case.network,
&layout,
&Constraints::new(&case.network),
Candidates::default(),
);
assert!(
bad.rejects_at(0.05),
"a fifteen-sigma error should be rejected, chi-squared {:.3e} on {} dof",
bad.chi_squared,
bad.degrees_of_freedom
);
assert_eq!(
bad.suspects.first().map(|s| s.measurement),
Some(victim),
"the largest normalized residual should be the measurement that was corrupted"
);
let worst = bad.suspects[0].normalized_residual;
let next = bad.suspects.get(1).map_or(0.0, |s| s.normalized_residual);
assert!(
worst > 5.0 * next.max(1e-12),
"{worst} against a runner-up of {next}"
);
}
#[test]
fn a_large_error_diverges_and_says_so() {
use gridoxide::se::nr::{SeOptions, SeStatus, estimate, linear_start};
let mut case = case_3ph("tests/data/pgm/state_estimation/transmission-case");
let victim = case
.measurements
.iter()
.position(|m| {
matches!(
m.kind,
gridoxide::measurement::MeasurementKind::VoltageMagnitude
)
})
.unwrap();
let sigma = case.measurements[victim].sigma;
case.measurements[victim].value += 20.0 * sigma;
let mut buses = case.buses.clone();
linear_start(&mut buses, &case.network, &case.measurements);
let report = estimate(
&case.measurements,
&mut buses,
&case.network,
&SeOptions {
max_iter: 40,
..SeOptions::default()
},
);
assert_eq!(
report.status,
SeStatus::MaxIterations,
"a diverged estimate must not report success, objective {:.3e}",
report.objective
);
assert!(
report.objective > 1e6,
"expected a diverged objective, got {:.3e}",
report.objective
);
}
#[test]
fn a_clean_phase_domain_estimate_is_not_rejected() {
use gridoxide::se::bad_data::{self, Candidates};
use gridoxide::se::constraints::Constraints;
use gridoxide::se::jacobian::StateLayout;
use gridoxide::se::nr::{SeOptions, estimate, linear_start};
let case = case_3ph("tests/data/pgm/state_estimation/transmission-case");
let mut buses = case.buses.clone();
linear_start(&mut buses, &case.network, &case.measurements);
let report = estimate(
&case.measurements,
&mut buses,
&case.network,
&SeOptions {
max_iter: 40,
..SeOptions::default()
},
);
let layout = StateLayout::new(&buses, &case.measurements, &case.network);
let bad = bad_data::analyze(
&case.measurements,
&report.residuals,
&buses,
&case.network,
&layout,
&Constraints::new(&case.network),
Candidates::default(),
);
assert!(
!bad.rejects_at(0.05),
"nothing is wrong with this data, chi-squared {:.3e} on {} dof, p = {:.3e}",
bad.chi_squared,
bad.degrees_of_freedom,
bad.p_value
);
assert!(bad.degrees_of_freedom > 0);
}
#[test]
fn the_batch_solver_carries_over_to_the_phase_domain() {
use gridoxide::se::batch::{MeasurementOverride, SeBatchSolver, SeScenario};
use gridoxide::se::nr::{SeOptions, SeStatus, estimate, linear_start};
let case = case_3ph("tests/data/pgm/state_estimation/transmission-case");
let voltages: Vec<usize> = case
.measurements
.iter()
.enumerate()
.filter(|(_, m)| {
matches!(
m.kind,
gridoxide::measurement::MeasurementKind::VoltageMagnitude
)
})
.map(|(i, _)| i)
.take(5)
.collect();
assert_eq!(
voltages.len(),
5,
"this fixture has voltage sensors on several phases"
);
let scenarios: Vec<SeScenario> = voltages
.iter()
.map(|&i| {
let m = &case.measurements[i];
SeScenario::new(vec![
MeasurementOverride::new(i).value(m.value + 2.0 * m.sigma),
])
})
.collect();
let options = SeOptions {
max_iter: 40,
..SeOptions::default()
};
let solver = SeBatchSolver::new(options.clone());
let batched = solver
.estimate(&case.buses, &case.network, &case.measurements, &scenarios)
.expect("the batch runs");
assert_eq!(batched.len(), scenarios.len());
for (k, scenario) in scenarios.iter().enumerate() {
let mut measurements = case.measurements.clone();
for ov in &scenario.overrides {
if let Some(value) = ov.value {
measurements[ov.measurement].value = value;
}
}
let mut buses = case.buses.clone();
linear_start(&mut buses, &case.network, &measurements);
let alone = estimate(&measurements, &mut buses, &case.network, &options);
assert_eq!(alone.status, SeStatus::Converged);
assert_eq!(batched[k].report.status, SeStatus::Converged);
for (a, b) in batched[k].buses.iter().zip(&buses) {
assert_eq!(
a.voltage_mag.to_bits(),
b.voltage_mag.to_bits(),
"scenario {k}: batching must not change a single bit of the answer"
);
assert_eq!(a.voltage_ang.to_bits(), b.voltage_ang.to_bits());
}
}
}
#[test]
fn a_voltage_regulator_does_not_stop_an_estimate() {
let dir = fixture("tests/data/pgm/state_estimation/transmission-case");
let raw = std::fs::read_to_string(dir.join("input.json")).unwrap();
let mut doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let data = doc.get_mut("data").unwrap().as_object_mut().unwrap();
let regulated = data
.get("sym_gen")
.and_then(|g| g.as_array())
.and_then(|g| g.first())
.and_then(|g| g.get("id"))
.cloned()
.unwrap_or_else(|| data["node"].as_array().unwrap()[0]["id"].clone());
data.insert(
"voltage_regulator".to_string(),
serde_json::json!([{
"id": 999_999,
"regulated_object": regulated,
"status": 1,
"u_ref": 1.0
}]),
);
let input: gridoxide::pgm::PgmInput =
serde_json::from_value(doc).expect("the document still parses");
let with =
gridoxide::pgm::se::se_case_from_pgm_3ph(&input, S_BASE_VA, 50.0).expect("estimable");
let without = case_3ph("tests/data/pgm/state_estimation/transmission-case");
assert_eq!(with.phases, without.phases);
assert_eq!(with.buses.len(), without.buses.len());
assert_eq!(with.measurements.len(), without.measurements.len());
assert_eq!(with.network.zero_injection, without.network.zero_injection);
}
#[test]
fn a_link_is_phase_transparent() {
use gridoxide::pgm::{LINK_Y, pgm_3ph_maps};
let dir = fixture("tests/data/pgm/state_estimation/dummy-test-sym");
let input = common::load_pgm_input(&dir.join("input.json"));
let maps = pgm_3ph_maps(&input);
let link = input.data.link.first().expect("this fixture has a link");
let branch = *maps
.branch_idx
.get(&link.id)
.expect("a link takes a branch index");
assert_eq!(link.from_status, 1);
assert_eq!(link.to_status, 1);
let case = case_3ph("tests/data/pgm/state_estimation/dummy-test-sym");
let from = maps.node_idx[&link.from_node];
let to = maps.node_idx[&link.to_node];
for phase in 0..3 {
let terminal = &case.network.terminals[3 * branch + phase][0];
assert_eq!(terminal.at, 3 * from + phase);
let floor = 1e-9 * LINK_Y.norm();
let live: Vec<_> = terminal
.coefficients
.iter()
.filter(|(_, y)| y.norm() > floor)
.collect();
assert_eq!(live.len(), 2, "phase {phase}: {:?}", terminal.coefficients);
let self_y = live
.iter()
.find(|(bus, _)| *bus == 3 * from + phase)
.unwrap()
.1;
let mutual_y = live
.iter()
.find(|(bus, _)| *bus == 3 * to + phase)
.unwrap()
.1;
assert!(
(self_y - LINK_Y).norm() < 1e-6 * LINK_Y.norm(),
"phase {phase}: {self_y}"
);
assert!(
(mutual_y + LINK_Y).norm() < 1e-6 * LINK_Y.norm(),
"phase {phase}: {mutual_y}"
);
}
}
#[test]
fn a_current_sensor_on_a_link_is_refused() {
use gridoxide::pgm::measurement::{MeasurementError, measurements_from_pgm_3ph};
use gridoxide::pgm::pgm_3ph_maps;
let dir = fixture("tests/data/pgm/state_estimation/dummy-test-sym");
let raw = std::fs::read_to_string(dir.join("input.json")).unwrap();
let mut doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let data = doc.get_mut("data").unwrap().as_object_mut().unwrap();
let link_id = data["link"].as_array().unwrap()[0]["id"].clone();
data.insert(
"sym_current_sensor".to_string(),
serde_json::json!([{
"id": 999_998,
"measured_object": link_id,
"measured_terminal_type": 0,
"angle_measurement_type": 0,
"i_measured": 1.0,
"i_angle_measured": 0.0,
"i_sigma": 0.01,
"i_angle_sigma": 0.01
}]),
);
let input: gridoxide::pgm::PgmInput = serde_json::from_value(doc).unwrap();
let maps = pgm_3ph_maps(&input);
let (buses, _, _) = pgm_to_3ph_network(input.clone(), S_BASE_VA, 50.0);
let u_rated = |bus: usize| buses[bus].u_rated;
match measurements_from_pgm_3ph(&input, &maps, S_BASE_VA, &u_rated) {
Err(MeasurementError::CurrentSensorOnLink { sensor, .. }) => assert_eq!(sensor, 999_998),
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn a_three_winding_transformer_estimates_in_the_phase_domain() {
use gridoxide::se::nr::{SeOptions, SeStatus, estimate, linear_start};
let name = "tests/data/pgm/state_estimation/three_winding_transformer";
let case = case_3ph(name);
assert_eq!(case.buses.len(), 15);
assert_eq!(case.nodes().len(), 3);
assert!(case.measurements.len() >= 12, "{}", case.measurements.len());
let mut buses = case.buses.clone();
linear_start(&mut buses, &case.network, &case.measurements);
let report = estimate(
&case.measurements,
&mut buses,
&case.network,
&SeOptions {
max_iter: 40,
..SeOptions::default()
},
);
assert_eq!(
report.status,
SeStatus::Converged,
"objective {:.3e}",
report.objective
);
let expected = common::load_json(&fixture(name).join("sym_output.json"));
for node in expected["data"]["node"].as_array().unwrap() {
let id = node["id"].as_u64().unwrap();
let u_pu = node["u_pu"].as_f64().unwrap();
for phase in 0..3 {
let bus = case.bus_of(id, phase).expect("a document node");
assert!(
(buses[bus].voltage_mag - u_pu).abs() < 1e-5,
"node {id} phase {phase}: {} against power-grid-model's {u_pu}",
buses[bus].voltage_mag
);
}
}
let n_physical = 3 * case.nodes().len();
for bus in n_physical..n_physical + 3 {
assert!(
case.network.zero_injection[bus],
"the star node injects nothing"
);
}
}