gridoxide_core/batch.rs
1//! Batched power flow: one topology, many scenarios, solved across all cores.
2//!
3//! This is the workload where parallelism actually pays. A single solve is
4//! ~60% sparse LU, which neither threads nor a
5//! GPU help with; but N-1 screening, time series/QSTS and Monte Carlo run
6//! *thousands* of independent solves over one unchanging topology, and those
7//! scale essentially linearly.
8//!
9//! Two properties make that cheap here:
10//!
11//! - Every scenario shares one [`YBusSparse`], so the Jacobian's sparsity
12//! pattern is identical across the whole batch. Each worker keeps one
13//! [`PersistentSolver`] and amortizes a single symbolic factorization
14//! across its entire share of the batch — measured at ~45% of solve time
15//! on a 9,241-bus case when redone per solve.
16//! - Scenarios are fully independent, so the only coordination is handing
17//! out indices.
18//!
19//! [`BatchSolver::solve_contingencies`] extends this to **branch outages**,
20//! which change the network rather than its bus values. The first property
21//! above appears to rule that out — and it was long assumed to — but only the
22//! *sparsity pattern* has to hold still, not the Y-bus itself:
23//! [`network::build_ybus_with_outages`](crate::network::build_ybus_with_outages)
24//! takes a branch out while keeping its structural entries, so the symbolic
25//! factorization still carries across the sweep. What cannot carry is the
26//! Jacobian's cached per-nonzero recipe, which stores the admittance it was
27//! analyzed against; that is re-derived per scenario via
28//! [`PersistentSolver::invalidate_admittances`], at O(nonzeros) rather than a
29//! fresh symbolic analysis. Measured at 2.0–2.7x against independent solves,
30//! single-threaded, before any parallelism.
31//!
32//! This is also the CPU baseline any future GPU work has to beat: beating a
33//! single-threaded CPU solver is not a result.
34
35use std::sync::Mutex;
36use std::sync::atomic::{AtomicUsize, Ordering};
37
38use crate::PowerFlowReport;
39use crate::network::{
40 ShuntAdm, YBusSparse, build_ybus, build_ybus_with_outages, linear_initial_guess, stamp_shunts,
41 structural_component_count,
42};
43pub use crate::scenario::{BatchError, BusOverride, Scenario, uniform_load_scaling};
44use crate::solver::{JacobianBackend, PersistentSolver};
45use crate::types::{Bus, Line, Transformer};
46
47/// Solves many scenarios over one shared topology, in parallel.
48///
49/// ```no_run
50/// # use gridoxide_core::batch::{BatchSolver, Scenario, BusOverride};
51/// # use gridoxide_core::solver::JacobianBackend;
52/// # use gridoxide_core::network::YBusSparse;
53/// # use gridoxide_core::types::Bus;
54/// # fn example(buses: Vec<Bus>, ybus: &YBusSparse) {
55/// let scenarios: Vec<Scenario> = (0..256)
56/// .map(|k| Scenario::new(vec![BusOverride::new(3).p(-0.1 * k as f64)]))
57/// .collect();
58/// let batch = BatchSolver::new(JacobianBackend::KluNative);
59/// let reports = batch.solve(&buses, ybus, &scenarios, 1e-6, 20).unwrap();
60/// assert_eq!(reports.len(), 256);
61/// # }
62/// ```
63pub struct BatchSolver {
64 backend: JacobianBackend,
65 /// Built once and reused across `solve` calls. `None` uses rayon's
66 /// global pool, which is also what `faer` uses internally — sharing it
67 /// is what prevents nested parallelism from oversubscribing the machine.
68 pool: Option<rayon::ThreadPool>,
69}
70
71impl BatchSolver {
72 /// Uses rayon's global thread pool (honors `RAYON_NUM_THREADS`).
73 pub fn new(backend: JacobianBackend) -> Self {
74 Self {
75 backend,
76 pool: None,
77 }
78 }
79
80 /// Uses a dedicated pool of exactly `threads` workers. Built once here
81 /// and reused by every `solve` call, so repeated batches never pay
82 /// thread-creation cost.
83 pub fn with_threads(backend: JacobianBackend, threads: usize) -> Result<Self, BatchError> {
84 let pool = rayon::ThreadPoolBuilder::new()
85 .num_threads(threads)
86 .build()
87 .map_err(|e| BatchError::ThreadPool(e.to_string()))?;
88 Ok(Self {
89 backend,
90 pool: Some(pool),
91 })
92 }
93
94 /// Workers this solver will use for a batch.
95 pub fn threads(&self) -> usize {
96 match &self.pool {
97 Some(p) => p.current_num_threads(),
98 None => rayon::current_num_threads(),
99 }
100 }
101
102 /// Solves every scenario and returns one [`PowerFlowReport`] each, **in
103 /// scenario order regardless of thread count**.
104 ///
105 /// Each scenario starts from `buses_template` with its overrides applied
106 /// and a fresh [`linear_initial_guess`], so scenarios never influence
107 /// each other's starting point — results are identical to a sequential
108 /// loop over `PersistentSolver::solve`, which `tests/batch_solver_test.rs`
109 /// asserts directly.
110 ///
111 /// A scenario that fails to converge is *not* an error: its report
112 /// carries `SolveStatus::MaxIterationsReached`/`Singular` and the rest of
113 /// the batch is unaffected. Divergent contingency scenarios are normal
114 /// and must not poison the batch.
115 pub fn solve(
116 &self,
117 buses_template: &[Bus],
118 ybus: &YBusSparse,
119 scenarios: &[Scenario],
120 tol: f64,
121 max_iter: usize,
122 ) -> Result<Vec<PowerFlowReport>, BatchError> {
123 for (i, sc) in scenarios.iter().enumerate() {
124 if !sc.branch_outages.is_empty() {
125 return Err(BatchError::OutagesUnsupported { scenario: i });
126 }
127 for ov in &sc.bus_overrides {
128 if ov.bus >= buses_template.len() {
129 return Err(BatchError::BusOutOfRange {
130 scenario: i,
131 bus: ov.bus,
132 n_buses: buses_template.len(),
133 });
134 }
135 }
136 }
137 if scenarios.is_empty() {
138 return Ok(Vec::new());
139 }
140
141 let next = AtomicUsize::new(0);
142 let collected: Mutex<Vec<(usize, PowerFlowReport)>> =
143 Mutex::new(Vec::with_capacity(scenarios.len()));
144 let n_workers = self.threads().max(1).min(scenarios.len());
145 let backend = self.backend;
146
147 let run = || {
148 rayon::scope(|s| {
149 for _ in 0..n_workers {
150 s.spawn(|_| {
151 // Constructed *inside* the spawned closure, so it
152 // never crosses a thread boundary. This is what
153 // keeps the `!Send` FFI backends usable here:
154 // `KluRealSystem` owns raw `*mut klu_symbolic` and
155 // `PardisoRealSystem` a `[*mut c_void; 64]`, so a
156 // `PersistentSolver` holding either is not `Send`.
157 // `rayon::scope`/`spawn` only require the *closure*
158 // to be `Send`; work-stealing steals unstarted
159 // tasks and never migrates one mid-execution, so
160 // this local stays on one thread for its lifetime.
161 // (`rayon`'s `map_init` would demand `T: Send` and
162 // silently exclude those two backends.)
163 let mut solver = PersistentSolver::new(backend);
164
165 loop {
166 let i = next.fetch_add(1, Ordering::Relaxed);
167 if i >= scenarios.len() {
168 break;
169 }
170
171 let mut buses = buses_template.to_vec();
172 for ov in &scenarios[i].bus_overrides {
173 let b = &mut buses[ov.bus];
174 if let Some(p) = ov.p_spec {
175 b.p_spec = p;
176 }
177 if let Some(q) = ov.q_spec {
178 b.q_spec = q;
179 }
180 if let Some(vm) = ov.voltage_mag {
181 b.voltage_mag = vm;
182 }
183 }
184
185 linear_initial_guess(&mut buses, ybus);
186 let (islands, stats) =
187 solver.solve_with_stats(&mut buses, ybus, tol, max_iter);
188 collected
189 .lock()
190 .expect("batch result mutex poisoned by a panicking worker")
191 .push((
192 i,
193 PowerFlowReport {
194 buses,
195 islands,
196 stats,
197 dc: None,
198 linear: None,
199 outer: None,
200 },
201 ));
202 }
203 });
204 }
205 });
206 };
207
208 match &self.pool {
209 Some(pool) => pool.install(run),
210 None => run(),
211 }
212
213 let mut out = collected.into_inner().expect("batch result mutex poisoned");
214 out.sort_by_key(|(i, _)| *i);
215 Ok(out.into_iter().map(|(_, r)| r).collect())
216 }
217
218 /// Solves every scenario **honoring [`Scenario::branch_outages`]**,
219 /// returning one [`PowerFlowReport`] each in scenario order.
220 ///
221 /// Takes branch lists rather than a finished Y-bus, because an outage
222 /// changes the network rather than its bus values, and a Y-bus has already
223 /// summed the branches away.
224 ///
225 /// # Two paths, and why
226 ///
227 /// Most contingencies leave the network connected. Those take the fast
228 /// path: [`network::build_ybus_with_outages`](crate::network::build_ybus_with_outages)
229 /// rebuilds the Y-bus with the outaged branches contributing nothing but
230 /// **keeping their structural entries**, so the sparsity pattern — and
231 /// therefore `jacobian::JacobianPattern` and the symbolic factorization
232 /// behind it — is bit-for-bit the intact network's. A worker reuses one
233 /// [`PersistentSolver`] across every such scenario it handles, exactly as
234 /// [`solve`](Self::solve) does for injection-only batches. That is what
235 /// this method exists to make possible, and it is why the old
236 /// "an outage forces its own sparsity pattern" objection turns out to be
237 /// avoidable in the common case.
238 ///
239 /// A contingency that *splits* the network cannot use that trick:
240 /// [`connected_components`](crate::network::connected_components) reads the
241 /// same structural entries and cannot tell a zero-valued one from a live
242 /// one, so it would report the severed buses as still connected and their
243 /// now-referenceless rows would come back as a singular solve instead of an
244 /// honest `NoReferenceBus`. Those scenarios are detected up front with
245 /// [`structural_component_count`]
246 /// — removing branches can only *increase* the component count, so a count
247 /// above the intact network's is exactly the "something split" condition —
248 /// and take a slow path: the outaged branches are dropped outright and the
249 /// worker's solver is reset around that scenario, since its pattern
250 /// genuinely differs.
251 ///
252 /// A scenario that fails to converge is not an error: its report carries
253 /// the failure and the rest of the batch is unaffected. Islanding into a
254 /// sourceless region is not a failure at all — that island comes back
255 /// `IslandStatus::NoReferenceBus`, which for N-1 screening is a result.
256 #[allow(clippy::too_many_arguments)]
257 pub fn solve_contingencies(
258 &self,
259 buses_template: &[Bus],
260 lines: &[Line],
261 transformers: &[Transformer],
262 shunts: &[ShuntAdm],
263 scenarios: &[Scenario],
264 tol: f64,
265 max_iter: usize,
266 ) -> Result<Vec<PowerFlowReport>, BatchError> {
267 let n_branches = lines.len() + transformers.len();
268 for (i, sc) in scenarios.iter().enumerate() {
269 for &b in &sc.branch_outages {
270 if b >= n_branches {
271 return Err(BatchError::BranchOutOfRange {
272 scenario: i,
273 branch: b,
274 n_branches,
275 });
276 }
277 }
278 for ov in &sc.bus_overrides {
279 if ov.bus >= buses_template.len() {
280 return Err(BatchError::BusOutOfRange {
281 scenario: i,
282 bus: ov.bus,
283 n_buses: buses_template.len(),
284 });
285 }
286 }
287 }
288 if scenarios.is_empty() {
289 return Ok(Vec::new());
290 }
291
292 let n = buses_template.len();
293 let intact_components = structural_component_count(n, lines, transformers, &[]);
294
295 // Decided once, up front, so workers never repeat the analysis: which
296 // branches are out, and whether taking them out split the network.
297 let plans: Vec<(Vec<bool>, bool)> = scenarios
298 .iter()
299 .map(|sc| {
300 let mut outaged = vec![false; n_branches];
301 for &b in &sc.branch_outages {
302 outaged[b] = true;
303 }
304 let splits = !sc.branch_outages.is_empty()
305 && structural_component_count(n, lines, transformers, &outaged)
306 != intact_components;
307 (outaged, splits)
308 })
309 .collect();
310
311 let next = AtomicUsize::new(0);
312 let collected: Mutex<Vec<(usize, PowerFlowReport)>> =
313 Mutex::new(Vec::with_capacity(scenarios.len()));
314 let n_workers = self.threads().max(1).min(scenarios.len());
315 let backend = self.backend;
316
317 let run = || {
318 rayon::scope(|s| {
319 for _ in 0..n_workers {
320 s.spawn(|_| {
321 // Inside the closure for the same reason as in
322 // `solve`: it is what keeps the `!Send` FFI backends
323 // usable here.
324 let mut solver = PersistentSolver::new(backend);
325
326 loop {
327 let i = next.fetch_add(1, Ordering::Relaxed);
328 if i >= scenarios.len() {
329 break;
330 }
331 let (outaged, splits) = &plans[i];
332
333 let mut buses = buses_template.to_vec();
334 for ov in &scenarios[i].bus_overrides {
335 let b = &mut buses[ov.bus];
336 if let Some(p) = ov.p_spec {
337 b.p_spec = p;
338 }
339 if let Some(q) = ov.q_spec {
340 b.q_spec = q;
341 }
342 if let Some(vm) = ov.voltage_mag {
343 b.voltage_mag = vm;
344 }
345 }
346
347 let mut ybus = if *splits {
348 let keep_lines: Vec<Line> = lines
349 .iter()
350 .enumerate()
351 .filter(|(k, _)| !outaged[*k])
352 .map(|(_, l)| l.clone())
353 .collect();
354 let keep_transformers: Vec<Transformer> = transformers
355 .iter()
356 .enumerate()
357 .filter(|(k, _)| !outaged[lines.len() + k])
358 .map(|(_, t)| t.clone())
359 .collect();
360 // This scenario's pattern differs from the one
361 // the worker has been reusing.
362 solver.reset();
363 build_ybus(n, &keep_lines, &keep_transformers)
364 } else {
365 // Same pattern, different values: the symbolic
366 // factorization survives, the Jacobian's cached
367 // admittances do not.
368 solver.invalidate_admittances();
369 build_ybus_with_outages(n, lines, transformers, outaged)
370 };
371 stamp_shunts(&mut ybus, shunts);
372 let ybus = ybus.finish();
373
374 linear_initial_guess(&mut buses, &ybus);
375 let (islands, stats) =
376 solver.solve_with_stats(&mut buses, &ybus, tol, max_iter);
377 if *splits {
378 // ...and must not leak into the next one.
379 solver.reset();
380 }
381
382 collected
383 .lock()
384 .expect("batch result mutex poisoned by a panicking worker")
385 .push((
386 i,
387 PowerFlowReport {
388 buses,
389 islands,
390 stats,
391 dc: None,
392 linear: None,
393 outer: None,
394 },
395 ));
396 }
397 });
398 }
399 });
400 };
401
402 match &self.pool {
403 Some(pool) => pool.install(run),
404 None => run(),
405 }
406
407 let mut out = collected.into_inner().expect("batch result mutex poisoned");
408 out.sort_by_key(|(i, _)| *i);
409 Ok(out.into_iter().map(|(_, r)| r).collect())
410 }
411}