Skip to main content

gridoxide_core/se/
mod.rs

1//! State estimation: recovering the most likely grid state from noisy,
2//! redundant, partial measurements.
3//!
4//! Power flow answers "given the injections, what are the voltages". State
5//! estimation answers the operational question instead: given a set of
6//! measurements that disagree with each other, none of them exact and not
7//! enough of them to determine the state directly, what state best explains
8//! them.
9//!
10//! This module holds the pieces both planned estimator methods share. The
11//! measurement *set* comes from [`crate::measurement`]; what lives here is the
12//! measurement *model* — the functions `h(x)` that say what each measurement
13//! would read in a given state.
14//!
15//! # Why `h(x)` is not just "read it off the network"
16//!
17//! Three of power-grid-model's sensor types describe quantities that gridoxide
18//! models structurally rather than as appliances, so their measurement
19//! functions are not the bus injection despite PGM presenting them that way:
20//!
21//! | PGM sensor | PGM's model | gridoxide's model | `h(x)` here |
22//! |---|---|---|---|
23//! | `source` | appliance at the node | virtual slack bus behind an impedance branch | that branch's flow |
24//! | `shunt` | appliance at the node | Y-bus diagonal entry | `-|V|²·conj(y_sh)` |
25//! | `node` | sum of all appliances | — | bus injection **plus** the two above |
26//!
27//! Treating any of them as the plain bus injection is wrong by construction,
28//! not by a sign: gridoxide's `power_injections` at a source node is zero by
29//! KCL, because the source's power arrives through a branch that is part of the
30//! network. `tests/measurement_residual_test.rs` is what established this — it
31//! reported a 63-sigma disagreement on exactly that quantity.
32
33pub mod bad_data;
34pub mod batch;
35pub mod case;
36pub mod constraints;
37pub mod functional;
38pub mod iterative;
39pub mod jacobian;
40pub mod nr;
41pub mod observability;
42
43use num_complex::Complex;
44
45use crate::branch_flow::{BranchParams, branch_params};
46use crate::measurement::{Measurement, MeasurementKind, Target};
47use crate::network::YBusSparse;
48use crate::types::Bus;
49
50/// Everything `h(x)` needs about the network, beyond the state itself.
51///
52/// Assembled once per topology. The per-bus shunt admittance and the per-bus
53/// list of source branches are kept explicitly because both are otherwise only
54/// recoverable by inspecting Y-bus entries, which cannot distinguish a shunt
55/// from a line's charging susceptance.
56pub struct SeNetwork {
57    pub ybus: YBusSparse,
58    /// Per branch, the current functional at each terminal: `[0]` is the from
59    /// side, `[1]` the to side.
60    ///
61    /// Stored resolved rather than as branch parameters, because a three-phase
62    /// terminal is not a two-bus quantity: phase `p` of one end depends on all
63    /// six phasors of the branch, through the 3×3 blocks. A coefficient list
64    /// expresses both cases, and the estimator above it cannot tell them apart —
65    /// which is what makes the phase domain a matter of longer lists rather than
66    /// of a second Jacobian.
67    ///
68    /// In the phase domain the index is `3·branch + phase`, exactly as a bus
69    /// index is `3·node + phase`. That is what keeps
70    /// [`Target`] unchanged between the two.
71    pub terminals: Vec<[functional::CurrentFunctional; 2]>,
72    /// Total shunt admittance stamped at each bus, or zero.
73    pub shunt_y: Vec<Complex<f64>>,
74    /// For each bus, the synthesized source branches delivering into it.
75    pub source_branches: Vec<Vec<usize>>,
76    /// Per-bus zero-injection flags, carried through from the conversion — see
77    /// gridoxide's `pgm::PgmNetwork::zero_injection`.
78    pub zero_injection: Vec<bool>,
79    /// Whether each bus is reachable from any source.
80    ///
81    /// A connected component containing no source carries no voltage that any
82    /// measurement can determine: every quantity in it is identically zero, and
83    /// the sensors that sit there — a network can perfectly well have a voltage
84    /// sensor on a de-energized node — describe nothing. power-grid-model
85    /// reports such nodes as `energized: 0` with a state of exactly zero, and
86    /// decides it topologically, ignoring whatever sensors are attached.
87    ///
88    /// Without this the gain matrix comes back singular rather than merely
89    /// uninformative, because those buses' columns are *touched* — by their own
90    /// sensors, or by a zero-injection constraint — while being determined by
91    /// nothing. `jacobian::mask_untouched` only pins what nothing touches at
92    /// all, which catches a fully isolated bus and not this.
93    ///
94    /// `solver::PersistentSolver` has done the equivalent for power flow all
95    /// along, via `network::connected_components` and `mark_unreferenced_islands`.
96    pub energized: Vec<bool>,
97}
98
99/// One `[from, to]` functional pair per scalar branch.
100fn terminals_from_params(params: &[BranchParams]) -> Vec<[functional::CurrentFunctional; 2]> {
101    use crate::branch_flow::Terminal;
102    params
103        .iter()
104        .map(|b| {
105            [Terminal::From, Terminal::To].map(|t| {
106                let (near, far) = b.buses(t);
107                let (y_self, y_mut) = b.seen_from(t);
108                functional::CurrentFunctional {
109                    at: near,
110                    coefficients: vec![(near, y_self), (far, y_mut)],
111                }
112            })
113        })
114        .collect()
115}
116
117impl SeNetwork {
118    /// Builds the model from a converted network plus the shunt list that was
119    /// stamped into its Y-bus.
120    ///
121    /// `shunts` must be the same slice passed to
122    /// [`network::stamp_shunts`](crate::network::stamp_shunts) — the shunt
123    /// measurement function needs the admittance itself, and the Y-bus has
124    /// already summed it together with everything else on that diagonal.
125    ///
126    /// `source_branch_idx` maps each source to the branch synthesized for it,
127    /// and `zero_injection` flags the buses known to inject nothing; the PGM
128    /// converter records both, and
129    /// gridoxide's `pgm::PgmNetwork::se_network` passes
130    /// them on.
131    pub fn new(
132        ybus: YBusSparse,
133        lines: &[crate::types::Line],
134        transformers: &[crate::types::Transformer],
135        shunts: &[crate::network::ShuntAdm],
136        source_branch_idx: &std::collections::HashMap<u64, usize>,
137        zero_injection: &[bool],
138    ) -> Self {
139        let n = ybus.n();
140        let terminals = terminals_from_params(&branch_params(lines, transformers));
141        let mut source_branches = vec![Vec::new(); n];
142        for &branch in source_branch_idx.values() {
143            // The synthesized branch runs virtual -> node, so the node it feeds
144            // is its `to` end.
145            source_branches[terminals[branch][1].at].push(branch);
146        }
147        for list in &mut source_branches {
148            list.sort_unstable();
149        }
150
151        // A component is energized if any bus in it is fed by a source. The
152        // virtual slack bus behind a source is in that same component, since
153        // the synthesized branch connects them.
154        let mut energized = vec![false; n];
155        for component in crate::network::connected_components(&ybus) {
156            if component.iter().any(|&i| !source_branches[i].is_empty()) {
157                for &i in &component {
158                    energized[i] = true;
159                }
160            }
161        }
162
163        Self::from_parts(
164            ybus,
165            terminals,
166            shunts,
167            source_branches,
168            zero_injection.to_vec(),
169            energized,
170        )
171    }
172
173    /// The measurement model for state estimation over a node-breaker
174    /// network's bus view.
175    ///
176    /// `ybus` must be the one built from *its* lines and transformers with
177    /// [`stamp_shunts`](crate::network::stamp_shunts) applied — the estimator
178    /// reads the branch list a second time to build its functionals, and a
179    /// disagreement produces an estimate that converges confidently to the
180    /// wrong answer.
181    ///
182    /// The retained switches are already in the branch list, so a measurement on
183    /// a breaker is an ordinary branch-terminal measurement at
184    /// [`branch_of_switch`](crate::switches::NodeBreakerNetwork::branch_of_switch)
185    /// — as with power flow, the estimator never learns that any of its
186    /// branches is a switch.
187    ///
188    /// What *is* specific to this view is the constraint set. Almost every bus
189    /// the node-breaker topology adds is a bay-internal node with nothing
190    /// attached, so
191    /// [`zero_injection`](crate::switches::NodeBreakerNetwork::zero_injection)
192    /// is dense here in a way it never is on a bus-branch model, and those
193    /// buses are observable only because of it.
194    pub fn from_node_breaker(net: &crate::switches::NodeBreakerNetwork, ybus: YBusSparse) -> Self {
195        Self::from_bus_network(
196            &net.buses,
197            ybus,
198            &net.lines,
199            &net.transformers,
200            &net.shunts,
201            net.zero_injection.clone(),
202        )
203    }
204
205    /// Builds the model for a network whose sources are **bus types** rather
206    /// than synthesized source branches — everything that does not come through
207    /// gridoxide's `pgm`, which for now means CGMES, including the
208    /// node-breaker view.
209    ///
210    /// The two differ in exactly one thing, and it is not the measurement model:
211    /// power-grid-model gives a source its own virtual bus behind a Thévenin
212    /// impedance, so "is this bus fed?" is answered by a branch list, while
213    /// CGMES marks a reference bus as `BusType::Slack` and injects nothing extra.
214    /// `source_branches` is therefore empty here, and every place that consulted
215    /// it for a *reference* goes through [`SeNetwork::reference_bus`] instead.
216    ///
217    /// `zero_injection` is the caller's: which buses carry no appliance at all.
218    /// It has to be structural, not a test on `p_spec`/`q_spec` — a load that
219    /// happens to sit at zero in one snapshot is not a bus that injects nothing,
220    /// and a hard constraint saying otherwise biases the estimate.
221    pub fn from_bus_network(
222        buses: &[Bus],
223        ybus: YBusSparse,
224        lines: &[crate::types::Line],
225        transformers: &[crate::types::Transformer],
226        shunts: &[crate::network::ShuntAdm],
227        zero_injection: Vec<bool>,
228    ) -> Self {
229        let n = ybus.n();
230        let components = crate::network::connected_components(&ybus);
231
232        // A component with no reference bus carries nothing any measurement can
233        // resolve, which is what `energized` means here. `network::classify`
234        // owns that rule for power flow — including the part that is easy to get
235        // wrong, that a `Slack` bus at `V = 0` is a de-energized placeholder and
236        // not a reference — so it decides it here too rather than being
237        // reimplemented a second way.
238        let mut energized = vec![false; n];
239        for c in crate::network::classify(buses, &components) {
240            if !c.slack_indices.is_empty() {
241                for &i in &c.bus_indices {
242                    energized[i] = true;
243                }
244            }
245        }
246
247        let terminals = terminals_from_params(&branch_params(lines, transformers));
248        let mut zero_injection = zero_injection;
249        zero_injection.resize(n, false);
250        Self::from_parts(
251            ybus,
252            terminals,
253            shunts,
254            vec![Vec::new(); n],
255            zero_injection,
256            energized,
257        )
258    }
259
260    /// The common tail of both constructors: everything that does not depend on
261    /// where the network came from.
262    fn from_parts(
263        ybus: YBusSparse,
264        terminals: Vec<[functional::CurrentFunctional; 2]>,
265        shunts: &[crate::network::ShuntAdm],
266        source_branches: Vec<Vec<usize>>,
267        zero_injection: Vec<bool>,
268        energized: Vec<bool>,
269    ) -> Self {
270        let mut shunt_y = vec![Complex::new(0.0, 0.0); ybus.n()];
271        for s in shunts {
272            shunt_y[s.at] += s.y;
273        }
274        Self {
275            ybus,
276            terminals,
277            shunt_y,
278            source_branches,
279            zero_injection,
280            energized,
281        }
282    }
283
284    /// The bus whose angle is held fixed when nothing measures phase.
285    ///
286    /// With no angle measurement anywhere, the state is determined only up to a
287    /// common rotation: one angle has to be pinned or the gain matrix is
288    /// singular by construction. *Which* one is arbitrary mathematically, but it
289    /// must be a bus that carries voltage at all — pinning a de-energized bus
290    /// pins nothing and leaves the rotation free.
291    ///
292    /// A source's own bus is the natural choice and is what a power flow would
293    /// use. The `Slack` fallback is what makes a CGMES network work, since it
294    /// has no source branches; the final `0` is unreachable in practice and is
295    /// there so this never panics on a degenerate network that the estimator
296    /// will reject a moment later anyway.
297    pub fn reference_bus(&self, buses: &[Bus]) -> usize {
298        self.source_branches
299            .iter()
300            .position(|feeding| !feeding.is_empty())
301            .or_else(|| {
302                buses.iter().position(|b| {
303                    b.bus_type == crate::types::BusType::Slack && b.voltage_mag != 0.0
304                })
305            })
306            .or_else(|| self.energized.iter().position(|&live| live))
307            .unwrap_or(0)
308    }
309
310    /// Builds the model for a three-phase network.
311    ///
312    /// Everything above this is unchanged by the phase domain. Bus `k` phase `p`
313    /// is index `3k + p`, as `lib.rs` documents for power flow, and a branch
314    /// terminal is indexed `3b + p` to match — so `Target`, `StateLayout`, the
315    /// Jacobian, the constraints and both estimator methods carry over without
316    /// knowing there are phases at all. What changes is the length of a
317    /// coefficient list: six terms where the scalar case has two, because phase
318    /// `p` of one end of a branch depends on all six phasors through the 3×3
319    /// blocks.
320    ///
321    /// `lines`, `transformers` and `shunts` must be the same slices stamped into
322    /// `ybus` — this reads their blocks a second time to build the measurement
323    /// model, and a disagreement between the two would produce an estimate that
324    /// converges confidently to the wrong answer.
325    pub fn from_3ph(
326        ybus: YBusSparse,
327        lines: &[crate::types::Line3Ph],
328        transformers: &[crate::types::Transformer3PhSeq],
329        shunts: &[crate::network::ShuntAdm3Ph],
330        source_branch_idx: &std::collections::HashMap<u64, usize>,
331        zero_injection_per_node: &[bool],
332    ) -> Self {
333        let n = ybus.n();
334        let n_nodes = n / 3;
335
336        // One functional per (branch, phase), laid out `3·branch + phase` so the
337        // index arithmetic matches the bus one.
338        let mut terminals = Vec::with_capacity(3 * (lines.len() + transformers.len()));
339        let blocks = lines
340            .iter()
341            .map(|ln| (ln.from, ln.to, crate::network::line3ph_blocks(ln)))
342            .chain(
343                transformers
344                    .iter()
345                    .map(|t| (t.from, t.to, crate::network::transformer3ph_blocks(t))),
346            );
347        for (from, to, [yff, yft, ytf, ytt]) in blocks {
348            for p in 0..3 {
349                // From side: `I_p = Σ_q Yff[p][q]·V_from,q + Σ_q Yft[p][q]·V_to,q`.
350                let coeffs = |near_block: &[[Complex<f64>; 3]; 3],
351                              far_block: &[[Complex<f64>; 3]; 3],
352                              near: usize,
353                              far: usize| {
354                    (0..3)
355                        .map(|q| (3 * near + q, near_block[p][q]))
356                        .chain((0..3).map(|q| (3 * far + q, far_block[p][q])))
357                        .collect::<Vec<_>>()
358                };
359                terminals.push([
360                    functional::CurrentFunctional {
361                        at: 3 * from + p,
362                        coefficients: coeffs(&yff, &yft, from, to),
363                    },
364                    functional::CurrentFunctional {
365                        at: 3 * to + p,
366                        coefficients: coeffs(&ytt, &ytf, to, from),
367                    },
368                ]);
369            }
370        }
371
372        let mut shunt_y = vec![Complex::new(0.0, 0.0); n];
373        for s in shunts {
374            // A three-phase shunt is a 3×3 block, but `shunt_y` is the *diagonal*
375            // a shunt-injection measurement reads. The off-diagonal coupling is
376            // in the Y-bus, where the bus-injection functional already picks it
377            // up; only the standalone shunt target uses this.
378            let m = crate::network::seq_to_phase_shunt(s.y1, s.y0);
379            for p in 0..3 {
380                shunt_y[3 * s.at + p] += m[p][p];
381            }
382        }
383
384        let mut source_branches = vec![Vec::new(); n];
385        for &branch in source_branch_idx.values() {
386            for p in 0..3 {
387                let phase_branch = 3 * branch + p;
388                source_branches[terminals[phase_branch][1].at].push(phase_branch);
389            }
390        }
391        for list in &mut source_branches {
392            list.sort_unstable();
393        }
394
395        // A node's zero-injection flag applies to all three of its phases: an
396        // appliance-free node injects nothing on any of them.
397        let mut zero_injection = vec![false; n];
398        for (k, &zero) in zero_injection_per_node.iter().enumerate().take(n_nodes) {
399            for p in 0..3 {
400                zero_injection[3 * k + p] = zero;
401            }
402        }
403
404        let mut energized = vec![false; n];
405        for component in crate::network::connected_components(&ybus) {
406            if component.iter().any(|&i| !source_branches[i].is_empty()) {
407                for &i in &component {
408                    energized[i] = true;
409                }
410            }
411        }
412
413        Self {
414            ybus,
415            terminals,
416            shunt_y,
417            source_branches,
418            zero_injection,
419            energized,
420        }
421    }
422
423    /// Buses a zero-injection constraint should apply to.
424    ///
425    /// A de-energized bus's injection is zero too, but asserting it adds a row
426    /// that reaches columns nothing determines — which is how a constraint turns
427    /// an uninformative island into a singular matrix.
428    pub fn constrained_buses(&self) -> Vec<bool> {
429        self.zero_injection
430            .iter()
431            .zip(&self.energized)
432            .map(|(&zero, &live)| zero && live)
433            .collect()
434    }
435}
436
437/// Evaluates `h(x)` for every measurement in `measurements`, in order.
438///
439/// `buses` carries the state; everything else comes from `net`. Two arms, not
440/// five: a voltage row reads the state directly, and every power row is its
441/// target's [`CurrentFunctional`](functional::CurrentFunctional) evaluated at
442/// the state.
443pub fn measurement_functions(
444    measurements: &[Measurement],
445    buses: &[Bus],
446    net: &SeNetwork,
447) -> Vec<f64> {
448    let model = MeasurementModel::new(measurements, net);
449    measurement_functions_with(measurements, buses, &model)
450}
451
452/// [`measurement_functions`] against an already-resolved model.
453///
454/// Resolving a target into a functional allocates a coefficient list, and the
455/// estimator evaluates `h(x)` once per iteration over an unchanging measurement
456/// set — so the resolution belongs outside the loop. `PersistentEstimator`
457/// caches one of these next to the state layout.
458pub fn measurement_functions_with(
459    measurements: &[Measurement],
460    buses: &[Bus],
461    model: &MeasurementModel,
462) -> Vec<f64> {
463    let v: Vec<Complex<f64>> = buses
464        .iter()
465        .map(|b| Complex::from_polar(b.voltage_mag, b.voltage_ang))
466        .collect();
467
468    measurements
469        .iter()
470        .enumerate()
471        .map(|(i, m)| match (m.kind, m.target) {
472            _ if !model.is_live(i) => 0.0,
473            (MeasurementKind::VoltageMagnitude, Target::Bus(b)) => buses[b].voltage_mag,
474            (MeasurementKind::VoltageAngle, Target::Bus(b)) => buses[b].voltage_ang,
475            _ => match model.functional(i) {
476                Some(f) => {
477                    // Which quantity of the functional this row reads: the
478                    // power, the current itself, or the current in the
479                    // terminal's own voltage frame.
480                    let value = match m.target {
481                        Target::BranchTerminalCurrent { frame, .. } => match frame {
482                            crate::measurement::AngleFrame::Global => f.current(&v),
483                            crate::measurement::AngleFrame::Local => f.local_current(&v),
484                        },
485                        _ => f.power(&v),
486                    };
487                    match m.kind {
488                        MeasurementKind::ActivePower | MeasurementKind::CurrentReal => value.re,
489                        _ => value.im,
490                    }
491                }
492                None => 0.0,
493            },
494        })
495        .collect()
496}
497
498/// One resolved [`CurrentFunctional`](functional::CurrentFunctional) per
499/// measurement, in input order.
500///
501/// `None` for a voltage row, which reads the state directly and has no
502/// functional, and for a target that does not resolve in this network.
503pub struct MeasurementModel {
504    functionals: Vec<Option<functional::CurrentFunctional>>,
505    /// Whether each measurement describes anything at all.
506    ///
507    /// False for a measurement whose bus is de-energized. Such a row is dropped
508    /// from the system entirely rather than given a zero weight: a zero-weight
509    /// row still *touches* its columns, and touching is what stops
510    /// [`jacobian::mask_untouched`] pinning them. power-grid-model does the same
511    /// by construction, since a de-energized node is not in its math model at
512    /// all.
513    live: Vec<bool>,
514}
515
516impl MeasurementModel {
517    /// Resolves every measurement's target once.
518    ///
519    /// Valid for as long as the network and the measurement set's *structure*
520    /// are unchanged — the same condition
521    /// [`PersistentEstimator`](nr::PersistentEstimator) already states for its
522    /// cached factorization, since both depend on which quantities are measured
523    /// where and neither on their values.
524    pub fn new(measurements: &[Measurement], net: &SeNetwork) -> Self {
525        let functionals: Vec<Option<functional::CurrentFunctional>> = measurements
526            .iter()
527            .map(|m| match (m.kind, m.target) {
528                (
529                    MeasurementKind::VoltageMagnitude | MeasurementKind::VoltageAngle,
530                    Target::Bus(_),
531                ) => None,
532                _ => net.functional(m.target),
533            })
534            .collect();
535
536        // Which bus a measurement lives on: its own for a voltage row, the
537        // functional's `at` otherwise. Both ends of a branch are in one
538        // component, so either end answers the question.
539        let live = measurements
540            .iter()
541            .zip(&functionals)
542            .map(|(m, f)| match (m.target, f) {
543                (Target::Bus(b), None) => net.energized[b],
544                (_, Some(f)) => net.energized[f.at],
545                (_, None) => false,
546            })
547            .collect();
548
549        Self { functionals, live }
550    }
551
552    /// Whether measurement `i` describes anything — see `Self::live`.
553    pub fn is_live(&self, measurement: usize) -> bool {
554        self.live[measurement]
555    }
556
557    pub fn functional(&self, measurement: usize) -> Option<&functional::CurrentFunctional> {
558        self.functionals[measurement].as_ref()
559    }
560
561    pub fn len(&self) -> usize {
562        self.functionals.len()
563    }
564
565    pub fn is_empty(&self) -> bool {
566        self.functionals.is_empty()
567    }
568}
569
570#[cfg(test)]
571pub(crate) mod tests {
572    use super::*;
573    use crate::branch_flow::{Terminal, terminal_flow};
574    use crate::network::YBus;
575    use crate::types::BusType;
576
577    fn bus(idx: usize, mag: f64, ang: f64) -> Bus {
578        Bus {
579            idx,
580            bus_type: BusType::PQ,
581            voltage_mag: mag,
582            voltage_ang: ang,
583            p_spec: 0.0,
584            q_spec: 0.0,
585            q_min: f64::NEG_INFINITY,
586            q_max: f64::INFINITY,
587            u_rated: 1.0,
588            zip_terms: Vec::new(),
589        }
590    }
591
592    /// Two buses joined by one branch, with a shunt on bus 1 and the branch
593    /// treated as a source feeding bus 1 — enough to exercise all three of the
594    /// structural measurement functions against one state.
595    /// The scalar branch `two_bus_net` is built from.
596    ///
597    /// Exposed so the functional tests can check themselves against
598    /// `branch_flow`'s own closed forms, which is a genuinely independent
599    /// derivation rather than a restatement of the one under test.
600    pub(crate) fn two_bus_params() -> BranchParams {
601        let y_series = Complex::new(1.0, 0.0) / Complex::new(0.05, 0.2);
602        BranchParams {
603            from: 0,
604            to: 1,
605            y: [y_series, -y_series, -y_series, y_series],
606        }
607    }
608
609    pub(crate) fn two_bus_net() -> (SeNetwork, Vec<Bus>) {
610        let y_series = Complex::new(1.0, 0.0) / Complex::new(0.05, 0.2);
611        let y_shunt = Complex::new(0.02, 0.3);
612
613        let mut yb = YBus::new(2);
614        yb.add(0, 0, y_series);
615        yb.add(1, 1, y_series);
616        yb.add(0, 1, -y_series);
617        yb.add(1, 0, -y_series);
618        yb.add(1, 1, y_shunt);
619
620        let net = SeNetwork {
621            ybus: yb.finish(),
622            terminals: terminals_from_params(&[two_bus_params()]),
623            shunt_y: vec![Complex::new(0.0, 0.0), y_shunt],
624            source_branches: vec![Vec::new(), vec![0]],
625            zero_injection: vec![false, false],
626            // One component, fed by the source on bus 1.
627            energized: vec![true, true],
628        };
629        (net, vec![bus(0, 1.02, 0.03), bus(1, 0.99, -0.02)])
630    }
631
632    /// Energization is topological: a component with a source is live, one
633    /// without is not, whatever sensors sit on it.
634    #[test]
635    fn energization_follows_components_not_sensors() {
636        // Three buses: 0–1 joined and fed by a source on 1, bus 2 isolated.
637        let y = Complex::new(1.0, 0.0) / Complex::new(0.05, 0.2);
638        let mut yb = YBus::new(3);
639        for (i, j, v) in [(0, 0, y), (1, 1, y), (0, 1, -y), (1, 0, -y)] {
640            yb.add(i, j, v);
641        }
642        // An isolated bus still needs a diagonal to appear in the Y-bus at all.
643        yb.add(2, 2, Complex::new(0.0, 0.0));
644
645        let net = SeNetwork {
646            ybus: yb.finish(),
647            terminals: terminals_from_params(&[BranchParams {
648                from: 0,
649                to: 1,
650                y: [y, -y, -y, y],
651            }]),
652            shunt_y: vec![Complex::new(0.0, 0.0); 3],
653            source_branches: vec![Vec::new(), vec![0], Vec::new()],
654            zero_injection: vec![true, false, true],
655            energized: vec![true, true, false],
656        };
657
658        let rebuilt: Vec<bool> = crate::network::connected_components(&net.ybus).iter().fold(
659            vec![false; 3],
660            |mut acc, c| {
661                if c.iter().any(|&i| !net.source_branches[i].is_empty()) {
662                    for &i in c {
663                        acc[i] = true;
664                    }
665                }
666                acc
667            },
668        );
669        assert_eq!(
670            rebuilt,
671            vec![true, true, false],
672            "bus 2 has no source in its component"
673        );
674
675        // And a zero-injection flag on the de-energized bus does not become a
676        // constraint, which is what used to make the gain matrix singular.
677        assert_eq!(net.constrained_buses(), vec![true, false, false]);
678    }
679
680    /// A shunt *consumes* `|V|²·conj(y)`, so as an injection its active part is
681    /// negative for a positive conductance and its reactive part is positive
682    /// for a positive susceptance. Sign errors here are invisible in a
683    /// magnitude check, so both are asserted.
684    #[test]
685    fn shunt_injection_is_negated_consumption() {
686        let (net, buses) = two_bus_net();
687        let v: Vec<Complex<f64>> = buses
688            .iter()
689            .map(|b| Complex::from_polar(b.voltage_mag, b.voltage_ang))
690            .collect();
691        let s = net
692            .functional(Target::ShuntInjection { bus: 1 })
693            .expect("shunt functional")
694            .power(&v);
695        let (p, q) = (s.re, s.im);
696        let v2 = 0.99_f64 * 0.99;
697        assert!((p + v2 * 0.02).abs() < 1e-12, "p={p}");
698        assert!((q - v2 * 0.3).abs() < 1e-12, "q={q}");
699        assert!(p < 0.0, "a resistive shunt must consume active power");
700    }
701
702    /// A source delivers into its node, which is the negation of the flow the
703    /// node pushes into the synthesized branch.
704    #[test]
705    fn source_injection_is_negated_to_terminal_flow() {
706        let (net, buses) = two_bus_net();
707        let v: Vec<Complex<f64>> = buses
708            .iter()
709            .map(|b| Complex::from_polar(b.voltage_mag, b.voltage_ang))
710            .collect();
711        let (p_to, q_to) = terminal_flow(&two_bus_params(), Terminal::To, &v);
712        let s_src = net
713            .functional(Target::SourceInjection { branch: 0 })
714            .expect("source functional")
715            .power(&v);
716        let (p_src, q_src) = (s_src.re, s_src.im);
717        assert!((p_src + p_to).abs() < 1e-12);
718        assert!((q_src + q_to).abs() < 1e-12);
719    }
720
721    /// The identity that makes `NodeInjection` a separate measurement function
722    /// rather than a relabelling of `Bus`: it is the bus injection plus the
723    /// contributions gridoxide models structurally.
724    #[test]
725    fn node_injection_is_bus_plus_source_plus_shunt() {
726        let (net, buses) = two_bus_net();
727        let ms = |target| Measurement {
728            kind: MeasurementKind::ActivePower,
729            target,
730            value: 0.0,
731            sigma: 1.0,
732        };
733        let measurements = vec![ms(Target::Bus(1)), ms(Target::NodeInjection(1))];
734        let h = measurement_functions(&measurements, &buses, &net);
735
736        let v: Vec<Complex<f64>> = buses
737            .iter()
738            .map(|b| Complex::from_polar(b.voltage_mag, b.voltage_ang))
739            .collect();
740        let power = |t| net.functional(t).expect("functional").power(&v).re;
741        let expected = h[0]
742            + power(Target::SourceInjection { branch: 0 })
743            + power(Target::ShuntInjection { bus: 1 });
744        assert!(
745            (h[1] - expected).abs() < 1e-12,
746            "node={} expected={expected}",
747            h[1]
748        );
749        // And the two are genuinely different quantities, so a test that
750        // confused them would not pass by coincidence.
751        assert!(
752            (h[1] - h[0]).abs() > 1e-6,
753            "bus and node injection should differ here"
754        );
755    }
756}