Skip to main content

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}