Skip to main content

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