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}