|
| 1 | +//! Weakest-links + local-boundary ("flap") analysis. |
| 2 | +//! |
| 3 | +//! On a grid (real PyPSA core via args, else synthetic), reports: |
| 4 | +//! 1. STRUCTURAL weakest links — per-line single-trip algebraic-connectivity |
| 5 | +//! loss (Weyl/Fiedler; limit-independent → pure topology). |
| 6 | +//! 2. The CHEEGER local boundary — the min-conductance sweep cut: which buses |
| 7 | +//! sit on the small side and how many lines cross it (the seam that flaps). |
| 8 | +//! 3. OPERATIONAL weakest links — per-line N-1 cascade size under |
| 9 | +//! self-calibrated limits (which seed trip cascades furthest). |
| 10 | +//! |
| 11 | +//! Run: cargo run --release --manifest-path crates/perturbation-sim/Cargo.toml \ |
| 12 | +//! --example weakest_links -- /tmp/pypsa/buses.csv /tmp/pypsa/lines.csv ES |
| 13 | +
|
| 14 | +use perturbation_sim::{ |
| 15 | + cheeger_sweep, dc_flows, simulate_outage, spectral_perturbation, symmetric_eigen, |
| 16 | + CascadeConfig, Edge, Grid, |
| 17 | +}; |
| 18 | + |
| 19 | +struct Rng(u64); |
| 20 | +impl Rng { |
| 21 | + fn f(&mut self) -> f64 { |
| 22 | + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); |
| 23 | + let mut z = self.0; |
| 24 | + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); |
| 25 | + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); |
| 26 | + ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64 |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +fn synthetic(rows: usize, cols: usize) -> (Grid, Vec<String>) { |
| 31 | + let id = |r: usize, c: usize| r * cols + c; |
| 32 | + let mut edges = Vec::new(); |
| 33 | + for r in 0..rows { |
| 34 | + for c in 0..cols { |
| 35 | + if c + 1 < cols { |
| 36 | + edges.push(Edge::new(id(r, c), id(r, c + 1), 1.0, 1.0)); |
| 37 | + } |
| 38 | + if r + 1 < rows { |
| 39 | + edges.push(Edge::new(id(r, c), id(r + 1, c), 1.0, 1.0)); |
| 40 | + } |
| 41 | + } |
| 42 | + } |
| 43 | + let ids = (0..rows * cols).map(|i| i.to_string()).collect(); |
| 44 | + (Grid::new(rows * cols, edges), ids) |
| 45 | +} |
| 46 | + |
| 47 | +fn main() { |
| 48 | + let args: Vec<String> = std::env::args().collect(); |
| 49 | + let (grid, ids) = if args.len() >= 3 { |
| 50 | + let buses = std::fs::read_to_string(&args[1]).expect("buses.csv"); |
| 51 | + let lines = std::fs::read_to_string(&args[2]).expect("lines.csv"); |
| 52 | + let cc = args.get(3).map(|s| s.as_str()).unwrap_or("ES"); |
| 53 | + let imp = perturbation_sim::from_pypsa_csv(&buses, &lines, Some(cc)) |
| 54 | + .expect("import") |
| 55 | + .largest_component(); |
| 56 | + println!("grid: {cc} PyPSA core — {} buses, {} lines\n", imp.grid.n, imp.grid.edges.len()); |
| 57 | + (imp.grid, imp.bus_ids) |
| 58 | + } else { |
| 59 | + let (g, ids) = synthetic(6, 6); |
| 60 | + println!("grid: synthetic 6×6 — {} buses, {} lines\n", g.n, g.edges.len()); |
| 61 | + (g, ids) |
| 62 | + }; |
| 63 | + |
| 64 | + let n = grid.n; |
| 65 | + let m = grid.edges.len(); |
| 66 | + let alive = vec![true; m]; |
| 67 | + let lbl = |e: usize| format!("{}–{}", ids[grid.edges[e].from], ids[grid.edges[e].to]); |
| 68 | + |
| 69 | + // 1. STRUCTURAL weakest links: single-trip λ₂-loss (no limits, no cascade). |
| 70 | + let mut struct_rank: Vec<(usize, f64, bool)> = (0..m) |
| 71 | + .map(|e| { |
| 72 | + let sp = spectral_perturbation(&grid, &alive, e); |
| 73 | + (e, sp.connectivity_loss(), sp.fiedler_after.abs() < 1e-9) |
| 74 | + }) |
| 75 | + .collect(); |
| 76 | + struct_rank.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); |
| 77 | + |
| 78 | + println!("== 1. Structural weakest links (single-trip λ₂ loss; pure topology) =="); |
| 79 | + for (e, loss, splits) in struct_rank.iter().take(10) { |
| 80 | + println!( |
| 81 | + " line {e:>4} {:<16} λ₂-loss {:>6.2}%{}", |
| 82 | + lbl(*e), |
| 83 | + 100.0 * loss, |
| 84 | + if *splits { " ← cutting it DISCONNECTS the grid (bridge)" } else { "" } |
| 85 | + ); |
| 86 | + } |
| 87 | + let bridges = struct_rank.iter().filter(|(_, _, s)| *s).count(); |
| 88 | + println!(" → {bridges} single lines are bridges (their trip alone disconnects the core)\n"); |
| 89 | + |
| 90 | + // 2. CHEEGER local boundary — where the grid wants to separate (the flap). |
| 91 | + let c = cheeger_sweep(&grid, &alive); |
| 92 | + let small = c.partition.iter().filter(|&&b| b).count(); |
| 93 | + let cut_lines: Vec<usize> = (0..m) |
| 94 | + .filter(|&e| c.partition[grid.edges[e].from] != c.partition[grid.edges[e].to]) |
| 95 | + .collect(); |
| 96 | + println!("== 2. Cheeger local boundary (the seam that flaps) =="); |
| 97 | + println!(" μ₂ (normalized gap) : {:.5}", c.mu2); |
| 98 | + println!(" conductance φ of the cut : {:.5} (Cheeger {:.5} ≤ h ≤ {:.5})", c.conductance, c.lower, c.upper); |
| 99 | + println!(" partition : {small} | {} buses (small side | rest)", n - small); |
| 100 | + println!(" the boundary crosses {} lines:", cut_lines.len()); |
| 101 | + for &e in cut_lines.iter().take(8) { |
| 102 | + println!(" line {e:>4} {}", lbl(e)); |
| 103 | + } |
| 104 | + if cut_lines.len() > 8 { |
| 105 | + println!(" … +{} more", cut_lines.len() - 8); |
| 106 | + } |
| 107 | + println!(); |
| 108 | + |
| 109 | + // 3. OPERATIONAL weakest links: N-1 cascade size under self-calibrated limits. |
| 110 | + let mut rng = Rng(0xBEEF); |
| 111 | + let raw: Vec<f64> = (0..n).map(|_| rng.f()).collect(); |
| 112 | + let mean = raw.iter().sum::<f64>() / n as f64; |
| 113 | + let p: Vec<f64> = raw.iter().map(|x| x - mean).collect(); |
| 114 | + let mut g = grid.clone(); |
| 115 | + let eig = symmetric_eigen(&g.laplacian_of(&alive), n); |
| 116 | + let base = dc_flows(&g, &alive, &eig.pseudo_apply(&p, 1e-9)); |
| 117 | + for (e, edge) in g.edges.iter_mut().enumerate() { |
| 118 | + edge.limit = (1.1 * base[e].abs()).max(1e-6); |
| 119 | + } |
| 120 | + let mut op_rank: Vec<(usize, usize, f64, bool)> = (0..m) |
| 121 | + .map(|e| { |
| 122 | + let r = simulate_outage(&g, &p, e, CascadeConfig::default()); |
| 123 | + (e, r.shape.n_tripped(), r.fraction_tripped, r.islanded) |
| 124 | + }) |
| 125 | + .collect(); |
| 126 | + op_rank.sort_by_key(|x| std::cmp::Reverse(x.1)); |
| 127 | + |
| 128 | + println!("== 3. Operational weakest links (N-1 cascade size, headroom ×1.1) =="); |
| 129 | + for (e, ntrip, frac, islanded) in op_rank.iter().take(10) { |
| 130 | + println!( |
| 131 | + " seed {e:>4} {:<16} → {ntrip:>3} lines ({:>4.1}%){}", |
| 132 | + lbl(*e), |
| 133 | + 100.0 * frac, |
| 134 | + if *islanded { " ISLANDS the grid" } else { "" } |
| 135 | + ); |
| 136 | + } |
| 137 | + let big = op_rank.iter().filter(|(_, nt, _, _)| *nt >= 3).count(); |
| 138 | + println!(" → {big}/{m} seed trips cascade to ≥3 lines under 10% headroom\n"); |
| 139 | + |
| 140 | + println!( |
| 141 | + "Reads: structural rank = WHERE the grid is topologically thin (bridges/cut);\n\ |
| 142 | + the Cheeger boundary = the seam it separates along; operational rank = WHICH\n\ |
| 143 | + seed trips snowball once loaded. Synthetic injections + estimated limits —\n\ |
| 144 | + feed real ENTSO-E/ESIOS load for the operational ranking; significance via Jirak." |
| 145 | + ); |
| 146 | +} |
0 commit comments