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//! removes jumper branches through [`merge_buses`](BalancedNetwork::merge_buses).
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
14mod subset;
15pub use subset::BusSubset;
16pub(crate) use subset::filter_connectivity;
17use subset::filter_rows;
18
19use serde_json::Value;
20
21use crate::diagnostics::Diagnostic;
22use crate::merge::{BusMergeRule, MergeSurvivor, ZeroImpedanceRule, kind_priority};
23use crate::network::{
24    BalancedNetwork, BalancedNetworkTables, Branch, Bus, BusId, BusType, Extras,
25    IMPEDANCE_CORRECTION_APPLIED_EXTRA, IMPEDANCE_CORRECTION_NOMINAL_EXTRA,
26    ImpedanceCorrectionVariable, SourceFormat, current_correction_nominal,
27};
28
29/// The nominal impedance an earlier impedance correction recorded in `extras`
30/// for the present impedance `current`, or `current`, recorded there now. An
31/// impedance edited since the correction wrote it is the new nominal.
32fn recorded_nominal<T>(extras: &mut Extras, current: T) -> T
33where
34    T: PartialEq + serde::Serialize + serde::de::DeserializeOwned,
35{
36    if let Some(nominal) = current_correction_nominal(extras, &current) {
37        return nominal;
38    }
39    let value = serde_json::to_value(&current).expect("an impedance serializes to JSON");
40    extras.insert(IMPEDANCE_CORRECTION_NOMINAL_EXTRA.to_owned(), value);
41    current
42}
43
44/// Record the corrected impedance an impedance correction wrote, so a later
45/// edit of it is recognized.
46fn record_applied<T: serde::Serialize>(extras: &mut Extras, applied: &T) {
47    let value = serde_json::to_value(applied).expect("an impedance serializes to JSON");
48    extras.insert(IMPEDANCE_CORRECTION_APPLIED_EXTRA.to_owned(), value);
49}
50
51/// `(a.0 + j·a.1)·(b.0 + j·b.1)`.
52fn complex_product(a: (f64, f64), b: (f64, f64)) -> (f64, f64) {
53    (a.0 * b.0 - a.1 * b.1, a.0 * b.1 + a.1 * b.0)
54}
55
56/// The endpoint of `b` other than `m` (assumes `m` is an endpoint).
57fn other_end(b: &Branch, m: BusId) -> BusId {
58    if b.from == m { b.to } else { b.from }
59}
60
61/// Combine two thermal ratings into the equivalent for a series pair. `0` means
62/// "no limit" in the MATPOWER convention, so it yields to a finite rating; two
63/// finite ratings give the more limiting (smaller) one.
64fn combine_rate(a: f64, b: f64) -> f64 {
65    match (a == 0.0, b == 0.0) {
66        (true, _) => b,
67        (_, true) => a,
68        _ => a.min(b),
69    }
70}
71
72/// What [`reduce_zero_impedance_with_report`](BalancedNetwork::reduce_zero_impedance_with_report)
73/// did: the number of branches it removed and the merge's diagnostics, or
74/// the error that left the network unchanged.
75#[derive(Debug, Clone, Default)]
76#[non_exhaustive]
77pub struct ZeroImpedanceReduction {
78    /// Branches removed, the jumpers and any branch they shorted. Zero when
79    /// the merge failed.
80    pub removed: usize,
81    /// What the merge changed, or the error that stopped it.
82    pub diagnostics: Vec<Diagnostic>,
83}
84
85/// Which buses a [`subset`](BalancedNetwork::subset) keeps: inclusive ranges over area,
86/// zone, base kV, and bus number, ANDed together. An unset (`None`) filter
87/// matches every bus, so [`Selector::default`] selects the whole network.
88#[derive(Debug, Clone, Default, PartialEq)]
89pub struct Selector {
90    /// Inclusive `(low, high)` area-number range.
91    pub area: Option<(usize, usize)>,
92    /// Inclusive `(low, high)` zone-number range.
93    pub zone: Option<(usize, usize)>,
94    /// Inclusive `(low, high)` base-kV range.
95    pub base_kv: Option<(f64, f64)>,
96    /// Inclusive `(low, high)` bus-number range.
97    pub bus: Option<(usize, usize)>,
98}
99
100impl Selector {
101    /// Whether `bus` satisfies every set filter.
102    fn matches(&self, bus: &Bus) -> bool {
103        fn in_usize(range: Option<(usize, usize)>, v: usize) -> bool {
104            range.is_none_or(|(lo, hi)| lo <= v && v <= hi)
105        }
106        fn in_f64(range: Option<(f64, f64)>, v: f64) -> bool {
107            range.is_none_or(|(lo, hi)| lo <= v && v <= hi)
108        }
109        in_usize(self.area, bus.area)
110            && in_usize(self.zone, bus.zone)
111            && in_f64(self.base_kv, bus.base_kv)
112            && in_usize(self.bus, bus.id.0)
113    }
114}
115
116impl BalancedNetwork {
117    /// Carve out the sub-network whose buses match `sel`.
118    ///
119    /// In-scope buses keep their loads, shunts, generators, and storage; a branch,
120    /// HVDC line, or 3-winding transformer is kept when every bus it touches is
121    /// kept. With `keep_boundary`, a branch or HVDC line straddling the selection
122    /// edge pulls its out-of-scope endpoint in as a *tie bus* (tagged
123    /// `extras["tie_bus"] = true`) so the carved island has no dangling branch
124    /// ends; without it, a straddling branch is dropped. A tie bus is a stub: its
125    /// own loads/generators are not pulled in. A control reference (regulated bus)
126    /// that falls outside the kept set is cleared so the result is
127    /// reference-consistent. Detailed connectivity is filtered with the electrical
128    /// tables; unassigned source topology remains, and an emptied voltage level
129    /// is removed when no retained topology uses it.
130    ///
131    /// The result preserves normalized units for normalized input; otherwise it
132    /// is a fresh [`SourceFormat::InMemory`] network. An empty `Selector` returns
133    /// a clone-equivalent of the whole case,
134    /// and a selector matching no bus returns an empty network.
135    #[must_use]
136    pub fn subset(&self, sel: &Selector, keep_boundary: bool) -> BalancedNetwork {
137        let in_scope: HashSet<BusId> = self
138            .buses()
139            .iter()
140            .filter(|b| sel.matches(b))
141            .map(|b| b.id)
142            .collect();
143        self.subset_scope(&in_scope, keep_boundary, &HashSet::new())
144            .network
145    }
146
147    /// Carve out the sub-network and source-to-result row maps on `buses`, such as one island of
148    /// [`calc_islands`](BalancedNetwork::calc_islands): the
149    /// [`subset`](BalancedNetwork::subset) rules with no boundary buses, so a
150    /// branch, switch, HVDC line, or 3-winding transformer is kept when every
151    /// bus it touches is in the set. Ids the network does not declare are
152    /// ignored.
153    ///
154    /// A 3-winding transformer with a winding in the set is also kept when
155    /// each of its other windings is dead: its bus is typed isolated, or the
156    /// winding is out of service ([`crate::Transformer3W::winding_in_service`]).
157    /// That winding's bus comes along as an isolated stub (tagged
158    /// `extras["tie_bus"]`), so an island that such a transformer joins stays
159    /// one island. The returned [`BusSubset`] maps every source table row to
160    /// its result row, including stub buses and excluded injections.
161    #[must_use]
162    pub fn subset_buses(&self, buses: &std::collections::BTreeSet<BusId>) -> BusSubset {
163        let in_scope: HashSet<BusId> = buses.iter().copied().collect();
164        let isolated: HashSet<BusId> = self
165            .buses()
166            .iter()
167            .filter(|bus| bus.kind == BusType::Isolated)
168            .map(|bus| bus.id)
169            .collect();
170        let mut stubs = HashSet::new();
171        for transformer in self.transformers_3w().iter().filter(|t| t.in_service) {
172            let windings = || {
173                transformer
174                    .windings
175                    .iter()
176                    .zip(transformer.winding_in_service())
177            };
178            let touches = windings().any(|(w, _)| in_scope.contains(&w.bus));
179            let dead_outside = windings().all(|(w, connected)| {
180                in_scope.contains(&w.bus) || !connected || isolated.contains(&w.bus)
181            });
182            if touches && dead_outside {
183                stubs.extend(
184                    windings()
185                        .map(|(w, _)| w.bus)
186                        .filter(|bus| !in_scope.contains(bus)),
187                );
188            }
189        }
190        self.subset_scope(&in_scope, false, &stubs)
191    }
192
193    /// [`subset`](BalancedNetwork::subset) over a bus set already chosen.
194    /// `stubs` are out-of-scope buses kept as isolated tie buses.
195    // A flat filter pipeline, one stanza per element table; splitting it would add
196    // indirection without clarity.
197    #[expect(clippy::too_many_lines)]
198    fn subset_scope(
199        &self,
200        in_scope: &HashSet<BusId>,
201        keep_boundary: bool,
202        stubs: &HashSet<BusId>,
203    ) -> BusSubset {
204        // Boundary: the out-of-scope endpoint of any branch/HVDC with exactly one
205        // endpoint in scope, plus the stubs.
206        let mut boundary: HashSet<BusId> = stubs.clone();
207        if keep_boundary {
208            let mut edge = |a: BusId, b: BusId| match (in_scope.contains(&a), in_scope.contains(&b))
209            {
210                (true, false) => {
211                    boundary.insert(b);
212                }
213                (false, true) => {
214                    boundary.insert(a);
215                }
216                _ => {}
217            };
218            for br in self.branches() {
219                edge(br.from, br.to);
220            }
221            for d in self.hvdc() {
222                edge(d.from, d.to);
223            }
224        }
225        let kept: HashSet<BusId> = in_scope.union(&boundary).copied().collect();
226
227        let (mut buses, bus_rows) = filter_rows(self.buses(), |b| kept.contains(&b.id));
228        for bus in &mut buses {
229            if boundary.contains(&bus.id) {
230                bus.extras.insert("tie_bus".into(), Value::Bool(true));
231            }
232            if stubs.contains(&bus.id) {
233                bus.kind = BusType::Isolated;
234            }
235        }
236
237        // Build the source-to-result maps in the same pass that selects rows.
238        // Injections on retained boundary/stub buses are deliberately excluded.
239        let (loads, load_rows) = filter_rows(self.loads(), |v| in_scope.contains(&v.bus));
240        let (mut shunts, shunt_rows) = filter_rows(self.shunts(), |v| in_scope.contains(&v.bus));
241        let (static_var_compensators, static_var_compensator_rows) =
242            filter_rows(self.static_var_compensators(), |v| {
243                in_scope.contains(&v.bus)
244            });
245        let (mut generators, generator_rows) =
246            filter_rows(self.generators(), |v| in_scope.contains(&v.bus));
247        let (storage, storage_rows) = filter_rows(self.storage(), |v| in_scope.contains(&v.bus));
248        let ends_kept = |from: BusId, to: BusId| kept.contains(&from) && kept.contains(&to);
249        let (mut branches, branch_rows) = filter_rows(self.branches(), |v| ends_kept(v.from, v.to));
250        let (switches, switch_rows) = filter_rows(self.switches(), |v| ends_kept(v.from, v.to));
251        let (hvdc, hvdc_rows) = filter_rows(self.hvdc(), |v| ends_kept(v.from, v.to));
252        let (mut transformers_3w, transformer_3w_rows) = filter_rows(self.transformers_3w(), |v| {
253            v.windings.iter().all(|w| kept.contains(&w.bus))
254        });
255
256        // Clear control references that point outside the kept set.
257        for br in &mut branches {
258            if let Some(c) = &mut br.control
259                && c.controlled_bus.is_some_and(|b| !kept.contains(&b))
260            {
261                c.controlled_bus = None;
262            }
263        }
264        for transformer in &mut transformers_3w {
265            for winding in &mut transformer.windings {
266                if let Some(control) = &mut winding.control
267                    && control
268                        .controlled_bus
269                        .is_some_and(|bus| !kept.contains(&bus))
270                {
271                    control.controlled_bus = None;
272                }
273            }
274        }
275        for sh in &mut shunts {
276            if let Some(c) = &mut sh.control
277                && c.control_bus.is_some_and(|b| !kept.contains(&b))
278            {
279                c.control_bus = None;
280            }
281        }
282        for g in &mut generators {
283            if g.regulated_bus.is_some_and(|b| !kept.contains(&b)) {
284                g.regulated_bus = None;
285            }
286        }
287
288        // Keep the area records still referenced by a kept bus (clearing a dangling
289        // area-slack), plus the global solver settings. The bus `area` numbers alone
290        // can't carry the interchange schedule or the solver tolerances, so dropping
291        // them would silently lose data a PSS/E/PSLF write of the subset emits.
292        let kept_area_numbers: HashSet<usize> = buses.iter().map(|b| b.area).collect();
293        let (mut areas, area_rows) =
294            filter_rows(self.areas(), |a| kept_area_numbers.contains(&a.number));
295        for area in &mut areas {
296            if area.slack_bus.is_some_and(|b| !kept.contains(&b)) {
297                area.slack_bus = None;
298            }
299        }
300
301        let mut net = BalancedNetwork::from_tables(BalancedNetworkTables {
302            name: format!("{} (subset)", self.name()),
303            base_mva: self.base_mva(),
304            base_frequency: self.base_frequency(),
305            geo: self.geo().clone(),
306            case_metadata: self.case_metadata().clone(),
307            detailed_connectivity: self.detailed_connectivity().clone(),
308            generated_uids: self.generated_uids().clone(),
309            buses: buses.into(),
310            loads: loads.into(),
311            shunts: shunts.into(),
312            static_var_compensators: static_var_compensators.into(),
313            branches: branches.into(),
314            switches: switches.into(),
315            generators: generators.into(),
316            storage: storage.into(),
317            hvdc: hvdc.into(),
318            transformers_3w: transformers_3w.into(),
319            areas: areas.into(),
320            solver: self.solver().clone(),
321            source_format: if self.is_normalized() {
322                SourceFormat::Normalized
323            } else {
324                SourceFormat::InMemory
325            },
326        });
327        subset::filter_connectivity(self, &mut net);
328        debug_assert!(
329            net.validate().is_ok(),
330            "subset produced a dangling reference"
331        );
332        BusSubset {
333            network: net,
334            bus_rows,
335            load_rows,
336            shunt_rows,
337            static_var_compensator_rows,
338            branch_rows,
339            switch_rows,
340            generator_rows,
341            storage_rows,
342            hvdc_rows,
343            transformer_3w_rows,
344            area_rows,
345        }
346    }
347
348    /// Merge bus `from` into bus `into`: re-home every element on `from` (loads,
349    /// shunts, generators, storage, branch/HVDC/transformer endpoints, and control
350    /// references) onto `into`, drop the branches, switches, and HVDC lines that
351    /// ran directly between the two (now self-loops), and remove the `from` bus.
352    /// The surviving bus keeps the stronger of the two bus kinds (a slack is not
353    /// demoted).
354    ///
355    /// A no-op when `into == from`. The other attributes of `from` (its voltage,
356    /// limits, name) are discarded; the topology and injections are what move.
357    /// [`merge_buses`](BalancedNetwork::merge_buses) is the rule driven merge
358    /// that also reports what it removed.
359    pub fn merge_bus(&mut self, into: BusId, from: BusId) {
360        if into == from {
361            return;
362        }
363        self.remap_buses(|bus| if bus == from { into } else { bus });
364        self.branches_mut().retain(|b| b.from != b.to);
365        self.switches_mut().retain(|s| s.from != s.to);
366        self.hvdc_mut().retain(|d| d.from != d.to);
367
368        // Promote the surviving bus kind, then drop the merged bus.
369        let from_kind = self.buses().iter().find(|b| b.id == from).map(|b| b.kind);
370        self.buses_mut().retain(|b| b.id != from);
371        if let (Some(fk), Some(into_bus)) = (
372            from_kind,
373            self.buses_mut().iter_mut().find(|b| b.id == into),
374        ) && kind_priority(fk) > kind_priority(into_bus.kind)
375        {
376            into_bus.kind = fk;
377        }
378        // The topology changed, so the retained source text is stale.
379    }
380
381    /// Rewrite every element's bus references through `map`, one pass per
382    /// table: load, shunt, static var compensator, generator, storage, branch,
383    /// switch, and HVDC buses; generator regulated buses; switched shunt,
384    /// transformer, and winding control buses; three winding transformer
385    /// windings; and area swing buses. The bus table and the detailed
386    /// connectivity are the caller's to keep consistent. A table none of
387    /// whose references change is not copied.
388    pub(crate) fn remap_buses(&mut self, map: impl Fn(BusId) -> BusId) {
389        fn remap_table<T: Clone>(
390            table: &mut std::sync::Arc<Vec<T>>,
391            touches: impl Fn(&T) -> bool,
392            apply: impl FnMut(&mut T),
393        ) {
394            if table.iter().any(touches) {
395                std::sync::Arc::make_mut(table).iter_mut().for_each(apply);
396            }
397        }
398        let moved = |bus: BusId| map(bus) != bus;
399        let moved_opt = |bus: Option<BusId>| bus.is_some_and(moved);
400        let set = |bus: &mut BusId| *bus = map(*bus);
401        let set_opt = |bus: &mut Option<BusId>| {
402            if let Some(bus) = bus {
403                *bus = map(*bus);
404            }
405        };
406        let tables = self.tables_mut();
407        remap_table(&mut tables.loads, |l| moved(l.bus), |l| set(&mut l.bus));
408        remap_table(
409            &mut tables.shunts,
410            |s| moved(s.bus) || moved_opt(s.control.as_ref().and_then(|c| c.control_bus)),
411            |s| {
412                set(&mut s.bus);
413                if let Some(control) = s.control.as_mut() {
414                    set_opt(&mut control.control_bus);
415                }
416            },
417        );
418        remap_table(
419            &mut tables.static_var_compensators,
420            |svc| moved(svc.bus),
421            |svc| set(&mut svc.bus),
422        );
423        remap_table(
424            &mut tables.generators,
425            |g| moved(g.bus) || moved_opt(g.regulated_bus),
426            |g| {
427                set(&mut g.bus);
428                set_opt(&mut g.regulated_bus);
429            },
430        );
431        remap_table(&mut tables.storage, |s| moved(s.bus), |s| set(&mut s.bus));
432        remap_table(
433            &mut tables.branches,
434            |b| {
435                moved(b.from)
436                    || moved(b.to)
437                    || moved_opt(b.control.as_ref().and_then(|c| c.controlled_bus))
438            },
439            |b| {
440                set(&mut b.from);
441                set(&mut b.to);
442                if let Some(control) = b.control.as_mut() {
443                    set_opt(&mut control.controlled_bus);
444                }
445            },
446        );
447        remap_table(
448            &mut tables.switches,
449            |s| moved(s.from) || moved(s.to),
450            |s| {
451                set(&mut s.from);
452                set(&mut s.to);
453            },
454        );
455        remap_table(
456            &mut tables.hvdc,
457            |d| moved(d.from) || moved(d.to),
458            |d| {
459                set(&mut d.from);
460                set(&mut d.to);
461            },
462        );
463        remap_table(
464            &mut tables.transformers_3w,
465            |t| {
466                t.windings.iter().any(|w| {
467                    moved(w.bus) || moved_opt(w.control.as_ref().and_then(|c| c.controlled_bus))
468                })
469            },
470            |t| {
471                for w in &mut t.windings {
472                    set(&mut w.bus);
473                    if let Some(control) = w.control.as_mut() {
474                        set_opt(&mut control.controlled_bus);
475                    }
476                }
477            },
478        );
479        remap_table(
480            &mut tables.areas,
481            |a| moved_opt(a.slack_bus),
482            |a| set_opt(&mut a.slack_bus),
483        );
484    }
485
486    /// Collapse every in-service, non-transformer branch whose series impedance
487    /// magnitude is at or below `threshold` by merging its endpoints (the to-bus
488    /// into the from-bus), returning the number of branches removed. Parallel
489    /// jumpers between the same pair go in the same step.
490    ///
491    /// This is [`merge_buses`](BalancedNetwork::merge_buses) under
492    /// [`ZeroImpedanceRule::ImpedanceMagnitude`] and [`MergeSurvivor::FromBus`]
493    /// with closed switches left alone, applied in place; prefer `merge_buses`,
494    /// which also returns the bus map, the removed elements, and diagnostics.
495    /// A threshold that is negative or not finite merges nothing.
496    ///
497    /// An out-of-service jumper is an open switch whose endpoints are not
498    /// electrically joined, so it is left in place. Transformers are never
499    /// collapsed (a unity-ratio transformer is a real device, not a jumper); a
500    /// jumper between two windings of the same 3-winding transformer is also
501    /// kept, since merging would collapse that transformer onto one node.
502    ///
503    /// When the merge fails, for example because a jumper shorts an ideal
504    /// transformer with a nonunity tap, nothing is merged and this returns
505    /// zero; [`reduce_zero_impedance_with_report`](Self::reduce_zero_impedance_with_report)
506    /// reports why.
507    pub fn reduce_zero_impedance(&mut self, threshold: f64) -> usize {
508        self.reduce_zero_impedance_with_report(threshold).removed
509    }
510
511    /// [`reduce_zero_impedance`](Self::reduce_zero_impedance) with the merge's
512    /// diagnostics. When the merge fails the network is left unchanged,
513    /// `removed` is zero, and the diagnostics hold the error under its own
514    /// code, so a failure is no longer indistinguishable from a network with
515    /// no jumpers.
516    pub fn reduce_zero_impedance_with_report(&mut self, threshold: f64) -> ZeroImpedanceReduction {
517        let rule = BusMergeRule::new(
518            false,
519            Some(ZeroImpedanceRule::ImpedanceMagnitude(threshold)),
520        )
521        .with_survivor(MergeSurvivor::FromBus);
522        match self.merge_buses(&rule) {
523            Ok(merge) => {
524                let removed = merge.removed_branches.len();
525                *self = merge.network;
526                ZeroImpedanceReduction {
527                    removed,
528                    diagnostics: merge.diagnostics,
529                }
530            }
531            Err(error) => ZeroImpedanceReduction {
532                removed: 0,
533                diagnostics: vec![Diagnostic::of(
534                    error.code(),
535                    format!("zero impedance reduction merged nothing: {error}"),
536                )],
537            },
538        }
539    }
540
541    /// Collapse degree-2 passthrough buses, returning the number removed. A
542    /// passthrough bus carries nothing but two in-service line sections, so it is
543    /// an electrically inert junction: the two sections fold into one equivalent
544    /// branch between their outer endpoints and the middle bus is deleted.
545    ///
546    /// This is the multi-section-line reduction. Exporters often split one circuit
547    /// into segments joined at dummy buses; folding them back recovers the single
548    /// branch. A bus qualifies only when it carries no load, generator, shunt, or
549    /// storage, is not a control reference, area swing, HVDC endpoint, or 3-winding
550    /// winding bus, is not the system slack, and is touched by exactly two ordinary
551    /// branches (never transformers) that are both in service and run to two
552    /// distinct other buses. The equivalent branch sums the series impedance and
553    /// line charging, takes the more limiting thermal rating of the two sections,
554    /// and intersects their angle limits. Chains of dummy buses collapse fully, one
555    /// bus per step.
556    pub fn reduce_passthrough_buses(&mut self) -> usize {
557        let mut collapsed = 0;
558        // Re-scan after each fold: the equivalent branch becomes a section for the
559        // next bus in a dummy chain, and the bus list shrinks.
560        while let Some(mid) = self
561            .buses()
562            .iter()
563            .map(|b| b.id)
564            .find(|&m| self.is_passthrough(m))
565        {
566            self.collapse_passthrough(mid);
567            collapsed += 1;
568        }
569        collapsed
570    }
571
572    /// Whether `m` is a collapsible degree-2 passthrough bus (see
573    /// [`reduce_passthrough_buses`](BalancedNetwork::reduce_passthrough_buses)).
574    fn is_passthrough(&self, m: BusId) -> bool {
575        let Some(bus) = self.buses().iter().find(|b| b.id == m) else {
576            return false;
577        };
578        if bus.kind == BusType::Ref {
579            return false;
580        }
581        if self.loads().iter().any(|l| l.bus == m)
582            || self.generators().iter().any(|g| g.bus == m)
583            || self.shunts().iter().any(|s| s.bus == m)
584            || self
585                .static_var_compensators()
586                .iter()
587                .any(|svc| svc.bus == m)
588            || self.storage().iter().any(|s| s.bus == m)
589            || self.hvdc().iter().any(|d| d.from == m || d.to == m)
590        {
591            return false;
592        }
593        if self
594            .transformers_3w()
595            .iter()
596            .any(|t| t.windings.iter().any(|w| w.bus == m))
597        {
598            return false;
599        }
600        if self.areas().iter().any(|a| a.slack_bus == Some(m)) {
601            return false;
602        }
603        let controlled = self
604            .branches()
605            .iter()
606            .any(|b| b.control.as_ref().and_then(|c| c.controlled_bus) == Some(m));
607        let winding_controlled = self.transformers_3w().iter().any(|transformer| {
608            transformer.windings.iter().any(|winding| {
609                winding
610                    .control
611                    .as_ref()
612                    .and_then(|control| control.controlled_bus)
613                    == Some(m)
614            })
615        });
616        let regulated = self
617            .shunts()
618            .iter()
619            .any(|s| s.control.as_ref().and_then(|c| c.control_bus) == Some(m));
620        let gen_regulated = self.generators().iter().any(|g| g.regulated_bus == Some(m));
621        if controlled || winding_controlled || regulated || gen_regulated {
622            return false;
623        }
624        let incident: Vec<&Branch> = self
625            .branches()
626            .iter()
627            .filter(|b| b.from == m || b.to == m)
628            .collect();
629        if incident.len() != 2 {
630            return false;
631        }
632        let a = other_end(incident[0], m);
633        let c = other_end(incident[1], m);
634        incident.iter().all(|b| !b.is_transformer() && b.in_service) && a != m && c != m && a != c
635    }
636
637    /// Fold the two line sections at passthrough bus `m` into one equivalent branch
638    /// and remove `m`. The caller has already checked [`is_passthrough`].
639    fn collapse_passthrough(&mut self, m: BusId) {
640        let mut sections: Vec<Branch> = Vec::new();
641        self.branches_mut().retain(|b| {
642            if b.from == m || b.to == m {
643                sections.push(b.clone());
644                false
645            } else {
646                true
647            }
648        });
649        debug_assert_eq!(sections.len(), 2, "passthrough bus must have two sections");
650        let (s1, s2) = (&sections[0], &sections[1]);
651        // Intersect the two sections' angle windows, but never emit an inverted
652        // (empty) limit: two disjoint windows give angmin > angmax, which an OPF
653        // angle-difference constraint reads as infeasible. Disjoint windows fall
654        // back to the union so folding a multi-section line never turns a feasible
655        // case infeasible. (Whether series sections should intersect vs sum their
656        // windows is a modeling choice; this only fixes the invalid-range case.)
657        let mut angmin = s1.angmin.max(s2.angmin);
658        let mut angmax = s1.angmax.min(s2.angmax);
659        if angmin > angmax {
660            angmin = s1.angmin.min(s2.angmin);
661            angmax = s1.angmax.max(s2.angmax);
662        }
663        self.branches_mut().push(Branch {
664            name: None,
665            from: other_end(s1, m),
666            to: other_end(s2, m),
667            r: s1.r + s2.r,
668            x: s1.x + s2.x,
669            b: s1.calc_total_charging_b() + s2.calc_total_charging_b(),
670            charging: None,
671            rate_a: combine_rate(s1.rate_a, s2.rate_a),
672            rate_b: combine_rate(s1.rate_b, s2.rate_b),
673            rate_c: combine_rate(s1.rate_c, s2.rate_c),
674            rating_sets: Vec::new(),
675            current_ratings: None,
676            tap: 0.0,
677            shift: 0.0,
678            in_service: true,
679            angmin,
680            angmax,
681            control: None,
682            solution: None,
683            uid: None,
684            route: None,
685            extras: Extras::new(),
686        });
687        self.buses_mut().retain(|b| b.id != m);
688        // The topology changed, so the retained source text is stale.
689    }
690
691    /// Scale every transformer's series impedance by its impedance correction
692    /// table, at the winding's present turns ratio or phase shift, returning
693    /// the number of two winding transformers and three winding correction
694    /// targets (windings or pairs, according to ZCOD) scaled.
695    ///
696    /// A two winding transformer's factor multiplies `r + jx`. A three winding
697    /// transformer's factor for a winding multiplies that winding's star
698    /// impedance, and the pairwise impedances are restated from the scaled
699    /// star. With PSS/E `ZCOD=1`, TAB1/2/3 instead scale nominal Z12/Z23/Z31
700    /// at the sole winding with a nonfixed control (even if disabled).
701    /// A ZCOD=1 transformer with no unique adjustable winding is skipped;
702    /// the RAW reader refuses that ambiguous configuration.
703    /// The first call records each scaled transformer's nominal
704    /// impedance under `extras["impedance_correction_nominal"]`, and every
705    /// call scales that nominal value: a repeated call changes nothing, and a
706    /// call after a tap or shift change evaluates the table there. The
707    /// corrected impedance is recorded under
708    /// `extras["impedance_correction_applied"]`; an impedance edited after
709    /// the call no longer matches it and becomes the new nominal value. The
710    /// PSS/E writer states the nominal impedance beside the table, as the
711    /// format does.
712    pub fn apply_impedance_correction(&mut self) -> usize {
713        // A normalized network states phase shifts in radians; the tables
714        // state degrees.
715        let angle_scale = if self.is_normalized() {
716            crate::normalize::RAD_TO_DEG
717        } else {
718            1.0
719        };
720        let mut scaled = 0;
721        for branch in self.branches_mut().iter_mut() {
722            let Some(correction) = branch.impedance_correction() else {
723                continue;
724            };
725            let at = match correction.variable {
726                ImpedanceCorrectionVariable::Angle => branch.shift * angle_scale,
727                _ => branch.calc_effective_tap(),
728            };
729            let Some(factor) = correction.calc_factor(at) else {
730                continue;
731            };
732            let [r, x] = recorded_nominal(&mut branch.extras, [branch.r, branch.x]);
733            (branch.r, branch.x) = complex_product((r, x), factor);
734            record_applied(&mut branch.extras, &[branch.r, branch.x]);
735            scaled += 1;
736        }
737        for transformer in self.transformers_3w_mut().iter_mut() {
738            let pairwise_mode =
739                transformer.extras.get("psse_zcod").and_then(Value::as_i64) == Some(1);
740            let controlling = transformer.impedance_correction_control_winding();
741            // The RAW reader refuses this ambiguous configuration. If controls
742            // were edited afterward, leave this transformer unchanged and
743            // contribute zero to the aggregate applied count.
744            if pairwise_mode && controlling.is_none() {
745                continue;
746            }
747            let factors: [Option<(f64, f64)>; 3] = std::array::from_fn(|winding| {
748                let correction = transformer.winding_impedance_correction(winding)?;
749                let setting = if pairwise_mode {
750                    controlling.unwrap_or(winding)
751                } else {
752                    winding
753                };
754                let w = &transformer.windings[setting];
755                let at = match correction.variable {
756                    ImpedanceCorrectionVariable::Angle => w.shift * angle_scale,
757                    _ => w.tap,
758                };
759                correction.calc_factor(at)
760            });
761            if factors.iter().all(Option::is_none) {
762                continue;
763            }
764            let pairwise = transformer.z.map(|z| [z.r, z.x]);
765            let nominal = recorded_nominal(&mut transformer.extras, pairwise);
766            if pairwise_mode {
767                for ((z, [r, x]), factor) in transformer.z.iter_mut().zip(nominal).zip(factors) {
768                    (z.r, z.x) = if let Some(factor) = factor {
769                        scaled += 1;
770                        complex_product((r, x), factor)
771                    } else {
772                        (r, x)
773                    };
774                }
775                let applied = transformer.z.map(|z| [z.r, z.x]);
776                record_applied(&mut transformer.extras, &applied);
777                continue;
778            }
779            let [z12, z23, z31] = nominal;
780            let star = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
781                ((a[0] + b[0] - c[0]) / 2.0, (a[1] + b[1] - c[1]) / 2.0)
782            };
783            let mut stars = [
784                star(z12, z31, z23),
785                star(z12, z23, z31),
786                star(z23, z31, z12),
787            ];
788            for (star, factor) in stars.iter_mut().zip(factors) {
789                if let Some(factor) = factor {
790                    *star = complex_product(*star, factor);
791                    scaled += 1;
792                }
793            }
794            for (pair, (a, b)) in [(0, 1), (1, 2), (2, 0)].into_iter().enumerate() {
795                transformer.z[pair].r = stars[a].0 + stars[b].0;
796                transformer.z[pair].x = stars[a].1 + stars[b].1;
797            }
798            let applied = transformer.z.map(|z| [z.r, z.x]);
799            record_applied(&mut transformer.extras, &applied);
800        }
801        scaled
802    }
803
804    /// Retype to [`BusType::Isolated`] every bus with no in-service electrical
805    /// connection — no in-service incident branch, closed switch, HVDC line,
806    /// or 3-winding transformer — returning the number retyped.
807    ///
808    /// A stranded bus (retired or not-yet-built equipment, or the residue of a
809    /// topology edit) otherwise keeps a PQ/PV/slack kind that tells a solver to
810    /// include it, leaving an ungrounded singleton in the system. This only
811    /// *demotes* a disconnected bus; it never promotes a connected one, and a bus
812    /// the source already marks isolated is left untouched. Connectivity is judged
813    /// on in-service equipment only, so opening the last branch into a bus makes it
814    /// eligible.
815    pub fn retype_isolated_buses(&mut self) -> usize {
816        let mut connected: HashSet<BusId> = HashSet::new();
817        for br in self.branches().iter().filter(|b| b.in_service) {
818            connected.insert(br.from);
819            connected.insert(br.to);
820        }
821        for sw in self.switches().iter().filter(|sw| sw.closed) {
822            connected.insert(sw.from);
823            connected.insert(sw.to);
824        }
825        for d in self.hvdc().iter().filter(|d| d.in_service) {
826            connected.insert(d.from);
827            connected.insert(d.to);
828        }
829        for t in self.transformers_3w().iter().filter(|t| t.in_service) {
830            for (w, in_service) in t.windings.iter().zip(t.winding_in_service()) {
831                if in_service {
832                    connected.insert(w.bus);
833                }
834            }
835        }
836        let mut retyped = 0;
837        for b in self.buses_mut() {
838            if b.kind != BusType::Isolated && !connected.contains(&b.id) {
839                b.kind = BusType::Isolated;
840                retyped += 1;
841            }
842        }
843        // Only a real retype invalidates the source; a no-op call stays lossless.
844
845        retyped
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use crate::network::{
853        Area, BusType, Extras, Generator, GeneratorEnergySource, Impedance, Load, Transformer3W,
854        Winding,
855    };
856
857    fn bus(id: usize, area: usize, base_kv: f64) -> Bus {
858        Bus {
859            id: BusId(id),
860            kind: BusType::Pq,
861            vm: 1.0,
862            va: 0.0,
863            base_kv,
864            vmax: 1.1,
865            vmin: 0.9,
866            evhi: None,
867            evlo: None,
868            area,
869            zone: 1,
870            name: None,
871            uid: None,
872            location: None,
873            extras: Extras::new(),
874        }
875    }
876
877    fn line(from: usize, to: usize) -> Branch {
878        Branch {
879            name: None,
880            from: BusId(from),
881            to: BusId(to),
882            r: 0.0,
883            x: 0.1,
884            b: 0.0,
885            charging: None,
886            rate_a: 0.0,
887            rate_b: 0.0,
888            rate_c: 0.0,
889            rating_sets: Vec::new(),
890            current_ratings: None,
891            tap: 0.0,
892            shift: 0.0,
893            in_service: true,
894            angmin: -360.0,
895            angmax: 360.0,
896            control: None,
897            solution: None,
898            uid: None,
899            route: None,
900            extras: Extras::new(),
901        }
902    }
903
904    fn load(bus: usize) -> Load {
905        Load {
906            bus: BusId(bus),
907            p: 10.0,
908            q: 5.0,
909            voltage_model: None,
910            in_service: true,
911            uid: None,
912            extras: Extras::new(),
913        }
914    }
915
916    /// Two area-1 buses (1, 2) and one area-2 bus (3); a line within area 1 and a
917    /// line crossing into area 2.
918    fn two_area_net() -> BalancedNetwork {
919        let mut net = BalancedNetwork::in_memory(
920            "net",
921            100.0,
922            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 2, 230.0)],
923            vec![line(1, 2), line(2, 3)],
924        );
925        net.loads_mut().push(load(1));
926        net.loads_mut().push(load(3));
927        net
928    }
929
930    fn transformer_3w(a: usize, b: usize, c: usize) -> Transformer3W {
931        let winding = |bus| Winding {
932            bus: BusId(bus),
933            tap: 1.0,
934            shift: 0.0,
935            nominal_kv: 0.0,
936            rate_a: 0.0,
937            rate_b: 0.0,
938            rate_c: 0.0,
939            control: None,
940        };
941        let imp = Impedance {
942            r: 0.0,
943            x: 0.1,
944            base_mva: 100.0,
945        };
946        Transformer3W {
947            windings: [winding(a), winding(b), winding(c)],
948            z: [imp, imp, imp],
949            star_vm: 1.0,
950            star_va: 0.0,
951            mag_g: 0.0,
952            mag_b: 0.0,
953            in_service: true,
954            name: None,
955            uid: None,
956            extras: Extras::new(),
957        }
958    }
959
960    fn gen_regulating(bus: usize, regulated: usize) -> Generator {
961        Generator {
962            bus: BusId(bus),
963            energy_source: GeneratorEnergySource::default(),
964            pg: 10.0,
965            qg: 0.0,
966            pmax: 100.0,
967            pmin: 0.0,
968            qmax: 50.0,
969            qmin: -50.0,
970            vg: 1.0,
971            mbase: 100.0,
972            in_service: true,
973            cost: None,
974            caps: Default::default(),
975            voltage_regulation_on: true,
976            regulating_terminal: None,
977            regulated_bus: Some(BusId(regulated)),
978            active_power_control: None,
979            uid: None,
980        }
981    }
982
983    #[test]
984    fn subset_clears_a_regulated_bus_outside_the_kept_set() {
985        // A generator on in-scope bus 1 regulates bus 3, which the area filter drops.
986        let mut net = two_area_net();
987        net.generators_mut().push(gen_regulating(1, 3));
988        let sel = Selector {
989            area: Some((1, 1)),
990            ..Selector::default()
991        };
992        let sub = net.subset(&sel, false);
993        assert_eq!(sub.generators().len(), 1);
994        assert_eq!(
995            sub.generators()[0].regulated_bus,
996            None,
997            "the dropped remote regulated bus is cleared, not left dangling"
998        );
999        sub.validate().unwrap();
1000    }
1001
1002    #[test]
1003    fn merge_bus_remaps_regulated_bus_and_area_slack() {
1004        let mut net = two_area_net();
1005        net.generators_mut().push(gen_regulating(1, 3)); // gen on bus 1 regulates bus 3
1006        net.areas_mut().push(Area {
1007            number: 1,
1008            slack_bus: Some(BusId(3)),
1009            net_interchange: 0.0,
1010            tolerance: 0.0,
1011            name: None,
1012            uid: None,
1013            area_type: None,
1014        });
1015        net.merge_bus(BusId(2), BusId(3)); // bus 3 merges into bus 2
1016        assert_eq!(
1017            net.generators()[0].regulated_bus,
1018            Some(BusId(2)),
1019            "the regulated bus follows the merge"
1020        );
1021        assert_eq!(
1022            net.areas()[0].slack_bus,
1023            Some(BusId(2)),
1024            "the area swing follows the merge"
1025        );
1026        net.validate().unwrap();
1027    }
1028
1029    #[test]
1030    fn reduce_passthrough_keeps_a_generator_regulated_bus() {
1031        // Bus 2 is a degree-2 junction with no injection, but a generator on bus 1
1032        // regulates it, so it is not an inert passthrough.
1033        let mut net = BalancedNetwork::in_memory(
1034            "net",
1035            100.0,
1036            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1037            vec![line(1, 2), line(2, 3)],
1038        );
1039        net.generators_mut().push(gen_regulating(1, 2));
1040        assert_eq!(net.reduce_passthrough_buses(), 0);
1041        assert_eq!(net.buses().len(), 3);
1042        net.validate().unwrap();
1043    }
1044
1045    #[test]
1046    fn subset_by_area_drops_out_of_scope_buses_and_cut_branches() {
1047        let net = two_area_net();
1048        let sel = Selector {
1049            area: Some((1, 1)),
1050            ..Selector::default()
1051        };
1052        let sub = net.subset(&sel, false);
1053
1054        // Buses 1, 2 kept; bus 3 (area 2) dropped along with the crossing line.
1055        assert_eq!(sub.buses().len(), 2);
1056        assert!(sub.buses().iter().all(|b| b.area == 1));
1057        assert_eq!(sub.branches().len(), 1, "only the intra-area line survives");
1058        assert_eq!(sub.loads().len(), 1, "the area-2 load is dropped");
1059        sub.validate().unwrap();
1060    }
1061
1062    #[test]
1063    fn subset_keep_boundary_pulls_in_the_tie_bus() {
1064        let net = two_area_net();
1065        let sel = Selector {
1066            area: Some((1, 1)),
1067            ..Selector::default()
1068        };
1069        let sub = net.subset(&sel, true);
1070
1071        // Bus 3 is pulled in as a tie bus so the crossing line keeps both ends.
1072        assert_eq!(sub.buses().len(), 3);
1073        assert_eq!(sub.branches().len(), 2);
1074        let tie = sub.buses().iter().find(|b| b.id == BusId(3)).unwrap();
1075        assert_eq!(tie.extras.get("tie_bus"), Some(&Value::Bool(true)));
1076        // The tie bus is a stub: its load is not pulled in.
1077        assert_eq!(sub.loads().len(), 1);
1078        sub.validate().unwrap();
1079    }
1080
1081    #[test]
1082    fn empty_selector_keeps_everything() {
1083        let net = two_area_net();
1084        let sub = net.subset(&Selector::default(), false);
1085        assert_eq!(sub.buses().len(), net.buses().len());
1086        assert_eq!(sub.branches().len(), net.branches().len());
1087    }
1088
1089    #[test]
1090    fn base_kv_range_filters_by_voltage() {
1091        let mut net = two_area_net();
1092        net.buses_mut()[2].base_kv = 115.0; // bus 3 to a different voltage class
1093        let sel = Selector {
1094            base_kv: Some((200.0, 300.0)),
1095            ..Selector::default()
1096        };
1097        let sub = net.subset(&sel, false);
1098        assert_eq!(sub.buses().len(), 2, "only the 230 kV buses match");
1099    }
1100
1101    #[test]
1102    fn merge_bus_rehomes_elements_and_drops_the_connecting_branch() {
1103        let mut net = two_area_net(); // buses 1,2,3; lines 1-2, 2-3; loads on 1, 3
1104        net.merge_bus(BusId(2), BusId(3));
1105
1106        assert_eq!(net.buses().len(), 2, "bus 3 removed");
1107        assert!(net.buses().iter().all(|b| b.id != BusId(3)));
1108        assert_eq!(
1109            net.branches().len(),
1110            1,
1111            "the 2-3 line collapsed to a self-loop"
1112        );
1113        assert_eq!(net.branches()[0].from, BusId(1));
1114        assert_eq!(net.branches()[0].to, BusId(2));
1115        // Both loads survive; the one on bus 3 moved to bus 2.
1116        assert_eq!(net.loads().len(), 2);
1117        assert!(net.loads().iter().any(|l| l.bus == BusId(2)));
1118        net.validate().unwrap();
1119    }
1120
1121    #[test]
1122    fn merge_bus_keeps_the_stronger_bus_kind() {
1123        let mut net = two_area_net();
1124        net.buses_mut()[2].kind = BusType::Ref; // bus 3 is the slack
1125        net.merge_bus(BusId(2), BusId(3)); // merge the slack into the PQ bus 2
1126        let two = net.buses().iter().find(|b| b.id == BusId(2)).unwrap();
1127        assert_eq!(two.kind, BusType::Ref, "the slack designation is not lost");
1128    }
1129
1130    #[test]
1131    fn reduce_passthrough_folds_a_multi_section_line() {
1132        // A 1-2-3-4 chain where 2 and 3 are dummy junctions; ratings 100 / 80 /
1133        // unlimited along the sections.
1134        let mut s1 = line(1, 2);
1135        s1.rate_a = 100.0;
1136        let mut s2 = line(2, 3);
1137        s2.rate_a = 80.0;
1138        let s3 = line(3, 4); // rate_a 0 == no limit
1139        let mut net = BalancedNetwork::in_memory(
1140            "net",
1141            100.0,
1142            vec![
1143                bus(1, 1, 230.0),
1144                bus(2, 1, 230.0),
1145                bus(3, 1, 230.0),
1146                bus(4, 1, 230.0),
1147            ],
1148            vec![s1, s2, s3],
1149        );
1150
1151        let removed = net.reduce_passthrough_buses();
1152        assert_eq!(removed, 2, "both dummy buses collapse");
1153        assert_eq!(net.buses().len(), 2);
1154        assert!(
1155            net.buses()
1156                .iter()
1157                .all(|b| b.id == BusId(1) || b.id == BusId(4))
1158        );
1159        assert_eq!(net.branches().len(), 1, "one equivalent branch");
1160        let eq = &net.branches()[0];
1161        assert_eq!(
1162            [eq.from, eq.to].iter().copied().collect::<HashSet<_>>(),
1163            [BusId(1), BusId(4)].into_iter().collect::<HashSet<_>>(),
1164        );
1165        assert!((eq.x - 0.3).abs() < 1e-9, "series reactance sums");
1166        assert!(
1167            (eq.rate_a - 80.0).abs() < 1e-9,
1168            "the more limiting finite rating wins"
1169        );
1170        net.validate().unwrap();
1171    }
1172
1173    #[test]
1174    fn reduce_passthrough_keeps_a_bus_with_injection() {
1175        // Bus 2 is degree 2 but carries a load, so it is not inert.
1176        let mut net = BalancedNetwork::in_memory(
1177            "net",
1178            100.0,
1179            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1180            vec![line(1, 2), line(2, 3)],
1181        );
1182        net.loads_mut().push(load(2));
1183        assert_eq!(net.reduce_passthrough_buses(), 0);
1184        assert_eq!(net.buses().len(), 3);
1185    }
1186
1187    #[test]
1188    fn reduce_passthrough_does_not_fold_across_a_transformer() {
1189        // Section 2-3 is a transformer, so bus 2 is a real terminal, not a junction.
1190        let mut xfmr = line(2, 3);
1191        xfmr.tap = 1.0;
1192        let mut net = BalancedNetwork::in_memory(
1193            "net",
1194            100.0,
1195            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1196            vec![line(1, 2), xfmr],
1197        );
1198        assert_eq!(net.reduce_passthrough_buses(), 0);
1199        assert_eq!(net.buses().len(), 3);
1200    }
1201
1202    #[test]
1203    fn retype_isolated_marks_stranded_buses() {
1204        // Bus 3 has no incident branch.
1205        let mut net = BalancedNetwork::in_memory(
1206            "net",
1207            100.0,
1208            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1209            vec![line(1, 2)],
1210        );
1211        assert_eq!(net.retype_isolated_buses(), 1);
1212        let three = net.buses().iter().find(|b| b.id == BusId(3)).unwrap();
1213        assert_eq!(three.kind, BusType::Isolated);
1214        // The connected buses keep their kind.
1215        let one = net.buses().iter().find(|b| b.id == BusId(1)).unwrap();
1216        assert_eq!(one.kind, BusType::Pq);
1217        net.validate().unwrap();
1218    }
1219
1220    #[test]
1221    fn retype_isolated_judges_in_service_equipment_only() {
1222        // The only branch is out of service, so both of its ends are stranded.
1223        let mut br = line(1, 2);
1224        br.in_service = false;
1225        let mut net = BalancedNetwork::in_memory(
1226            "net",
1227            100.0,
1228            vec![bus(1, 1, 230.0), bus(2, 1, 230.0)],
1229            vec![br],
1230        );
1231        assert_eq!(net.retype_isolated_buses(), 2);
1232        assert!(net.buses().iter().all(|b| b.kind == BusType::Isolated));
1233    }
1234
1235    #[test]
1236    fn retype_isolated_counts_a_closed_switch_as_a_connection() {
1237        // Bus 3 is reached only through a closed switch; bus 4 only through an
1238        // open one.
1239        let mut net = BalancedNetwork::in_memory(
1240            "net",
1241            100.0,
1242            vec![
1243                bus(1, 1, 230.0),
1244                bus(2, 1, 230.0),
1245                bus(3, 1, 230.0),
1246                bus(4, 1, 230.0),
1247            ],
1248            vec![line(1, 2)],
1249        );
1250        net.switches_mut().extend([
1251            crate::network::Switch::new(BusId(2), BusId(3), true),
1252            crate::network::Switch::new(BusId(2), BusId(4), false),
1253        ]);
1254        assert_eq!(net.retype_isolated_buses(), 1);
1255        let kind = |id| net.buses().iter().find(|b| b.id == BusId(id)).unwrap().kind;
1256        assert_eq!(kind(3), BusType::Pq);
1257        assert_eq!(kind(4), BusType::Isolated);
1258    }
1259
1260    #[test]
1261    fn retype_isolated_is_idempotent() {
1262        let mut net = BalancedNetwork::in_memory(
1263            "net",
1264            100.0,
1265            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1266            vec![line(1, 2)],
1267        );
1268        assert_eq!(net.retype_isolated_buses(), 1);
1269        assert_eq!(net.retype_isolated_buses(), 0, "second pass is a no-op");
1270    }
1271
1272    #[test]
1273    fn reduce_zero_impedance_collapses_jumpers_only() {
1274        // Buses 1-2 a real line, 2-3 a zero-impedance jumper.
1275        let mut jumper = line(2, 3);
1276        jumper.x = 0.0;
1277        let mut net = BalancedNetwork::in_memory(
1278            "net",
1279            100.0,
1280            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1281            vec![line(1, 2), jumper],
1282        );
1283        net.loads_mut().push(load(3));
1284
1285        let removed = net.reduce_zero_impedance(1e-9);
1286        assert_eq!(removed, 1, "only the jumper is collapsed");
1287        assert_eq!(net.buses().len(), 2);
1288        assert_eq!(net.branches().len(), 1, "the real 1-2 line remains");
1289        assert!(
1290            net.loads().iter().any(|l| l.bus == BusId(2)),
1291            "load re-homed"
1292        );
1293        net.validate().unwrap();
1294    }
1295
1296    #[test]
1297    fn reduce_zero_impedance_keeps_the_from_bus() {
1298        // The jumper runs from bus 3 to bus 2, so bus 3 survives although bus
1299        // 2 has the smaller id.
1300        let mut jumper = line(3, 2);
1301        jumper.x = 0.0;
1302        let mut net = BalancedNetwork::in_memory(
1303            "net",
1304            100.0,
1305            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1306            vec![line(1, 2), jumper],
1307        );
1308        net.loads_mut().push(load(2));
1309
1310        assert_eq!(net.reduce_zero_impedance(1e-9), 1);
1311        let ids: Vec<BusId> = net.buses().iter().map(|b| b.id).collect();
1312        assert_eq!(ids, [BusId(1), BusId(3)]);
1313        assert_eq!(net.branches()[0].to, BusId(3));
1314        assert_eq!(net.loads()[0].bus, BusId(3));
1315        net.validate().unwrap();
1316    }
1317
1318    #[test]
1319    fn reduce_zero_impedance_keeps_an_open_jumper() {
1320        // A zero-impedance jumper between 2 and 3 is out of service: it models an
1321        // open switch, so its endpoints stay separate and must not be merged.
1322        let mut jumper = line(2, 3);
1323        jumper.x = 0.0;
1324        jumper.in_service = false;
1325        let mut net = BalancedNetwork::in_memory(
1326            "net",
1327            100.0,
1328            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1329            vec![line(1, 2), jumper],
1330        );
1331
1332        let removed = net.reduce_zero_impedance(1e-9);
1333        assert_eq!(removed, 0, "an open jumper is left in place");
1334        assert_eq!(net.buses().len(), 3);
1335        assert_eq!(net.branches().len(), 2);
1336        net.validate().unwrap();
1337    }
1338
1339    #[test]
1340    fn a_failed_zero_impedance_reduction_is_reported() {
1341        // The jumper 1-2 would short an ideal off-nominal transformer between
1342        // the same buses, which has no finite shunt equivalent, so the merge
1343        // fails and nothing is merged.
1344        let mut jumper = line(1, 2);
1345        jumper.x = 0.0;
1346        let mut ideal = line(1, 2);
1347        ideal.x = 0.0;
1348        ideal.tap = 1.05;
1349        let mut second = line(2, 3);
1350        second.x = 0.0;
1351        let network = BalancedNetwork::in_memory(
1352            "net",
1353            100.0,
1354            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1355            vec![jumper, ideal, second],
1356        );
1357
1358        let mut old = network.clone();
1359        assert_eq!(old.reduce_zero_impedance(1e-9), 0);
1360        assert_eq!(old.buses().len(), 3);
1361        assert_eq!(old.branches().len(), 3);
1362
1363        let mut reported = network.clone();
1364        let report = reported.reduce_zero_impedance_with_report(1e-9);
1365        assert_eq!(report.removed, 0);
1366        assert_eq!(reported.buses().len(), 3, "nothing is merged");
1367        assert_eq!(reported.branches().len(), 3);
1368        let codes: Vec<&str> = report.diagnostics.iter().map(Diagnostic::code).collect();
1369        assert_eq!(codes, ["BUILD.BRANCH.ZERO_IMPEDANCE"]);
1370        assert!(
1371            report.diagnostics[0]
1372                .message()
1373                .contains("zero impedance reduction merged nothing"),
1374            "{}",
1375            report.diagnostics[0].message()
1376        );
1377    }
1378
1379    #[test]
1380    fn a_zero_impedance_reduction_reports_the_merge() {
1381        let mut jumper = line(2, 3);
1382        jumper.x = 0.0;
1383        let mut net = BalancedNetwork::in_memory(
1384            "net",
1385            100.0,
1386            vec![bus(1, 1, 230.0), bus(2, 1, 230.0), bus(3, 1, 230.0)],
1387            vec![line(1, 2), jumper],
1388        );
1389        let report = net.reduce_zero_impedance_with_report(1e-9);
1390        assert_eq!(report.removed, 1);
1391        let codes: Vec<&str> = report.diagnostics.iter().map(Diagnostic::code).collect();
1392        assert_eq!(codes, ["CANONICALIZE.MERGE.ZERO_IMPEDANCE"]);
1393    }
1394
1395    #[test]
1396    fn reduce_zero_impedance_keeps_a_3w_winding_pair() {
1397        // A zero-impedance jumper between buses 2 and 3, which are two windings of
1398        // the same 3-winding transformer. Merging them would short two windings
1399        // onto one node, so the jumper is left in place.
1400        let mut jumper = line(2, 3);
1401        jumper.x = 0.0;
1402        let mut net = BalancedNetwork::in_memory(
1403            "net",
1404            100.0,
1405            vec![bus(1, 1, 230.0), bus(2, 1, 138.0), bus(3, 1, 13.8)],
1406            vec![line(1, 2), jumper],
1407        );
1408        net.transformers_3w_mut().push(transformer_3w(1, 2, 3));
1409
1410        let removed = net.reduce_zero_impedance(1e-9);
1411        assert_eq!(
1412            removed, 0,
1413            "a jumper across two windings of one 3W transformer is kept"
1414        );
1415        assert_eq!(net.buses().len(), 3);
1416        net.validate().unwrap();
1417    }
1418}