Skip to main content

powerio_tx/
merge.rs

1//! One explicit bus merge: closed switches and zero impedance branches.
2//!
3//! [`BalancedNetwork::merge_buses`] joins every set of buses connected by the
4//! elements a [`BusMergeRule`] selects into one surviving bus, rewrites every
5//! element reference onto it, and removes the joining elements. The result is
6//! a [`BusMerge`]: the merged network, the map from each merged bus to its
7//! survivor, every removed element with its source row and ratings, the
8//! branch and switch row maps, and diagnostics for what the merge changed.
9//!
10//! The rule is always the caller's: [`ZeroImpedanceRule::Exact`] merges only
11//! branches with `r == 0` and `x == 0`, and a threshold rule names its
12//! threshold. Nothing here selects elements by a tolerance the caller did not
13//! state; only the attribute conflict report compares within a fixed `1e-9`.
14//!
15//! The merged network no longer has the removed elements' flows as
16//! variables. [`BusMerge::calc_removed_flows`] recovers their active power
17//! from a solution of the merged network.
18
19use std::collections::{BTreeMap, HashMap};
20
21use crate::diagnostics::{Diagnostic, codes};
22use crate::network::{BalancedNetwork, Bus, BusId, BusType};
23use crate::{Error, Result};
24
25/// The zero impedance threshold PSS/E applies when a case states none
26/// (`THRSHZ`, per unit). [`BusMergeRule::psse`] does not fall back to it; a
27/// caller that wants it for a case that states no threshold names it.
28pub const PSSE_DEFAULT_ZERO_IMPEDANCE_THRESHOLD: f64 = 0.0001;
29
30/// Which branches a [`BusMergeRule`] treats as zero impedance connections.
31///
32/// Every rule considers in-service branches only: an out-of-service branch
33/// does not join its buses.
34#[derive(Clone, Copy, Debug, PartialEq)]
35#[non_exhaustive]
36pub enum ZeroImpedanceRule {
37    /// `r == 0` and `x == 0` exactly. A transformer qualifies only at
38    /// nominal ratio with no phase shift, since an ideal off-nominal
39    /// transformer is a voltage ratio rather than a connection.
40    Exact,
41    /// PSS/E zero impedance line semantics: a non-transformer branch with
42    /// `r == 0` and `|x| <= threshold` (per unit). A threshold of zero
43    /// disables the treatment, as `THRSHZ = 0` does in PSS/E.
44    PsseThreshold(f64),
45    /// A non-transformer branch whose series impedance magnitude
46    /// `hypot(r, x)` is at or below the threshold (per unit). Wider than the
47    /// PSS/E rule: it also merges small resistive jumpers.
48    ImpedanceMagnitude(f64),
49}
50
51impl ZeroImpedanceRule {
52    /// Whether `branch` is a zero impedance connection under this rule.
53    #[allow(clippy::float_cmp)] // the exact rule compares exactly, by definition
54    fn matches(self, branch: &crate::network::Branch) -> bool {
55        if !branch.in_service {
56            return false;
57        }
58        match self {
59            ZeroImpedanceRule::Exact => {
60                branch.r == 0.0
61                    && branch.x == 0.0
62                    && branch.calc_effective_tap() == 1.0
63                    && branch.shift == 0.0
64            }
65            ZeroImpedanceRule::PsseThreshold(threshold) => {
66                threshold > 0.0
67                    && !branch.is_transformer()
68                    && branch.r == 0.0
69                    && branch.x.abs() <= threshold
70            }
71            ZeroImpedanceRule::ImpedanceMagnitude(threshold) => {
72                !branch.is_transformer() && branch.r.hypot(branch.x) <= threshold
73            }
74        }
75    }
76
77    fn threshold(self) -> Option<f64> {
78        match self {
79            ZeroImpedanceRule::Exact => None,
80            ZeroImpedanceRule::PsseThreshold(threshold)
81            | ZeroImpedanceRule::ImpedanceMagnitude(threshold) => Some(threshold),
82        }
83    }
84}
85
86impl std::fmt::Display for ZeroImpedanceRule {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            ZeroImpedanceRule::Exact => f.write_str("r == 0 and x == 0"),
90            ZeroImpedanceRule::PsseThreshold(threshold) => {
91                write!(f, "non-transformer, r == 0 and |x| <= {threshold}")
92            }
93            ZeroImpedanceRule::ImpedanceMagnitude(threshold) => {
94                write!(f, "non-transformer, |r + jx| <= {threshold}")
95            }
96        }
97    }
98}
99
100/// What happens to the line charging and line shunts of a branch the merge
101/// removes. A bypassed transformer's series equivalent is always retained.
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
103#[non_exhaustive]
104pub enum MergedCharging {
105    /// Fold each removed in-service branch's terminal admittances (`B/2` per
106    /// end and any line shunts), with the from end divided by the tap squared,
107    /// onto the survivor as one fixed shunt together with the series equivalent.
108    /// This preserves `Yff + Yft + Ytf + Ytt` at equal terminal voltages.
109    #[default]
110    FoldToShunt,
111    /// Drop the terminal charging admittances with the branch, while retaining
112    /// any series equivalent caused by a nonunity tap or phase shift.
113    Drop,
114}
115
116/// Which bus of a merged group survives and carries the group.
117#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
118#[non_exhaustive]
119pub enum MergeSurvivor {
120    /// The group's reference bus, else a bus hosting an in-service
121    /// generator, else a bus an in-service generator regulates, with the
122    /// smallest id breaking each tie.
123    #[default]
124    Strongest,
125    /// The group's smallest bus id, the survivor
126    /// `powerio_prob::merge_zero_impedance_buses` keeps.
127    SmallestId,
128    /// The from bus: taking the merged elements in table order, closed
129    /// switches first, the to bus's group joins the from bus's group. This is
130    /// the survivor [`BalancedNetwork::reduce_zero_impedance`] keeps.
131    FromBus,
132}
133
134/// What [`BalancedNetwork::merge_buses`] merges.
135#[derive(Clone, Copy, Debug, PartialEq)]
136#[non_exhaustive]
137pub struct BusMergeRule {
138    /// Merge the two buses of every closed switch.
139    pub closed_switches: bool,
140    /// Merge the two buses of every branch this rule selects; `None` merges
141    /// no branch.
142    pub zero_impedance: Option<ZeroImpedanceRule>,
143    /// What happens to the charging of a removed branch.
144    pub charging: MergedCharging,
145    /// Which bus of each merged group survives.
146    pub survivor: MergeSurvivor,
147}
148
149impl BusMergeRule {
150    /// A rule from its two parts, folding removed branches' charging onto
151    /// the survivors.
152    #[must_use]
153    pub const fn new(closed_switches: bool, zero_impedance: Option<ZeroImpedanceRule>) -> Self {
154        Self {
155            closed_switches,
156            zero_impedance,
157            charging: MergedCharging::FoldToShunt,
158            survivor: MergeSurvivor::Strongest,
159        }
160    }
161
162    /// The same rule with `charging` for the removed branches' charging.
163    #[must_use]
164    pub const fn with_charging(mut self, charging: MergedCharging) -> Self {
165        self.charging = charging;
166        self
167    }
168
169    /// The same rule with `survivor` choosing each group's surviving bus.
170    #[must_use]
171    pub const fn with_survivor(mut self, survivor: MergeSurvivor) -> Self {
172        self.survivor = survivor;
173        self
174    }
175
176    /// Closed switches and exact zero impedance branches: the connections
177    /// that impose equal terminal voltages with no tolerance.
178    #[must_use]
179    pub const fn exact() -> Self {
180        Self::new(true, Some(ZeroImpedanceRule::Exact))
181    }
182
183    /// Closed switches only.
184    #[must_use]
185    pub const fn closed_switches() -> Self {
186        Self::new(true, None)
187    }
188
189    /// PSS/E semantics for `network`: closed switches plus
190    /// [`ZeroImpedanceRule::PsseThreshold`] at the zero impedance threshold
191    /// the case states (`GENERAL THRSHZ`, carried in
192    /// [`SolverParams::zero_impedance_threshold`](crate::SolverParams::zero_impedance_threshold)).
193    ///
194    /// Read it from the network as parsed: normalization does not carry the
195    /// solver parameters, so a normalized network states no threshold.
196    ///
197    /// # Errors
198    /// [`Error::BusMergeRule`] when the case states no threshold. Name one
199    /// with [`ZeroImpedanceRule::PsseThreshold`], for example
200    /// [`PSSE_DEFAULT_ZERO_IMPEDANCE_THRESHOLD`].
201    pub fn psse(network: &BalancedNetwork) -> Result<Self> {
202        let threshold = network
203            .solver()
204            .as_ref()
205            .and_then(|solver| solver.zero_impedance_threshold)
206            .ok_or_else(|| Error::BusMergeRule {
207                message: "the case states no zero impedance threshold (PSS/E THRSHZ); give the \
208                          threshold explicitly"
209                    .to_owned(),
210            })?;
211        let rule = Self::new(true, Some(ZeroImpedanceRule::PsseThreshold(threshold)));
212        rule.check()?;
213        Ok(rule)
214    }
215
216    fn check(&self) -> Result<()> {
217        if let Some(threshold) = self.zero_impedance.and_then(ZeroImpedanceRule::threshold)
218            && !(threshold.is_finite() && threshold >= 0.0)
219        {
220            return Err(Error::BusMergeRule {
221                message: format!(
222                    "the zero impedance threshold must be a finite nonnegative number, got {threshold}"
223                ),
224            });
225        }
226        Ok(())
227    }
228}
229
230impl std::fmt::Display for BusMergeRule {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        match (self.closed_switches, self.zero_impedance) {
233            (true, Some(rule)) => write!(f, "closed switches and branches with {rule}"),
234            (false, Some(rule)) => write!(f, "branches with {rule}"),
235            (true, None) => f.write_str("closed switches"),
236            (false, None) => f.write_str("nothing"),
237        }
238    }
239}
240
241/// Why a merge removed an element.
242#[derive(Clone, Copy, Debug, PartialEq, Eq)]
243#[non_exhaustive]
244pub enum RemovalReason {
245    /// A branch the rule's [`ZeroImpedanceRule`] selected.
246    ZeroImpedance,
247    /// A closed switch the rule merged.
248    ClosedSwitch,
249    /// An element the rule did not select whose two buses the merge joined
250    /// through other elements: a branch in parallel with a jumper path, or an
251    /// open switch across a merged group.
252    Shorted,
253}
254
255/// A branch the merge removed.
256#[derive(Clone, Debug, PartialEq)]
257#[non_exhaustive]
258pub struct RemovedBranch {
259    /// Row in the source network's branch table.
260    pub row: usize,
261    /// The branch's `uid`, else `branches:{row}`.
262    pub identity: String,
263    /// The source endpoints.
264    pub from: BusId,
265    pub to: BusId,
266    /// The bus both endpoints merged into.
267    pub survivor: BusId,
268    pub reason: RemovalReason,
269    pub in_service: bool,
270    pub r: f64,
271    pub x: f64,
272    /// Total line charging susceptance, per unit. [`MergedCharging`] decides
273    /// whether it moves to the survivor or goes with the branch.
274    pub b: f64,
275    /// Thermal ratings in MVA; `0` means unlimited.
276    pub rate_a: f64,
277    pub rate_b: f64,
278    pub rate_c: f64,
279}
280
281/// A switch the merge removed.
282#[derive(Clone, Debug, PartialEq)]
283#[non_exhaustive]
284pub struct RemovedSwitch {
285    /// Row in the source network's switch table.
286    pub row: usize,
287    /// The switch's `uid`, else `switches:{row}`.
288    pub identity: String,
289    pub from: BusId,
290    pub to: BusId,
291    pub survivor: BusId,
292    pub reason: RemovalReason,
293    pub closed: bool,
294    /// Thermal rating in MVA, when stated.
295    pub thermal_rating: Option<f64>,
296}
297
298/// Buses the merge joined into one.
299#[derive(Clone, Debug, PartialEq, Eq)]
300#[non_exhaustive]
301pub struct BusGroup {
302    /// The bus that carries the group in the merged network.
303    pub survivor: BusId,
304    /// Every bus of the group, survivor included, in ascending id order.
305    pub members: Vec<BusId>,
306}
307
308/// The result of [`BalancedNetwork::merge_buses`].
309#[derive(Clone, Debug)]
310#[non_exhaustive]
311pub struct BusMerge {
312    /// The merged network.
313    pub network: BalancedNetwork,
314    /// The network the merge read; a shared handle, not a copy.
315    pub source: BalancedNetwork,
316    /// The rule that produced this merge.
317    pub rule: BusMergeRule,
318    /// Every merged bus to the bus that now carries it. Buses that survived,
319    /// whether or not they absorbed others, are absent.
320    pub merged_buses: BTreeMap<BusId, BusId>,
321    /// Every group of two or more buses, in ascending survivor order.
322    pub groups: Vec<BusGroup>,
323    /// Removed branches in source row order.
324    pub removed_branches: Vec<RemovedBranch>,
325    /// Removed switches in source row order.
326    pub removed_switches: Vec<RemovedSwitch>,
327    /// For each source branch row, its row in the merged network, or `None`
328    /// when the merge removed it.
329    pub branch_rows: Vec<Option<usize>>,
330    /// For each source switch row, its row in the merged network, or `None`
331    /// when the merge removed it.
332    pub switch_rows: Vec<Option<usize>>,
333    /// Rows of the merged network's shunt table holding removed branches'
334    /// equivalent admittances, one per in-service removed branch with a
335    /// nonzero equivalent, after the source's own shunts. This includes series
336    /// equivalents even under [`MergedCharging::Drop`]. Each carries the
337    /// `uid` `merged-charging:<branch>`.
338    pub charging_shunts: Vec<usize>,
339    /// What the merge changed or could not do.
340    pub diagnostics: Vec<Diagnostic>,
341}
342
343impl BusMerge {
344    /// The bus that carries `bus` in the merged network: its survivor when
345    /// it merged, else `bus` itself.
346    #[must_use]
347    pub fn survivor(&self, bus: BusId) -> BusId {
348        self.merged_buses.get(&bus).copied().unwrap_or(bus)
349    }
350
351    /// Whether the merge changed nothing.
352    #[must_use]
353    pub fn is_identity(&self) -> bool {
354        self.merged_buses.is_empty()
355            && self.removed_branches.is_empty()
356            && self.removed_switches.is_empty()
357    }
358}
359
360/// A solution of a merged network, as [`BusMerge::calc_removed_flows`]
361/// reads it. Every value is active power in the network's own units (MW for
362/// a network as parsed, per unit for a normalized one).
363#[derive(Clone, Copy, Debug)]
364#[non_exhaustive]
365pub struct MergedFlows<'a> {
366    /// Power entering each branch of the merged network at its from end,
367    /// in merged branch order.
368    pub branch_p_from: &'a [f64],
369    /// Power entering each branch at its to end. A lossless DC solution
370    /// states the negation of `branch_p_from`.
371    pub branch_p_to: &'a [f64],
372    /// Power each generator injects, in generator order; out-of-service rows
373    /// are ignored. `None` reads the stated `pg`.
374    pub generator_p: Option<&'a [f64]>,
375    /// Power entering each winding of each three winding transformer from
376    /// its bus. Required when a merged group holds a winding bus.
377    pub transformer_3w_p: &'a [[f64; 3]],
378}
379
380impl<'a> MergedFlows<'a> {
381    /// Branch end flows with stated generation and no three winding
382    /// transformer flows.
383    #[must_use]
384    pub fn new(branch_p_from: &'a [f64], branch_p_to: &'a [f64]) -> Self {
385        Self {
386            branch_p_from,
387            branch_p_to,
388            generator_p: None,
389            transformer_3w_p: &[],
390        }
391    }
392
393    /// Use these generator outputs rather than the stated `pg`.
394    #[must_use]
395    pub fn with_generator_p(mut self, generator_p: &'a [f64]) -> Self {
396        self.generator_p = Some(generator_p);
397        self
398    }
399
400    /// Supply the three winding transformer winding flows.
401    #[must_use]
402    pub fn with_transformer_3w_p(mut self, transformer_3w_p: &'a [[f64; 3]]) -> Self {
403        self.transformer_3w_p = transformer_3w_p;
404        self
405    }
406}
407
408/// How [`BusMerge::calc_removed_flows`] determined one flow.
409#[derive(Clone, Copy, Debug, PartialEq, Eq)]
410#[non_exhaustive]
411pub enum RemovedFlowMethod {
412    /// The element is out of service or an open switch, so it carries none.
413    OutOfService,
414    /// The removed elements form a tree here, so Kirchhoff's current law
415    /// alone fixes the flow.
416    Tree,
417    /// The element lies on a loop, split by the elements' own reactances.
418    Reactance,
419    /// The element lies on a loop of zero reactance elements, whose split no
420    /// network quantity fixes; the minimum norm split is reported.
421    MinimumNorm,
422}
423
424/// One recovered flow.
425#[derive(Clone, Copy, Debug, PartialEq)]
426#[non_exhaustive]
427pub struct RemovedFlow {
428    /// Active power entering the element at its source from end.
429    pub p_from: f64,
430    pub method: RemovedFlowMethod,
431}
432
433/// The result of [`BusMerge::calc_removed_flows`].
434#[derive(Clone, Debug, PartialEq)]
435#[non_exhaustive]
436pub struct RemovedFlows {
437    /// One flow per [`BusMerge::removed_branches`] entry, in that order.
438    pub branches: Vec<RemovedFlow>,
439    /// One flow per [`BusMerge::removed_switches`] entry, in that order.
440    pub switches: Vec<RemovedFlow>,
441    /// Loops split by minimum norm and groups whose injections do not
442    /// balance.
443    pub diagnostics: Vec<Diagnostic>,
444}
445
446/// Union-find over dense bus indices that refuses a union joining two
447/// windings of one three winding transformer.
448///
449/// The refusal covers every pair of live windings, including two secondaries
450/// tied by a closed switch. Such a tie is a valid parallel connection, and
451/// the star lowering could model it by joining the two windings at one bus;
452/// admitting it is deferred, so for now normalization refuses it with
453/// `BUILD.SWITCH.CLOSED`.
454struct MergeSets {
455    parent: Vec<usize>,
456    /// Per root, the three winding transformers with a winding in the set.
457    windings: HashMap<usize, Vec<usize>>,
458    /// Keep the first argument's root on a join, so the roots are the
459    /// [`MergeSurvivor::FromBus`] survivors; otherwise the smaller index.
460    keep_first: bool,
461}
462
463enum Join {
464    Joined,
465    AlreadyJoined,
466    /// The union would join two windings of this transformer.
467    Refused(usize),
468}
469
470impl MergeSets {
471    fn new(n: usize, windings: HashMap<usize, Vec<usize>>, keep_first: bool) -> Self {
472        Self {
473            parent: (0..n).collect(),
474            windings,
475            keep_first,
476        }
477    }
478
479    fn root(&mut self, node: usize) -> usize {
480        let mut root = node;
481        while self.parent[root] != root {
482            root = self.parent[root];
483        }
484        let mut walk = node;
485        while self.parent[walk] != root {
486            let next = self.parent[walk];
487            self.parent[walk] = root;
488            walk = next;
489        }
490        root
491    }
492
493    fn join(&mut self, a: usize, b: usize) -> Join {
494        let (ra, rb) = (self.root(a), self.root(b));
495        if ra == rb {
496            return Join::AlreadyJoined;
497        }
498        if let (Some(wa), Some(wb)) = (self.windings.get(&ra), self.windings.get(&rb))
499            && let Some(&shared) = wa.iter().find(|t| wb.contains(t))
500        {
501            return Join::Refused(shared);
502        }
503        let (keep, gone) = if self.keep_first || ra < rb {
504            (ra, rb)
505        } else {
506            (rb, ra)
507        };
508        self.parent[gone] = keep;
509        if let Some(moved) = self.windings.remove(&gone) {
510            self.windings.entry(keep).or_default().extend(moved);
511        }
512        Join::Joined
513    }
514}
515
516/// Move the detailed connectivity's calculated bus assignments onto the
517/// survivors: connectivity nodes and configured buses point at the survivor,
518/// voltage levels list it once, and the calculated bus records of one survivor
519/// combine. When that leaves the hierarchy inconsistent, as when a merge joins
520/// buses of two voltage levels, the calculated bus assignments are dropped and
521/// the finding returned; the rest of the record, such as component metadata,
522/// stays.
523fn remap_detailed_connectivity(
524    network: &mut BalancedNetwork,
525    resolve: &impl Fn(BusId) -> BusId,
526) -> Option<Diagnostic> {
527    let detailed = network.detailed_connectivity().as_deref()?;
528    let moved = |bus: BusId| resolve(bus) != bus;
529    let touches = detailed
530        .voltage_levels
531        .iter()
532        .any(|level| level.buses.iter().copied().any(moved))
533        || detailed
534            .bus_breaker_buses
535            .iter()
536            .any(|bus| bus.calculated_bus.is_some_and(moved))
537        || detailed
538            .connectivity_nodes
539            .iter()
540            .any(|node| node.calculated_bus.is_some_and(moved))
541        || detailed
542            .calculated_buses
543            .iter()
544            .any(|calculated| moved(calculated.calculated_bus));
545    if !touches {
546        return None;
547    }
548    let mut remapped = detailed.clone();
549    for level in &mut remapped.voltage_levels {
550        let mut seen = std::collections::HashSet::new();
551        level.buses = level
552            .buses
553            .iter()
554            .map(|&bus| resolve(bus))
555            .filter(|&bus| seen.insert(bus))
556            .collect();
557    }
558    for bus in &mut remapped.bus_breaker_buses {
559        bus.calculated_bus = bus.calculated_bus.map(resolve);
560    }
561    for node in &mut remapped.connectivity_nodes {
562        node.calculated_bus = node.calculated_bus.map(resolve);
563    }
564    let mut combined: Vec<crate::network::CalculatedBus> = Vec::new();
565    let mut position: HashMap<BusId, usize> = HashMap::new();
566    for mut calculated in std::mem::take(&mut remapped.calculated_buses) {
567        calculated.calculated_bus = resolve(calculated.calculated_bus);
568        if let Some(&kept) = position.get(&calculated.calculated_bus) {
569            combined[kept].nodes.extend(calculated.nodes);
570        } else {
571            position.insert(calculated.calculated_bus, combined.len());
572            combined.push(calculated);
573        }
574    }
575    remapped.calculated_buses = combined;
576    *network.detailed_connectivity_mut() = Some(std::sync::Arc::new(remapped));
577    if network.validate().is_ok() {
578        return None;
579    }
580
581    let detailed = std::sync::Arc::make_mut(
582        network
583            .detailed_connectivity_mut()
584            .as_mut()
585            .expect("the record was just written"),
586    );
587    for level in &mut detailed.voltage_levels {
588        level.buses.clear();
589    }
590    for bus in &mut detailed.bus_breaker_buses {
591        bus.calculated_bus = None;
592    }
593    for node in &mut detailed.connectivity_nodes {
594        node.calculated_bus = None;
595    }
596    detailed.calculated_buses.clear();
597    Some(Diagnostic::of(
598        &codes::CANONICALIZE_MERGE_DETAIL_DROPPED,
599        "the detailed connectivity's calculated bus assignments were dropped: the merge joined \
600         buses its hierarchy keeps apart, such as buses of two voltage levels",
601    ))
602}
603
604/// Append the one-port equivalent of each removed in-service branch. For
605/// equal terminal voltages its series term is y * |1/a - 1|², where a is the
606/// complex tap. Terminal charging adds y_fr / tap² + y_to when requested.
607#[allow(clippy::float_cmp)] // only an exactly nominal ideal connection has a finite equivalent
608fn fold_admittance(
609    network: &mut BalancedNetwork,
610    source: &BalancedNetwork,
611    removed: &[RemovedBranch],
612    charging: MergedCharging,
613) -> Result<Vec<usize>> {
614    let scale = if source.is_normalized() {
615        1.0
616    } else {
617        source.base_mva()
618    };
619    let mut shunts = Vec::new();
620    for removed in removed.iter().filter(|removed| removed.in_service) {
621        let branch = &source.branches()[removed.row];
622        let tap = branch.calc_divisible_tap(removed.row)?;
623        let shift = if source.is_normalized() {
624            branch.shift
625        } else {
626            branch.shift.to_radians()
627        };
628        let (sin, cos) = shift.sin_cos();
629        // The squared norm avoids cancellation between the four Y entries
630        // for a near-unity tap. It is exactly zero for a nominal jumper.
631        let factor = (tap.recip() - cos).powi(2) + sin.powi(2);
632        let (mut g, mut b) = match branch.calc_series_admittance(removed.row)? {
633            Some((g, b)) => (g * factor, b * factor),
634            None if tap == 1.0 && shift == 0.0 => (0.0, 0.0),
635            None => return Err(Error::ZeroImpedance { row: removed.row }),
636        };
637        if charging == MergedCharging::FoldToShunt {
638            let terminal = branch.calc_terminal_charging();
639            let inverse_tap_squared = tap.recip().powi(2);
640            g += terminal.g_fr * inverse_tap_squared + terminal.g_to;
641            b += terminal.b_fr * inverse_tap_squared + terminal.b_to;
642        }
643        let (g, b) = (g * scale, b * scale);
644        if !g.is_finite() || !b.is_finite() {
645            return Err(Error::NonFiniteSusceptance { row: removed.row });
646        }
647        if g != 0.0 || b != 0.0 {
648            let mut shunt = crate::network::Shunt::new(removed.survivor, g, b);
649            shunt.uid = Some(format!("merged-charging:{}", removed.identity));
650            shunts.push(shunt);
651        }
652    }
653    let first = network.shunts().len();
654    let rows = (first..first + shunts.len()).collect();
655    if !shunts.is_empty() {
656        network.shunts_mut().extend(shunts);
657    }
658    Ok(rows)
659}
660
661/// Two bus attributes differ by more than this (per unit for voltage
662/// magnitude limits, degrees for angles) before a merge reports them.
663const ATTRIBUTE_TOLERANCE: f64 = 1e-9;
664
665/// Whether two attribute values disagree beyond [`ATTRIBUTE_TOLERANCE`]. Equal
666/// infinities agree; a NaN disagrees with every other value.
667fn attribute_differs(a: f64, b: f64) -> bool {
668    let gap = (a - b).abs();
669    a.to_bits() != b.to_bits() && (gap.is_nan() || gap > ATTRIBUTE_TOLERANCE)
670}
671
672/// The attributes a merge group's survivor takes from the whole group.
673struct SurvivorAttributes {
674    kind: BusType,
675    vmin: f64,
676    vmax: f64,
677    va: f64,
678}
679
680/// The voltage magnitude band of a merge group: the intersection of its
681/// members' bands (the largest `vmin` and the smallest `vmax`), since the
682/// merged bus carries every member's voltage. Nonfinite limits are ignored.
683/// When the members disagree the merge reports it, naming each bus whose band
684/// differs from the result; when the bands do not overlap the survivor keeps
685/// its own band and the report says so.
686fn merge_voltage_limits(
687    buses: &[Bus],
688    ids: &[BusId],
689    index_of: &HashMap<BusId, usize>,
690    survivor_index: usize,
691    diagnostics: &mut Vec<Diagnostic>,
692) -> (f64, f64) {
693    let survivor = &buses[survivor_index];
694    let members: Vec<&Bus> = ids.iter().map(|id| &buses[index_of[id]]).collect();
695    let tightest = |values: Vec<f64>, pick: fn(f64, f64) -> f64, own: f64| {
696        values
697            .into_iter()
698            .filter(|value| value.is_finite())
699            .reduce(pick)
700            .unwrap_or(own)
701    };
702    let vmin = tightest(
703        members.iter().map(|bus| bus.vmin).collect(),
704        f64::max,
705        survivor.vmin,
706    );
707    let vmax = tightest(
708        members.iter().map(|bus| bus.vmax).collect(),
709        f64::min,
710        survivor.vmax,
711    );
712    let differing: Vec<String> = members
713        .iter()
714        .filter(|bus| attribute_differs(bus.vmin, vmin) || attribute_differs(bus.vmax, vmax))
715        .map(|bus| bus.id.to_string())
716        .collect();
717    if differing.is_empty() {
718        return (vmin, vmax);
719    }
720    let bands = members
721        .iter()
722        .map(|bus| format!("bus {} [{}, {}]", bus.id, bus.vmin, bus.vmax))
723        .collect::<Vec<_>>()
724        .join(", ");
725    let id = survivor.id;
726    if vmin > vmax {
727        diagnostics.push(Diagnostic::of(
728            &codes::CANONICALIZE_MERGE_ATTRIBUTE_CONFLICT,
729            format!(
730                "buses merged into bus {id} have voltage limits that do not overlap ({bands} \
731                 p.u.); bus {id} kept its own limits [{}, {}] p.u.",
732                survivor.vmin, survivor.vmax
733            ),
734        ));
735        return (survivor.vmin, survivor.vmax);
736    }
737    diagnostics.push(Diagnostic::of(
738        &codes::CANONICALIZE_MERGE_ATTRIBUTE_CONFLICT,
739        format!(
740            "buses merged into bus {id} have differing voltage limits ({bands} p.u.); bus {id} \
741             takes their intersection [{vmin}, {vmax}] p.u., which narrows the limits of \
742             bus(es) {}",
743            differing.join(", ")
744        ),
745    ));
746    (vmin, vmax)
747}
748
749/// The voltage angle of a merge group's survivor. A group with a reference
750/// bus keeps the reference's angle: the survivor's own when it is a
751/// reference, else the angle of the smallest id reference it takes over. The
752/// merge reports a survivor angle or another reference angle that differs.
753fn merge_reference_angle(
754    buses: &[Bus],
755    references: &[BusId],
756    index_of: &HashMap<BusId, usize>,
757    survivor_index: usize,
758    diagnostics: &mut Vec<Diagnostic>,
759) -> f64 {
760    let survivor = &buses[survivor_index];
761    let Some(&first) = references.first() else {
762        return survivor.va;
763    };
764    let reference = if survivor.kind == BusType::Ref {
765        survivor
766    } else {
767        &buses[index_of[&first]]
768    };
769    let va = reference.va;
770    let mut angles: Vec<&Bus> = references.iter().map(|id| &buses[index_of[id]]).collect();
771    if survivor.kind != BusType::Ref {
772        angles.push(survivor);
773        angles.sort_by_key(|bus| bus.id);
774    }
775    if angles.iter().all(|bus| !attribute_differs(bus.va, va)) {
776        return va;
777    }
778    let listed = angles
779        .iter()
780        .map(|bus| format!("bus {} {} deg", bus.id, bus.va))
781        .collect::<Vec<_>>()
782        .join(", ");
783    let id = survivor.id;
784    diagnostics.push(Diagnostic::of(
785        &codes::CANONICALIZE_MERGE_ATTRIBUTE_CONFLICT,
786        format!(
787            "buses merged into bus {id} disagree on the reference angle ({listed}); bus {id} \
788             takes the angle {va} deg of reference bus {}",
789            reference.id
790        ),
791    ));
792    va
793}
794
795/// Bus kind importance: the survivor of a group takes the strongest kind of
796/// its members, so a merge never demotes a slack.
797pub(crate) fn kind_priority(kind: BusType) -> u8 {
798    match kind {
799        BusType::Ref => 3,
800        BusType::Pv => 2,
801        BusType::Pq => 1,
802        BusType::Isolated => 0,
803    }
804}
805
806fn identity(uid: Option<&str>, table: &str, row: usize) -> String {
807    uid.map_or_else(|| format!("{table}:{row}"), str::to_owned)
808}
809
810/// A finding whose details carry the counts its message states.
811fn counted(
812    info: &'static crate::diagnostics::DiagnosticInfo,
813    message: String,
814    counts: &[(&str, usize)],
815) -> Diagnostic {
816    let details = counts
817        .iter()
818        .map(|(key, count)| ((*key).to_owned(), serde_json::Value::from(*count)))
819        .collect();
820    Diagnostic::of(info, message)
821        .with_details(details)
822        .expect("a few short count entries are within the detail bounds")
823}
824
825impl BalancedNetwork {
826    /// Merge every set of buses the elements `rule` selects connect.
827    ///
828    /// Each set becomes one bus. The survivor is the set's reference bus,
829    /// else a bus hosting an in-service generator, else a bus an in-service
830    /// generator regulates, with the smallest id breaking each tie
831    /// ([`MergeSurvivor::Strongest`]; [`BusMergeRule::with_survivor`] selects
832    /// another rule). It takes the strongest bus kind of the set (reference,
833    /// then PV, then PQ) and the intersection of the set's voltage limits
834    /// (the largest `vmin` and the smallest `vmax`; when the limits do not
835    /// overlap it keeps its own). When the set holds a reference bus, the
836    /// survivor takes the reference's voltage angle. It keeps its own other
837    /// attributes. Limits or reference angles that differ by more than `1e-9`
838    /// are reported as `CANONICALIZE.MERGE.ATTRIBUTE_CONFLICT`, naming the
839    /// buses. Every element reference to a merged bus moves to the survivor: loads, shunts, static var compensators,
840    /// generators and their regulated buses, storage, branch, switch, and
841    /// HVDC endpoints, three winding transformer windings, control buses,
842    /// and area swing buses.
843    ///
844    /// The merge removes the selected elements and any other branch or
845    /// switch whose two buses it joined; [`BusMerge::removed_branches`] and
846    /// [`BusMerge::removed_switches`] list each with its source row and
847    /// ratings. A removed in-service branch's complete equivalent admittance
848    /// becomes a fixed shunt at the survivor; [`MergedCharging`] controls only
849    /// its terminal charging. An HVDC line whose two ends merged stays, with a diagnostic.
850    /// An element whose merge would join two windings of one three winding
851    /// transformer stays in place and is reported. Detailed connectivity
852    /// follows the survivors; when the merge joins buses its hierarchy keeps
853    /// apart, such as buses of two voltage levels, its calculated bus
854    /// assignments are dropped with a diagnostic.
855    ///
856    /// The input is not modified, and a merge that joins nothing returns a
857    /// shared handle to it. Merging the result again under the same rule
858    /// joins nothing.
859    ///
860    /// # Errors
861    /// [`Error::BusMergeRule`] for a threshold that is negative or not
862    /// finite. [`Error::UnknownBus`] when a selected element names a bus the
863    /// network does not declare.
864    /// [`Error::ZeroImpedance`] when bypassing an ideal transformer with a
865    /// nonunity tap or phase shift, which has no finite shunt equivalent.
866    /// [`Error::DegenerateTap`] or [`Error::NonFiniteSusceptance`] when a
867    /// removed branch's equivalent cannot be represented finitely.
868    #[allow(clippy::too_many_lines)] // one stanza per stage, in order
869    pub fn merge_buses(&self, rule: &BusMergeRule) -> Result<BusMerge> {
870        rule.check()?;
871        let buses = self.buses();
872        let index_of: HashMap<BusId, usize> = buses
873            .iter()
874            .enumerate()
875            .map(|(index, bus)| (bus.id, index))
876            .collect();
877        let lookup = |bus: BusId, row: usize| {
878            index_of.get(&bus).copied().ok_or(Error::UnknownBus {
879                bus_id: bus,
880                element_index: row,
881            })
882        };
883
884        let mut windings: HashMap<usize, Vec<usize>> = HashMap::new();
885        // Only live windings couple through the star point, so a merge across
886        // an out-of-service transformer or winding shorts nothing.
887        for (t, transformer) in self.transformers_3w().iter().enumerate() {
888            if !transformer.in_service {
889                continue;
890            }
891            let live = transformer.winding_in_service();
892            for (winding, _) in transformer
893                .windings
894                .iter()
895                .zip(live)
896                .filter(|(_, live)| *live)
897            {
898                if let Some(&index) = index_of.get(&winding.bus) {
899                    let list = windings.entry(index).or_default();
900                    if !list.contains(&t) {
901                        list.push(t);
902                    }
903                }
904            }
905        }
906        let mut sets = MergeSets::new(
907            buses.len(),
908            windings,
909            rule.survivor == MergeSurvivor::FromBus,
910        );
911        let mut diagnostics = Vec::new();
912        let mut refused = |what: String, from: BusId, to: BusId, t: usize| {
913            let transformer = &self.transformers_3w()[t];
914            let name = identity(transformer.uid.as_deref(), "transformers_3w", t);
915            diagnostics.push(Diagnostic::of(
916                &codes::CANONICALIZE_MERGE_WINDING_PAIR_KEPT,
917                format!(
918                    "{what} between buses {from} and {to} was kept: merging it would join two \
919                     windings of three winding transformer `{name}`"
920                ),
921            ));
922        };
923
924        if rule.closed_switches {
925            for (row, switch) in self.switches().iter().enumerate() {
926                if !switch.closed || switch.from == switch.to {
927                    continue;
928                }
929                let (a, b) = (lookup(switch.from, row)?, lookup(switch.to, row)?);
930                if let Join::Refused(t) = sets.join(a, b) {
931                    let name = identity(switch.uid.as_deref(), "switches", row);
932                    refused(format!("closed switch `{name}`"), switch.from, switch.to, t);
933                }
934            }
935        }
936        let mut selected = vec![false; self.branches().len()];
937        if let Some(zero_impedance) = rule.zero_impedance {
938            for (row, branch) in self.branches().iter().enumerate() {
939                if branch.from == branch.to || !zero_impedance.matches(branch) {
940                    continue;
941                }
942                let (a, b) = (lookup(branch.from, row)?, lookup(branch.to, row)?);
943                match sets.join(a, b) {
944                    Join::Joined | Join::AlreadyJoined => selected[row] = true,
945                    Join::Refused(t) => {
946                        let name = identity(branch.uid.as_deref(), "branches", row);
947                        refused(format!("branch `{name}`"), branch.from, branch.to, t);
948                    }
949                }
950            }
951        }
952
953        // Group the buses by root and pick each group's survivor.
954        let mut members_of: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
955        for index in 0..buses.len() {
956            let root = sets.root(index);
957            if root != index {
958                members_of.entry(root).or_default().push(index);
959            }
960        }
961        // Only merged members so far; each group's root joins its list.
962        for (&root, members) in &mut members_of {
963            members.push(root);
964        }
965        if members_of.is_empty() {
966            return Ok(self.identity_merge(*rule, diagnostics));
967        }
968
969        let mut hosts_generator = vec![false; buses.len()];
970        let mut regulated = vec![false; buses.len()];
971        for generator in self.generators().iter().filter(|g| g.in_service) {
972            if let Some(&index) = index_of.get(&generator.bus) {
973                hosts_generator[index] = true;
974            }
975            if let Some(index) = generator.regulated_bus.and_then(|b| index_of.get(&b)) {
976                regulated[*index] = true;
977            }
978        }
979        let rank = |index: usize| {
980            let bus = &buses[index];
981            (
982                bus.kind == BusType::Ref,
983                hosts_generator[index],
984                regulated[index],
985                std::cmp::Reverse(bus.id),
986            )
987        };
988
989        let mut merged_buses: HashMap<BusId, BusId> = HashMap::new();
990        let mut groups = Vec::with_capacity(members_of.len());
991        let mut survivor_attributes: HashMap<BusId, SurvivorAttributes> = HashMap::new();
992        for (&root, members) in &members_of {
993            let survivor_index = match rule.survivor {
994                MergeSurvivor::Strongest => {
995                    members.iter().copied().max_by_key(|&index| rank(index))
996                }
997                MergeSurvivor::SmallestId => {
998                    members.iter().copied().min_by_key(|&index| buses[index].id)
999                }
1000                MergeSurvivor::FromBus => None,
1001            }
1002            .unwrap_or(root);
1003            let survivor = buses[survivor_index].id;
1004            let kind = members
1005                .iter()
1006                .map(|&index| buses[index].kind)
1007                .max_by_key(|&kind| kind_priority(kind))
1008                .unwrap_or(buses[survivor_index].kind);
1009            let mut ids: Vec<BusId> = members.iter().map(|&index| buses[index].id).collect();
1010            ids.sort_unstable();
1011            for &id in &ids {
1012                if id != survivor {
1013                    merged_buses.insert(id, survivor);
1014                }
1015            }
1016
1017            let base_kv = buses[survivor_index].base_kv;
1018            let mut other_kv: Vec<f64> = members
1019                .iter()
1020                .map(|&index| buses[index].base_kv)
1021                .filter(|kv| kv.to_bits() != base_kv.to_bits())
1022                .collect();
1023            if !other_kv.is_empty() {
1024                other_kv.sort_by(f64::total_cmp);
1025                other_kv.dedup_by(|a, b| a.to_bits() == b.to_bits());
1026                let other = other_kv
1027                    .iter()
1028                    .map(|kv| format!("{kv} kV"))
1029                    .collect::<Vec<_>>()
1030                    .join(", ");
1031                diagnostics.push(Diagnostic::of(
1032                    &codes::CANONICALIZE_MERGE_ATTRIBUTE_CONFLICT,
1033                    format!(
1034                        "buses of base {other} merged into bus {survivor} (base {base_kv} kV); \
1035                         the surviving base was kept"
1036                    ),
1037                ));
1038            }
1039            let references: Vec<BusId> = ids
1040                .iter()
1041                .copied()
1042                .filter(|&id| buses[index_of[&id]].kind == BusType::Ref)
1043                .collect();
1044            if references.len() > 1 {
1045                let list = references
1046                    .iter()
1047                    .map(ToString::to_string)
1048                    .collect::<Vec<_>>()
1049                    .join(", ");
1050                diagnostics.push(Diagnostic::of(
1051                    &codes::CANONICALIZE_MERGE_ATTRIBUTE_CONFLICT,
1052                    format!(
1053                        "reference buses {list} merged into one; bus {survivor} remains the \
1054                         reference"
1055                    ),
1056                ));
1057            }
1058            let (vmin, vmax) =
1059                merge_voltage_limits(buses, &ids, &index_of, survivor_index, &mut diagnostics);
1060            let va = merge_reference_angle(
1061                buses,
1062                &references,
1063                &index_of,
1064                survivor_index,
1065                &mut diagnostics,
1066            );
1067            survivor_attributes.insert(
1068                survivor,
1069                SurvivorAttributes {
1070                    kind,
1071                    vmin,
1072                    vmax,
1073                    va,
1074                },
1075            );
1076            groups.push(BusGroup {
1077                survivor,
1078                members: ids,
1079            });
1080        }
1081        groups.sort_by_key(|group| group.survivor);
1082        let resolve = |bus: BusId| merged_buses.get(&bus).copied().unwrap_or(bus);
1083
1084        // Classify what the merge removes.
1085        let mut removed_branches = Vec::new();
1086        let mut branch_rows = Vec::with_capacity(self.branches().len());
1087        let mut next = 0usize;
1088        let (mut charged, mut rated) = (0usize, 0usize);
1089        for (row, branch) in self.branches().iter().enumerate() {
1090            let survivor = resolve(branch.from);
1091            if branch.from == branch.to || survivor != resolve(branch.to) {
1092                branch_rows.push(Some(next));
1093                next += 1;
1094                continue;
1095            }
1096            branch_rows.push(None);
1097            let reason = if selected[row] {
1098                RemovalReason::ZeroImpedance
1099            } else {
1100                RemovalReason::Shorted
1101            };
1102            let removed = RemovedBranch {
1103                row,
1104                identity: identity(branch.uid.as_deref(), "branches", row),
1105                from: branch.from,
1106                to: branch.to,
1107                survivor,
1108                reason,
1109                in_service: branch.in_service,
1110                r: branch.r,
1111                x: branch.x,
1112                b: branch.calc_total_charging_b(),
1113                rate_a: branch.rate_a,
1114                rate_b: branch.rate_b,
1115                rate_c: branch.rate_c,
1116            };
1117            if reason == RemovalReason::Shorted {
1118                let kind = if branch.is_transformer() {
1119                    "transformer"
1120                } else {
1121                    "branch"
1122                };
1123                diagnostics.push(Diagnostic::of(
1124                    &codes::CANONICALIZE_MERGE_ELEMENT_SHORTED,
1125                    format!(
1126                        "{kind} `{}` between buses {} and {} was removed: the merge joined its \
1127                         two buses into bus {survivor} through other elements",
1128                        removed.identity, removed.from, removed.to
1129                    ),
1130                ));
1131            } else {
1132                charged += usize::from(removed.b != 0.0);
1133                rated += usize::from(
1134                    removed.rate_a > 0.0 || removed.rate_b > 0.0 || removed.rate_c > 0.0,
1135                );
1136            }
1137            removed_branches.push(removed);
1138        }
1139        let mut removed_switches = Vec::new();
1140        let mut switch_rows = Vec::with_capacity(self.switches().len());
1141        let mut next = 0usize;
1142        for (row, switch) in self.switches().iter().enumerate() {
1143            let survivor = resolve(switch.from);
1144            if switch.from == switch.to || survivor != resolve(switch.to) {
1145                switch_rows.push(Some(next));
1146                next += 1;
1147                continue;
1148            }
1149            switch_rows.push(None);
1150            let reason = if switch.closed && rule.closed_switches {
1151                RemovalReason::ClosedSwitch
1152            } else {
1153                RemovalReason::Shorted
1154            };
1155            let removed = RemovedSwitch {
1156                row,
1157                identity: identity(switch.uid.as_deref(), "switches", row),
1158                from: switch.from,
1159                to: switch.to,
1160                survivor,
1161                reason,
1162                closed: switch.closed,
1163                thermal_rating: switch.thermal_rating,
1164            };
1165            if reason == RemovalReason::Shorted {
1166                let state = if switch.closed { "closed" } else { "open" };
1167                diagnostics.push(Diagnostic::of(
1168                    &codes::CANONICALIZE_MERGE_ELEMENT_SHORTED,
1169                    format!(
1170                        "{state} switch `{}` between buses {} and {} was removed: the merge \
1171                         joined its two buses into bus {survivor} through other elements",
1172                        removed.identity, removed.from, removed.to
1173                    ),
1174                ));
1175            }
1176            removed_switches.push(removed);
1177        }
1178        for (row, line) in self.hvdc().iter().enumerate() {
1179            if line.from != line.to && resolve(line.from) == resolve(line.to) {
1180                let name = identity(line.uid.as_deref(), "hvdc", row);
1181                diagnostics.push(Diagnostic::of(
1182                    &codes::CANONICALIZE_MERGE_ELEMENT_SHORTED,
1183                    format!(
1184                        "HVDC line `{name}` between buses {} and {} now has both ends on bus {}; \
1185                         it was kept",
1186                        line.from,
1187                        line.to,
1188                        resolve(line.from)
1189                    ),
1190                ));
1191            }
1192        }
1193
1194        let zero_impedance = removed_branches
1195            .iter()
1196            .filter(|removed| removed.reason == RemovalReason::ZeroImpedance)
1197            .count();
1198        if let Some(branch_rule) = rule.zero_impedance
1199            && zero_impedance > 0
1200        {
1201            let charging = match rule.charging {
1202                MergedCharging::FoldToShunt => "folded onto the survivors as fixed shunts",
1203                MergedCharging::Drop => "dropped",
1204            };
1205            diagnostics.push(counted(
1206                &codes::CANONICALIZE_MERGE_ZERO_IMPEDANCE,
1207                format!(
1208                    "{zero_impedance} in-service branch(es) with {branch_rule} were merged \
1209                     ({rated} with a thermal rating, {charged} with line charging, {charging}); \
1210                     their flows are no longer variables of the merged network, and \
1211                     BusMerge::calc_removed_flows recovers them from a solution"
1212                ),
1213                &[
1214                    ("count", zero_impedance),
1215                    ("rated", rated),
1216                    ("charged", charged),
1217                ],
1218            ));
1219        }
1220        let closed = removed_switches
1221            .iter()
1222            .filter(|removed| removed.reason == RemovalReason::ClosedSwitch)
1223            .count();
1224        if closed > 0 {
1225            diagnostics.push(counted(
1226                &codes::CANONICALIZE_MERGE_CLOSED_SWITCH,
1227                format!(
1228                    "{closed} closed switch(es) were merged; their flows are no longer variables \
1229                     of the merged network, and BusMerge::calc_removed_flows recovers them from a \
1230                     solution"
1231                ),
1232                &[("count", closed)],
1233            ));
1234        }
1235
1236        let mut network = self.clone();
1237        {
1238            let mut row = 0usize;
1239            network.branches_mut().retain(|_| {
1240                let keep = branch_rows[row].is_some();
1241                row += 1;
1242                keep
1243            });
1244            if !removed_switches.is_empty() {
1245                let mut row = 0usize;
1246                network.switches_mut().retain(|_| {
1247                    let keep = switch_rows[row].is_some();
1248                    row += 1;
1249                    keep
1250                });
1251            }
1252        }
1253        network.remap_buses(resolve);
1254        let charging_shunts =
1255            fold_admittance(&mut network, self, &removed_branches, rule.charging)?;
1256        network.buses_mut().retain_mut(|bus| {
1257            if merged_buses.contains_key(&bus.id) {
1258                return false;
1259            }
1260            if let Some(attributes) = survivor_attributes.get(&bus.id) {
1261                bus.kind = attributes.kind;
1262                bus.vmin = attributes.vmin;
1263                bus.vmax = attributes.vmax;
1264                bus.va = attributes.va;
1265            }
1266            true
1267        });
1268        if let Some(diagnostic) = remap_detailed_connectivity(&mut network, &resolve) {
1269            diagnostics.push(diagnostic);
1270        }
1271        debug_assert!(
1272            network.validate().is_ok(),
1273            "merge_buses produced a dangling reference"
1274        );
1275
1276        Ok(BusMerge {
1277            network,
1278            source: self.clone(),
1279            rule: *rule,
1280            merged_buses: merged_buses.into_iter().collect(),
1281            groups,
1282            removed_branches,
1283            removed_switches,
1284            branch_rows,
1285            switch_rows,
1286            charging_shunts,
1287            diagnostics,
1288        })
1289    }
1290
1291    fn identity_merge(&self, rule: BusMergeRule, diagnostics: Vec<Diagnostic>) -> BusMerge {
1292        BusMerge {
1293            network: self.clone(),
1294            source: self.clone(),
1295            rule,
1296            merged_buses: BTreeMap::new(),
1297            groups: Vec::new(),
1298            removed_branches: Vec::new(),
1299            removed_switches: Vec::new(),
1300            branch_rows: (0..self.branches().len()).map(Some).collect(),
1301            switch_rows: (0..self.switches().len()).map(Some).collect(),
1302            charging_shunts: Vec::new(),
1303            diagnostics,
1304        }
1305    }
1306}
1307
1308/// The largest loop component [`BusMerge::calc_removed_flows`] splits with a
1309/// dense solve.
1310const MAX_DENSE_LOOP_BUSES: usize = 2000;
1311
1312/// One removed element inside a merged group, in the group's local indices.
1313#[derive(Clone, Copy)]
1314struct GroupEdge {
1315    from: usize,
1316    to: usize,
1317    /// Series reactance; zero for a switch.
1318    x: f64,
1319    slot: Slot,
1320}
1321
1322#[derive(Clone, Copy)]
1323enum Slot {
1324    Branch(usize),
1325    Switch(usize),
1326}
1327
1328impl BusMerge {
1329    /// Recover the active power on every removed element from a solution of
1330    /// the merged network.
1331    ///
1332    /// Each merged group is one bus of the merged solution. The power its
1333    /// members exchange through the removed elements follows from what sits
1334    /// at each member: the stated in-service loads (`p`), shunts (`g`, at
1335    /// 1 per unit voltage), storage (`ps`), and HVDC terminals (`-pf` at the
1336    /// from end, `pt` at the to end), the generator outputs, and the flows
1337    /// `flows` states for the kept branches and three winding transformer
1338    /// windings at that member. Whatever the merged solution injects at a
1339    /// group beyond those stated values, such as a slack generator's
1340    /// adjustment, enters at the survivor.
1341    ///
1342    /// Where the removed elements of a group form a tree, Kirchhoff's current
1343    /// law fixes every flow. A loop is split by the elements' reactances,
1344    /// which is the DC power flow of the unmerged elements. A zero reactance
1345    /// element (a switch, or an exact zero impedance branch) holds its two
1346    /// buses at one angle, so an element in parallel with it carries nothing.
1347    /// A loop made only of zero reactance
1348    /// elements has no physical split, so the minimum norm split is reported
1349    /// with a diagnostic. Out-of-service branches and open switches carry
1350    /// none.
1351    ///
1352    /// A group whose injections do not balance gets a diagnostic: a group
1353    /// with no reference bus, or any group when `flows` states the generator
1354    /// outputs, should balance, and an imbalance means the solution modeled
1355    /// a device differently.
1356    ///
1357    /// # Errors
1358    /// [`Error::MergedFlowShape`] when a slice in `flows` does not match the
1359    /// merged network's table, or when a group holds a three winding
1360    /// transformer winding and `flows` states no winding flows.
1361    #[allow(clippy::too_many_lines)] // one stanza per injection source, in order
1362    pub fn calc_removed_flows(&self, flows: &MergedFlows<'_>) -> Result<RemovedFlows> {
1363        let shape = |what: &'static str, expected: usize, got: usize| {
1364            if expected == got {
1365                Ok(())
1366            } else {
1367                Err(Error::MergedFlowShape {
1368                    what,
1369                    expected,
1370                    got,
1371                })
1372            }
1373        };
1374        let merged_branches = self.network.branches().len();
1375        shape("branch_p_from", merged_branches, flows.branch_p_from.len())?;
1376        shape("branch_p_to", merged_branches, flows.branch_p_to.len())?;
1377        let source = &self.source;
1378        if let Some(generator_p) = flows.generator_p {
1379            shape("generator_p", source.generators().len(), generator_p.len())?;
1380        }
1381        if !flows.transformer_3w_p.is_empty() {
1382            shape(
1383                "transformer_3w_p",
1384                source.transformers_3w().len(),
1385                flows.transformer_3w_p.len(),
1386            )?;
1387        }
1388
1389        let mut place: HashMap<BusId, (usize, usize)> = HashMap::new();
1390        for (group, members) in self.groups.iter().enumerate() {
1391            for (local, &bus) in members.members.iter().enumerate() {
1392                place.insert(bus, (group, local));
1393            }
1394        }
1395        let mut injection: Vec<Vec<f64>> = self
1396            .groups
1397            .iter()
1398            .map(|group| vec![0.0; group.members.len()])
1399            .collect();
1400        // Per group: the stated device injection, the power the merged
1401        // solution sends out through kept elements, and a magnitude scale
1402        // for the balance check.
1403        let mut device = vec![0.0; self.groups.len()];
1404        let mut leaving = vec![0.0; self.groups.len()];
1405        let mut scale = vec![0.0f64; self.groups.len()];
1406        let mut inject = |bus: BusId, value: f64, from_device: bool| {
1407            if let Some(&(group, local)) = place.get(&bus) {
1408                injection[group][local] += value;
1409                if from_device {
1410                    device[group] += value;
1411                } else {
1412                    leaving[group] -= value;
1413                }
1414                scale[group] += value.abs();
1415            }
1416        };
1417
1418        for (row, generator) in source.generators().iter().enumerate() {
1419            if generator.in_service {
1420                let p = flows.generator_p.map_or(generator.pg, |p| p[row]);
1421                inject(generator.bus, p, true);
1422            }
1423        }
1424        for load in source.loads().iter().filter(|load| load.in_service) {
1425            inject(load.bus, -load.p, true);
1426        }
1427        for shunt in source.shunts().iter().filter(|shunt| shunt.in_service) {
1428            inject(shunt.bus, -shunt.g, true);
1429        }
1430        for storage in source.storage().iter().filter(|storage| storage.in_service) {
1431            inject(storage.bus, -storage.ps, true);
1432        }
1433        for line in source.hvdc().iter().filter(|line| line.in_service) {
1434            inject(line.from, -line.pf, true);
1435            inject(line.to, line.pt, true);
1436        }
1437        for (row, merged_row) in self.branch_rows.iter().enumerate() {
1438            if let Some(merged_row) = *merged_row {
1439                let branch = &source.branches()[row];
1440                inject(branch.from, -flows.branch_p_from[merged_row], false);
1441                inject(branch.to, -flows.branch_p_to[merged_row], false);
1442            }
1443        }
1444        for (row, transformer) in source.transformers_3w().iter().enumerate() {
1445            if !transformer.in_service {
1446                continue;
1447            }
1448            for (winding, terminal) in transformer.windings.iter().enumerate() {
1449                if !place.contains_key(&terminal.bus) {
1450                    continue;
1451                }
1452                let Some(p) = flows.transformer_3w_p.get(row) else {
1453                    return Err(Error::MergedFlowShape {
1454                        what: "transformer_3w_p",
1455                        expected: source.transformers_3w().len(),
1456                        got: flows.transformer_3w_p.len(),
1457                    });
1458                };
1459                inject(terminal.bus, -p[winding], false);
1460            }
1461        }
1462
1463        let references: std::collections::HashSet<BusId> = source
1464            .buses()
1465            .iter()
1466            .filter(|bus| bus.kind == BusType::Ref)
1467            .map(|bus| bus.id)
1468            .collect();
1469        let mut diagnostics = Vec::new();
1470        for (index, group) in self.groups.iter().enumerate() {
1471            let residual = leaving[index] - device[index];
1472            let local = group
1473                .members
1474                .iter()
1475                .position(|&bus| bus == group.survivor)
1476                .unwrap_or_default();
1477            injection[index][local] += residual;
1478            let has_reference = group.members.iter().any(|bus| references.contains(bus));
1479            let tolerance = 1e-6 * scale[index].max(1.0);
1480            if residual.abs() > tolerance && (flows.generator_p.is_some() || !has_reference) {
1481                diagnostics.push(Diagnostic::of(
1482                    &codes::CANONICALIZE_MERGE_FLOW_RESIDUAL,
1483                    format!(
1484                        "the group merged into bus {} receives {residual} more from the merged \
1485                         solution than its stated devices inject; the difference was placed at \
1486                         bus {}, so its removed element flows carry it",
1487                        group.survivor, group.survivor
1488                    ),
1489                ));
1490            }
1491        }
1492
1493        let not_carried = RemovedFlow {
1494            p_from: 0.0,
1495            method: RemovedFlowMethod::OutOfService,
1496        };
1497        let mut branches = vec![not_carried; self.removed_branches.len()];
1498        let mut switches = vec![not_carried; self.removed_switches.len()];
1499        let mut edges: Vec<Vec<GroupEdge>> = vec![Vec::new(); self.groups.len()];
1500        let local = |bus: BusId| place[&bus];
1501        for (index, removed) in self.removed_branches.iter().enumerate() {
1502            if removed.in_service {
1503                let ((group, from), (_, to)) = (local(removed.from), local(removed.to));
1504                edges[group].push(GroupEdge {
1505                    from,
1506                    to,
1507                    x: removed.x,
1508                    slot: Slot::Branch(index),
1509                });
1510            }
1511        }
1512        for (index, removed) in self.removed_switches.iter().enumerate() {
1513            if removed.closed {
1514                let ((group, from), (_, to)) = (local(removed.from), local(removed.to));
1515                edges[group].push(GroupEdge {
1516                    from,
1517                    to,
1518                    x: 0.0,
1519                    slot: Slot::Switch(index),
1520                });
1521            }
1522        }
1523
1524        for (index, group) in self.groups.iter().enumerate() {
1525            if edges[index].is_empty() {
1526                continue;
1527            }
1528            let solved = split_group(group.members.len(), &edges[index], &injection[index]);
1529            for (edge, flow) in edges[index].iter().zip(&solved.flows) {
1530                match edge.slot {
1531                    Slot::Branch(row) => branches[row] = *flow,
1532                    Slot::Switch(row) => switches[row] = *flow,
1533                }
1534            }
1535            if solved.minimum_norm > 0 {
1536                diagnostics.push(Diagnostic::of(
1537                    &codes::CANONICALIZE_MERGE_FLOW_UNDETERMINED,
1538                    format!(
1539                        "{} removed element(s) in the group merged into bus {} lie on loops of \
1540                         zero reactance elements; no network quantity fixes their split, so the \
1541                         minimum norm split is reported",
1542                        solved.minimum_norm, group.survivor
1543                    ),
1544                ));
1545            }
1546            if solved.too_large > 0 {
1547                diagnostics.push(Diagnostic::of(
1548                    &codes::CANONICALIZE_MERGE_FLOW_UNDETERMINED,
1549                    format!(
1550                        "{} removed element(s) in the group merged into bus {} lie on a loop of \
1551                         more than {MAX_DENSE_LOOP_BUSES} buses, which is not split; their flows \
1552                         are NaN",
1553                        solved.too_large, group.survivor
1554                    ),
1555                ));
1556            }
1557        }
1558
1559        Ok(RemovedFlows {
1560            branches,
1561            switches,
1562            diagnostics,
1563        })
1564    }
1565}
1566
1567struct SplitGroup {
1568    flows: Vec<RemovedFlow>,
1569    minimum_norm: usize,
1570    too_large: usize,
1571}
1572
1573/// Split one group's member injections over its removed elements.
1574///
1575/// Zero reactance elements force their buses to one angle, so they are
1576/// collapsed first and the reactive elements are split over the collapsed
1577/// graph by their reactances. Each collapsed set then routes what remains
1578/// at its members over its zero reactance elements.
1579fn split_group(members: usize, edges: &[GroupEdge], injection: &[f64]) -> SplitGroup {
1580    let mut flows = vec![
1581        RemovedFlow {
1582            p_from: 0.0,
1583            method: RemovedFlowMethod::Reactance,
1584        };
1585        edges.len()
1586    ];
1587    let mut too_large = 0;
1588
1589    // Collapse the zero reactance elements.
1590    let mut sets = MergeSets::new(members, HashMap::new(), false);
1591    for edge in edges.iter().filter(|edge| edge.x == 0.0) {
1592        let _ = sets.join(edge.from, edge.to);
1593    }
1594    let mut collapsed_of = vec![usize::MAX; members];
1595    let mut collapsed = 0usize;
1596    for node in 0..members {
1597        let root = sets.root(node);
1598        if collapsed_of[root] == usize::MAX {
1599            collapsed_of[root] = collapsed;
1600            collapsed += 1;
1601        }
1602        collapsed_of[node] = collapsed_of[root];
1603    }
1604    let mut collapsed_injection = vec![0.0; collapsed];
1605    for (node, value) in injection.iter().enumerate() {
1606        collapsed_injection[collapsed_of[node]] += value;
1607    }
1608
1609    // Reactive elements between collapsed sets, by reactance. One inside a
1610    // set is shorted by a zero reactance path and carries nothing.
1611    let reactive: Vec<usize> = (0..edges.len())
1612        .filter(|&e| edges[e].x != 0.0 && collapsed_of[edges[e].from] != collapsed_of[edges[e].to])
1613        .collect();
1614    let mut remaining = injection.to_vec();
1615    if !reactive.is_empty() {
1616        let links: Vec<(usize, usize, f64)> = reactive
1617            .iter()
1618            .map(|&e| {
1619                (
1620                    collapsed_of[edges[e].from],
1621                    collapsed_of[edges[e].to],
1622                    edges[e].x,
1623                )
1624            })
1625            .collect();
1626        let solved = split_network(collapsed, &links, &collapsed_injection);
1627        for (&e, (flow, kind)) in reactive.iter().zip(solved) {
1628            flows[e] = RemovedFlow {
1629                p_from: flow,
1630                method: match kind {
1631                    Split::Tree => RemovedFlowMethod::Tree,
1632                    Split::Loop => RemovedFlowMethod::Reactance,
1633                    Split::TooLarge => {
1634                        too_large += 1;
1635                        RemovedFlowMethod::Reactance
1636                    }
1637                },
1638            };
1639            remaining[edges[e].from] -= flow;
1640            remaining[edges[e].to] += flow;
1641        }
1642    }
1643
1644    // Zero reactance elements within each collapsed set.
1645    let zero: Vec<usize> = (0..edges.len()).filter(|&e| edges[e].x == 0.0).collect();
1646    let mut minimum_norm = 0;
1647    if !zero.is_empty() {
1648        let links: Vec<(usize, usize, f64)> = zero
1649            .iter()
1650            .map(|&e| (edges[e].from, edges[e].to, 1.0))
1651            .collect();
1652        let solved = split_network(members, &links, &remaining);
1653        for (&e, (flow, kind)) in zero.iter().zip(solved) {
1654            flows[e] = RemovedFlow {
1655                p_from: flow,
1656                method: match kind {
1657                    Split::Tree => RemovedFlowMethod::Tree,
1658                    Split::Loop => {
1659                        minimum_norm += 1;
1660                        RemovedFlowMethod::MinimumNorm
1661                    }
1662                    Split::TooLarge => {
1663                        too_large += 1;
1664                        RemovedFlowMethod::MinimumNorm
1665                    }
1666                },
1667            };
1668        }
1669    }
1670    SplitGroup {
1671        flows,
1672        minimum_norm,
1673        too_large,
1674    }
1675}
1676
1677#[derive(Clone, Copy)]
1678enum Split {
1679    Tree,
1680    Loop,
1681    TooLarge,
1682}
1683
1684/// Flows `from → to` on `links` (each `(from, to, reactance)`) carrying the
1685/// node `injection`. Leaf elimination fixes every link outside a loop; each
1686/// meshed core left over is split by its reactance weighted Laplacian.
1687fn split_network(
1688    nodes: usize,
1689    links: &[(usize, usize, f64)],
1690    injection: &[f64],
1691) -> Vec<(f64, Split)> {
1692    let mut result = vec![(0.0, Split::Tree); links.len()];
1693    let mut net = injection.to_vec();
1694    let mut incident = vec![Vec::new(); nodes];
1695    for (index, &(from, to, _)) in links.iter().enumerate() {
1696        incident[from].push(index);
1697        incident[to].push(index);
1698    }
1699    let mut degree: Vec<usize> = incident.iter().map(Vec::len).collect();
1700    let mut alive = vec![true; links.len()];
1701    let mut leaves: Vec<usize> = (0..nodes).filter(|&node| degree[node] == 1).collect();
1702    while let Some(leaf) = leaves.pop() {
1703        let Some(&index) = incident[leaf].iter().find(|&&index| alive[index]) else {
1704            continue;
1705        };
1706        alive[index] = false;
1707        let (from, to, _) = links[index];
1708        let other = if from == leaf { to } else { from };
1709        let out = net[leaf];
1710        result[index] = (if from == leaf { out } else { -out }, Split::Tree);
1711        net[other] += out;
1712        net[leaf] = 0.0;
1713        degree[leaf] = 0;
1714        degree[other] -= 1;
1715        if degree[other] == 1 {
1716            leaves.push(other);
1717        }
1718    }
1719
1720    // What is left is a set of meshed cores, each split on its own.
1721    let mut sets = MergeSets::new(nodes, HashMap::new(), false);
1722    for (index, &(from, to, _)) in links.iter().enumerate() {
1723        if alive[index] {
1724            let _ = sets.join(from, to);
1725        }
1726    }
1727    let mut cores: BTreeMap<usize, (Vec<usize>, Vec<usize>)> = BTreeMap::new();
1728    for node in (0..nodes).filter(|&node| degree[node] > 0) {
1729        cores.entry(sets.root(node)).or_default().0.push(node);
1730    }
1731    for (index, &(from, _, _)) in links.iter().enumerate() {
1732        if alive[index] {
1733            cores.entry(sets.root(from)).or_default().1.push(index);
1734        }
1735    }
1736    for (members, link_indices) in cores.values() {
1737        if members.len() > MAX_DENSE_LOOP_BUSES {
1738            for &index in link_indices {
1739                result[index] = (f64::NAN, Split::TooLarge);
1740            }
1741            continue;
1742        }
1743        let solved = solve_loop(members, links, link_indices, &net).unwrap_or_else(|| {
1744            // A singular reactance Laplacian (reactances of opposite sign
1745            // cancelling around a loop): split by minimum norm.
1746            let unit: Vec<(usize, usize, f64)> =
1747                links.iter().map(|&(from, to, _)| (from, to, 1.0)).collect();
1748            solve_loop(members, &unit, link_indices, &net)
1749                .expect("a unit weight Laplacian of a connected graph is nonsingular")
1750        });
1751        for (index, flow) in solved {
1752            result[index] = (flow, Split::Loop);
1753        }
1754    }
1755    result
1756}
1757
1758/// Solve the weighted Laplacian of a meshed component, grounded at its first
1759/// member, and return each link's flow. `None` when the system is singular.
1760fn solve_loop(
1761    members: &[usize],
1762    links: &[(usize, usize, f64)],
1763    link_indices: &[usize],
1764    injection: &[f64],
1765) -> Option<Vec<(usize, f64)>> {
1766    let position: HashMap<usize, usize> = members
1767        .iter()
1768        .enumerate()
1769        .map(|(position, &node)| (node, position))
1770        .collect();
1771    let size = members.len() - 1;
1772    let mut matrix = vec![0.0; size * size];
1773    let mut rhs: Vec<f64> = members[1..].iter().map(|&node| injection[node]).collect();
1774    for &index in link_indices {
1775        let (from, to, x) = links[index];
1776        let conductance = 1.0 / x;
1777        let (i, j) = (position[&from], position[&to]);
1778        for (a, b, value) in [
1779            (i, i, conductance),
1780            (j, j, conductance),
1781            (i, j, -conductance),
1782            (j, i, -conductance),
1783        ] {
1784            if a > 0 && b > 0 {
1785                matrix[(a - 1) * size + (b - 1)] += value;
1786            }
1787        }
1788    }
1789    let angle = solve_dense(&mut matrix, &mut rhs, size)?;
1790    let theta = |node: usize| match position[&node] {
1791        0 => 0.0,
1792        p => angle[p - 1],
1793    };
1794    Some(
1795        link_indices
1796            .iter()
1797            .map(|&index| {
1798                let (from, to, x) = links[index];
1799                (index, (theta(from) - theta(to)) / x)
1800            })
1801            .collect(),
1802    )
1803}
1804
1805/// Gaussian elimination with partial pivoting on a dense row major system.
1806fn solve_dense(matrix: &mut [f64], rhs: &mut [f64], size: usize) -> Option<Vec<f64>> {
1807    let largest = matrix.iter().fold(0.0f64, |m, v| m.max(v.abs()));
1808    let floor = largest * 1e-13;
1809    for column in 0..size {
1810        let pivot = (column..size).max_by(|&a, &b| {
1811            matrix[a * size + column]
1812                .abs()
1813                .total_cmp(&matrix[b * size + column].abs())
1814        })?;
1815        if matrix[pivot * size + column].abs() <= floor
1816            || !matrix[pivot * size + column].is_finite()
1817        {
1818            return None;
1819        }
1820        if pivot != column {
1821            for k in 0..size {
1822                matrix.swap(pivot * size + k, column * size + k);
1823            }
1824            rhs.swap(pivot, column);
1825        }
1826        let diagonal = matrix[column * size + column];
1827        for row in column + 1..size {
1828            let factor = matrix[row * size + column] / diagonal;
1829            if factor == 0.0 {
1830                continue;
1831            }
1832            for k in column..size {
1833                matrix[row * size + k] -= factor * matrix[column * size + k];
1834            }
1835            rhs[row] -= factor * rhs[column];
1836        }
1837    }
1838    let mut solution = vec![0.0; size];
1839    for row in (0..size).rev() {
1840        let mut value = rhs[row];
1841        for k in row + 1..size {
1842            value -= matrix[row * size + k] * solution[k];
1843        }
1844        solution[row] = value / matrix[row * size + row];
1845    }
1846    Some(solution)
1847}
1848
1849#[cfg(test)]
1850#[allow(clippy::float_cmp)] // the recovered flows are exact in these small cases
1851mod tests {
1852    use super::*;
1853    use crate::network::{
1854        Area, Branch, Bus, Generator, Hvdc, Impedance, Load, Shunt, SolverParams, Switch,
1855        SwitchedShuntControl, SwitchedShuntMode, Transformer3W, TransformerControl,
1856        TransformerControlMode, Winding,
1857    };
1858
1859    const PSSE: BusMergeRule =
1860        BusMergeRule::new(true, Some(ZeroImpedanceRule::PsseThreshold(1e-4)));
1861
1862    fn net(ids: &[usize], branches: Vec<Branch>) -> BalancedNetwork {
1863        let buses = ids
1864            .iter()
1865            .map(|&id| Bus::new(BusId(id), BusType::Pq, 230.0))
1866            .collect();
1867        BalancedNetwork::in_memory("merge", 100.0, buses, branches)
1868    }
1869
1870    fn line(from: usize, to: usize, r: f64, x: f64) -> Branch {
1871        Branch::new(BusId(from), BusId(to), r, x)
1872    }
1873
1874    fn jumper(from: usize, to: usize) -> Branch {
1875        line(from, to, 0.0, 5e-5)
1876    }
1877
1878    fn transformer_3w(buses: [usize; 3]) -> Transformer3W {
1879        Transformer3W::new(
1880            buses.map(|bus| Winding::new(BusId(bus))),
1881            [Impedance::new(0.0, 0.1, 100.0); 3],
1882        )
1883    }
1884
1885    fn codes_of(diagnostics: &[Diagnostic]) -> Vec<&str> {
1886        diagnostics.iter().map(Diagnostic::code).collect()
1887    }
1888
1889    #[test]
1890    fn a_ring_of_closed_switches_merges_and_an_open_switch_stays() {
1891        let mut network = net(&[1, 2, 3, 4, 5, 6], vec![line(5, 6, 0.01, 0.1)]);
1892        network.switches_mut().extend([
1893            Switch::new(BusId(1), BusId(2), true),
1894            Switch::new(BusId(2), BusId(3), true),
1895            Switch::new(BusId(3), BusId(4), true),
1896            Switch::new(BusId(4), BusId(1), true),
1897            Switch::new(BusId(4), BusId(5), false),
1898        ]);
1899        let merge = network
1900            .merge_buses(&BusMergeRule::closed_switches())
1901            .unwrap();
1902
1903        assert_eq!(
1904            merge.groups,
1905            vec![BusGroup {
1906                survivor: BusId(1),
1907                members: vec![BusId(1), BusId(2), BusId(3), BusId(4)],
1908            }]
1909        );
1910        assert_eq!(merge.survivor(BusId(4)), BusId(1));
1911        assert_eq!(merge.survivor(BusId(5)), BusId(5));
1912        assert_eq!(merge.removed_switches.len(), 4);
1913        assert!(
1914            merge
1915                .removed_switches
1916                .iter()
1917                .all(|removed| removed.reason == RemovalReason::ClosedSwitch)
1918        );
1919        assert_eq!(
1920            merge.switch_rows,
1921            vec![None, None, None, None, Some(0)],
1922            "the open switch keeps its identity at merged row 0"
1923        );
1924        let open = &merge.network.switches()[0];
1925        assert_eq!(
1926            (open.from, open.to, open.closed),
1927            (BusId(1), BusId(5), false)
1928        );
1929        assert_eq!(merge.network.buses().len(), 3);
1930        assert!(codes_of(&merge.diagnostics).contains(&"CANONICALIZE.MERGE.CLOSED_SWITCH"));
1931        merge.network.validate().unwrap();
1932    }
1933
1934    #[test]
1935    fn the_psse_threshold_merges_a_chain_of_jumpers() {
1936        let network = net(
1937            &[1, 2, 3, 4, 5],
1938            vec![
1939                jumper(1, 2),
1940                jumper(2, 3),
1941                jumper(3, 4),
1942                line(4, 5, 0.01, 0.1),
1943            ],
1944        );
1945        let merge = network.merge_buses(&PSSE).unwrap();
1946        assert_eq!(merge.groups.len(), 1);
1947        assert_eq!(merge.groups[0].members.len(), 4);
1948        assert_eq!(merge.removed_branches.len(), 3);
1949        assert_eq!(merge.branch_rows, vec![None, None, None, Some(0)]);
1950        let kept = &merge.network.branches()[0];
1951        assert_eq!((kept.from, kept.to), (BusId(1), BusId(5)));
1952        let summary = merge
1953            .diagnostics
1954            .iter()
1955            .find(|d| d.code() == "CANONICALIZE.MERGE.ZERO_IMPEDANCE")
1956            .unwrap();
1957        assert_eq!(summary.details()["count"], 3);
1958    }
1959
1960    #[test]
1961    fn a_merged_jumper_s_charging_becomes_a_shunt_at_the_survivor() {
1962        let mut charged = jumper(2, 3);
1963        charged.b = 0.02;
1964        let network = net(&[1, 2, 3], vec![line(1, 2, 0.01, 0.1), charged]);
1965        let merge = network.merge_buses(&PSSE).unwrap();
1966        assert_eq!(merge.charging_shunts, [0]);
1967        let shunt = &merge.network.shunts()[0];
1968        assert_eq!(shunt.bus, BusId(2));
1969        assert!(
1970            (shunt.b - 2.0).abs() < 1e-12,
1971            "0.02 pu on 100 MVA is 2 MVAr"
1972        );
1973        assert_eq!(shunt.g, 0.0);
1974        assert!(
1975            shunt
1976                .uid
1977                .as_deref()
1978                .is_some_and(|uid| uid.starts_with("merged-charging:"))
1979        );
1980        merge.network.validate().unwrap();
1981
1982        let dropped = network
1983            .merge_buses(&PSSE.with_charging(MergedCharging::Drop))
1984            .unwrap();
1985        assert_eq!(dropped.charging_shunts, Vec::<usize>::new());
1986        assert_eq!(dropped.network.shunts().as_slice(), []);
1987        let summary = dropped
1988            .diagnostics
1989            .iter()
1990            .find(|d| d.code() == "CANONICALIZE.MERGE.ZERO_IMPEDANCE")
1991            .unwrap();
1992        assert!(summary.message().contains("dropped"));
1993        assert_eq!(summary.details()["charged"], 1);
1994    }
1995
1996    #[test]
1997    fn the_psse_threshold_skips_resistive_jumpers_and_transformers() {
1998        let mut transformer = jumper(3, 4);
1999        transformer.tap = 1.0;
2000        let network = net(
2001            &[1, 2, 3, 4],
2002            vec![line(1, 2, 1e-5, 5e-5), line(2, 3, 0.01, 0.1), transformer],
2003        );
2004        let merge = network.merge_buses(&PSSE).unwrap();
2005        assert!(
2006            merge.is_identity(),
2007            "R > 0 and transformers are not PSS/E jumpers"
2008        );
2009
2010        let wider = BusMergeRule::new(false, Some(ZeroImpedanceRule::ImpedanceMagnitude(1e-3)));
2011        let merge = network.merge_buses(&wider).unwrap();
2012        assert_eq!(merge.merged_buses, BTreeMap::from([(BusId(2), BusId(1))]));
2013        assert_eq!(merge.removed_branches.len(), 1, "the transformer stays");
2014    }
2015
2016    #[test]
2017    fn a_zero_threshold_disables_the_psse_rule() {
2018        let network = net(&[1, 2], vec![line(1, 2, 0.0, 0.0)]);
2019        let off = BusMergeRule::new(false, Some(ZeroImpedanceRule::PsseThreshold(0.0)));
2020        assert!(network.merge_buses(&off).unwrap().is_identity());
2021        let exact = network.merge_buses(&BusMergeRule::exact()).unwrap();
2022        assert_eq!(exact.removed_branches.len(), 1);
2023    }
2024
2025    #[test]
2026    fn the_exact_rule_keeps_an_off_nominal_ideal_transformer() {
2027        let mut ideal = line(1, 2, 0.0, 0.0);
2028        ideal.tap = 1.05;
2029        let mut nominal = line(2, 3, 0.0, 0.0);
2030        nominal.tap = 1.0;
2031        let network = net(&[1, 2, 3], vec![ideal, nominal]);
2032        let merge = network.merge_buses(&BusMergeRule::exact()).unwrap();
2033        assert_eq!(merge.merged_buses, BTreeMap::from([(BusId(3), BusId(2))]));
2034    }
2035
2036    #[test]
2037    fn the_survivor_is_the_reference_then_a_generator_then_a_regulated_bus() {
2038        // Bus 1 is plain, 2 is regulated by the generator on 5, 3 hosts a
2039        // generator, 4 is the reference.
2040        let chain = |ids: &[usize]| {
2041            let mut network = net(ids, ids.windows(2).map(|w| jumper(w[0], w[1])).collect());
2042            network.generators_mut().push(Generator::new(BusId(5)));
2043            network.generators_mut()[0].regulated_bus = Some(BusId(2));
2044            network
2045        };
2046        let mut network = chain(&[1, 2, 3, 4, 5]);
2047        network.generators_mut().push(Generator::new(BusId(3)));
2048        network.buses_mut()[3].kind = BusType::Ref;
2049        let merge = network.merge_buses(&PSSE).unwrap();
2050        assert_eq!(merge.groups[0].survivor, BusId(4));
2051        let survivor = &merge.network.buses()[0];
2052        assert_eq!((survivor.id, survivor.kind), (BusId(4), BusType::Ref));
2053
2054        let mut network = chain(&[1, 2, 3, 6]);
2055        network.generators_mut()[0].bus = BusId(6);
2056        network.generators_mut().push(Generator::new(BusId(3)));
2057        network.generators_mut()[1].in_service = true;
2058        let merge = network.merge_buses(&PSSE).unwrap();
2059        assert_eq!(
2060            merge.groups[0].survivor,
2061            BusId(3),
2062            "generator over lower id"
2063        );
2064
2065        let mut network = chain(&[1, 2, 3, 5]);
2066        network.generators_mut()[0].in_service = true;
2067        network.generators_mut()[0].bus = BusId(7);
2068        network
2069            .buses_mut()
2070            .push(Bus::new(BusId(7), BusType::Pv, 230.0));
2071        let merge = network.merge_buses(&PSSE).unwrap();
2072        assert_eq!(
2073            merge.groups[0].survivor,
2074            BusId(2),
2075            "regulated over lower id"
2076        );
2077
2078        let network = net(&[3, 1, 2], vec![jumper(3, 1), jumper(1, 2)]);
2079        let merge = network.merge_buses(&PSSE).unwrap();
2080        assert_eq!(merge.groups[0].survivor, BusId(1), "lowest id");
2081    }
2082
2083    #[test]
2084    fn the_survivor_rule_can_keep_the_smallest_id_or_the_from_bus() {
2085        // Jumpers 3-1 and 4-3 in that order; bus 4 is the reference and bus 3
2086        // hosts a generator.
2087        let mut network = net(&[1, 3, 4], vec![jumper(3, 1), jumper(4, 3)]);
2088        network.buses_mut()[2].kind = BusType::Ref;
2089        network.generators_mut().push(Generator::new(BusId(3)));
2090        let survivor = |rule: MergeSurvivor| {
2091            let merge = network.merge_buses(&PSSE.with_survivor(rule)).unwrap();
2092            let bus = &merge.network.buses()[0];
2093            assert_eq!(merge.network.buses().len(), 1);
2094            assert_eq!(bus.kind, BusType::Ref, "the strongest kind survives");
2095            assert_eq!(merge.network.generators()[0].bus, bus.id);
2096            merge.groups[0].survivor
2097        };
2098        assert_eq!(survivor(MergeSurvivor::Strongest), BusId(4));
2099        assert_eq!(survivor(MergeSurvivor::SmallestId), BusId(1));
2100        // 1 joins 3, then 3's group joins 4.
2101        assert_eq!(survivor(MergeSurvivor::FromBus), BusId(4));
2102
2103        let network = net(&[1, 2, 3], vec![jumper(2, 1), jumper(2, 3)]);
2104        let merge = network
2105            .merge_buses(&PSSE.with_survivor(MergeSurvivor::FromBus))
2106            .unwrap();
2107        assert_eq!(merge.groups[0].survivor, BusId(2));
2108        assert_eq!(merge.survivor(BusId(1)), BusId(2));
2109        assert_eq!(merge.survivor(BusId(3)), BusId(2));
2110    }
2111
2112    #[test]
2113    fn every_reference_follows_the_survivor() {
2114        let mut network = net(
2115            &[1, 2, 3, 4, 5, 6],
2116            vec![jumper(1, 2), line(2, 3, 0.01, 0.1), line(3, 4, 0.01, 0.1)],
2117        );
2118        network.buses_mut()[0].kind = BusType::Ref;
2119        let mut regulating = Generator::new(BusId(3));
2120        regulating.regulated_bus = Some(BusId(2));
2121        network.generators_mut().push(regulating);
2122        network.loads_mut().push(Load::new(BusId(2), 10.0, 1.0));
2123        let mut shunt = Shunt::new(BusId(3), 0.0, 10.0);
2124        let mut control =
2125            SwitchedShuntControl::new(SwitchedShuntMode::Discrete, 1.05, 0.95, vec![]);
2126        control.control_bus = Some(BusId(2));
2127        shunt.control = Some(control);
2128        network.shunts_mut().push(shunt);
2129        let mut tap_control = TransformerControl::new(TransformerControlMode::Voltage);
2130        tap_control.controlled_bus = Some(BusId(2));
2131        network.branches_mut()[2].tap = 1.0;
2132        network.branches_mut()[2].control = Some(tap_control);
2133        network.hvdc_mut().push(Hvdc::new(BusId(2), BusId(5)));
2134        network
2135            .transformers_3w_mut()
2136            .push(transformer_3w([2, 5, 6]));
2137        let mut area = Area::new(1);
2138        area.slack_bus = Some(BusId(2));
2139        network.areas_mut().push(area);
2140        network
2141            .static_var_compensators_mut()
2142            .push(crate::network::StaticVarCompensator::new(
2143                BusId(2),
2144                -0.01,
2145                0.01,
2146            ));
2147        network
2148            .storage_mut()
2149            .push(crate::network::Storage::new(BusId(2)));
2150        network
2151            .switches_mut()
2152            .push(Switch::new(BusId(2), BusId(4), false));
2153
2154        let merge = network.merge_buses(&PSSE).unwrap();
2155        let merged = &merge.network;
2156        assert_eq!(merge.merged_buses, BTreeMap::from([(BusId(2), BusId(1))]));
2157        assert_eq!(merged.generators()[0].regulated_bus, Some(BusId(1)));
2158        assert_eq!(merged.loads()[0].bus, BusId(1));
2159        let control = merged.shunts()[0].control.as_ref().unwrap();
2160        assert_eq!(control.control_bus, Some(BusId(1)));
2161        let tap = merged.branches()[1].control.as_ref().unwrap();
2162        assert_eq!(tap.controlled_bus, Some(BusId(1)));
2163        assert_eq!(merged.branches()[0].from, BusId(1));
2164        assert_eq!(merged.hvdc()[0].from, BusId(1));
2165        assert_eq!(merged.transformers_3w()[0].windings[0].bus, BusId(1));
2166        assert_eq!(merged.areas()[0].slack_bus, Some(BusId(1)));
2167        assert_eq!(merged.static_var_compensators()[0].bus, BusId(1));
2168        assert_eq!(merged.storage()[0].bus, BusId(1));
2169        assert_eq!(merged.switches()[0].from, BusId(1));
2170        merged.validate().unwrap();
2171        // The input is untouched.
2172        assert_eq!(network.loads()[0].bus, BusId(2));
2173    }
2174
2175    #[test]
2176    fn a_jumper_across_two_windings_of_one_transformer_is_kept() {
2177        let mut network = net(
2178            &[1, 2, 3, 4],
2179            vec![jumper(1, 2), jumper(3, 4), jumper(2, 4)],
2180        );
2181        // Windings on 1 and 3: the 1-2 and 3-4 jumpers merge, the 2-4 jumper
2182        // would then join both windings.
2183        network
2184            .transformers_3w_mut()
2185            .push(transformer_3w([1, 3, 4]));
2186        let merge = network.merge_buses(&PSSE).unwrap();
2187        assert_eq!(
2188            merge.groups.len(),
2189            1,
2190            "only 1-2 merges; 3-4 joins two windings"
2191        );
2192        assert_eq!(merge.network.branches().len(), 2);
2193        let kept: Vec<&Diagnostic> = merge
2194            .diagnostics
2195            .iter()
2196            .filter(|d| d.code() == "CANONICALIZE.MERGE.WINDING_PAIR_KEPT")
2197            .collect();
2198        assert_eq!(kept.len(), 2, "{:#?}", merge.diagnostics);
2199        merge.network.validate().unwrap();
2200    }
2201
2202    #[test]
2203    fn merging_the_result_again_joins_nothing() {
2204        let mut network = net(
2205            &[1, 2, 3, 4, 5],
2206            vec![
2207                jumper(1, 2),
2208                jumper(2, 3),
2209                line(3, 4, 0.01, 0.1),
2210                jumper(4, 5),
2211            ],
2212        );
2213        network
2214            .switches_mut()
2215            .push(Switch::new(BusId(5), BusId(1), true));
2216        network
2217            .transformers_3w_mut()
2218            .push(transformer_3w([1, 3, 5]));
2219        let once = network.merge_buses(&PSSE).unwrap();
2220        assert!(!once.is_identity());
2221        let twice = once.network.merge_buses(&PSSE).unwrap();
2222        assert!(twice.is_identity());
2223        assert_eq!(twice.network.buses(), once.network.buses());
2224    }
2225
2226    #[test]
2227    fn the_psse_rule_reads_the_stated_threshold() {
2228        let mut network = net(&[1, 2], vec![line(1, 2, 0.0, 3e-4)]);
2229        let error = BusMergeRule::psse(&network).unwrap_err();
2230        assert_eq!(error.code().code, "CANONICALIZE.MERGE.INVALID_RULE");
2231
2232        let mut solver = SolverParams::new();
2233        solver.zero_impedance_threshold = Some(5e-4);
2234        *network.solver_mut() = Some(solver);
2235        let rule = BusMergeRule::psse(&network).unwrap();
2236        assert_eq!(
2237            rule,
2238            BusMergeRule::new(true, Some(ZeroImpedanceRule::PsseThreshold(5e-4)))
2239        );
2240        assert_eq!(
2241            network.merge_buses(&rule).unwrap().removed_branches.len(),
2242            1
2243        );
2244
2245        for threshold in [-1e-4, f64::NAN, f64::INFINITY] {
2246            let rule = BusMergeRule::new(false, Some(ZeroImpedanceRule::PsseThreshold(threshold)));
2247            assert!(network.merge_buses(&rule).is_err(), "{threshold}");
2248        }
2249    }
2250
2251    #[test]
2252    fn differing_base_voltages_and_two_references_are_reported() {
2253        let mut network = net(&[1, 2, 3], vec![jumper(1, 2), jumper(2, 3)]);
2254        network.buses_mut()[1].base_kv = 34.5;
2255        network.buses_mut()[0].kind = BusType::Ref;
2256        network.buses_mut()[2].kind = BusType::Ref;
2257        let merge = network.merge_buses(&PSSE).unwrap();
2258        let conflicts: Vec<&str> = merge
2259            .diagnostics
2260            .iter()
2261            .filter(|d| d.code() == "CANONICALIZE.MERGE.ATTRIBUTE_CONFLICT")
2262            .map(Diagnostic::message)
2263            .collect();
2264        assert_eq!(conflicts.len(), 2, "{conflicts:?}");
2265        assert!(conflicts[0].contains("34.5 kV"));
2266        assert!(conflicts[1].contains("reference buses 1, 3"));
2267        assert_eq!(merge.network.buses()[0].kind, BusType::Ref);
2268    }
2269
2270    fn conflicts(merge: &BusMerge) -> Vec<&str> {
2271        merge
2272            .diagnostics
2273            .iter()
2274            .filter(|d| d.code() == "CANONICALIZE.MERGE.ATTRIBUTE_CONFLICT")
2275            .map(Diagnostic::message)
2276            .collect()
2277    }
2278
2279    #[test]
2280    fn a_merge_intersects_differing_voltage_limits_and_reports_them() {
2281        let mut network = net(
2282            &[1, 2, 3, 4],
2283            vec![jumper(1, 2), jumper(2, 3), line(3, 4, 0.0, 0.1)],
2284        );
2285        network.buses_mut()[1].vmin = 0.95;
2286        network.buses_mut()[2].vmax = 1.05;
2287        network.buses_mut()[2].vmin = 0.93;
2288        let merge = network.merge_buses(&PSSE).unwrap();
2289        let survivor = &merge.network.buses()[0];
2290        assert_eq!(survivor.id, BusId(1));
2291        assert_eq!((survivor.vmin, survivor.vmax), (0.95, 1.05));
2292        let conflicts = conflicts(&merge);
2293        assert_eq!(conflicts.len(), 1, "{conflicts:?}");
2294        assert!(
2295            conflicts[0].contains("intersection [0.95, 1.05]"),
2296            "{}",
2297            conflicts[0]
2298        );
2299        assert!(conflicts[0].contains("bus(es) 1, 2, 3"), "{}", conflicts[0]);
2300        // The bus outside the group keeps its own limits.
2301        assert_eq!(merge.network.buses()[1].vmin, 0.9);
2302    }
2303
2304    #[test]
2305    fn equal_voltage_limits_and_angles_merge_without_a_conflict() {
2306        let mut network = net(&[1, 2, 3], vec![jumper(1, 2), jumper(2, 3)]);
2307        for bus in network.buses_mut() {
2308            (bus.vmin, bus.vmax, bus.va) = (0.94, 1.06, -3.0);
2309        }
2310        // Within the tolerance counts as equal.
2311        network.buses_mut()[2].vmax = 1.06 + 1e-12;
2312        network.buses_mut()[1].kind = BusType::Ref;
2313        let merge = network
2314            .merge_buses(&PSSE.with_survivor(MergeSurvivor::SmallestId))
2315            .unwrap();
2316        assert_eq!(conflicts(&merge), Vec::<&str>::new());
2317        let survivor = &merge.network.buses()[0];
2318        assert_eq!((survivor.vmin, survivor.vmax), (0.94, 1.06));
2319        assert_eq!(survivor.kind, BusType::Ref);
2320    }
2321
2322    #[test]
2323    fn disjoint_voltage_limits_keep_the_survivor_s_own() {
2324        let mut network = net(&[1, 2], vec![jumper(1, 2)]);
2325        (network.buses_mut()[1].vmin, network.buses_mut()[1].vmax) = (1.15, 1.2);
2326        let merge = network.merge_buses(&PSSE).unwrap();
2327        let survivor = &merge.network.buses()[0];
2328        assert_eq!((survivor.vmin, survivor.vmax), (0.9, 1.1));
2329        let conflicts = conflicts(&merge);
2330        assert_eq!(conflicts.len(), 1, "{conflicts:?}");
2331        assert!(conflicts[0].contains("do not overlap"), "{}", conflicts[0]);
2332    }
2333
2334    #[test]
2335    fn a_survivor_taking_over_a_reference_takes_its_angle() {
2336        let mut network = net(&[1, 2, 3], vec![jumper(1, 2), jumper(2, 3)]);
2337        network.buses_mut()[0].va = 4.0;
2338        network.buses_mut()[1].va = -7.5;
2339        network.buses_mut()[1].kind = BusType::Ref;
2340        let merge = network
2341            .merge_buses(&PSSE.with_survivor(MergeSurvivor::SmallestId))
2342            .unwrap();
2343        let survivor = &merge.network.buses()[0];
2344        assert_eq!(survivor.id, BusId(1));
2345        assert_eq!(survivor.kind, BusType::Ref);
2346        assert_eq!(survivor.va, -7.5);
2347        let conflicts = conflicts(&merge);
2348        assert_eq!(conflicts.len(), 1, "{conflicts:?}");
2349        assert!(
2350            conflicts[0].contains("bus 1 4 deg, bus 2 -7.5 deg"),
2351            "{}",
2352            conflicts[0]
2353        );
2354        assert!(conflicts[0].contains("reference bus 2"), "{}", conflicts[0]);
2355    }
2356
2357    #[test]
2358    fn a_line_in_parallel_with_a_jumper_is_shorted_and_an_hvdc_line_stays() {
2359        let mut network = net(&[1, 2, 3], vec![jumper(1, 2), line(1, 2, 0.01, 0.1)]);
2360        network.hvdc_mut().push(Hvdc::new(BusId(1), BusId(2)));
2361        network
2362            .switches_mut()
2363            .push(Switch::new(BusId(2), BusId(1), false));
2364        let merge = network.merge_buses(&PSSE).unwrap();
2365        let reasons: Vec<RemovalReason> = merge
2366            .removed_branches
2367            .iter()
2368            .map(|removed| removed.reason)
2369            .collect();
2370        assert_eq!(
2371            reasons,
2372            [RemovalReason::ZeroImpedance, RemovalReason::Shorted]
2373        );
2374        assert_eq!(merge.removed_switches[0].reason, RemovalReason::Shorted);
2375        assert_eq!(merge.network.hvdc().len(), 1);
2376        let shorted = codes_of(&merge.diagnostics)
2377            .into_iter()
2378            .filter(|code| *code == "CANONICALIZE.MERGE.ELEMENT_SHORTED")
2379            .count();
2380        assert_eq!(shorted, 3);
2381    }
2382
2383    #[test]
2384    fn detailed_connectivity_follows_the_survivor() {
2385        use crate::network::{DetailedConnectivity, TopologyKind, VoltageLevel};
2386        let mut network = net(&[1, 2, 3], vec![jumper(1, 2), line(2, 3, 0.01, 0.1)]);
2387        let level = |name: &str, buses: Vec<BusId>| VoltageLevel {
2388            component: powerio_core::ComponentId::new("voltage_level", name).unwrap(),
2389            substation: None,
2390            nominal_kv: 230.0,
2391            low_voltage_limit_kv: None,
2392            high_voltage_limit_kv: None,
2393            topology_kind: TopologyKind::BusBreaker,
2394            buses,
2395        };
2396        let detailed = DetailedConnectivity {
2397            voltage_levels: vec![
2398                level("A", vec![BusId(1), BusId(2)]),
2399                level("B", vec![BusId(3)]),
2400            ],
2401            ..DetailedConnectivity::default()
2402        };
2403        *network.detailed_connectivity_mut() = Some(std::sync::Arc::new(detailed));
2404        network.validate().unwrap();
2405
2406        let merge = network.merge_buses(&PSSE).unwrap();
2407        let levels = &merge
2408            .network
2409            .detailed_connectivity()
2410            .as_deref()
2411            .unwrap()
2412            .voltage_levels;
2413        assert_eq!(levels[0].buses, vec![BusId(1)]);
2414        assert!(!codes_of(&merge.diagnostics).contains(&"CANONICALIZE.MERGE.DETAIL_DROPPED"));
2415
2416        // A jumper across the two levels leaves one bus in both, so the
2417        // calculated bus assignments go.
2418        network.branches_mut()[1] = jumper(2, 3);
2419        let merge = network.merge_buses(&PSSE).unwrap();
2420        let detailed = merge.network.detailed_connectivity().as_deref().unwrap();
2421        assert!(
2422            detailed
2423                .voltage_levels
2424                .iter()
2425                .all(|level| level.buses.is_empty())
2426        );
2427        assert!(codes_of(&merge.diagnostics).contains(&"CANONICALIZE.MERGE.DETAIL_DROPPED"));
2428        merge.network.validate().unwrap();
2429    }
2430
2431    /// Bus 1 feeds a group {2, 3, 4} through a 1-2 line; loads sit on 3
2432    /// and 4.
2433    fn fed_group(extra: Vec<Branch>) -> BalancedNetwork {
2434        let mut branches = vec![line(1, 2, 0.01, 0.1)];
2435        branches.extend(extra);
2436        let mut network = net(&[1, 2, 3, 4], branches);
2437        network.buses_mut()[0].kind = BusType::Ref;
2438        network.generators_mut().push(Generator::new(BusId(1)));
2439        network.loads_mut().push(Load::new(BusId(3), 30.0, 0.0));
2440        network.loads_mut().push(Load::new(BusId(4), 50.0, 0.0));
2441        network
2442    }
2443
2444    #[test]
2445    fn removed_flows_follow_kirchhoff_on_a_tree() {
2446        let network = fed_group(vec![jumper(2, 3), jumper(3, 4)]);
2447        let merge = network.merge_buses(&PSSE).unwrap();
2448        // The merged 1-2 line carries the group's 80 MW.
2449        let flows = merge
2450            .calc_removed_flows(&MergedFlows::new(&[80.0], &[-80.0]))
2451            .unwrap();
2452        let p: Vec<f64> = flows.branches.iter().map(|flow| flow.p_from).collect();
2453        assert_eq!(p, [80.0, 50.0]);
2454        assert!(
2455            flows
2456                .branches
2457                .iter()
2458                .all(|flow| flow.method == RemovedFlowMethod::Tree)
2459        );
2460        assert!(flows.diagnostics.is_empty(), "{:#?}", flows.diagnostics);
2461    }
2462
2463    #[test]
2464    fn removed_flows_split_a_loop_by_reactance() {
2465        // Two parallel jumpers 2-3 (x 1e-5 and 3e-5) and a jumper 3-4.
2466        let network = fed_group(vec![
2467            line(2, 3, 0.0, 1e-5),
2468            line(3, 2, 0.0, 3e-5),
2469            jumper(3, 4),
2470        ]);
2471        let merge = network.merge_buses(&PSSE).unwrap();
2472        let flows = merge
2473            .calc_removed_flows(&MergedFlows::new(&[80.0], &[-80.0]))
2474            .unwrap();
2475        let p: Vec<f64> = flows.branches.iter().map(|flow| flow.p_from).collect();
2476        assert!((p[0] - 60.0).abs() < 1e-9, "{p:?}");
2477        assert!(
2478            (p[1] + 20.0).abs() < 1e-9,
2479            "the reversed jumper reads negative: {p:?}"
2480        );
2481        assert!((p[2] - 50.0).abs() < 1e-9, "{p:?}");
2482        assert_eq!(flows.branches[0].method, RemovedFlowMethod::Reactance);
2483        assert_eq!(flows.branches[2].method, RemovedFlowMethod::Tree);
2484    }
2485
2486    #[test]
2487    fn a_loop_of_switches_is_split_by_minimum_norm() {
2488        let mut network = fed_group(vec![jumper(3, 4)]);
2489        network.switches_mut().extend([
2490            Switch::new(BusId(2), BusId(3), true),
2491            Switch::new(BusId(2), BusId(3), true),
2492        ]);
2493        let merge = network.merge_buses(&PSSE).unwrap();
2494        let flows = merge
2495            .calc_removed_flows(&MergedFlows::new(&[80.0], &[-80.0]))
2496            .unwrap();
2497        assert_eq!(flows.branches[0].p_from, 50.0);
2498        for flow in &flows.switches {
2499            assert!((flow.p_from - 40.0).abs() < 1e-9);
2500            assert_eq!(flow.method, RemovedFlowMethod::MinimumNorm);
2501        }
2502        assert_eq!(
2503            codes_of(&flows.diagnostics),
2504            ["CANONICALIZE.MERGE.FLOW_UNDETERMINED"]
2505        );
2506    }
2507
2508    #[test]
2509    fn a_zero_reactance_element_carries_its_parallel_paths_share() {
2510        // A closed switch 2-3 in parallel with a jumper 2-3: the switch takes
2511        // everything, and the jumper none.
2512        let mut network = fed_group(vec![jumper(2, 3), jumper(3, 4)]);
2513        network
2514            .switches_mut()
2515            .push(Switch::new(BusId(2), BusId(3), true));
2516        let merge = network.merge_buses(&PSSE).unwrap();
2517        let flows = merge
2518            .calc_removed_flows(&MergedFlows::new(&[80.0], &[-80.0]))
2519            .unwrap();
2520        assert_eq!(flows.branches[0].p_from, 0.0);
2521        assert_eq!(flows.switches[0].p_from, 80.0);
2522        assert_eq!(flows.switches[0].method, RemovedFlowMethod::Tree);
2523    }
2524
2525    #[test]
2526    fn an_unbalanced_group_and_a_wrong_shape_are_reported() {
2527        let mut network = fed_group(vec![jumper(2, 3), jumper(3, 4)]);
2528        network.buses_mut()[0].kind = BusType::Pq;
2529        let merge = network.merge_buses(&PSSE).unwrap();
2530        let flows = merge
2531            .calc_removed_flows(&MergedFlows::new(&[70.0], &[-70.0]))
2532            .unwrap();
2533        assert_eq!(
2534            codes_of(&flows.diagnostics),
2535            ["CANONICALIZE.MERGE.FLOW_RESIDUAL"]
2536        );
2537        let error = merge
2538            .calc_removed_flows(&MergedFlows::new(&[], &[]))
2539            .unwrap_err();
2540        assert_eq!(error.code().code, "CANONICALIZE.MERGE.FLOW_SHAPE_MISMATCH");
2541    }
2542
2543    #[test]
2544    fn out_of_service_elements_carry_no_flow() {
2545        let mut network = fed_group(vec![jumper(2, 3), jumper(3, 4)]);
2546        let mut open = line(2, 4, 0.01, 0.1);
2547        open.in_service = false;
2548        network.branches_mut().push(open);
2549        let merge = network.merge_buses(&PSSE).unwrap();
2550        let flows = merge
2551            .calc_removed_flows(&MergedFlows::new(&[80.0], &[-80.0]))
2552            .unwrap();
2553        assert_eq!(flows.branches[2].method, RemovedFlowMethod::OutOfService);
2554        assert_eq!(flows.branches[2].p_from, 0.0);
2555    }
2556}