Skip to main content

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}