Skip to content

Commit 29c797d

Browse files
committed
feat(perturbation-sim): weakest_links example — N-1 weak-link ranking + Cheeger boundary
examples/weakest_links.rs: on a grid (real PyPSA core via args, else synthetic), reports (1) STRUCTURAL weakest links — per-line single-trip λ₂-loss (Weyl/Fiedler, limit-independent; flags bridges whose trip alone disconnects the core), (2) the CHEEGER local boundary — the min-conductance sweep cut (μ₂, φ, which buses on the small side, which lines cross the seam = the 'flap'), (3) OPERATIONAL weakest links — per-line N-1 cascade size under self-calibrated limits. Clippy-clean.
1 parent e5910d4 commit 29c797d

2 files changed

Lines changed: 150 additions & 0 deletions

File tree

crates/perturbation-sim/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ path = "examples/iberian.rs"
4343
[[example]]
4444
name = "validate"
4545
path = "examples/validate.rs"
46+
47+
[[example]]
48+
name = "weakest_links"
49+
path = "examples/weakest_links.rs"
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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

Comments
 (0)