gridoxide_core/dc.rs
1//! Real-valued DC network model and Newton-Raphson solver.
2//!
3//! A DC network has no angle and no reactive power — only a voltage
4//! magnitude and a real power/current balance at each node — so this is
5//! deliberately *not* built on [`crate::types::Bus`]/[`crate::types::Line`]:
6//! reusing those would drag in dead fields (`voltage_ang`, `q_spec`,
7//! `q_min`/`q_max`) and make AC-only machinery (`BusType::PV` Q-limit
8//! switching, the complex Y-bus) nonsensically reachable for a DC bus.
9//!
10//! Used for CGMES HVDC subsystems: a [`DcBus`] per `DCTopologicalNode`, a
11//! [`DcLine`] per resistive DC branch (`DCLineSegment`, a switch/breaker
12//! stamped as a near-zero resistance, etc.). These networks are small (a
13//! single HVDC link's switchyard fixture tops out around a dozen nodes), so
14//! `solve_dc_network` uses a plain dense Newton-Raphson solve with Gaussian
15//! elimination rather than pulling in `solver.rs`'s sparse-LU machinery,
16//! which exists to scale to real AC networks with thousands of buses.
17
18use super::network::{YBus, connected_components};
19use num_complex::Complex;
20
21/// What fixes a DC bus's voltage/current/power, mirroring the CGMES
22/// `pPccControl`/`ACDCConverter`/`DCGround` semantics that produce each role.
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub enum DcBusRole {
25 /// Voltage held fixed at `DcBus::udc_fixed` — the DC-voltage-regulating
26 /// converter of an HVDC link (`pPccControl=udc`/`dcVoltage`).
27 UdcSlack,
28 /// Voltage held fixed at 0 — a `DCGround` connection. Structurally
29 /// identical to `UdcSlack` (both remove the bus from the unknowns), kept
30 /// as its own variant so callers/reports can tell the two apart.
31 Ground,
32 /// Net power injection into the DC network held fixed
33 /// (`pPccControl=pPcc`) — the nonlinear row: `p_spec = V·ΣG·V`. Same
34 /// injection sign convention as `types::Bus::p_spec`: positive means
35 /// power flows OUT of this bus into the network (generation-like),
36 /// negative means it's drawn FROM the network (load-like) — the
37 /// opposite of CGMES's own SSH "load sign convention" on
38 /// `ACDCConverter.p`, so callers wiring in CGMES data negate it, exactly
39 /// as `cgmes.rs` already does for every AC injection.
40 FixedP { p_spec: f64 },
41 /// Net current injection into the DC network held fixed
42 /// (`pPccControl=dcCurrent`) — a row that's linear in the unknown
43 /// voltages: `idc_spec = ΣG·V`. Same injection-sign convention as
44 /// `FixedP::p_spec` above.
45 FixedIdc { idc_spec: f64 },
46 /// No injection at all — a plain junction/switchyard node
47 /// (`DCBusbar`, a closed switch's synthesized node, etc.). Same linear
48 /// form as `FixedIdc` with `idc_spec = 0`.
49 Passive,
50}
51
52#[derive(Clone, Debug)]
53pub struct DcBus {
54 pub idx: usize,
55 pub role: DcBusRole,
56 /// Fixed voltage for `UdcSlack`/`Ground` roles; ignored otherwise.
57 pub udc_fixed: f64,
58 /// Working/solved voltage. For `UdcSlack`/`Ground` this is overwritten
59 /// with `udc_fixed` at the start of `solve_dc_network`; for other roles
60 /// it's both the initial guess (0.0 means "let the solver pick one") and,
61 /// after solving, the result.
62 pub voltage: f64,
63 /// Extra conductance to ground at this node (e.g. a `DCShunt`, which has
64 /// only one terminal so can't be represented as a `DcLine`).
65 pub shunt_g: f64,
66}
67
68/// A resistive two-terminal DC branch: `DCLineSegment`, `DCSeriesDevice`, or
69/// a switch/breaker/disconnector stamped as a near-zero resistance.
70#[derive(Clone, Copy, Debug)]
71pub struct DcLine {
72 pub from: usize,
73 pub to: usize,
74 pub r: f64,
75}
76
77#[derive(Clone, Debug, PartialEq)]
78pub struct DcSolveStatus {
79 pub converged: bool,
80 pub iterations: usize,
81 /// Bus indices belonging to a connected component with no `UdcSlack`/
82 /// `Ground` reference at all (e.g. a disconnected spare switchyard
83 /// branch) — left unsolved (`voltage` untouched) rather than blocking
84 /// the rest of the network, mirroring how AC's multi-island support
85 /// isolates a no-reference component instead of failing the whole solve.
86 pub isolated_buses: Vec<usize>,
87}
88
89/// Builds the dense conductance ("real Y-bus") matrix for `lines`/`shunt_g`,
90/// and, via [`connected_components`], the connectivity graph used to isolate
91/// reference-less components. Reuses `network::YBus`'s triplet accumulation
92/// (with zero imaginary parts) rather than a separate graph implementation.
93fn build_conductance(buses: &[DcBus], lines: &[DcLine]) -> (Vec<Vec<f64>>, Vec<Vec<usize>>) {
94 let n = buses.len();
95 let mut g = vec![vec![0.0_f64; n]; n];
96 let mut ybus = YBus::new(n);
97 for line in lines {
98 let gij = 1.0 / line.r;
99 g[line.from][line.from] += gij;
100 g[line.to][line.to] += gij;
101 g[line.from][line.to] -= gij;
102 g[line.to][line.from] -= gij;
103 let c = Complex::new(gij, 0.0);
104 ybus.add(line.from, line.from, c);
105 ybus.add(line.to, line.to, c);
106 ybus.add(line.from, line.to, -c);
107 ybus.add(line.to, line.from, -c);
108 }
109 for (i, b) in buses.iter().enumerate() {
110 if b.shunt_g != 0.0 {
111 g[i][i] += b.shunt_g;
112 ybus.add(i, i, Complex::new(b.shunt_g, 0.0));
113 }
114 }
115 let components = connected_components(&ybus.finish());
116 (g, components)
117}
118
119/// `S[i] = Σ_j G[i][j]·V[j]` for every bus — the net current injected into
120/// the network at each node, given the buses' current `voltage` values.
121fn nodal_injections(g: &[Vec<f64>], buses: &[DcBus]) -> Vec<f64> {
122 (0..buses.len())
123 .map(|i| (0..buses.len()).map(|j| g[i][j] * buses[j].voltage).sum())
124 .collect()
125}
126
127/// The net current injected into the network at each bus (`S[i]` above),
128/// computed from `buses`' current (typically post-solve) voltages — the
129/// per-bus DC current `Idc`, in the same units as `DcLine::r`/`DcBus::voltage`
130/// combine to produce (kA, given resistance in Ω and voltage in kV). Used by
131/// CGMES conversion to translate a solved DC bus's state back into a
132/// converter's own AC-side power via its loss curve.
133pub fn injected_currents(buses: &[DcBus], lines: &[DcLine]) -> Vec<f64> {
134 let (g, _) = build_conductance(buses, lines);
135 nodal_injections(&g, buses)
136}
137
138/// Solves a small resistive DC network for the unknown bus voltages, given
139/// each bus's role (fixed voltage, fixed power, fixed current, or passive).
140///
141/// Newton-Raphson over the non-`UdcSlack`/`Ground` buses (`FixedIdc`/
142/// `Passive` rows are already linear — `ΣG·V - injection = 0` — but are
143/// solved in the same loop as `FixedP`'s genuinely nonlinear `V·ΣG·V -
144/// p_spec = 0` rather than special-cased, exactly as AC's P- and Q-mismatch
145/// rows differ in structure but share one Jacobian). Components with no
146/// `UdcSlack`/`Ground` reference at all are excluded from the unknowns
147/// entirely and reported via `DcSolveStatus::isolated_buses`, rather than
148/// producing a singular (indeterminate) system.
149pub fn solve_dc_network(
150 buses: &mut [DcBus],
151 lines: &[DcLine],
152 tol: f64,
153 max_iter: usize,
154) -> DcSolveStatus {
155 let (g, components) = build_conductance(buses, lines);
156
157 for b in buses.iter_mut() {
158 if matches!(b.role, DcBusRole::UdcSlack | DcBusRole::Ground) {
159 b.voltage = b.udc_fixed;
160 }
161 }
162
163 let mut isolated_buses = Vec::new();
164 let mut unknown_idx = Vec::new();
165 for component in &components {
166 let has_reference = component
167 .iter()
168 .any(|&i| matches!(buses[i].role, DcBusRole::UdcSlack | DcBusRole::Ground));
169 if has_reference {
170 for &i in component {
171 if !matches!(buses[i].role, DcBusRole::UdcSlack | DcBusRole::Ground) {
172 unknown_idx.push(i);
173 }
174 }
175 } else {
176 isolated_buses.extend(component.iter().copied());
177 }
178 }
179 unknown_idx.sort_unstable();
180
181 // Seed any not-yet-initialized (voltage == 0.0) unknown with the average
182 // fixed voltage in the network, so Newton starts from a physically
183 // sensible point rather than 0 V.
184 let fixed_avg = {
185 let fixed: Vec<f64> = buses
186 .iter()
187 .filter(|b| matches!(b.role, DcBusRole::UdcSlack))
188 .map(|b| b.udc_fixed)
189 .collect();
190 if fixed.is_empty() {
191 1.0
192 } else {
193 fixed.iter().sum::<f64>() / fixed.len() as f64
194 }
195 };
196 for &i in &unknown_idx {
197 if buses[i].voltage == 0.0 {
198 buses[i].voltage = fixed_avg;
199 }
200 }
201
202 let m = unknown_idx.len();
203 if m == 0 {
204 return DcSolveStatus {
205 converged: true,
206 iterations: 0,
207 isolated_buses,
208 };
209 }
210
211 for iter in 0..max_iter {
212 let s = nodal_injections(&g, buses);
213
214 let mut mismatch = vec![0.0; m];
215 let mut converged = true;
216 for (row, &i) in unknown_idx.iter().enumerate() {
217 let vi = buses[i].voltage;
218 mismatch[row] = match buses[i].role {
219 DcBusRole::FixedP { p_spec } => p_spec - vi * s[i],
220 DcBusRole::FixedIdc { idc_spec } => idc_spec - s[i],
221 DcBusRole::Passive => -s[i],
222 DcBusRole::UdcSlack | DcBusRole::Ground => {
223 unreachable!("fixed-voltage buses are excluded from unknown_idx")
224 }
225 };
226 if mismatch[row].abs() > tol {
227 converged = false;
228 }
229 }
230 if converged {
231 return DcSolveStatus {
232 converged: true,
233 iterations: iter,
234 isolated_buses,
235 };
236 }
237
238 let mut jac = vec![vec![0.0; m]; m];
239 for (row, &i) in unknown_idx.iter().enumerate() {
240 let vi = buses[i].voltage;
241 for (col, &k) in unknown_idx.iter().enumerate() {
242 jac[row][col] = match buses[i].role {
243 // d(p_spec - V_i·S_i)/dV_i = -(S_i + V_i·G_ii), since S_i
244 // itself already contains the G_ii·V_i term.
245 DcBusRole::FixedP { .. } if k == i => -(s[i] + g[i][i] * vi),
246 DcBusRole::FixedP { .. } => -vi * g[i][k],
247 DcBusRole::FixedIdc { .. } | DcBusRole::Passive => -g[i][k],
248 DcBusRole::UdcSlack | DcBusRole::Ground => unreachable!(),
249 };
250 }
251 }
252
253 let Some(delta) = gridoxide_sparse::sparse::solve_dense(jac, mismatch) else {
254 return DcSolveStatus {
255 converged: false,
256 iterations: iter,
257 isolated_buses,
258 };
259 };
260 for (row, &i) in unknown_idx.iter().enumerate() {
261 buses[i].voltage -= delta[row];
262 }
263 }
264
265 DcSolveStatus {
266 converged: false,
267 iterations: max_iter,
268 isolated_buses,
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 fn passive(idx: usize) -> DcBus {
277 DcBus {
278 idx,
279 role: DcBusRole::Passive,
280 udc_fixed: 0.0,
281 voltage: 0.0,
282 shunt_g: 0.0,
283 }
284 }
285
286 #[test]
287 fn two_node_fixed_p_matches_analytic_solution() {
288 // bus0 UdcSlack @ 100, bus1 FixedP, r=2.0. bus1 is load-like (draws
289 // power from the network), so p_spec is negative — the physical
290 // root is V1=90: S1 = (90-100)/2 = -5, P1 = 90*(-5) = -450.
291 let mut buses = vec![
292 DcBus {
293 idx: 0,
294 role: DcBusRole::UdcSlack,
295 udc_fixed: 100.0,
296 voltage: 100.0,
297 shunt_g: 0.0,
298 },
299 DcBus {
300 idx: 1,
301 role: DcBusRole::FixedP { p_spec: -450.0 },
302 udc_fixed: 0.0,
303 voltage: 0.0,
304 shunt_g: 0.0,
305 },
306 ];
307 let lines = vec![DcLine {
308 from: 0,
309 to: 1,
310 r: 2.0,
311 }];
312 let status = solve_dc_network(&mut buses, &lines, 1e-9, 50);
313 assert!(status.converged);
314 assert!(status.isolated_buses.is_empty());
315 assert!(
316 (buses[1].voltage - 90.0).abs() < 1e-6,
317 "V1 = {}",
318 buses[1].voltage
319 );
320 }
321
322 #[test]
323 fn three_node_passive_junction_splits_series_resistance() {
324 // bus0 UdcSlack @ 100 -- r=1 -- bus1 Passive -- r=1 -- bus2
325 // FixedIdc=-5 (load-like, draws current from the network).
326 // Series resistance: idc = (V2 - 100) / 2 = -5 => V2 = 90, V1 = 95.
327 let mut buses = vec![
328 DcBus {
329 idx: 0,
330 role: DcBusRole::UdcSlack,
331 udc_fixed: 100.0,
332 voltage: 100.0,
333 shunt_g: 0.0,
334 },
335 passive(1),
336 DcBus {
337 idx: 2,
338 role: DcBusRole::FixedIdc { idc_spec: -5.0 },
339 udc_fixed: 0.0,
340 voltage: 0.0,
341 shunt_g: 0.0,
342 },
343 ];
344 let lines = vec![
345 DcLine {
346 from: 0,
347 to: 1,
348 r: 1.0,
349 },
350 DcLine {
351 from: 1,
352 to: 2,
353 r: 1.0,
354 },
355 ];
356 let status = solve_dc_network(&mut buses, &lines, 1e-9, 50);
357 assert!(status.converged);
358 assert!(
359 (buses[1].voltage - 95.0).abs() < 1e-6,
360 "V1 = {}",
361 buses[1].voltage
362 );
363 assert!(
364 (buses[2].voltage - 90.0).abs() < 1e-6,
365 "V2 = {}",
366 buses[2].voltage
367 );
368 }
369
370 #[test]
371 fn fixed_idc_bus_converges_immediately_since_linear() {
372 let mut buses = vec![
373 DcBus {
374 idx: 0,
375 role: DcBusRole::UdcSlack,
376 udc_fixed: 100.0,
377 voltage: 100.0,
378 shunt_g: 0.0,
379 },
380 DcBus {
381 idx: 1,
382 role: DcBusRole::FixedIdc { idc_spec: -10.0 },
383 udc_fixed: 0.0,
384 voltage: 0.0,
385 shunt_g: 0.0,
386 },
387 ];
388 let lines = vec![DcLine {
389 from: 0,
390 to: 1,
391 r: 1.0,
392 }];
393 let status = solve_dc_network(&mut buses, &lines, 1e-9, 50);
394 assert!(status.converged);
395 assert!(
396 status.iterations <= 2,
397 "expected near-immediate convergence for a linear row, got {} iterations",
398 status.iterations
399 );
400 assert!(
401 (buses[1].voltage - 90.0).abs() < 1e-6,
402 "V1 = {}",
403 buses[1].voltage
404 );
405 }
406
407 #[test]
408 fn disconnected_dead_subgraph_does_not_perturb_live_solve() {
409 // Live subsystem: bus0 UdcSlack @ 100 -- r=2 -- bus1 FixedP=-450
410 // (same as the two-node test, so V1 should still be exactly 90).
411 // Dead subsystem: bus2 -- bus3, both Passive, no reference at all —
412 // must be isolated, not solved, and must not perturb the live pair.
413 let mut buses = vec![
414 DcBus {
415 idx: 0,
416 role: DcBusRole::UdcSlack,
417 udc_fixed: 100.0,
418 voltage: 100.0,
419 shunt_g: 0.0,
420 },
421 DcBus {
422 idx: 1,
423 role: DcBusRole::FixedP { p_spec: -450.0 },
424 udc_fixed: 0.0,
425 voltage: 0.0,
426 shunt_g: 0.0,
427 },
428 passive(2),
429 passive(3),
430 ];
431 let lines = vec![
432 DcLine {
433 from: 0,
434 to: 1,
435 r: 2.0,
436 },
437 DcLine {
438 from: 2,
439 to: 3,
440 r: 1.0,
441 },
442 ];
443 let status = solve_dc_network(&mut buses, &lines, 1e-9, 50);
444 assert!(status.converged);
445 assert!(
446 (buses[1].voltage - 90.0).abs() < 1e-6,
447 "V1 = {}",
448 buses[1].voltage
449 );
450 let mut isolated = status.isolated_buses.clone();
451 isolated.sort_unstable();
452 assert_eq!(isolated, vec![2, 3]);
453 // Untouched: no fixed reference means no sensible value to compute.
454 assert_eq!(buses[2].voltage, 0.0);
455 assert_eq!(buses[3].voltage, 0.0);
456 }
457}