1use crate::error::OxiGridError;
15use std::cmp::Ordering;
16use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
17
18#[derive(Debug, Clone)]
24pub struct NetworkEdge {
25 pub from: usize,
27 pub to: usize,
29 pub length_km: f64,
31 pub voltage_kv: f64,
33 pub capacity_mw: f64,
35 pub cost_million_eur: f64,
37 pub resistance_pu: f64,
39 pub reactance_pu: f64,
41 pub is_existing: bool,
43 pub build_years: f64,
45}
46
47impl NetworkEdge {
48 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#[derive(Debug, Clone)]
60pub struct TopologyNode {
61 pub id: usize,
63 pub is_terminal: bool,
65 pub is_substation: bool,
67 pub peak_load_mw: f64,
69 pub peak_generation_mw: f64,
71 pub x: f64,
73 pub y: f64,
75}
76
77pub struct SteinerTreeSolver {
90 pub nodes: Vec<TopologyNode>,
92 pub edges: Vec<NetworkEdge>,
94}
95
96#[derive(Debug, Clone)]
98pub struct SteinerTreeResult {
99 pub selected_edges: Vec<usize>,
101 pub total_cost_million_eur: f64,
103 pub total_length_km: f64,
105 pub is_connected: bool,
107 pub radial: bool,
109}
110
111#[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 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 pub fn new(nodes: Vec<TopologyNode>, edges: Vec<NetworkEdge>) -> Self {
139 Self { nodes, edges }
140 }
141
142 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 let (dist_matrix, path_matrix) = self.all_pairs_shortest_paths(&terminals)?;
169
170 let mst_edges_in_closure = self.mst_of_metric_closure(&terminals, &dist_matrix)?;
172
173 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 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 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 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 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 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 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 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 fn is_radial(n_nodes: usize, selected_edges: &[usize], edges: &[NetworkEdge]) -> bool {
316 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 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 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 fn reconstruct_path(&self, target: usize, pred_edge: &[Option<usize>]) -> Vec<usize> {
376 let mut path = Vec::new();
377 let mut cur = target;
378 let mut seen = HashSet::new();
381 loop {
382 if seen.contains(&cur) {
383 break; }
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 cur = if e.to == cur { e.from } else { e.to };
393 }
394 }
395 }
396 path
397 }
398
399 #[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 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 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 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 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 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 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 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 let leaf = degree
515 .iter()
516 .find(|(&node, °)| deg <= 1 && !terminal_set.contains(&node))
517 .map(|(&node, _)| node);
518
519 match leaf {
520 None => break,
521 Some(leaf_node) => {
522 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
540struct 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
581pub struct ExpansionPlanner {
590 pub existing_network: Vec<NetworkEdge>,
592 pub candidate_lines: Vec<NetworkEdge>,
594 pub nodes: Vec<TopologyNode>,
596 pub planning_years: usize,
598 pub discount_rate: f64,
600 pub n_scenarios: usize,
602 pub load_growth_rate: f64,
604}
605
606#[derive(Debug, Clone)]
608pub struct ExpansionCandidate {
609 pub line_idx: usize,
611 pub build_year: usize,
613 pub npv_benefit_million_eur: f64,
615 pub npv_cost_million_eur: f64,
617 pub bcr: f64,
619 pub congestion_relief_mw: f64,
621}
622
623#[derive(Debug, Clone)]
625pub struct ExpansionPlan {
626 pub investments: Vec<ExpansionCandidate>,
628 pub total_cost_million_eur: f64,
630 pub total_benefit_million_eur: f64,
632 pub total_npv_million_eur: f64,
634 pub years_to_capacity_limit: Vec<f64>,
636}
637
638const VOLL_MILLION_EUR_PER_MWH: f64 = 0.010; const OPERATING_HOURS_PER_YEAR: f64 = 8_760.0;
642
643impl ExpansionPlanner {
644 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 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 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 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; 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 candidates_this_year
730 .sort_by(|a, b| b.bcr.partial_cmp(&a.bcr).unwrap_or(Ordering::Equal));
731
732 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 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 } else {
775 b_system / (b_system + b_system) }
777 }
778
779 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 total_capacity >= total_load
798 }
799
800 pub fn n1_security_check(&self, plan: &ExpansionPlan) -> Vec<(usize, bool)> {
805 plan.investments
806 .iter()
807 .map(|inv| {
808 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 fn npv_discount_factor(&self, year: usize) -> f64 {
825 (1.0 + self.discount_rate).powi(-(year as i32))
826 }
827
828 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 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 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); 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
868pub struct SubstationSiting {
877 pub load_points: Vec<(f64, f64, f64)>,
879 pub n_substations: usize,
881 pub voltage_kv: f64,
883 pub cable_cost_million_eur_per_km: f64,
885}
886
887#[derive(Debug, Clone)]
889pub struct SitingResult {
890 pub substation_locations: Vec<(f64, f64)>,
892 pub assignments: Vec<usize>,
894 pub total_cable_cost_million_eur: f64,
896 pub max_feeder_length_km: f64,
898 pub avg_feeder_length_km: f64,
900}
901
902impl SubstationSiting {
903 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 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 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 #[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 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] } 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 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 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 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 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#[cfg(test)]
1076mod tests {
1077 use super::*;
1078
1079 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 (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 #[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 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 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 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 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 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 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 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 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 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 assert!(!SteinerTreeSolver::is_connected(4, &[0, 1], &edges));
1490 }
1491
1492 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 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 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 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 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 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 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 let feasible = planner.check_feasibility(&[]);
1710 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; }
1719
1720 #[test]
1721 fn test_feasibility_check_exceeds_capacity() {
1722 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 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 fn make_load_points_2cluster() -> Vec<(f64, f64, f64)> {
1778 vec![
1780 (0.0, 0.0, 10.0),
1781 (1.0, 0.0, 12.0),
1782 (0.5, 1.0, 8.0), (20.0, 0.0, 15.0),
1784 (21.0, 0.0, 11.0),
1785 (20.5, 1.0, 9.0), ]
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 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 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 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 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}