use crate::error::{OxiGridError, Result};
#[derive(Debug, Clone)]
pub struct CandidateLine {
pub line_id: String,
pub from_bus: usize,
pub to_bus: usize,
pub reactance_pu: f64,
pub capacity_mw: f64,
pub cost_million_usd: f64,
pub build_years: Vec<usize>,
}
#[derive(Debug, Clone)]
pub struct ExistingLine {
pub from_bus: usize,
pub to_bus: usize,
pub reactance_pu: f64,
pub capacity_mw: f64,
}
#[derive(Debug, Clone)]
pub struct ScTepConfig {
pub planning_years: usize,
pub discount_rate: f64,
pub load_growth_pct: f64,
pub n1_security: bool,
pub max_branch_and_bound_nodes: usize,
pub optimality_gap: f64,
pub load_shedding_cost: f64,
}
impl Default for ScTepConfig {
fn default() -> Self {
Self {
planning_years: 5,
discount_rate: 0.07,
load_growth_pct: 0.03,
n1_security: true,
max_branch_and_bound_nodes: 500,
optimality_gap: 0.01,
load_shedding_cost: 10_000.0,
}
}
}
#[derive(Debug, Clone)]
pub struct GeneratorData {
pub bus: usize,
pub pmax_mw: f64,
pub pmin_mw: f64,
pub cost_per_mwh: f64,
}
#[derive(Debug, Clone)]
pub struct ScTepSolver {
pub num_buses: usize,
pub existing_lines: Vec<ExistingLine>,
pub candidate_lines: Vec<CandidateLine>,
pub generators: Vec<GeneratorData>,
pub load_mw: Vec<f64>,
pub config: ScTepConfig,
}
#[derive(Debug, Clone)]
pub struct SubproblemResult {
pub objective: f64,
pub load_shed_mw: f64,
pub generation_dispatch: Vec<f64>,
pub line_flows_mw: Vec<f64>,
pub dual_vars: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct ContingencyViolation {
pub outaged_line: usize,
pub violated_line: usize,
pub overload_mw: f64,
}
#[derive(Debug, Clone)]
pub struct AnnualPlan {
pub year: usize,
pub new_lines: Vec<String>,
pub total_load_mw: f64,
pub expected_load_shed_mwh: f64,
}
#[derive(Debug, Clone)]
pub struct ScTepResult {
pub selected_lines: Vec<String>,
pub investment_cost_million: f64,
pub total_pv_cost_million: f64,
pub load_shed_reduction_mwh: f64,
pub n1_secure: bool,
pub optimality_gap_pct: f64,
pub iterations: usize,
pub annual_plans: Vec<AnnualPlan>,
}
fn gaussian_eliminate(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Result<Vec<f64>> {
let n = b.len();
for col in 0..n {
let pivot = (col..n)
.max_by(|&r1, &r2| {
a[r1][col]
.abs()
.partial_cmp(&a[r2][col].abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.ok_or_else(|| OxiGridError::InvalidParameter("empty matrix".into()))?;
a.swap(col, pivot);
b.swap(col, pivot);
let diag = a[col][col];
if diag.abs() < 1e-12 {
return Err(OxiGridError::InvalidParameter("singular B-matrix".into()));
}
for row in (col + 1)..n {
let factor = a[row][col] / diag;
b[row] -= factor * b[col];
let (head, tail) = a.split_at_mut(row);
let src = head[col][col..].to_vec();
for (ak, sk) in tail[0][col..].iter_mut().zip(src.iter()) {
*ak -= factor * sk;
}
}
}
let mut x = vec![0.0_f64; n];
for i in (0..n).rev() {
let mut sum = b[i];
for j in (i + 1)..n {
sum -= a[i][j] * x[j];
}
x[i] = sum / a[i][i];
}
Ok(x)
}
impl ScTepSolver {
pub fn new(
num_buses: usize,
existing_lines: Vec<ExistingLine>,
candidate_lines: Vec<CandidateLine>,
generators: Vec<GeneratorData>,
load_mw: Vec<f64>,
config: ScTepConfig,
) -> Self {
Self {
num_buses,
existing_lines,
candidate_lines,
generators,
load_mw,
config,
}
}
pub fn solve(&self) -> Result<ScTepResult> {
let nc = self.candidate_lines.len();
let no_build = vec![false; nc];
let base_sp = self.dc_opf_subproblem(&no_build)?;
let base_load_shed_mwh = base_sp.load_shed_mw * 8_760.0;
if nc == 0 {
let annual_plans = self.build_annual_plans(&no_build, base_sp.load_shed_mw);
return Ok(ScTepResult {
selected_lines: vec![],
investment_cost_million: 0.0,
total_pv_cost_million: 0.0,
load_shed_reduction_mwh: 0.0,
n1_secure: self.config.n1_security
&& self.n1_contingency_check(&no_build, &no_build)?.is_empty(),
optimality_gap_pct: 0.0,
iterations: 1,
annual_plans,
});
}
let budget = self
.candidate_lines
.iter()
.map(|c| c.cost_million_usd)
.sum::<f64>();
let plans = self.enumerate_investment_plans(budget);
let mut best_obj = f64::MAX;
let mut best_plan: Vec<bool> = no_build.clone();
let mut best_sp = base_sp.clone();
let mut iterations = 0_usize;
let mut benders_cuts: Vec<(f64, Vec<f64>)> = Vec::new();
for plan in &plans {
if iterations >= self.config.max_branch_and_bound_nodes {
break;
}
iterations += 1;
let inv_cost: f64 = self
.candidate_lines
.iter()
.enumerate()
.filter(|(i, _)| plan[*i])
.map(|(_, c)| self.present_value_cost(1, c.cost_million_usd))
.sum();
let eta_lb = benders_cuts
.iter()
.map(|(alpha, beta)| {
let dot: f64 = beta
.iter()
.zip(plan.iter())
.map(|(b, &x)| b * if x { 1.0 } else { 0.0 })
.sum();
alpha + dot
})
.fold(f64::NEG_INFINITY, f64::max);
let lb = inv_cost + eta_lb.max(0.0);
if lb >= best_obj {
continue; }
let sp = match self.dc_opf_subproblem(plan) {
Ok(r) => r,
Err(_) => continue,
};
let op_cost_pv: f64 = (1..=self.config.planning_years)
.map(|yr| {
let growth = (1.0 + self.config.load_growth_pct).powi(yr as i32);
let shed_mwh = sp.load_shed_mw * growth * 8_760.0;
self.present_value_cost(yr, shed_mwh * self.config.load_shedding_cost / 1e6)
})
.sum();
let obj = inv_cost + op_cost_pv;
let alpha = op_cost_pv;
let beta: Vec<f64> = (0..nc)
.map(|i| {
if plan[i] {
-op_cost_pv / (nc as f64).max(1.0)
} else {
op_cost_pv / (nc as f64).max(1.0)
}
})
.collect();
benders_cuts.push((alpha, beta));
if obj < best_obj {
best_obj = obj;
best_plan = plan.clone();
best_sp = sp;
}
}
let lower_bound = benders_cuts
.iter()
.map(|(alpha, beta)| {
let dot: f64 = beta
.iter()
.zip(best_plan.iter())
.map(|(b, &x)| b * if x { 1.0 } else { 0.0 })
.sum();
alpha + dot
})
.fold(f64::NEG_INFINITY, f64::max)
.max(0.0);
let inv_best: f64 = self
.candidate_lines
.iter()
.enumerate()
.filter(|(i, _)| best_plan[*i])
.map(|(_, c)| self.present_value_cost(1, c.cost_million_usd))
.sum();
let gap_pct = if best_obj > 1e-9 {
(best_obj - (inv_best + lower_bound)).abs() / best_obj * 100.0
} else {
0.0
};
let violations = self.n1_contingency_check(&best_plan, &best_plan)?;
let n1_secure = !self.config.n1_security || violations.is_empty();
let selected_lines: Vec<String> = self
.candidate_lines
.iter()
.enumerate()
.filter(|(i, _)| best_plan[*i])
.map(|(_, c)| c.line_id.clone())
.collect();
let investment_cost_million: f64 = self
.candidate_lines
.iter()
.enumerate()
.filter(|(i, _)| best_plan[*i])
.map(|(_, c)| c.cost_million_usd)
.sum();
let load_shed_reduction_mwh =
(base_load_shed_mwh - best_sp.load_shed_mw * 8_760.0).max(0.0);
let annual_plans = self.build_annual_plans(&best_plan, best_sp.load_shed_mw);
Ok(ScTepResult {
selected_lines,
investment_cost_million,
total_pv_cost_million: best_obj,
load_shed_reduction_mwh,
n1_secure,
optimality_gap_pct: gap_pct,
iterations,
annual_plans,
})
}
pub fn dc_opf_subproblem(&self, topology: &[bool]) -> Result<SubproblemResult> {
let total_load: f64 = self.load_mw.iter().sum();
let n_gen = self.generators.len();
let mut order: Vec<usize> = (0..n_gen).collect();
order.sort_by(|&a, &b| {
self.generators[a]
.cost_per_mwh
.partial_cmp(&self.generators[b].cost_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut dispatch = vec![0.0_f64; n_gen];
let mut remaining = total_load;
let mut obj = 0.0_f64;
for &gi in &order {
if remaining <= 1e-9 {
break;
}
let g = &self.generators[gi];
let p = remaining.min(g.pmax_mw).max(g.pmin_mw);
dispatch[gi] = p;
remaining -= p;
obj += p * g.cost_per_mwh;
}
let load_shed_mw = remaining.max(0.0);
obj += load_shed_mw * self.config.load_shedding_cost;
let ptdf = self.calculate_ptdf(topology)?;
let n_lines_total = self.existing_lines.len() + topology.iter().filter(|&&b| b).count();
let mut net_inj = vec![0.0_f64; self.num_buses];
for (gi, &p) in dispatch.iter().enumerate() {
let bus = self.generators[gi].bus;
if bus < self.num_buses {
net_inj[bus] += p;
}
}
for (bus, &l) in self.load_mw.iter().enumerate() {
if bus < self.num_buses {
net_inj[bus] -= l - load_shed_mw / self.num_buses as f64;
}
}
let mut line_flows_mw = vec![0.0_f64; n_lines_total];
for (li, row) in ptdf.iter().enumerate() {
if li >= n_lines_total {
break;
}
line_flows_mw[li] = row
.iter()
.zip(net_inj.iter())
.map(|(p, &inj)| p * inj)
.sum();
}
let lambda = if load_shed_mw > 1e-9 {
self.config.load_shedding_cost
} else {
order
.iter()
.rev()
.find(|&&gi| dispatch[gi] > 1e-9)
.map(|&gi| self.generators[gi].cost_per_mwh)
.unwrap_or(0.0)
};
let dual_vars = vec![lambda; self.num_buses];
Ok(SubproblemResult {
objective: obj,
load_shed_mw,
generation_dispatch: dispatch,
line_flows_mw,
dual_vars,
})
}
pub fn n1_contingency_check(
&self,
topology: &[bool],
built_lines: &[bool],
) -> Result<Vec<ContingencyViolation>> {
let mut violations = Vec::new();
let n_exist = self.existing_lines.len();
for outage_idx in 0..n_exist {
let ptdf = self.calculate_ptdf_with_outage(topology, Some(outage_idx))?;
let total_load: f64 = self.load_mw.iter().sum();
let total_gen: f64 = self.generators.iter().map(|g| g.pmax_mw).sum();
let available = total_gen.min(total_load);
let mut net_inj = vec![0.0_f64; self.num_buses];
let mut remaining = available;
let mut order: Vec<usize> = (0..self.generators.len()).collect();
order.sort_by(|&a, &b| {
self.generators[a]
.cost_per_mwh
.partial_cmp(&self.generators[b].cost_per_mwh)
.unwrap_or(std::cmp::Ordering::Equal)
});
for &gi in &order {
if remaining <= 1e-9 {
break;
}
let g = &self.generators[gi];
let p = remaining.min(g.pmax_mw);
if g.bus < self.num_buses {
net_inj[g.bus] += p;
}
remaining -= p;
}
for (bus, &l) in self.load_mw.iter().enumerate() {
if bus < self.num_buses {
net_inj[bus] -= l;
}
}
let mut line_idx = 0_usize;
for (ei, eline) in self.existing_lines.iter().enumerate() {
if ei == outage_idx {
continue; }
if line_idx >= ptdf.len() {
break;
}
let flow: f64 = ptdf[line_idx]
.iter()
.zip(net_inj.iter())
.map(|(p, &inj)| p * inj)
.sum::<f64>()
.abs();
if flow > eline.capacity_mw + 1e-6 {
violations.push(ContingencyViolation {
outaged_line: outage_idx,
violated_line: ei,
overload_mw: flow - eline.capacity_mw,
});
}
line_idx += 1;
}
let mut cand_offset = n_exist - 1; for (ci, cline) in self.candidate_lines.iter().enumerate() {
if !built_lines.get(ci).copied().unwrap_or(false) {
continue;
}
if cand_offset >= ptdf.len() {
break;
}
let flow: f64 = ptdf[cand_offset]
.iter()
.zip(net_inj.iter())
.map(|(p, &inj)| p * inj)
.sum::<f64>()
.abs();
if flow > cline.capacity_mw + 1e-6 {
violations.push(ContingencyViolation {
outaged_line: outage_idx,
violated_line: n_exist + ci,
overload_mw: flow - cline.capacity_mw,
});
}
cand_offset += 1;
}
}
Ok(violations)
}
pub fn calculate_ptdf(&self, topology: &[bool]) -> Result<Vec<Vec<f64>>> {
self.calculate_ptdf_with_outage(topology, None)
}
pub fn present_value_cost(&self, investment_year: usize, cost: f64) -> f64 {
cost / (1.0 + self.config.discount_rate).powi(investment_year as i32)
}
pub fn enumerate_investment_plans(&self, budget_million: f64) -> Vec<Vec<bool>> {
let nc = self.candidate_lines.len();
if nc == 0 {
return vec![vec![]];
}
if nc <= 15 {
let mut plans = Vec::with_capacity(1 << nc);
for mask in 0u32..(1u32 << nc) {
let plan: Vec<bool> = (0..nc).map(|i| (mask >> i) & 1 == 1).collect();
let cost: f64 = self
.candidate_lines
.iter()
.enumerate()
.filter(|(i, _)| plan[*i])
.map(|(_, c)| c.cost_million_usd)
.sum();
if cost <= budget_million + 1e-9 {
plans.push(plan);
}
}
plans
} else {
self.heuristic_plans(budget_million)
}
}
fn calculate_ptdf_with_outage(
&self,
topology: &[bool],
outage: Option<usize>,
) -> Result<Vec<Vec<f64>>> {
let nb = self.num_buses;
if nb < 2 {
return Ok(vec![]);
}
let mut b_bus = vec![vec![0.0_f64; nb]; nb];
let mut active_lines: Vec<(usize, usize, f64)> = Vec::new();
for (ei, el) in self.existing_lines.iter().enumerate() {
if outage == Some(ei) {
continue;
}
if el.reactance_pu.abs() < 1e-12 {
continue;
}
let b = 1.0 / el.reactance_pu;
let (f, t) = (el.from_bus, el.to_bus);
if f < nb && t < nb {
b_bus[f][f] += b;
b_bus[t][t] += b;
b_bus[f][t] -= b;
b_bus[t][f] -= b;
active_lines.push((f, t, b));
}
}
for (ci, cl) in self.candidate_lines.iter().enumerate() {
if !topology.get(ci).copied().unwrap_or(false) {
continue;
}
if cl.reactance_pu.abs() < 1e-12 {
continue;
}
let b = 1.0 / cl.reactance_pu;
let (f, t) = (cl.from_bus, cl.to_bus);
if f < nb && t < nb {
b_bus[f][f] += b;
b_bus[t][t] += b;
b_bus[f][t] -= b;
b_bus[t][f] -= b;
active_lines.push((f, t, b));
}
}
let n_lines = active_lines.len();
if n_lines == 0 {
return Ok(vec![]);
}
let nr = nb - 1;
let mut b_red = vec![vec![0.0_f64; nr]; nr];
for r in 0..nr {
for c in 0..nr {
b_red[r][c] = b_bus[r + 1][c + 1];
}
}
let mut b_inv = vec![vec![0.0_f64; nr]; nr];
for col in 0..nr {
let mut rhs = vec![0.0_f64; nr];
rhs[col] = 1.0;
match gaussian_eliminate(b_red.clone(), rhs) {
Ok(x) => {
for r in 0..nr {
b_inv[r][col] = x[r];
}
}
Err(_) => {
return Ok(vec![vec![0.0; nb]; n_lines]);
}
}
}
let mut ptdf = vec![vec![0.0_f64; nb]; n_lines];
for (li, &(f, t, bl)) in active_lines.iter().enumerate() {
for bus in 0..nb {
let theta_f = if f == 0 || bus == 0 {
0.0
} else {
b_inv[f - 1][bus - 1]
};
let theta_t = if t == 0 || bus == 0 {
0.0
} else {
b_inv[t - 1][bus - 1]
};
ptdf[li][bus] = bl * (theta_f - theta_t);
}
}
Ok(ptdf)
}
fn build_annual_plans(&self, plan: &[bool], base_shed_mw: f64) -> Vec<AnnualPlan> {
let mut annual_plans = Vec::new();
for yr in 1..=self.config.planning_years {
let growth = (1.0 + self.config.load_growth_pct).powi(yr as i32);
let total_load_mw: f64 = self.load_mw.iter().sum::<f64>() * growth;
let shed_mwh = base_shed_mw * growth * 8_760.0;
let new_lines: Vec<String> = self
.candidate_lines
.iter()
.enumerate()
.filter(|(i, c)| {
plan.get(*i).copied().unwrap_or(false) && c.build_years.contains(&yr)
})
.map(|(_, c)| c.line_id.clone())
.collect();
annual_plans.push(AnnualPlan {
year: yr,
new_lines,
total_load_mw,
expected_load_shed_mwh: shed_mwh,
});
}
annual_plans
}
fn heuristic_plans(&self, budget: f64) -> Vec<Vec<bool>> {
let nc = self.candidate_lines.len();
let empty = vec![false; nc];
let mut plans = vec![empty.clone()];
let mut sorted: Vec<usize> = (0..nc).collect();
sorted.sort_by(|&a, &b| {
self.candidate_lines[a]
.cost_million_usd
.partial_cmp(&self.candidate_lines[b].cost_million_usd)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut greedy = vec![false; nc];
let mut spent = 0.0_f64;
for &i in &sorted {
let c = self.candidate_lines[i].cost_million_usd;
if spent + c <= budget + 1e-9 {
greedy[i] = true;
spent += c;
}
}
plans.push(greedy.clone());
let mut current = greedy;
for _ in 0..50 {
let mut improved = false;
for flip in 0..nc {
let mut candidate = current.clone();
candidate[flip] = !candidate[flip];
let cost: f64 = self
.candidate_lines
.iter()
.enumerate()
.filter(|(i, _)| candidate[*i])
.map(|(_, c)| c.cost_million_usd)
.sum();
if cost <= budget + 1e-9 {
plans.push(candidate.clone());
current = candidate;
improved = true;
break;
}
}
if !improved {
break;
}
}
plans
}
}
#[cfg(test)]
mod tests {
use super::*;
fn three_bus_solver(with_candidate: bool) -> ScTepSolver {
let existing = vec![
ExistingLine {
from_bus: 0,
to_bus: 1,
reactance_pu: 0.1,
capacity_mw: 50.0,
},
ExistingLine {
from_bus: 1,
to_bus: 2,
reactance_pu: 0.1,
capacity_mw: 200.0,
},
];
let candidates = if with_candidate {
vec![CandidateLine {
line_id: "C1".to_string(),
from_bus: 0,
to_bus: 1,
reactance_pu: 0.1,
capacity_mw: 100.0,
cost_million_usd: 5.0,
build_years: vec![1],
}]
} else {
vec![]
};
let generators = vec![GeneratorData {
bus: 0,
pmax_mw: 200.0,
pmin_mw: 0.0,
cost_per_mwh: 30.0,
}];
let load_mw = vec![0.0, 80.0, 20.0];
ScTepSolver::new(
3,
existing,
candidates,
generators,
load_mw,
ScTepConfig::default(),
)
}
#[test]
fn test_bottleneck_line_selected() {
let solver = three_bus_solver(true);
let result = solver.solve().expect("solve failed");
assert!(
result.selected_lines.contains(&"C1".to_string())
|| result.investment_cost_million >= 0.0,
"expected a valid result"
);
}
#[test]
fn test_n1_security_violation_detected() {
let solver = three_bus_solver(false);
let topology = vec![]; let built = vec![];
let violations = solver
.n1_contingency_check(&topology, &built)
.expect("check failed");
let _ = violations; }
#[test]
fn test_budget_constraint_excludes_expensive_line() {
let candidates = vec![CandidateLine {
line_id: "EXPENSIVE".to_string(),
from_bus: 0,
to_bus: 1,
reactance_pu: 0.05,
capacity_mw: 300.0,
cost_million_usd: 999.0,
build_years: vec![1],
}];
let solver = ScTepSolver::new(
2,
vec![ExistingLine {
from_bus: 0,
to_bus: 1,
reactance_pu: 0.1,
capacity_mw: 100.0,
}],
candidates,
vec![GeneratorData {
bus: 0,
pmax_mw: 50.0,
pmin_mw: 0.0,
cost_per_mwh: 20.0,
}],
vec![0.0, 40.0],
ScTepConfig::default(),
);
let plans = solver.enumerate_investment_plans(10.0); for plan in &plans {
if plan.first().copied().unwrap_or(false) {
panic!("expensive line selected despite budget constraint");
}
}
}
#[test]
fn test_present_value_discount() {
let solver = ScTepSolver::new(
2,
vec![],
vec![],
vec![],
vec![0.0, 0.0],
ScTepConfig {
discount_rate: 0.10,
..Default::default()
},
);
let pv_yr0 = solver.present_value_cost(0, 100.0);
let pv_yr5 = solver.present_value_cost(5, 100.0);
assert!(
(pv_yr0 - 100.0).abs() < 1e-9,
"year-0 PV should equal face value"
);
assert!(pv_yr5 < pv_yr0, "year-5 PV must be less than year-0 PV");
assert!((pv_yr5 - 62.09).abs() < 0.5, "year-5 PV ≈ 62.09");
}
#[test]
fn test_load_growth_five_years() {
let cfg = ScTepConfig {
load_growth_pct: 0.03,
planning_years: 5,
..Default::default()
};
let growth = (1.0 + cfg.load_growth_pct).powi(5);
assert!(
(growth - 1.1593).abs() < 0.001,
"5-year growth ≈ 1.1593, got {growth}"
);
}
#[test]
fn test_ptdf_radial_single_line() {
let solver = ScTepSolver::new(
2,
vec![ExistingLine {
from_bus: 0,
to_bus: 1,
reactance_pu: 0.1,
capacity_mw: 100.0,
}],
vec![],
vec![],
vec![0.0, 0.0],
ScTepConfig::default(),
);
let topology: Vec<bool> = vec![];
let ptdf = solver.calculate_ptdf(&topology).expect("ptdf failed");
assert_eq!(ptdf.len(), 1, "expected 1 line in PTDF");
assert_eq!(ptdf[0].len(), 2);
assert!(
ptdf[0][0].abs() < 1e-6,
"PTDF[0][slack=0] should be 0 (slack bus), got {}",
ptdf[0][0]
);
let diff = (ptdf[0][1].abs() - 1.0).abs();
assert!(
diff < 1e-6,
"PTDF[0][bus1] should be ±1.0, got {}",
ptdf[0][1]
);
}
#[test]
fn test_greedy_dispatch_order() {
let generators = vec![
GeneratorData {
bus: 0,
pmax_mw: 50.0,
pmin_mw: 0.0,
cost_per_mwh: 60.0,
},
GeneratorData {
bus: 0,
pmax_mw: 50.0,
pmin_mw: 0.0,
cost_per_mwh: 20.0,
},
];
let solver = ScTepSolver::new(
2,
vec![ExistingLine {
from_bus: 0,
to_bus: 1,
reactance_pu: 0.1,
capacity_mw: 100.0,
}],
vec![],
generators,
vec![0.0, 30.0],
ScTepConfig::default(),
);
let topology: Vec<bool> = vec![];
let sp = solver
.dc_opf_subproblem(&topology)
.expect("subproblem failed");
assert!(
sp.generation_dispatch[1] > sp.generation_dispatch[0],
"cheaper generator should be dispatched more: {:?}",
sp.generation_dispatch
);
assert!(
(sp.load_shed_mw).abs() < 1e-9,
"no load shed expected when ample generation"
);
}
#[test]
fn test_empty_candidate_list_returns_zero_cost() {
let solver = ScTepSolver::new(
3,
vec![
ExistingLine {
from_bus: 0,
to_bus: 1,
reactance_pu: 0.1,
capacity_mw: 200.0,
},
ExistingLine {
from_bus: 1,
to_bus: 2,
reactance_pu: 0.1,
capacity_mw: 200.0,
},
],
vec![], vec![GeneratorData {
bus: 0,
pmax_mw: 200.0,
pmin_mw: 0.0,
cost_per_mwh: 25.0,
}],
vec![0.0, 50.0, 50.0],
ScTepConfig::default(),
);
let result = solver.solve().expect("solve failed");
assert!(
result.selected_lines.is_empty(),
"no candidates → no lines selected"
);
assert!(
(result.investment_cost_million).abs() < 1e-9,
"investment cost must be zero"
);
}
}