Skip to main content

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}