Skip to main content

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}