Skip to main content

gridoxide_core/topology/
reduction.rs

1//! Topological reduction: collapsing buses tied together by zero-impedance
2//! connections.
3//!
4//! This is approach 1 of the three surveyed in
5//! `docs/src/powerflow/zero_impedance_branches.md` — merge before you
6//! formulate, so the admittance matrix never sees an infinite entry. It lives
7//! here rather than in an importer because both importers can reach a network
8//! needing it, and because the graph pass is the same regardless of what named
9//! the connection.
10//!
11//! # When merging is the right answer
12//!
13//! Merging is exact and free — no new equations, no new unknowns, no numerical
14//! stiffness — but it *destroys identity*: once two buses are one, nothing
15//! downstream can report a flow through the connection or an injection at
16//! either end separately.
17//!
18//! So the policy is about identity, not about impedance:
19//!
20//! - A **CGMES closed switch** has no identity in the bus/branch view — that
21//!   view is *defined* as the merged one, which is what powsybl's
22//!   `CalculatedBusImpl` produces too. Merge.
23//! - A **PGM `link`** does have identity: power-grid-model's output schema
24//!   carries a `link` record with its own flows, and its fixtures assert them.
25//!   Merging deletes the branch those numbers describe, so a link stays a
26//!   branch.
27//!
28//! There is a second, empirical reason the CGMES side merges. Stamping its
29//! switches as large-admittance branches was tried and *diverged*: the AC
30//! Newton-Raphson solve failed on FullGrid with 20-odd such branches active at
31//! once. That is the conditioning cost approach 2 carries, met in practice.
32
33/// The admittance gridoxide gives any element it treats as an ideal
34/// connection, declared or detected: `2e5 + j2e5` per-unit.
35///
36/// Three orders below power-grid-model's own `1e8 + j1e8` for a `link`, and
37/// chosen by measurement rather than derivation, because the two calculation
38/// types pull in opposite directions and their windows barely overlap:
39///
40/// - **Power flow** wants it large. The drop across the connection is
41///   `ΔV = I/y`, and the `dummy-test` fixture checks node voltages and the
42///   link's own current at 1e-5 relative. At `1e5` both land exactly on that
43///   boundary and fail; from `2e5` up they pass.
44/// - **State estimation** wants it small. `G = HᵀWH` *squares* the admittance,
45///   so power-grid-model's `1e8` becomes `1e16` in the gain matrix and the
46///   `node-injection-*` fixtures come back singular. They stay singular at
47///   `1e6`.
48///
49/// | `y` | power flow | state estimation |
50/// |---|---|---|
51/// | `1e8` (power-grid-model's) | pass | **singular** |
52/// | `1e6` | pass | **singular** |
53/// | **`2e5`** | **pass** | **converge** |
54/// | `1e5` | **fail**, at tolerance | converge |
55///
56/// The narrowness of that window is the argument for treating this as a
57/// regularization parameter with a measured value rather than a physical
58/// constant: a network far outside these fixtures' power scale may need it
59/// re-measured, and if no value serves both, the equality-constrained
60/// formulation (`docs/src/powerflow/zero_impedance_branches.md`, approach 3) is
61/// the exit — it imposes `V_i = V_j` exactly, with no large number anywhere.
62pub const IDEAL_CONNECTION_Y: num_complex::Complex<f64> = num_complex::Complex::new(2e5, 2e5);
63
64/// Per-unit impedance standing for an **open** branch — the dual of
65/// [`IDEAL_CONNECTION_Y`], and used for the same reason: a branch that has to
66/// stay in the model while carrying nothing.
67///
68/// A branch is opened by giving it this impedance rather than by removing it,
69/// because removal renumbers every later branch and the crate-wide flat index
70/// is what ties a `Line`, a `DcBranch`, a `BranchParams` and a CRAC element id
71/// together.
72///
73/// **Large and finite, not infinite.** `Complex::new(f64::INFINITY,
74/// f64::INFINITY).inv()` is `NaN`, not zero, so an "infinitely open" branch
75/// poisons the whole Y-bus and the solve reports `Singular` — which looks like
76/// a numerical problem and is a modelling mistake. At `1e9` per-unit the
77/// admittance is `1e-9`, below any tolerance that matters and finite
78/// everywhere.
79pub const OPEN_BRANCH_Z: f64 = 1e9;
80
81/// The impedance corresponding to [`IDEAL_CONNECTION_Y`]: `3.54e-6` p.u.
82///
83/// What a branch caught by [`ZERO_IMPEDANCE_THRESHOLD`] is raised *to*.
84pub(crate) fn ideal_connection_z() -> f64 {
85    1.0 / (IDEAL_CONNECTION_Y.re.powi(2) + IDEAL_CONNECTION_Y.im.powi(2)).sqrt()
86}
87
88/// Below this impedance, per-unit, a branch is treated as an ideal connection
89/// rather than as an ordinary one.
90///
91/// `Y = 1/Z` is unbounded as `Z -> 0`, and a network can reach that by accident
92/// — a jumper modelled as a very short line rather than as a declared switch or
93/// link. power-grid-model's own `ill-conditioned-by-line-meshed` fixture
94/// contains exactly that, a line at `7.07e-9` p.u., and is named for the
95/// consequence.
96///
97/// **Detection and treatment are separate numbers, deliberately.** This
98/// threshold only decides *whether* a branch is an ideal connection; what it is
99/// clamped to is [`IDEAL_CONNECTION_Y`], the same admittance a declared link
100/// gets. Tying the two together would force a single value to satisfy two
101/// incompatible constraints:
102///
103/// - it must sit below every *legitimate* branch, or real data gets mangled —
104///   the smallest in the committed fixtures are `1.0e-6` (PGM) and `2.92e-6`
105///   (CGMES);
106/// - yet clamping only to that value leaves `|Y|` as high as `1e7`, which is
107///   inside the range measured as singular for state estimation.
108///
109/// Separating them satisfies both: detection at `1e-7` touches nothing real,
110/// and anything it does catch is raised to a link's stiffness — which is what
111/// such a branch *is*, an undeclared link.
112///
113/// powsybl-open-loadflow's equivalent (`lowImpedanceThreshold`) is `1e-8`. Its
114/// default treatment is the equality-constrained formulation rather than a
115/// clamp, so it never has to reconcile these two roles in one number.
116pub const ZERO_IMPEDANCE_THRESHOLD: f64 = 1e-7;
117
118/// Raises a branch below [`ZERO_IMPEDANCE_THRESHOLD`] to the impedance of
119/// [`IDEAL_CONNECTION_Y`], preserving the R/X ratio so it keeps its character.
120///
121/// A branch at exactly zero has no ratio to preserve and becomes purely
122/// reactive, matching how a zero-impedance connection behaves in practice.
123///
124/// This is powsybl's `REPLACE_BY_MIN_IMPEDANCE_LINE` treatment
125/// (`SimplePiModel::setMinZ`). Its *default* is the equality-constrained
126/// alternative instead; gridoxide clamps because it has no augmented-system
127/// machinery on the power-flow side — see
128/// `docs/src/powerflow/zero_impedance_branches.md`.
129pub fn clamp_branch_impedance(r: f64, x: f64) -> (f64, f64) {
130    let z = (r * r + x * x).sqrt();
131    if z >= ZERO_IMPEDANCE_THRESHOLD || !z.is_finite() {
132        return (r, x);
133    }
134    let target = ideal_connection_z();
135    if z == 0.0 {
136        return (0.0, target);
137    }
138    let scale = target / z;
139    (r * scale, x * scale)
140}
141
142/// Minimal path-compressing union-find.
143///
144/// Deliberately without union-by-rank: the inputs here are at most a few
145/// hundred buses, and path compression alone already flattens them.
146pub struct UnionFind {
147    parent: Vec<usize>,
148}
149
150impl UnionFind {
151    pub fn new(n: usize) -> Self {
152        UnionFind {
153            parent: (0..n).collect(),
154        }
155    }
156
157    pub fn find(&mut self, x: usize) -> usize {
158        if self.parent[x] != x {
159            self.parent[x] = self.find(self.parent[x]);
160        }
161        self.parent[x]
162    }
163
164    pub fn union(&mut self, a: usize, b: usize) {
165        let (ra, rb) = (self.find(a), self.find(b));
166        if ra != rb {
167            self.parent[ra] = rb;
168        }
169    }
170}
171
172/// Collapses each union-find group into a single index.
173///
174/// Returns `(remap, n_merged)`, where `remap[old] = new`. Group representatives
175/// are chosen by whichever member the root lands on — arbitrary but
176/// deterministic, and the resulting order is stable across runs because it
177/// follows ascending original index.
178///
179/// The caller keeps ownership of what a bus *is*: this returns only the index
180/// mapping, so an importer can carry across whichever fields the representative
181/// should keep. That is safe for the usual case because a zero-impedance
182/// connection ties buses at the same nominal voltage, so `u_rated` and friends
183/// agree across a group by construction.
184pub fn merge_groups(n: usize, uf: &mut UnionFind) -> (Vec<usize>, usize) {
185    let mut remap = vec![usize::MAX; n];
186    let mut next = 0;
187    for i in 0..n {
188        let root = uf.find(i);
189        if remap[root] == usize::MAX {
190            remap[root] = next;
191            next += 1;
192        }
193        remap[i] = remap[root];
194    }
195    (remap, next)
196}
197
198/// Builds the union-find for a set of index pairs to be tied together.
199///
200/// A convenience for callers that already have their pairs in hand; importers
201/// with per-element conditions to check (open switches, out-of-service
202/// equipment) will usually drive [`UnionFind`] directly instead.
203pub fn union_all(n: usize, pairs: impl IntoIterator<Item = (usize, usize)>) -> UnionFind {
204    let mut uf = UnionFind::new(n);
205    for (a, b) in pairs {
206        uf.union(a, b);
207    }
208    uf
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn untied_indices_are_left_alone() {
217        let mut uf = UnionFind::new(3);
218        let (remap, n) = merge_groups(3, &mut uf);
219        assert_eq!(remap, vec![0, 1, 2]);
220        assert_eq!(n, 3);
221    }
222
223    #[test]
224    fn a_tied_pair_becomes_one_index() {
225        let mut uf = union_all(3, [(0, 2)]);
226        let (remap, n) = merge_groups(3, &mut uf);
227        assert_eq!(n, 2);
228        assert_eq!(remap[0], remap[2], "tied indices must land together");
229        assert_ne!(remap[0], remap[1]);
230    }
231
232    /// Transitivity: a chain collapses to one index, not to a pair of groups.
233    #[test]
234    fn a_chain_collapses_entirely() {
235        let mut uf = union_all(4, [(0, 1), (1, 2), (2, 3)]);
236        let (remap, n) = merge_groups(4, &mut uf);
237        assert_eq!(n, 1);
238        assert!(remap.iter().all(|&r| r == remap[0]));
239    }
240
241    /// A loop of ties is not a problem for merging — it is only a problem for
242    /// the equality-constrained approach, which needs a spanning tree to avoid
243    /// linearly dependent constraints. Worth pinning, since it is one of the
244    /// concrete reasons this codebase prefers merging where it can.
245    #[test]
246    fn a_loop_merges_without_special_handling() {
247        let mut uf = union_all(3, [(0, 1), (1, 2), (2, 0)]);
248        let (remap, n) = merge_groups(3, &mut uf);
249        assert_eq!(n, 1);
250        assert!(remap.iter().all(|&r| r == remap[0]));
251    }
252
253    #[test]
254    fn an_ordinary_impedance_is_left_alone() {
255        let (r, x) = clamp_branch_impedance(0.05, 0.2);
256        assert_eq!((r, x), (0.05, 0.2));
257    }
258
259    /// Clamping preserves the R/X ratio, so a clamped branch keeps its
260    /// character rather than becoming arbitrarily resistive or reactive.
261    #[test]
262    fn clamping_preserves_the_r_over_x_ratio() {
263        let (r, x) = clamp_branch_impedance(1e-10, 2e-10);
264        let z = (r * r + x * x).sqrt();
265        assert!((z - ideal_connection_z()).abs() < 1e-18, "z = {z}");
266        assert!((x / r - 2.0).abs() < 1e-9, "ratio not preserved: {}", x / r);
267    }
268
269    /// The fixture case: power-grid-model's `ill-conditioned-by-line-meshed`
270    /// carries a line at 7.07e-9 p.u., which would otherwise put an admittance
271    /// of 1.4e8 into the Y-bus.
272    #[test]
273    fn the_ill_conditioned_fixture_value_is_caught() {
274        let (r, x) = clamp_branch_impedance(5e-9, 5e-9);
275        let y = 1.0 / (r * r + x * x).sqrt();
276        let ideal = (IDEAL_CONNECTION_Y.re.powi(2) + IDEAL_CONNECTION_Y.im.powi(2)).sqrt();
277        assert!(
278            (y - ideal).abs() < 1.0,
279            "a caught branch should land at a link's stiffness, not merely under the \
280             detection threshold: {y:.3e} vs {ideal:.3e}"
281        );
282    }
283
284    /// A branch at exactly zero has no ratio to preserve.
285    #[test]
286    fn exactly_zero_becomes_purely_reactive() {
287        assert_eq!(
288            clamp_branch_impedance(0.0, 0.0),
289            (0.0, ideal_connection_z())
290        );
291    }
292
293    /// The smallest legitimate branch in the committed fixtures is 1.0e-6 p.u.
294    /// and must pass through untouched, or the threshold is disturbing real
295    /// data rather than protecting against pathological data.
296    #[test]
297    fn the_smallest_real_fixture_branch_is_untouched() {
298        let (r, x) = clamp_branch_impedance(0.0, 1.0e-6);
299        assert_eq!((r, x), (0.0, 1.0e-6));
300    }
301
302    /// The remap is dense and ascending, so it can index straight into a
303    /// freshly built vector without a second pass.
304    #[test]
305    fn the_remap_is_dense_and_ordered() {
306        let mut uf = union_all(5, [(1, 3)]);
307        let (remap, n) = merge_groups(5, &mut uf);
308        assert_eq!(n, 4);
309        assert_eq!(remap, vec![0, 1, 2, 1, 3]);
310    }
311}