use crate::error::OxiGridError;
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
#[derive(Debug, Clone)]
pub struct NetworkEdge {
pub from: usize,
pub to: usize,
pub length_km: f64,
pub voltage_kv: f64,
pub capacity_mw: f64,
pub cost_million_eur: f64,
pub resistance_pu: f64,
pub reactance_pu: f64,
pub is_existing: bool,
pub build_years: f64,
}
impl NetworkEdge {
pub fn susceptance_pu(&self) -> f64 {
if self.reactance_pu.abs() < 1e-12 {
0.0
} else {
1.0 / self.reactance_pu
}
}
}
#[derive(Debug, Clone)]
pub struct TopologyNode {
pub id: usize,
pub is_terminal: bool,
pub is_substation: bool,
pub peak_load_mw: f64,
pub peak_generation_mw: f64,
pub x: f64,
pub y: f64,
}
pub struct SteinerTreeSolver {
pub nodes: Vec<TopologyNode>,
pub edges: Vec<NetworkEdge>,
}
#[derive(Debug, Clone)]
pub struct SteinerTreeResult {
pub selected_edges: Vec<usize>,
pub total_cost_million_eur: f64,
pub total_length_km: f64,
pub is_connected: bool,
pub radial: bool,
}
#[derive(Debug, Clone, PartialEq)]
struct DijkState {
cost: f64,
node: usize,
}
impl Eq for DijkState {}
impl Ord for DijkState {
fn cmp(&self, other: &Self) -> Ordering {
other
.cost
.partial_cmp(&self.cost)
.unwrap_or(Ordering::Equal)
}
}
impl PartialOrd for DijkState {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl SteinerTreeSolver {
pub fn new(nodes: Vec<TopologyNode>, edges: Vec<NetworkEdge>) -> Self {
Self { nodes, edges }
}
pub fn solve_approximate(&self) -> Result<SteinerTreeResult, OxiGridError> {
let n_nodes = self.nodes.len();
if n_nodes == 0 {
return Err(OxiGridError::InvalidNetwork("no nodes defined".to_string()));
}
let terminals: Vec<usize> = self
.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.is_terminal)
.map(|(i, _)| i)
.collect();
if terminals.len() < 2 {
return Err(OxiGridError::InvalidNetwork(
"Steiner tree requires at least 2 terminal nodes".to_string(),
));
}
let (dist_matrix, path_matrix) = self.all_pairs_shortest_paths(&terminals)?;
let mst_edges_in_closure = self.mst_of_metric_closure(&terminals, &dist_matrix)?;
let mut selected_set: HashSet<usize> = HashSet::new();
for (ti, tj) in &mst_edges_in_closure {
let path = &path_matrix[*ti][*tj];
for &edge_idx in path {
selected_set.insert(edge_idx);
}
}
let selected_edges = self.prune_steiner_points(selected_set, &terminals, n_nodes);
let total_cost: f64 = selected_edges
.iter()
.map(|&i| self.edges[i].cost_million_eur)
.sum();
let total_length: f64 = selected_edges
.iter()
.map(|&i| self.edges[i].length_km)
.sum();
let connected =
Self::is_connected_fn(n_nodes, &selected_edges, &self.edges, Some(&terminals));
let radial = Self::is_radial(n_nodes, &selected_edges, &self.edges);
Ok(SteinerTreeResult {
selected_edges,
total_cost_million_eur: total_cost,
total_length_km: total_length,
is_connected: connected,
radial,
})
}
pub fn solve_mst_baseline(&self) -> Result<SteinerTreeResult, OxiGridError> {
let n_nodes = self.nodes.len();
if n_nodes == 0 || self.edges.is_empty() {
return Err(OxiGridError::InvalidNetwork(
"no nodes or edges defined".to_string(),
));
}
let mut sorted_indices: Vec<usize> = (0..self.edges.len()).collect();
sorted_indices.sort_by(|&a, &b| {
self.edges[a]
.cost_million_eur
.partial_cmp(&self.edges[b].cost_million_eur)
.unwrap_or(Ordering::Equal)
});
let mut uf = UnionFind::new(n_nodes);
let mut selected = Vec::new();
for &ei in &sorted_indices {
let e = &self.edges[ei];
if e.from >= n_nodes || e.to >= n_nodes {
continue;
}
if uf.find(e.from) != uf.find(e.to) {
uf.union(e.from, e.to);
selected.push(ei);
if selected.len() == n_nodes - 1 {
break;
}
}
}
let total_cost: f64 = selected
.iter()
.map(|&i| self.edges[i].cost_million_eur)
.sum();
let total_length: f64 = selected.iter().map(|&i| self.edges[i].length_km).sum();
let connected = Self::is_connected_fn(n_nodes, &selected, &self.edges, None);
let radial = Self::is_radial(n_nodes, &selected, &self.edges);
Ok(SteinerTreeResult {
selected_edges: selected,
total_cost_million_eur: total_cost,
total_length_km: total_length,
is_connected: connected,
radial,
})
}
pub fn is_connected(n_nodes: usize, selected_edges: &[usize], edges: &[NetworkEdge]) -> bool {
Self::is_connected_fn(n_nodes, selected_edges, edges, None)
}
fn is_connected_fn(
n_nodes: usize,
selected_edges: &[usize],
edges: &[NetworkEdge],
required_nodes: Option<&[usize]>,
) -> bool {
if n_nodes == 0 {
return true;
}
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n_nodes];
for &ei in selected_edges {
if ei < edges.len() {
let e = &edges[ei];
if e.from < n_nodes && e.to < n_nodes {
adj[e.from].push(e.to);
adj[e.to].push(e.from);
}
}
}
let start = required_nodes.and_then(|r| r.first()).copied().unwrap_or(0);
let mut visited = vec![false; n_nodes];
let mut queue = VecDeque::new();
if start < n_nodes {
queue.push_back(start);
visited[start] = true;
}
while let Some(node) = queue.pop_front() {
for &nb in &adj[node] {
if !visited[nb] {
visited[nb] = true;
queue.push_back(nb);
}
}
}
match required_nodes {
Some(req) => req.iter().all(|&r| r < n_nodes && visited[r]),
None => visited.iter().all(|&v| v),
}
}
fn is_radial(n_nodes: usize, selected_edges: &[usize], edges: &[NetworkEdge]) -> bool {
if n_nodes == 0 {
return true;
}
let relevant: Vec<usize> = selected_edges
.iter()
.filter(|&&ei| ei < edges.len() && edges[ei].from < n_nodes && edges[ei].to < n_nodes)
.copied()
.collect();
if relevant.len() != n_nodes.saturating_sub(1) {
return false;
}
Self::is_connected_fn(n_nodes, &relevant, edges, None)
}
fn dijkstra(&self, source: usize) -> (Vec<f64>, Vec<Option<usize>>) {
let n = self.nodes.len();
let mut dist = vec![f64::INFINITY; n];
let mut pred_edge: Vec<Option<usize>> = vec![None; n];
dist[source] = 0.0;
let mut adj: Vec<Vec<(usize, usize, f64)>> = vec![Vec::new(); n];
for (ei, e) in self.edges.iter().enumerate() {
if e.from < n && e.to < n {
adj[e.from].push((ei, e.to, e.cost_million_eur));
adj[e.to].push((ei, e.from, e.cost_million_eur));
}
}
let mut heap = BinaryHeap::new();
heap.push(DijkState {
cost: 0.0,
node: source,
});
while let Some(DijkState { cost, node }) = heap.pop() {
if cost > dist[node] + 1e-12 {
continue;
}
for &(ei, nb, w) in &adj[node] {
let new_cost = dist[node] + w;
if new_cost < dist[nb] - 1e-12 {
dist[nb] = new_cost;
pred_edge[nb] = Some(ei);
heap.push(DijkState {
cost: new_cost,
node: nb,
});
}
}
}
(dist, pred_edge)
}
fn reconstruct_path(&self, target: usize, pred_edge: &[Option<usize>]) -> Vec<usize> {
let mut path = Vec::new();
let mut cur = target;
let mut seen = HashSet::new();
loop {
if seen.contains(&cur) {
break; }
seen.insert(cur);
match pred_edge.get(cur).and_then(|e| *e) {
None => break,
Some(ei) => {
path.push(ei);
let e = &self.edges[ei];
cur = if e.to == cur { e.from } else { e.to };
}
}
}
path
}
#[allow(clippy::type_complexity)]
fn all_pairs_shortest_paths(
&self,
terminals: &[usize],
) -> Result<(Vec<Vec<f64>>, Vec<Vec<Vec<usize>>>), OxiGridError> {
let nt = terminals.len();
let mut dist_matrix = vec![vec![f64::INFINITY; nt]; nt];
let mut path_matrix = vec![vec![Vec::new(); nt]; nt];
for (ti, &t_node) in terminals.iter().enumerate() {
let (dist, pred) = self.dijkstra(t_node);
dist_matrix[ti][ti] = 0.0;
for (tj, &t2_node) in terminals.iter().enumerate() {
if ti == tj {
continue;
}
if dist[t2_node].is_infinite() {
dist_matrix[ti][tj] = f64::INFINITY;
} else {
dist_matrix[ti][tj] = dist[t2_node];
path_matrix[ti][tj] = self.reconstruct_path(t2_node, &pred);
}
}
}
for tj in 1..nt {
if dist_matrix[0][tj].is_infinite() {
return Err(OxiGridError::InvalidNetwork(format!(
"terminal node {} is unreachable from terminal node {} (disconnected graph)",
terminals[tj], terminals[0]
)));
}
}
Ok((dist_matrix, path_matrix))
}
fn mst_of_metric_closure(
&self,
terminals: &[usize],
dist_matrix: &[Vec<f64>],
) -> Result<Vec<(usize, usize)>, OxiGridError> {
let nt = terminals.len();
let mut closure_edges: Vec<(f64, usize, usize)> = Vec::new();
#[allow(clippy::needless_range_loop)]
for ti in 0..nt {
for tj in (ti + 1)..nt {
let d = dist_matrix[ti][tj];
if d.is_finite() {
closure_edges.push((d, ti, tj));
}
}
}
closure_edges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
let mut uf = UnionFind::new(nt);
let mut mst_pairs = Vec::new();
for (_, ti, tj) in &closure_edges {
if uf.find(*ti) != uf.find(*tj) {
uf.union(*ti, *tj);
mst_pairs.push((*ti, *tj));
if mst_pairs.len() == nt - 1 {
break;
}
}
}
if mst_pairs.len() < nt - 1 {
return Err(OxiGridError::InvalidNetwork(
"cannot form spanning tree over terminals — graph is disconnected".to_string(),
));
}
Ok(mst_pairs)
}
fn prune_steiner_points(
&self,
mut selected_set: HashSet<usize>,
terminals: &[usize],
n_nodes: usize,
) -> Vec<usize> {
let terminal_set: HashSet<usize> = terminals.iter().copied().collect();
loop {
let mut degree: HashMap<usize, usize> = HashMap::new();
for &ei in &selected_set {
if ei < self.edges.len() {
let e = &self.edges[ei];
if e.from < n_nodes && e.to < n_nodes {
*degree.entry(e.from).or_insert(0) += 1;
*degree.entry(e.to).or_insert(0) += 1;
}
}
}
let leaf = degree
.iter()
.find(|(&node, °)| deg <= 1 && !terminal_set.contains(&node))
.map(|(&node, _)| node);
match leaf {
None => break,
Some(leaf_node) => {
selected_set.retain(|&ei| {
if ei >= self.edges.len() {
return true;
}
let e = &self.edges[ei];
e.from != leaf_node && e.to != leaf_node
});
}
}
}
let mut result: Vec<usize> = selected_set.into_iter().collect();
result.sort_unstable();
result
}
}
struct UnionFind {
parent: Vec<usize>,
rank: Vec<usize>,
}
impl UnionFind {
fn new(n: usize) -> Self {
Self {
parent: (0..n).collect(),
rank: vec![0; n],
}
}
fn find(&mut self, x: usize) -> usize {
if self.parent[x] != x {
self.parent[x] = self.find(self.parent[x]);
}
self.parent[x]
}
fn union(&mut self, a: usize, b: usize) {
let ra = self.find(a);
let rb = self.find(b);
if ra == rb {
return;
}
match self.rank[ra].cmp(&self.rank[rb]) {
Ordering::Less => self.parent[ra] = rb,
Ordering::Greater => self.parent[rb] = ra,
Ordering::Equal => {
self.parent[rb] = ra;
self.rank[ra] += 1;
}
}
}
}
pub struct ExpansionPlanner {
pub existing_network: Vec<NetworkEdge>,
pub candidate_lines: Vec<NetworkEdge>,
pub nodes: Vec<TopologyNode>,
pub planning_years: usize,
pub discount_rate: f64,
pub n_scenarios: usize,
pub load_growth_rate: f64,
}
#[derive(Debug, Clone)]
pub struct ExpansionCandidate {
pub line_idx: usize,
pub build_year: usize,
pub npv_benefit_million_eur: f64,
pub npv_cost_million_eur: f64,
pub bcr: f64,
pub congestion_relief_mw: f64,
}
#[derive(Debug, Clone)]
pub struct ExpansionPlan {
pub investments: Vec<ExpansionCandidate>,
pub total_cost_million_eur: f64,
pub total_benefit_million_eur: f64,
pub total_npv_million_eur: f64,
pub years_to_capacity_limit: Vec<f64>,
}
const VOLL_MILLION_EUR_PER_MWH: f64 = 0.010; const OPERATING_HOURS_PER_YEAR: f64 = 8_760.0;
impl ExpansionPlanner {
pub fn new(
existing_network: Vec<NetworkEdge>,
candidate_lines: Vec<NetworkEdge>,
nodes: Vec<TopologyNode>,
planning_years: usize,
discount_rate: f64,
n_scenarios: usize,
load_growth_rate: f64,
) -> Self {
Self {
existing_network,
candidate_lines,
nodes,
planning_years,
discount_rate,
n_scenarios,
load_growth_rate,
}
}
pub fn optimize_greedy(&self, budget_million_eur: f64) -> Result<ExpansionPlan, OxiGridError> {
if self.candidate_lines.is_empty() {
return Err(OxiGridError::InvalidNetwork(
"no candidate lines defined".to_string(),
));
}
if self.planning_years == 0 {
return Err(OxiGridError::InvalidParameter(
"planning_years must be > 0".to_string(),
));
}
if budget_million_eur < 0.0 {
return Err(OxiGridError::InvalidParameter(
"budget must be non-negative".to_string(),
));
}
let mut spent = 0.0_f64;
let mut selected: Vec<ExpansionCandidate> = Vec::new();
let mut built_set: HashSet<usize> = HashSet::new();
for year in 0..self.planning_years {
if spent >= budget_million_eur - 1e-9 {
break;
}
let growth_factor = (1.0 + self.load_growth_rate).powi(year as i32);
let discount_factor = self.npv_discount_factor(year);
let mut candidates_this_year: Vec<ExpansionCandidate> = self
.candidate_lines
.iter()
.enumerate()
.filter(|(idx, _)| !built_set.contains(idx))
.map(|(idx, line)| {
let ptdf = Self::compute_ptdf_entry(line.from, line.to, &self.existing_network);
let relief_mw = line.capacity_mw * ptdf.abs() * growth_factor;
let benefit_per_year =
relief_mw * OPERATING_HOURS_PER_YEAR * VOLL_MILLION_EUR_PER_MWH * 0.01; let npv_benefit = benefit_per_year * self.annuity_factor() * discount_factor;
let npv_cost = line.cost_million_eur * discount_factor;
let bcr = if npv_cost > 1e-9 {
npv_benefit / npv_cost
} else {
0.0
};
ExpansionCandidate {
line_idx: idx,
build_year: year,
npv_benefit_million_eur: npv_benefit,
npv_cost_million_eur: npv_cost,
bcr,
congestion_relief_mw: relief_mw,
}
})
.filter(|c| c.bcr > 0.0)
.collect();
candidates_this_year
.sort_by(|a, b| b.bcr.partial_cmp(&a.bcr).unwrap_or(Ordering::Equal));
for cand in candidates_this_year {
let remaining = budget_million_eur - spent;
if cand.npv_cost_million_eur > remaining + 1e-9 {
continue;
}
spent += cand.npv_cost_million_eur;
built_set.insert(cand.line_idx);
selected.push(cand);
if spent >= budget_million_eur - 1e-9 {
break;
}
}
}
let total_cost: f64 = selected.iter().map(|c| c.npv_cost_million_eur).sum();
let total_benefit: f64 = selected.iter().map(|c| c.npv_benefit_million_eur).sum();
let years_to_limit = self.estimate_years_to_capacity_limit();
Ok(ExpansionPlan {
investments: selected,
total_cost_million_eur: total_cost,
total_benefit_million_eur: total_benefit,
total_npv_million_eur: total_benefit - total_cost,
years_to_capacity_limit: years_to_limit,
})
}
pub fn compute_ptdf_entry(from: usize, to: usize, network: &[NetworkEdge]) -> f64 {
let b_system: f64 = network
.iter()
.filter(|e| e.from == from || e.to == from || e.from == to || e.to == to)
.map(|e| e.susceptance_pu())
.sum();
if b_system < 1e-12 {
1.0 } else {
b_system / (b_system + b_system) }
}
pub fn check_feasibility(&self, selected: &[usize]) -> bool {
let total_load: f64 = self.nodes.iter().map(|n| n.peak_load_mw).sum();
let mut all_lines: Vec<&NetworkEdge> = self.existing_network.iter().collect();
for &si in selected {
if si < self.candidate_lines.len() {
all_lines.push(&self.candidate_lines[si]);
}
}
if all_lines.is_empty() {
return total_load < 1e-9;
}
let total_capacity: f64 = all_lines.iter().map(|e| e.capacity_mw).sum();
total_capacity >= total_load
}
pub fn n1_security_check(&self, plan: &ExpansionPlan) -> Vec<(usize, bool)> {
plan.investments
.iter()
.map(|inv| {
let others: Vec<usize> = plan
.investments
.iter()
.filter(|c| c.line_idx != inv.line_idx)
.map(|c| c.line_idx)
.collect();
let secure = self.check_feasibility(&others);
(inv.line_idx, secure)
})
.collect()
}
fn npv_discount_factor(&self, year: usize) -> f64 {
(1.0 + self.discount_rate).powi(-(year as i32))
}
fn annuity_factor(&self) -> f64 {
let r = self.discount_rate;
let n = self.planning_years as f64;
if r.abs() < 1e-12 {
n
} else {
(1.0 - (1.0 + r).powf(-n)) / r
}
}
fn estimate_years_to_capacity_limit(&self) -> Vec<f64> {
self.nodes
.iter()
.map(|node| {
if node.peak_load_mw < 1e-9 || self.load_growth_rate < 1e-9 {
return f64::INFINITY;
}
let cap: f64 = self
.existing_network
.iter()
.filter(|e| e.from == node.id || e.to == node.id)
.map(|e| e.capacity_mw)
.sum::<f64>()
.max(100.0); let ratio = cap / node.peak_load_mw;
if ratio <= 1.0 {
0.0
} else {
ratio.ln() / (1.0 + self.load_growth_rate).ln()
}
})
.collect()
}
}
pub struct SubstationSiting {
pub load_points: Vec<(f64, f64, f64)>,
pub n_substations: usize,
pub voltage_kv: f64,
pub cable_cost_million_eur_per_km: f64,
}
#[derive(Debug, Clone)]
pub struct SitingResult {
pub substation_locations: Vec<(f64, f64)>,
pub assignments: Vec<usize>,
pub total_cable_cost_million_eur: f64,
pub max_feeder_length_km: f64,
pub avg_feeder_length_km: f64,
}
impl SubstationSiting {
pub fn new(
load_points: Vec<(f64, f64, f64)>,
n_substations: usize,
voltage_kv: f64,
cable_cost_million_eur_per_km: f64,
) -> Self {
Self {
load_points,
n_substations,
voltage_kv,
cable_cost_million_eur_per_km,
}
}
pub fn optimize_kmeans(&self, max_iter: usize) -> Result<SitingResult, OxiGridError> {
let n = self.load_points.len();
let k = self.n_substations;
if n == 0 {
return Err(OxiGridError::InvalidNetwork(
"no load points defined".to_string(),
));
}
if k == 0 {
return Err(OxiGridError::InvalidParameter(
"n_substations must be > 0".to_string(),
));
}
if k > n {
return Err(OxiGridError::InvalidParameter(format!(
"n_substations ({k}) exceeds number of load points ({n})"
)));
}
let mut sorted_indices: Vec<usize> = (0..n).collect();
sorted_indices.sort_by(|&a, &b| {
self.load_points[a]
.0
.partial_cmp(&self.load_points[b].0)
.unwrap_or(Ordering::Equal)
});
let mut centroids: Vec<(f64, f64)> = (0..k)
.map(|seg| {
let start = seg * n / k;
let end = ((seg + 1) * n / k).min(n);
let seg_points: Vec<(f64, f64, f64)> = sorted_indices[start..end]
.iter()
.map(|&i| self.load_points[i])
.collect();
Self::load_weighted_centroid(&seg_points)
})
.collect();
let mut assignments = vec![0usize; n];
for _iter in 0..max_iter {
#[allow(clippy::needless_range_loop)]
for i in 0..n {
let (px, py, _) = self.load_points[i];
let nearest = centroids
.iter()
.enumerate()
.min_by(|(_, &c1), (_, &c2)| {
Self::euclidean_distance((px, py), c1)
.partial_cmp(&Self::euclidean_distance((px, py), c2))
.unwrap_or(Ordering::Equal)
})
.map(|(idx, _)| idx)
.unwrap_or(0);
assignments[i] = nearest;
}
let mut new_centroids: Vec<(f64, f64)> = Vec::with_capacity(k);
let mut converged = true;
#[allow(clippy::needless_range_loop)]
for ci in 0..k {
let cluster_points: Vec<(f64, f64, f64)> = (0..n)
.filter(|&i| assignments[i] == ci)
.map(|i| self.load_points[i])
.collect();
let new_c = if cluster_points.is_empty() {
centroids[ci] } else {
Self::load_weighted_centroid(&cluster_points)
};
let shift = Self::euclidean_distance(centroids[ci], new_c);
if shift > 0.01 {
converged = false;
}
new_centroids.push(new_c);
}
centroids = new_centroids;
if converged {
break;
}
}
let distances: Vec<f64> = (0..n)
.map(|i| {
let (px, py, _) = self.load_points[i];
Self::euclidean_distance((px, py), centroids[assignments[i]])
})
.collect();
let total_cable_len: f64 = distances.iter().sum();
let max_len = distances.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let avg_len = if n > 0 {
total_cable_len / n as f64
} else {
0.0
};
let total_cost = total_cable_len * self.cable_cost_million_eur_per_km;
Ok(SitingResult {
substation_locations: centroids,
assignments,
total_cable_cost_million_eur: total_cost,
max_feeder_length_km: max_len.max(0.0),
avg_feeder_length_km: avg_len,
})
}
pub fn euclidean_distance(p1: (f64, f64), p2: (f64, f64)) -> f64 {
let dx = p1.0 - p2.0;
let dy = p1.1 - p2.1;
(dx * dx + dy * dy).sqrt()
}
pub fn load_weighted_centroid(points: &[(f64, f64, f64)]) -> (f64, f64) {
let total_w: f64 = points.iter().map(|(_, _, w)| w).sum();
if total_w < 1e-12 {
let n = points.len() as f64;
if n < 1e-12 {
return (0.0, 0.0);
}
let x = points.iter().map(|(x, _, _)| x).sum::<f64>() / n;
let y = points.iter().map(|(_, y, _)| y).sum::<f64>() / n;
return (x, y);
}
let x = points.iter().map(|(xi, _, wi)| xi * wi).sum::<f64>() / total_w;
let y = points.iter().map(|(_, yi, wi)| yi * wi).sum::<f64>() / total_w;
(x, y)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_f64(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005u64)
.wrapping_add(1_442_695_040_888_963_407u64);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
fn next_range(&mut self, lo: f64, hi: f64) -> f64 {
lo + self.next_f64() * (hi - lo)
}
}
#[test]
fn test_network_edge_creation() {
let edge = NetworkEdge {
from: 0,
to: 1,
length_km: 50.0,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 5.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 3.0,
};
assert_eq!(edge.from, 0);
assert_eq!(edge.to, 1);
assert!((edge.length_km - 50.0).abs() < 1e-9);
assert!((edge.susceptance_pu() - 20.0).abs() < 1e-6);
}
#[test]
fn test_topology_node_creation() {
let node = TopologyNode {
id: 3,
is_terminal: true,
is_substation: false,
peak_load_mw: 150.0,
peak_generation_mw: 0.0,
x: 10.0,
y: 20.0,
};
assert_eq!(node.id, 3);
assert!(node.is_terminal);
assert!(!node.is_substation);
assert!((node.peak_load_mw - 150.0).abs() < 1e-9);
}
fn make_triangle() -> SteinerTreeSolver {
let nodes = vec![
TopologyNode {
id: 0,
is_terminal: true,
is_substation: false,
peak_load_mw: 100.0,
peak_generation_mw: 0.0,
x: 0.0,
y: 0.0,
},
TopologyNode {
id: 1,
is_terminal: true,
is_substation: false,
peak_load_mw: 80.0,
peak_generation_mw: 0.0,
x: 10.0,
y: 0.0,
},
TopologyNode {
id: 2,
is_terminal: true,
is_substation: false,
peak_load_mw: 60.0,
peak_generation_mw: 0.0,
x: 5.0,
y: 8.0,
},
];
let edges = vec![
NetworkEdge {
from: 0,
to: 1,
length_km: 10.0,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
NetworkEdge {
from: 1,
to: 2,
length_km: 9.4,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 1.5,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
NetworkEdge {
from: 0,
to: 2,
length_km: 9.4,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 2.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
];
SteinerTreeSolver::new(nodes, edges)
}
#[test]
fn test_steiner_tree_3_terminals() {
let solver = make_triangle();
let result = solver.solve_approximate().expect("should succeed");
assert_eq!(
result.selected_edges.len(),
2,
"3-terminal Steiner tree needs exactly 2 edges"
);
assert!(result.is_connected, "result must be connected");
}
#[test]
fn test_steiner_tree_4_terminals() {
let nodes = vec![
TopologyNode {
id: 0,
is_terminal: true,
is_substation: false,
peak_load_mw: 50.0,
peak_generation_mw: 0.0,
x: 0.0,
y: 0.0,
},
TopologyNode {
id: 1,
is_terminal: true,
is_substation: false,
peak_load_mw: 50.0,
peak_generation_mw: 0.0,
x: 10.0,
y: 0.0,
},
TopologyNode {
id: 2,
is_terminal: true,
is_substation: false,
peak_load_mw: 50.0,
peak_generation_mw: 0.0,
x: 10.0,
y: 10.0,
},
TopologyNode {
id: 3,
is_terminal: true,
is_substation: false,
peak_load_mw: 50.0,
peak_generation_mw: 0.0,
x: 0.0,
y: 10.0,
},
TopologyNode {
id: 4,
is_terminal: false,
is_substation: true,
peak_load_mw: 0.0,
peak_generation_mw: 0.0,
x: 5.0,
y: 5.0,
},
];
let edges = vec![
NetworkEdge {
from: 0,
to: 4,
length_km: 7.07,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
NetworkEdge {
from: 1,
to: 4,
length_km: 7.07,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
NetworkEdge {
from: 2,
to: 4,
length_km: 7.07,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
NetworkEdge {
from: 3,
to: 4,
length_km: 7.07,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
NetworkEdge {
from: 0,
to: 1,
length_km: 10.0,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 5.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
NetworkEdge {
from: 1,
to: 2,
length_km: 10.0,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 5.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
NetworkEdge {
from: 2,
to: 3,
length_km: 10.0,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 5.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
NetworkEdge {
from: 0,
to: 3,
length_km: 10.0,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 5.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
];
let solver = SteinerTreeSolver::new(nodes, edges);
let result = solver
.solve_approximate()
.expect("4-terminal Steiner should succeed");
assert!(result.is_connected, "4-terminal result must be connected");
assert!(
result.total_cost_million_eur < 20.0,
"cost should be reasonable"
);
}
#[test]
fn test_steiner_tree_connectivity() {
let solver = make_triangle();
let result = solver.solve_approximate().expect("should succeed");
assert!(
result.is_connected,
"Steiner result must connect all terminals"
);
assert!(
SteinerTreeSolver::is_connected(
solver.nodes.len(),
&result.selected_edges,
&solver.edges
),
"public is_connected must confirm result"
);
}
#[test]
fn test_steiner_tree_vs_mst() {
let solver = make_triangle();
let steiner = solver.solve_approximate().expect("Steiner ok");
let mst = solver.solve_mst_baseline().expect("MST ok");
assert!(
steiner.total_cost_million_eur <= mst.total_cost_million_eur + 1e-9,
"Steiner cost ({:.3}) must be ≤ MST cost ({:.3})",
steiner.total_cost_million_eur,
mst.total_cost_million_eur
);
}
#[test]
fn test_mst_baseline() {
let solver = make_triangle();
let mst = solver.solve_mst_baseline().expect("MST should succeed");
assert_eq!(mst.selected_edges.len(), 2);
assert!(mst.total_cost_million_eur > 0.0);
}
#[test]
fn test_is_connected_true() {
let edges = vec![
NetworkEdge {
from: 0,
to: 1,
length_km: 5.0,
voltage_kv: 110.0,
capacity_mw: 100.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: true,
build_years: 0.0,
},
NetworkEdge {
from: 1,
to: 2,
length_km: 5.0,
voltage_kv: 110.0,
capacity_mw: 100.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: true,
build_years: 0.0,
},
];
assert!(SteinerTreeSolver::is_connected(3, &[0, 1], &edges));
}
#[test]
fn test_is_connected_false() {
let edges = vec![
NetworkEdge {
from: 0,
to: 1,
length_km: 5.0,
voltage_kv: 110.0,
capacity_mw: 100.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: true,
build_years: 0.0,
},
NetworkEdge {
from: 2,
to: 3,
length_km: 5.0,
voltage_kv: 110.0,
capacity_mw: 100.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: true,
build_years: 0.0,
},
];
assert!(!SteinerTreeSolver::is_connected(4, &[0, 1], &edges));
}
fn make_planner_single() -> ExpansionPlanner {
let existing = vec![NetworkEdge {
from: 0,
to: 1,
length_km: 20.0,
voltage_kv: 220.0,
capacity_mw: 300.0,
cost_million_eur: 0.0,
resistance_pu: 0.005,
reactance_pu: 0.02,
is_existing: true,
build_years: 0.0,
}];
let candidate = vec![NetworkEdge {
from: 1,
to: 2,
length_km: 30.0,
voltage_kv: 220.0,
capacity_mw: 250.0,
cost_million_eur: 10.0,
resistance_pu: 0.008,
reactance_pu: 0.03,
is_existing: false,
build_years: 2.0,
}];
let nodes = vec![
TopologyNode {
id: 0,
is_terminal: true,
is_substation: true,
peak_load_mw: 0.0,
peak_generation_mw: 500.0,
x: 0.0,
y: 0.0,
},
TopologyNode {
id: 1,
is_terminal: true,
is_substation: true,
peak_load_mw: 200.0,
peak_generation_mw: 0.0,
x: 20.0,
y: 0.0,
},
TopologyNode {
id: 2,
is_terminal: true,
is_substation: false,
peak_load_mw: 150.0,
peak_generation_mw: 0.0,
x: 50.0,
y: 0.0,
},
];
ExpansionPlanner::new(existing, candidate, nodes, 10, 0.07, 5, 0.02)
}
#[test]
fn test_expansion_planner_single_candidate() {
let planner = make_planner_single();
let plan = planner.optimize_greedy(100.0).expect("should succeed");
assert!(
!plan.investments.is_empty(),
"should select at least one line"
);
assert!(plan.total_cost_million_eur > 0.0);
assert!(plan.total_npv_million_eur.is_finite());
}
#[test]
fn test_expansion_planner_budget_constraint() {
let planner = make_planner_single();
let plan = planner.optimize_greedy(0.5).expect("should succeed");
assert!(
plan.total_cost_million_eur <= 0.5 + 1e-9,
"total cost must not exceed budget"
);
}
#[test]
fn test_expansion_planner_bcr_ranking() {
let existing = vec![NetworkEdge {
from: 0,
to: 1,
length_km: 10.0,
voltage_kv: 110.0,
capacity_mw: 100.0,
cost_million_eur: 0.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: true,
build_years: 0.0,
}];
let candidates = vec![
NetworkEdge {
from: 1,
to: 2,
length_km: 5.0,
voltage_kv: 110.0,
capacity_mw: 200.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: false,
build_years: 1.0,
},
NetworkEdge {
from: 2,
to: 3,
length_km: 50.0,
voltage_kv: 110.0,
capacity_mw: 10.0,
cost_million_eur: 50.0,
resistance_pu: 0.05,
reactance_pu: 0.2,
is_existing: false,
build_years: 3.0,
},
];
let nodes = vec![
TopologyNode {
id: 0,
is_terminal: true,
is_substation: true,
peak_load_mw: 0.0,
peak_generation_mw: 200.0,
x: 0.0,
y: 0.0,
},
TopologyNode {
id: 1,
is_terminal: true,
is_substation: false,
peak_load_mw: 100.0,
peak_generation_mw: 0.0,
x: 10.0,
y: 0.0,
},
TopologyNode {
id: 2,
is_terminal: true,
is_substation: false,
peak_load_mw: 80.0,
peak_generation_mw: 0.0,
x: 15.0,
y: 0.0,
},
TopologyNode {
id: 3,
is_terminal: true,
is_substation: false,
peak_load_mw: 50.0,
peak_generation_mw: 0.0,
x: 65.0,
y: 0.0,
},
];
let planner = ExpansionPlanner::new(existing, candidates, nodes, 10, 0.07, 3, 0.02);
let plan = planner.optimize_greedy(5.0).expect("BCR ranking test");
if !plan.investments.is_empty() {
let first_bcr = plan.investments[0].bcr;
for c in &plan.investments[1..] {
assert!(
c.bcr <= first_bcr + 1e-9,
"investments should be sorted by descending BCR within each year"
);
}
}
}
#[test]
fn test_ptdf_entry_calculation() {
let network = vec![
NetworkEdge {
from: 0,
to: 1,
length_km: 10.0,
voltage_kv: 110.0,
capacity_mw: 100.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: true,
build_years: 0.0,
},
NetworkEdge {
from: 1,
to: 2,
length_km: 10.0,
voltage_kv: 110.0,
capacity_mw: 100.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: true,
build_years: 0.0,
},
];
let ptdf = ExpansionPlanner::compute_ptdf_entry(0, 1, &network);
assert!(
ptdf > 0.0 && ptdf <= 1.0,
"PTDF must be in (0, 1], got {ptdf}"
);
}
#[test]
fn test_feasibility_check_within_capacity() {
let planner = make_planner_single();
let feasible = planner.check_feasibility(&[]);
let feasible_with_cand = planner.check_feasibility(&[0]);
assert!(
feasible_with_cand,
"with candidate line, total cap 550 > load 350"
);
let _ = feasible; }
#[test]
fn test_feasibility_check_exceeds_capacity() {
let existing = vec![NetworkEdge {
from: 0,
to: 1,
length_km: 5.0,
voltage_kv: 110.0,
capacity_mw: 10.0,
cost_million_eur: 0.0,
resistance_pu: 0.01,
reactance_pu: 0.05,
is_existing: true,
build_years: 0.0,
}];
let nodes = vec![
TopologyNode {
id: 0,
is_terminal: true,
is_substation: true,
peak_load_mw: 0.0,
peak_generation_mw: 500.0,
x: 0.0,
y: 0.0,
},
TopologyNode {
id: 1,
is_terminal: true,
is_substation: false,
peak_load_mw: 1000.0,
peak_generation_mw: 0.0,
x: 5.0,
y: 0.0,
},
];
let planner = ExpansionPlanner::new(existing, vec![], nodes, 5, 0.07, 1, 0.02);
assert!(
!planner.check_feasibility(&[]),
"10 MW capacity < 1000 MW load"
);
}
#[test]
fn test_n1_security_check() {
let planner = make_planner_single();
let plan = planner.optimize_greedy(100.0).expect("plan ok");
let security = planner.n1_security_check(&plan);
for (line_idx, is_secure) in &security {
assert!(*line_idx < planner.candidate_lines.len());
let _ = is_secure;
}
assert_eq!(security.len(), plan.investments.len());
}
fn make_load_points_2cluster() -> Vec<(f64, f64, f64)> {
vec![
(0.0, 0.0, 10.0),
(1.0, 0.0, 12.0),
(0.5, 1.0, 8.0), (20.0, 0.0, 15.0),
(21.0, 0.0, 11.0),
(20.5, 1.0, 9.0), ]
}
#[test]
fn test_substation_siting_2_substations() {
let points = make_load_points_2cluster();
let siting = SubstationSiting::new(points, 2, 110.0, 0.1);
let result = siting.optimize_kmeans(100).expect("siting ok");
assert_eq!(result.substation_locations.len(), 2);
assert_eq!(result.assignments.len(), 6);
assert!(result.total_cable_cost_million_eur >= 0.0);
assert!(result.max_feeder_length_km >= result.avg_feeder_length_km - 1e-9);
}
#[test]
fn test_substation_siting_3_substations() {
let mut rng = Lcg::new(42);
let points: Vec<(f64, f64, f64)> = (0..15)
.map(|i| {
let cluster = i / 5;
let base_x = cluster as f64 * 30.0;
(
base_x + rng.next_range(0.0, 5.0),
rng.next_range(0.0, 5.0),
rng.next_range(5.0, 20.0),
)
})
.collect();
let siting = SubstationSiting::new(points, 3, 110.0, 0.1);
let result = siting.optimize_kmeans(50).expect("3-siting ok");
assert_eq!(result.substation_locations.len(), 3);
let counts = {
let mut c = [0usize; 3];
for &a in &result.assignments {
if a < 3 {
c[a] += 1;
}
}
c
};
assert!(
counts.iter().all(|&c| c > 0),
"each substation should serve some points"
);
}
#[test]
fn test_kmeans_convergence() {
let points = vec![
(0.0, 0.0, 1.0),
(0.1, 0.0, 1.0),
(0.0, 0.1, 1.0),
(100.0, 0.0, 1.0),
(100.1, 0.0, 1.0),
(100.0, 0.1, 1.0),
];
let siting = SubstationSiting::new(points, 2, 110.0, 0.05);
let result = siting.optimize_kmeans(200).expect("convergence test");
let locs = &result.substation_locations;
let any_near_origin = locs.iter().any(|(x, y)| x.abs() < 5.0 && y.abs() < 5.0);
let any_near_100 = locs
.iter()
.any(|(x, y)| (x - 100.0).abs() < 5.0 && y.abs() < 5.0);
assert!(any_near_origin, "one substation should be near origin");
assert!(any_near_100, "one substation should be near x=100");
}
#[test]
fn test_load_weighted_centroid() {
let points = vec![(0.0, 0.0, 1.0), (2.0, 0.0, 1.0)];
let (cx, cy) = SubstationSiting::load_weighted_centroid(&points);
assert!(
(cx - 1.0).abs() < 1e-9,
"centroid x should be 1.0, got {cx}"
);
assert!(cy.abs() < 1e-9, "centroid y should be 0.0, got {cy}");
}
#[test]
fn test_load_weighted_centroid_weighted() {
let points = vec![(0.0, 0.0, 3.0), (4.0, 0.0, 1.0)];
let (cx, _cy) = SubstationSiting::load_weighted_centroid(&points);
assert!(
(cx - 1.0).abs() < 1e-9,
"weighted centroid x should be 1.0, got {cx}"
);
}
#[test]
fn test_euclidean_distance() {
let d = SubstationSiting::euclidean_distance((0.0, 0.0), (3.0, 4.0));
assert!((d - 5.0).abs() < 1e-9, "distance should be 5.0, got {d}");
}
#[test]
fn test_expansion_planner_years_to_limit() {
let planner = make_planner_single();
let plan = planner.optimize_greedy(100.0).expect("plan ok");
assert_eq!(plan.years_to_capacity_limit.len(), planner.nodes.len());
for &y in &plan.years_to_capacity_limit {
assert!(
y >= 0.0 || y.is_infinite(),
"years must be non-negative or infinite"
);
}
}
#[test]
fn test_steiner_tree_total_length_positive() {
let solver = make_triangle();
let result = solver.solve_approximate().expect("ok");
assert!(
result.total_length_km > 0.0,
"total length should be positive"
);
}
#[test]
fn test_mst_baseline_connected() {
let solver = make_triangle();
let mst = solver.solve_mst_baseline().expect("MST ok");
assert!(mst.is_connected, "MST result must be connected");
}
#[test]
fn test_expansion_zero_budget() {
let planner = make_planner_single();
let plan = planner.optimize_greedy(0.0).expect("zero budget ok");
assert!(plan.investments.is_empty(), "zero budget → no investments");
assert!((plan.total_cost_million_eur).abs() < 1e-9);
}
#[test]
fn test_network_edge_susceptance_zero_reactance() {
let edge = NetworkEdge {
from: 0,
to: 1,
length_km: 1.0,
voltage_kv: 110.0,
capacity_mw: 100.0,
cost_million_eur: 1.0,
resistance_pu: 0.01,
reactance_pu: 0.0,
is_existing: false,
build_years: 1.0,
};
assert_eq!(
edge.susceptance_pu(),
0.0,
"zero reactance → zero susceptance"
);
}
}