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}