Skip to main content

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}