1212//! --example weakest_links -- /tmp/pypsa/buses.csv /tmp/pypsa/lines.csv ES
1313
1414use perturbation_sim:: {
15- cheeger_sweep, dc_flows, simulate_outage, spectral_perturbation, symmetric_eigen,
16- CascadeConfig , Edge , Grid ,
15+ cheeger_sweep, dc_flows, simulate_outage, symmetric_eigen, CascadeConfig , Edge , Grid ,
1716} ;
1817
1918struct Rng ( u64 ) ;
@@ -66,26 +65,48 @@ fn main() {
6665 let alive = vec ! [ true ; m] ;
6766 let lbl = |e : usize | format ! ( "{}–{}" , ids[ grid. edges[ e] . from] , ids[ grid. edges[ e] . to] ) ;
6867
69- // 1. STRUCTURAL weakest links: single-trip λ₂-loss (no limits, no cascade).
70- let mut struct_rank: Vec < ( usize , f64 , bool ) > = ( 0 ..m)
68+ // 1. STRUCTURAL weakest links via first-order Fiedler sensitivity.
69+ // ∂λ₂/∂wₑ = (v₂[a]−v₂[b])² (exact derivative), so removing line e drops
70+ // λ₂ by ≈ (v₂[a]−v₂[b])²·bₑ to first order. One eigensolve ranks all m
71+ // lines; the exact λ₂-loss is then recomputed only for the top few.
72+ let base_eig = symmetric_eigen ( & grid. laplacian_of ( & alive) , n) ;
73+ let lam2 = base_eig. values . get ( 1 ) . copied ( ) . unwrap_or ( 0.0 ) ;
74+ let v2 = base_eig. eigenvector ( 1 ) ;
75+ let mut struct_rank: Vec < ( usize , f64 ) > = ( 0 ..m)
7176 . map ( |e| {
72- let sp = spectral_perturbation ( & grid, & alive, e) ;
73- ( e, sp. connectivity_loss ( ) , sp. fiedler_after . abs ( ) < 1e-9 )
77+ let ( a, b) = ( grid. edges [ e] . from , grid. edges [ e] . to ) ;
78+ let d = v2[ a] - v2[ b] ;
79+ ( e, d * d * grid. edges [ e] . susceptance ) // first-order Δλ₂ proxy
7480 } )
7581 . collect ( ) ;
76- struct_rank. sort_by ( |a , b| b . 1 . partial_cmp ( & a . 1 ) . unwrap ( ) ) ;
82+ struct_rank. sort_by ( |x , y| y . 1 . partial_cmp ( & x . 1 ) . unwrap ( ) ) ;
7783
78- println ! ( "== 1. Structural weakest links (single-trip λ₂ loss; pure topology) ==" ) ;
79- for ( e, loss, splits) in struct_rank. iter ( ) . take ( 10 ) {
84+ println ! ( "== 1. Structural weakest links (first-order Fiedler sensitivity ∂λ₂/∂wₑ) ==" ) ;
85+ println ! ( " base λ₂ = {lam2:.6}" ) ;
86+ let mut bridges = 0usize ;
87+ for ( e, sens) in struct_rank. iter ( ) . take ( 10 ) {
88+ // Exact recompute for the top lines only.
89+ let mut after = alive. clone ( ) ;
90+ after[ * e] = false ;
91+ let lam2_after = symmetric_eigen ( & grid. laplacian_of ( & after) , n)
92+ . values
93+ . get ( 1 )
94+ . copied ( )
95+ . unwrap_or ( 0.0 ) ;
96+ let loss = if lam2 > 1e-12 { 1.0 - lam2_after / lam2 } else { 0.0 } ;
97+ let splits = lam2_after < 1e-9 ;
98+ if splits {
99+ bridges += 1 ;
100+ }
80101 println ! (
81- " line {e:>4} {:<16} λ₂-loss {:>6.2}%{}" ,
102+ " line {e:>4} {:<16} sens {:>8.5} exact λ₂-loss {:>6.2}%{}" ,
82103 lbl( * e) ,
104+ sens,
83105 100.0 * loss,
84- if * splits { " ← cutting it DISCONNECTS the grid (bridge )" } else { "" }
106+ if splits { " ← BRIDGE (trip disconnects the core )" } else { "" }
85107 ) ;
86108 }
87- let bridges = struct_rank. iter ( ) . filter ( |( _, _, s) | * s) . count ( ) ;
88- println ! ( " → {bridges} single lines are bridges (their trip alone disconnects the core)\n " ) ;
109+ println ! ( " → {bridges}/10 top-sensitivity lines are bridges\n " ) ;
89110
90111 // 2. CHEEGER local boundary — where the grid wants to separate (the flap).
91112 let c = cheeger_sweep ( & grid, & alive) ;
@@ -117,15 +138,20 @@ fn main() {
117138 for ( e, edge) in g. edges . iter_mut ( ) . enumerate ( ) {
118139 edge. limit = ( 1.1 * base[ e] . abs ( ) ) . max ( 1e-6 ) ;
119140 }
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 ( ) ) ;
141+ // Cascade only the top structural candidates (full N-1 is O(m·rounds)
142+ // eigensolves — intractable at m=348); bound rounds too.
143+ let cfg = CascadeConfig { max_rounds : 16 , ..CascadeConfig :: default ( ) } ;
144+ let candidates: Vec < usize > = struct_rank. iter ( ) . take ( 25 ) . map ( |x| x. 0 ) . collect ( ) ;
145+ let mut op_rank: Vec < ( usize , usize , f64 , bool ) > = candidates
146+ . iter ( )
147+ . map ( |& e| {
148+ let r = simulate_outage ( & g, & p, e, cfg) ;
123149 ( e, r. shape . n_tripped ( ) , r. fraction_tripped , r. islanded )
124150 } )
125151 . collect ( ) ;
126152 op_rank. sort_by_key ( |x| std:: cmp:: Reverse ( x. 1 ) ) ;
127153
128- println ! ( "== 3. Operational weakest links (N-1 cascade size, headroom ×1.1) ==" ) ;
154+ println ! ( "== 3. Operational weakest links (cascade size of the top-25 structural candidates , headroom ×1.1) ==" ) ;
129155 for ( e, ntrip, frac, islanded) in op_rank. iter ( ) . take ( 10 ) {
130156 println ! (
131157 " seed {e:>4} {:<16} → {ntrip:>3} lines ({:>4.1}%){}" ,
@@ -135,7 +161,7 @@ fn main() {
135161 ) ;
136162 }
137163 let big = op_rank. iter ( ) . filter ( |( _, nt, _, _) | * nt >= 3 ) . count ( ) ;
138- println ! ( " → {big}/{m} seed trips cascade to ≥3 lines under 10% headroom\n " ) ;
164+ println ! ( " → {big}/{} candidate seed trips cascade to ≥3 lines under 10% headroom\n " , candidates . len ( ) ) ;
139165
140166 println ! (
141167 "Reads: structural rank = WHERE the grid is topologically thin (bridges/cut);\n \
0 commit comments