use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LossMinimizationMethod {
SuccessiveLinearProgramming,
GradientDescent,
SwarmOptimization,
BranchBoundRelaxation,
SensitivityBased,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReactiveSource {
CapacitorBank,
SynchronousCondenser,
StatCom,
SvcDevice,
GeneratorReactivePower,
WindTurbine,
SolarInverter,
BatteryInverter,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReactivePowerSource {
pub id: usize,
pub name: String,
pub bus_id: usize,
pub source_type: ReactiveSource,
pub q_min_mvar: f64,
pub q_max_mvar: f64,
pub q_current_mvar: f64,
pub cost_usd_per_mvarh: f64,
pub response_time_s: f64,
pub discrete: bool,
pub step_size_mvar: f64,
}
impl ReactivePowerSource {
pub fn quantize(&self, q: f64) -> f64 {
let clamped = q.clamp(self.q_min_mvar, self.q_max_mvar);
if self.discrete && self.step_size_mvar > 0.0 {
let steps = ((clamped - self.q_min_mvar) / self.step_size_mvar).round();
let quantized = self.q_min_mvar + steps * self.step_size_mvar;
quantized.clamp(self.q_min_mvar, self.q_max_mvar)
} else {
clamped
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LossMinBusData {
pub id: usize,
pub v_pu: f64,
pub p_load_mw: f64,
pub q_load_mvar: f64,
pub p_gen_mw: f64,
pub q_gen_mvar: f64,
pub q_min_mvar: f64,
pub q_max_mvar: f64,
}
impl LossMinBusData {
pub fn q_net_mvar(&self) -> f64 {
self.q_gen_mvar - self.q_load_mvar
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LossMinBranchData {
pub id: usize,
pub from_bus: usize,
pub to_bus: usize,
pub r_pu: f64,
pub x_pu: f64,
pub rating_mva: f64,
pub p_flow_mw: f64,
pub q_flow_mvar: f64,
}
impl LossMinBranchData {
pub fn compute_loss(&self, v_from_pu: f64) -> f64 {
let v2 = v_from_pu * v_from_pu;
if v2 < 1e-12 {
return 0.0;
}
(self.p_flow_mw * self.p_flow_mw + self.q_flow_mvar * self.q_flow_mvar) / v2 * self.r_pu
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LossReductionResult {
pub q_dispatch: Vec<(usize, f64)>,
pub total_losses_mw_before: f64,
pub total_losses_mw_after: f64,
pub loss_reduction_mw: f64,
pub loss_reduction_pct: f64,
pub voltage_deviation_before: f64,
pub voltage_deviation_after: f64,
pub reactive_compensation_cost_usd: f64,
pub converged: bool,
pub iterations: usize,
pub benefit_cost_ratio: f64,
}
#[derive(Debug, Clone)]
pub struct LossMinimizationProblem {
pub buses: Vec<LossMinBusData>,
pub branches: Vec<LossMinBranchData>,
pub reactive_sources: Vec<ReactivePowerSource>,
pub method: LossMinimizationMethod,
pub base_mva: f64,
pub energy_price_usd_per_mwh: f64,
pub operating_hours_per_year: f64,
pub max_iterations: usize,
pub convergence_tolerance_mw: f64,
}
impl LossMinimizationProblem {
pub fn new(
buses: Vec<LossMinBusData>,
branches: Vec<LossMinBranchData>,
reactive_sources: Vec<ReactivePowerSource>,
) -> Self {
Self {
buses,
branches,
reactive_sources,
method: LossMinimizationMethod::SensitivityBased,
base_mva: 100.0,
energy_price_usd_per_mwh: 50.0,
operating_hours_per_year: 8760.0,
max_iterations: 100,
convergence_tolerance_mw: 0.01,
}
}
pub fn compute_losses(&self) -> f64 {
self.branches
.iter()
.map(|br| {
let v = self.bus_voltage(br.from_bus);
br.compute_loss(v)
})
.sum()
}
pub fn compute_voltage_deviation(&self) -> f64 {
self.buses
.iter()
.map(|b| {
let d = b.v_pu - 1.0;
d * d
})
.sum()
}
#[allow(non_snake_case)]
pub fn compute_loss_sensitivity_dLdQ(&self, source_bus: usize) -> f64 {
let v = self.bus_voltage(source_bus);
let v4 = v * v * v * v;
if v4 < 1e-12 {
return 0.0;
}
self.branches
.iter()
.filter(|br| br.from_bus == source_bus || br.to_bus == source_bus)
.map(|br| 2.0 * br.r_pu * br.q_flow_mvar / v4)
.sum()
}
pub fn solve_gradient_descent(&mut self) -> LossReductionResult {
let losses_before = self.compute_losses();
let dev_before = self.compute_voltage_deviation();
let mut alpha = 0.1_f64; let mut prev_losses = losses_before;
let mut converged = false;
let mut iterations = 0usize;
for iter in 0..self.max_iterations {
iterations = iter + 1;
let mut dispatch: Vec<(usize, f64)> = Vec::with_capacity(self.reactive_sources.len());
for src in &self.reactive_sources {
let sens = self.compute_loss_sensitivity_dLdQ(src.bus_id);
let q_new = src.quantize(src.q_current_mvar - alpha * sens);
dispatch.push((src.id, q_new));
}
self.apply_reactive_dispatch(&dispatch);
self.update_flows_after_dispatch(&dispatch);
let new_losses = self.compute_losses();
let delta = (prev_losses - new_losses).abs();
if new_losses > prev_losses {
alpha *= 0.5;
}
if delta < self.convergence_tolerance_mw {
converged = true;
break;
}
prev_losses = new_losses;
}
self.build_result(losses_before, dev_before, converged, iterations)
}
pub fn solve_sensitivity_based(&mut self) -> LossReductionResult {
let losses_before = self.compute_losses();
let dev_before = self.compute_voltage_deviation();
let mut sensitivities: Vec<(usize, f64)> = self
.reactive_sources
.iter()
.map(|src| (src.id, self.compute_loss_sensitivity_dLdQ(src.bus_id)))
.collect();
sensitivities.sort_by(|a, b| {
b.1.abs()
.partial_cmp(&a.1.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
let threshold = 1e-6_f64;
let mut dispatch: Vec<(usize, f64)> = Vec::new();
for (src_id, sens) in &sensitivities {
if let Some(src) = self.reactive_sources.iter().find(|s| s.id == *src_id) {
let q_target = if sens.abs() < threshold {
src.q_current_mvar
} else if *sens < 0.0 {
src.q_max_mvar
} else {
src.q_min_mvar
};
dispatch.push((*src_id, src.quantize(q_target)));
}
}
self.apply_reactive_dispatch(&dispatch);
self.update_flows_after_dispatch(&dispatch);
self.build_result(losses_before, dev_before, true, 1)
}
pub fn solve_slp(&mut self) -> LossReductionResult {
let losses_before = self.compute_losses();
let dev_before = self.compute_voltage_deviation();
let mut prev_losses = losses_before;
let mut converged = false;
let mut iterations = 0usize;
let mut step_limit = 1.0_f64;
for iter in 0..self.max_iterations {
iterations = iter + 1;
let mut dispatch: Vec<(usize, f64)> = Vec::with_capacity(self.reactive_sources.len());
for src in &self.reactive_sources {
let sens = self.compute_loss_sensitivity_dLdQ(src.bus_id);
let q0 = src.q_current_mvar;
let range = src.q_max_mvar - src.q_min_mvar;
let max_delta = range * step_limit;
let delta_q = if sens < 0.0 {
(src.q_max_mvar - q0).min(max_delta)
} else if sens > 0.0 {
(src.q_min_mvar - q0).max(-max_delta)
} else {
0.0
};
let q_new = src.quantize(q0 + delta_q);
dispatch.push((src.id, q_new));
}
self.apply_reactive_dispatch(&dispatch);
self.update_flows_after_dispatch(&dispatch);
let new_losses = self.compute_losses();
let delta = (prev_losses - new_losses).abs();
if new_losses >= prev_losses {
step_limit *= 0.5;
}
if delta < self.convergence_tolerance_mw {
converged = true;
break;
}
prev_losses = new_losses;
}
self.build_result(losses_before, dev_before, converged, iterations)
}
pub fn solve(&mut self) -> LossReductionResult {
match self.method {
LossMinimizationMethod::GradientDescent => self.solve_gradient_descent(),
LossMinimizationMethod::SensitivityBased => self.solve_sensitivity_based(),
LossMinimizationMethod::SuccessiveLinearProgramming => self.solve_slp(),
LossMinimizationMethod::SwarmOptimization => self.solve_gradient_descent(),
LossMinimizationMethod::BranchBoundRelaxation => self.solve_slp(),
}
}
pub fn apply_reactive_dispatch(&mut self, dispatch: &[(usize, f64)]) {
for (src_id, q) in dispatch {
if let Some(src) = self.reactive_sources.iter_mut().find(|s| s.id == *src_id) {
src.q_current_mvar = src.quantize(*q);
}
}
}
pub fn update_flows_after_dispatch(&mut self, dispatch: &[(usize, f64)]) {
let deltas: Vec<(usize, usize, f64)> = dispatch
.iter()
.filter_map(|(src_id, q_new)| {
self.reactive_sources
.iter()
.find(|s| s.id == *src_id)
.map(|src| (src.bus_id, *src_id, q_new - src.q_current_mvar))
})
.collect();
for (br_idx, br) in self.branches.iter_mut().enumerate() {
let mut dq_flow = 0.0_f64;
for (bus_id, _src_id, delta_q) in &deltas {
let sens = Self::compute_flow_sensitivity_dP_dQ_static(br_idx, *bus_id, br);
dq_flow += sens * delta_q;
}
br.q_flow_mvar += dq_flow;
}
}
#[allow(non_snake_case)]
pub fn compute_flow_sensitivity_dP_dQ(&self, branch_idx: usize, bus_idx: usize) -> f64 {
match self.branches.get(branch_idx) {
Some(br) => Self::compute_flow_sensitivity_dP_dQ_static(branch_idx, bus_idx, br),
None => 0.0,
}
}
#[allow(non_snake_case)]
fn compute_flow_sensitivity_dP_dQ_static(
_branch_idx: usize,
bus_idx: usize,
br: &LossMinBranchData,
) -> f64 {
if br.from_bus != bus_idx && br.to_bus != bus_idx {
return 0.0;
}
let v2 = 1.0_f64; if v2 < 1e-12 {
return 0.0;
}
br.r_pu * br.q_flow_mvar / v2
}
fn bus_voltage(&self, bus_id: usize) -> f64 {
self.buses
.iter()
.find(|b| b.id == bus_id)
.map(|b| b.v_pu)
.unwrap_or(1.0)
}
fn current_dispatch(&self) -> Vec<(usize, f64)> {
self.reactive_sources
.iter()
.map(|src| (src.id, src.q_current_mvar))
.collect()
}
fn compute_compensation_cost(&self) -> f64 {
self.reactive_sources
.iter()
.map(|src| src.q_current_mvar.abs() * src.cost_usd_per_mvarh)
.sum()
}
fn build_result(
&self,
losses_before: f64,
dev_before: f64,
converged: bool,
iterations: usize,
) -> LossReductionResult {
let losses_after = self.compute_losses();
let dev_after = self.compute_voltage_deviation();
let loss_reduction_mw = (losses_before - losses_after).max(0.0);
let loss_reduction_pct = if losses_before > 1e-12 {
(loss_reduction_mw / losses_before * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
let cost_per_hour = self.compute_compensation_cost();
let annual_cost = cost_per_hour * self.operating_hours_per_year;
let annual_savings = LossSensitivityAnalyzer::compute_annual_savings(
loss_reduction_mw,
self.energy_price_usd_per_mwh,
self.operating_hours_per_year,
);
let benefit_cost_ratio = if annual_cost > 1e-12 {
annual_savings / annual_cost
} else if annual_savings > 0.0 {
f64::MAX
} else {
0.0
};
LossReductionResult {
q_dispatch: self.current_dispatch(),
total_losses_mw_before: losses_before,
total_losses_mw_after: losses_after,
loss_reduction_mw,
loss_reduction_pct,
voltage_deviation_before: dev_before,
voltage_deviation_after: dev_after,
reactive_compensation_cost_usd: cost_per_hour,
converged,
iterations,
benefit_cost_ratio,
}
}
}
pub struct LossSensitivityAnalyzer;
impl LossSensitivityAnalyzer {
pub fn compute_loss_coefficients(
branches: &[LossMinBranchData],
buses: &[LossMinBusData],
) -> Vec<f64> {
branches
.iter()
.map(|br| {
let v = buses
.iter()
.find(|b| b.id == br.from_bus)
.map(|b| b.v_pu)
.unwrap_or(1.0);
let v2 = v * v;
if v2 < 1e-12 {
0.0
} else {
br.r_pu / v2
}
})
.collect()
}
pub fn rank_sources_by_sensitivity(sensitivities: &[(usize, f64)]) -> Vec<usize> {
let mut indexed: Vec<(usize, f64)> = sensitivities.to_vec();
indexed.sort_by(|a, b| {
b.1.abs()
.partial_cmp(&a.1.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
indexed.iter().map(|(id, _)| *id).collect()
}
pub fn compute_optimal_q_for_unity_pf(load_q_mvar: f64, source_q_max: f64) -> f64 {
load_q_mvar.min(source_q_max).max(0.0)
}
pub fn compute_annual_savings(loss_reduction_mw: f64, energy_price: f64, hours: f64) -> f64 {
loss_reduction_mw * energy_price * hours
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_bus(id: usize, v_pu: f64, q_load: f64) -> LossMinBusData {
LossMinBusData {
id,
v_pu,
p_load_mw: 10.0,
q_load_mvar: q_load,
p_gen_mw: 0.0,
q_gen_mvar: 0.0,
q_min_mvar: -5.0,
q_max_mvar: 5.0,
}
}
fn make_branch(id: usize, from: usize, to: usize, r: f64, p: f64, q: f64) -> LossMinBranchData {
LossMinBranchData {
id,
from_bus: from,
to_bus: to,
r_pu: r,
x_pu: r * 2.0,
rating_mva: 100.0,
p_flow_mw: p,
q_flow_mvar: q,
}
}
fn make_source(id: usize, bus_id: usize, q_min: f64, q_max: f64) -> ReactivePowerSource {
ReactivePowerSource {
id,
name: format!("Source-{}", id),
bus_id,
source_type: ReactiveSource::CapacitorBank,
q_min_mvar: q_min,
q_max_mvar: q_max,
q_current_mvar: 0.0,
cost_usd_per_mvarh: 1.0,
response_time_s: 1.0,
discrete: false,
step_size_mvar: 0.0,
}
}
fn simple_problem() -> LossMinimizationProblem {
let buses = vec![
make_bus(0, 1.02, 5.0),
make_bus(1, 0.98, 8.0),
make_bus(2, 0.96, 3.0),
];
let branches = vec![
make_branch(0, 0, 1, 0.05, 20.0, 10.0),
make_branch(1, 1, 2, 0.04, 15.0, 8.0),
];
let sources = vec![make_source(0, 1, 0.0, 10.0), make_source(1, 2, 0.0, 5.0)];
LossMinimizationProblem::new(buses, branches, sources)
}
#[test]
fn test_loss_computation_single_branch() {
let buses = vec![make_bus(0, 1.0, 0.0), make_bus(1, 1.0, 0.0)];
let branches = vec![make_branch(0, 0, 1, 0.1, 3.0, 4.0)];
let prob = LossMinimizationProblem::new(buses, branches, vec![]);
let loss = prob.compute_losses();
let expected = (3.0_f64.powi(2) + 4.0_f64.powi(2)) / 1.0_f64.powi(2) * 0.1;
assert!(
(loss - expected).abs() < 1e-9,
"loss={loss} expected={expected}"
);
}
#[test]
fn test_loss_computation_zero_flow() {
let buses = vec![make_bus(0, 1.0, 0.0), make_bus(1, 1.0, 0.0)];
let branches = vec![make_branch(0, 0, 1, 0.05, 0.0, 0.0)];
let prob = LossMinimizationProblem::new(buses, branches, vec![]);
assert!(prob.compute_losses().abs() < 1e-12);
}
#[test]
fn test_voltage_deviation_at_nominal() {
let buses = vec![make_bus(0, 1.0, 0.0), make_bus(1, 1.0, 0.0)];
let prob = LossMinimizationProblem::new(buses, vec![], vec![]);
assert!(prob.compute_voltage_deviation().abs() < 1e-12);
}
#[test]
fn test_sensitivity_calculation() {
let prob = simple_problem();
let sens = prob.compute_loss_sensitivity_dLdQ(1);
assert!(sens.is_finite());
}
#[test]
fn test_gradient_descent_reduces_losses() {
let mut prob = simple_problem();
prob.method = LossMinimizationMethod::GradientDescent;
let result = prob.solve_gradient_descent();
assert!(result.total_losses_mw_after >= 0.0);
assert!(result.total_losses_mw_before >= 0.0);
}
#[test]
fn test_gradient_descent_converges() {
let mut prob = simple_problem();
prob.max_iterations = 200;
let result = prob.solve_gradient_descent();
assert!(
result.converged,
"Expected convergence within {} iterations",
prob.max_iterations
);
}
#[test]
fn test_sensitivity_based_reduces_losses() {
let mut prob = simple_problem();
let result = prob.solve_sensitivity_based();
assert!(result.total_losses_mw_after >= 0.0);
assert!(result.total_losses_mw_before > 0.0);
}
#[test]
fn test_reactive_bounds_respected() {
let mut prob = simple_problem();
let result = prob.solve_sensitivity_based();
for (src_id, q) in &result.q_dispatch {
if let Some(src) = prob.reactive_sources.iter().find(|s| s.id == *src_id) {
assert!(
*q >= src.q_min_mvar - 1e-9 && *q <= src.q_max_mvar + 1e-9,
"Source {} Q={} out of bounds [{}, {}]",
src_id,
q,
src.q_min_mvar,
src.q_max_mvar
);
}
}
}
#[test]
fn test_slp_converges() {
let mut prob = simple_problem();
prob.max_iterations = 200;
let result = prob.solve_slp();
assert!(
result.converged,
"SLP should converge; got {} iterations",
result.iterations
);
}
#[test]
fn test_loss_reduction_positive() {
let mut prob = simple_problem();
let result = prob.solve();
assert!(result.loss_reduction_mw >= 0.0);
}
#[test]
fn test_loss_reduction_pct_valid() {
let mut prob = simple_problem();
let result = prob.solve();
assert!(
result.loss_reduction_pct >= 0.0 && result.loss_reduction_pct <= 100.0,
"loss_reduction_pct={} out of range",
result.loss_reduction_pct
);
}
#[test]
fn test_benefit_cost_ratio() {
let mut prob = simple_problem();
prob.energy_price_usd_per_mwh = 60.0;
let result = prob.solve();
assert!(result.benefit_cost_ratio >= 0.0, "BCR must be non-negative");
}
#[test]
fn test_discrete_source_step() {
let mut src = make_source(0, 0, 0.0, 10.0);
src.discrete = true;
src.step_size_mvar = 2.5;
let q = src.quantize(3.7);
let expected_steps = ((3.7_f64 - 0.0) / 2.5).round();
let expected = (0.0 + expected_steps * 2.5).clamp(0.0, 10.0);
assert!((q - expected).abs() < 1e-9, "q={q} expected={expected}");
}
#[test]
fn test_multiple_sources_prioritized() {
let buses = vec![
make_bus(0, 1.0, 0.0),
make_bus(1, 0.95, 5.0),
make_bus(2, 0.98, 2.0),
];
let branches = vec![
make_branch(0, 0, 1, 0.1, 20.0, 15.0), make_branch(1, 0, 2, 0.05, 10.0, 3.0),
];
let sources = vec![make_source(0, 1, 0.0, 20.0), make_source(1, 2, 0.0, 20.0)];
let prob = LossMinimizationProblem::new(buses, branches, sources);
let sens_bus1 = prob.compute_loss_sensitivity_dLdQ(1);
let sens_bus2 = prob.compute_loss_sensitivity_dLdQ(2);
let sensitivities = vec![(0usize, sens_bus1), (1usize, sens_bus2)];
let ranked = LossSensitivityAnalyzer::rank_sources_by_sensitivity(&sensitivities);
assert_eq!(ranked.len(), 2);
let first_sens = sensitivities
.iter()
.find(|(id, _)| *id == ranked[0])
.map(|(_, s)| s.abs())
.unwrap_or(0.0);
let second_sens = sensitivities
.iter()
.find(|(id, _)| *id == ranked[1])
.map(|(_, s)| s.abs())
.unwrap_or(0.0);
assert!(first_sens >= second_sens);
}
#[test]
fn test_annual_savings_computation() {
let savings = LossSensitivityAnalyzer::compute_annual_savings(1.0, 50.0, 8760.0);
assert!((savings - 438_000.0).abs() < 1.0, "savings={savings}");
}
#[test]
fn test_rank_sources_by_sensitivity() {
let sensitivities = vec![(0, -0.5), (1, 0.1), (2, -0.8), (3, 0.3)];
let ranked = LossSensitivityAnalyzer::rank_sources_by_sensitivity(&sensitivities);
assert_eq!(ranked[0], 2);
assert_eq!(ranked[1], 0);
assert_eq!(ranked[2], 3);
assert_eq!(ranked[3], 1);
}
#[test]
fn test_optimal_q_unity_pf() {
let q = LossSensitivityAnalyzer::compute_optimal_q_for_unity_pf(3.0, 10.0);
assert!((q - 3.0).abs() < 1e-9);
let q2 = LossSensitivityAnalyzer::compute_optimal_q_for_unity_pf(15.0, 10.0);
assert!((q2 - 10.0).abs() < 1e-9);
let q3 = LossSensitivityAnalyzer::compute_optimal_q_for_unity_pf(-2.0, 10.0);
assert!(q3 >= 0.0);
}
#[test]
fn test_loss_coefficients_positive() {
let buses = vec![make_bus(0, 1.0, 0.0), make_bus(1, 0.95, 0.0)];
let branches = vec![make_branch(0, 0, 1, 0.05, 10.0, 5.0)];
let coeffs = LossSensitivityAnalyzer::compute_loss_coefficients(&branches, &buses);
assert_eq!(coeffs.len(), 1);
assert!(coeffs[0] > 0.0, "coefficient must be positive");
}
#[test]
fn test_solve_dispatches_correctly() {
let mut prob = simple_problem();
let result = prob.solve();
assert_eq!(result.q_dispatch.len(), prob.reactive_sources.len());
assert!(result.total_losses_mw_before.is_finite());
assert!(result.total_losses_mw_after.is_finite());
assert!(result.benefit_cost_ratio.is_finite());
assert!(result.loss_reduction_pct.is_finite());
}
#[test]
fn test_zero_reactive_sources() {
let buses = vec![make_bus(0, 1.0, 0.0), make_bus(1, 0.98, 5.0)];
let branches = vec![make_branch(0, 0, 1, 0.05, 10.0, 5.0)];
let mut prob = LossMinimizationProblem::new(buses, branches, vec![]);
let result = prob.solve();
assert!(result.q_dispatch.is_empty());
assert!(result.total_losses_mw_before > 0.0);
assert!((result.total_losses_mw_before - result.total_losses_mw_after).abs() < 1e-9);
assert!(result.converged); }
#[test]
fn test_slp_via_solve_alias() {
let mut prob = simple_problem();
prob.method = LossMinimizationMethod::SuccessiveLinearProgramming;
let result = prob.solve();
assert!(result.iterations > 0);
}
#[test]
fn test_swarm_alias() {
let mut prob = simple_problem();
prob.method = LossMinimizationMethod::SwarmOptimization;
let result = prob.solve();
assert!(result.total_losses_mw_after >= 0.0);
}
#[test]
fn test_flow_sensitivity_returns_nonzero_for_adjacent_bus() {
let buses = vec![make_bus(0, 1.0, 0.0), make_bus(1, 1.0, 0.0)];
let branches = vec![make_branch(0, 0, 1, 0.1, 5.0, 10.0)];
let solver = LossMinimizationProblem::new(buses, branches, vec![]);
let sens_adjacent = solver.compute_flow_sensitivity_dP_dQ(0, 0);
assert!(
(sens_adjacent - 1.0).abs() < 1e-9,
"expected 1.0 for adjacent bus 0, got {}",
sens_adjacent
);
let sens_nonadjacent = solver.compute_flow_sensitivity_dP_dQ(0, 99);
assert!(
sens_nonadjacent.abs() < 1e-12,
"expected 0.0 for non-adjacent bus 99, got {}",
sens_nonadjacent
);
let sens_oob = solver.compute_flow_sensitivity_dP_dQ(99, 0);
assert!(
sens_oob.abs() < 1e-12,
"expected 0.0 for out-of-bounds branch 99, got {}",
sens_oob
);
}
}