Skip to main content

gridoxide_core/linear/
impedance.rs

1//! The constant-admittance linearization: replace every load by the
2//! admittance that would draw its rated power at `|V| = 1`, and what is left
3//! is a linear circuit.
4//!
5//! \\[ S_i = U_i \overline{I_i} = -U_i \overline{U_i Y_{load}}
6//!    \implies Y_{load} = -\overline{S_i} \quad \text{at } |U| = 1 \\]
7//!
8//! Folding that into the Y-bus diagonal turns the power-flow problem into one
9//! complex linear system `Y' U = I`, solvable in a single factorization with
10//! no iteration and no convergence to fail. Unlike the DC approximation in
11//! [`btheta`](super::btheta) it keeps resistance and produces voltage
12//! *magnitudes*, which is what makes it the useful linearization on
13//! distribution networks — but it is only exact when every load really is
14//! constant-impedance, and it degrades as loading moves away from nominal.
15//!
16//! This is mathematically power-grid-model's `CalculationMethod.linear`
17//! (`power_grid_model/math_solver/linear_pf_solver.hpp`), down to the
18//! `Y_load = -conj(S_base)` step. gridoxide has had the algorithm since it
19//! was written as Newton-Raphson's warm start, mirroring PGM's own
20//! `NewtonRaphsonPFSolver::initialize_derived_solver`; this module is that
21//! code promoted to a solver in its own right, with the island handling the
22//! warm-start path never needed.
23//!
24//! # What counts as an unknown
25//!
26//! Only `PQ` buses. `PV` buses are held at their present voltage, exactly as
27//! `Slack` buses are — the method has no mechanism for "magnitude fixed,
28//! angle free", since that constraint is not linear in `U`. On PGM data this
29//! is free (PGM has no PV buses: sources are slacks), and it is why the
30//! comparison with PGM is exact. On data that does carry PV buses, treating
31//! them as fully fixed is an approximation on top of the linearization, and
32//! the reason this mode is offered alongside Newton-Raphson rather than
33//! instead of it.
34
35use crate::network::{
36    Verdict, YBusSparse, classify, connected_components, mark_unreferenced_islands,
37    solve_constant_admittance,
38};
39use crate::types::{Bus, BusType};
40
41/// How one connected component's constant-admittance solve turned out.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum LinearIslandStatus {
44    /// Solved; this island's `PQ` bus voltages satisfy its linearized
45    /// equations exactly.
46    Solved,
47    /// The reduced system was singular, so this island's voltages were left
48    /// at whatever the caller supplied.
49    Singular,
50    /// No `Slack` bus, so there is nothing to reference the solve against.
51    /// Pinned to `V = 0` by
52    /// [`network::mark_unreferenced_islands`](crate::network).
53    NoReferenceBus,
54    /// Two or more `Slack` buses. Unlike the Newton path — which leaves such
55    /// a component live and warns that the result may satisfy neither
56    /// reference — this solves it, because the system stays linear and well
57    /// posed: each fixed voltage simply contributes its own term to the
58    /// right-hand side. The result is the true solution of the circuit *as
59    /// described*, which is a meaningful answer to an arguably ill-posed
60    /// question.
61    SolvedMultipleReferences,
62}
63
64/// One connected component's constant-admittance outcome.
65#[derive(Clone, Debug)]
66pub struct LinearIslandReport {
67    pub bus_indices: Vec<usize>,
68    pub slack_indices: Vec<usize>,
69    pub status: LinearIslandStatus,
70}
71
72/// The result of a [`linear_power_flow`] solve. Voltages are written back
73/// into the `buses` slice; the per-island breakdown lives here.
74#[derive(Clone, Debug)]
75pub struct LinearReport {
76    pub islands: Vec<LinearIslandReport>,
77}
78
79/// Runs the constant-admittance linearization as a standalone power flow.
80///
81/// Partitions into connected components first, so a sourceless island is
82/// pinned rather than solved and a singular island is attributable to itself
83/// — the same treatment
84/// [`btheta::dc_power_flow`](super::btheta::dc_power_flow) gives, and for the
85/// same reason: components are decoupled in the Y-bus, so solving them
86/// separately costs nothing and reports more.
87pub fn linear_power_flow(buses: &mut [Bus], ybus: &YBusSparse) -> LinearReport {
88    let components = connected_components(ybus);
89    let classified = classify(buses, &components);
90    mark_unreferenced_islands(buses, &classified);
91
92    let mut islands = Vec::with_capacity(classified.len());
93    for c in &classified {
94        if matches!(c.verdict, Verdict::NoReferenceBus) {
95            islands.push(LinearIslandReport {
96                bus_indices: c.bus_indices.clone(),
97                slack_indices: c.slack_indices.clone(),
98                status: LinearIslandStatus::NoReferenceBus,
99            });
100            continue;
101        }
102
103        let unknown_idx: Vec<usize> = c
104            .bus_indices
105            .iter()
106            .copied()
107            .filter(|&i| buses[i].bus_type == BusType::PQ)
108            .collect();
109        let ok = solve_constant_admittance(buses, ybus, &unknown_idx);
110
111        let status = if !ok {
112            LinearIslandStatus::Singular
113        } else if c.slack_indices.len() > 1 {
114            LinearIslandStatus::SolvedMultipleReferences
115        } else {
116            LinearIslandStatus::Solved
117        };
118        islands.push(LinearIslandReport {
119            bus_indices: c.bus_indices.clone(),
120            slack_indices: c.slack_indices.clone(),
121            status,
122        });
123    }
124
125    LinearReport { islands }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::network::build_ybus;
132    use crate::types::Line;
133    use num_complex::Complex;
134
135    fn bus(idx: usize, bus_type: BusType, p_spec: f64, q_spec: f64) -> Bus {
136        Bus {
137            idx,
138            bus_type,
139            voltage_mag: 1.0,
140            voltage_ang: 0.0,
141            p_spec,
142            q_spec,
143            q_min: 0.0,
144            q_max: 0.0,
145            u_rated: 0.0,
146            zip_terms: Vec::new(),
147        }
148    }
149
150    /// The defining property of the method: a load that really *is* constant
151    /// impedance is solved exactly, so the resulting voltages reproduce the
152    /// specified power to round-off. Any other load model would leave an
153    /// error here.
154    #[test]
155    fn a_constant_impedance_load_is_reproduced_exactly() {
156        let mut buses = vec![
157            bus(0, BusType::Slack, 0.0, 0.0),
158            bus(1, BusType::PQ, -0.2, -0.1),
159        ];
160        let lines = vec![Line {
161            from: 0,
162            to: 1,
163            r: 0.02,
164            x: 0.06,
165            b_shunt: 0.0,
166            g_shunt: 0.0,
167        }];
168        let ybus = build_ybus(2, &lines, &[]).finish();
169        let report = linear_power_flow(&mut buses, &ybus);
170        assert_eq!(report.islands[0].status, LinearIslandStatus::Solved);
171
172        // The model replaced the load by y = -conj(S); at the solved voltage
173        // it therefore draws S·|U|², which is what we recover here.
174        let u: Vec<Complex<f64>> = buses
175            .iter()
176            .map(|b| Complex::from_polar(b.voltage_mag, b.voltage_ang))
177            .collect();
178        let i = ybus.mul_vec(&u);
179        let s1 = u[1] * i[1].conj();
180        let expected = Complex::new(-0.2, -0.1) * buses[1].voltage_mag.powi(2);
181        assert!((s1 - expected).norm() < 1e-12, "{s1} vs {expected}");
182    }
183
184    /// A load draws current, so the bus feeding it must sit below the source
185    /// — the sanity check that the sign of `Y_load` is right way round.
186    #[test]
187    fn load_depresses_voltage_and_lags_the_source() {
188        let mut buses = vec![
189            bus(0, BusType::Slack, 0.0, 0.0),
190            bus(1, BusType::PQ, -0.5, -0.2),
191        ];
192        let lines = vec![Line {
193            from: 0,
194            to: 1,
195            r: 0.02,
196            x: 0.06,
197            b_shunt: 0.0,
198            g_shunt: 0.0,
199        }];
200        let ybus = build_ybus(2, &lines, &[]).finish();
201        linear_power_flow(&mut buses, &ybus);
202        assert!(buses[1].voltage_mag < 1.0, "{}", buses[1].voltage_mag);
203        assert!(buses[1].voltage_ang < 0.0, "{}", buses[1].voltage_ang);
204    }
205
206    /// Sourceless islands are pinned, not invented — same contract as the AC
207    /// and DC paths.
208    #[test]
209    fn sourceless_island_is_reported_not_invented() {
210        let mut buses = vec![
211            bus(0, BusType::Slack, 0.0, 0.0),
212            bus(1, BusType::PQ, -0.2, 0.0),
213            bus(2, BusType::PQ, -0.1, 0.0),
214            bus(3, BusType::PQ, 0.1, 0.0),
215        ];
216        let lines = vec![
217            Line {
218                from: 0,
219                to: 1,
220                r: 0.02,
221                x: 0.06,
222                b_shunt: 0.0,
223                g_shunt: 0.0,
224            },
225            Line {
226                from: 2,
227                to: 3,
228                r: 0.02,
229                x: 0.06,
230                b_shunt: 0.0,
231                g_shunt: 0.0,
232            },
233        ];
234        let ybus = build_ybus(4, &lines, &[]).finish();
235        let report = linear_power_flow(&mut buses, &ybus);
236
237        assert_eq!(report.islands.len(), 2);
238        assert_eq!(report.islands[0].status, LinearIslandStatus::Solved);
239        assert_eq!(report.islands[1].status, LinearIslandStatus::NoReferenceBus);
240        assert_eq!(buses[2].voltage_mag, 0.0);
241    }
242
243    /// `linear_power_flow` must agree bus-for-bus with the warm-start entry
244    /// point it was extracted from, on a network where the two see the same
245    /// unknown set. This is what pins the promotion as a refactor rather than
246    /// a reimplementation.
247    #[test]
248    fn agrees_with_the_warm_start_entry_point() {
249        let lines = vec![
250            Line {
251                from: 0,
252                to: 1,
253                r: 0.02,
254                x: 0.06,
255                b_shunt: 0.03,
256                g_shunt: 0.0,
257            },
258            Line {
259                from: 1,
260                to: 2,
261                r: 0.04,
262                x: 0.10,
263                b_shunt: 0.02,
264                g_shunt: 0.0,
265            },
266        ];
267        let ybus = build_ybus(3, &lines, &[]).finish();
268
269        let make = || {
270            vec![
271                bus(0, BusType::Slack, 0.0, 0.0),
272                bus(1, BusType::PQ, -0.3, -0.1),
273                bus(2, BusType::PQ, -0.2, -0.05),
274            ]
275        };
276
277        let mut via_mode = make();
278        linear_power_flow(&mut via_mode, &ybus);
279
280        let mut via_warm_start = make();
281        crate::network::linear_initial_guess(&mut via_warm_start, &ybus);
282
283        for i in 0..3 {
284            assert!(
285                (via_mode[i].voltage_mag - via_warm_start[i].voltage_mag).abs() < 1e-15
286                    && (via_mode[i].voltage_ang - via_warm_start[i].voltage_ang).abs() < 1e-15,
287                "bus {i} disagrees"
288            );
289        }
290    }
291}