Skip to main content

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}