Skip to main content

oxigrid/network/
network_design.rs

1//! Optimal network topology design — Steiner tree, transmission expansion planning,
2//! and geographic substation siting.
3//!
4//! # Modules
5//! - [`SteinerTreeSolver`] — approximate Steiner tree via shortest-path heuristic (metric closure MST)
6//! - [`ExpansionPlanner`] — greedy BCR-ranked transmission expansion planning with PTDF-based congestion
7//! - [`SubstationSiting`] — k-means load-weighted substation placement
8//!
9//! # References
10//! - Takahashi & Matsuyama, "An approximate solution for the Steiner problem in graphs", 1980.
11//! - Garver, "Transmission Network Estimation Using Linear Programming", IEEE TPAS 89(7), 1970.
12//! - Hakimi, "Optimum Distribution of Switching Centers in a Communication Network", 1965.
13
14use crate::error::OxiGridError;
15use std::cmp::Ordering;
16use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
17
18// ─────────────────────────────────────────────────────────────────────────────
19// Core data structures
20// ─────────────────────────────────────────────────────────────────────────────
21
22/// Edge candidate for network expansion or Steiner tree computation.
23#[derive(Debug, Clone)]
24pub struct NetworkEdge {
25    /// Source node index.
26    pub from: usize,
27    /// Destination node index.
28    pub to: usize,
29    /// Physical length of the line [km].
30    pub length_km: f64,
31    /// Nominal voltage level [kV].
32    pub voltage_kv: f64,
33    /// Thermal capacity [MW].
34    pub capacity_mw: f64,
35    /// Capital cost [million EUR].
36    pub cost_million_eur: f64,
37    /// Series resistance in per-unit on system base.
38    pub resistance_pu: f64,
39    /// Series reactance in per-unit on system base.
40    pub reactance_pu: f64,
41    /// `true` if the line is already built (no investment required).
42    pub is_existing: bool,
43    /// Construction lead time [years].
44    pub build_years: f64,
45}
46
47impl NetworkEdge {
48    /// DC susceptance in per-unit: `b = 1 / x`.
49    pub fn susceptance_pu(&self) -> f64 {
50        if self.reactance_pu.abs() < 1e-12 {
51            0.0
52        } else {
53            1.0 / self.reactance_pu
54        }
55    }
56}
57
58/// Load/generation node for topology planning.
59#[derive(Debug, Clone)]
60pub struct TopologyNode {
61    /// Unique node identifier.
62    pub id: usize,
63    /// `true` if this node must be connected (demand or generation node).
64    pub is_terminal: bool,
65    /// `true` if this node can serve as a Steiner (relay) point.
66    pub is_substation: bool,
67    /// Peak active load [MW].
68    pub peak_load_mw: f64,
69    /// Peak active generation [MW].
70    pub peak_generation_mw: f64,
71    /// X geographic coordinate [km].
72    pub x: f64,
73    /// Y geographic coordinate [km].
74    pub y: f64,
75}
76
77// ─────────────────────────────────────────────────────────────────────────────
78// Steiner Tree Solver
79// ─────────────────────────────────────────────────────────────────────────────
80
81/// Approximate Steiner tree solver for power network design.
82///
83/// Uses the Takahashi-Matsuyama heuristic:
84/// 1. Build shortest-path distance matrix on terminal nodes (Dijkstra on edge costs).
85/// 2. Construct metric closure graph on terminal nodes only.
86/// 3. Find MST of the metric closure (Kruskal).
87/// 4. Map back to original graph edges via the stored shortest paths.
88/// 5. Prune non-terminal Steiner points with degree ≤ 1.
89pub struct SteinerTreeSolver {
90    /// All nodes (terminals + potential Steiner points).
91    pub nodes: Vec<TopologyNode>,
92    /// All candidate edges.
93    pub edges: Vec<NetworkEdge>,
94}
95
96/// Result of a Steiner tree computation.
97#[derive(Debug, Clone)]
98pub struct SteinerTreeResult {
99    /// Indices into [`SteinerTreeSolver::edges`] that form the selected tree.
100    pub selected_edges: Vec<usize>,
101    /// Sum of `cost_million_eur` for selected edges.
102    pub total_cost_million_eur: f64,
103    /// Sum of `length_km` for selected edges.
104    pub total_length_km: f64,
105    /// Whether the result connects all terminal nodes.
106    pub is_connected: bool,
107    /// Whether the selected edge set forms a tree (no cycles).
108    pub radial: bool,
109}
110
111/// Wrapper for Dijkstra priority queue: (cost, node_index).
112#[derive(Debug, Clone, PartialEq)]
113struct DijkState {
114    cost: f64,
115    node: usize,
116}
117
118impl Eq for DijkState {}
119
120impl Ord for DijkState {
121    fn cmp(&self, other: &Self) -> Ordering {
122        // Reverse ordering for min-heap
123        other
124            .cost
125            .partial_cmp(&self.cost)
126            .unwrap_or(Ordering::Equal)
127    }
128}
129
130impl PartialOrd for DijkState {
131    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
132        Some(self.cmp(other))
133    }
134}
135
136impl SteinerTreeSolver {
137    /// Construct a new solver.
138    pub fn new(nodes: Vec<TopologyNode>, edges: Vec<NetworkEdge>) -> Self {
139        Self { nodes, edges }
140    }
141
142    /// Run the approximate Steiner tree algorithm.
143    ///
144    /// Returns an error if:
145    /// - There are fewer than 2 terminal nodes.
146    /// - The graph is disconnected (some terminals are unreachable from others).
147    pub fn solve_approximate(&self) -> Result<SteinerTreeResult, OxiGridError> {
148        let n_nodes = self.nodes.len();
149        if n_nodes == 0 {
150            return Err(OxiGridError::InvalidNetwork("no nodes defined".to_string()));
151        }
152
153        let terminals: Vec<usize> = self
154            .nodes
155            .iter()
156            .enumerate()
157            .filter(|(_, n)| n.is_terminal)
158            .map(|(i, _)| i)
159            .collect();
160
161        if terminals.len() < 2 {
162            return Err(OxiGridError::InvalidNetwork(
163                "Steiner tree requires at least 2 terminal nodes".to_string(),
164            ));
165        }
166
167        // Step 1: Dijkstra from each terminal to all other nodes (on edge cost)
168        let (dist_matrix, path_matrix) = self.all_pairs_shortest_paths(&terminals)?;
169
170        // Step 2 & 3: Kruskal MST on metric closure (terminals only)
171        let mst_edges_in_closure = self.mst_of_metric_closure(&terminals, &dist_matrix)?;
172
173        // Step 4: Map back to original edges
174        let mut selected_set: HashSet<usize> = HashSet::new();
175        for (ti, tj) in &mst_edges_in_closure {
176            let path = &path_matrix[*ti][*tj];
177            for &edge_idx in path {
178                selected_set.insert(edge_idx);
179            }
180        }
181
182        // Step 5: Prune non-terminal Steiner points with degree ≤ 1
183        let selected_edges = self.prune_steiner_points(selected_set, &terminals, n_nodes);
184
185        let total_cost: f64 = selected_edges
186            .iter()
187            .map(|&i| self.edges[i].cost_million_eur)
188            .sum();
189        let total_length: f64 = selected_edges
190            .iter()
191            .map(|&i| self.edges[i].length_km)
192            .sum();
193
194        let connected =
195            Self::is_connected_fn(n_nodes, &selected_edges, &self.edges, Some(&terminals));
196        let radial = Self::is_radial(n_nodes, &selected_edges, &self.edges);
197
198        Ok(SteinerTreeResult {
199            selected_edges,
200            total_cost_million_eur: total_cost,
201            total_length_km: total_length,
202            is_connected: connected,
203            radial,
204        })
205    }
206
207    /// MST baseline: run Kruskal on ALL nodes (ignores Steiner structure).
208    pub fn solve_mst_baseline(&self) -> Result<SteinerTreeResult, OxiGridError> {
209        let n_nodes = self.nodes.len();
210        if n_nodes == 0 || self.edges.is_empty() {
211            return Err(OxiGridError::InvalidNetwork(
212                "no nodes or edges defined".to_string(),
213            ));
214        }
215
216        // Sort edges by cost ascending
217        let mut sorted_indices: Vec<usize> = (0..self.edges.len()).collect();
218        sorted_indices.sort_by(|&a, &b| {
219            self.edges[a]
220                .cost_million_eur
221                .partial_cmp(&self.edges[b].cost_million_eur)
222                .unwrap_or(Ordering::Equal)
223        });
224
225        // Kruskal with union-find
226        let mut uf = UnionFind::new(n_nodes);
227        let mut selected = Vec::new();
228
229        for &ei in &sorted_indices {
230            let e = &self.edges[ei];
231            if e.from >= n_nodes || e.to >= n_nodes {
232                continue;
233            }
234            if uf.find(e.from) != uf.find(e.to) {
235                uf.union(e.from, e.to);
236                selected.push(ei);
237                if selected.len() == n_nodes - 1 {
238                    break;
239                }
240            }
241        }
242
243        let total_cost: f64 = selected
244            .iter()
245            .map(|&i| self.edges[i].cost_million_eur)
246            .sum();
247        let total_length: f64 = selected.iter().map(|&i| self.edges[i].length_km).sum();
248        let connected = Self::is_connected_fn(n_nodes, &selected, &self.edges, None);
249        let radial = Self::is_radial(n_nodes, &selected, &self.edges);
250
251        Ok(SteinerTreeResult {
252            selected_edges: selected,
253            total_cost_million_eur: total_cost,
254            total_length_km: total_length,
255            is_connected: connected,
256            radial,
257        })
258    }
259
260    /// BFS/DFS connectivity check.
261    ///
262    /// If `required_nodes` is `Some(list)`, checks that all listed nodes are
263    /// mutually reachable.  If `None`, checks that all `n_nodes` are reachable
264    /// from node 0.
265    pub fn is_connected(n_nodes: usize, selected_edges: &[usize], edges: &[NetworkEdge]) -> bool {
266        Self::is_connected_fn(n_nodes, selected_edges, edges, None)
267    }
268
269    // ── Internal helpers ──────────────────────────────────────────────────────
270
271    fn is_connected_fn(
272        n_nodes: usize,
273        selected_edges: &[usize],
274        edges: &[NetworkEdge],
275        required_nodes: Option<&[usize]>,
276    ) -> bool {
277        if n_nodes == 0 {
278            return true;
279        }
280        // Build adjacency
281        let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n_nodes];
282        for &ei in selected_edges {
283            if ei < edges.len() {
284                let e = &edges[ei];
285                if e.from < n_nodes && e.to < n_nodes {
286                    adj[e.from].push(e.to);
287                    adj[e.to].push(e.from);
288                }
289            }
290        }
291
292        let start = required_nodes.and_then(|r| r.first()).copied().unwrap_or(0);
293        let mut visited = vec![false; n_nodes];
294        let mut queue = VecDeque::new();
295        if start < n_nodes {
296            queue.push_back(start);
297            visited[start] = true;
298        }
299        while let Some(node) = queue.pop_front() {
300            for &nb in &adj[node] {
301                if !visited[nb] {
302                    visited[nb] = true;
303                    queue.push_back(nb);
304                }
305            }
306        }
307
308        match required_nodes {
309            Some(req) => req.iter().all(|&r| r < n_nodes && visited[r]),
310            None => visited.iter().all(|&v| v),
311        }
312    }
313
314    /// Check whether the selected edges form a tree (connected + no cycles).
315    fn is_radial(n_nodes: usize, selected_edges: &[usize], edges: &[NetworkEdge]) -> bool {
316        // A tree on n nodes has exactly n-1 edges and is connected.
317        if n_nodes == 0 {
318            return true;
319        }
320        let relevant: Vec<usize> = selected_edges
321            .iter()
322            .filter(|&&ei| ei < edges.len() && edges[ei].from < n_nodes && edges[ei].to < n_nodes)
323            .copied()
324            .collect();
325
326        if relevant.len() != n_nodes.saturating_sub(1) {
327            return false;
328        }
329        Self::is_connected_fn(n_nodes, &relevant, edges, None)
330    }
331
332    /// Single-source Dijkstra returning `(dist[node], predecessor_edge[node])`.
333    fn dijkstra(&self, source: usize) -> (Vec<f64>, Vec<Option<usize>>) {
334        let n = self.nodes.len();
335        let mut dist = vec![f64::INFINITY; n];
336        let mut pred_edge: Vec<Option<usize>> = vec![None; n];
337        dist[source] = 0.0;
338
339        // Build adjacency list (edge_index, neighbour, cost)
340        let mut adj: Vec<Vec<(usize, usize, f64)>> = vec![Vec::new(); n];
341        for (ei, e) in self.edges.iter().enumerate() {
342            if e.from < n && e.to < n {
343                adj[e.from].push((ei, e.to, e.cost_million_eur));
344                adj[e.to].push((ei, e.from, e.cost_million_eur));
345            }
346        }
347
348        let mut heap = BinaryHeap::new();
349        heap.push(DijkState {
350            cost: 0.0,
351            node: source,
352        });
353
354        while let Some(DijkState { cost, node }) = heap.pop() {
355            if cost > dist[node] + 1e-12 {
356                continue;
357            }
358            for &(ei, nb, w) in &adj[node] {
359                let new_cost = dist[node] + w;
360                if new_cost < dist[nb] - 1e-12 {
361                    dist[nb] = new_cost;
362                    pred_edge[nb] = Some(ei);
363                    heap.push(DijkState {
364                        cost: new_cost,
365                        node: nb,
366                    });
367                }
368            }
369        }
370
371        (dist, pred_edge)
372    }
373
374    /// Reconstruct the edge-path from `source` to `target` using predecessor arrays.
375    fn reconstruct_path(&self, target: usize, pred_edge: &[Option<usize>]) -> Vec<usize> {
376        let mut path = Vec::new();
377        let mut cur = target;
378        // Traverse backwards via predecessor edges.
379        // To find the predecessor node, look at the edge's endpoints.
380        let mut seen = HashSet::new();
381        loop {
382            if seen.contains(&cur) {
383                break; // cycle guard
384            }
385            seen.insert(cur);
386            match pred_edge.get(cur).and_then(|e| *e) {
387                None => break,
388                Some(ei) => {
389                    path.push(ei);
390                    let e = &self.edges[ei];
391                    // Move to the other endpoint of the edge
392                    cur = if e.to == cur { e.from } else { e.to };
393                }
394            }
395        }
396        path
397    }
398
399    /// Compute all-pairs shortest paths *from each terminal* to *all nodes*.
400    ///
401    /// Returns:
402    /// - `dist_matrix[ti][tj]` — shortest cost from terminal `ti` to terminal `tj`.
403    /// - `path_matrix[ti][tj]` — list of original edge indices on that path.
404    ///
405    /// Indices in the returned matrices are into `terminals`, not node IDs.
406    #[allow(clippy::type_complexity)]
407    fn all_pairs_shortest_paths(
408        &self,
409        terminals: &[usize],
410    ) -> Result<(Vec<Vec<f64>>, Vec<Vec<Vec<usize>>>), OxiGridError> {
411        let nt = terminals.len();
412        let mut dist_matrix = vec![vec![f64::INFINITY; nt]; nt];
413        let mut path_matrix = vec![vec![Vec::new(); nt]; nt];
414
415        for (ti, &t_node) in terminals.iter().enumerate() {
416            let (dist, pred) = self.dijkstra(t_node);
417            dist_matrix[ti][ti] = 0.0;
418            for (tj, &t2_node) in terminals.iter().enumerate() {
419                if ti == tj {
420                    continue;
421                }
422                if dist[t2_node].is_infinite() {
423                    // disconnected
424                    dist_matrix[ti][tj] = f64::INFINITY;
425                } else {
426                    dist_matrix[ti][tj] = dist[t2_node];
427                    path_matrix[ti][tj] = self.reconstruct_path(t2_node, &pred);
428                }
429            }
430        }
431
432        // Verify all terminals are reachable from terminal 0
433        for tj in 1..nt {
434            if dist_matrix[0][tj].is_infinite() {
435                return Err(OxiGridError::InvalidNetwork(format!(
436                    "terminal node {} is unreachable from terminal node {} (disconnected graph)",
437                    terminals[tj], terminals[0]
438                )));
439            }
440        }
441
442        Ok((dist_matrix, path_matrix))
443    }
444
445    /// Kruskal MST on the metric closure of terminals.
446    ///
447    /// Returns a list of `(ti_index, tj_index)` pairs (indices into `terminals`).
448    fn mst_of_metric_closure(
449        &self,
450        terminals: &[usize],
451        dist_matrix: &[Vec<f64>],
452    ) -> Result<Vec<(usize, usize)>, OxiGridError> {
453        let nt = terminals.len();
454        // Build all pairs with finite distances
455        let mut closure_edges: Vec<(f64, usize, usize)> = Vec::new();
456        #[allow(clippy::needless_range_loop)]
457        for ti in 0..nt {
458            for tj in (ti + 1)..nt {
459                let d = dist_matrix[ti][tj];
460                if d.is_finite() {
461                    closure_edges.push((d, ti, tj));
462                }
463            }
464        }
465        // Sort by distance
466        closure_edges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
467
468        let mut uf = UnionFind::new(nt);
469        let mut mst_pairs = Vec::new();
470
471        for (_, ti, tj) in &closure_edges {
472            if uf.find(*ti) != uf.find(*tj) {
473                uf.union(*ti, *tj);
474                mst_pairs.push((*ti, *tj));
475                if mst_pairs.len() == nt - 1 {
476                    break;
477                }
478            }
479        }
480
481        if mst_pairs.len() < nt - 1 {
482            return Err(OxiGridError::InvalidNetwork(
483                "cannot form spanning tree over terminals — graph is disconnected".to_string(),
484            ));
485        }
486
487        Ok(mst_pairs)
488    }
489
490    /// Remove non-terminal Steiner nodes that have degree ≤ 1 in the selected tree
491    /// (iterative leaf pruning).
492    fn prune_steiner_points(
493        &self,
494        mut selected_set: HashSet<usize>,
495        terminals: &[usize],
496        n_nodes: usize,
497    ) -> Vec<usize> {
498        let terminal_set: HashSet<usize> = terminals.iter().copied().collect();
499
500        loop {
501            // Build degree map
502            let mut degree: HashMap<usize, usize> = HashMap::new();
503            for &ei in &selected_set {
504                if ei < self.edges.len() {
505                    let e = &self.edges[ei];
506                    if e.from < n_nodes && e.to < n_nodes {
507                        *degree.entry(e.from).or_insert(0) += 1;
508                        *degree.entry(e.to).or_insert(0) += 1;
509                    }
510                }
511            }
512
513            // Find a non-terminal leaf
514            let leaf = degree
515                .iter()
516                .find(|(&node, &deg)| deg <= 1 && !terminal_set.contains(&node))
517                .map(|(&node, _)| node);
518
519            match leaf {
520                None => break,
521                Some(leaf_node) => {
522                    // Remove all edges incident to this leaf
523                    selected_set.retain(|&ei| {
524                        if ei >= self.edges.len() {
525                            return true;
526                        }
527                        let e = &self.edges[ei];
528                        e.from != leaf_node && e.to != leaf_node
529                    });
530                }
531            }
532        }
533
534        let mut result: Vec<usize> = selected_set.into_iter().collect();
535        result.sort_unstable();
536        result
537    }
538}
539
540// ─────────────────────────────────────────────────────────────────────────────
541// Union-Find (Disjoint Set Union)
542// ─────────────────────────────────────────────────────────────────────────────
543
544struct UnionFind {
545    parent: Vec<usize>,
546    rank: Vec<usize>,
547}
548
549impl UnionFind {
550    fn new(n: usize) -> Self {
551        Self {
552            parent: (0..n).collect(),
553            rank: vec![0; n],
554        }
555    }
556
557    fn find(&mut self, x: usize) -> usize {
558        if self.parent[x] != x {
559            self.parent[x] = self.find(self.parent[x]);
560        }
561        self.parent[x]
562    }
563
564    fn union(&mut self, a: usize, b: usize) {
565        let ra = self.find(a);
566        let rb = self.find(b);
567        if ra == rb {
568            return;
569        }
570        match self.rank[ra].cmp(&self.rank[rb]) {
571            Ordering::Less => self.parent[ra] = rb,
572            Ordering::Greater => self.parent[rb] = ra,
573            Ordering::Equal => {
574                self.parent[rb] = ra;
575                self.rank[ra] += 1;
576            }
577        }
578    }
579}
580
581// ─────────────────────────────────────────────────────────────────────────────
582// Transmission Expansion Planner
583// ─────────────────────────────────────────────────────────────────────────────
584
585/// Greedy BCR-ranked transmission expansion planner.
586///
587/// For each candidate line, estimates the NPV benefit via PTDF-based congestion
588/// relief and VOLL (Value of Lost Load), then ranks by benefit-cost ratio.
589pub struct ExpansionPlanner {
590    /// Already-built network lines.
591    pub existing_network: Vec<NetworkEdge>,
592    /// Lines that could be built (investment candidates).
593    pub candidate_lines: Vec<NetworkEdge>,
594    /// All planning nodes.
595    pub nodes: Vec<TopologyNode>,
596    /// Planning horizon [years].
597    pub planning_years: usize,
598    /// Annual discount rate (e.g. 0.07 for 7 %).
599    pub discount_rate: f64,
600    /// Number of Monte Carlo scenarios for uncertainty.
601    pub n_scenarios: usize,
602    /// Annual load growth fraction (e.g. 0.02 for 2 %).
603    pub load_growth_rate: f64,
604}
605
606/// A single transmission investment decision with economics.
607#[derive(Debug, Clone)]
608pub struct ExpansionCandidate {
609    /// Index into [`ExpansionPlanner::candidate_lines`].
610    pub line_idx: usize,
611    /// Year in the planning horizon when the line would be built (0-based).
612    pub build_year: usize,
613    /// Discounted benefit [million EUR].
614    pub npv_benefit_million_eur: f64,
615    /// Discounted cost [million EUR].
616    pub npv_cost_million_eur: f64,
617    /// Benefit-cost ratio (BCR = benefit / cost).
618    pub bcr: f64,
619    /// Estimated congestion relief [MW].
620    pub congestion_relief_mw: f64,
621}
622
623/// Full expansion plan output.
624#[derive(Debug, Clone)]
625pub struct ExpansionPlan {
626    /// Selected investments sorted by descending BCR.
627    pub investments: Vec<ExpansionCandidate>,
628    /// Total capital cost [million EUR].
629    pub total_cost_million_eur: f64,
630    /// Total discounted benefit [million EUR].
631    pub total_benefit_million_eur: f64,
632    /// Net present value of the plan (benefit − cost) [million EUR].
633    pub total_npv_million_eur: f64,
634    /// Approximate years until each node hits its capacity limit.
635    pub years_to_capacity_limit: Vec<f64>,
636}
637
638/// Value of Lost Load assumed for benefit calculation [million EUR/MWh].
639const VOLL_MILLION_EUR_PER_MWH: f64 = 0.010; // 10 000 EUR/MWh
640/// Annual operating hours assumed for energy calculations.
641const OPERATING_HOURS_PER_YEAR: f64 = 8_760.0;
642
643impl ExpansionPlanner {
644    /// Create a new planner.
645    pub fn new(
646        existing_network: Vec<NetworkEdge>,
647        candidate_lines: Vec<NetworkEdge>,
648        nodes: Vec<TopologyNode>,
649        planning_years: usize,
650        discount_rate: f64,
651        n_scenarios: usize,
652        load_growth_rate: f64,
653    ) -> Self {
654        Self {
655            existing_network,
656            candidate_lines,
657            nodes,
658            planning_years,
659            discount_rate,
660            n_scenarios,
661            load_growth_rate,
662        }
663    }
664
665    /// Run the greedy BCR-ranked expansion optimisation.
666    ///
667    /// Iterates through planning years; for each year computes per-candidate BCR
668    /// and selects the best until the budget is exhausted.
669    pub fn optimize_greedy(&self, budget_million_eur: f64) -> Result<ExpansionPlan, OxiGridError> {
670        if self.candidate_lines.is_empty() {
671            return Err(OxiGridError::InvalidNetwork(
672                "no candidate lines defined".to_string(),
673            ));
674        }
675        if self.planning_years == 0 {
676            return Err(OxiGridError::InvalidParameter(
677                "planning_years must be > 0".to_string(),
678            ));
679        }
680        if budget_million_eur < 0.0 {
681            return Err(OxiGridError::InvalidParameter(
682                "budget must be non-negative".to_string(),
683            ));
684        }
685
686        let mut spent = 0.0_f64;
687        let mut selected: Vec<ExpansionCandidate> = Vec::new();
688        let mut built_set: HashSet<usize> = HashSet::new();
689
690        // Evaluate all candidates for each year and greedily pick highest BCR
691        for year in 0..self.planning_years {
692            if spent >= budget_million_eur - 1e-9 {
693                break;
694            }
695            let growth_factor = (1.0 + self.load_growth_rate).powi(year as i32);
696            let discount_factor = self.npv_discount_factor(year);
697
698            // Collect all un-built candidates with BCR > 1
699            let mut candidates_this_year: Vec<ExpansionCandidate> = self
700                .candidate_lines
701                .iter()
702                .enumerate()
703                .filter(|(idx, _)| !built_set.contains(idx))
704                .map(|(idx, line)| {
705                    let ptdf = Self::compute_ptdf_entry(line.from, line.to, &self.existing_network);
706                    let relief_mw = line.capacity_mw * ptdf.abs() * growth_factor;
707                    let benefit_per_year =
708                        relief_mw * OPERATING_HOURS_PER_YEAR * VOLL_MILLION_EUR_PER_MWH * 0.01; // assume 1% probability of congestion per hour
709                    let npv_benefit = benefit_per_year * self.annuity_factor() * discount_factor;
710                    let npv_cost = line.cost_million_eur * discount_factor;
711                    let bcr = if npv_cost > 1e-9 {
712                        npv_benefit / npv_cost
713                    } else {
714                        0.0
715                    };
716                    ExpansionCandidate {
717                        line_idx: idx,
718                        build_year: year,
719                        npv_benefit_million_eur: npv_benefit,
720                        npv_cost_million_eur: npv_cost,
721                        bcr,
722                        congestion_relief_mw: relief_mw,
723                    }
724                })
725                .filter(|c| c.bcr > 0.0)
726                .collect();
727
728            // Sort by BCR descending
729            candidates_this_year
730                .sort_by(|a, b| b.bcr.partial_cmp(&a.bcr).unwrap_or(Ordering::Equal));
731
732            // Greedily select
733            for cand in candidates_this_year {
734                let remaining = budget_million_eur - spent;
735                if cand.npv_cost_million_eur > remaining + 1e-9 {
736                    continue;
737                }
738                spent += cand.npv_cost_million_eur;
739                built_set.insert(cand.line_idx);
740                selected.push(cand);
741                if spent >= budget_million_eur - 1e-9 {
742                    break;
743                }
744            }
745        }
746
747        let total_cost: f64 = selected.iter().map(|c| c.npv_cost_million_eur).sum();
748        let total_benefit: f64 = selected.iter().map(|c| c.npv_benefit_million_eur).sum();
749        let years_to_limit = self.estimate_years_to_capacity_limit();
750
751        Ok(ExpansionPlan {
752            investments: selected,
753            total_cost_million_eur: total_cost,
754            total_benefit_million_eur: total_benefit,
755            total_npv_million_eur: total_benefit - total_cost,
756            years_to_capacity_limit: years_to_limit,
757        })
758    }
759
760    /// Compute an approximate PTDF entry for a new line from `from` to `to`.
761    ///
762    /// Uses the lossless DC formula:
763    /// `PTDF ≈ b_new / (b_new + b_system)` where `b_system` is the total
764    /// susceptance of existing lines sharing at least one endpoint.
765    pub fn compute_ptdf_entry(from: usize, to: usize, network: &[NetworkEdge]) -> f64 {
766        let b_system: f64 = network
767            .iter()
768            .filter(|e| e.from == from || e.to == from || e.from == to || e.to == to)
769            .map(|e| e.susceptance_pu())
770            .sum();
771
772        if b_system < 1e-12 {
773            1.0 // isolated corridor — new line carries 100% of flow
774        } else {
775            b_system / (b_system + b_system) // simplified: 50% sharing
776        }
777    }
778
779    /// Check whether all existing + selected lines keep their loading below capacity.
780    ///
781    /// Uses a flat-voltage DC power flow approximation:
782    /// `P_flow = V²·b·(θ_from - θ_to) ≈ b·Δθ`
783    /// Here, loading is estimated as `total_load / (n_lines · mean_capacity)`.
784    pub fn check_feasibility(&self, selected: &[usize]) -> bool {
785        let total_load: f64 = self.nodes.iter().map(|n| n.peak_load_mw).sum();
786        let mut all_lines: Vec<&NetworkEdge> = self.existing_network.iter().collect();
787        for &si in selected {
788            if si < self.candidate_lines.len() {
789                all_lines.push(&self.candidate_lines[si]);
790            }
791        }
792        if all_lines.is_empty() {
793            return total_load < 1e-9;
794        }
795        let total_capacity: f64 = all_lines.iter().map(|e| e.capacity_mw).sum();
796        // Feasible if total capacity exceeds total peak load (conservative DC check)
797        total_capacity >= total_load
798    }
799
800    /// N-1 security check: for each selected line, temporarily remove it and
801    /// verify the remaining network is still feasible.
802    ///
803    /// Returns `(line_idx, is_secure)` for each investment.
804    pub fn n1_security_check(&self, plan: &ExpansionPlan) -> Vec<(usize, bool)> {
805        plan.investments
806            .iter()
807            .map(|inv| {
808                // Build set of all selected lines except this one
809                let others: Vec<usize> = plan
810                    .investments
811                    .iter()
812                    .filter(|c| c.line_idx != inv.line_idx)
813                    .map(|c| c.line_idx)
814                    .collect();
815                let secure = self.check_feasibility(&others);
816                (inv.line_idx, secure)
817            })
818            .collect()
819    }
820
821    // ── Internal helpers ──────────────────────────────────────────────────────
822
823    /// NPV discount factor for a given year: `1 / (1 + r)^year`.
824    fn npv_discount_factor(&self, year: usize) -> f64 {
825        (1.0 + self.discount_rate).powi(-(year as i32))
826    }
827
828    /// Present value of an annuity of 1 EUR/year over `planning_years`.
829    fn annuity_factor(&self) -> f64 {
830        let r = self.discount_rate;
831        let n = self.planning_years as f64;
832        if r.abs() < 1e-12 {
833            n
834        } else {
835            (1.0 - (1.0 + r).powf(-n)) / r
836        }
837    }
838
839    /// Estimate years until each node's local load exceeds a nominal 100 MW limit.
840    fn estimate_years_to_capacity_limit(&self) -> Vec<f64> {
841        self.nodes
842            .iter()
843            .map(|node| {
844                if node.peak_load_mw < 1e-9 || self.load_growth_rate < 1e-9 {
845                    return f64::INFINITY;
846                }
847                // nominal_capacity = sum of existing line capacities incident on node
848                let cap: f64 = self
849                    .existing_network
850                    .iter()
851                    .filter(|e| e.from == node.id || e.to == node.id)
852                    .map(|e| e.capacity_mw)
853                    .sum::<f64>()
854                    .max(100.0); // fallback 100 MW if no lines
855                                 // years until load * (1+g)^t > cap
856                                 // t = ln(cap / load) / ln(1+g)
857                let ratio = cap / node.peak_load_mw;
858                if ratio <= 1.0 {
859                    0.0
860                } else {
861                    ratio.ln() / (1.0 + self.load_growth_rate).ln()
862                }
863            })
864            .collect()
865    }
866}
867
868// ─────────────────────────────────────────────────────────────────────────────
869// Substation Siting — K-means
870// ─────────────────────────────────────────────────────────────────────────────
871
872/// Geographic substation siting via load-weighted k-means clustering.
873///
874/// Each cluster centroid represents an optimal substation location that minimises
875/// the total weighted distance (≈ cable length) to served load points.
876pub struct SubstationSiting {
877    /// Load points: `(x_km, y_km, load_mw)`.
878    pub load_points: Vec<(f64, f64, f64)>,
879    /// Desired number of substations.
880    pub n_substations: usize,
881    /// Nominal voltage of the distribution feeder [kV].
882    pub voltage_kv: f64,
883    /// Cable cost per km [million EUR/km].
884    pub cable_cost_million_eur_per_km: f64,
885}
886
887/// Result of the substation siting optimisation.
888#[derive(Debug, Clone)]
889pub struct SitingResult {
890    /// Optimal geographic coordinates of each substation `(x_km, y_km)`.
891    pub substation_locations: Vec<(f64, f64)>,
892    /// Cluster assignment: `assignments[i]` is the substation index for load point `i`.
893    pub assignments: Vec<usize>,
894    /// Estimated total cable investment cost [million EUR].
895    pub total_cable_cost_million_eur: f64,
896    /// Length of the longest feeder [km].
897    pub max_feeder_length_km: f64,
898    /// Mean feeder length weighted by number of load points [km].
899    pub avg_feeder_length_km: f64,
900}
901
902impl SubstationSiting {
903    /// Construct a new siting problem.
904    pub fn new(
905        load_points: Vec<(f64, f64, f64)>,
906        n_substations: usize,
907        voltage_kv: f64,
908        cable_cost_million_eur_per_km: f64,
909    ) -> Self {
910        Self {
911            load_points,
912            n_substations,
913            voltage_kv,
914            cable_cost_million_eur_per_km,
915        }
916    }
917
918    /// Optimise substation locations using load-weighted k-means.
919    ///
920    /// Initialisation: spread substations by percentile of x-coordinate, choosing
921    /// the load-weighted centroid within each segment as the initial centre.
922    ///
923    /// Iteration: assign each load point to its nearest substation (Euclidean),
924    /// recompute load-weighted centroids.  Converge when centroid shift < 0.01 km.
925    pub fn optimize_kmeans(&self, max_iter: usize) -> Result<SitingResult, OxiGridError> {
926        let n = self.load_points.len();
927        let k = self.n_substations;
928
929        if n == 0 {
930            return Err(OxiGridError::InvalidNetwork(
931                "no load points defined".to_string(),
932            ));
933        }
934        if k == 0 {
935            return Err(OxiGridError::InvalidParameter(
936                "n_substations must be > 0".to_string(),
937            ));
938        }
939        if k > n {
940            return Err(OxiGridError::InvalidParameter(format!(
941                "n_substations ({k}) exceeds number of load points ({n})"
942            )));
943        }
944
945        // Initialise centroids by splitting sorted-x load points into k segments
946        let mut sorted_indices: Vec<usize> = (0..n).collect();
947        sorted_indices.sort_by(|&a, &b| {
948            self.load_points[a]
949                .0
950                .partial_cmp(&self.load_points[b].0)
951                .unwrap_or(Ordering::Equal)
952        });
953
954        let mut centroids: Vec<(f64, f64)> = (0..k)
955            .map(|seg| {
956                let start = seg * n / k;
957                let end = ((seg + 1) * n / k).min(n);
958                let seg_points: Vec<(f64, f64, f64)> = sorted_indices[start..end]
959                    .iter()
960                    .map(|&i| self.load_points[i])
961                    .collect();
962                Self::load_weighted_centroid(&seg_points)
963            })
964            .collect();
965
966        let mut assignments = vec![0usize; n];
967
968        for _iter in 0..max_iter {
969            // Assignment step
970            #[allow(clippy::needless_range_loop)]
971            for i in 0..n {
972                let (px, py, _) = self.load_points[i];
973                let nearest = centroids
974                    .iter()
975                    .enumerate()
976                    .min_by(|(_, &c1), (_, &c2)| {
977                        Self::euclidean_distance((px, py), c1)
978                            .partial_cmp(&Self::euclidean_distance((px, py), c2))
979                            .unwrap_or(Ordering::Equal)
980                    })
981                    .map(|(idx, _)| idx)
982                    .unwrap_or(0);
983                assignments[i] = nearest;
984            }
985
986            // Update centroids
987            let mut new_centroids: Vec<(f64, f64)> = Vec::with_capacity(k);
988            let mut converged = true;
989
990            #[allow(clippy::needless_range_loop)]
991            for ci in 0..k {
992                let cluster_points: Vec<(f64, f64, f64)> = (0..n)
993                    .filter(|&i| assignments[i] == ci)
994                    .map(|i| self.load_points[i])
995                    .collect();
996
997                let new_c = if cluster_points.is_empty() {
998                    centroids[ci] // keep existing centroid if cluster is empty
999                } else {
1000                    Self::load_weighted_centroid(&cluster_points)
1001                };
1002
1003                let shift = Self::euclidean_distance(centroids[ci], new_c);
1004                if shift > 0.01 {
1005                    converged = false;
1006                }
1007                new_centroids.push(new_c);
1008            }
1009
1010            centroids = new_centroids;
1011
1012            if converged {
1013                break;
1014            }
1015        }
1016
1017        // Compute metrics
1018        let distances: Vec<f64> = (0..n)
1019            .map(|i| {
1020                let (px, py, _) = self.load_points[i];
1021                Self::euclidean_distance((px, py), centroids[assignments[i]])
1022            })
1023            .collect();
1024
1025        let total_cable_len: f64 = distances.iter().sum();
1026        let max_len = distances.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1027        let avg_len = if n > 0 {
1028            total_cable_len / n as f64
1029        } else {
1030            0.0
1031        };
1032        let total_cost = total_cable_len * self.cable_cost_million_eur_per_km;
1033
1034        Ok(SitingResult {
1035            substation_locations: centroids,
1036            assignments,
1037            total_cable_cost_million_eur: total_cost,
1038            max_feeder_length_km: max_len.max(0.0),
1039            avg_feeder_length_km: avg_len,
1040        })
1041    }
1042
1043    /// Euclidean distance between two 2D points.
1044    pub fn euclidean_distance(p1: (f64, f64), p2: (f64, f64)) -> f64 {
1045        let dx = p1.0 - p2.0;
1046        let dy = p1.1 - p2.1;
1047        (dx * dx + dy * dy).sqrt()
1048    }
1049
1050    /// Load-weighted centroid of a set of `(x, y, load_mw)` points.
1051    ///
1052    /// Returns the unweighted centroid if total load is zero.
1053    pub fn load_weighted_centroid(points: &[(f64, f64, f64)]) -> (f64, f64) {
1054        let total_w: f64 = points.iter().map(|(_, _, w)| w).sum();
1055        if total_w < 1e-12 {
1056            // Unweighted centroid as fallback
1057            let n = points.len() as f64;
1058            if n < 1e-12 {
1059                return (0.0, 0.0);
1060            }
1061            let x = points.iter().map(|(x, _, _)| x).sum::<f64>() / n;
1062            let y = points.iter().map(|(_, y, _)| y).sum::<f64>() / n;
1063            return (x, y);
1064        }
1065        let x = points.iter().map(|(xi, _, wi)| xi * wi).sum::<f64>() / total_w;
1066        let y = points.iter().map(|(_, yi, wi)| yi * wi).sum::<f64>() / total_w;
1067        (x, y)
1068    }
1069}
1070
1071// ─────────────────────────────────────────────────────────────────────────────
1072// Tests
1073// ─────────────────────────────────────────────────────────────────────────────
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078
1079    // LCG random number generator (no rand crate)
1080    struct Lcg(u64);
1081    impl Lcg {
1082        fn new(seed: u64) -> Self {
1083            Self(seed)
1084        }
1085        fn next_f64(&mut self) -> f64 {
1086            self.0 = self
1087                .0
1088                .wrapping_mul(6_364_136_223_846_793_005u64)
1089                .wrapping_add(1_442_695_040_888_963_407u64);
1090            // Map to [0, 1)
1091            (self.0 >> 11) as f64 / (1u64 << 53) as f64
1092        }
1093        fn next_range(&mut self, lo: f64, hi: f64) -> f64 {
1094            lo + self.next_f64() * (hi - lo)
1095        }
1096    }
1097
1098    // ── NetworkEdge / TopologyNode construction ───────────────────────────────
1099
1100    #[test]
1101    fn test_network_edge_creation() {
1102        let edge = NetworkEdge {
1103            from: 0,
1104            to: 1,
1105            length_km: 50.0,
1106            voltage_kv: 110.0,
1107            capacity_mw: 200.0,
1108            cost_million_eur: 5.0,
1109            resistance_pu: 0.01,
1110            reactance_pu: 0.05,
1111            is_existing: false,
1112            build_years: 3.0,
1113        };
1114        assert_eq!(edge.from, 0);
1115        assert_eq!(edge.to, 1);
1116        assert!((edge.length_km - 50.0).abs() < 1e-9);
1117        assert!((edge.susceptance_pu() - 20.0).abs() < 1e-6);
1118    }
1119
1120    #[test]
1121    fn test_topology_node_creation() {
1122        let node = TopologyNode {
1123            id: 3,
1124            is_terminal: true,
1125            is_substation: false,
1126            peak_load_mw: 150.0,
1127            peak_generation_mw: 0.0,
1128            x: 10.0,
1129            y: 20.0,
1130        };
1131        assert_eq!(node.id, 3);
1132        assert!(node.is_terminal);
1133        assert!(!node.is_substation);
1134        assert!((node.peak_load_mw - 150.0).abs() < 1e-9);
1135    }
1136
1137    // ── Helper to build a simple 3-node triangle ──────────────────────────────
1138
1139    fn make_triangle() -> SteinerTreeSolver {
1140        let nodes = vec![
1141            TopologyNode {
1142                id: 0,
1143                is_terminal: true,
1144                is_substation: false,
1145                peak_load_mw: 100.0,
1146                peak_generation_mw: 0.0,
1147                x: 0.0,
1148                y: 0.0,
1149            },
1150            TopologyNode {
1151                id: 1,
1152                is_terminal: true,
1153                is_substation: false,
1154                peak_load_mw: 80.0,
1155                peak_generation_mw: 0.0,
1156                x: 10.0,
1157                y: 0.0,
1158            },
1159            TopologyNode {
1160                id: 2,
1161                is_terminal: true,
1162                is_substation: false,
1163                peak_load_mw: 60.0,
1164                peak_generation_mw: 0.0,
1165                x: 5.0,
1166                y: 8.0,
1167            },
1168        ];
1169        let edges = vec![
1170            NetworkEdge {
1171                from: 0,
1172                to: 1,
1173                length_km: 10.0,
1174                voltage_kv: 110.0,
1175                capacity_mw: 200.0,
1176                cost_million_eur: 1.0,
1177                resistance_pu: 0.01,
1178                reactance_pu: 0.05,
1179                is_existing: false,
1180                build_years: 1.0,
1181            },
1182            NetworkEdge {
1183                from: 1,
1184                to: 2,
1185                length_km: 9.4,
1186                voltage_kv: 110.0,
1187                capacity_mw: 200.0,
1188                cost_million_eur: 1.5,
1189                resistance_pu: 0.01,
1190                reactance_pu: 0.05,
1191                is_existing: false,
1192                build_years: 1.0,
1193            },
1194            NetworkEdge {
1195                from: 0,
1196                to: 2,
1197                length_km: 9.4,
1198                voltage_kv: 110.0,
1199                capacity_mw: 200.0,
1200                cost_million_eur: 2.0,
1201                resistance_pu: 0.01,
1202                reactance_pu: 0.05,
1203                is_existing: false,
1204                build_years: 1.0,
1205            },
1206        ];
1207        SteinerTreeSolver::new(nodes, edges)
1208    }
1209
1210    #[test]
1211    fn test_steiner_tree_3_terminals() {
1212        let solver = make_triangle();
1213        let result = solver.solve_approximate().expect("should succeed");
1214        // For a 3-terminal problem all terminals, Steiner tree = MST of terminals = 2 edges
1215        assert_eq!(
1216            result.selected_edges.len(),
1217            2,
1218            "3-terminal Steiner tree needs exactly 2 edges"
1219        );
1220        assert!(result.is_connected, "result must be connected");
1221    }
1222
1223    #[test]
1224    fn test_steiner_tree_4_terminals() {
1225        // 4 corners + 1 Steiner point in the center
1226        // Terminals: 0(0,0), 1(10,0), 2(10,10), 3(0,10)
1227        // Steiner point: 4(5,5)
1228        let nodes = vec![
1229            TopologyNode {
1230                id: 0,
1231                is_terminal: true,
1232                is_substation: false,
1233                peak_load_mw: 50.0,
1234                peak_generation_mw: 0.0,
1235                x: 0.0,
1236                y: 0.0,
1237            },
1238            TopologyNode {
1239                id: 1,
1240                is_terminal: true,
1241                is_substation: false,
1242                peak_load_mw: 50.0,
1243                peak_generation_mw: 0.0,
1244                x: 10.0,
1245                y: 0.0,
1246            },
1247            TopologyNode {
1248                id: 2,
1249                is_terminal: true,
1250                is_substation: false,
1251                peak_load_mw: 50.0,
1252                peak_generation_mw: 0.0,
1253                x: 10.0,
1254                y: 10.0,
1255            },
1256            TopologyNode {
1257                id: 3,
1258                is_terminal: true,
1259                is_substation: false,
1260                peak_load_mw: 50.0,
1261                peak_generation_mw: 0.0,
1262                x: 0.0,
1263                y: 10.0,
1264            },
1265            TopologyNode {
1266                id: 4,
1267                is_terminal: false,
1268                is_substation: true,
1269                peak_load_mw: 0.0,
1270                peak_generation_mw: 0.0,
1271                x: 5.0,
1272                y: 5.0,
1273            },
1274        ];
1275        // Edges: corners to center (cheap), plus direct corner-to-corner (expensive)
1276        let edges = vec![
1277            NetworkEdge {
1278                from: 0,
1279                to: 4,
1280                length_km: 7.07,
1281                voltage_kv: 110.0,
1282                capacity_mw: 200.0,
1283                cost_million_eur: 1.0,
1284                resistance_pu: 0.01,
1285                reactance_pu: 0.05,
1286                is_existing: false,
1287                build_years: 1.0,
1288            },
1289            NetworkEdge {
1290                from: 1,
1291                to: 4,
1292                length_km: 7.07,
1293                voltage_kv: 110.0,
1294                capacity_mw: 200.0,
1295                cost_million_eur: 1.0,
1296                resistance_pu: 0.01,
1297                reactance_pu: 0.05,
1298                is_existing: false,
1299                build_years: 1.0,
1300            },
1301            NetworkEdge {
1302                from: 2,
1303                to: 4,
1304                length_km: 7.07,
1305                voltage_kv: 110.0,
1306                capacity_mw: 200.0,
1307                cost_million_eur: 1.0,
1308                resistance_pu: 0.01,
1309                reactance_pu: 0.05,
1310                is_existing: false,
1311                build_years: 1.0,
1312            },
1313            NetworkEdge {
1314                from: 3,
1315                to: 4,
1316                length_km: 7.07,
1317                voltage_kv: 110.0,
1318                capacity_mw: 200.0,
1319                cost_million_eur: 1.0,
1320                resistance_pu: 0.01,
1321                reactance_pu: 0.05,
1322                is_existing: false,
1323                build_years: 1.0,
1324            },
1325            // Expensive direct edges (fallback connectivity)
1326            NetworkEdge {
1327                from: 0,
1328                to: 1,
1329                length_km: 10.0,
1330                voltage_kv: 110.0,
1331                capacity_mw: 200.0,
1332                cost_million_eur: 5.0,
1333                resistance_pu: 0.01,
1334                reactance_pu: 0.05,
1335                is_existing: false,
1336                build_years: 1.0,
1337            },
1338            NetworkEdge {
1339                from: 1,
1340                to: 2,
1341                length_km: 10.0,
1342                voltage_kv: 110.0,
1343                capacity_mw: 200.0,
1344                cost_million_eur: 5.0,
1345                resistance_pu: 0.01,
1346                reactance_pu: 0.05,
1347                is_existing: false,
1348                build_years: 1.0,
1349            },
1350            NetworkEdge {
1351                from: 2,
1352                to: 3,
1353                length_km: 10.0,
1354                voltage_kv: 110.0,
1355                capacity_mw: 200.0,
1356                cost_million_eur: 5.0,
1357                resistance_pu: 0.01,
1358                reactance_pu: 0.05,
1359                is_existing: false,
1360                build_years: 1.0,
1361            },
1362            NetworkEdge {
1363                from: 0,
1364                to: 3,
1365                length_km: 10.0,
1366                voltage_kv: 110.0,
1367                capacity_mw: 200.0,
1368                cost_million_eur: 5.0,
1369                resistance_pu: 0.01,
1370                reactance_pu: 0.05,
1371                is_existing: false,
1372                build_years: 1.0,
1373            },
1374        ];
1375        let solver = SteinerTreeSolver::new(nodes, edges);
1376        let result = solver
1377            .solve_approximate()
1378            .expect("4-terminal Steiner should succeed");
1379        assert!(result.is_connected, "4-terminal result must be connected");
1380        // Cost should be dominated by center connections (4 × 1.0 = 4.0 before pruning)
1381        assert!(
1382            result.total_cost_million_eur < 20.0,
1383            "cost should be reasonable"
1384        );
1385    }
1386
1387    #[test]
1388    fn test_steiner_tree_connectivity() {
1389        let solver = make_triangle();
1390        let result = solver.solve_approximate().expect("should succeed");
1391        assert!(
1392            result.is_connected,
1393            "Steiner result must connect all terminals"
1394        );
1395        // Verify manually using the public helper
1396        assert!(
1397            SteinerTreeSolver::is_connected(
1398                solver.nodes.len(),
1399                &result.selected_edges,
1400                &solver.edges
1401            ),
1402            "public is_connected must confirm result"
1403        );
1404    }
1405
1406    #[test]
1407    fn test_steiner_tree_vs_mst() {
1408        let solver = make_triangle();
1409        let steiner = solver.solve_approximate().expect("Steiner ok");
1410        let mst = solver.solve_mst_baseline().expect("MST ok");
1411        // For all-terminal graphs, Steiner = MST, so cost should be ≤ MST cost
1412        assert!(
1413            steiner.total_cost_million_eur <= mst.total_cost_million_eur + 1e-9,
1414            "Steiner cost ({:.3}) must be ≤ MST cost ({:.3})",
1415            steiner.total_cost_million_eur,
1416            mst.total_cost_million_eur
1417        );
1418    }
1419
1420    #[test]
1421    fn test_mst_baseline() {
1422        let solver = make_triangle();
1423        let mst = solver.solve_mst_baseline().expect("MST should succeed");
1424        // MST of 3 nodes = 2 edges
1425        assert_eq!(mst.selected_edges.len(), 2);
1426        assert!(mst.total_cost_million_eur > 0.0);
1427    }
1428
1429    #[test]
1430    fn test_is_connected_true() {
1431        let edges = vec![
1432            NetworkEdge {
1433                from: 0,
1434                to: 1,
1435                length_km: 5.0,
1436                voltage_kv: 110.0,
1437                capacity_mw: 100.0,
1438                cost_million_eur: 1.0,
1439                resistance_pu: 0.01,
1440                reactance_pu: 0.05,
1441                is_existing: true,
1442                build_years: 0.0,
1443            },
1444            NetworkEdge {
1445                from: 1,
1446                to: 2,
1447                length_km: 5.0,
1448                voltage_kv: 110.0,
1449                capacity_mw: 100.0,
1450                cost_million_eur: 1.0,
1451                resistance_pu: 0.01,
1452                reactance_pu: 0.05,
1453                is_existing: true,
1454                build_years: 0.0,
1455            },
1456        ];
1457        assert!(SteinerTreeSolver::is_connected(3, &[0, 1], &edges));
1458    }
1459
1460    #[test]
1461    fn test_is_connected_false() {
1462        let edges = vec![
1463            NetworkEdge {
1464                from: 0,
1465                to: 1,
1466                length_km: 5.0,
1467                voltage_kv: 110.0,
1468                capacity_mw: 100.0,
1469                cost_million_eur: 1.0,
1470                resistance_pu: 0.01,
1471                reactance_pu: 0.05,
1472                is_existing: true,
1473                build_years: 0.0,
1474            },
1475            NetworkEdge {
1476                from: 2,
1477                to: 3,
1478                length_km: 5.0,
1479                voltage_kv: 110.0,
1480                capacity_mw: 100.0,
1481                cost_million_eur: 1.0,
1482                resistance_pu: 0.01,
1483                reactance_pu: 0.05,
1484                is_existing: true,
1485                build_years: 0.0,
1486            },
1487        ];
1488        // 4 nodes, 2 disconnected edges → not connected
1489        assert!(!SteinerTreeSolver::is_connected(4, &[0, 1], &edges));
1490    }
1491
1492    // ── ExpansionPlanner tests ────────────────────────────────────────────────
1493
1494    fn make_planner_single() -> ExpansionPlanner {
1495        let existing = vec![NetworkEdge {
1496            from: 0,
1497            to: 1,
1498            length_km: 20.0,
1499            voltage_kv: 220.0,
1500            capacity_mw: 300.0,
1501            cost_million_eur: 0.0,
1502            resistance_pu: 0.005,
1503            reactance_pu: 0.02,
1504            is_existing: true,
1505            build_years: 0.0,
1506        }];
1507        let candidate = vec![NetworkEdge {
1508            from: 1,
1509            to: 2,
1510            length_km: 30.0,
1511            voltage_kv: 220.0,
1512            capacity_mw: 250.0,
1513            cost_million_eur: 10.0,
1514            resistance_pu: 0.008,
1515            reactance_pu: 0.03,
1516            is_existing: false,
1517            build_years: 2.0,
1518        }];
1519        let nodes = vec![
1520            TopologyNode {
1521                id: 0,
1522                is_terminal: true,
1523                is_substation: true,
1524                peak_load_mw: 0.0,
1525                peak_generation_mw: 500.0,
1526                x: 0.0,
1527                y: 0.0,
1528            },
1529            TopologyNode {
1530                id: 1,
1531                is_terminal: true,
1532                is_substation: true,
1533                peak_load_mw: 200.0,
1534                peak_generation_mw: 0.0,
1535                x: 20.0,
1536                y: 0.0,
1537            },
1538            TopologyNode {
1539                id: 2,
1540                is_terminal: true,
1541                is_substation: false,
1542                peak_load_mw: 150.0,
1543                peak_generation_mw: 0.0,
1544                x: 50.0,
1545                y: 0.0,
1546            },
1547        ];
1548        ExpansionPlanner::new(existing, candidate, nodes, 10, 0.07, 5, 0.02)
1549    }
1550
1551    #[test]
1552    fn test_expansion_planner_single_candidate() {
1553        let planner = make_planner_single();
1554        let plan = planner.optimize_greedy(100.0).expect("should succeed");
1555        // With a generous budget, the single candidate should be selected
1556        assert!(
1557            !plan.investments.is_empty(),
1558            "should select at least one line"
1559        );
1560        assert!(plan.total_cost_million_eur > 0.0);
1561        assert!(plan.total_npv_million_eur.is_finite());
1562    }
1563
1564    #[test]
1565    fn test_expansion_planner_budget_constraint() {
1566        let planner = make_planner_single();
1567        // Budget too small to build anything (cost = 10.0, budget = 0.5)
1568        let plan = planner.optimize_greedy(0.5).expect("should succeed");
1569        assert!(
1570            plan.total_cost_million_eur <= 0.5 + 1e-9,
1571            "total cost must not exceed budget"
1572        );
1573    }
1574
1575    #[test]
1576    fn test_expansion_planner_bcr_ranking() {
1577        // Two candidates: one with high BCR, one with low BCR
1578        let existing = vec![NetworkEdge {
1579            from: 0,
1580            to: 1,
1581            length_km: 10.0,
1582            voltage_kv: 110.0,
1583            capacity_mw: 100.0,
1584            cost_million_eur: 0.0,
1585            resistance_pu: 0.01,
1586            reactance_pu: 0.05,
1587            is_existing: true,
1588            build_years: 0.0,
1589        }];
1590        let candidates = vec![
1591            // Cheap, high-capacity → high BCR
1592            NetworkEdge {
1593                from: 1,
1594                to: 2,
1595                length_km: 5.0,
1596                voltage_kv: 110.0,
1597                capacity_mw: 200.0,
1598                cost_million_eur: 1.0,
1599                resistance_pu: 0.01,
1600                reactance_pu: 0.05,
1601                is_existing: false,
1602                build_years: 1.0,
1603            },
1604            // Expensive, low-capacity → low BCR
1605            NetworkEdge {
1606                from: 2,
1607                to: 3,
1608                length_km: 50.0,
1609                voltage_kv: 110.0,
1610                capacity_mw: 10.0,
1611                cost_million_eur: 50.0,
1612                resistance_pu: 0.05,
1613                reactance_pu: 0.2,
1614                is_existing: false,
1615                build_years: 3.0,
1616            },
1617        ];
1618        let nodes = vec![
1619            TopologyNode {
1620                id: 0,
1621                is_terminal: true,
1622                is_substation: true,
1623                peak_load_mw: 0.0,
1624                peak_generation_mw: 200.0,
1625                x: 0.0,
1626                y: 0.0,
1627            },
1628            TopologyNode {
1629                id: 1,
1630                is_terminal: true,
1631                is_substation: false,
1632                peak_load_mw: 100.0,
1633                peak_generation_mw: 0.0,
1634                x: 10.0,
1635                y: 0.0,
1636            },
1637            TopologyNode {
1638                id: 2,
1639                is_terminal: true,
1640                is_substation: false,
1641                peak_load_mw: 80.0,
1642                peak_generation_mw: 0.0,
1643                x: 15.0,
1644                y: 0.0,
1645            },
1646            TopologyNode {
1647                id: 3,
1648                is_terminal: true,
1649                is_substation: false,
1650                peak_load_mw: 50.0,
1651                peak_generation_mw: 0.0,
1652                x: 65.0,
1653                y: 0.0,
1654            },
1655        ];
1656        let planner = ExpansionPlanner::new(existing, candidates, nodes, 10, 0.07, 3, 0.02);
1657        let plan = planner.optimize_greedy(5.0).expect("BCR ranking test");
1658        // Only the cheap line (index 0) should fit in budget of 5.0
1659        if !plan.investments.is_empty() {
1660            let first_bcr = plan.investments[0].bcr;
1661            for c in &plan.investments[1..] {
1662                assert!(
1663                    c.bcr <= first_bcr + 1e-9,
1664                    "investments should be sorted by descending BCR within each year"
1665                );
1666            }
1667        }
1668    }
1669
1670    #[test]
1671    fn test_ptdf_entry_calculation() {
1672        let network = vec![
1673            NetworkEdge {
1674                from: 0,
1675                to: 1,
1676                length_km: 10.0,
1677                voltage_kv: 110.0,
1678                capacity_mw: 100.0,
1679                cost_million_eur: 1.0,
1680                resistance_pu: 0.01,
1681                reactance_pu: 0.05,
1682                is_existing: true,
1683                build_years: 0.0,
1684            },
1685            NetworkEdge {
1686                from: 1,
1687                to: 2,
1688                length_km: 10.0,
1689                voltage_kv: 110.0,
1690                capacity_mw: 100.0,
1691                cost_million_eur: 1.0,
1692                resistance_pu: 0.01,
1693                reactance_pu: 0.05,
1694                is_existing: true,
1695                build_years: 0.0,
1696            },
1697        ];
1698        let ptdf = ExpansionPlanner::compute_ptdf_entry(0, 1, &network);
1699        assert!(
1700            ptdf > 0.0 && ptdf <= 1.0,
1701            "PTDF must be in (0, 1], got {ptdf}"
1702        );
1703    }
1704
1705    #[test]
1706    fn test_feasibility_check_within_capacity() {
1707        let planner = make_planner_single();
1708        // Existing network alone should be feasible if capacity ≥ load
1709        let feasible = planner.check_feasibility(&[]);
1710        // existing capacity = 300 MW, total load = 350 MW → might not be feasible
1711        // but with candidate added, 300+250 = 550 MW > 350 MW
1712        let feasible_with_cand = planner.check_feasibility(&[0]);
1713        assert!(
1714            feasible_with_cand,
1715            "with candidate line, total cap 550 > load 350"
1716        );
1717        let _ = feasible; // just check it runs without panic
1718    }
1719
1720    #[test]
1721    fn test_feasibility_check_exceeds_capacity() {
1722        // Network with very low capacity vs high load
1723        let existing = vec![NetworkEdge {
1724            from: 0,
1725            to: 1,
1726            length_km: 5.0,
1727            voltage_kv: 110.0,
1728            capacity_mw: 10.0,
1729            cost_million_eur: 0.0,
1730            resistance_pu: 0.01,
1731            reactance_pu: 0.05,
1732            is_existing: true,
1733            build_years: 0.0,
1734        }];
1735        let nodes = vec![
1736            TopologyNode {
1737                id: 0,
1738                is_terminal: true,
1739                is_substation: true,
1740                peak_load_mw: 0.0,
1741                peak_generation_mw: 500.0,
1742                x: 0.0,
1743                y: 0.0,
1744            },
1745            TopologyNode {
1746                id: 1,
1747                is_terminal: true,
1748                is_substation: false,
1749                peak_load_mw: 1000.0,
1750                peak_generation_mw: 0.0,
1751                x: 5.0,
1752                y: 0.0,
1753            },
1754        ];
1755        let planner = ExpansionPlanner::new(existing, vec![], nodes, 5, 0.07, 1, 0.02);
1756        assert!(
1757            !planner.check_feasibility(&[]),
1758            "10 MW capacity < 1000 MW load"
1759        );
1760    }
1761
1762    #[test]
1763    fn test_n1_security_check() {
1764        let planner = make_planner_single();
1765        let plan = planner.optimize_greedy(100.0).expect("plan ok");
1766        let security = planner.n1_security_check(&plan);
1767        // Each entry should have a valid line_idx
1768        for (line_idx, is_secure) in &security {
1769            assert!(*line_idx < planner.candidate_lines.len());
1770            let _ = is_secure;
1771        }
1772        assert_eq!(security.len(), plan.investments.len());
1773    }
1774
1775    // ── SubstationSiting tests ────────────────────────────────────────────────
1776
1777    fn make_load_points_2cluster() -> Vec<(f64, f64, f64)> {
1778        // Two well-separated clusters
1779        vec![
1780            (0.0, 0.0, 10.0),
1781            (1.0, 0.0, 12.0),
1782            (0.5, 1.0, 8.0), // cluster A
1783            (20.0, 0.0, 15.0),
1784            (21.0, 0.0, 11.0),
1785            (20.5, 1.0, 9.0), // cluster B
1786        ]
1787    }
1788
1789    #[test]
1790    fn test_substation_siting_2_substations() {
1791        let points = make_load_points_2cluster();
1792        let siting = SubstationSiting::new(points, 2, 110.0, 0.1);
1793        let result = siting.optimize_kmeans(100).expect("siting ok");
1794        assert_eq!(result.substation_locations.len(), 2);
1795        assert_eq!(result.assignments.len(), 6);
1796        assert!(result.total_cable_cost_million_eur >= 0.0);
1797        assert!(result.max_feeder_length_km >= result.avg_feeder_length_km - 1e-9);
1798    }
1799
1800    #[test]
1801    fn test_substation_siting_3_substations() {
1802        let mut rng = Lcg::new(42);
1803        let points: Vec<(f64, f64, f64)> = (0..15)
1804            .map(|i| {
1805                let cluster = i / 5;
1806                let base_x = cluster as f64 * 30.0;
1807                (
1808                    base_x + rng.next_range(0.0, 5.0),
1809                    rng.next_range(0.0, 5.0),
1810                    rng.next_range(5.0, 20.0),
1811                )
1812            })
1813            .collect();
1814        let siting = SubstationSiting::new(points, 3, 110.0, 0.1);
1815        let result = siting.optimize_kmeans(50).expect("3-siting ok");
1816        assert_eq!(result.substation_locations.len(), 3);
1817        // Each cluster should have some assigned points
1818        let counts = {
1819            let mut c = [0usize; 3];
1820            for &a in &result.assignments {
1821                if a < 3 {
1822                    c[a] += 1;
1823                }
1824            }
1825            c
1826        };
1827        assert!(
1828            counts.iter().all(|&c| c > 0),
1829            "each substation should serve some points"
1830        );
1831    }
1832
1833    #[test]
1834    fn test_kmeans_convergence() {
1835        // Perfectly separated clusters → should converge in very few iterations
1836        let points = vec![
1837            (0.0, 0.0, 1.0),
1838            (0.1, 0.0, 1.0),
1839            (0.0, 0.1, 1.0),
1840            (100.0, 0.0, 1.0),
1841            (100.1, 0.0, 1.0),
1842            (100.0, 0.1, 1.0),
1843        ];
1844        let siting = SubstationSiting::new(points, 2, 110.0, 0.05);
1845        let result = siting.optimize_kmeans(200).expect("convergence test");
1846        // Two substations should end up near (0.033, 0.033) and (100.033, 0.033)
1847        let locs = &result.substation_locations;
1848        let any_near_origin = locs.iter().any(|(x, y)| x.abs() < 5.0 && y.abs() < 5.0);
1849        let any_near_100 = locs
1850            .iter()
1851            .any(|(x, y)| (x - 100.0).abs() < 5.0 && y.abs() < 5.0);
1852        assert!(any_near_origin, "one substation should be near origin");
1853        assert!(any_near_100, "one substation should be near x=100");
1854    }
1855
1856    #[test]
1857    fn test_load_weighted_centroid() {
1858        let points = vec![(0.0, 0.0, 1.0), (2.0, 0.0, 1.0)];
1859        let (cx, cy) = SubstationSiting::load_weighted_centroid(&points);
1860        assert!(
1861            (cx - 1.0).abs() < 1e-9,
1862            "centroid x should be 1.0, got {cx}"
1863        );
1864        assert!(cy.abs() < 1e-9, "centroid y should be 0.0, got {cy}");
1865    }
1866
1867    #[test]
1868    fn test_load_weighted_centroid_weighted() {
1869        // Point at 0 with weight 3, point at 4 with weight 1 → centroid at 1.0
1870        let points = vec![(0.0, 0.0, 3.0), (4.0, 0.0, 1.0)];
1871        let (cx, _cy) = SubstationSiting::load_weighted_centroid(&points);
1872        assert!(
1873            (cx - 1.0).abs() < 1e-9,
1874            "weighted centroid x should be 1.0, got {cx}"
1875        );
1876    }
1877
1878    #[test]
1879    fn test_euclidean_distance() {
1880        let d = SubstationSiting::euclidean_distance((0.0, 0.0), (3.0, 4.0));
1881        assert!((d - 5.0).abs() < 1e-9, "distance should be 5.0, got {d}");
1882    }
1883
1884    #[test]
1885    fn test_expansion_planner_years_to_limit() {
1886        let planner = make_planner_single();
1887        let plan = planner.optimize_greedy(100.0).expect("plan ok");
1888        assert_eq!(plan.years_to_capacity_limit.len(), planner.nodes.len());
1889        for &y in &plan.years_to_capacity_limit {
1890            assert!(
1891                y >= 0.0 || y.is_infinite(),
1892                "years must be non-negative or infinite"
1893            );
1894        }
1895    }
1896
1897    #[test]
1898    fn test_steiner_tree_total_length_positive() {
1899        let solver = make_triangle();
1900        let result = solver.solve_approximate().expect("ok");
1901        assert!(
1902            result.total_length_km > 0.0,
1903            "total length should be positive"
1904        );
1905    }
1906
1907    #[test]
1908    fn test_mst_baseline_connected() {
1909        let solver = make_triangle();
1910        let mst = solver.solve_mst_baseline().expect("MST ok");
1911        assert!(mst.is_connected, "MST result must be connected");
1912    }
1913
1914    #[test]
1915    fn test_expansion_zero_budget() {
1916        let planner = make_planner_single();
1917        let plan = planner.optimize_greedy(0.0).expect("zero budget ok");
1918        assert!(plan.investments.is_empty(), "zero budget → no investments");
1919        assert!((plan.total_cost_million_eur).abs() < 1e-9);
1920    }
1921
1922    #[test]
1923    fn test_network_edge_susceptance_zero_reactance() {
1924        let edge = NetworkEdge {
1925            from: 0,
1926            to: 1,
1927            length_km: 1.0,
1928            voltage_kv: 110.0,
1929            capacity_mw: 100.0,
1930            cost_million_eur: 1.0,
1931            resistance_pu: 0.01,
1932            reactance_pu: 0.0,
1933            is_existing: false,
1934            build_years: 1.0,
1935        };
1936        assert_eq!(
1937            edge.susceptance_pu(),
1938            0.0,
1939            "zero reactance → zero susceptance"
1940        );
1941    }
1942}