Skip to main content

powerio/
operations.rs

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