Skip to content

Commit 8a3e335

Browse files
authored
Merge pull request #513 from AdaWorldAPI/claude/perturbation-sim-inertia-clam
feat(perturbation-sim): inertia §0 promotion gate + CAKES/CHAODA + witness standing-wave + H ingest
2 parents c3dddfc + 723472a commit 8a3e335

10 files changed

Lines changed: 1009 additions & 2 deletions

File tree

crates/perturbation-sim/Cargo.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,15 @@ path = "examples/calibrate.rs"
100100
[[example]]
101101
name = "hhtl_grid"
102102
path = "examples/hhtl_grid.rs"
103+
104+
[[example]]
105+
name = "chaoda"
106+
path = "examples/chaoda.rs"
107+
108+
[[example]]
109+
name = "witness"
110+
path = "examples/witness.rs"
111+
112+
[[example]]
113+
name = "inertia_ingest"
114+
path = "examples/inertia_ingest.rs"
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//! CAKES + CHAODA over the HHTL basins of a real grid core — the similarity
2+
//! (attraction) / anomaly (repulsion) pair from the CLAM family, scoring grid
3+
//! compartments the way CHAODA scores papillary muscles or terrain tiles.
4+
//!
5+
//! HHTL is the family basin ("where"); CAKES finds each basin's relatives ("who
6+
//! looks similar"); CHAODA scores how far a basin sits from its family ("why am I
7+
//! different") — the top-anomaly basin is the fail-first compartment.
8+
//!
9+
//! Run: cargo run --release --manifest-path crates/perturbation-sim/Cargo.toml \
10+
//! --example chaoda -- /tmp/pypsa/buses.csv /tmp/pypsa/lines.csv ES
11+
//! (no args → a synthetic 8×8 grid with one deliberately weakened block).
12+
13+
use perturbation_sim::{
14+
anomaly_ranking, cakes_neighbors, chaoda_scores, resilience_basin_features, Edge, Grid,
15+
HhtlKey, CHAODA_FLAG,
16+
};
17+
18+
/// Synthetic 8×8 lattice with one corner block weakened (low-susceptance internal
19+
/// edges) — a planted fail-first compartment for the no-data demo.
20+
fn synthetic() -> Grid {
21+
let (rows, cols) = (8usize, 8usize);
22+
let id = |r: usize, c: usize| r * cols + c;
23+
let mut e = Vec::new();
24+
for r in 0..rows {
25+
for c in 0..cols {
26+
// The 3×3 top-left block is brittle (weak internal coupling).
27+
let brittle = r < 3 && c < 3;
28+
let b = if brittle { 0.05 } else { 1.0 };
29+
if c + 1 < cols {
30+
e.push(Edge::new(id(r, c), id(r, c + 1), b, 1.0));
31+
}
32+
if r + 1 < rows {
33+
e.push(Edge::new(id(r, c), id(r + 1, c), b, 1.0));
34+
}
35+
}
36+
}
37+
Grid::new(rows * cols, e)
38+
}
39+
40+
/// Deterministic per-bus inertia proxy (real `H` is not in PyPSA topology): a small
41+
/// fixed cycle, decoupled from wiring, so the buffer axis is an independent input —
42+
/// the structure (basins as families, the outlier as fail-first) holds regardless.
43+
fn proxy_inertia(n: usize) -> Vec<f64> {
44+
(0..n).map(|i| 2.0 + (i % 5) as f64).collect()
45+
}
46+
47+
fn fmt_key(k: &HhtlKey) -> String {
48+
format!("{}.{}.{}", k.heel, k.hip, k.twig)
49+
}
50+
51+
fn main() {
52+
let args: Vec<String> = std::env::args().collect();
53+
let grid = if args.len() >= 3 {
54+
let buses = std::fs::read_to_string(&args[1]).expect("buses.csv");
55+
let lines = std::fs::read_to_string(&args[2]).expect("lines.csv");
56+
let cc = args.get(3).map(|s| s.as_str()).unwrap_or("ES");
57+
let imp = perturbation_sim::from_pypsa_csv(&buses, &lines, Some(cc))
58+
.expect("import")
59+
.largest_component();
60+
println!("grid: {cc} PyPSA core — {} buses", imp.grid.n);
61+
imp.grid
62+
} else {
63+
let g = synthetic();
64+
println!(
65+
"grid: synthetic 8×8 with a planted brittle 3×3 block — {} buses",
66+
g.n
67+
);
68+
g
69+
};
70+
71+
let h = proxy_inertia(grid.n);
72+
let (basins, rows) = resilience_basin_features(&grid, &h, 0.2);
73+
let scores = chaoda_scores(&rows, 2);
74+
75+
println!("\n== HHTL family basins — CAKES (similar) + CHAODA (anomalous) ==");
76+
println!(
77+
" {:>10} {:>6} {:>8} {:>8} {:>8} flag",
78+
"basin", "size", "λ₂n", "inertia", "CHAODA"
79+
);
80+
for (i, k) in basins.iter().enumerate() {
81+
let flag = if scores[i] >= CHAODA_FLAG {
82+
"◀ ANOMALY"
83+
} else {
84+
""
85+
};
86+
println!(
87+
" {:>10} {:>6.2} {:>8.3} {:>8.3} {:>8.3} {}",
88+
fmt_key(k),
89+
rows[i][1],
90+
rows[i][0],
91+
rows[i][2],
92+
scores[i],
93+
flag
94+
);
95+
}
96+
97+
// CHAODA: the fail-first compartment is the top anomaly.
98+
let rank = anomaly_ranking(&rows, 2);
99+
if let Some(&(top, score)) = rank.first() {
100+
println!(
101+
"\n CHAODA → fail-first compartment: basin {} (score {:.3}{})\n \
102+
\"why am I different\" — the basin whose resilience profile deviates most\n \
103+
from its family. {} basins; flag threshold {CHAODA_FLAG}.",
104+
fmt_key(&basins[top]),
105+
score,
106+
if score >= CHAODA_FLAG {
107+
", FLAGGED"
108+
} else {
109+
""
110+
},
111+
basins.len()
112+
);
113+
114+
// CAKES: the top-anomaly basin's nearest relatives ("who looks similar").
115+
let nbrs = cakes_neighbors(&rows, top, 3);
116+
let rel: Vec<String> = nbrs
117+
.iter()
118+
.map(|(i, d)| format!("{} (d={:.3})", fmt_key(&basins[*i]), d))
119+
.collect();
120+
println!(
121+
" CAKES → {}'s nearest relatives: [{}]\n \
122+
attraction vs repulsion: the family it resembles, and how far it still sits from them.",
123+
fmt_key(&basins[top]),
124+
rel.join(", ")
125+
);
126+
}
127+
128+
println!(
129+
"\n CAKES pulls in the similar; CHAODA pushes out the unusual.\n \
130+
Family basin (HHTL) + deviation-from-family (CHAODA) = the fail-first locator.\n \
131+
(CHAODA-lite kNN scorer; ndarray::clam's ClamTree ensemble is the gated production path.)"
132+
);
133+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! Probe 3 — the per-bus inertia (H) ingest path: parse a `bus,H` table, align it
2+
//! to the grid's bus order, disclose the proxy fallback, and feed the
3+
//! inertia-buffer column. No external data is bundled; this uses an inline fixture
4+
//! standing in for an ENTSO-E / ESIOS / TSO inertia export.
5+
//!
6+
//! Run: cargo run --release --manifest-path crates/perturbation-sim/Cargo.toml \
7+
//! --example inertia_ingest
8+
9+
use perturbation_sim::{
10+
inertia_buffer_column, inertia_for_buses, parse_bus_inertia, proxy_inertia,
11+
};
12+
13+
// Stand-in for an operator inertia export (real H is never committed).
14+
const INERTIA_CSV: &str = "\
15+
bus_id,inertia_h,source
16+
ES_NUC_1,6.5,nuclear
17+
ES_HYD_1,4.0,hydro
18+
ES_GAS_1,5.5,gas-synchronous
19+
ES_WND_1,0.8,wind-synthetic
20+
";
21+
22+
fn main() {
23+
// The grid's buses (would come from PypsaImport.bus_ids). ES_SOLAR_1 has no
24+
// measured H → it falls back to the proxy value, and that is disclosed.
25+
let bus_ids: Vec<String> = ["ES_NUC_1", "ES_HYD_1", "ES_GAS_1", "ES_WND_1", "ES_SOLAR_1"]
26+
.iter()
27+
.map(|s| s.to_string())
28+
.collect();
29+
30+
let measured = parse_bus_inertia(INERTIA_CSV);
31+
let fallback = 1.0; // low-inertia stand-in for an undocumented (likely inverter) bus
32+
let (h, prov) = inertia_for_buses(&bus_ids, &measured, fallback);
33+
let col = inertia_buffer_column(&h, 0.2);
34+
35+
println!("per-bus inertia ingest (measured H → grid order → buffer column)\n");
36+
println!(
37+
" {:>12} {:>8} {:>10} source",
38+
"bus", "H (s)", "buffer_n"
39+
);
40+
for (i, id) in bus_ids.iter().enumerate() {
41+
let src = if measured.contains_key(id) {
42+
"measured"
43+
} else {
44+
"proxy"
45+
};
46+
println!(" {:>12} {:>8.2} {:>10.3} {src}", id, h[i], col[i]);
47+
}
48+
println!(
49+
"\n provenance: {} measured, {} proxy (disclose the proxy fraction, like\n \
50+
PypsaImport's n_estimated_* counters).",
51+
prov.measured, prov.proxy
52+
);
53+
54+
// No-data path: a fully deterministic, topology-blind proxy field.
55+
let demo = proxy_inertia(bus_ids.len(), 2.0, 6.0, 0xDEFA17);
56+
println!(
57+
"\n no-data fallback — proxy_inertia(n=5, base=2, span=6, seed=0xDEFA17):\n \
58+
[{}]\n decoupled from wiring on purpose (buffer ⊥ topology); deterministic, never\n \
59+
bundled. Wire measured H when an operator export is available; the column math\n \
60+
is identical either way.",
61+
demo.iter()
62+
.map(|h| format!("{h:.2}"))
63+
.collect::<Vec<_>>()
64+
.join(", ")
65+
);
66+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//! The witness arc as a standing wave (METHODS §11) — particle (pointer-chase) vs
2+
//! wave (Walsh-pyramid Parseval), on a real grid inertia field.
3+
//!
4+
//! Builds the per-bus inertia-buffer field, then reads three witness arcs both ways:
5+
//! the particle walk (`O(hops)` per arc) and the standing wave (one `field_spectrum`
6+
//! transform, then `O(N)` per arc). They agree to floating point — and the wave form
7+
//! amortizes one transform across all arcs.
8+
//!
9+
//! Run: cargo run --release --manifest-path crates/perturbation-sim/Cargo.toml \
10+
//! --example witness
11+
12+
use perturbation_sim::{
13+
field_spectrum, inertia_buffer_column, witness_from_spectrum, witness_particle,
14+
};
15+
16+
fn proxy_inertia(n: usize) -> Vec<f64> {
17+
(0..n).map(|i| 2.0 + (i % 5) as f64).collect()
18+
}
19+
20+
fn main() {
21+
// The field: a per-bus inertia-buffer field over a 16-bus line (METHODS §11's
22+
// "inertia field on power grids"; the promoted additive member as the carrier).
23+
let n = 16;
24+
let field = inertia_buffer_column(&proxy_inertia(n), 0.2);
25+
let field: Vec<f64> = field.iter().map(|&x| x as f64).collect();
26+
27+
// Three witness arcs (Markov #1 reference chains over the buses):
28+
// - a single-hop witness (read one bus),
29+
// - a coarse dyadic arc (the first half — a Raumgewinn-scale reference),
30+
// - an alternating signed chain (a fine-scale infight reference).
31+
let mut single = vec![0.0; n];
32+
single[5] = 1.0;
33+
let mut coarse = vec![0.0; n];
34+
for c in coarse.iter_mut().take(n / 2) {
35+
*c = 1.0;
36+
}
37+
let alt: Vec<f64> = (0..n)
38+
.map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
39+
.collect();
40+
let arcs: [(&str, &[f64]); 3] = [
41+
("single-hop", &single),
42+
("coarse dyadic", &coarse),
43+
("alternating", &alt),
44+
];
45+
46+
// Wave view: transform the field ONCE; every arc is then an O(N) read off it.
47+
let spectrum = field_spectrum(&field);
48+
49+
println!("witness arc as a standing wave — particle (walk) vs wave (Parseval)\n");
50+
println!(" field: {n}-bus inertia-buffer field; one Walsh transform reused by all arcs\n");
51+
println!(
52+
" {:>14} {:>14} {:>14} {:>10}",
53+
"arc", "particle", "wave", "Δ"
54+
);
55+
let mut max_err = 0.0_f64;
56+
for (name, arc) in arcs {
57+
let p = witness_particle(&field, arc);
58+
let w = witness_from_spectrum(&spectrum, arc);
59+
let d = (p - w).abs();
60+
max_err = max_err.max(d);
61+
println!(" {name:>14} {p:>14.9} {w:>14.9} {d:>10.2e}");
62+
}
63+
println!(
64+
"\n max |particle − wave| = {max_err:.2e} (Parseval: Hᵀ H = N·I, exact up to fp).\n \
65+
particle = O(hops) pointer-chase per arc; wave = O(N log N) once + O(N) per arc.\n \
66+
The standing wave IS the witness arc — evaluated all at once, no chain walk.\n \
67+
(Demonstration in perturbation-sim; the contract witness_table evaluator is the\n \
68+
separate gated step — the SoA spine is additive-only behind the iron rules.)"
69+
);
70+
}

crates/perturbation-sim/src/buffer.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,57 @@ pub fn compartment_buffer(inertia_h: &[f64], df_band: f64) -> f64 {
7474
inertia_h.iter().map(|&h| impulse_buffer(h, df_band)).sum()
7575
}
7676

77+
/// The `inertia_buffer` SoA column for a set of buses: each bus's impulse buffer
78+
/// ([`impulse_buffer`]) min-max **normalized to `[0,1]`** — the form the calibrated
79+
/// [`crate::INERTIA`] spec stores (normalized, not raw physical units; normalizing
80+
/// also lifts the axis clear of the ICC variance-underflow guard). This is the
81+
/// promoted additive value member, *computed*. It is orthogonal to topology by the
82+
/// HHTL-OGAR key/value split (topology is the GUID key; this is one more value) —
83+
/// the structural fact `buffer_is_independent_of_connectivity` witnesses. Degenerate
84+
/// (all-equal `H`, or empty) input yields all-zeros, never `NaN`.
85+
pub fn inertia_buffer_column(per_bus_h: &[f64], df_band: f64) -> Vec<f32> {
86+
let raw: Vec<f64> = per_bus_h
87+
.iter()
88+
.map(|&h| impulse_buffer(h, df_band))
89+
.collect();
90+
let lo = raw.iter().copied().fold(f64::INFINITY, f64::min);
91+
let hi = raw.iter().copied().fold(f64::NEG_INFINITY, f64::max);
92+
let span = hi - lo;
93+
raw.iter()
94+
.map(|&r| {
95+
if span > 0.0 {
96+
((r - lo) / span) as f32
97+
} else {
98+
0.0
99+
}
100+
})
101+
.collect()
102+
}
103+
77104
#[cfg(test)]
78105
mod tests {
79106
use super::*;
80107

108+
#[test]
109+
fn inertia_column_is_normalized_and_monotone_in_h() {
110+
// Per-bus inertia decoupled from any wiring: the column normalizes to [0,1]
111+
// and preserves the H order (impulse_buffer is monotone in H at fixed df).
112+
let h = [1.0_f64, 5.0, 3.0, 0.0, 8.0];
113+
let col = inertia_buffer_column(&h, 0.2);
114+
assert_eq!(col.len(), h.len());
115+
for &c in &col {
116+
assert!((0.0..=1.0).contains(&c), "normalized to [0,1]");
117+
}
118+
assert_eq!(col[3], 0.0, "H=0 is the min → 0.0");
119+
assert_eq!(col[4], 1.0, "H=8 is the max → 1.0");
120+
// Order preserved: H[0]=1 < H[2]=3 < H[1]=5 < H[4]=8.
121+
assert!(col[0] < col[2] && col[2] < col[1] && col[1] < col[4]);
122+
// Degenerate (all-equal H → no span) yields zeros, never NaN.
123+
let flat = inertia_buffer_column(&[4.0, 4.0, 4.0], 0.2);
124+
assert!(flat.iter().all(|&c| c == 0.0));
125+
assert!(inertia_buffer_column(&[], 0.2).is_empty());
126+
}
127+
81128
#[test]
82129
fn buffer_grows_with_inertia() {
83130
// Twice the inertia ⇒ twice the impulse absorbed (the storage scaling).

0 commit comments

Comments
 (0)