Skip to content

Commit 723472a

Browse files
committed
feat(perturbation-sim): probe 3 — per-bus inertia (H) ingest path
Real per-bus inertia H is not in PyPSA/OSM topology (buffer.rs flags this); it comes from operator data (ENTSO-E inventory / ESIOS dispatch / TSO estimate). This is the ingest path, honest about provenance and bundling nothing: - parse_bus_inertia(&str): minimal `bus,H` CSV (`,`/`;`, column-alias bus + h), skipping non-positive/unparseable rows; extra columns ignored (ENTSO-E exports parse as-is). - inertia_for_buses(bus_ids, measured, fallback) -> (Vec<f64>, InertiaProvenance): align measured H to the grid bus order, fill misses with a fallback, and DISCLOSE measured-vs-proxy counts (mirrors PypsaImport's n_estimated_* honesty). - proxy_inertia(n, base, span, seed): deterministic SplitMix64 no-data stand-in, decoupled from wiring (buffer ⊥ topology preserved); never bundled. Feeds straight into inertia_buffer_column (probe-0 promotion gate). Example `inertia_ingest` parses an inline fixture, aligns to 5 buses (1 proxy fallback), prints the buffer column + provenance, and shows the no-data proxy field. Tests: +4 (parse skips bad rows, align discloses proxy fill, proxy deterministic + bounded, ingested H feeds the buffer column). fmt + clippy --all-targets -D warnings clean; 90 tests. No external data committed (network/data policy).
1 parent 006d232 commit 723472a

4 files changed

Lines changed: 255 additions & 0 deletions

File tree

crates/perturbation-sim/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,7 @@ path = "examples/chaoda.rs"
108108
[[example]]
109109
name = "witness"
110110
path = "examples/witness.rs"
111+
112+
[[example]]
113+
name = "inertia_ingest"
114+
path = "examples/inertia_ingest.rs"
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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
//! Probe 3 — per-bus inertia (`H`) ingest path.
2+
//!
3+
//! Real per-bus inertia constants `H` (seconds of stored rotational energy) are
4+
//! **not** in the PyPSA/OSM topology (`buffer.rs` flags this). They come from
5+
//! operator data: an ENTSO-E generation inventory, ESIOS dispatch, or a TSO inertia
6+
//! estimate, mapped to buses. This module is the **ingest path** — parse a `bus,H`
7+
//! table when available, align it to the grid's bus order ([`crate::PypsaImport::bus_ids`]),
8+
//! and **disclose** how many buses fell back to a proxy (mirroring `ingest.rs`'s
9+
//! `n_estimated_*` honesty). The result feeds [`crate::inertia_buffer_column`].
10+
//!
11+
//! **No external data is bundled** (network/data policy): the caller supplies the
12+
//! file contents (the lib takes `&str`, like `from_pypsa_csv`), or uses the
13+
//! deterministic [`proxy_inertia`] for a no-data demo. A proxy is decoupled from
14+
//! wiring on purpose — the buffer axis is storage, orthogonal to topology, so a
15+
//! topology-blind proxy is the honest stand-in until measured `H` is wired.
16+
17+
use std::collections::HashMap;
18+
19+
/// Provenance of an aligned per-bus inertia vector — how many buses carried a
20+
/// measured `H` vs a proxy fallback. Disclose it the way `PypsaImport` discloses
21+
/// estimated reactance/limit counts.
22+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23+
pub struct InertiaProvenance {
24+
/// Buses whose `H` came from the measured table.
25+
pub measured: usize,
26+
/// Buses that fell back to the proxy value.
27+
pub proxy: usize,
28+
}
29+
30+
/// Parse a `bus,H` table (CSV, `,` or `;`): a header naming a bus column
31+
/// (`bus_id` / `name` / `bus` / `bus0`) and an inertia column (`h` / `inertia` /
32+
/// `inertia_h` / `inertia_s` / `h_s`). Returns `bus_id → H`. Rows with an
33+
/// unparseable / non-positive `H` are skipped. Unknown columns are ignored, so an
34+
/// ENTSO-E/ESIOS export with extra fields parses as-is.
35+
pub fn parse_bus_inertia(text: &str) -> HashMap<String, f64> {
36+
let mut out = HashMap::new();
37+
let mut lines = text.lines().filter(|l| !l.trim().is_empty());
38+
let Some(header) = lines.next() else {
39+
return out;
40+
};
41+
let delim = if header.matches(';').count() > header.matches(',').count() {
42+
';'
43+
} else {
44+
','
45+
};
46+
let cols: Vec<String> = header
47+
.split(delim)
48+
.map(|h| h.trim().to_ascii_lowercase())
49+
.collect();
50+
let find = |names: &[&str]| -> Option<usize> {
51+
names.iter().find_map(|w| cols.iter().position(|c| c == w))
52+
};
53+
let (Some(bi), Some(hi)) = (
54+
find(&["bus_id", "name", "bus", "bus0"]),
55+
find(&["h", "inertia", "inertia_h", "inertia_s", "h_s"]),
56+
) else {
57+
return out;
58+
};
59+
for row in lines {
60+
let f: Vec<&str> = row.split(delim).collect();
61+
let (Some(b), Some(h)) = (f.get(bi), f.get(hi)) else {
62+
continue;
63+
};
64+
let bus = b.trim().to_string();
65+
if bus.is_empty() {
66+
continue;
67+
}
68+
if let Ok(val) = h.trim().parse::<f64>() {
69+
if val > 0.0 {
70+
out.insert(bus, val);
71+
}
72+
}
73+
}
74+
out
75+
}
76+
77+
/// Align a measured `bus → H` map to the grid's bus order, filling missing buses
78+
/// with `fallback`. Returns the per-bus `H` vector (length `bus_ids.len()`) plus its
79+
/// [`InertiaProvenance`] so the caller can disclose the proxy fraction.
80+
pub fn inertia_for_buses(
81+
bus_ids: &[String],
82+
measured: &HashMap<String, f64>,
83+
fallback: f64,
84+
) -> (Vec<f64>, InertiaProvenance) {
85+
let mut h = Vec::with_capacity(bus_ids.len());
86+
let mut n_measured = 0usize;
87+
for id in bus_ids {
88+
match measured.get(id) {
89+
Some(&v) => {
90+
n_measured += 1;
91+
h.push(v);
92+
}
93+
None => h.push(fallback),
94+
}
95+
}
96+
(
97+
h,
98+
InertiaProvenance {
99+
measured: n_measured,
100+
proxy: bus_ids.len() - n_measured,
101+
},
102+
)
103+
}
104+
105+
/// Deterministic per-bus inertia proxy in `[base, base+span]` (SplitMix64-seeded,
106+
/// decoupled from wiring). Honest no-data stand-in: the buffer axis is storage, so a
107+
/// topology-blind proxy preserves buffer ⊥ topology. Same `(n, base, span, seed)` ⇒
108+
/// same vector.
109+
pub fn proxy_inertia(n: usize, base: f64, span: f64, seed: u64) -> Vec<f64> {
110+
let mut state = seed;
111+
(0..n)
112+
.map(|_| {
113+
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
114+
let mut z = state;
115+
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
116+
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
117+
z ^= z >> 31;
118+
base + span * (z as f64 / u64::MAX as f64)
119+
})
120+
.collect()
121+
}
122+
123+
#[cfg(test)]
124+
mod tests {
125+
use super::*;
126+
use crate::inertia_buffer_column;
127+
128+
const INERTIA_CSV: &str = "\
129+
bus_id,inertia_h,source
130+
A,5.0,nuclear
131+
B,2.5,wind
132+
C,0.0,solar
133+
D,abc,bad
134+
E,8.0,hydro
135+
";
136+
137+
#[test]
138+
fn parses_bus_inertia_skipping_bad_rows() {
139+
let m = parse_bus_inertia(INERTIA_CSV);
140+
assert_eq!(m.len(), 3, "C (H=0) and D (unparseable) are skipped");
141+
assert_eq!(m["A"], 5.0);
142+
assert_eq!(m["B"], 2.5);
143+
assert_eq!(m["E"], 8.0);
144+
assert!(!m.contains_key("C") && !m.contains_key("D"));
145+
}
146+
147+
#[test]
148+
fn aligns_to_grid_order_and_discloses_proxy_fill() {
149+
let m = parse_bus_inertia(INERTIA_CSV);
150+
// Grid has A,B,X (X has no measured H) → X uses the fallback.
151+
let bus_ids = vec!["A".to_string(), "B".to_string(), "X".to_string()];
152+
let (h, prov) = inertia_for_buses(&bus_ids, &m, 1.0);
153+
assert_eq!(h, vec![5.0, 2.5, 1.0]);
154+
assert_eq!(prov.measured, 2);
155+
assert_eq!(prov.proxy, 1);
156+
}
157+
158+
#[test]
159+
fn proxy_is_deterministic_and_bounded() {
160+
let a = proxy_inertia(64, 2.0, 6.0, 0xC0FFEE);
161+
let b = proxy_inertia(64, 2.0, 6.0, 0xC0FFEE);
162+
assert_eq!(a, b, "same seed ⇒ same proxy");
163+
assert!(
164+
a.iter().all(|&h| (2.0..=8.0).contains(&h)),
165+
"in [base, base+span]"
166+
);
167+
// A different seed gives a different vector (decoupled, not constant).
168+
assert_ne!(a, proxy_inertia(64, 2.0, 6.0, 0xBEEF));
169+
}
170+
171+
#[test]
172+
fn ingested_h_feeds_the_inertia_buffer_column() {
173+
let m = parse_bus_inertia(INERTIA_CSV);
174+
let bus_ids = vec!["A".to_string(), "B".to_string(), "E".to_string()];
175+
let (h, _) = inertia_for_buses(&bus_ids, &m, 1.0);
176+
let col = inertia_buffer_column(&h, 0.2);
177+
// E (H=8) is max → 1.0, B (H=2.5) is min → 0.0; all normalized.
178+
assert_eq!(col.len(), 3);
179+
assert_eq!(col[2], 1.0);
180+
assert_eq!(col[1], 0.0);
181+
assert!(col.iter().all(|&c| (0.0..=1.0).contains(&c)));
182+
}
183+
}

crates/perturbation-sim/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pub mod eigen;
5959
pub mod flow;
6060
pub mod graph;
6161
pub mod hhtl;
62+
pub mod inertia_data;
6263
pub mod ingest;
6364
pub mod model;
6465
pub mod perturbation;
@@ -88,6 +89,7 @@ pub use eigen::{symmetric_eigen, Eigen};
8889
pub use flow::{dc_flows, lodf};
8990
pub use graph::{Edge, Grid};
9091
pub use hhtl::{basin_lambda2, hhtl_keys, HhtlKey};
92+
pub use inertia_data::{inertia_for_buses, parse_bus_inertia, proxy_inertia, InertiaProvenance};
9193
pub use ingest::{estimate_snom_mva, from_pypsa_csv, PypsaImport};
9294
pub use model::{
9395
apply_aging, assess_capability, edge_age_factors, scale_susceptance, with_uniform_derate,

0 commit comments

Comments
 (0)