Skip to main content

powerio/
operations.rs

1//! Network operations: deriving or rewriting a [`Network`].
2//!
3//! These are model-level transforms, distinct from the format readers/writers and
4//! from the per unit [`to_normalized`](Network::to_normalized) form.
5//! [`subset`](Network::subset) carves a study footprint out of a larger case;
6//! [`merge_bus`](Network::merge_bus) collapses two buses into one (re-homing the
7//! incident elements), and [`reduce_zero_impedance`](Network::reduce_zero_impedance)
8//! builds on it to remove jumper branches.
9//! [`reduce_passthrough_buses`](Network::reduce_passthrough_buses) folds dummy-bus
10//! line sections back into one equivalent branch.
11
12use std::collections::HashSet;
13
14use serde_json::Value;
15
16use crate::network::{
17    Branch, Bus, BusId, BusType, Extras, Generator, Network, Shunt, SourceFormat,
18};
19
20/// The endpoint of `b` other than `m` (assumes `m` is an endpoint).
21fn other_end(b: &Branch, m: BusId) -> BusId {
22    if b.from == m { b.to } else { b.from }
23}
24
25/// Combine two thermal ratings into the equivalent for a series pair. `0` means
26/// "no limit" in the MATPOWER convention, so it yields to a finite rating; two
27/// finite ratings give the more limiting (smaller) one.
28fn combine_rate(a: f64, b: f64) -> f64 {
29    match (a == 0.0, b == 0.0) {
30        (true, _) => b,
31        (_, true) => a,
32        _ => a.min(b),
33    }
34}
35
36/// Bus-kind importance, so a [`merge_bus`](Network::merge_bus) keeps the stronger
37/// designation (a slack outranks a PV bus, which outranks PQ, which outranks an
38/// isolated stub).
39fn kind_priority(kind: BusType) -> u8 {
40    match kind {
41        BusType::Ref => 3,
42        BusType::Pv => 2,
43        BusType::Pq => 1,
44        BusType::Isolated => 0,
45    }
46}
47
48/// Which buses a [`subset`](Network::subset) keeps: inclusive ranges over area,
49/// zone, base kV, and bus number, ANDed together. An unset (`None`) filter
50/// matches every bus, so [`Selector::default`] selects the whole network.
51#[derive(Debug, Clone, Default, PartialEq)]
52pub struct Selector {
53    /// Inclusive `(low, high)` area-number range.
54    pub area: Option<(usize, usize)>,
55    /// Inclusive `(low, high)` zone-number range.
56    pub zone: Option<(usize, usize)>,
57    /// Inclusive `(low, high)` base-kV range.
58    pub base_kv: Option<(f64, f64)>,
59    /// Inclusive `(low, high)` bus-number range.
60    pub bus: Option<(usize, usize)>,
61}
62
63impl Selector {
64    /// Whether `bus` satisfies every set filter.
65    fn matches(&self, bus: &Bus) -> bool {
66        fn in_usize(range: Option<(usize, usize)>, v: usize) -> bool {
67            range.is_none_or(|(lo, hi)| lo <= v && v <= hi)
68        }
69        fn in_f64(range: Option<(f64, f64)>, v: f64) -> bool {
70            range.is_none_or(|(lo, hi)| lo <= v && v <= hi)
71        }
72        in_usize(self.area, bus.area)
73            && in_usize(self.zone, bus.zone)
74            && in_f64(self.base_kv, bus.base_kv)
75            && in_usize(self.bus, bus.id.0)
76    }
77}
78
79impl Network {
80    /// Carve out the sub-network whose buses match `sel`.
81    ///
82    /// In-scope buses keep their loads, shunts, generators, and storage; a branch,
83    /// HVDC line, or 3-winding transformer is kept when every bus it touches is
84    /// kept. With `keep_boundary`, a branch or HVDC line straddling the selection
85    /// edge pulls its out-of-scope endpoint in as a *tie bus* (tagged
86    /// `extras["tie_bus"] = true`) so the carved island has no dangling branch
87    /// ends; without it, a straddling branch is dropped. A tie bus is a stub: its
88    /// own loads/generators are not pulled in. A control reference (regulated bus)
89    /// that falls outside the kept set is cleared so the result is
90    /// reference-consistent.
91    ///
92    /// The result is a fresh [`SourceFormat::InMemory`] network (no retained
93    /// source); an empty `Selector` returns a clone-equivalent of the whole case,
94    /// and a selector matching no bus returns an empty network.
95    #[must_use]
96    // A flat filter pipeline, one stanza per element table; splitting it would add
97    // indirection without clarity.
98    #[expect(clippy::too_many_lines)]
99    pub fn subset(&self, sel: &Selector, keep_boundary: bool) -> Network {
100        let in_scope: HashSet<BusId> = self
101            .buses
102            .iter()
103            .filter(|b| sel.matches(b))
104            .map(|b| b.id)
105            .collect();
106
107        // Boundary: the out-of-scope endpoint of any branch/HVDC with exactly one
108        // endpoint in scope.
109        let mut boundary: HashSet<BusId> = HashSet::new();
110        if keep_boundary {
111            let mut edge = |a: BusId, b: BusId| match (in_scope.contains(&a), in_scope.contains(&b))
112            {
113                (true, false) => {
114                    boundary.insert(b);
115                }
116                (false, true) => {
117                    boundary.insert(a);
118                }
119                _ => {}
120            };
121            for br in &self.branches {
122                edge(br.from, br.to);
123            }
124            for d in &self.hvdc {
125                edge(d.from, d.to);
126            }
127        }
128        let kept: HashSet<BusId> = in_scope.union(&boundary).copied().collect();
129
130        let buses: Vec<Bus> = self
131            .buses
132            .iter()
133            .filter(|b| kept.contains(&b.id))
134            .map(|b| {
135                let mut b = b.clone();
136                if boundary.contains(&b.id) {
137                    b.extras.insert("tie_bus".into(), Value::Bool(true));
138                }
139                b
140            })
141            .collect();
142
143        // Injection elements live only on in-scope buses; tie buses are stubs.
144        let loads = self
145            .loads
146            .iter()
147            .filter(|l| in_scope.contains(&l.bus))
148            .cloned()
149            .collect();
150        let mut shunts: Vec<Shunt> = self
151            .shunts
152            .iter()
153            .filter(|s| in_scope.contains(&s.bus))
154            .cloned()
155            .collect();
156        let mut generators: Vec<Generator> = self
157            .generators
158            .iter()
159            .filter(|g| in_scope.contains(&g.bus))
160            .cloned()
161            .collect();
162        let storage = self
163            .storage
164            .iter()
165            .filter(|s| in_scope.contains(&s.bus))
166            .cloned()
167            .collect();
168
169        let mut branches: Vec<Branch> = self
170            .branches
171            .iter()
172            .filter(|br| kept.contains(&br.from) && kept.contains(&br.to))
173            .cloned()
174            .collect();
175        let switches = self
176            .switches
177            .iter()
178            .filter(|sw| kept.contains(&sw.from) && kept.contains(&sw.to))
179            .cloned()
180            .collect();
181        let hvdc = self
182            .hvdc
183            .iter()
184            .filter(|d| kept.contains(&d.from) && kept.contains(&d.to))
185            .cloned()
186            .collect();
187        let transformers_3w = self
188            .transformers_3w
189            .iter()
190            .filter(|t| t.windings.iter().all(|w| kept.contains(&w.bus)))
191            .cloned()
192            .collect();
193
194        // Clear control references that point outside the kept set.
195        for br in &mut branches {
196            if let Some(c) = &mut br.control {
197                if c.controlled_bus.is_some_and(|b| !kept.contains(&b)) {
198                    c.controlled_bus = None;
199                }
200            }
201        }
202        for sh in &mut shunts {
203            if let Some(c) = &mut sh.control {
204                if c.control_bus.is_some_and(|b| !kept.contains(&b)) {
205                    c.control_bus = None;
206                }
207            }
208        }
209        for g in &mut generators {
210            if g.regulated_bus.is_some_and(|b| !kept.contains(&b)) {
211                g.regulated_bus = None;
212            }
213        }
214
215        // Keep the area records still referenced by a kept bus (clearing a dangling
216        // area-slack), plus the global solver settings. The bus `area` numbers alone
217        // can't carry the interchange schedule or the solver tolerances, so dropping
218        // them would silently lose data a PSS/E/PSLF write of the subset emits.
219        let kept_area_numbers: HashSet<usize> = buses.iter().map(|b| b.area).collect();
220        let areas = self
221            .areas
222            .iter()
223            .filter(|a| kept_area_numbers.contains(&a.number))
224            .cloned()
225            .map(|mut a| {
226                if a.slack_bus.is_some_and(|b| !kept.contains(&b)) {
227                    a.slack_bus = None;
228                }
229                a
230            })
231            .collect::<Vec<_>>();
232
233        let net = Network {
234            name: format!("{} (subset)", self.name),
235            base_mva: self.base_mva,
236            base_frequency: self.base_frequency,
237            buses,
238            loads,
239            shunts,
240            branches,
241            switches,
242            generators,
243            storage,
244            hvdc,
245            transformers_3w,
246            areas,
247            solver: self.solver.clone(),
248            source_format: SourceFormat::InMemory,
249            source: None,
250        };
251        debug_assert!(
252            net.validate().is_ok(),
253            "subset produced a dangling reference"
254        );
255        net
256    }
257
258    /// Merge bus `from` into bus `into`: re-home every element on `from` (loads,
259    /// shunts, generators, storage, branch/HVDC/transformer endpoints, and control
260    /// references) onto `into`, drop the branches and HVDC lines that ran directly
261    /// between the two (now self-loops), and remove the `from` bus. The surviving
262    /// bus keeps the stronger of the two bus kinds (a slack is not demoted).
263    ///
264    /// A no-op when `into == from`. The other attributes of `from` (its voltage,
265    /// limits, name) are discarded; the topology and injections are what move.
266    pub fn merge_bus(&mut self, into: BusId, from: BusId) {
267        if into == from {
268            return;
269        }
270        let remap = |b: &mut BusId| {
271            if *b == from {
272                *b = into;
273            }
274        };
275
276        for l in &mut self.loads {
277            remap(&mut l.bus);
278        }
279        for s in &mut self.shunts {
280            remap(&mut s.bus);
281            if let Some(cb) = s.control.as_mut().and_then(|c| c.control_bus.as_mut()) {
282                remap(cb);
283            }
284        }
285        for g in &mut self.generators {
286            remap(&mut g.bus);
287            if let Some(rb) = g.regulated_bus.as_mut() {
288                remap(rb);
289            }
290        }
291        for st in &mut self.storage {
292            remap(&mut st.bus);
293        }
294        for br in &mut self.branches {
295            remap(&mut br.from);
296            remap(&mut br.to);
297            if let Some(cb) = br.control.as_mut().and_then(|c| c.controlled_bus.as_mut()) {
298                remap(cb);
299            }
300        }
301        self.branches.retain(|b| b.from != b.to);
302        for sw in &mut self.switches {
303            remap(&mut sw.from);
304            remap(&mut sw.to);
305        }
306        self.switches.retain(|s| s.from != s.to);
307        for d in &mut self.hvdc {
308            remap(&mut d.from);
309            remap(&mut d.to);
310        }
311        self.hvdc.retain(|d| d.from != d.to);
312        for t in &mut self.transformers_3w {
313            for w in &mut t.windings {
314                remap(&mut w.bus);
315            }
316        }
317        for a in &mut self.areas {
318            if let Some(slack) = a.slack_bus.as_mut() {
319                remap(slack);
320            }
321        }
322
323        // Promote the surviving bus kind, then drop the merged bus.
324        let from_kind = self.buses.iter().find(|b| b.id == from).map(|b| b.kind);
325        self.buses.retain(|b| b.id != from);
326        if let (Some(fk), Some(into_bus)) =
327            (from_kind, self.buses.iter_mut().find(|b| b.id == into))
328        {
329            if kind_priority(fk) > kind_priority(into_bus.kind) {
330                into_bus.kind = fk;
331            }
332        }
333        // The topology changed, so the retained source text is stale.
334        self.invalidate_source();
335    }
336
337    /// Collapse every in-service, non-transformer branch whose series impedance
338    /// magnitude is at or below `threshold` by merging its endpoints (the to-bus
339    /// into the from-bus), returning the number of branches removed. Parallel
340    /// jumpers between the same pair go in the same step.
341    ///
342    /// Zero-impedance branches (bus ties, breakers modeled as jumpers) carry no
343    /// power flow drop, so collapsing them shrinks the network without changing
344    /// its electrical behavior. An out-of-service jumper is an open switch whose
345    /// endpoints are not electrically joined, so it is left in place. Transformers
346    /// are never collapsed (a unity-ratio transformer is a real device, not a
347    /// jumper); a jumper between two windings of the same 3-winding transformer is
348    /// also skipped, since merging would collapse that transformer onto one node.
349    pub fn reduce_zero_impedance(&mut self, threshold: f64) -> usize {
350        let before = self.branches.len();
351        // Re-scan after each merge: bus ids and the branch list both change.
352        while let Some((into, from)) = self.branches.iter().find_map(|b| {
353            (b.in_service
354                && !b.is_transformer()
355                && b.from != b.to
356                && b.r.hypot(b.x) <= threshold
357                && !self.shares_transformer_3w(b.from, b.to))
358            .then_some((b.from, b.to))
359        }) {
360            self.merge_bus(into, from);
361        }
362        before - self.branches.len()
363    }
364
365    /// Whether buses `a` and `b` are two windings of the same 3-winding
366    /// transformer; merging them would short two windings onto one node.
367    fn shares_transformer_3w(&self, a: BusId, b: BusId) -> bool {
368        self.transformers_3w
369            .iter()
370            .any(|t| t.windings.iter().any(|w| w.bus == a) && t.windings.iter().any(|w| w.bus == b))
371    }
372
373    /// Collapse degree-2 passthrough buses, returning the number removed. A
374    /// passthrough bus carries nothing but two in-service line sections, so it is
375    /// an electrically inert junction: the two sections fold into one equivalent
376    /// branch between their outer endpoints and the middle bus is deleted.
377    ///
378    /// This is the multi-section-line reduction. Exporters often split one circuit
379    /// into segments joined at dummy buses; folding them back recovers the single
380    /// branch. A bus qualifies only when it carries no load, generator, shunt, or
381    /// storage, is not a control reference, area swing, HVDC endpoint, or 3-winding
382    /// winding bus, is not the system slack, and is touched by exactly two ordinary
383    /// branches (never transformers) that are both in service and run to two
384    /// distinct other buses. The equivalent branch sums the series impedance and
385    /// line charging, takes the more limiting thermal rating of the two sections,
386    /// and intersects their angle limits. Chains of dummy buses collapse fully, one
387    /// bus per step.
388    pub fn reduce_passthrough_buses(&mut self) -> usize {
389        let mut collapsed = 0;
390        // Re-scan after each fold: the equivalent branch becomes a section for the
391        // next bus in a dummy chain, and the bus list shrinks.
392        while let Some(mid) = self
393            .buses
394            .iter()
395            .map(|b| b.id)
396            .find(|&m| self.is_passthrough(m))
397        {
398            self.collapse_passthrough(mid);
399            collapsed += 1;
400        }
401        collapsed
402    }
403
404    /// Whether `m` is a collapsible degree-2 passthrough bus (see
405    /// [`reduce_passthrough_buses`](Network::reduce_passthrough_buses)).
406    fn is_passthrough(&self, m: BusId) -> bool {
407        let Some(bus) = self.buses.iter().find(|b| b.id == m) else {
408            return false;
409        };
410        if bus.kind == BusType::Ref {
411            return false;
412        }
413        if self.loads.iter().any(|l| l.bus == m)
414            || self.generators.iter().any(|g| g.bus == m)
415            || self.shunts.iter().any(|s| s.bus == m)
416            || self.storage.iter().any(|s| s.bus == m)
417            || self.hvdc.iter().any(|d| d.from == m || d.to == m)
418        {
419            return false;
420        }
421        if self
422            .transformers_3w
423            .iter()
424            .any(|t| t.windings.iter().any(|w| w.bus == m))
425        {
426            return false;
427        }
428        if self.areas.iter().any(|a| a.slack_bus == Some(m)) {
429            return false;
430        }
431        let controlled = self
432            .branches
433            .iter()
434            .any(|b| b.control.as_ref().and_then(|c| c.controlled_bus) == Some(m));
435        let regulated = self
436            .shunts
437            .iter()
438            .any(|s| s.control.as_ref().and_then(|c| c.control_bus) == Some(m));
439        let gen_regulated = self.generators.iter().any(|g| g.regulated_bus == Some(m));
440        if controlled || regulated || gen_regulated {
441            return false;
442        }
443        let incident: Vec<&Branch> = self
444            .branches
445            .iter()
446            .filter(|b| b.from == m || b.to == m)
447            .collect();
448        if incident.len() != 2 {
449            return false;
450        }
451        let a = other_end(incident[0], m);
452        let c = other_end(incident[1], m);
453        incident.iter().all(|b| !b.is_transformer() && b.in_service) && a != m && c != m && a != c
454    }
455
456    /// Fold the two line sections at passthrough bus `m` into one equivalent branch
457    /// and remove `m`. The caller has already checked [`is_passthrough`].
458    fn collapse_passthrough(&mut self, m: BusId) {
459        let mut sections: Vec<Branch> = Vec::new();
460        self.branches.retain(|b| {
461            if b.from == m || b.to == m {
462                sections.push(b.clone());
463                false
464            } else {
465                true
466            }
467        });
468        debug_assert_eq!(sections.len(), 2, "passthrough bus must have two sections");
469        let (s1, s2) = (&sections[0], &sections[1]);
470        // Intersect the two sections' angle windows, but never emit an inverted
471        // (empty) limit: two disjoint windows give angmin > angmax, which an OPF
472        // angle-difference constraint reads as infeasible. Disjoint windows fall
473        // back to the union so folding a multi-section line never turns a feasible
474        // case infeasible. (Whether series sections should intersect vs sum their
475        // windows is a modeling choice; this only fixes the invalid-range case.)
476        let mut angmin = s1.angmin.max(s2.angmin);
477        let mut angmax = s1.angmax.min(s2.angmax);
478        if angmin > angmax {
479            angmin = s1.angmin.min(s2.angmin);
480            angmax = s1.angmax.max(s2.angmax);
481        }
482        self.branches.push(Branch {
483            from: other_end(s1, m),
484            to: other_end(s2, m),
485            r: s1.r + s2.r,
486            x: s1.x + s2.x,
487            b: s1.legacy_total_charging_b() + s2.legacy_total_charging_b(),
488            charging: None,
489            rate_a: combine_rate(s1.rate_a, s2.rate_a),
490            rate_b: combine_rate(s1.rate_b, s2.rate_b),
491            rate_c: combine_rate(s1.rate_c, s2.rate_c),
492            rating_sets: Vec::new(),
493            current_ratings: None,
494            tap: 0.0,
495            shift: 0.0,
496            in_service: true,
497            angmin,
498            angmax,
499            control: None,
500            solution: None,
501            uid: None,
502            extras: Extras::new(),
503        });
504        self.buses.retain(|b| b.id != m);
505        // The topology changed, so the retained source text is stale.
506        self.invalidate_source();
507    }
508
509    /// Retype to [`BusType::Isolated`] every bus with no in-service electrical
510    /// connection — no in-service incident branch, HVDC line, or 3-winding
511    /// transformer — returning the number retyped.
512    ///
513    /// A stranded bus (retired or not-yet-built equipment, or the residue of a
514    /// topology edit) otherwise keeps a PQ/PV/slack kind that tells a solver to
515    /// include it, leaving an ungrounded singleton in the system. This only
516    /// *demotes* a disconnected bus; it never promotes a connected one, and a bus
517    /// the source already marks isolated is left untouched. Connectivity is judged
518    /// on in-service equipment only, so opening the last branch into a bus makes it
519    /// eligible.
520    pub fn retype_isolated_buses(&mut self) -> usize {
521        let mut connected: HashSet<BusId> = HashSet::new();
522        for br in self.branches.iter().filter(|b| b.in_service) {
523            connected.insert(br.from);
524            connected.insert(br.to);
525        }
526        for d in self.hvdc.iter().filter(|d| d.in_service) {
527            connected.insert(d.from);
528            connected.insert(d.to);
529        }
530        for t in self.transformers_3w.iter().filter(|t| t.in_service) {
531            for w in &t.windings {
532                connected.insert(w.bus);
533            }
534        }
535        let mut retyped = 0;
536        for b in &mut self.buses {
537            if b.kind != BusType::Isolated && !connected.contains(&b.id) {
538                b.kind = BusType::Isolated;
539                retyped += 1;
540            }
541        }
542        // Only a real retype invalidates the source; a no-op call stays lossless.
543        if retyped > 0 {
544            self.invalidate_source();
545        }
546        retyped
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use crate::network::{
554        Area, BusType, Extras, Generator, Impedance, Load, Transformer3W, Winding,
555    };
556
557    fn bus(id: usize, area: usize, base_kv: f64) -> Bus {
558        Bus {
559            id: BusId(id),
560            kind: BusType::Pq,
561            vm: 1.0,
562            va: 0.0,
563            base_kv,
564            vmax: 1.1,
565            vmin: 0.9,
566            evhi: None,
567            evlo: None,
568            area,
569            zone: 1,
570            name: None,
571            uid: None,
572            extras: Extras::new(),
573        }
574    }
575
576    fn line(from: usize, to: usize) -> Branch {
577        Branch {
578            from: BusId(from),
579            to: BusId(to),
580            r: 0.0,
581            x: 0.1,
582            b: 0.0,
583            charging: None,
584            rate_a: 0.0,
585            rate_b: 0.0,
586            rate_c: 0.0,
587            rating_sets: Vec::new(),
588            current_ratings: None,
589            tap: 0.0,
590            shift: 0.0,
591            in_service: true,
592            angmin: -360.0,
593            angmax: 360.0,
594            control: None,
595            solution: None,
596            uid: None,
597            extras: Extras::new(),
598        }
599    }
600
601    fn load(bus: usize) -> Load {
602        Load {
603            bus: BusId(bus),
604            p: 10.0,
605            q: 5.0,
606            voltage_model: None,
607            in_service: true,
608            uid: None,
609            extras: Extras::new(),
610        }
611    }
612
613    /// Two area-1 buses (1, 2) and one area-2 bus (3); a line within area 1 and a
614    /// line crossing into area 2.
615    fn two_area_net() -> Network {
616        let mut net = Network::in_memory(
617            "net",
618            100.0,
619            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 2, 230.0)],
620            vec![line(1, 2), line(2, 3)],
621        );
622        net.loads.push(load(1));
623        net.loads.push(load(3));
624        net
625    }
626
627    fn transformer_3w(a: usize, b: usize, c: usize) -> Transformer3W {
628        let winding = |bus| Winding {
629            bus: BusId(bus),
630            tap: 1.0,
631            shift: 0.0,
632            nominal_kv: 0.0,
633            rate_a: 0.0,
634            rate_b: 0.0,
635            rate_c: 0.0,
636        };
637        let imp = Impedance {
638            r: 0.0,
639            x: 0.1,
640            base_mva: 100.0,
641        };
642        Transformer3W {
643            windings: [winding(a), winding(b), winding(c)],
644            z: [imp, imp, imp],
645            star_vm: 1.0,
646            star_va: 0.0,
647            mag_g: 0.0,
648            mag_b: 0.0,
649            in_service: true,
650            name: None,
651            uid: None,
652            extras: Extras::new(),
653        }
654    }
655
656    fn gen_regulating(bus: usize, regulated: usize) -> Generator {
657        Generator {
658            bus: BusId(bus),
659            pg: 10.0,
660            qg: 0.0,
661            pmax: 100.0,
662            pmin: 0.0,
663            qmax: 50.0,
664            qmin: -50.0,
665            vg: 1.0,
666            mbase: 100.0,
667            in_service: true,
668            cost: None,
669            caps: Default::default(),
670            regulated_bus: Some(BusId(regulated)),
671            uid: None,
672        }
673    }
674
675    #[test]
676    fn subset_clears_a_regulated_bus_outside_the_kept_set() {
677        // A generator on in-scope bus 1 regulates bus 3, which the area filter drops.
678        let mut net = two_area_net();
679        net.generators.push(gen_regulating(1, 3));
680        let sel = Selector {
681            area: Some((1, 1)),
682            ..Selector::default()
683        };
684        let sub = net.subset(&sel, false);
685        assert_eq!(sub.generators.len(), 1);
686        assert_eq!(
687            sub.generators[0].regulated_bus, None,
688            "the dropped remote regulated bus is cleared, not left dangling"
689        );
690        sub.validate().unwrap();
691    }
692
693    #[test]
694    fn merge_bus_remaps_regulated_bus_and_area_slack() {
695        let mut net = two_area_net();
696        net.generators.push(gen_regulating(1, 3)); // gen on bus 1 regulates bus 3
697        net.areas.push(Area {
698            number: 1,
699            slack_bus: Some(BusId(3)),
700            net_interchange: 0.0,
701            tolerance: 0.0,
702            name: None,
703        });
704        net.merge_bus(BusId(2), BusId(3)); // bus 3 merges into bus 2
705        assert_eq!(
706            net.generators[0].regulated_bus,
707            Some(BusId(2)),
708            "the regulated bus follows the merge"
709        );
710        assert_eq!(
711            net.areas[0].slack_bus,
712            Some(BusId(2)),
713            "the area swing follows the merge"
714        );
715        net.validate().unwrap();
716    }
717
718    #[test]
719    fn reduce_passthrough_keeps_a_generator_regulated_bus() {
720        // Bus 2 is a degree-2 junction with no injection, but a generator on bus 1
721        // regulates it, so it is not an inert passthrough.
722        let mut net = Network::in_memory(
723            "net",
724            100.0,
725            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
726            vec![line(1, 2), line(2, 3)],
727        );
728        net.generators.push(gen_regulating(1, 2));
729        assert_eq!(net.reduce_passthrough_buses(), 0);
730        assert_eq!(net.buses.len(), 3);
731        net.validate().unwrap();
732    }
733
734    #[test]
735    fn subset_by_area_drops_out_of_scope_buses_and_cut_branches() {
736        let net = two_area_net();
737        let sel = Selector {
738            area: Some((1, 1)),
739            ..Selector::default()
740        };
741        let sub = net.subset(&sel, false);
742
743        // Buses 1, 2 kept; bus 3 (area 2) dropped along with the crossing line.
744        assert_eq!(sub.buses.len(), 2);
745        assert!(sub.buses.iter().all(|b| b.area == 1));
746        assert_eq!(sub.branches.len(), 1, "only the intra-area line survives");
747        assert_eq!(sub.loads.len(), 1, "the area-2 load is dropped");
748        sub.validate().unwrap();
749    }
750
751    #[test]
752    fn subset_keep_boundary_pulls_in_the_tie_bus() {
753        let net = two_area_net();
754        let sel = Selector {
755            area: Some((1, 1)),
756            ..Selector::default()
757        };
758        let sub = net.subset(&sel, true);
759
760        // Bus 3 is pulled in as a tie bus so the crossing line keeps both ends.
761        assert_eq!(sub.buses.len(), 3);
762        assert_eq!(sub.branches.len(), 2);
763        let tie = sub.buses.iter().find(|b| b.id == BusId(3)).unwrap();
764        assert_eq!(tie.extras.get("tie_bus"), Some(&Value::Bool(true)));
765        // The tie bus is a stub: its load is not pulled in.
766        assert_eq!(sub.loads.len(), 1);
767        sub.validate().unwrap();
768    }
769
770    #[test]
771    fn empty_selector_keeps_everything() {
772        let net = two_area_net();
773        let sub = net.subset(&Selector::default(), false);
774        assert_eq!(sub.buses.len(), net.buses.len());
775        assert_eq!(sub.branches.len(), net.branches.len());
776    }
777
778    #[test]
779    fn base_kv_range_filters_by_voltage() {
780        let mut net = two_area_net();
781        net.buses[2].base_kv = 115.0; // bus 3 to a different voltage class
782        let sel = Selector {
783            base_kv: Some((200.0, 300.0)),
784            ..Selector::default()
785        };
786        let sub = net.subset(&sel, false);
787        assert_eq!(sub.buses.len(), 2, "only the 230 kV buses match");
788    }
789
790    #[test]
791    fn merge_bus_rehomes_elements_and_drops_the_connecting_branch() {
792        let mut net = two_area_net(); // buses 1,2,3; lines 1-2, 2-3; loads on 1, 3
793        net.merge_bus(BusId(2), BusId(3));
794
795        assert_eq!(net.buses.len(), 2, "bus 3 removed");
796        assert!(net.buses.iter().all(|b| b.id != BusId(3)));
797        assert_eq!(
798            net.branches.len(),
799            1,
800            "the 2-3 line collapsed to a self-loop"
801        );
802        assert_eq!(net.branches[0].from, BusId(1));
803        assert_eq!(net.branches[0].to, BusId(2));
804        // Both loads survive; the one on bus 3 moved to bus 2.
805        assert_eq!(net.loads.len(), 2);
806        assert!(net.loads.iter().any(|l| l.bus == BusId(2)));
807        net.validate().unwrap();
808    }
809
810    #[test]
811    fn merge_bus_keeps_the_stronger_bus_kind() {
812        let mut net = two_area_net();
813        net.buses[2].kind = BusType::Ref; // bus 3 is the slack
814        net.merge_bus(BusId(2), BusId(3)); // merge the slack into the PQ bus 2
815        let two = net.buses.iter().find(|b| b.id == BusId(2)).unwrap();
816        assert_eq!(two.kind, BusType::Ref, "the slack designation is not lost");
817    }
818
819    #[test]
820    fn reduce_passthrough_folds_a_multi_section_line() {
821        // A 1-2-3-4 chain where 2 and 3 are dummy junctions; ratings 100 / 80 /
822        // unlimited along the sections.
823        let mut s1 = line(1, 2);
824        s1.rate_a = 100.0;
825        let mut s2 = line(2, 3);
826        s2.rate_a = 80.0;
827        let s3 = line(3, 4); // rate_a 0 == no limit
828        let mut net = Network::in_memory(
829            "net",
830            100.0,
831            vec![
832                bus(1, 1, 230.0),
833                bus(2, 1, 230.0),
834                bus(3, 1, 230.0),
835                bus(4, 1, 230.0),
836            ],
837            vec![s1, s2, s3],
838        );
839
840        let removed = net.reduce_passthrough_buses();
841        assert_eq!(removed, 2, "both dummy buses collapse");
842        assert_eq!(net.buses.len(), 2);
843        assert!(
844            net.buses
845                .iter()
846                .all(|b| b.id == BusId(1) || b.id == BusId(4))
847        );
848        assert_eq!(net.branches.len(), 1, "one equivalent branch");
849        let eq = &net.branches[0];
850        assert_eq!(
851            [eq.from, eq.to].iter().copied().collect::<HashSet<_>>(),
852            [BusId(1), BusId(4)].into_iter().collect::<HashSet<_>>(),
853        );
854        assert!((eq.x - 0.3).abs() < 1e-9, "series reactance sums");
855        assert!(
856            (eq.rate_a - 80.0).abs() < 1e-9,
857            "the more limiting finite rating wins"
858        );
859        net.validate().unwrap();
860    }
861
862    #[test]
863    fn reduce_passthrough_keeps_a_bus_with_injection() {
864        // Bus 2 is degree 2 but carries a load, so it is not inert.
865        let mut net = Network::in_memory(
866            "net",
867            100.0,
868            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
869            vec![line(1, 2), line(2, 3)],
870        );
871        net.loads.push(load(2));
872        assert_eq!(net.reduce_passthrough_buses(), 0);
873        assert_eq!(net.buses.len(), 3);
874    }
875
876    #[test]
877    fn reduce_passthrough_does_not_fold_across_a_transformer() {
878        // Section 2-3 is a transformer, so bus 2 is a real terminal, not a junction.
879        let mut xfmr = line(2, 3);
880        xfmr.tap = 1.0;
881        let mut net = Network::in_memory(
882            "net",
883            100.0,
884            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
885            vec![line(1, 2), xfmr],
886        );
887        assert_eq!(net.reduce_passthrough_buses(), 0);
888        assert_eq!(net.buses.len(), 3);
889    }
890
891    #[test]
892    fn retype_isolated_marks_stranded_buses() {
893        // Bus 3 has no incident branch.
894        let mut net = Network::in_memory(
895            "net",
896            100.0,
897            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
898            vec![line(1, 2)],
899        );
900        assert_eq!(net.retype_isolated_buses(), 1);
901        let three = net.buses.iter().find(|b| b.id == BusId(3)).unwrap();
902        assert_eq!(three.kind, BusType::Isolated);
903        // The connected buses keep their kind.
904        let one = net.buses.iter().find(|b| b.id == BusId(1)).unwrap();
905        assert_eq!(one.kind, BusType::Pq);
906        net.validate().unwrap();
907    }
908
909    #[test]
910    fn retype_isolated_judges_in_service_equipment_only() {
911        // The only branch is out of service, so both of its ends are stranded.
912        let mut br = line(1, 2);
913        br.in_service = false;
914        let mut net = Network::in_memory(
915            "net",
916            100.0,
917            vec![bus(1, 1, 230.0), bus(2, 1, 230.0)],
918            vec![br],
919        );
920        assert_eq!(net.retype_isolated_buses(), 2);
921        assert!(net.buses.iter().all(|b| b.kind == BusType::Isolated));
922    }
923
924    #[test]
925    fn retype_isolated_is_idempotent() {
926        let mut net = Network::in_memory(
927            "net",
928            100.0,
929            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
930            vec![line(1, 2)],
931        );
932        assert_eq!(net.retype_isolated_buses(), 1);
933        assert_eq!(net.retype_isolated_buses(), 0, "second pass is a no-op");
934    }
935
936    #[test]
937    fn reduce_zero_impedance_collapses_jumpers_only() {
938        // Buses 1-2 a real line, 2-3 a zero-impedance jumper.
939        let mut jumper = line(2, 3);
940        jumper.x = 0.0;
941        let mut net = Network::in_memory(
942            "net",
943            100.0,
944            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
945            vec![line(1, 2), jumper],
946        );
947        net.loads.push(load(3));
948
949        let removed = net.reduce_zero_impedance(1e-9);
950        assert_eq!(removed, 1, "only the jumper is collapsed");
951        assert_eq!(net.buses.len(), 2);
952        assert_eq!(net.branches.len(), 1, "the real 1-2 line remains");
953        assert!(net.loads.iter().any(|l| l.bus == BusId(2)), "load re-homed");
954        net.validate().unwrap();
955    }
956
957    #[test]
958    fn reduce_zero_impedance_keeps_an_open_jumper() {
959        // A zero-impedance jumper between 2 and 3 is out of service: it models an
960        // open switch, so its endpoints stay separate and must not be merged.
961        let mut jumper = line(2, 3);
962        jumper.x = 0.0;
963        jumper.in_service = false;
964        let mut net = Network::in_memory(
965            "net",
966            100.0,
967            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
968            vec![line(1, 2), jumper],
969        );
970
971        let removed = net.reduce_zero_impedance(1e-9);
972        assert_eq!(removed, 0, "an open jumper is left in place");
973        assert_eq!(net.buses.len(), 3);
974        assert_eq!(net.branches.len(), 2);
975        net.validate().unwrap();
976    }
977
978    #[test]
979    fn reduce_zero_impedance_keeps_a_3w_winding_pair() {
980        // A zero-impedance jumper between buses 2 and 3, which are two windings of
981        // the same 3-winding transformer. Merging them would short two windings
982        // onto one node, so the jumper is left in place.
983        let mut jumper = line(2, 3);
984        jumper.x = 0.0;
985        let mut net = Network::in_memory(
986            "net",
987            100.0,
988            vec![bus(1, 1, 230.0), bus(2, 1, 138.0), bus(3, 1, 13.8)],
989            vec![line(1, 2), jumper],
990        );
991        net.transformers_3w.push(transformer_3w(1, 2, 3));
992
993        let removed = net.reduce_zero_impedance(1e-9);
994        assert_eq!(
995            removed, 0,
996            "a jumper across two windings of one 3W transformer is kept"
997        );
998        assert_eq!(net.buses.len(), 3);
999        net.validate().unwrap();
1000    }
1001
1002    #[test]
1003    fn in_place_mutations_invalidate_the_retained_source() {
1004        use std::sync::Arc;
1005        let retained = || Some(Arc::new("RETAINED".to_string()));
1006
1007        // merge_bus (via reduce_zero_impedance): a collapse drops the stale source.
1008        let mut jumper = line(2, 3);
1009        jumper.x = 0.0;
1010        let mut net = Network::in_memory(
1011            "net",
1012            100.0,
1013            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1014            vec![line(1, 2), jumper],
1015        );
1016        net.source = retained();
1017        assert_eq!(net.reduce_zero_impedance(1e-9), 1);
1018        assert!(net.source.is_none(), "a merge invalidates the source");
1019
1020        // A no-op reduction keeps the byte-exact echo.
1021        let mut net = Network::in_memory(
1022            "net",
1023            100.0,
1024            vec![bus(1, 1, 230.0), bus(2, 1, 230.0)],
1025            vec![line(1, 2)],
1026        );
1027        net.source = retained();
1028        assert_eq!(net.reduce_zero_impedance(1e-9), 0);
1029        assert!(net.source.is_some(), "a no-op leaves the source intact");
1030
1031        // reduce_passthrough_buses (via collapse_passthrough).
1032        let mut net = Network::in_memory(
1033            "net",
1034            100.0,
1035            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1036            vec![line(1, 2), line(2, 3)],
1037        );
1038        net.source = retained();
1039        assert_eq!(net.reduce_passthrough_buses(), 1);
1040        assert!(net.source.is_none());
1041
1042        // retype_isolated_buses: bus 3 is stranded.
1043        let mut net = Network::in_memory(
1044            "net",
1045            100.0,
1046            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1047            vec![line(1, 2)],
1048        );
1049        net.source = retained();
1050        assert_eq!(net.retype_isolated_buses(), 1);
1051        assert!(net.source.is_none());
1052
1053        // repair: an out-of-domain voltage is clamped.
1054        let mut net = Network::in_memory("net", 100.0, vec![bus(1, 1, 230.0)], Vec::new());
1055        net.buses[0].vm = -1.0;
1056        net.source = retained();
1057        assert!(!net.repair().is_empty());
1058        assert!(net.source.is_none());
1059    }
1060}