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}