gridoxide_core/se/nr.rs
1//! Weighted-least-squares state estimation by Gauss-Newton.
2//!
3//! Minimizes `J(x) = ½·(z − h(x))ᵀ W (z − h(x))` by repeatedly solving the
4//! normal equations
5//!
6//! ```text
7//! G Δx = HᵀW r, G = HᵀWH, r = z − h(x)
8//! ```
9//!
10//! and applying `Δx` to the state. `G` is square and symmetric, so every
11//! [`LinearSolver`](gridoxide_sparse::sparse::LinearSolver) backend gridoxide already has
12//! for power flow works here unchanged — see [`super::jacobian`] for why that
13//! shaped the whole design.
14//!
15//! # How this differs from the power-flow Newton loop
16//!
17//! `solver::newton_raphson_cached` drives a *mismatch* to zero: there is an
18//! exact solution and it converges onto it. Gauss-Newton has no such target —
19//! the measurements are inconsistent by construction, so `r` stays nonzero at
20//! the optimum. Convergence is therefore tested on the size of the *step*,
21//! not the residual. A converged estimate with a large `J(x)` is not a failure
22//! to converge; it means the measurements disagree with each other, which is
23//! what bad-data detection is for.
24
25use crate::measurement::Measurement;
26use crate::solver::JacobianBackend;
27use crate::types::Bus;
28use gridoxide_sparse::lu::SparseLu;
29
30use super::constraints::Constraints;
31use super::jacobian::{StateLayout, gain_and_rhs, mask_untouched, measurement_jacobian_with};
32use super::{MeasurementModel, SeNetwork, measurement_functions, measurement_functions_with};
33
34/// How the estimate finished.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum SeStatus {
37 /// The step fell below the tolerance.
38 Converged,
39 /// `max_iter` was reached with the step still above tolerance.
40 MaxIterations,
41 /// The gain matrix could not be factorized. With WLS this usually means the
42 /// system is unobservable — too few independent measurements to pin down
43 /// every state variable — rather than a numerical accident.
44 ///
45 /// [`observability::analyze`](super::observability::analyze) turns this into
46 /// a diagnosis, naming the buses and quantities the measurements leave
47 /// undetermined. It is not run automatically because it densifies the gain
48 /// matrix, which is affordable for a deliberate analysis pass and not for
49 /// every solve.
50 Singular,
51}
52
53/// Which algorithm to estimate with.
54///
55/// Both minimize the same weighted-least-squares objective and, on a
56/// well-conditioned problem, reach the same state — power-grid-model's fixtures
57/// accept either method against one expected answer, and gridoxide's tests
58/// check that they agree.
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
60pub enum SeMethod {
61 /// Gauss-Newton on the nonlinear problem. Slower per iteration — a fresh
62 /// Jacobian and factorization each time — and the more accurate of the two.
63 #[default]
64 NewtonRaphson,
65 /// Linearize the measurements into currents, factorize once, iterate on the
66 /// right-hand side alone. Faster, at the cost of the approximations
67 /// [`super::iterative`] documents.
68 IterativeLinear,
69}
70
71/// Options for a single estimate.
72#[derive(Clone, Copy, Debug)]
73pub struct SeOptions {
74 /// Convergence threshold on the largest element of `Δx`.
75 pub tol: f64,
76 pub max_iter: usize,
77 pub backend: JacobianBackend,
78 /// Which algorithm to use. The backend applies only to
79 /// [`SeMethod::NewtonRaphson`]; the iterative-linear method solves a
80 /// complex system and has a single path.
81 pub method: SeMethod,
82}
83
84impl Default for SeOptions {
85 fn default() -> Self {
86 Self {
87 tol: 1e-8,
88 max_iter: 20,
89 backend: JacobianBackend::default(),
90 method: SeMethod::default(),
91 }
92 }
93}
94
95/// The outcome of an estimate.
96#[derive(Clone, Debug)]
97pub struct SeReport {
98 pub status: SeStatus,
99 pub iterations: usize,
100 /// `J(x) = ½·rᵀWr` at the final state — the quantity being minimized, and
101 /// the statistic bad-data detection's chi-squared test is built on.
102 pub objective: f64,
103 /// Largest `Δx` element of the final step, for diagnosing a run that hit
104 /// `max_iter`.
105 pub last_step: f64,
106 /// Final residuals `z − h(x)`, one per measurement in input order.
107 pub residuals: Vec<f64>,
108 /// State-vector columns that no measurement constrains at all.
109 ///
110 /// These are held at their starting value rather than estimated — an
111 /// unknown nothing observes cannot be recovered, and moving it would be
112 /// inventing information. In practice they are usually the virtual slack
113 /// buses gridoxide synthesizes per source: power-grid-model has no such bus
114 /// in its state space, so a network it considers fully observable can still
115 /// leave gridoxide with surplus unknowns whenever the source's own power is
116 /// unmeasured.
117 ///
118 /// This is *structural* detection only — an all-zero column. A column that
119 /// is nonzero but linearly dependent on others is just as unobservable and
120 /// is not caught here; that is numerical observability analysis.
121 pub unconstrained: Vec<usize>,
122}
123
124/// A starting state: every magnitude at 1 p.u. and every angle at zero, with
125/// voltage-magnitude measurements applied where they exist.
126///
127/// The flat start is the standard choice, and seeding it with the voltage
128/// measurements costs nothing and starts the buses that *are* measured much
129/// closer to their answer. power-grid-model does the same
130/// (`se-algorithms.md`'s initialization step).
131///
132/// Note this leaves every angle at zero, which is wrong by 30° per `clock` step
133/// across a phase-shifting transformer — see [`linear_start`], which is the
134/// better default wherever a [`SeNetwork`] is available.
135pub fn flat_start(buses: &mut [Bus], measurements: &[Measurement]) {
136 for b in buses.iter_mut() {
137 b.voltage_mag = 1.0;
138 b.voltage_ang = 0.0;
139 }
140 apply_measured_magnitudes(buses, measurements);
141}
142
143/// A starting state that carries the network's own structural phase shifts,
144/// then applies the voltage-magnitude measurements on top.
145///
146/// Prefer this to [`flat_start`] wherever a [`SeNetwork`] is at hand. A flat
147/// start puts every angle at zero, which is a poor description of any network
148/// containing a phase-shifting transformer: a `clock` of 11 offsets one side by
149/// 30° (0.52 rad) before any load flows at all. Gauss-Newton started half a
150/// radian away from the answer does not merely take longer — on
151/// `three_winding_transformer` it converges to a *different* stationary point,
152/// reporting an objective of 2.4e2 where the true optimum is 4e-11, with the
153/// source node at 0.21 p.u. against its own sensor reading of 1.00.
154///
155/// [`linear_initial_guess`](crate::network::linear_initial_guess) is the fix
156/// gridoxide already had: it solves the linear network with loads as constant
157/// admittances, so each transformer's complex tap ratio propagates its phase
158/// shift exactly. On a state-estimation document, where `p_specified` is
159/// normally unset, it degenerates to a no-load solve — which still carries every
160/// phase shift, which is the part that matters here. It is also safe by
161/// construction: a singular linear system leaves the flat state untouched rather
162/// than failing.
163///
164/// power-grid-model reaches the same place differently, carrying a per-bus
165/// `phase_shift` in its math topology and initializing its voltages with it.
166pub fn linear_start(buses: &mut [Bus], net: &SeNetwork, measurements: &[Measurement]) {
167 // Reset the *unknowns* only. `linear_initial_guess` solves for the PQ buses
168 // while reading every other bus's voltage as a fixed boundary condition, so
169 // flattening those first would be discarding the very thing it solves
170 // against.
171 //
172 // It matters twice over. A source's `u_ref` is not generally 1 p.u., and
173 // flattening it degrades the guess by exactly that offset. And in the phase
174 // domain it is fatal rather than merely lossy: a virtual slack bus carries
175 // its phase's own 0/−120/+120 angle, which is the *only* thing distinguishing
176 // the three phases before any load flows. Flatten it and the guess comes back
177 // with all three phases at one angle — a balanced-collapsed state that
178 // Gauss-Newton does not recover from, and which looks like a modelling bug
179 // rather than a starting one.
180 for b in buses.iter_mut() {
181 if !matches!(b.bus_type, crate::types::BusType::Slack) {
182 b.voltage_mag = 1.0;
183 b.voltage_ang = 0.0;
184 }
185 }
186 crate::network::linear_initial_guess(buses, &net.ybus);
187 apply_measured_magnitudes(buses, measurements);
188 // A de-energized bus is reported at exactly zero, which is both
189 // power-grid-model's convention for `energized: 0` and what
190 // `network::mark_unreferenced_islands` already does on the power-flow side.
191 // Nothing determines these buses, so `mask_untouched` pins them wherever the
192 // start leaves them — which makes the start the answer, and zero the only
193 // defensible one.
194 for (b, &live) in buses.iter_mut().zip(&net.energized) {
195 if !live {
196 b.voltage_mag = 0.0;
197 b.voltage_ang = 0.0;
198 }
199 }
200}
201
202/// Overwrites each bus's magnitude with a directly measured one where it exists.
203///
204/// Shared by [`flat_start`] and [`linear_start`]: a measured magnitude is a
205/// better starting value than either produces on its own, and costs nothing.
206fn apply_measured_magnitudes(buses: &mut [Bus], measurements: &[Measurement]) {
207 for m in measurements {
208 if let (
209 crate::measurement::MeasurementKind::VoltageMagnitude,
210 crate::measurement::Target::Bus(bus),
211 ) = (m.kind, m.target)
212 {
213 if m.value.is_finite() && m.value > 0.0 {
214 buses[bus].voltage_mag = m.value;
215 }
216 }
217 }
218}
219
220fn estimate_with(
221 measurements: &[Measurement],
222 buses: &mut [Bus],
223 net: &SeNetwork,
224 layout: &StateLayout,
225 constraints: &Constraints,
226 model: &MeasurementModel,
227 options: &SeOptions,
228 lu: &mut SparseLu,
229) -> SeReport {
230 let mut residuals = Vec::new();
231 let mut last_step = f64::INFINITY;
232 let mut last_unconstrained = Vec::new();
233
234 for iteration in 1..=options.max_iter {
235 let h = measurement_functions_with(measurements, buses, model);
236 residuals = measurements
237 .iter()
238 .zip(&h)
239 .map(|(m, &hi)| m.value - hi)
240 .collect();
241
242 let rows = measurement_jacobian_with(measurements, buses, layout, model);
243 let (mut triplets, rhs, _) = gain_and_rhs(&rows, measurements, &residuals);
244
245 let n = layout.n_unknowns();
246 let mut rhs = rhs;
247 rhs.resize(n, 0.0);
248
249 // Zero-injection buses enter as hard constraints rather than as
250 // heavily-weighted pseudo-measurements; see `se::constraints` for why
251 // that matters for conditioning.
252 let (c_values, c_rows) = constraints.evaluate(buses, net, layout);
253
254 let unconstrained = mask_untouched(&mut triplets, &mut rhs, &[&rows, &c_rows], n);
255
256 let (triplets, rhs) = super::constraints::augment(triplets, rhs, n, &c_values, &c_rows);
257 let n_aug = n + constraints.len();
258
259 // The gain matrix's sparsity pattern is fixed for a topology and a
260 // measurement set, so the symbolic factorization is built once and
261 // reused across iterations — the same property `PersistentSolver`
262 // relies on for power flow.
263 if !lu.is_analyzed() && !lu.analyze(n_aug, &triplets) {
264 return SeReport {
265 status: SeStatus::Singular,
266 iterations: iteration,
267 objective: objective(measurements, &residuals),
268 last_step,
269 residuals,
270 unconstrained,
271 };
272 }
273 let Some(dx) = lu.factor_and_solve(&triplets, &rhs) else {
274 return SeReport {
275 status: SeStatus::Singular,
276 iterations: iteration,
277 objective: objective(measurements, &residuals),
278 last_step,
279 residuals,
280 unconstrained,
281 };
282 };
283
284 for bus in 0..layout.n_buses {
285 if let Some(col) = layout.theta(bus) {
286 buses[bus].voltage_ang += dx[col];
287 }
288 buses[bus].voltage_mag += dx[layout.vmag(bus)];
289 }
290
291 last_unconstrained = unconstrained.clone();
292 // Only the leading `n` entries are the state step; the tail holds the
293 // Lagrange multipliers, which say how hard each constraint is pulling
294 // and must not be mistaken for a lack of convergence.
295 last_step = dx[..n].iter().fold(0.0f64, |a, &b| a.max(b.abs()));
296 if last_step < options.tol {
297 // Recompute residuals at the state actually returned, so the
298 // reported objective describes the answer rather than the
299 // second-to-last iterate.
300 let h = measurement_functions(measurements, buses, net);
301 let residuals: Vec<f64> = measurements
302 .iter()
303 .zip(&h)
304 .map(|(m, &hi)| m.value - hi)
305 .collect();
306 return SeReport {
307 status: SeStatus::Converged,
308 iterations: iteration,
309 objective: objective(measurements, &residuals),
310 last_step,
311 residuals,
312 unconstrained,
313 };
314 }
315 }
316
317 SeReport {
318 status: SeStatus::MaxIterations,
319 iterations: options.max_iter,
320 objective: objective(measurements, &residuals),
321 last_step,
322 residuals,
323 unconstrained: last_unconstrained,
324 }
325}
326
327/// `J(x) = ½·rᵀWr`, skipping zero-weight (infinite-sigma) rows.
328fn objective(measurements: &[Measurement], residuals: &[f64]) -> f64 {
329 measurements
330 .iter()
331 .zip(residuals)
332 .filter(|(m, _)| m.weight().is_finite() && m.weight() != 0.0)
333 .map(|(m, &r)| m.weight() * r * r)
334 .sum::<f64>()
335 / 2.0
336}
337
338/// A reusable estimator that keeps its factorization between solves.
339///
340/// [`estimate`] is the one-shot entry point: it builds the state layout, the
341/// constraint set and the solver's symbolic factorization, uses them once, and
342/// throws all three away. For a single estimate that is the whole cost of doing
343/// business; for a sequence of them — a time series, a scenario sweep, a live
344/// feed refreshing the same telemetry — it is repeated work, and measurably so.
345/// Power flow solved the identical problem with
346/// [`PersistentSolver`](crate::solver::PersistentSolver); this is its
347/// counterpart.
348///
349/// # When a cached factorization stays valid
350///
351/// Four things are cached — the state layout, the constraint set, each
352/// measurement's resolved [`MeasurementModel`], and one
353/// symbolic factorization — and all four hold under the same condition, which is
354/// what makes it one condition to state rather than four.
355///
356/// The gain matrix's sparsity pattern depends on the topology *and* on the
357/// measurement set's structure — which quantities are measured where — but not
358/// on their values. So this stays valid while:
359///
360/// - the network is unchanged, and
361/// - the same measurements exist, on the same targets, in the same order.
362///
363/// New readings on the existing sensors are the case this is built for and need
364/// no reset. Sigmas may change too: a weight scales `G`'s values, never its
365/// pattern. Adding, removing or reordering a measurement does invalidate it, as
366/// does anything that changes whether an angle is measured at all — that flips
367/// whether a reference is pinned, and with it the number of unknowns. Call
368/// [`reset`](Self::reset) then.
369///
370/// Nothing here detects a stale cache. The same is true of `PersistentSolver`,
371/// and for the same reason: the check would cost as much as the work it saves.
372pub struct PersistentEstimator {
373 options: SeOptions,
374 layout: Option<StateLayout>,
375 constraints: Option<Constraints>,
376 /// Each measurement's target resolved to a current functional, valid under
377 /// exactly this struct's own stated condition.
378 model: Option<MeasurementModel>,
379 lu: SparseLu,
380}
381
382impl PersistentEstimator {
383 pub fn new(options: SeOptions) -> Self {
384 let lu = SparseLu::new(options.backend.lu_backend());
385 Self {
386 options,
387 layout: None,
388 constraints: None,
389 model: None,
390 lu,
391 }
392 }
393
394 /// Discards every cached artifact. Call after changing the network or the
395 /// structure of the measurement set.
396 pub fn reset(&mut self) {
397 self.layout = None;
398 self.constraints = None;
399 self.model = None;
400 self.lu.reset();
401 }
402
403 /// Estimates, reusing whatever this estimator already holds.
404 ///
405 /// The iterative-linear method has nothing to reuse *between* calls — it
406 /// factorizes once per call already, and its matrix depends on the starting
407 /// magnitudes, which move — so it delegates to the one-shot path. The
408 /// saving here is Newton-Raphson's, which is also the slower method and so
409 /// the one that wanted it.
410 pub fn estimate(
411 &mut self,
412 measurements: &[Measurement],
413 buses: &mut [Bus],
414 net: &SeNetwork,
415 ) -> SeReport {
416 if self.options.method == SeMethod::IterativeLinear {
417 return super::iterative::estimate(measurements, buses, net, &self.options);
418 }
419
420 if self.layout.is_none() {
421 self.layout = Some(StateLayout::new(buses, measurements, net));
422 }
423 if self.constraints.is_none() {
424 self.constraints = Some(Constraints::new(net));
425 }
426 if self.model.is_none() {
427 self.model = Some(MeasurementModel::new(measurements, net));
428 }
429 let layout = self.layout.as_ref().expect("just populated");
430 let constraints = self.constraints.as_ref().expect("just populated");
431 let model = self.model.as_ref().expect("just populated");
432
433 estimate_with(
434 measurements,
435 buses,
436 net,
437 layout,
438 constraints,
439 model,
440 &self.options,
441 &mut self.lu,
442 )
443 }
444}
445
446/// Runs weighted-least-squares state estimation, updating `buses` in place.
447///
448/// `buses` supplies the starting state; use [`flat_start`] for the standard
449/// one. The estimate covers every bus, including the virtual slack buses
450/// gridoxide synthesizes for sources — in state estimation a source's voltage
451/// is an unknown like any other, not a boundary condition.
452pub fn estimate(
453 measurements: &[Measurement],
454 buses: &mut [Bus],
455 net: &SeNetwork,
456 options: &SeOptions,
457) -> SeReport {
458 if options.method == SeMethod::IterativeLinear {
459 return super::iterative::estimate(measurements, buses, net, options);
460 }
461 let layout = StateLayout::new(buses, measurements, net);
462 let constraints = Constraints::new(net);
463 let model = MeasurementModel::new(measurements, net);
464 estimate_with(
465 measurements,
466 buses,
467 net,
468 &layout,
469 &constraints,
470 &model,
471 options,
472 &mut SparseLu::new(options.backend.lu_backend()),
473 )
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::measurement::{MeasurementKind, Target};
480
481 fn m(kind: MeasurementKind, target: Target, value: f64, sigma: f64) -> Measurement {
482 Measurement {
483 kind,
484 target,
485 value,
486 sigma,
487 }
488 }
489
490 /// With enough consistent measurements, the estimate should reproduce the
491 /// state they were generated from.
492 ///
493 /// The measurements here are *exact* readings taken from a known state, so
494 /// a correct estimator has to drive the residuals to zero — which makes
495 /// this a test of the whole loop (h, H, gain assembly, update) rather than
496 /// of any one piece.
497 #[test]
498 fn recovers_the_state_its_measurements_came_from() {
499 let (net, truth) = crate::se::tests::two_bus_net();
500 let layout = StateLayout::new(&truth, &[], &net);
501
502 // Read every quantity off the true state, noise-free.
503 let h_targets = vec![
504 (MeasurementKind::VoltageMagnitude, Target::Bus(0)),
505 (MeasurementKind::VoltageMagnitude, Target::Bus(1)),
506 (MeasurementKind::ActivePower, Target::Bus(1)),
507 (MeasurementKind::ReactivePower, Target::Bus(1)),
508 (
509 MeasurementKind::ActivePower,
510 Target::BranchTerminal {
511 branch: 0,
512 terminal: crate::branch_flow::Terminal::From,
513 },
514 ),
515 (
516 MeasurementKind::ReactivePower,
517 Target::BranchTerminal {
518 branch: 0,
519 terminal: crate::branch_flow::Terminal::From,
520 },
521 ),
522 ];
523 let probe: Vec<Measurement> = h_targets
524 .iter()
525 .map(|&(kind, target)| m(kind, target, 0.0, 1.0))
526 .collect();
527 let exact = measurement_functions(&probe, &truth, &net);
528 let measurements: Vec<Measurement> = probe
529 .iter()
530 .zip(&exact)
531 .map(|(p, &v)| Measurement { value: v, ..*p })
532 .collect();
533
534 let mut buses = truth.clone();
535 flat_start(&mut buses, &measurements);
536 let report = estimate(&measurements, &mut buses, &net, &SeOptions::default());
537
538 assert_eq!(report.status, SeStatus::Converged, "report={report:?}");
539 assert!(report.objective < 1e-16, "objective={}", report.objective);
540 for (i, (got, want)) in buses.iter().zip(&truth).enumerate() {
541 assert!(
542 (got.voltage_mag - want.voltage_mag).abs() < 1e-8,
543 "bus {i} magnitude: {} vs {}",
544 got.voltage_mag,
545 want.voltage_mag
546 );
547 // Angles are only determined up to the reference, which is pinned.
548 let reference = layout
549 .angle_ref
550 .expect("no angle measurements here, so one is pinned");
551 let d_ang = (got.voltage_ang - want.voltage_ang)
552 - (buses[reference].voltage_ang - truth[reference].voltage_ang);
553 assert!(d_ang.abs() < 1e-8, "bus {i} angle off by {d_ang}");
554 }
555 }
556
557 /// Measurements that leave state variables untouched do not sink the whole
558 /// estimate: the unobservable unknowns are pinned and named, and the rest
559 /// is solved.
560 ///
561 /// A single voltage magnitude cannot determine four unknowns, and the naive
562 /// result is a singular gain matrix. Reporting that as an outright failure
563 /// would be needlessly destructive — the one thing that *is* measured is
564 /// perfectly well determined — so the untouched columns are masked out and
565 /// listed in `unconstrained` instead. Note this is structural detection
566 /// only; a column that is present but linearly dependent still surfaces as
567 /// [`SeStatus::Singular`], which numerical observability analysis diagnoses.
568 #[test]
569 fn unobservable_unknowns_are_pinned_and_reported() {
570 let (net, truth) = crate::se::tests::two_bus_net();
571 let measurements = vec![m(
572 MeasurementKind::VoltageMagnitude,
573 Target::Bus(0),
574 1.03,
575 0.01,
576 )];
577 let mut buses = truth.clone();
578 flat_start(&mut buses, &measurements);
579 let report = estimate(&measurements, &mut buses, &net, &SeOptions::default());
580
581 assert_eq!(report.status, SeStatus::Converged, "report={report:?}");
582 // Three of the four unknowns are untouched: the other bus's magnitude
583 // and both angles bar the pinned reference.
584 assert!(
585 !report.unconstrained.is_empty(),
586 "a single measurement cannot constrain the whole state: {report:?}"
587 );
588 // The measured quantity is still recovered exactly.
589 assert!(
590 (buses[0].voltage_mag - 1.03).abs() < 1e-9,
591 "got {}",
592 buses[0].voltage_mag
593 );
594 // And the unconstrained ones kept their starting value rather than
595 // drifting somewhere arbitrary.
596 assert!(
597 (buses[1].voltage_mag - 1.0).abs() < 1e-12,
598 "got {}",
599 buses[1].voltage_mag
600 );
601 }
602
603 /// A reused estimator must produce exactly what a fresh one does.
604 ///
605 /// The whole premise of caching is that the second solve is the same
606 /// computation with less setup, so anything else is a bug rather than a
607 /// tradeoff. Checked bit-for-bit, not approximately.
608 #[test]
609 fn reuse_reproduces_a_fresh_estimate_exactly() {
610 let (net, truth) = crate::se::tests::two_bus_net();
611 let probe = vec![
612 m(MeasurementKind::VoltageMagnitude, Target::Bus(0), 0.0, 0.01),
613 m(MeasurementKind::VoltageMagnitude, Target::Bus(1), 0.0, 0.01),
614 m(MeasurementKind::ActivePower, Target::Bus(1), 0.0, 0.01),
615 m(MeasurementKind::ReactivePower, Target::Bus(1), 0.0, 0.01),
616 ];
617 let exact = measurement_functions(&probe, &truth, &net);
618 let measurements: Vec<Measurement> = probe
619 .iter()
620 .zip(&exact)
621 .map(|(p, &v)| Measurement { value: v, ..*p })
622 .collect();
623
624 let mut fresh = truth.clone();
625 flat_start(&mut fresh, &measurements);
626 let one_shot = estimate(&measurements, &mut fresh, &net, &SeOptions::default());
627
628 let mut persistent = PersistentEstimator::new(SeOptions::default());
629 let mut reused = Vec::new();
630 for _ in 0..3 {
631 let mut buses = truth.clone();
632 flat_start(&mut buses, &measurements);
633 let report = persistent.estimate(&measurements, &mut buses, &net);
634 reused.push((report, buses));
635 }
636
637 for (i, (report, buses)) in reused.iter().enumerate() {
638 assert_eq!(report.status, one_shot.status, "solve {i} status");
639 assert_eq!(
640 report.iterations, one_shot.iterations,
641 "solve {i} iterations"
642 );
643 for (bus, (got, want)) in buses.iter().zip(&fresh).enumerate() {
644 assert_eq!(
645 got.voltage_mag, want.voltage_mag,
646 "solve {i} bus {bus}: reuse changed the answer"
647 );
648 assert_eq!(
649 got.voltage_ang, want.voltage_ang,
650 "solve {i} bus {bus} angle"
651 );
652 }
653 }
654 }
655
656 /// New readings on the same sensors are the case the cache is built for:
657 /// values may move freely, only the *structure* has to hold still.
658 #[test]
659 fn reuse_tracks_changed_measurement_values() {
660 let (net, truth) = crate::se::tests::two_bus_net();
661 let probe = vec![
662 m(MeasurementKind::VoltageMagnitude, Target::Bus(0), 0.0, 0.01),
663 m(MeasurementKind::VoltageMagnitude, Target::Bus(1), 0.0, 0.01),
664 m(MeasurementKind::ActivePower, Target::Bus(1), 0.0, 0.01),
665 m(MeasurementKind::ReactivePower, Target::Bus(1), 0.0, 0.01),
666 ];
667 let exact = measurement_functions(&probe, &truth, &net);
668 let first: Vec<Measurement> = probe
669 .iter()
670 .zip(&exact)
671 .map(|(p, &v)| Measurement { value: v, ..*p })
672 .collect();
673 // A different operating point, same sensors.
674 let second: Vec<Measurement> = first
675 .iter()
676 .map(|m| Measurement {
677 value: m.value * 0.98,
678 ..*m
679 })
680 .collect();
681
682 let mut persistent = PersistentEstimator::new(SeOptions::default());
683 let mut a = truth.clone();
684 flat_start(&mut a, &first);
685 persistent.estimate(&first, &mut a, &net);
686
687 let mut b = truth.clone();
688 flat_start(&mut b, &second);
689 persistent.estimate(&second, &mut b, &net);
690
691 // Against a fresh estimator on the second set.
692 let mut reference = truth.clone();
693 flat_start(&mut reference, &second);
694 estimate(&second, &mut reference, &net, &SeOptions::default());
695
696 for (bus, (got, want)) in b.iter().zip(&reference).enumerate() {
697 assert!(
698 (got.voltage_mag - want.voltage_mag).abs() < 1e-12,
699 "bus {bus}: cached run gave {} against {}",
700 got.voltage_mag,
701 want.voltage_mag
702 );
703 }
704 assert!(
705 (a[1].voltage_mag - b[1].voltage_mag).abs() > 1e-6,
706 "the two measurement sets should give visibly different answers, \
707 or this test proves nothing"
708 );
709 }
710
711 /// A noisy measurement set still converges; the leftover objective is the
712 /// disagreement, not a convergence failure. Asserting both is what
713 /// distinguishes "converged with imperfect data" from "did not converge".
714 #[test]
715 fn converges_with_inconsistent_measurements() {
716 let (net, truth) = crate::se::tests::two_bus_net();
717 let probe = vec![
718 m(MeasurementKind::VoltageMagnitude, Target::Bus(0), 0.0, 0.01),
719 m(MeasurementKind::VoltageMagnitude, Target::Bus(1), 0.0, 0.01),
720 m(MeasurementKind::ActivePower, Target::Bus(1), 0.0, 0.01),
721 m(MeasurementKind::ReactivePower, Target::Bus(1), 0.0, 0.01),
722 m(
723 MeasurementKind::ActivePower,
724 Target::BranchTerminal {
725 branch: 0,
726 terminal: crate::branch_flow::Terminal::From,
727 },
728 0.0,
729 0.01,
730 ),
731 ];
732 let exact = measurement_functions(&probe, &truth, &net);
733 // Perturb each reading by a different amount, so no state explains them all.
734 let measurements: Vec<Measurement> = probe
735 .iter()
736 .zip(&exact)
737 .enumerate()
738 .map(|(i, (p, &v))| Measurement {
739 value: v + 0.002 * (i as f64 - 2.0),
740 ..*p
741 })
742 .collect();
743
744 let mut buses = truth.clone();
745 flat_start(&mut buses, &measurements);
746 let report = estimate(&measurements, &mut buses, &net, &SeOptions::default());
747
748 assert_eq!(report.status, SeStatus::Converged, "report={report:?}");
749 assert!(
750 report.objective > 0.0,
751 "inconsistent data should leave residual disagreement"
752 );
753 assert!(report.last_step < SeOptions::default().tol);
754 }
755}