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