Skip to main content

powerio_prob/instance/
merge.rs

1//! The checked, explicit zero impedance resolution.
2//!
3//! Networks and instances preserve zero impedance branches; a finite matrix
4//! or problem projection refuses them rather than silently skipping rows.
5//! [`merge_zero_impedance_buses`] is the explicit resolution: buses joined by
6//! an in service branch with zero series impedance merge into one electrical
7//! node, and the transformation returns the complete mapping plus diagnostics
8//! for the branch behavior the merge removes.
9
10use std::collections::BTreeMap;
11
12use powerio_core::{Diagnostic, Error};
13use powerio_tx::{BalancedNetwork, BusId};
14
15use crate::diagnostics::codes;
16use crate::operating::row_identity;
17
18/// What one merge did: which buses now name which surviving bus, and which
19/// branches the merge removed.
20#[derive(Clone, Debug, Default, PartialEq)]
21#[non_exhaustive]
22pub struct ZeroImpedanceMerge {
23    /// Every merged bus to the bus that now carries it. Buses that survived
24    /// unchanged are absent.
25    pub merged_buses: BTreeMap<BusId, BusId>,
26    /// The removed zero impedance branches, by stable identity
27    /// (`uid`, else `branches:{row}` of the source network).
28    pub removed_branches: Vec<String>,
29}
30
31/// Merge every group of buses joined by in service branches with zero series
32/// impedance (`r == 0` and `x == 0`, self loops excluded) into that group's
33/// smallest bus id, rewriting every element reference and dropping the merged
34/// buses and the zero impedance branches.
35///
36/// The flow through a removed branch is no longer a variable of any derived
37/// calculation, and merged buses may have stated different attributes; both
38/// are reported as diagnostics. The input network is never mutated.
39///
40/// # Errors
41/// A zero impedance branch naming a bus the network does not declare.
42fn find(parent: &mut [usize], node: usize) -> usize {
43    let mut root = node;
44    while parent[root] != root {
45        root = parent[root];
46    }
47    let mut walk = node;
48    while parent[walk] != root {
49        let next = parent[walk];
50        parent[walk] = root;
51        walk = next;
52    }
53    root
54}
55
56#[allow(clippy::too_many_lines)] // one pass per element table, stated in full
57pub fn merge_zero_impedance_buses(
58    network: &BalancedNetwork,
59) -> Result<(BalancedNetwork, ZeroImpedanceMerge, Vec<Diagnostic>), Error> {
60    let mut diagnostics = Vec::new();
61
62    // Union-find over bus ids, keyed by table index.
63    let index_of: BTreeMap<BusId, usize> = network
64        .buses()
65        .iter()
66        .enumerate()
67        .map(|(index, bus)| (bus.id, index))
68        .collect();
69    let mut parent: Vec<usize> = (0..network.buses().len()).collect();
70
71    let mut removed_rows = Vec::new();
72    for (row, branch) in network.branches().iter().enumerate() {
73        let zero = branch.r == 0.0 && branch.x == 0.0;
74        if !zero || !branch.in_service || branch.from == branch.to {
75            continue;
76        }
77        let (Some(&from), Some(&to)) = (index_of.get(&branch.from), index_of.get(&branch.to))
78        else {
79            return Err(Error::new(
80                &codes::BUILD_INSTANCE_SHAPE_MISMATCH,
81                format!(
82                    "zero impedance branch row {row} names bus {} or {} the network does not declare",
83                    branch.from, branch.to
84                ),
85            ));
86        };
87        let (from_root, to_root) = (find(&mut parent, from), find(&mut parent, to));
88        if from_root != to_root {
89            parent[from_root.max(to_root)] = from_root.min(to_root);
90        }
91        removed_rows.push(row);
92        let identity = row_identity(branch.uid.as_deref(), "branches", row);
93        diagnostics.push(Diagnostic::of(
94            &codes::CANONICALIZE_MERGE_ZERO_IMPEDANCE,
95            format!(
96                "zero impedance branch `{identity}` between buses {} and {} was merged; its flow is not a variable of any derived calculation",
97                branch.from, branch.to
98            ),
99        ));
100    }
101
102    if removed_rows.is_empty() {
103        return Ok((network.clone(), ZeroImpedanceMerge::default(), diagnostics));
104    }
105
106    // The surviving bus of each group is the smallest bus id in it, which is
107    // the root after unioning toward the smaller table index of an id-sorted
108    // bus table; resolve ids directly so the rule holds for any table order.
109    let mut survivor_of_root: BTreeMap<usize, BusId> = BTreeMap::new();
110    for index in 0..network.buses().len() {
111        let root = find(&mut parent, index);
112        let id = network.buses()[index].id;
113        let entry = survivor_of_root.entry(root).or_insert(id);
114        if id < *entry {
115            *entry = id;
116        }
117    }
118    let mut merged_buses = BTreeMap::new();
119    for index in 0..network.buses().len() {
120        let root = find(&mut parent, index);
121        let id = network.buses()[index].id;
122        let survivor = survivor_of_root[&root];
123        if id != survivor {
124            merged_buses.insert(id, survivor);
125        }
126    }
127
128    let resolve = |bus: BusId| merged_buses.get(&bus).copied().unwrap_or(bus);
129
130    let mut merged = network.clone();
131    // Attribute conflicts between a merged bus and its survivor are reported;
132    // the survivor's values are kept.
133    for (&gone, &kept) in &merged_buses {
134        let gone_bus = &network.buses()[index_of[&gone]];
135        let kept_bus = &network.buses()[index_of[&kept]];
136        // Bit inequality on purpose: any stated difference is worth a note,
137        // and the values come from one document, so equal bases agree
138        // exactly.
139        if gone_bus.base_kv.to_bits() != kept_bus.base_kv.to_bits() {
140            diagnostics.push(Diagnostic::of(
141                &codes::CANONICALIZE_MERGE_ATTRIBUTE_CONFLICT,
142                format!(
143                    "bus {gone} (base {} kV) merged into bus {kept} (base {} kV); the surviving base was kept",
144                    gone_bus.base_kv, kept_bus.base_kv
145                ),
146            ));
147        }
148        if gone_bus.kind != kept_bus.kind && gone_bus.kind == powerio_tx::BusType::Ref {
149            // A reference designation must survive the merge.
150            let survivor = &mut merged.buses_mut()[index_of[&kept]];
151            survivor.kind = powerio_tx::BusType::Ref;
152        }
153    }
154
155    let removed: std::collections::BTreeSet<usize> = removed_rows.iter().copied().collect();
156    let removed_branches = removed_rows
157        .iter()
158        .map(|&row| row_identity(network.branches()[row].uid.as_deref(), "branches", row))
159        .collect();
160
161    merged
162        .buses_mut()
163        .retain(|bus| !merged_buses.contains_key(&bus.id));
164    let mut row = 0usize;
165    merged.branches_mut().retain(|_| {
166        let keep = !removed.contains(&row);
167        row += 1;
168        keep
169    });
170    for branch in merged.branches_mut() {
171        branch.from = resolve(branch.from);
172        branch.to = resolve(branch.to);
173    }
174    for load in merged.loads_mut() {
175        load.bus = resolve(load.bus);
176    }
177    for generator in merged.generators_mut() {
178        generator.bus = resolve(generator.bus);
179    }
180    for shunt in merged.shunts_mut() {
181        shunt.bus = resolve(shunt.bus);
182    }
183
184    Ok((
185        merged,
186        ZeroImpedanceMerge {
187            merged_buses,
188            removed_branches,
189        },
190        diagnostics,
191    ))
192}