use approx::assert_relative_eq;
use crate::indexed::IndexedNetwork;
use crate::matrix::{
BuildOptions, DcConvention, MatrixStats, Scheme, build_bdoubleprime, build_bprime,
build_incidence, build_lacpf, build_weighted_laplacian, build_ybus, sddm_check,
triplet::CooBuilder,
};
use crate::network::{Branch, BranchCharging, Bus, BusId, BusType, Network, Shunt};
use crate::parse_psse;
use crate::pipeline::{MatrixKind, matrix_stats_for_kind};
fn bus(id: usize, kind: BusType) -> Bus {
Bus::new(BusId(id), kind, 345.0)
}
fn br(from: usize, to: usize, r: f64, x: f64, b: f64) -> Branch {
let mut branch = Branch::new(BusId(from), BusId(to), r, x);
branch.b = b;
branch
}
fn three_bus() -> Network {
Network::in_memory(
"tiny",
100.0,
vec![
bus(1, BusType::Ref),
bus(2, BusType::Pq),
bus(3, BusType::Pq),
],
vec![
br(1, 2, 0.0, 0.1, 0.0),
br(1, 3, 0.0, 0.2, 0.0),
br(2, 3, 0.0, 0.25, 0.0),
],
)
}
fn zero_impedance_bus_pair() -> Network {
Network::in_memory(
"zero",
100.0,
vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
vec![br(1, 2, 0.0, 0.0, 0.0)],
)
}
#[test]
fn three_winding_transformer_enters_the_matrices_and_connects_its_windings() {
let raw = r"0, 100.00, 33, 0, 0, 60.00 / x
CASE
COMMENT
1,'B1 ', 230.0,3,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
2,'B2 ', 138.0,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
3,'B3 ', 13.8,1,1,1,1,1.00000,0.0,1.1,0.9,1.1,0.9
0 / END OF BUS DATA, BEGIN LOAD DATA
0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA
0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA
0 / END OF GENERATOR DATA, BEGIN BRANCH DATA
0 / END OF BRANCH DATA, BEGIN TRANSFORMER DATA
1, 2, 3, '1', 1, 1, 1, 0.0, 0.0, 2, 'T3W ', 1, 1, 1, 0, 1, 0, 1, 0, 1, ' '
0.01, 0.10, 100.0, 0.02, 0.20, 100.0, 0.03, 0.30, 100.0, 0.98, -1.5
1.0, 230.0, 0.0, 100.0, 90.0, 80.0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
1.025, 138.0, 0.0, 110.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
0.95, 13.8, 30.0, 50.0, 0, 0, 0, 0, 1.1, 0.9, 1.1, 0.9, 33, 0, 0, 0, 0
0 / END OF TRANSFORMER DATA, BEGIN AREA DATA
Q
";
let net = parse_psse(raw).unwrap();
assert!(net.branches.is_empty(), "a 3W is not folded into branches");
assert_eq!(net.transformers_3w.len(), 1);
let view = IndexedNetwork::new(&net);
assert_eq!(view.n(), 4, "three buses plus the synthetic star point");
assert_eq!(view.n_connected_components(), 1);
view.check_reference_coverage().unwrap();
let b = build_bprime(&view, &BuildOptions::default()).unwrap();
assert_eq!(b.rows(), 4);
assert_eq!(b.cols(), 4);
assert_eq!(net.buses.len(), 3);
assert!(net.branches.is_empty());
assert_eq!(net.transformers_3w.len(), 1);
}
#[test]
fn bprime_three_bus_has_correct_structure() {
let net = three_bus();
let view = IndexedNetwork::new(&net);
let b = build_bprime(&view, &BuildOptions::default()).unwrap();
assert_eq!(b.rows(), 3);
assert_eq!(b.cols(), 3);
let dense = b.to_dense();
assert_relative_eq!(dense[[0, 0]], 15.0, max_relative = 1e-12);
assert_relative_eq!(dense[[1, 1]], 14.0, max_relative = 1e-12);
assert_relative_eq!(dense[[2, 2]], 9.0, max_relative = 1e-12);
assert_relative_eq!(dense[[0, 1]], -10.0, max_relative = 1e-12);
assert_relative_eq!(dense[[1, 0]], -10.0, max_relative = 1e-12);
assert_relative_eq!(dense[[0, 2]], -5.0, max_relative = 1e-12);
assert_relative_eq!(dense[[1, 2]], -4.0, max_relative = 1e-12);
}
#[test]
fn bprime_is_symmetric_and_laplacian() {
let net = three_bus();
let view = IndexedNetwork::new(&net);
let b = build_bprime(&view, &BuildOptions::default()).unwrap();
let stats = MatrixStats::from_csr(&b);
assert!(stats.m_matrix_sign);
assert_relative_eq!(stats.min_dd_margin, 0.0, epsilon = 1e-12);
assert!(stats.min_diag > 0.0);
}
#[test]
fn bprime_cancels_tap_magnitude_and_keeps_phase_shift() {
let mut shifted = br(1, 2, 0.0, 0.2, 0.0);
shifted.tap = 1.25;
shifted.shift = 60.0;
let net = Network::in_memory(
"phase-shifter",
100.0,
vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
vec![shifted],
);
let view = IndexedNetwork::new(&net);
let b = build_bprime(
&view,
&BuildOptions {
scheme: Scheme::Xb,
..Default::default()
},
)
.unwrap()
.to_dense();
assert_relative_eq!(b[[0, 0]], 5.0, max_relative = 1e-12);
assert_relative_eq!(b[[1, 1]], 5.0, max_relative = 1e-12);
assert_relative_eq!(b[[0, 1]], -2.5, max_relative = 1e-12);
assert_relative_eq!(b[[1, 0]], -2.5, max_relative = 1e-12);
let ybus = build_ybus(&view, &BuildOptions::default())
.unwrap()
.b
.to_dense();
let minus_im_ybus_offdiag = -ybus[[0, 1]];
assert!(
(minus_im_ybus_offdiag + 2.5).abs() > 1e-6,
"Ybus keeps the transformer tap magnitude"
);
let inc = build_incidence(&view, DcConvention::PaperPure, &BuildOptions::default()).unwrap();
let dc_l = build_weighted_laplacian(&inc.a, &inc.b).to_dense();
assert_relative_eq!(dc_l[[0, 1]], -5.0, max_relative = 1e-12);
assert!(
(b[[0, 1]] - dc_l[[0, 1]]).abs() > 1e-6,
"phase shifts make Bp differ from A diag(1/x) A^T"
);
}
#[test]
fn bprime_with_phase_shift_and_resistance_is_not_sddm() {
let mut shifted = br(1, 2, 0.05, 0.2, 0.0);
shifted.shift = 45.0;
let net = Network::in_memory(
"asymmetric-bp",
100.0,
vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
vec![shifted],
);
let view = IndexedNetwork::new(&net);
let b = build_bprime(&view, &BuildOptions::default()).unwrap();
let stats = MatrixStats::from_csr(&b);
assert!(stats.m_matrix_sign);
assert!(stats.min_dd_margin >= -1e-12);
assert!(!sddm_check(&b), "asymmetric Bp must not be marked SDDM");
}
#[test]
fn bprime_ignores_bus_shunts_and_line_charging() {
let base = three_bus();
let mut decorated = base.clone();
decorated.shunts = vec![
Shunt::new(BusId(1), 1.0, -10.0),
Shunt::new(BusId(2), 0.0, 15.0),
Shunt::new(BusId(3), 0.5, -20.0),
];
decorated.branches[0].b = 0.8;
decorated.branches[1].charging = Some(BranchCharging::new(0.01, 0.3, 0.02, 0.5));
decorated.branches[2].b = -0.4;
let base_view = IndexedNetwork::new(&base);
let decorated_view = IndexedNetwork::new(&decorated);
let base_b = build_bprime(&base_view, &BuildOptions::default())
.unwrap()
.to_dense();
let decorated_b = build_bprime(&decorated_view, &BuildOptions::default())
.unwrap()
.to_dense();
for i in 0..3 {
for j in 0..3 {
assert_relative_eq!(decorated_b[[i, j]], base_b[[i, j]], epsilon = 1e-12);
}
}
}
#[test]
fn bprime_folds_phase_shifted_self_loop_like_make_ybus() {
let branch = br(1, 2, 0.0, 0.1, 0.0);
let mut loop_branch = br(1, 1, 0.0, 0.2, 0.0);
loop_branch.tap = 1.25;
loop_branch.shift = 60.0;
let base = Network::in_memory(
"without-self-loop",
100.0,
vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
vec![branch.clone()],
);
let with_loop = Network::in_memory(
"with-self-loop",
100.0,
vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
vec![branch, loop_branch],
);
let base_view = IndexedNetwork::new(&base);
let loop_view = IndexedNetwork::new(&with_loop);
let base_bp = build_bprime(&base_view, &BuildOptions::default())
.unwrap()
.to_dense();
let loop_bp = build_bprime(&loop_view, &BuildOptions::default())
.unwrap()
.to_dense();
assert_relative_eq!(loop_bp[[0, 0]] - base_bp[[0, 0]], 5.0, epsilon = 1e-12);
assert_relative_eq!(loop_bp[[0, 1]], base_bp[[0, 1]], epsilon = 1e-12);
assert_relative_eq!(loop_bp[[1, 0]], base_bp[[1, 0]], epsilon = 1e-12);
assert_relative_eq!(loop_bp[[1, 1]], base_bp[[1, 1]], epsilon = 1e-12);
let base_bpp = build_bdoubleprime(&base_view, &BuildOptions::default())
.unwrap()
.to_dense();
let loop_bpp = build_bdoubleprime(&loop_view, &BuildOptions::default())
.unwrap()
.to_dense();
assert!(
(loop_bpp[[0, 0]] - base_bpp[[0, 0]]).abs() > 1e-6,
"Bpp keeps self-loop shunt effects"
);
}
#[test]
fn bdoubleprime_clears_phase_shifts() {
let mut shifted = three_bus();
shifted.branches[0].shift = 30.0;
shifted.branches[1].shift = -25.0;
let mut unshifted = shifted.clone();
for br in &mut unshifted.branches {
br.shift = 0.0;
}
let shifted_view = IndexedNetwork::new(&shifted);
let unshifted_view = IndexedNetwork::new(&unshifted);
let shifted_bpp = build_bdoubleprime(&shifted_view, &BuildOptions::default())
.unwrap()
.to_dense();
let unshifted_bpp = build_bdoubleprime(&unshifted_view, &BuildOptions::default())
.unwrap()
.to_dense();
for i in 0..3 {
for j in 0..3 {
assert_relative_eq!(shifted_bpp[[i, j]], unshifted_bpp[[i, j]], epsilon = 1e-12);
}
}
let shifted_bp = build_bprime(
&shifted_view,
&BuildOptions {
scheme: Scheme::Xb,
..Default::default()
},
)
.unwrap()
.to_dense();
let unshifted_bp = build_bprime(
&unshifted_view,
&BuildOptions {
scheme: Scheme::Xb,
..Default::default()
},
)
.unwrap()
.to_dense();
assert!(
(shifted_bp[[0, 1]] - unshifted_bp[[0, 1]]).abs() > 1e-6,
"Bp keeps the same phase shifts Bpp clears"
);
}
#[test]
fn bdoubleprime_keeps_shunts_charging_and_taps() {
let mut branch = br(1, 2, 0.0, 0.2, 0.4);
branch.tap = 2.0;
branch.shift = 45.0;
let mut net = Network::in_memory(
"bpp-shunts-charging-tap",
100.0,
vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
vec![branch],
);
net.shunts = vec![
Shunt::new(BusId(1), 0.0, -10.0),
Shunt::new(BusId(2), 0.0, -20.0),
];
let view = IndexedNetwork::new(&net);
let bpp = build_bdoubleprime(&view, &BuildOptions::default())
.unwrap()
.to_dense();
assert_relative_eq!(bpp[[0, 0]], 1.3, max_relative = 1e-12);
assert_relative_eq!(bpp[[1, 1]], 5.0, max_relative = 1e-12);
assert_relative_eq!(bpp[[0, 1]], -2.5, max_relative = 1e-12);
assert_relative_eq!(bpp[[1, 0]], -2.5, max_relative = 1e-12);
}
#[test]
fn bprime_ignores_out_of_service() {
let mut net = three_bus();
net.branches[0].in_service = false;
let view = IndexedNetwork::new(&net);
let b = build_bprime(&view, &BuildOptions::default()).unwrap();
let dense = b.to_dense();
assert_relative_eq!(dense[[0, 0]], 5.0, max_relative = 1e-12);
assert_relative_eq!(dense[[0, 1]], 0.0, max_relative = 1e-12);
}
#[test]
fn xb_and_bx_disagree_when_resistance_present() {
let mut net = three_bus();
for b in &mut net.branches {
b.r = 0.05;
}
let view = IndexedNetwork::new(&net);
let xb = build_bprime(
&view,
&BuildOptions {
scheme: Scheme::Xb,
..Default::default()
},
)
.unwrap();
let bx = build_bprime(
&view,
&BuildOptions {
scheme: Scheme::Bx,
..Default::default()
},
)
.unwrap();
let xb_dense = xb.to_dense();
let bx_dense = bx.to_dense();
assert_relative_eq!(xb_dense[[0, 1]], -10.0, max_relative = 1e-12);
assert_relative_eq!(bx_dense[[0, 1]], -8.0, max_relative = 1e-12);
}
#[test]
fn bdoubleprime_with_shunts_is_strictly_dominant() {
let mut net = three_bus();
net.shunts = vec![
Shunt::new(BusId(1), 0.0, -10.0),
Shunt::new(BusId(2), 0.0, -10.0),
Shunt::new(BusId(3), 0.0, -10.0),
];
let view = IndexedNetwork::new(&net);
let bpp = build_bdoubleprime(&view, &BuildOptions::default()).unwrap();
let stats = MatrixStats::from_csr(&bpp);
assert!(stats.min_dd_margin > 0.0, "expected strict dominance");
}
#[test]
fn ybus_reciprocity_and_symmetry() {
let net = three_bus();
let view = IndexedNetwork::new(&net);
let parts = build_ybus(&view, &BuildOptions::default()).unwrap();
let g = parts.g.to_dense();
let b = parts.b.to_dense();
for i in 0..3 {
for j in 0..3 {
assert_relative_eq!(g[[i, j]], g[[j, i]], epsilon = 1e-12);
assert_relative_eq!(b[[i, j]], b[[j, i]], epsilon = 1e-12);
}
}
}
#[test]
fn ybus_uses_asymmetric_terminal_admittance() {
let mut branch = br(1, 2, 0.0, 0.1, 0.0);
branch.charging = Some(BranchCharging::new(0.01, 0.02, 0.03, 0.04));
let net = Network::in_memory(
"terminal-charging",
100.0,
vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
vec![branch],
);
let view = IndexedNetwork::new(&net);
let parts = build_ybus(&view, &BuildOptions::default()).unwrap();
let g = parts.g.to_dense();
let b = parts.b.to_dense();
assert_relative_eq!(g[[0, 0]], 0.01, epsilon = 1e-12);
assert_relative_eq!(g[[1, 1]], 0.03, epsilon = 1e-12);
assert_relative_eq!(g[[0, 1]], 0.0, epsilon = 1e-12);
assert_relative_eq!(g[[1, 0]], 0.0, epsilon = 1e-12);
assert_relative_eq!(b[[0, 0]], -9.98, epsilon = 1e-12);
assert_relative_eq!(b[[1, 1]], -9.96, epsilon = 1e-12);
assert_relative_eq!(b[[0, 1]], 10.0, epsilon = 1e-12);
assert_relative_eq!(b[[1, 0]], 10.0, epsilon = 1e-12);
}
#[test]
fn lacpf_block_is_2n_by_2n() {
let net = three_bus();
let view = IndexedNetwork::new(&net);
let j = build_lacpf(&view, &BuildOptions::default()).unwrap();
assert_eq!(j.rows(), 6);
assert_eq!(j.cols(), 6);
}
#[test]
fn lacpf_blocks_equal_g_and_minus_b() {
let net = three_bus();
let view = IndexedNetwork::new(&net);
let opts = BuildOptions::default();
let ybus = build_ybus(&view, &opts).unwrap();
let g = ybus.g.to_dense();
let b = ybus.b.to_dense();
let j = build_lacpf(&view, &opts).unwrap().to_dense();
let n = 3;
for r in 0..n {
for c in 0..n {
assert_relative_eq!(j[[r, c]], g[[r, c]], epsilon = 1e-12); assert_relative_eq!(j[[r, c + n]], -b[[r, c]], epsilon = 1e-12); assert_relative_eq!(j[[r + n, c]], -b[[r, c]], epsilon = 1e-12); assert_relative_eq!(j[[r + n, c + n]], -g[[r, c]], epsilon = 1e-12); }
}
}
#[test]
fn ybus_tap_scales_from_diagonal_only() {
let mut branch = br(1, 2, 0.0, 0.2, 0.0); branch.tap = 1.25;
let net = Network::in_memory(
"tap2",
100.0,
vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
vec![branch],
);
let view = IndexedNetwork::new(&net);
let b = build_ybus(&view, &BuildOptions::default())
.unwrap()
.b
.to_dense();
assert_relative_eq!(b[[0, 0]], -5.0 / 1.5625, max_relative = 1e-12); assert_relative_eq!(b[[1, 1]], -5.0, max_relative = 1e-12);
assert_relative_eq!(b[[0, 1]], 4.0, max_relative = 1e-12);
assert_relative_eq!(b[[1, 0]], 4.0, max_relative = 1e-12);
}
#[test]
fn bprime_rejects_nan_reactance() {
let mut net = three_bus();
net.branches[0].x = f64::NAN;
let view = IndexedNetwork::new(&net);
let err = build_bprime(&view, &BuildOptions::default()).unwrap_err();
assert!(matches!(err, crate::Error::NonFiniteSusceptance { .. }));
}
#[test]
fn ybus_rejects_nan_reactance() {
let mut net = three_bus();
net.branches[0].x = f64::NAN;
let view = IndexedNetwork::new(&net);
let err = build_ybus(&view, &BuildOptions::default()).unwrap_err();
assert!(matches!(err, crate::Error::NonFiniteSusceptance { .. }));
}
#[test]
fn zero_impedance_policy_is_shared_across_matrix_builders() {
let net = zero_impedance_bus_pair();
let view = IndexedNetwork::new(&net);
let opts = BuildOptions::default();
let bprime = build_bprime(&view, &opts).unwrap();
let bprime_stats = matrix_stats_for_kind(&bprime, &view, MatrixKind::BPrime, &opts);
assert_eq!(bprime_stats.skipped_zero_impedance, 1);
assert_eq!(bprime_stats.skipped_zero_impedance_branches, vec![0]);
let ybus = build_ybus(&view, &opts).unwrap();
let ybus_stats = matrix_stats_for_kind(&ybus.b, &view, MatrixKind::YbusB, &opts);
assert_eq!(ybus_stats.skipped_zero_impedance, 1);
assert_eq!(ybus_stats.skipped_zero_impedance_branches, vec![0]);
let inc = build_incidence(&view, DcConvention::PaperPure, &opts).unwrap();
assert_eq!(inc.skipped_zero_impedance.count, 1);
assert_eq!(inc.skipped_zero_impedance.branch_indices, vec![0]);
}
#[test]
fn zero_impedance_policy_can_error_instead_of_skipping() {
let net = zero_impedance_bus_pair();
let view = IndexedNetwork::new(&net);
let opts = BuildOptions {
skip_zero_impedance: false,
..Default::default()
};
let bprime = build_bprime(&view, &opts).unwrap_err();
assert!(matches!(bprime, crate::Error::ZeroImpedance { row: 0 }));
let ybus = build_ybus(&view, &opts).unwrap_err();
assert!(matches!(ybus, crate::Error::ZeroImpedance { row: 0 }));
let inc = build_incidence(&view, DcConvention::PaperPure, &opts).unwrap_err();
assert!(matches!(inc, crate::Error::ZeroImpedance { row: 0 }));
}
#[test]
fn self_loop_with_zero_reactance_drops_unconditionally() {
let net = Network::in_memory(
"self-loop",
100.0,
vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
vec![br(1, 2, 0.0, 0.1, 0.0), br(1, 1, 0.0, 0.0, 0.0)],
);
let view = IndexedNetwork::new(&net);
let opts = BuildOptions::default();
let inc = build_incidence(&view, DcConvention::PaperPure, &opts).unwrap();
assert_eq!(inc.skipped_zero_impedance.count, 0);
assert!(inc.skipped_zero_impedance.branch_indices.is_empty());
let strict = BuildOptions {
skip_zero_impedance: false,
..Default::default()
};
build_incidence(&view, DcConvention::PaperPure, &strict)
.expect("a self-loop must not trip ZeroImpedance even when skip_zero_impedance is false");
}
#[test]
#[should_panic(expected = "COO coordinate (0, 2) out of bounds")]
fn coo_builder_rejects_out_of_bounds_column() {
let mut coo = CooBuilder::new_rect(2, 2);
coo.add(0, 2, 1.0);
}