Skip to main content

powerio/
error.rs

1use thiserror::Error;
2
3use crate::network::BusId;
4
5pub type Result<T> = std::result::Result<T, Error>;
6
7#[derive(Debug, Error)]
8#[non_exhaustive]
9pub enum Error {
10    #[error("missing required MATPOWER field `{0}`")]
11    MissingField(&'static str),
12
13    #[error(
14        "malformed MATPOWER `{field}` row {row}: expected at least {expected} columns, got {got}"
15    )]
16    ShortRow {
17        field: &'static str,
18        row: usize,
19        expected: usize,
20        got: usize,
21    },
22
23    #[error("could not parse `{field}` row {row} value `{value}` as f64")]
24    BadFloat {
25        field: &'static str,
26        row: usize,
27        value: String,
28    },
29
30    #[error("unbalanced brackets in MATPOWER `{0}` matrix")]
31    UnbalancedBrackets(&'static str),
32
33    #[error("element references unknown bus id {bus_id} (in-service index {element_index})")]
34    UnknownBus { bus_id: BusId, element_index: usize },
35
36    #[error("branch row {row} has zero impedance (r=0, x=0); not representable in B'")]
37    ZeroImpedance { row: usize },
38
39    #[error("branch row {row} has non-finite DC susceptance b = 1/x (x is NaN, Inf, or denormal)")]
40    NonFiniteSusceptance { row: usize },
41
42    #[error("output dimension mismatch: matrix is {n}x{n} but RHS has length {b_len}")]
43    DimensionMismatch { n: usize, b_len: usize },
44
45    #[error("case has no generators; DC-OPF requires an `mpc.gen` block")]
46    NoGenerators,
47
48    #[error("generator {gen_index} has no cost data")]
49    MissingGenCost { gen_index: usize },
50
51    #[error(
52        "generator {gen_index} has an unsupported cost model (model {model}, ncost {ncost}); need polynomial model 2 with degree ≤ 2"
53    )]
54    UnsupportedCostModel {
55        gen_index: usize,
56        model: u8,
57        ncost: usize,
58    },
59
60    #[error("`gen` has {gens} rows but `gencost` has {gencost}; expected {gens} (active only) or {} (active + reactive)", gens * 2)]
61    GenCostCountMismatch { gens: usize, gencost: usize },
62
63    #[error("expected exactly one reference (slack) bus, found {found}")]
64    ReferenceBusCount { found: usize },
65
66    #[error("base MVA must be a positive, finite number, got {base}")]
67    InvalidBaseMva { base: f64 },
68
69    #[error("dimension mismatch: `{what}` expected length {expected}, got {got}")]
70    ShapeMismatch {
71        what: &'static str,
72        expected: usize,
73        got: usize,
74    },
75
76    #[error(
77        "DC sensitivity solve failed: the reference-grounded Laplacian is singular even though every component is grounded"
78    )]
79    SingularNetwork,
80
81    #[error(
82        "{components} connected component(s) have no reference (slack) bus to ground; DC sensitivities need at least one reference per island"
83    )]
84    UngroundedComponent { components: usize },
85
86    #[error(transparent)]
87    Io(#[from] std::io::Error),
88
89    #[error("matrix-market I/O: {0}")]
90    Mtx(String),
91
92    #[error("gridfm Parquet export: {0}")]
93    Parquet(String),
94
95    #[error("gridfm scenario batch is empty; provide at least one snapshot")]
96    EmptyScenarioBatch,
97
98    #[error("gridfm scenario id overflows i64 when numbering snapshot {index} from base {base}")]
99    ScenarioIdOverflow {
100        base: i64,
101        /// 0-based position of the snapshot whose `base + index` overflowed.
102        index: usize,
103    },
104
105    #[error(
106        "gridfm snapshot scenario {scenario} is normalized; gridfm export expects raw MW and degree fields"
107    )]
108    NormalizedGridfmSnapshot { scenario: i64 },
109
110    #[error(
111        "gridfm snapshot scenario {scenario} has non-finite {element} row {row} field `{field}`: {value}"
112    )]
113    NonFiniteGridfmValue {
114        scenario: i64,
115        element: &'static str,
116        row: usize,
117        field: &'static str,
118        value: f64,
119    },
120
121    #[error(
122        "gridfm snapshot {index} doesn't match the first snapshot's element set: {reason}; \
123         a scenario batch shares one base element set (same bus/branch/gen counts and bus-id order)"
124    )]
125    ScenarioShapeMismatch {
126        /// 0-based position of the offending snapshot in the batch (independent
127        /// of the snapshot's scenario id).
128        index: usize,
129        reason: ScenarioMismatch,
130    },
131
132    #[error("{format} read error: {message}")]
133    FormatRead {
134        format: &'static str,
135        message: String,
136    },
137
138    #[error("unknown or unsupported case format: {0}")]
139    UnknownFormat(String),
140}
141
142/// Coarse classification of an [`enum@Error`], for callers that map onto their own
143/// taxonomy (the Python layer's exception subclasses, C ABI status codes, a
144/// CLI exit code). Distinguishing "the input file is bad" from "the operation
145/// can't run on this otherwise-valid case" is the split callers actually branch
146/// on, and it's a property of the error, not of the binding that surfaces it.
147///
148/// Deliberately *not* `#[non_exhaustive]` (unlike [`enum@Error`]): a category-mapping
149/// match should fail to compile when a category is added, so every binding is
150/// forced to decide how to surface it.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum ErrorCategory {
153    /// Underlying I/O failure reading or writing a file.
154    Io,
155    /// The requested format is unknown or can't be inferred from the path.
156    UnknownFormat,
157    /// The input is malformed or unparseable.
158    Parse,
159    /// A well-formed case can't satisfy the requested operation.
160    Data,
161    /// An output serialization step (matrix-market, Parquet) failed.
162    Output,
163}
164
165impl Error {
166    /// Classify this error. The match is exhaustive over the variant set (no
167    /// wildcard), so adding an `Error` variant is a compile error here until it
168    /// is categorized — categorization can't silently drift as the enum grows.
169    pub fn category(&self) -> ErrorCategory {
170        use ErrorCategory as C;
171        match self {
172            Error::Io(_) => C::Io,
173            Error::UnknownFormat(_) => C::UnknownFormat,
174            // Malformed or unparseable input. Only the parser/format readers
175            // raise these.
176            Error::MissingField(_)
177            | Error::ShortRow { .. }
178            | Error::BadFloat { .. }
179            | Error::UnbalancedBrackets(_)
180            | Error::FormatRead { .. } => C::Parse,
181            // A well-formed case that can't satisfy a requested operation. These
182            // surface mid-build (matrix/OPF/gridfm), not at parse time —
183            // `UnknownBus` and the scenario batch checks included: the file
184            // parsed, the operation can't proceed.
185            Error::UnknownBus { .. }
186            | Error::ZeroImpedance { .. }
187            | Error::NonFiniteSusceptance { .. }
188            | Error::DimensionMismatch { .. }
189            | Error::NoGenerators
190            | Error::MissingGenCost { .. }
191            | Error::UnsupportedCostModel { .. }
192            | Error::GenCostCountMismatch { .. }
193            | Error::ReferenceBusCount { .. }
194            | Error::InvalidBaseMva { .. }
195            | Error::ShapeMismatch { .. }
196            | Error::SingularNetwork
197            | Error::UngroundedComponent { .. }
198            | Error::EmptyScenarioBatch
199            | Error::ScenarioIdOverflow { .. }
200            | Error::NormalizedGridfmSnapshot { .. }
201            | Error::NonFiniteGridfmValue { .. }
202            | Error::ScenarioShapeMismatch { .. } => C::Data,
203            // Output-side serialization write failures.
204            Error::Mtx(_) | Error::Parquet(_) => C::Output,
205        }
206    }
207}
208
209/// The element counts that define a scenario batch's shared base shape. Named
210/// (rather than a bare `(usize, usize, usize)`) so the three same-typed fields
211/// can't be transposed silently in an error message or a comparison.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct ElementCounts {
214    pub buses: usize,
215    pub branches: usize,
216    pub gens: usize,
217}
218
219impl std::fmt::Display for ElementCounts {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        write!(
222            f,
223            "{} buses, {} branches, {} gens",
224            self.buses, self.branches, self.gens
225        )
226    }
227}
228
229/// Why a gridfm scenario snapshot doesn't line up with the first snapshot's
230/// base element set (the row-stack keeps every table schema-consistent by
231/// requiring the same element counts and bus-id ordering across snapshots).
232///
233/// `#[non_exhaustive]`: future checks (e.g. branch endpoints, voltage base) may
234/// add variants, so downstream matches must keep a wildcard arm.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236#[non_exhaustive]
237pub enum ScenarioMismatch {
238    /// Element counts differ.
239    Counts {
240        expected: ElementCounts,
241        got: ElementCounts,
242    },
243    /// Counts match, but the buses are listed in a different order (so the dense
244    /// bus index wouldn't mean the same bus across snapshots).
245    BusOrder,
246}
247
248impl std::fmt::Display for ScenarioMismatch {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            Self::Counts { expected, got } => {
252                write!(f, "got ({got}) vs the first snapshot's ({expected})")
253            }
254            Self::BusOrder => {
255                write!(f, "counts match but the bus ids are in a different order")
256            }
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn category_pins_the_intended_buckets() {
267        use ErrorCategory::*;
268        // The parser/format readers raise these.
269        assert_eq!(Error::MissingField("bus").category(), Parse);
270        assert_eq!(
271            Error::FormatRead {
272                format: "psse",
273                message: "bad record".into()
274            }
275            .category(),
276            Parse
277        );
278        // An unmet operation precondition on an already-parsed case. UnknownBus
279        // and the scenario batch checks surface mid-build, not at parse time, so
280        // they are Data, not Parse — regression guard for that classification.
281        assert_eq!(Error::NoGenerators.category(), Data);
282        assert_eq!(Error::InvalidBaseMva { base: 0.0 }.category(), Data);
283        assert_eq!(
284            Error::UngroundedComponent { components: 1 }.category(),
285            Data
286        );
287        assert_eq!(
288            Error::UnknownBus {
289                bus_id: BusId(7),
290                element_index: 0
291            }
292            .category(),
293            Data
294        );
295        assert_eq!(Error::EmptyScenarioBatch.category(), Data);
296        assert_eq!(
297            Error::ScenarioShapeMismatch {
298                index: 1,
299                reason: ScenarioMismatch::BusOrder
300            }
301            .category(),
302            Data
303        );
304        // Format selection, output serialization, and underlying I/O.
305        assert_eq!(Error::UnknownFormat("xyz".into()).category(), UnknownFormat);
306        assert_eq!(Error::Mtx("write failed".into()).category(), Output);
307        assert_eq!(
308            Error::Io(std::io::Error::from(std::io::ErrorKind::NotFound)).category(),
309            Io
310        );
311    }
312}