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