gridoxide_core/scenario.rs
1//! What a batch varies: per-bus overrides and branch outages against a
2//! shared template, and the errors a batch reports for them.
3//!
4//! Shared by the AC [`batch::BatchSolver`](crate::batch::BatchSolver) and the
5//! DC [`linear::batch::DcBatchSolver`](crate::linear::batch::DcBatchSolver).
6//! Kept apart from either so that `linear` need not depend on the AC batch
7//! solver, which itself sits above `linear`; `batch` re-exports everything
8//! here, so `gridoxide::batch::Scenario` and friends resolve as before.
9
10use std::fmt;
11
12use crate::types::Bus;
13
14/// A per-bus change applied on top of the batch's shared bus template.
15/// `None` fields leave the template's value alone.
16///
17/// Deliberately cannot change `bus_type`: switching `PV`↔`PQ` changes
18/// `n_unknowns`, which invalidates the cached symbolic factorization that
19/// makes batching worth doing at all. A caller that needs per-scenario bus
20/// types wants separate batches, one per type assignment.
21#[derive(Clone, Copy, Debug, Default, PartialEq)]
22pub struct BusOverride {
23 pub bus: usize,
24 pub p_spec: Option<f64>,
25 pub q_spec: Option<f64>,
26 /// Voltage setpoint for a `Slack`/`PV` bus. Ignored for `PQ` buses,
27 /// whose magnitude is an unknown the solver overwrites.
28 pub voltage_mag: Option<f64>,
29}
30
31impl BusOverride {
32 pub fn new(bus: usize) -> Self {
33 Self {
34 bus,
35 ..Default::default()
36 }
37 }
38
39 pub fn p(mut self, p_spec: f64) -> Self {
40 self.p_spec = Some(p_spec);
41 self
42 }
43
44 pub fn q(mut self, q_spec: f64) -> Self {
45 self.q_spec = Some(q_spec);
46 self
47 }
48
49 pub fn vm(mut self, voltage_mag: f64) -> Self {
50 self.voltage_mag = Some(voltage_mag);
51 self
52 }
53}
54
55/// One scenario in a batch: a set of bus-value overrides against the shared
56/// template.
57#[derive(Clone, Debug, Default, PartialEq)]
58pub struct Scenario {
59 pub bus_overrides: Vec<BusOverride>,
60 /// Flat branch indices to take out of service — lines first, then
61 /// transformers, the same space `branch_flow::branch_params` uses.
62 ///
63 /// Honored by
64 /// [`BatchSolver::solve_contingencies`](crate::batch::BatchSolver::solve_contingencies),
65 /// which takes the branch lists an outage needs.
66 /// [`BatchSolver::solve`](crate::batch::BatchSolver::solve) still rejects a non-empty
67 /// list with [`BatchError::OutagesUnsupported`] rather than ignoring it: it
68 /// is handed a finished Y-bus, in which the branches have already been
69 /// summed away and cannot be removed.
70 pub branch_outages: Vec<usize>,
71}
72
73impl Scenario {
74 pub fn new(bus_overrides: Vec<BusOverride>) -> Self {
75 Self {
76 bus_overrides,
77 branch_outages: Vec::new(),
78 }
79 }
80}
81
82/// Builds a scenario scaling every bus's `p_spec`/`q_spec` by `factor` —
83/// the standard time-series/QSTS and Monte Carlo shape, and what
84/// `scripts/bench/bench_batch.py` drives.
85///
86/// Buses with zero injection stay zero, so this touches only buses that
87/// actually carry load or generation.
88pub fn uniform_load_scaling(buses_template: &[Bus], factor: f64) -> Scenario {
89 let overrides = buses_template
90 .iter()
91 .filter(|b| b.p_spec != 0.0 || b.q_spec != 0.0)
92 .map(|b| {
93 BusOverride::new(b.idx)
94 .p(b.p_spec * factor)
95 .q(b.q_spec * factor)
96 })
97 .collect();
98 Scenario::new(overrides)
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub enum BatchError {
103 /// `Scenario::branch_outages` was non-empty on
104 /// [`BatchSolver::solve`](crate::batch::BatchSolver::solve), which cannot
105 /// honor it. See that field's doc comment, and use
106 /// [`BatchSolver::solve_contingencies`](crate::batch::BatchSolver::solve_contingencies)
107 /// instead.
108 OutagesUnsupported { scenario: usize },
109 /// A `BusOverride::bus` was not a valid index into the bus template.
110 BusOutOfRange {
111 scenario: usize,
112 bus: usize,
113 n_buses: usize,
114 },
115 /// A `Scenario::branch_outages` entry was not a valid flat branch index.
116 BranchOutOfRange {
117 scenario: usize,
118 branch: usize,
119 n_branches: usize,
120 },
121 /// `rayon` refused to build the requested thread pool.
122 ThreadPool(String),
123 /// The reduced susceptance matrix was singular, so no scenario in a
124 /// [`linear::batch::DcBatchSolver`](crate::linear::batch::DcBatchSolver)
125 /// batch can be solved.
126 ///
127 /// Unlike the Newton path — where a scenario that fails is reported *in*
128 /// its own result and never poisons the batch — this is a property of the
129 /// shared topology rather than of any one scenario, so it fails the whole
130 /// call.
131 DcSingular,
132}
133
134impl fmt::Display for BatchError {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 match self {
137 BatchError::OutagesUnsupported { scenario } => write!(
138 f,
139 "scenario {scenario} requests branch outages, which `solve` cannot honor from a \
140 finished Y-bus — use `BatchSolver::solve_contingencies`, which takes the branch \
141 lists"
142 ),
143 BatchError::BusOutOfRange {
144 scenario,
145 bus,
146 n_buses,
147 } => write!(
148 f,
149 "scenario {scenario} overrides bus {bus}, but the template has only {n_buses} bus(es)"
150 ),
151 BatchError::BranchOutOfRange {
152 scenario,
153 branch,
154 n_branches,
155 } => write!(
156 f,
157 "scenario {scenario} outages branch {branch}, but the network has only \
158 {n_branches} branch(es)"
159 ),
160 BatchError::ThreadPool(e) => write!(f, "building the rayon thread pool failed: {e}"),
161 BatchError::DcSingular => write!(
162 f,
163 "the reduced susceptance matrix is singular, so no DC scenario can be solved"
164 ),
165 }
166 }
167}
168
169impl std::error::Error for BatchError {}