powerio/indexed.rs
1//! [`IndexedNetwork`]: the dense-indexed analysis view over a [`Network`].
2//!
3//! [`Network`] 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 `&Network`;
8//! [`IndexedNetwork`] pairs that core with the network and answers the queries.
9//! Keeping this off `Network` is what stops `Network` from turning into a god
10//! type: data on one side, derived analysis on the other.
11//!
12//! The derived core is one `HashMap` and four `Vec<f64>`. One-shot callers use
13//! [`IndexedNetwork::new`], which builds and owns a throwaway core. A long-lived
14//! handle (the Python and C ABI wrappers) builds an [`IndexCore`] once at parse
15//! time and rebinds a borrowing view per query with
16//! [`IndexedNetwork::with_core`], so repeated queries never re-fold the loads
17//! and shunts.
18
19use std::borrow::Cow;
20use std::collections::HashMap;
21
22use petgraph::graph::UnGraph;
23
24use crate::network::{Branch, BusId, BusType, Generator, Network};
25use crate::{Error, Result};
26
27/// The owned, network-independent derivation behind [`IndexedNetwork`]: the
28/// dense bus-id map plus the per-bus demand/shunt aggregates. Build it once with
29/// [`IndexCore::build`] and reuse it across many [`IndexedNetwork::with_core`]
30/// views of the same [`Network`].
31#[derive(Debug, Clone)]
32pub struct IndexCore {
33 /// Stable bus id → dense index in `[0, n)`.
34 bus_id_to_idx: HashMap<BusId, usize>,
35 /// Active demand summed per bus (dense index order, MW).
36 pd: Vec<f64>,
37 /// Reactive demand summed per bus (MVAr).
38 qd: Vec<f64>,
39 /// Shunt conductance summed per bus (MW at V = 1 p.u.).
40 gs: Vec<f64>,
41 /// Shunt susceptance summed per bus (MVAr at V = 1 p.u.).
42 bs: Vec<f64>,
43}
44
45impl IndexCore {
46 /// Index `net`: map bus ids to dense indices and fold every load/shunt onto
47 /// its bus. Loads and shunts are summed regardless of their `in_service`
48 /// flag, matching the folded `pd/qd/gs/bs` the MATPOWER-shaped model carried
49 /// on the bus row (the matrices key off topology and these aggregates, not
50 /// per-element service status).
51 ///
52 /// # Correctness
53 /// Bus ids must be unique; a duplicate collapses two buses onto one dense
54 /// index and silently corrupts every aggregate. The format readers and
55 /// [`Network::from_json`](crate::Network::from_json) run
56 /// [`Network::validate`](crate::Network::validate) before this, so a parsed
57 /// or JSON-sourced network always satisfies it; a hand-built [`Network`] must
58 /// call `validate` itself. Backstopped here by a `debug_assert`.
59 #[must_use]
60 pub fn build(net: &Network) -> Self {
61 let n = net.buses.len();
62 let bus_id_to_idx: HashMap<BusId, usize> = net
63 .buses
64 .iter()
65 .enumerate()
66 .map(|(idx, b)| (b.id, idx))
67 .collect();
68 debug_assert_eq!(
69 bus_id_to_idx.len(),
70 n,
71 "duplicate bus id in network (run Network::check_references first)"
72 );
73 let mut pd = vec![0.0; n];
74 let mut qd = vec![0.0; n];
75 for l in &net.loads {
76 if let Some(&idx) = bus_id_to_idx.get(&l.bus) {
77 pd[idx] += l.p;
78 qd[idx] += l.q;
79 }
80 }
81 let mut gs = vec![0.0; n];
82 let mut bs = vec![0.0; n];
83 for s in &net.shunts {
84 if let Some(&idx) = bus_id_to_idx.get(&s.bus) {
85 gs[idx] += s.g;
86 bs[idx] += s.b;
87 }
88 }
89 Self {
90 bus_id_to_idx,
91 pd,
92 qd,
93 gs,
94 bs,
95 }
96 }
97}
98
99/// A `Network` paired with its derived [`IndexCore`]. The network is borrowed for
100/// the common case, but owned when it had to be star-lowered (a 3-winding
101/// transformer expanded into a star bus plus three branches via
102/// `Network::expand_transformers_3w`); the core is owned ([`IndexedNetwork::new`])
103/// or borrowed from a cached [`IndexCore`] ([`IndexedNetwork::with_core`]).
104#[derive(Debug)]
105pub struct IndexedNetwork<'n> {
106 net: Cow<'n, Network>,
107 core: Cow<'n, IndexCore>,
108}
109
110impl<'n> IndexedNetwork<'n> {
111 /// Build a one-shot view that owns a freshly derived [`IndexCore`]. For
112 /// repeated queries on a long-lived handle, cache an [`IndexCore`] and use
113 /// [`with_core`](Self::with_core) so the derivation isn't rebuilt per call.
114 #[must_use]
115 pub fn new(net: &'n Network) -> Self {
116 let net = net.expand_transformers_3w();
117 let core = IndexCore::build(&net);
118 Self {
119 net,
120 core: Cow::Owned(core),
121 }
122 }
123
124 /// Pair `net` with an already-built [`IndexCore`] — no allocation when `net`
125 /// has no 3-winding transformer (the core must have been built from this same
126 /// `net`). When `net` does carry a 3-winding transformer, the view star-lowers
127 /// it and rebuilds the core over the expanded form, since the cached core was
128 /// derived from the unexpanded network.
129 #[must_use]
130 pub fn with_core(net: &'n Network, core: &'n IndexCore) -> Self {
131 match net.expand_transformers_3w() {
132 Cow::Borrowed(net) => Self {
133 net: Cow::Borrowed(net),
134 core: Cow::Borrowed(core),
135 },
136 Cow::Owned(net) => {
137 let core = IndexCore::build(&net);
138 Self {
139 net: Cow::Owned(net),
140 core: Cow::Owned(core),
141 }
142 }
143 }
144 }
145
146 /// The underlying network (the star-lowered form when a 3-winding transformer
147 /// was expanded).
148 #[inline]
149 pub fn network(&self) -> &Network {
150 &self.net
151 }
152
153 #[inline]
154 pub fn n(&self) -> usize {
155 self.net.buses.len()
156 }
157
158 #[inline]
159 pub fn base_mva(&self) -> f64 {
160 self.net.base_mva
161 }
162
163 /// The divisor for turning a power quantity (shunt, load, generation) into
164 /// per unit. `base_mva` for a raw network; `1.0` for a normalized one, whose
165 /// powers are already per unit (so a second division would scale them twice).
166 /// `base_mva` itself stays at the system base — for MW recovery and
167 /// write-back — so use this, not `base_mva`, wherever the intent is "÷ base
168 /// to get per unit". The effect is that a per-unit matrix builder yields the
169 /// same matrix for a network and its [`to_normalized`](Network::to_normalized)
170 /// form.
171 #[inline]
172 pub fn per_unit_base(&self) -> f64 {
173 if self.net.is_normalized() {
174 1.0
175 } else {
176 self.net.base_mva
177 }
178 }
179
180 /// A branch/bus angle field (`shift`, `va`) in radians. The raw model stores
181 /// angles in degrees; a normalized network already stores radians, so for it
182 /// this is the identity. The angle analogue of
183 /// [`per_unit_base`](Self::per_unit_base): a builder gets the same radians
184 /// whether it is handed a network or its
185 /// [`to_normalized`](Network::to_normalized) form, so the matrix comes out
186 /// the same.
187 #[inline]
188 pub fn angle_radians(&self, angle: f64) -> f64 {
189 if self.net.is_normalized() {
190 angle
191 } else {
192 angle.to_radians()
193 }
194 }
195
196 #[inline]
197 pub fn name(&self) -> &str {
198 &self.net.name
199 }
200
201 /// All branches, in source order (column order for incidence-based builds).
202 #[inline]
203 pub fn branches(&self) -> &[Branch] {
204 &self.net.branches
205 }
206
207 /// All generators, in source order.
208 #[inline]
209 pub fn generators(&self) -> &[Generator] {
210 &self.net.generators
211 }
212
213 /// Resolve a bus id to its dense `[0, n)` index.
214 #[inline]
215 pub fn bus_index(&self, bus_id: BusId) -> Option<usize> {
216 self.core.bus_id_to_idx.get(&bus_id).copied()
217 }
218
219 /// The bus id at dense index `idx` — the inverse of
220 /// [`bus_index`](Self::bus_index).
221 ///
222 /// # Panics
223 /// Panics if `idx >= n`. Pass a dense index (e.g. from [`bus_index`](Self::bus_index) or a
224 /// matrix row), not a raw bus id.
225 #[inline]
226 pub fn bus_id(&self, idx: usize) -> BusId {
227 self.net.buses[idx].id
228 }
229
230 /// Nodal active demand, length `n`.
231 #[inline]
232 pub fn pd(&self) -> &[f64] {
233 &self.core.pd
234 }
235
236 /// Nodal reactive demand, length `n`.
237 #[inline]
238 pub fn qd(&self) -> &[f64] {
239 &self.core.qd
240 }
241
242 /// Nodal shunt conductance, length `n`.
243 #[inline]
244 pub fn gs(&self) -> &[f64] {
245 &self.core.gs
246 }
247
248 /// Nodal shunt susceptance, length `n`.
249 #[inline]
250 pub fn bs(&self) -> &[f64] {
251 &self.core.bs
252 }
253
254 /// In-service branches with their index into [`branches`](Self::branches).
255 pub fn in_service_branches(&self) -> impl Iterator<Item = (usize, &Branch)> {
256 self.net
257 .branches
258 .iter()
259 .enumerate()
260 .filter(|(_, b)| b.in_service)
261 }
262
263 /// In-service generators with their index into [`generators`](Self::generators).
264 pub fn in_service_gens(&self) -> impl Iterator<Item = (usize, &Generator)> {
265 self.net
266 .generators
267 .iter()
268 .enumerate()
269 .filter(|(_, g)| g.in_service)
270 }
271
272 /// Dense indices of every reference (slack) bus, in ascending order. A
273 /// network may carry more than one [`BusType::Ref`] (a slack per island, or
274 /// several the source file marked) — the matrix layer grounds one row/column
275 /// per entry. Empty when the network has no reference bus.
276 pub fn reference_bus_indices(&self) -> Vec<usize> {
277 self.net
278 .buses
279 .iter()
280 .enumerate()
281 .filter(|(_, b)| b.kind == BusType::Ref)
282 .map(|(i, _)| i)
283 .collect()
284 }
285
286 /// Dense index of the single reference (slack) bus. Errors unless exactly
287 /// one [`BusType::Ref`] exists; for the multi-reference case use
288 /// [`reference_bus_indices`](Self::reference_bus_indices).
289 pub fn reference_bus_index(&self) -> Result<usize> {
290 match self.reference_bus_indices().as_slice() {
291 [r] => Ok(*r),
292 other => Err(Error::ReferenceBusCount { found: other.len() }),
293 }
294 }
295
296 /// Undirected graph view: node weight = dense bus index, edge weight =
297 /// index into [`branches`](Self::branches). Out-of-service branches are
298 /// skipped; parallel branches are kept as separate edges.
299 pub fn to_petgraph(&self) -> UnGraph<usize, usize> {
300 let mut g = UnGraph::with_capacity(self.n(), self.net.branches.len());
301 let nodes: Vec<_> = (0..self.n()).map(|i| g.add_node(i)).collect();
302 for (idx, br) in self.in_service_branches() {
303 if let (Some(i), Some(j)) = (self.bus_index(br.from), self.bus_index(br.to)) {
304 g.add_edge(nodes[i], nodes[j], idx);
305 }
306 }
307 g
308 }
309
310 /// Number of connected components in the in-service topology.
311 pub fn n_connected_components(&self) -> usize {
312 petgraph::algo::connected_components(&self.to_petgraph())
313 }
314
315 /// Connected-component label per dense bus index (in-service topology): two
316 /// buses share a label iff an in-service branch path joins them, and an
317 /// isolated bus is its own component. Labels are representative indices in
318 /// `[0, n)`, not a dense `[0, k)` range — use them for equality grouping
319 /// (e.g. checking every island carries a reference bus to ground).
320 pub fn connected_component_labels(&self) -> Vec<usize> {
321 let mut uf = petgraph::unionfind::UnionFind::new(self.n());
322 for (_, br) in self.in_service_branches() {
323 if let (Some(i), Some(j)) = (self.bus_index(br.from), self.bus_index(br.to)) {
324 uf.union(i, j);
325 }
326 }
327 uf.into_labeling()
328 }
329
330 /// Error unless every connected component of the in-service topology carries
331 /// at least one reference (slack) bus. This is the grounding precondition
332 /// for the DC Laplacian: an island with no reference leaves its all-ones
333 /// null vector in the system, so the reference-grounded Laplacian stays
334 /// singular. With one reference in a single island it reduces to the
335 /// single slack requirement. Reports the count of ungrounded components.
336 pub fn check_reference_coverage(&self) -> Result<()> {
337 let labels = self.connected_component_labels();
338 // Mark each component (by its representative index in `[0, n)`) that holds
339 // a reference. A Vec<bool> keyed by label avoids hashing and uses one
340 // flat allocation.
341 let mut grounded = vec![false; labels.len()];
342 for r in self.reference_bus_indices() {
343 grounded[labels[r]] = true;
344 }
345 // A component shows up once at its root (`labels[i] == i`); count the
346 // roots whose component was never grounded.
347 let ungrounded = (0..labels.len())
348 .filter(|&i| labels[i] == i && !grounded[i])
349 .count();
350 if ungrounded > 0 {
351 return Err(Error::UngroundedComponent {
352 components: ungrounded,
353 });
354 }
355 Ok(())
356 }
357
358 /// True iff the in-service topology is a forest (`|E| = |V| - components`).
359 pub fn is_radial(&self) -> bool {
360 let g = self.to_petgraph();
361 let n_components = petgraph::algo::connected_components(&g);
362 g.edge_count() == g.node_count().saturating_sub(n_components)
363 }
364
365 /// One-shot topological diagnostic.
366 pub fn connectivity_report(&self) -> ConnectivityReport {
367 let g = self.to_petgraph();
368 let n_components = petgraph::algo::connected_components(&g);
369 let isolated: Vec<usize> = g
370 .node_indices()
371 .filter(|n| g.neighbors(*n).next().is_none())
372 .map(|n| g[n])
373 .collect();
374 ConnectivityReport {
375 n_buses: self.n(),
376 n_branches_in_service: self.net.branches.iter().filter(|b| b.in_service).count(),
377 n_components,
378 isolated_buses: isolated,
379 }
380 }
381}
382
383/// Topological invariants the TUI Inspect screen and downstream solvers care
384/// about.
385#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
386#[non_exhaustive]
387pub struct ConnectivityReport {
388 pub n_buses: usize,
389 pub n_branches_in_service: usize,
390 pub n_components: usize,
391 /// Dense bus indices with no incident in-service branch.
392 pub isolated_buses: Vec<usize>,
393}
394
395impl ConnectivityReport {
396 #[inline]
397 pub fn is_single_island(&self) -> bool {
398 self.n_components == 1 && self.isolated_buses.is_empty()
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::{IndexCore, IndexedNetwork};
405 use crate::network::{
406 Bus, BusId, BusType, Extras, Impedance, Load, Network, Shunt, Transformer3W, Winding,
407 };
408
409 fn bus(id: usize, kind: BusType) -> Bus {
410 Bus {
411 id: BusId(id),
412 kind,
413 vm: 1.0,
414 va: 0.0,
415 base_kv: 1.0,
416 vmax: 1.1,
417 vmin: 0.9,
418 evhi: None,
419 evlo: None,
420 area: 1,
421 zone: 1,
422 name: None,
423 uid: None,
424 extras: Extras::new(),
425 }
426 }
427
428 fn agg_net() -> Network {
429 let mut net = Network::in_memory(
430 "agg",
431 100.0,
432 vec![bus(1, BusType::Ref), bus(2, BusType::Pq)],
433 Vec::new(),
434 );
435 net.loads.push(Load {
436 bus: BusId(1),
437 p: 10.0,
438 q: 5.0,
439 voltage_model: None,
440 in_service: true,
441 uid: None,
442 extras: Extras::new(),
443 });
444 net.loads.push(Load {
445 bus: BusId(1),
446 p: 3.0,
447 q: 1.0,
448 voltage_model: None,
449 in_service: true,
450 uid: None,
451 extras: Extras::new(),
452 });
453 net.shunts.push(Shunt {
454 bus: BusId(1),
455 g: 0.2,
456 b: 0.4,
457 in_service: true,
458 control: None,
459 uid: None,
460 extras: Extras::new(),
461 });
462 net.shunts.push(Shunt {
463 bus: BusId(1),
464 g: 0.1,
465 b: 0.3,
466 in_service: true,
467 control: None,
468 uid: None,
469 extras: Extras::new(),
470 });
471 net
472 }
473
474 fn assert_aggregates(view: &IndexedNetwork) {
475 let i = view.bus_index(BusId(1)).unwrap();
476 assert!((view.pd()[i] - 13.0).abs() < 1e-12);
477 assert!((view.qd()[i] - 6.0).abs() < 1e-12);
478 assert!((view.gs()[i] - 0.3).abs() < 1e-12);
479 assert!((view.bs()[i] - 0.7).abs() < 1e-12);
480 let j = view.bus_index(BusId(2)).unwrap();
481 assert!(view.pd()[j].abs() < 1e-12);
482 assert!(view.gs()[j].abs() < 1e-12);
483 }
484
485 fn three_winding(a: usize, b: usize, c: usize) -> Transformer3W {
486 let winding = |bus| Winding {
487 bus: BusId(bus),
488 tap: 1.0,
489 shift: 0.0,
490 nominal_kv: 0.0,
491 rate_a: 0.0,
492 rate_b: 0.0,
493 rate_c: 0.0,
494 };
495 let imp = Impedance {
496 r: 0.0,
497 x: 0.1,
498 base_mva: 100.0,
499 };
500 Transformer3W {
501 windings: [winding(a), winding(b), winding(c)],
502 z: [imp, imp, imp],
503 star_vm: 1.0,
504 star_va: 0.0,
505 mag_g: 0.0,
506 mag_b: 0.0,
507 in_service: true,
508 name: None,
509 uid: None,
510 extras: Extras::new(),
511 }
512 }
513
514 #[test]
515 fn three_winding_star_lowering_adds_a_grounded_star_bus_with_its_magnetizing_shunt() {
516 // Three buses joined only by a 3-winding transformer; bus 1 is the
517 // reference. The view star-lowers it into one grounded component plus a
518 // synthetic star bus that carries the magnetizing shunt.
519 let mut net = Network::in_memory(
520 "t3w",
521 100.0,
522 vec![
523 bus(1, BusType::Ref),
524 bus(2, BusType::Pq),
525 bus(3, BusType::Pq),
526 ],
527 Vec::new(),
528 );
529 let mut t = three_winding(1, 2, 3);
530 t.mag_b = 0.05; // p.u. on the system base
531 net.transformers_3w.push(t);
532
533 let view = IndexedNetwork::new(&net);
534 assert_eq!(view.n(), 4, "three buses plus the synthetic star point");
535 assert_eq!(view.n_connected_components(), 1);
536 view.check_reference_coverage().unwrap();
537
538 // The star bus is the last dense index; its susceptance is the magnetizing
539 // b scaled to the system base (raw network: × base_mva).
540 let star = view.n() - 1;
541 assert!((view.bs()[star] - 0.05 * 100.0).abs() < 1e-9);
542 for i in 0..3 {
543 assert!(view.bs()[i].abs() < 1e-12, "original buses carry no shunt");
544 }
545
546 // The canonical model keeps the typed record and gains no buses/branches.
547 assert_eq!(net.buses.len(), 3);
548 assert!(net.branches.is_empty());
549 assert_eq!(net.transformers_3w.len(), 1);
550 }
551
552 #[test]
553 fn out_of_service_three_winding_is_not_expanded() {
554 let mut net = Network::in_memory(
555 "t3w",
556 100.0,
557 vec![
558 bus(1, BusType::Ref),
559 bus(2, BusType::Pq),
560 bus(3, BusType::Pq),
561 ],
562 Vec::new(),
563 );
564 let mut t = three_winding(1, 2, 3);
565 t.in_service = false;
566 net.transformers_3w.push(t);
567
568 let view = IndexedNetwork::new(&net);
569 // No star bus is synthesized for an out-of-service transformer, so the
570 // three buses stay as three islands (no spurious star point).
571 assert_eq!(view.n(), 3);
572 assert_eq!(view.n_connected_components(), 3);
573 }
574
575 #[test]
576 fn aggregates_sum_multiple_loads_and_shunts_per_bus() {
577 // PSS/E and PowerModels admit several loads/shunts on one bus; the
578 // per-bus fold must add them, not overwrite (last-writer-wins would pass
579 // every MATPOWER fixture, which folds one load per bus).
580 let net = agg_net();
581 assert_aggregates(&IndexedNetwork::new(&net));
582 }
583
584 #[test]
585 fn with_core_matches_one_shot_view() {
586 // A view over a cached core sees the same aggregates as a fresh one.
587 let net = agg_net();
588 let core = IndexCore::build(&net);
589 assert_aggregates(&IndexedNetwork::with_core(&net, &core));
590 }
591}