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}