powerio_tx/indexed.rs
1//! [`IndexedNetwork`]: the dense-indexed analysis view over a [`BalancedNetwork`].
2//!
3//! [`BalancedNetwork`] is the canonical data record: format neutral tables with no
4//! analysis behavior. The matrix builders, connectivity diagnostics, and the
5//! DC-OPF instance need things a plain table doesn't carry: a dense `[0, n)` bus
6//! index, demand and shunts aggregated per bus, the in-service subsets, and the
7//! reference bus. [`IndexCore`] derives those once from a borrowed `&BalancedNetwork`;
8//! [`IndexedNetwork`] pairs that core with the network and answers the queries.
9//! Keeping this off `BalancedNetwork` is what stops `BalancedNetwork` from turning into a god
10//! type: data on one side, derived analysis on the other.
11//!
12//! The derived core is one `HashMap`, six `Vec<f64>`, and the HVDC injection
13//! record. One-shot callers use
14//! [`IndexedNetwork::new`], which builds and owns a throwaway core. A long-lived
15//! handle (the Python and C ABI wrappers) builds an [`IndexCore`] once at parse
16//! time and rebinds a borrowing view per query with
17//! [`IndexedNetwork::with_core`], so repeated queries never re-fold the loads
18//! and shunts.
19
20use std::borrow::Cow;
21use std::collections::HashMap;
22
23use petgraph::graph::UnGraph;
24
25use crate::hvdc::HvdcInjections;
26use crate::network::{BalancedNetwork, Branch, BusId, BusType, Generator, Switch};
27use crate::{Error, Result};
28
29/// The owned, network-independent derivation behind [`IndexedNetwork`]: the
30/// dense bus-id map plus the per-bus demand/shunt aggregates. Build it once with
31/// [`IndexCore::build`] and reuse it across many [`IndexedNetwork::with_core`]
32/// views of the same [`BalancedNetwork`].
33#[derive(Debug, Clone)]
34pub struct IndexCore {
35 /// Stable bus id → dense index in `[0, n)`.
36 bus_id_to_idx: HashMap<BusId, usize>,
37 /// Active demand summed per bus (dense index order, MW).
38 pd: Vec<f64>,
39 /// Reactive demand summed per bus (MVAr).
40 qd: Vec<f64>,
41 /// Shunt conductance summed per bus (MW at V = 1 p.u.).
42 gs: Vec<f64>,
43 /// Shunt susceptance summed per bus (MVAr at V = 1 p.u.).
44 bs: Vec<f64>,
45 /// Fixed HVDC active injection summed per bus (MW).
46 p_hvdc: Vec<f64>,
47 /// Fixed HVDC reactive injection summed per bus (MVAr).
48 q_hvdc: Vec<f64>,
49 /// The per line record behind `p_hvdc` and `q_hvdc`.
50 hvdc: HvdcInjections,
51}
52
53impl IndexCore {
54 /// Index `net`: map bus ids to dense indices and fold every in-service
55 /// load and shunt onto its bus. An out-of-service load or shunt draws
56 /// nothing, so it is left out of `pd/qd/gs/bs` and of every matrix built
57 /// from them, the same rule the power flow instances apply to loads and
58 /// generators. Each in service HVDC line folds its fixed terminal
59 /// injections into `p_hvdc/q_hvdc` by the rule
60 /// [`BalancedNetwork::calc_hvdc_injections`] states.
61 ///
62 /// # Correctness
63 /// Bus ids must be unique; a duplicate collapses two buses onto one dense
64 /// index and silently corrupts every aggregate. Format readers run
65 /// [`BalancedNetwork::validate`](crate::BalancedNetwork::validate) before
66 /// this, so a parsed network satisfies it; a hand-built
67 /// [`BalancedNetwork`] must call `validate` itself. Backstopped here by a
68 /// `debug_assert`.
69 #[must_use]
70 pub fn build(net: &BalancedNetwork) -> Self {
71 let n = net.buses().len();
72 let bus_id_to_idx: HashMap<BusId, usize> = net
73 .buses()
74 .iter()
75 .enumerate()
76 .map(|(idx, b)| (b.id, idx))
77 .collect();
78 debug_assert_eq!(
79 bus_id_to_idx.len(),
80 n,
81 "duplicate bus id in network (run BalancedNetwork::check_references first)"
82 );
83 let mut pd = vec![0.0; n];
84 let mut qd = vec![0.0; n];
85 for l in net.loads().iter().filter(|l| l.in_service) {
86 if let Some(&idx) = bus_id_to_idx.get(&l.bus) {
87 pd[idx] += l.p;
88 qd[idx] += l.q;
89 }
90 }
91 let mut gs = vec![0.0; n];
92 let mut bs = vec![0.0; n];
93 for s in net.shunts().iter().filter(|s| s.in_service) {
94 if let Some(&idx) = bus_id_to_idx.get(&s.bus) {
95 gs[idx] += s.g;
96 bs[idx] += s.b;
97 }
98 }
99 let hvdc = net.calc_hvdc_injections();
100 let mut p_hvdc = vec![0.0; n];
101 let mut q_hvdc = vec![0.0; n];
102 for terminal in &hvdc.terminals {
103 if let Some(&idx) = bus_id_to_idx.get(&terminal.bus) {
104 p_hvdc[idx] += terminal.p;
105 q_hvdc[idx] += terminal.q;
106 }
107 }
108 Self {
109 bus_id_to_idx,
110 pd,
111 qd,
112 gs,
113 bs,
114 p_hvdc,
115 q_hvdc,
116 hvdc,
117 }
118 }
119}
120
121/// A `BalancedNetwork` paired with its derived [`IndexCore`]. The network is borrowed for
122/// the common case, but owned when it had to be star-lowered (a 3-winding
123/// transformer expanded into a star bus plus three branches via
124/// `BalancedNetwork::expand_transformers_3w`); the core is owned ([`IndexedNetwork::new`])
125/// or borrowed from a cached [`IndexCore`] ([`IndexedNetwork::with_core`]).
126#[derive(Debug)]
127pub struct IndexedNetwork<'n> {
128 net: Cow<'n, BalancedNetwork>,
129 core: Cow<'n, IndexCore>,
130}
131
132impl<'n> IndexedNetwork<'n> {
133 /// Build a one-shot view that owns a freshly derived [`IndexCore`]. For
134 /// repeated queries on a long-lived handle, cache an [`IndexCore`] and use
135 /// [`with_core`](Self::with_core) so the derivation isn't rebuilt per call.
136 #[must_use]
137 pub fn new(net: &'n BalancedNetwork) -> Self {
138 let net = net.expand_transformers_3w();
139 let core = IndexCore::build(&net);
140 Self {
141 net,
142 core: Cow::Owned(core),
143 }
144 }
145
146 /// Pair `net` with an already-built [`IndexCore`] — no allocation when `net`
147 /// has no 3-winding transformer (the core must have been built from this same
148 /// `net`). When `net` does carry a 3-winding transformer, the view star-lowers
149 /// it and rebuilds the core over the expanded form, since the cached core was
150 /// derived from the unexpanded network.
151 #[must_use]
152 pub fn with_core(net: &'n BalancedNetwork, core: &'n IndexCore) -> Self {
153 match net.expand_transformers_3w() {
154 Cow::Borrowed(net) => Self {
155 net: Cow::Borrowed(net),
156 core: Cow::Borrowed(core),
157 },
158 Cow::Owned(net) => {
159 let core = IndexCore::build(&net);
160 Self {
161 net: Cow::Owned(net),
162 core: Cow::Owned(core),
163 }
164 }
165 }
166 }
167
168 /// The underlying network (the star-lowered form when a 3-winding transformer
169 /// was expanded).
170 #[inline]
171 pub fn network(&self) -> &BalancedNetwork {
172 &self.net
173 }
174
175 #[inline]
176 pub fn n(&self) -> usize {
177 self.net.buses().len()
178 }
179
180 #[inline]
181 pub fn base_mva(&self) -> f64 {
182 self.net.base_mva()
183 }
184
185 /// The divisor for turning a power quantity (shunt, load, generation) into
186 /// per unit. `base_mva` for a raw network; `1.0` for a normalized one, whose
187 /// powers are already per unit (so a second division would scale them twice).
188 /// `base_mva` itself stays at the system base — for MW recovery and
189 /// write-back — so use this, not `base_mva`, wherever the intent is "÷ base
190 /// to get per unit". The effect is that a per-unit matrix builder yields the
191 /// same matrix for a network and its [`to_normalized`](BalancedNetwork::to_normalized)
192 /// form.
193 #[inline]
194 pub fn per_unit_base(&self) -> f64 {
195 if self.net.is_normalized() {
196 1.0
197 } else {
198 self.net.base_mva()
199 }
200 }
201
202 /// A branch/bus angle field (`shift`, `va`) in radians. The raw model stores
203 /// angles in degrees; a normalized network already stores radians, so for it
204 /// this is the identity. The angle analogue of
205 /// [`per_unit_base`](Self::per_unit_base): a builder gets the same radians
206 /// whether it is handed a network or its
207 /// [`to_normalized`](BalancedNetwork::to_normalized) form, so the matrix comes out
208 /// the same.
209 #[inline]
210 pub fn to_radians(&self, angle: f64) -> f64 {
211 if self.net.is_normalized() {
212 angle
213 } else {
214 angle.to_radians()
215 }
216 }
217
218 #[inline]
219 pub fn name(&self) -> &str {
220 self.net.name()
221 }
222
223 /// All branches, in source order (column order for incidence-based builds).
224 #[inline]
225 pub fn branches(&self) -> &[Branch] {
226 self.net.branches()
227 }
228
229 /// All generators, in source order.
230 #[inline]
231 pub fn generators(&self) -> &[Generator] {
232 self.net.generators()
233 }
234
235 /// Resolve a bus id to its dense `[0, n)` index.
236 #[inline]
237 pub fn bus_index(&self, bus_id: BusId) -> Option<usize> {
238 self.core.bus_id_to_idx.get(&bus_id).copied()
239 }
240
241 /// The bus id at dense index `idx` — the inverse of
242 /// [`bus_index`](Self::bus_index).
243 ///
244 /// # Panics
245 /// Panics if `idx >= n`. Pass a dense index (e.g. from [`bus_index`](Self::bus_index) or a
246 /// matrix row), not a raw bus id.
247 #[inline]
248 pub fn bus_id(&self, idx: usize) -> BusId {
249 self.net.buses()[idx].id
250 }
251
252 /// Nodal active demand, length `n`.
253 #[inline]
254 pub fn pd(&self) -> &[f64] {
255 &self.core.pd
256 }
257
258 /// Nodal reactive demand, length `n`.
259 #[inline]
260 pub fn qd(&self) -> &[f64] {
261 &self.core.qd
262 }
263
264 /// Nodal shunt conductance, length `n`.
265 #[inline]
266 pub fn gs(&self) -> &[f64] {
267 &self.core.gs
268 }
269
270 /// Nodal shunt susceptance, length `n`.
271 #[inline]
272 pub fn bs(&self) -> &[f64] {
273 &self.core.bs
274 }
275
276 /// Nodal fixed HVDC active injection, length `n`: `-pf` at each in
277 /// service line's from bus and `+pt` at its to bus, summed per bus, in
278 /// the unit of [`pd`](Self::pd). A calculation under
279 /// [`HvdcTreatment::FixedInjection`](crate::HvdcTreatment) adds it to the
280 /// net injection beside generation minus demand.
281 #[inline]
282 pub fn p_hvdc(&self) -> &[f64] {
283 &self.core.p_hvdc
284 }
285
286 /// Nodal fixed HVDC reactive injection, length `n`: `+qf` at each in
287 /// service line's from bus and `+qt` at its to bus, summed per bus, in
288 /// the unit of [`qd`](Self::qd).
289 #[inline]
290 pub fn q_hvdc(&self) -> &[f64] {
291 &self.core.q_hvdc
292 }
293
294 /// The per line record behind [`p_hvdc`](Self::p_hvdc) and
295 /// [`q_hvdc`](Self::q_hvdc), including the lines that inject nothing.
296 #[inline]
297 pub fn hvdc_injections(&self) -> &HvdcInjections {
298 &self.core.hvdc
299 }
300
301 /// In-service branches with their index into [`branches`](Self::branches).
302 pub fn in_service_branches(&self) -> impl Iterator<Item = (usize, &Branch)> {
303 self.net
304 .branches()
305 .iter()
306 .enumerate()
307 .filter(|(_, b)| b.in_service)
308 }
309
310 /// In-service generators with their index into [`generators`](Self::generators).
311 pub fn in_service_gens(&self) -> impl Iterator<Item = (usize, &Generator)> {
312 self.net
313 .generators()
314 .iter()
315 .enumerate()
316 .filter(|(_, g)| g.in_service)
317 }
318
319 /// Dense indices of every reference (slack) bus, in ascending order. A
320 /// network may carry more than one [`BusType::Ref`] (a slack per island, or
321 /// several the source file marked) — the matrix layer grounds one row/column
322 /// per entry. Empty when the network has no reference bus.
323 pub fn reference_bus_indices(&self) -> Vec<usize> {
324 self.net
325 .buses()
326 .iter()
327 .enumerate()
328 .filter(|(_, b)| b.kind == BusType::Ref)
329 .map(|(i, _)| i)
330 .collect()
331 }
332
333 /// Dense index of the single reference (slack) bus. Errors unless exactly
334 /// one [`BusType::Ref`] exists; for the multi-reference case use
335 /// [`reference_bus_indices`](Self::reference_bus_indices).
336 pub fn reference_bus_index(&self) -> Result<usize> {
337 match self.reference_bus_indices().as_slice() {
338 [r] => Ok(*r),
339 other => Err(Error::reference_bus_count(other.len())),
340 }
341 }
342
343 /// Undirected graph view: node weight = dense bus index, edge weight =
344 /// index into [`branches`](Self::branches). Out-of-service branches are
345 /// skipped; parallel branches are kept as separate edges.
346 pub fn to_petgraph(&self) -> UnGraph<usize, usize> {
347 let mut g = UnGraph::with_capacity(self.n(), self.net.branches().len());
348 let nodes: Vec<_> = (0..self.n()).map(|i| g.add_node(i)).collect();
349 for (idx, br) in self.in_service_branches() {
350 if let (Some(i), Some(j)) = (self.bus_index(br.from), self.bus_index(br.to)) {
351 g.add_edge(nodes[i], nodes[j], idx);
352 }
353 }
354 g
355 }
356
357 /// In-service switches that are closed and join two distinct buses, with
358 /// their index into the network's switch table. A closed switch is an
359 /// ideal connection: its two buses are one electrical node.
360 pub fn closed_switches(&self) -> impl Iterator<Item = (usize, &Switch)> {
361 self.net
362 .switches()
363 .iter()
364 .enumerate()
365 .filter(|(_, switch)| switch.closed && switch.from != switch.to)
366 }
367
368 /// Error if a closed switch joins two buses.
369 ///
370 /// The matrix builders and solver preparations stamp branches only, so on
371 /// a network that still carries a closed switch they leave its two buses
372 /// apart. Normalization merges closed switches by default
373 /// ([`ClosedSwitchPolicy::Merge`](crate::ClosedSwitchPolicy::Merge)), and
374 /// [`merge_buses`](BalancedNetwork::merge_buses) merges them under a rule
375 /// that selects them; call this before building to refuse a network that
376 /// skipped both.
377 ///
378 /// # Errors
379 /// [`Error::ClosedSwitch`] naming the first closed switch and the count,
380 /// with the calls that merge them.
381 pub fn check_closed_switches(&self) -> Result<()> {
382 match self.closed_switches().next() {
383 Some((row, switch)) => Err(Error::ClosedSwitch {
384 row,
385 from: switch.from,
386 to: switch.to,
387 closed: self.closed_switches().count(),
388 }),
389 None => Ok(()),
390 }
391 }
392
393 /// Number of connected components in the in-service topology: buses
394 /// joined by in-service branches or closed switches.
395 pub fn calc_island_count(&self) -> usize {
396 let labels = self.calc_island_labels();
397 (0..labels.len()).filter(|&i| labels[i] == i).count()
398 }
399
400 /// Connected-component label per dense bus index (in-service topology): two
401 /// buses share a label iff a path of in-service branches and closed
402 /// switches joins them, and an isolated bus is its own component. Labels
403 /// are representative indices in `[0, n)`, not a dense `[0, k)` range — use
404 /// them for equality grouping (e.g. checking every island carries a
405 /// reference bus to ground).
406 pub fn calc_island_labels(&self) -> Vec<usize> {
407 let mut uf = petgraph::unionfind::UnionFind::new(self.n());
408 let edges = self
409 .in_service_branches()
410 .map(|(_, br)| (br.from, br.to))
411 .chain(self.closed_switches().map(|(_, sw)| (sw.from, sw.to)));
412 for (from, to) in edges {
413 if let (Some(i), Some(j)) = (self.bus_index(from), self.bus_index(to)) {
414 uf.union(i, j);
415 }
416 }
417 uf.into_labeling()
418 }
419
420 /// Error unless every connected component of the in-service topology carries
421 /// at least one reference (slack) bus. This is the grounding precondition
422 /// for the DC Laplacian: an island with no reference leaves its all-ones
423 /// null vector in the system, so the reference-grounded Laplacian stays
424 /// singular. With one reference in a single island it reduces to the
425 /// single slack requirement. Reports the count of ungrounded components.
426 /// Closed switches join their buses here as they do in
427 /// [`calc_island_labels`](Self::calc_island_labels).
428 pub fn check_reference_coverage(&self) -> Result<()> {
429 let labels = self.calc_island_labels();
430 // Mark each component (by its representative index in `[0, n)`) that holds
431 // a reference. A Vec<bool> keyed by label avoids hashing and uses one
432 // flat allocation.
433 let mut grounded = vec![false; labels.len()];
434 for r in self.reference_bus_indices() {
435 grounded[labels[r]] = true;
436 }
437 // A component shows up once at its root (`labels[i] == i`); count the
438 // roots whose component was never grounded.
439 let ungrounded = (0..labels.len())
440 .filter(|&i| labels[i] == i && !grounded[i])
441 .count();
442 if ungrounded > 0 {
443 return Err(Error::UngroundedComponent {
444 components: ungrounded,
445 });
446 }
447 Ok(())
448 }
449
450 /// True iff the in-service topology, branches and closed switches alike,
451 /// is a forest (`|E| = |V| - components`).
452 pub fn is_radial(&self) -> bool {
453 let edges = self.in_service_branches().count() + self.closed_switches().count();
454 edges == self.n().saturating_sub(self.calc_island_count())
455 }
456
457 /// Calculate a one-shot topological diagnostic. Closed switches join their
458 /// buses as they do in [`calc_island_labels`](Self::calc_island_labels).
459 pub fn calc_connectivity_report(&self) -> ConnectivityReport {
460 let mut connected = vec![false; self.n()];
461 let edges = self
462 .in_service_branches()
463 .map(|(_, br)| (br.from, br.to))
464 .chain(self.closed_switches().map(|(_, sw)| (sw.from, sw.to)));
465 for (from, to) in edges {
466 if let (Some(i), Some(j)) = (self.bus_index(from), self.bus_index(to)) {
467 connected[i] = true;
468 connected[j] = true;
469 }
470 }
471 ConnectivityReport {
472 n_buses: self.n(),
473 n_branches_in_service: self.net.branches().iter().filter(|b| b.in_service).count(),
474 n_components: self.calc_island_count(),
475 isolated_buses: (0..self.n()).filter(|&i| !connected[i]).collect(),
476 }
477 }
478}
479
480/// Topological invariants the TUI Inspect screen and downstream solvers care
481/// about.
482#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
483#[non_exhaustive]
484pub struct ConnectivityReport {
485 pub n_buses: usize,
486 pub n_branches_in_service: usize,
487 pub n_components: usize,
488 /// Dense bus indices with no incident in-service branch or closed switch
489 /// to another bus.
490 pub isolated_buses: Vec<usize>,
491}
492
493impl ConnectivityReport {
494 #[inline]
495 pub fn is_single_island(&self) -> bool {
496 self.n_components == 1 && self.isolated_buses.is_empty()
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::{IndexCore, IndexedNetwork};
503 use crate::network::{
504 BalancedNetwork, Bus, BusId, BusType, Extras, Hvdc, Impedance, Load, Shunt, Transformer3W,
505 Winding,
506 };
507
508 fn bus(id: usize, kind: BusType) -> Bus {
509 Bus {
510 id: BusId(id),
511 kind,
512 vm: 1.0,
513 va: 0.0,
514 base_kv: 1.0,
515 vmax: 1.1,
516 vmin: 0.9,
517 evhi: None,
518 evlo: None,
519 area: 1,
520 zone: 1,
521 name: None,
522 uid: None,
523 location: None,
524 extras: Extras::new(),
525 }
526 }
527
528 fn agg_net() -> BalancedNetwork {
529 let mut net = BalancedNetwork::in_memory(
530 "agg",
531 100.0,
532 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
533 Vec::new(),
534 );
535 net.loads_mut().push(Load {
536 bus: BusId(1),
537 p: 10.0,
538 q: 5.0,
539 voltage_model: None,
540 in_service: true,
541 uid: None,
542 extras: Extras::new(),
543 });
544 net.loads_mut().push(Load {
545 bus: BusId(1),
546 p: 3.0,
547 q: 1.0,
548 voltage_model: None,
549 in_service: true,
550 uid: None,
551 extras: Extras::new(),
552 });
553 net.shunts_mut().push(Shunt {
554 bus: BusId(1),
555 g: 0.2,
556 b: 0.4,
557 in_service: true,
558 section_count: None,
559 control: None,
560 uid: None,
561 extras: Extras::new(),
562 });
563 net.shunts_mut().push(Shunt {
564 bus: BusId(1),
565 g: 0.1,
566 b: 0.3,
567 in_service: true,
568 section_count: None,
569 control: None,
570 uid: None,
571 extras: Extras::new(),
572 });
573 net
574 }
575
576 fn assert_aggregates(view: &IndexedNetwork) {
577 let i = view.bus_index(BusId(1)).unwrap();
578 assert!((view.pd()[i] - 13.0).abs() < 1e-12);
579 assert!((view.qd()[i] - 6.0).abs() < 1e-12);
580 assert!((view.gs()[i] - 0.3).abs() < 1e-12);
581 assert!((view.bs()[i] - 0.7).abs() < 1e-12);
582 let j = view.bus_index(BusId(2)).unwrap();
583 assert!(view.pd()[j].abs() < 1e-12);
584 assert!(view.gs()[j].abs() < 1e-12);
585 }
586
587 fn three_winding(a: usize, b: usize, c: usize) -> Transformer3W {
588 let winding = |bus| Winding {
589 bus: BusId(bus),
590 tap: 1.0,
591 shift: 0.0,
592 nominal_kv: 0.0,
593 rate_a: 0.0,
594 rate_b: 0.0,
595 rate_c: 0.0,
596 control: None,
597 };
598 let imp = Impedance {
599 r: 0.0,
600 x: 0.1,
601 base_mva: 100.0,
602 };
603 Transformer3W {
604 windings: [winding(a), winding(b), winding(c)],
605 z: [imp, imp, imp],
606 star_vm: 1.0,
607 star_va: 0.0,
608 mag_g: 0.0,
609 mag_b: 0.0,
610 in_service: true,
611 name: None,
612 uid: None,
613 extras: Extras::new(),
614 }
615 }
616
617 #[test]
618 fn three_winding_star_lowering_adds_a_grounded_star_bus_with_its_magnetizing_shunt() {
619 // Three buses joined only by a 3-winding transformer; bus 1 is the
620 // reference. The view star-lowers it into one grounded component plus a
621 // synthetic star bus that carries the magnetizing shunt.
622 let mut net = BalancedNetwork::in_memory(
623 "t3w",
624 100.0,
625 vec![
626 bus(1, BusType::Ref),
627 bus(2, BusType::Pq),
628 bus(3, BusType::Pq),
629 ],
630 Vec::new(),
631 );
632 let mut t = three_winding(1, 2, 3);
633 t.mag_b = 0.05; // p.u. on the system base
634 net.transformers_3w_mut().push(t);
635
636 let view = IndexedNetwork::new(&net);
637 assert_eq!(view.n(), 4, "three buses plus the synthetic star point");
638 assert_eq!(view.calc_island_count(), 1);
639 view.check_reference_coverage().unwrap();
640
641 // The star bus is the last dense index; its susceptance is the magnetizing
642 // b scaled to the system base (raw network: × base_mva).
643 let star = view.n() - 1;
644 assert!((view.bs()[star] - 0.05 * 100.0).abs() < 1e-9);
645 for i in 0..3 {
646 assert!(view.bs()[i].abs() < 1e-12, "original buses carry no shunt");
647 }
648
649 // The canonical model keeps the typed record and gains no buses/branches.
650 assert_eq!(net.buses().len(), 3);
651 assert_eq!(net.branches().as_slice(), []);
652 assert_eq!(net.transformers_3w().len(), 1);
653 }
654
655 #[test]
656 fn out_of_service_three_winding_is_not_expanded() {
657 let mut net = BalancedNetwork::in_memory(
658 "t3w",
659 100.0,
660 vec![
661 bus(1, BusType::Ref),
662 bus(2, BusType::Pq),
663 bus(3, BusType::Pq),
664 ],
665 Vec::new(),
666 );
667 let mut t = three_winding(1, 2, 3);
668 t.in_service = false;
669 net.transformers_3w_mut().push(t);
670
671 let view = IndexedNetwork::new(&net);
672 // No star bus is synthesized for an out-of-service transformer, so the
673 // three buses stay as three islands (no spurious star point).
674 assert_eq!(view.n(), 3);
675 assert_eq!(view.calc_island_count(), 3);
676 }
677
678 #[test]
679 fn aggregates_sum_multiple_loads_and_shunts_per_bus() {
680 // PSS/E and PowerModels admit several loads/shunts on one bus; the
681 // per-bus fold must add them, not overwrite (last-writer-wins would pass
682 // every MATPOWER fixture, which folds one load per bus).
683 let net = agg_net();
684 assert_aggregates(&IndexedNetwork::new(&net));
685 }
686
687 #[test]
688 fn aggregates_leave_out_out_of_service_loads_and_shunts() {
689 // A PSS/E load or fixed shunt with STATUS 0 stays in the network as a
690 // record but draws nothing; folding it in would put its demand into
691 // every power flow and its admittance on the Ybus diagonal.
692 let mut net = agg_net();
693 net.loads_mut().push(Load {
694 bus: BusId(1),
695 p: 50.0,
696 q: 20.0,
697 voltage_model: None,
698 in_service: false,
699 uid: None,
700 extras: Extras::new(),
701 });
702 net.shunts_mut().push(Shunt {
703 bus: BusId(1),
704 g: 7.0,
705 b: 9.0,
706 in_service: false,
707 section_count: None,
708 control: None,
709 uid: None,
710 extras: Extras::new(),
711 });
712 assert_aggregates(&IndexedNetwork::new(&net));
713 let core = IndexCore::build(&net);
714 assert_aggregates(&IndexedNetwork::with_core(&net, &core));
715 }
716
717 #[test]
718 fn a_bus_reached_only_through_a_closed_switch_is_not_islanded() {
719 // Bus 1 (reference) - bus 2 by a line; bus 3 hangs off bus 2 by a
720 // closed switch and bus 4 by an open one.
721 let mut net = BalancedNetwork::in_memory(
722 "switches",
723 100.0,
724 vec![
725 bus(1, BusType::Ref),
726 bus(2, BusType::Pq),
727 bus(3, BusType::Pq),
728 bus(4, BusType::Pq),
729 ],
730 vec![crate::network::Branch::new(BusId(1), BusId(2), 0.0, 0.1)],
731 );
732 net.switches_mut().extend([
733 crate::network::Switch::new(BusId(2), BusId(3), true),
734 crate::network::Switch::new(BusId(2), BusId(4), false),
735 ]);
736 let view = IndexedNetwork::new(&net);
737 let labels = view.calc_island_labels();
738 assert_eq!(labels[0], labels[2], "the closed switch joins bus 3");
739 assert_ne!(labels[0], labels[3], "the open switch does not join bus 4");
740 assert_eq!(view.calc_island_count(), 2);
741 let report = view.calc_connectivity_report();
742 assert_eq!(report.n_components, 2);
743 assert_eq!(report.isolated_buses, vec![3]);
744 assert!(view.is_radial());
745 let error = view.check_reference_coverage().unwrap_err();
746 assert!(matches!(
747 error,
748 crate::Error::UngroundedComponent { components: 1 }
749 ));
750
751 // The matrix builders model no switches; the strict check refuses
752 // the closed one rather than leaving bus 3 apart.
753 let error = view.check_closed_switches().unwrap_err();
754 assert_eq!(error.code().code, "BUILD.SWITCH.CLOSED");
755 assert!(error.to_string().contains("merge_buses"));
756 net.switches_mut()[0].closed = false;
757 IndexedNetwork::new(&net).check_closed_switches().unwrap();
758 }
759
760 #[test]
761 fn with_core_matches_one_shot_view() {
762 // A view over a cached core sees the same aggregates as a fresh one.
763 let net = agg_net();
764 let core = IndexCore::build(&net);
765 assert_aggregates(&IndexedNetwork::with_core(&net, &core));
766 }
767
768 #[test]
769 fn hvdc_lines_fold_into_fixed_injections_per_bus() {
770 // Two lines leave bus 1 for bus 2; the out of service one is left
771 // out. The from end withdraws pf and injects qf, the to end injects
772 // pt and qt.
773 let mut net = agg_net();
774 let mut line = Hvdc::new(BusId(1), BusId(2));
775 line.pf = 100.0;
776 line.pt = 97.0;
777 line.qf = -40.0;
778 line.qt = -35.0;
779 let mut second = line.clone();
780 second.pf = 20.0;
781 second.pt = 19.0;
782 second.qf = 0.0;
783 second.qt = 0.0;
784 let mut off = line.clone();
785 off.in_service = false;
786 net.hvdc_mut().extend([line, second, off]);
787 let view = IndexedNetwork::new(&net);
788 let (i, j) = (
789 view.bus_index(BusId(1)).unwrap(),
790 view.bus_index(BusId(2)).unwrap(),
791 );
792 assert!((view.p_hvdc()[i] + 120.0).abs() < 1e-12);
793 assert!((view.p_hvdc()[j] - 116.0).abs() < 1e-12);
794 assert!((view.q_hvdc()[i] + 40.0).abs() < 1e-12);
795 assert!((view.q_hvdc()[j] + 35.0).abs() < 1e-12);
796 assert_eq!(view.hvdc_injections().n_injecting(), 2);
797 assert_eq!(view.hvdc_injections().out_of_service, vec![2]);
798 // Loads and shunts are untouched by the HVDC fold.
799 assert_aggregates(&view);
800 }
801}