Skip to main content

gridoxide_core/linear/
batch.rs

1//! Batched DC power flow: many scenarios over one topology, one factorization
2//! in total.
3//!
4//! [`batch::BatchSolver`](crate::batch::BatchSolver) can reuse only the
5//! *symbolic* half of its factorization across scenarios, because Newton's
6//! Jacobian changes numerically at every iteration of every scenario. DC has
7//! no such problem: `B` depends on the topology alone, so a bus-injection
8//! scenario changes only the right-hand side. That makes batching here a
9//! qualitatively cheaper operation, not merely a parallel one.
10//!
11//! The implementation leans on DC being *exactly* linear rather than
12//! re-solving anything:
13//!
14//! \\[ \theta_s = \theta_{base} + \Delta\theta(\Delta P_s), \qquad
15//!    P^{f}_s = P^{f}_{base} + \Delta P^{f}(\Delta P_s) \\]
16//!
17//! One base solve establishes the phase-shift and reference-angle terms; every
18//! scenario is then that base plus [`DcSensitivity::response`] to its own
19//! injection delta. Since `response` solves against a factorization built once
20//! in [`DcSensitivity::new`], a batch of ten thousand scenarios performs
21//! exactly one numeric factorization.
22//!
23//! This is not an approximation, and
24//! `tests/dc_batch_test.rs::batch_matches_a_sequential_loop_exactly` asserts it
25//! against a plain loop over [`dc_power_flow`] to 1e-12.
26//!
27//! # Contingencies
28//!
29//! [`Scenario::branch_outages`](crate::batch::Scenario::branch_outages) is
30//! rejected here, as it is on the AC path — but for the opposite reason. On AC
31//! it is unimplemented because an outage gives each scenario its own sparsity
32//! pattern. On DC it is *unnecessary*: an outage's effect on flows is
33//! [`DcSensitivity::outage_flows`], one solve and no refactorization at all, so
34//! folding it into a scenario type designed around per-bus overrides would be
35//! a worse API than the one that already exists. See
36//! `docs/src/powerflow/dc.md`.
37
38use std::sync::Mutex;
39
40use crate::scenario::{BatchError, Scenario};
41use crate::types::{Bus, Line, Transformer};
42
43use super::DcOptions;
44use super::btheta::{DcIslandReport, DcSolution, dc_branches, dc_power_flow};
45use super::sensitivity::DcSensitivity;
46
47/// One scenario's DC answer.
48#[derive(Clone, Debug, PartialEq)]
49pub struct DcBatchResult {
50    /// Bus voltage angles, radians, one per bus.
51    pub voltage_ang: Vec<f64>,
52    /// Active power entering each branch at its `from` terminal, per-unit,
53    /// indexed by the flat branch index — the same space
54    /// [`DcSolution::branch_p`] uses.
55    pub branch_p: Vec<f64>,
56    /// Total active power each island's reference bus(es) supply, per-unit,
57    /// in the same order as [`DcBatchSolver::islands`].
58    ///
59    /// DC is lossless, so this is exactly the negation of everything else the
60    /// island injects — computed from the scenario's injections directly
61    /// rather than from the solved flows.
62    pub slack_pickup: Vec<f64>,
63}
64
65/// Solves many DC scenarios over one shared topology.
66///
67/// ```no_run
68/// # use gridoxide_core::batch::{BusOverride, Scenario};
69/// # use gridoxide_core::linear::{batch::DcBatchSolver, DcOptions};
70/// # use gridoxide_core::types::{Bus, Line, Transformer};
71/// # fn example(buses: Vec<Bus>, lines: Vec<Line>, transformers: Vec<Transformer>) {
72/// let scenarios: Vec<Scenario> = (0..10_000)
73///     .map(|k| Scenario::new(vec![BusOverride::new(3).p(-0.1 * k as f64)]))
74///     .collect();
75/// let batch = DcBatchSolver::new();
76/// let results = batch
77///     .solve(&buses, &lines, &transformers, DcOptions::default(), &scenarios)
78///     .unwrap();
79/// assert_eq!(results.len(), 10_000);
80/// # }
81/// ```
82pub struct DcBatchSolver {
83    /// Built once and reused across `solve` calls. `None` uses rayon's global
84    /// pool, which `faer` also uses internally — sharing it is what keeps
85    /// nested parallelism from oversubscribing the machine.
86    pool: Option<rayon::ThreadPool>,
87}
88
89impl Default for DcBatchSolver {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl DcBatchSolver {
96    /// Uses rayon's global thread pool (honors `RAYON_NUM_THREADS`).
97    pub fn new() -> Self {
98        Self { pool: None }
99    }
100
101    /// Uses a dedicated pool of exactly `threads` workers, built once here and
102    /// reused by every `solve` call.
103    pub fn with_threads(threads: usize) -> Result<Self, BatchError> {
104        let pool = rayon::ThreadPoolBuilder::new()
105            .num_threads(threads)
106            .build()
107            .map_err(|e| BatchError::ThreadPool(e.to_string()))?;
108        Ok(Self { pool: Some(pool) })
109    }
110
111    pub fn threads(&self) -> usize {
112        match &self.pool {
113            Some(p) => p.current_num_threads(),
114            None => rayon::current_num_threads(),
115        }
116    }
117
118    /// Solves every scenario, returning one [`DcBatchResult`] each **in
119    /// scenario order regardless of thread count**.
120    ///
121    /// Each scenario starts from `buses_template` with its own overrides
122    /// applied, so scenarios never influence one another.
123    /// `BusOverride::voltage_mag` is ignored: DC has no magnitude state to
124    /// set. `BusOverride::q_spec` is ignored too, for the same reason.
125    ///
126    /// Errors only for input that cannot be interpreted — an out-of-range bus,
127    /// a branch outage, or a network whose reduced `B` is singular. A scenario
128    /// cannot "fail to converge", since nothing iterates.
129    pub fn solve(
130        &self,
131        buses_template: &[Bus],
132        lines: &[Line],
133        transformers: &[Transformer],
134        opts: DcOptions,
135        scenarios: &[Scenario],
136    ) -> Result<Vec<DcBatchResult>, BatchError> {
137        for (i, sc) in scenarios.iter().enumerate() {
138            if !sc.branch_outages.is_empty() {
139                return Err(BatchError::OutagesUnsupported { scenario: i });
140            }
141            for ov in &sc.bus_overrides {
142                if ov.bus >= buses_template.len() {
143                    return Err(BatchError::BusOutOfRange {
144                        scenario: i,
145                        bus: ov.bus,
146                        n_buses: buses_template.len(),
147                    });
148                }
149            }
150        }
151        if scenarios.is_empty() {
152            return Ok(Vec::new());
153        }
154
155        let prepared = Prepared::new(buses_template, lines, transformers, opts)
156            .ok_or(BatchError::DcSingular)?;
157
158        let collected: Mutex<Vec<(usize, DcBatchResult)>> =
159            Mutex::new(Vec::with_capacity(scenarios.len()));
160        let run = || {
161            use rayon::prelude::*;
162            scenarios.par_iter().enumerate().for_each(|(i, sc)| {
163                let result = prepared.solve_one(sc);
164                collected
165                    .lock()
166                    .expect("batch collector poisoned")
167                    .push((i, result));
168            });
169        };
170        match &self.pool {
171            Some(pool) => pool.install(run),
172            None => run(),
173        }
174
175        let mut collected = collected.into_inner().expect("batch collector poisoned");
176        collected.sort_by_key(|(i, _)| *i);
177        Ok(collected.into_iter().map(|(_, r)| r).collect())
178    }
179
180    /// The base solve's per-island breakdown, in the order
181    /// [`DcBatchResult::slack_pickup`] indexes.
182    ///
183    /// Computed from the template, so it is the same for every scenario —
184    /// island membership depends on topology, which a bus override cannot
185    /// change.
186    pub fn islands(
187        buses_template: &[Bus],
188        lines: &[Line],
189        transformers: &[Transformer],
190        opts: DcOptions,
191    ) -> Vec<DcIslandReport> {
192        let mut scratch = buses_template.to_vec();
193        dc_power_flow(&mut scratch, lines, transformers, opts).islands
194    }
195}
196
197/// The base solve plus the one factorization every scenario shares.
198struct Prepared {
199    base_ang: Vec<f64>,
200    base: DcSolution,
201    sensitivity: DcSensitivity,
202    /// Per-bus injection the base solve used, so a scenario's delta can be
203    /// formed without re-deriving it.
204    base_injection: Vec<f64>,
205    /// Per-bus sum of the template's ZIP terms. A `BusOverride` replaces
206    /// `p_spec` alone, so a scenario's injection is its override plus this —
207    /// ZIP terms belong to the template and survive an override, exactly as
208    /// they do on the AC path.
209    zip_sum: Vec<f64>,
210    /// Per island, the non-slack member buses — all that
211    /// [`DcBatchResult::slack_pickup`] needs, since DC is lossless.
212    island_members: Vec<Vec<usize>>,
213}
214
215impl Prepared {
216    fn new(
217        buses_template: &[Bus],
218        lines: &[Line],
219        transformers: &[Transformer],
220        opts: DcOptions,
221    ) -> Option<Self> {
222        let mut scratch = buses_template.to_vec();
223        let base = dc_power_flow(&mut scratch, lines, transformers, opts);
224        let base_ang: Vec<f64> = scratch.iter().map(|b| b.voltage_ang).collect();
225
226        let branches = dc_branches(lines, transformers, opts);
227        let n_branches = lines.len() + transformers.len();
228        // Built from the *marked* buses `dc_power_flow` left behind, so an
229        // unreferenced island is excluded from both consistently.
230        let sensitivity = DcSensitivity::new(&scratch, &branches, n_branches)?;
231
232        let zip_sum: Vec<f64> = buses_template.iter().map(zip_total).collect();
233        let base_injection: Vec<f64> = buses_template
234            .iter()
235            .zip(&zip_sum)
236            .map(|(b, z)| b.p_spec + z)
237            .collect();
238        let island_members = base
239            .islands
240            .iter()
241            .map(|island| {
242                island
243                    .bus_indices
244                    .iter()
245                    .copied()
246                    .filter(|&i| !island.slack_indices.contains(&i))
247                    .collect()
248            })
249            .collect();
250
251        Some(Self {
252            base_ang,
253            base,
254            sensitivity,
255            base_injection,
256            zip_sum,
257            island_members,
258        })
259    }
260
261    fn solve_one(&self, scenario: &Scenario) -> DcBatchResult {
262        // The injection delta is all DC responds to. A delta at a slack bus is
263        // harmless: `response` builds its right-hand side from each island's
264        // *unknowns*, so a reference's injection is never read — which is
265        // correct, since a slack's injection is an output of the solve.
266        let mut delta = vec![0.0; self.base_injection.len()];
267        let mut scenario_injection = self.base_injection.clone();
268        for ov in &scenario.bus_overrides {
269            if let Some(p) = ov.p_spec {
270                let new = p + self.zip_sum[ov.bus];
271                delta[ov.bus] = new - self.base_injection[ov.bus];
272                scenario_injection[ov.bus] = new;
273            }
274        }
275
276        let (d_ang, d_flow) = self
277            .sensitivity
278            .response(&delta)
279            .expect("delta is one entry per bus and the factorization is non-singular");
280
281        let voltage_ang: Vec<f64> = self
282            .base_ang
283            .iter()
284            .zip(&d_ang)
285            .map(|(a, d)| a + d)
286            .collect();
287        let branch_p: Vec<f64> = self
288            .base
289            .branch_p
290            .iter()
291            .zip(&d_flow)
292            .map(|(p, d)| p + d)
293            .collect();
294        // Lossless, so each island's reference supplies exactly the negation
295        // of everything else in it.
296        let slack_pickup = self
297            .island_members
298            .iter()
299            .map(|members| -members.iter().map(|&i| scenario_injection[i]).sum::<f64>())
300            .collect();
301
302        DcBatchResult {
303            voltage_ang,
304            branch_p,
305            slack_pickup,
306        }
307    }
308}
309
310/// A bus's ZIP terms summed as active power. Every `ZipKind` collapses to its
311/// own `s_const` at the DC assumption `|V| = 1`, which is the same reduction
312/// `btheta::dc_injection` performs (private to that module).
313fn zip_total(bus: &Bus) -> f64 {
314    bus.zip_terms.iter().map(|z| z.s_const.re).sum()
315}