gridoxide_core/se/batch.rs
1//! Batched state estimation: one topology and one measurement *structure*,
2//! many readings, estimated across cores.
3//!
4//! This is the shape production state estimation actually has. A control room
5//! does not estimate one snapshot; it estimates the same grid every few seconds
6//! from the same instruments, with only the numbers changing. [`batch::BatchSolver`]
7//! already does the equivalent for power flow, and the two share a premise:
8//!
9//! - Every scenario shares one gain-matrix sparsity pattern, so each worker
10//! keeps one [`PersistentEstimator`] and amortizes a single symbolic
11//! factorization across its whole share of the batch.
12//! - Scenarios are independent, so the only coordination is handing out indices.
13//!
14//! [`PersistentEstimator`]'s cache-validity condition was written down before
15//! any of this existed and turns out to be exactly the batch invariant: valid
16//! while the topology and the measurement set's *structure* are unchanged, with
17//! values and sigmas free to move. [`MeasurementOverride`] is that condition
18//! made into a type.
19//!
20//! [`batch::BatchSolver`]: crate::batch::BatchSolver
21
22use std::collections::HashMap;
23use std::fmt;
24use std::sync::Mutex;
25use std::sync::atomic::{AtomicUsize, Ordering};
26
27use crate::measurement::{Measurement, MeasurementKind, Target};
28use crate::types::Bus;
29
30use super::SeNetwork;
31use super::nr::{PersistentEstimator, SeOptions, SeReport, linear_start};
32
33/// A per-measurement change against the batch's shared template.
34///
35/// Deliberately cannot change `kind` or `target`. Either would move the gain
36/// matrix's sparsity pattern and invalidate the cached symbolic factorization
37/// that makes batching worth doing at all — the exact analogue of
38/// [`BusOverride`](crate::batch::BusOverride) refusing to change `bus_type`. A
39/// caller whose sensor set genuinely changes shape wants a second batch.
40///
41/// A scenario that does not *have* some reading sets its sigma to infinity
42/// rather than dropping the row: the row then exists structurally and weighs
43/// exactly nothing, which is what keeps one pattern serving every scenario.
44#[derive(Clone, Copy, Debug, Default, PartialEq)]
45pub struct MeasurementOverride {
46 pub measurement: usize,
47 pub value: Option<f64>,
48 pub sigma: Option<f64>,
49}
50
51impl MeasurementOverride {
52 pub fn new(measurement: usize) -> Self {
53 Self {
54 measurement,
55 value: None,
56 sigma: None,
57 }
58 }
59
60 pub fn value(mut self, value: f64) -> Self {
61 self.value = Some(value);
62 self
63 }
64
65 pub fn sigma(mut self, sigma: f64) -> Self {
66 self.sigma = Some(sigma);
67 self
68 }
69
70 /// Marks the row present-but-uninformative, i.e. an infinite sigma.
71 pub fn absent(mut self) -> Self {
72 self.sigma = Some(f64::INFINITY);
73 self
74 }
75}
76
77/// One scenario: the template's measurements with some of their values and
78/// sigmas replaced.
79#[derive(Clone, Debug, Default, PartialEq)]
80pub struct SeScenario {
81 pub overrides: Vec<MeasurementOverride>,
82}
83
84impl SeScenario {
85 pub fn new(overrides: Vec<MeasurementOverride>) -> Self {
86 Self { overrides }
87 }
88}
89
90/// One scenario's outcome.
91#[derive(Clone, Debug)]
92pub struct SeBatchResult {
93 pub buses: Vec<Bus>,
94 pub report: SeReport,
95}
96
97#[derive(Clone, Debug, PartialEq)]
98pub enum SeBatchError {
99 /// An override named a row the template does not have.
100 MeasurementOutOfRange {
101 scenario: usize,
102 measurement: usize,
103 n: usize,
104 },
105 /// A scenario measures a voltage angle where the template does not, or the
106 /// reverse.
107 ///
108 /// That flips whether [`StateLayout`](super::jacobian::StateLayout) pins an
109 /// angle reference, which changes the number of unknowns. It is a different
110 /// problem, not a different right-hand side, and the shared factorization
111 /// cannot serve both — so it is rejected rather than silently answered
112 /// wrongly.
113 AngleReferenceChanged { scenario: usize },
114 /// Two scenarios disagree about which rows exist at all, so no single
115 /// template covers them. Only [`align_scenarios`] raises this.
116 IncompatibleScenarios,
117 /// `rayon` refused to build the requested thread pool.
118 ThreadPool(String),
119}
120
121impl fmt::Display for SeBatchError {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 match self {
124 SeBatchError::MeasurementOutOfRange {
125 scenario,
126 measurement,
127 n,
128 } => write!(
129 f,
130 "scenario {scenario} overrides measurement {measurement}, but the template has \
131 only {n} measurement(s)"
132 ),
133 SeBatchError::AngleReferenceChanged { scenario } => write!(
134 f,
135 "scenario {scenario} changes whether any voltage angle is measured, which changes \
136 the number of unknowns — run it as its own batch"
137 ),
138 SeBatchError::IncompatibleScenarios => {
139 write!(f, "no single measurement template covers every scenario")
140 }
141 SeBatchError::ThreadPool(e) => write!(f, "building the rayon thread pool failed: {e}"),
142 }
143 }
144}
145
146impl std::error::Error for SeBatchError {}
147
148/// Whether any voltage-angle row carries weight, which is what decides if
149/// [`StateLayout`](super::jacobian::StateLayout) pins a reference.
150fn angle_is_measured(measurements: &[Measurement]) -> bool {
151 measurements
152 .iter()
153 .any(|m| m.kind == MeasurementKind::VoltageAngle && m.weight() > 0.0)
154}
155
156/// Applies a scenario's overrides to a copy of the template.
157fn apply(template: &[Measurement], scenario: &SeScenario) -> Vec<Measurement> {
158 let mut out = template.to_vec();
159 for ov in &scenario.overrides {
160 let m = &mut out[ov.measurement];
161 if let Some(v) = ov.value {
162 m.value = v;
163 }
164 if let Some(s) = ov.sigma {
165 m.sigma = s;
166 }
167 }
168 out
169}
170
171/// Builds one template covering every scenario, plus the overrides that
172/// reproduce each.
173///
174/// Written for driving power-grid-model's own batch fixtures, where each
175/// scenario arrives as a whole document rather than as a diff, and where the
176/// base document may carry no readings at all — `sensor-update-initially-empty`
177/// has two valueless voltage sensors that only the scenarios fill in, so a
178/// template taken from the base would be empty and cover nothing.
179///
180/// Rows are keyed by `(kind, target)`, which is precisely the structure
181/// [`MeasurementOverride`] refuses to vary. A row missing from some scenario is
182/// given an infinite sigma there rather than dropped.
183pub fn align_scenarios(
184 scenarios: &[Vec<Measurement>],
185) -> Result<(Vec<Measurement>, Vec<SeScenario>), SeBatchError> {
186 if scenarios.is_empty() {
187 return Ok((Vec::new(), Vec::new()));
188 }
189
190 // Union in first-seen order, so the template is deterministic and a
191 // single-scenario batch reproduces that scenario's own row order exactly.
192 let mut index: HashMap<(MeasurementKind, Target), usize> = HashMap::new();
193 let mut template: Vec<Measurement> = Vec::new();
194 for scenario in scenarios {
195 for m in scenario {
196 index.entry((m.kind, m.target)).or_insert_with(|| {
197 template.push(*m);
198 template.len() - 1
199 });
200 }
201 }
202
203 let mut out = Vec::with_capacity(scenarios.len());
204 for scenario in scenarios {
205 let mut present = vec![false; template.len()];
206 let mut overrides = Vec::with_capacity(template.len());
207 for m in scenario {
208 let i = index[&(m.kind, m.target)];
209 if present[i] {
210 // The same quantity twice in one scenario means the caller has
211 // not aggregated, and merging here would silently invent a
212 // rule. `measurements_from_pgm` already does that job.
213 return Err(SeBatchError::IncompatibleScenarios);
214 }
215 present[i] = true;
216 overrides.push(MeasurementOverride::new(i).value(m.value).sigma(m.sigma));
217 }
218 for (i, &seen) in present.iter().enumerate() {
219 if !seen {
220 overrides.push(MeasurementOverride::new(i).absent());
221 }
222 }
223 overrides.sort_unstable_by_key(|o| o.measurement);
224 out.push(SeScenario::new(overrides));
225 }
226 Ok((template, out))
227}
228
229/// Estimates many scenarios over one shared topology and measurement structure.
230pub struct SeBatchSolver {
231 options: SeOptions,
232 /// Built once and reused across `estimate` calls, so repeated batches never
233 /// pay thread-creation cost. `None` uses rayon's global pool — which faer
234 /// also uses internally, and sharing it is what stops nested parallelism
235 /// oversubscribing the machine.
236 pool: Option<rayon::ThreadPool>,
237}
238
239impl SeBatchSolver {
240 /// Uses rayon's global thread pool (honors `RAYON_NUM_THREADS`).
241 pub fn new(options: SeOptions) -> Self {
242 Self {
243 options,
244 pool: None,
245 }
246 }
247
248 /// Uses a dedicated pool of exactly `threads` workers.
249 pub fn with_threads(options: SeOptions, threads: usize) -> Result<Self, SeBatchError> {
250 let pool = rayon::ThreadPoolBuilder::new()
251 .num_threads(threads)
252 .build()
253 .map_err(|e| SeBatchError::ThreadPool(e.to_string()))?;
254 Ok(Self {
255 options,
256 pool: Some(pool),
257 })
258 }
259
260 /// Workers this solver will use for a batch.
261 pub fn threads(&self) -> usize {
262 match &self.pool {
263 Some(p) => p.current_num_threads(),
264 None => rayon::current_num_threads(),
265 }
266 }
267
268 /// Estimates every scenario, returning one result each **in scenario order
269 /// regardless of thread count**.
270 ///
271 /// Each scenario starts from `buses_template` with a fresh
272 /// [`linear_start`], so scenarios never influence each other's starting
273 /// point — results are identical to a sequential loop over
274 /// [`PersistentEstimator::estimate`], which `tests/se_batch_test.rs`
275 /// asserts bit-for-bit.
276 ///
277 /// A scenario that fails to converge is *not* an error: its report carries
278 /// the status and the rest of the batch is unaffected, matching
279 /// [`BatchSolver::solve`](crate::batch::BatchSolver::solve).
280 pub fn estimate(
281 &self,
282 buses_template: &[Bus],
283 net: &SeNetwork,
284 measurements_template: &[Measurement],
285 scenarios: &[SeScenario],
286 ) -> Result<Vec<SeBatchResult>, SeBatchError> {
287 let n = measurements_template.len();
288 let template_has_angle = angle_is_measured(measurements_template);
289 for (i, sc) in scenarios.iter().enumerate() {
290 for ov in &sc.overrides {
291 if ov.measurement >= n {
292 return Err(SeBatchError::MeasurementOutOfRange {
293 scenario: i,
294 measurement: ov.measurement,
295 n,
296 });
297 }
298 }
299 if angle_is_measured(&apply(measurements_template, sc)) != template_has_angle {
300 return Err(SeBatchError::AngleReferenceChanged { scenario: i });
301 }
302 }
303 if scenarios.is_empty() {
304 return Ok(Vec::new());
305 }
306
307 let next = AtomicUsize::new(0);
308 let collected: Mutex<Vec<(usize, SeBatchResult)>> =
309 Mutex::new(Vec::with_capacity(scenarios.len()));
310 let n_workers = self.threads().max(1).min(scenarios.len());
311 let options = self.options;
312
313 let run = || {
314 rayon::scope(|s| {
315 for _ in 0..n_workers {
316 s.spawn(|_| {
317 // Constructed *inside* the spawned closure so it never
318 // crosses a thread boundary, for the same reason
319 // `batch::BatchSolver::solve` does it: a
320 // `PersistentEstimator` can hold a `KluRealSystem`
321 // (raw `*mut klu_symbolic`) or a `PardisoRealSystem`
322 // (`[*mut c_void; 64]`), neither of which is `Send`.
323 // `rayon::scope`/`spawn` require only the *closure* to
324 // be `Send`, and work-stealing never migrates a task
325 // mid-execution, so this local stays on one thread for
326 // its lifetime. `par_iter().map_init` would demand
327 // `T: Send` and silently exclude both backends.
328 let mut estimator = PersistentEstimator::new(options);
329
330 loop {
331 let i = next.fetch_add(1, Ordering::Relaxed);
332 if i >= scenarios.len() {
333 break;
334 }
335 let measurements = apply(measurements_template, &scenarios[i]);
336 let mut buses = buses_template.to_vec();
337 linear_start(&mut buses, net, &measurements);
338 let report = estimator.estimate(&measurements, &mut buses, net);
339 collected
340 .lock()
341 .expect("batch result mutex poisoned by a panicking worker")
342 .push((i, SeBatchResult { buses, report }));
343 }
344 });
345 }
346 });
347 };
348
349 match &self.pool {
350 Some(pool) => pool.install(run),
351 None => run(),
352 }
353
354 let mut out = collected.into_inner().expect("batch result mutex poisoned");
355 out.sort_by_key(|(i, _)| *i);
356 Ok(out.into_iter().map(|(_, r)| r).collect())
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::measurement::Target;
364
365 fn m(kind: MeasurementKind, target: Target, value: f64, sigma: f64) -> Measurement {
366 Measurement {
367 kind,
368 target,
369 value,
370 sigma,
371 }
372 }
373
374 /// The union is taken in first-seen order, and a row a scenario lacks comes
375 /// back with an infinite sigma rather than missing.
376 #[test]
377 fn align_unions_scenarios_and_marks_absent_rows() {
378 let a = vec![
379 m(MeasurementKind::VoltageMagnitude, Target::Bus(0), 1.0, 0.1),
380 m(MeasurementKind::ActivePower, Target::Bus(1), 0.5, 0.2),
381 ];
382 let b = vec![
383 m(MeasurementKind::VoltageMagnitude, Target::Bus(0), 1.1, 0.1),
384 m(MeasurementKind::ReactivePower, Target::Bus(1), 0.3, 0.2),
385 ];
386 let (template, scenarios) = align_scenarios(&[a, b]).unwrap();
387
388 assert_eq!(template.len(), 3, "union of two rows and one shared");
389 assert_eq!(template[0].kind, MeasurementKind::VoltageMagnitude);
390 assert_eq!(template[1].kind, MeasurementKind::ActivePower);
391 assert_eq!(template[2].kind, MeasurementKind::ReactivePower);
392
393 let first = apply(&template, &scenarios[0]);
394 assert_eq!(first[0].value, 1.0);
395 assert_eq!(first[1].value, 0.5);
396 assert!(
397 first[2].sigma.is_infinite(),
398 "scenario 0 has no reactive row"
399 );
400 assert_eq!(first[2].weight(), 0.0);
401
402 let second = apply(&template, &scenarios[1]);
403 assert_eq!(second[0].value, 1.1);
404 assert!(
405 second[1].sigma.is_infinite(),
406 "scenario 1 has no active row"
407 );
408 assert_eq!(second[2].value, 0.3);
409 }
410
411 /// A single scenario must reproduce its own row order, so that a batch of
412 /// one is indistinguishable from a one-shot estimate.
413 #[test]
414 fn a_single_scenario_round_trips_exactly() {
415 let one = vec![
416 m(MeasurementKind::ActivePower, Target::Bus(2), 0.7, 0.3),
417 m(MeasurementKind::VoltageMagnitude, Target::Bus(0), 1.0, 0.1),
418 ];
419 let (template, scenarios) = align_scenarios(std::slice::from_ref(&one)).unwrap();
420 assert_eq!(apply(&template, &scenarios[0]), one);
421 }
422
423 /// Two readings of one quantity in a single scenario are a caller error:
424 /// merging them here would invent an aggregation rule
425 /// `measurements_from_pgm` already owns.
426 #[test]
427 fn a_duplicated_quantity_in_one_scenario_is_rejected() {
428 let dup = vec![
429 m(MeasurementKind::VoltageMagnitude, Target::Bus(0), 1.0, 0.1),
430 m(MeasurementKind::VoltageMagnitude, Target::Bus(0), 1.2, 0.1),
431 ];
432 assert_eq!(
433 align_scenarios(&[dup]),
434 Err(SeBatchError::IncompatibleScenarios)
435 );
436 }
437}