gridoxide_core/constrained.rs
1//! A switch as an exact equality constraint, rather than as a stiff branch.
2//!
3//! This is approach 3 of `docs/src/powerflow/zero_impedance_branches.md`:
4//! [`SwitchTreatment::Constrain`](crate::switches::SwitchTreatment::Constrain).
5//! A closed switch does not become an admittance at all. Instead its power flow
6//! becomes an *unknown*, and the physics it enforces — the two ends are the same
7//! point electrically — becomes a pair of exact equations:
8//!
9//! ```text
10//! θ_i − θ_j = 0 V_i − V_j = 0
11//! ```
12//!
13//! # The system
14//!
15//! Two unknowns and two equations per switch, on top of the ordinary
16//! Newton-Raphson state. The switch's flow `(P_s, Q_s)` enters the power balance
17//! of both its ends, leaving `i` and arriving at `j`:
18//!
19//! ```text
20//! [ J Aᵀ ] [ Δx ] [ mismatch ]
21//! [ A 0 ] [ Δs ] = [ −c(x) ]
22//! ```
23//!
24//! where `J` is the Jacobian the unconstrained solver already builds, `A` holds
25//! the ±1 of each constraint, and `Aᵀ` the ±1 with which each flow enters a
26//! mismatch row. It is the same KKT shape
27//! [`se::constraints`](crate::se::constraints) assembles for zero-injection
28//! buses, for the same reason: a hard equality is not a heavily weighted
29//! measurement.
30//!
31//! # What this buys over `Regularize`
32//!
33//! Not scale — [`switches`](crate::switches) records that the expected
34//! conditioning ceiling did not materialize, and `Regularize` solves every
35//! node-breaker configuration in the tree. Three other things:
36//!
37//! - **No arbitrary constant.** `Regularize` picks a stiffness; the answer
38//! depends on it, slightly, everywhere. Here there is no constant to pick, so
39//! a closed switch is *exactly* a short circuit rather than very nearly one.
40//! - **An honest answer for a loop of closed switches.** Their flows are
41//! genuinely indeterminate — the network does not decide how a bus coupler
42//! splits current with a parallel one — and this reports them
43//! ([`ConstrainedSolution::indeterminate`]) rather than inventing a split from
44//! the stiffness it happened to choose.
45//! - **An open switch costs nothing.** It contributes no admittance and no
46//! coupling, where `Regularize` still carries its structural entries.
47//!
48//! # What it costs
49//!
50//! The matrix grows by two rows and columns per switch, and it stops being the
51//! power-flow Jacobian: the multiplier block on the diagonal is zero, so the
52//! system is indefinite and no longer has a nonzero diagonal everywhere. Every
53//! gridoxide backend is a general sparse LU, so this is assembly work rather
54//! than a new solver — the same argument `se::constraints` makes.
55//!
56//! # The sparsity pattern still does not move
57//!
58//! An open switch keeps its rows and columns. It simply constrains a different
59//! thing: `P_s = Q_s = 0` instead of `θ_i = θ_j` and `V_i = V_j`. Both forms'
60//! entries are stamped in every state, one of the two carrying a structural
61//! zero, so **flipping a switch changes values and not the pattern** — a
62//! property
63//! `switches::regularized_branches` reaches by the other route (an open
64//! terminal's all-zero stamp).
65
66use crate::jacobian::JacobianPattern;
67use crate::network::{YBusSparse, effective_injection, power_injections};
68use crate::solver::{LinearSolver, SolveStats, SolveStatus};
69use crate::types::{Bus, BusType};
70use gridoxide_sparse::sparse::RealSparseSystem;
71
72/// A switch to be represented as a constraint rather than as a branch.
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74pub struct ConstrainedSwitch {
75 pub from: usize,
76 pub to: usize,
77 pub open: bool,
78}
79
80/// How one switch's two rows are being used at this switching state.
81///
82/// Not a configuration choice — it is derived, and the derivation is the part
83/// worth naming, because a switch that *looks* closed can still fail to
84/// constrain anything.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86enum RowUse {
87 /// `θ_i − θ_j = 0` / `V_i − V_j = 0`, the real thing.
88 Tie,
89 /// `P_s = 0` / `Q_s = 0`: the flow is pinned because nothing determines it.
90 Pin,
91}
92
93/// The outcome of a constrained solve.
94#[derive(Clone, Debug)]
95pub struct ConstrainedSolution {
96 pub stats: SolveStats,
97 /// Per switch, the flow `(P, Q)` leaving its `from` end, in per unit.
98 ///
99 /// Exactly zero for an open switch, and for every switch named in
100 /// [`indeterminate`](Self::indeterminate).
101 pub flows: Vec<(f64, f64)>,
102 /// Switches whose flow the network does not determine, so this pinned it to
103 /// zero to keep the system nonsingular.
104 ///
105 /// Three ways in, all reported the same way because the caller's question is
106 /// the same one — *is this number physical?*:
107 ///
108 /// - **A loop of closed switches.** `θ_i = θ_j` around a cycle is one
109 /// redundant equation, and the flows around it are one free parameter.
110 /// `Regularize` hides this by resolving the split with its own stiffness;
111 /// there is no more information there than here.
112 /// - **A self-loop**, both ends on one bus. Nothing to tie.
113 /// - **Both ends fixed** — two `Slack` buses, or two `PV` buses for the
114 /// reactive half. The constraint is vacuous and the flow is absorbed by
115 /// whatever holds those buses.
116 ///
117 /// The reactive half can be indeterminate while the active half is not (a
118 /// switch between two `PV` buses), in which case the switch appears here and
119 /// its `Q` is zero while its `P` is real. See [`ConstrainedSolution::flows`].
120 pub indeterminate: Vec<usize>,
121}
122
123/// Runs Newton-Raphson with each closed switch enforced as an exact equality.
124///
125/// `ybus` must **not** contain the switches — that is the entire point. Build it
126/// from the network's own lines and transformers, and pass the switches here.
127///
128/// Islands are decided across closed switches, since a switch conducts even
129/// though it contributes no admittance. Without that, the two ends of every
130/// closed switch would look like separate components and
131/// [`mark_unreferenced_islands`](crate::network) would de-energize whichever
132/// side does not hold the reference — turning a perfectly ordinary network into
133/// a dead one.
134pub fn solve_constrained(
135 buses: &mut [Bus],
136 ybus: &YBusSparse,
137 switches: &[ConstrainedSwitch],
138 tol: f64,
139 max_iter: usize,
140) -> ConstrainedSolution {
141 solve_constrained_with::<RealSparseSystem>(buses, ybus, switches, tol, max_iter)
142}
143
144/// [`solve_constrained`] against a chosen linear-solver backend.
145pub fn solve_constrained_with<S: LinearSolver>(
146 buses: &mut [Bus],
147 ybus: &YBusSparse,
148 switches: &[ConstrainedSwitch],
149 tol: f64,
150 max_iter: usize,
151) -> ConstrainedSolution {
152 let n_sw = switches.len();
153 let empty = ConstrainedSolution {
154 stats: SolveStats::from_loop(SolveStatus::Singular, Vec::new()),
155 flows: vec![(0.0, 0.0); n_sw],
156 indeterminate: Vec::new(),
157 };
158
159 if switches
160 .iter()
161 .any(|s| s.from >= buses.len() || s.to >= buses.len())
162 {
163 return empty;
164 }
165
166 crate::network::mark_unreferenced_islands(
167 buses,
168 &crate::network::classify(
169 buses,
170 &components_across_switches(buses.len(), ybus, switches),
171 ),
172 );
173
174 // Which of each switch's two rows ties its ends, and which pins its flow.
175 //
176 // A tie is only independent if it says something the other rows do not, and
177 // union-find is what decides that — one disjoint-set forest per half,
178 // because the two halves fix different things. The subtlety is the seeding:
179 // every bus whose angle is *already* fixed starts in one shared set, and
180 // likewise every bus whose magnitude is. Then "already connected" covers
181 // both ways a constraint can be redundant, without either being special-
182 // cased:
183 //
184 // - a cycle of closed switches — the last one closing it adds no
185 // information, and its share of the flow is a free parameter;
186 // - a chain whose ends are both held — two `Slack` buses joined through a
187 // run of switches makes the second tie a copy of the first, which is a
188 // duplicate row and a singular matrix. This one is not hypothetical: it is
189 // what SmallGrid's full node-breaker view does, and checking only each
190 // switch's own two ends does not catch it.
191 let mut dsu_angle: Vec<usize> = (0..buses.len()).collect();
192 let mut dsu_mag: Vec<usize> = (0..buses.len()).collect();
193 seed_fixed(&mut dsu_angle, buses, has_angle);
194 seed_fixed(&mut dsu_mag, buses, has_magnitude);
195
196 let mut rows = Vec::with_capacity(n_sw);
197 let mut indeterminate = Vec::new();
198 for (k, sw) in switches.iter().enumerate() {
199 let (i, j) = (sw.from, sw.to);
200 let connects = !sw.open && i != j;
201 let use_p = tie_or_pin(&mut dsu_angle, connects, i, j);
202 let use_q = tie_or_pin(&mut dsu_mag, connects, i, j);
203 if !sw.open && (use_p == RowUse::Pin || use_q == RowUse::Pin) {
204 indeterminate.push(k);
205 }
206 rows.push((use_p, use_q));
207 }
208
209 // The base state vector, exactly as the unconstrained solver lays it out.
210 let non_slack_idx: Vec<usize> = buses
211 .iter()
212 .filter(|b| b.bus_type != BusType::Slack)
213 .map(|b| b.idx)
214 .collect();
215 let pq_idx: Vec<usize> = buses
216 .iter()
217 .filter(|b| b.bus_type == BusType::PQ)
218 .map(|b| b.idx)
219 .collect();
220 let n_angle = non_slack_idx.len();
221 let n_base = n_angle + pq_idx.len();
222 let n = n_base + 2 * n_sw;
223
224 let mut angle_col = vec![None; buses.len()];
225 for (pos, &i) in non_slack_idx.iter().enumerate() {
226 angle_col[i] = Some(pos);
227 }
228 let mut mag_col = vec![None; buses.len()];
229 for (pos, &i) in pq_idx.iter().enumerate() {
230 mag_col[i] = Some(n_angle + pos);
231 }
232
233 // The starting state, and why it needs its own Y-bus.
234 //
235 // `linear_initial_guess` is what every other entry point starts from
236 // (`run_power_flow_analysis_from_ybus` calls it before touching Newton), and
237 // on CGMES data the difference is not cosmetic: from a flat start this
238 // network's first mismatch is 2.7e2 against the guess's 2.0e-3, and Newton
239 // does not recover from the steps that produces — it oscillates for thirty
240 // iterations without converging. Measured, not assumed.
241 //
242 // The guess has to see the switches, or every bus reachable only through one
243 // is left flat, which is most of a node-breaker model. So it is taken
244 // against a Y-bus with the closed switches stamped stiffly — the very thing
245 // this formulation exists to avoid. That is not a contradiction: a starting
246 // guess is discarded the moment the first step is taken, and the conditioning
247 // argument is about the matrix Newton *factorizes*, which never contains it.
248 let mut warm = crate::network::YBus::new(buses.len());
249 for i in 0..buses.len() {
250 for &(j, y) in ybus.row(i) {
251 warm.add(i, j, y);
252 }
253 }
254 let y_switch = crate::switches::switch_admittance();
255 for sw in switches.iter().filter(|s| !s.open && s.from != s.to) {
256 warm.add(sw.from, sw.from, y_switch);
257 warm.add(sw.to, sw.to, y_switch);
258 warm.add(sw.from, sw.to, -y_switch);
259 warm.add(sw.to, sw.from, -y_switch);
260 }
261 crate::network::linear_initial_guess(buses, &warm.finish());
262
263 let pattern = JacobianPattern::analyze(buses, ybus);
264 let mut values: Vec<f64> = Vec::with_capacity(pattern.len());
265 let mut flows = vec![(0.0, 0.0); n_sw];
266 let mut history = Vec::with_capacity(max_iter);
267 let mut system: Option<S> = None;
268
269 for _ in 0..max_iter {
270 let (p_calc, q_calc) = power_injections(buses, ybus);
271
272 let mut rhs = vec![0.0; n];
273 for (row, &i) in non_slack_idx.iter().enumerate() {
274 rhs[row] = effective_injection(&buses[i]).0 - p_calc[i];
275 }
276 for (row, &i) in pq_idx.iter().enumerate() {
277 rhs[n_angle + row] = effective_injection(&buses[i]).1 - q_calc[i];
278 }
279 // The switch flows leave `from` and arrive at `to`, so they enter the
280 // two ends' balances with opposite signs.
281 for (k, sw) in switches.iter().enumerate() {
282 let (p_s, q_s) = flows[k];
283 for (bus, sign) in [(sw.from, 1.0), (sw.to, -1.0)] {
284 if let Some(row) = angle_col[bus] {
285 rhs[row] -= sign * p_s;
286 }
287 if let Some(row) = mag_col[bus] {
288 rhs[row] -= sign * q_s;
289 }
290 }
291 }
292 // And the constraints' own violations.
293 for (k, sw) in switches.iter().enumerate() {
294 let (use_p, use_q) = rows[k];
295 rhs[n_base + 2 * k] = -match use_p {
296 RowUse::Tie => buses[sw.from].voltage_ang - buses[sw.to].voltage_ang,
297 RowUse::Pin => flows[k].0,
298 };
299 rhs[n_base + 2 * k + 1] = -match use_q {
300 RowUse::Tie => buses[sw.from].voltage_mag - buses[sw.to].voltage_mag,
301 RowUse::Pin => flows[k].1,
302 };
303 }
304
305 let max_mis = rhs.iter().fold(0.0f64, |a, &b| a.max(b.abs()));
306 history.push(max_mis);
307 if max_mis < tol {
308 return ConstrainedSolution {
309 stats: SolveStats::from_loop(SolveStatus::Converged, history),
310 flows,
311 indeterminate,
312 };
313 }
314
315 pattern.fill(buses, &p_calc, &q_calc, &mut values);
316 let mut triplets = pattern.to_triplets(&values);
317 for (k, sw) in switches.iter().enumerate() {
318 let (p_col, q_col) = (n_base + 2 * k, n_base + 2 * k + 1);
319 let (p_row, q_row) = (n_base + 2 * k, n_base + 2 * k + 1);
320 let (use_p, use_q) = rows[k];
321
322 // Aᵀ: how each flow enters the two ends' balance equations.
323 for (bus, sign) in [(sw.from, 1.0), (sw.to, -1.0)] {
324 if let Some(row) = angle_col[bus] {
325 triplets.push((row, p_col, sign));
326 }
327 if let Some(row) = mag_col[bus] {
328 triplets.push((row, q_col, sign));
329 }
330 }
331
332 // A: the constraint rows. Both forms are stamped in every state,
333 // the inactive one as a structural zero, so the pattern is the same
334 // whatever the switch's position.
335 for (row, col_of, flow_col, active) in [
336 (p_row, &angle_col, p_col, use_p),
337 (q_row, &mag_col, q_col, use_q),
338 ] {
339 let tie = f64::from(active == RowUse::Tie);
340 for (bus, sign) in [(sw.from, 1.0), (sw.to, -1.0)] {
341 if let Some(c) = col_of[bus] {
342 triplets.push((row, c, sign * tie));
343 }
344 }
345 triplets.push((row, flow_col, 1.0 - tie));
346 }
347 }
348
349 if system.is_none() {
350 system = S::new(n, &triplets);
351 }
352 let Some(sys) = system.as_mut() else {
353 return ConstrainedSolution {
354 stats: SolveStats::from_loop(SolveStatus::Singular, history),
355 flows,
356 indeterminate,
357 };
358 };
359 let Some(dx) = sys.factor_and_solve(&triplets, &rhs) else {
360 return ConstrainedSolution {
361 stats: SolveStats::from_loop(SolveStatus::Singular, history),
362 flows,
363 indeterminate,
364 };
365 };
366
367 for (pos, &i) in non_slack_idx.iter().enumerate() {
368 buses[i].voltage_ang += dx[pos];
369 }
370 for (pos, &i) in pq_idx.iter().enumerate() {
371 buses[i].voltage_mag += dx[n_angle + pos];
372 }
373 for (k, flow) in flows.iter_mut().enumerate() {
374 flow.0 += dx[n_base + 2 * k];
375 flow.1 += dx[n_base + 2 * k + 1];
376 }
377 }
378
379 ConstrainedSolution {
380 stats: SolveStats::from_loop(SolveStatus::MaxIterationsReached, history),
381 flows,
382 indeterminate,
383 }
384}
385
386/// Puts every bus whose unknown is *not* free into one shared set, so a
387/// constraint between two of them reads as already-satisfied rather than as new
388/// information.
389fn seed_fixed(dsu: &mut [usize], buses: &[Bus], free: fn(&Bus) -> bool) {
390 let mut anchor: Option<usize> = None;
391 for (i, bus) in buses.iter().enumerate() {
392 if free(bus) {
393 continue;
394 }
395 match anchor {
396 None => anchor = Some(i),
397 Some(a) => {
398 let (x, y) = (find(dsu, i), find(dsu, a));
399 dsu[x] = y;
400 }
401 }
402 }
403}
404
405/// Ties `i` to `j` if that is new information, and records the union if so.
406fn tie_or_pin(dsu: &mut [usize], connects: bool, i: usize, j: usize) -> RowUse {
407 if !connects {
408 return RowUse::Pin;
409 }
410 let (a, b) = (find(dsu, i), find(dsu, j));
411 if a == b {
412 return RowUse::Pin;
413 }
414 dsu[a] = b;
415 RowUse::Tie
416}
417
418fn has_angle(bus: &Bus) -> bool {
419 bus.bus_type != BusType::Slack
420}
421
422fn has_magnitude(bus: &Bus) -> bool {
423 bus.bus_type == BusType::PQ
424}
425
426fn find(dsu: &mut [usize], mut x: usize) -> usize {
427 while dsu[x] != x {
428 dsu[x] = dsu[dsu[x]];
429 x = dsu[x];
430 }
431 x
432}
433
434/// Connected components of the Y-bus, further merged across closed switches.
435///
436/// A closed switch conducts without contributing admittance, which is precisely
437/// the case `network::connected_components` cannot see on its own.
438fn components_across_switches(
439 n: usize,
440 ybus: &YBusSparse,
441 switches: &[ConstrainedSwitch],
442) -> Vec<Vec<usize>> {
443 let base = crate::network::connected_components(ybus);
444 let mut dsu: Vec<usize> = (0..n).collect();
445 for component in &base {
446 for w in component.windows(2) {
447 let (a, b) = (find(&mut dsu, w[0]), find(&mut dsu, w[1]));
448 dsu[a] = b;
449 }
450 }
451 for sw in switches.iter().filter(|s| !s.open) {
452 if sw.from < n && sw.to < n {
453 let (a, b) = (find(&mut dsu, sw.from), find(&mut dsu, sw.to));
454 dsu[a] = b;
455 }
456 }
457
458 let mut of_root: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
459 let mut out: Vec<Vec<usize>> = Vec::new();
460 for i in 0..n {
461 let root = find(&mut dsu, i);
462 let slot = *of_root.entry(root).or_insert_with(|| {
463 out.push(Vec::new());
464 out.len() - 1
465 });
466 out[slot].push(i);
467 }
468 out
469}