Skip to content

Commit 50a005c

Browse files
committed
perf+docs(perturbation-sim): fast weakest_links (Fiedler sensitivity) + AMX two-sided note
weakest_links example: replaced the O(m) full-eigensolve structural sweep with first-order Fiedler sensitivity ∂λ₂/∂wₑ = (v₂[a]−v₂[b])²·bₑ — one eigensolve ranks all m lines, exact λ₂-loss recomputed only for the top 10 (bridge flag); operational cascade bounded to the top-25 candidates (max_rounds 16). The full N-1 cascade sweep was intractable (timeout). METHODS §10: corrected the AMX note to the two-sided picture — sign side (Walsh/XOR WHT) = AVX-512 f32, wired here; magnitude side (EWA-splat / Morton tile) = AMX bf16/int8 tile-GEMM in ndarray (bf16_tile_gemm/amx_matmul/edge_codec), genuinely AMX-backed but not yet wired in this crate.
1 parent fa061e4 commit 50a005c

2 files changed

Lines changed: 53 additions & 22 deletions

File tree

crates/perturbation-sim/METHODS.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,10 +327,15 @@ turning a modeling assumption into a testable claim.
327327

328328
### `ndarray-simd` feature (the Morton-pyramid transform, accelerated)
329329
The pyramid's Walsh–Hadamard transform routes through **`ndarray::simd::wht_f32`**
330-
(AVX-512 under `target-cpu=x86-64-v4`; **AMX is NOT used** — AMX is int8/bf16
331-
tile-GEMM, whereas the WHT is f32 and the field tier f64. An int8 AMX path
332-
(`ndarray::simd::matmul_i8_to_i32`) for a quantized resistance sketch is a
333-
possible future wiring, not present) — the one workspace-sanctioned SIMD
330+
(AVX-512 under `target-cpu=x86-64-v4`). **Two-sided picture (per the OGAR
331+
two-algebra rule):** the **sign side** (Walsh/XOR WHT) is what this crate wires —
332+
`wht_f32`, **AVX-512 f32**, *not* AMX. The **magnitude side** (the EWA Gaussian-
333+
splat / Morton-tile coarsening) maps onto ndarray's **AMX bf16/int8 tile-GEMM**
334+
(`bf16_tile_gemm` / `amx_matmul` / `edge_codec`'s `matmul_i8_to_i32`) — genuinely
335+
AMX-backed in ndarray, but **not yet wired here** (this crate's field tier is
336+
f64, its WHT f32). Wiring the magnitude/tile path (or an int8 resistance sketch
337+
via `matmul_i8_to_i32`) is the AMX entry point — the unwired half) — the one
338+
workspace-sanctioned SIMD
334339
source (never raw intrinsics here). Default **OFF** → scalar `fwht`, zero-dep;
335340
**ON** via `--features ndarray-simd` (ndarray fork as a git/path dep, `["std"]`).
336341
Both paths pass the same tests. Deeper *tile-specific* ndarray targets, to wire

crates/perturbation-sim/examples/weakest_links.rs

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
//! --example weakest_links -- /tmp/pypsa/buses.csv /tmp/pypsa/lines.csv ES
1313
1414
use 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

1918
struct 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

Comments
 (0)