Skip to main content

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}