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    // Raised from incidence assembly and both OPF builders too, not only from
43    // Y_bus, so the message names the division rather than one caller.
44    #[error("branch row {row} has a tap ratio of {tap} too small to divide by")]
45    DegenerateTap { row: usize, tap: f64 },
46
47    #[error("generator {gen_index} has no cost data")]
48    MissingGenCost { gen_index: usize },
49
50    #[error("default generator cost field `{field}` is not finite: {value}")]
51    NonFiniteGenCost { field: &'static str, value: f64 },
52
53    #[error("invalid generator cost patch row {row}: {reason}")]
54    InvalidGenCostPatch { row: usize, reason: String },
55
56    #[error("`gen` has {gens} rows but `gencost` has {gencost}; expected {gens} (active only) or {} (active + reactive)", gens * 2)]
57    GenCostCountMismatch { gens: usize, gencost: usize },
58
59    #[error("expected exactly one reference (slack) bus, found {found}")]
60    ReferenceBusCount { found: usize },
61
62    #[error("base MVA must be a positive, finite number, got {base}")]
63    InvalidBaseMva { base: f64 },
64
65    #[error("invalid normalize option `{field}`: {value}")]
66    InvalidNormalizeOption { field: &'static str, value: f64 },
67
68    #[error(
69        "{components} connected component(s) have no reference (slack) bus to ground; DC sensitivities need at least one reference per island"
70    )]
71    UngroundedComponent { components: usize },
72
73    #[error(transparent)]
74    Io(#[from] std::io::Error),
75
76    #[error(
77        "geo apply left {buses} bus(es) with no location and {branches} branch(es) with no route"
78    )]
79    UnlocatedElements { buses: usize, branches: usize },
80
81    #[error("{format} read error: {message}")]
82    FormatRead {
83        format: &'static str,
84        message: String,
85    },
86
87    #[error("unknown or unsupported case format: {0}")]
88    UnknownFormat(String),
89
90    /// The target format is recognized but read only: it has no writer. A
91    /// same-format write can still echo retained source; everything else is
92    /// refused with this error rather than a misleading [`Error::UnknownFormat`].
93    #[error("{format} is a read only format with no writer")]
94    WriteUnsupported { format: &'static str },
95}
96
97/// Coarse classification of an [`enum@Error`], for callers that map onto their own
98/// taxonomy (the Python layer's exception subclasses, C ABI status codes, a
99/// CLI exit code). Distinguishing "the input file is bad" from "the operation
100/// can't run on this otherwise-valid case" is the split callers actually branch
101/// on, and it's a property of the error, not of the binding that surfaces it.
102///
103/// Unlike [`enum@Error`], this enum is not `#[non_exhaustive]`. Adding a
104/// category makes exhaustive matches fail to compile, which requires each
105/// binding to map the new category.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum ErrorCategory {
108    /// Underlying I/O failure reading or writing a file.
109    Io,
110    /// The requested format is unknown or can't be inferred from the path.
111    UnknownFormat,
112    /// The input is malformed or unparseable.
113    Parse,
114    /// A well-formed case can't satisfy the requested operation.
115    Data,
116    /// An output serialization step (matrix-market, Parquet) failed.
117    Output,
118}
119
120impl ErrorCategory {
121    /// The token for this category, as it appears in a C `errbuf` message and
122    /// in `pio_build_info`.
123    #[must_use]
124    pub fn token(self) -> &'static str {
125        match self {
126            ErrorCategory::Io => "io",
127            ErrorCategory::UnknownFormat => "unknown_format",
128            ErrorCategory::Parse => "parse",
129            ErrorCategory::Data => "data",
130            ErrorCategory::Output => "output",
131        }
132    }
133
134    /// Every category token, for a consumer that wants the closed set without
135    /// hardcoding it. The C ABI reports errors as text and defines no error
136    /// codes, so a binding that branches on the kind of failure matches these.
137    pub const TOKENS: [&'static str; 5] = ["io", "unknown_format", "parse", "data", "output"];
138}
139
140impl Error {
141    /// Classify this error. The match is exhaustive over the variant set (no
142    /// wildcard), so adding an `Error` variant is a compile error here until it
143    /// is categorized — categorization can't silently drift as the enum grows.
144    pub fn category(&self) -> ErrorCategory {
145        use ErrorCategory as C;
146        match self {
147            Error::Io(_) => C::Io,
148            // WriteUnsupported keeps the UnknownFormat category so bindings
149            // surface it the same way (a ValueError, not a data error): the
150            // request named a format the writer can't produce.
151            Error::UnknownFormat(_) | Error::WriteUnsupported { .. } => C::UnknownFormat,
152            // Malformed or unparseable input. Only the parser/format readers
153            // raise these.
154            Error::MissingField(_)
155            | Error::ShortRow { .. }
156            | Error::BadFloat { .. }
157            | Error::UnbalancedBrackets(_)
158            | Error::FormatRead { .. } => C::Parse,
159            // A well-formed case that can't satisfy a requested operation. These
160            // surface mid-build (matrix/OPF/gridfm), not at parse time —
161            // `UnknownBus` and the scenario batch checks included: the file
162            // parsed, the operation can't proceed.
163            Error::UnknownBus { .. }
164            | Error::ZeroImpedance { .. }
165            | Error::NonFiniteSusceptance { .. }
166            | Error::DegenerateTap { .. }
167            | Error::MissingGenCost { .. }
168            | Error::NonFiniteGenCost { .. }
169            | Error::InvalidGenCostPatch { .. }
170            | Error::GenCostCountMismatch { .. }
171            | Error::ReferenceBusCount { .. }
172            | Error::InvalidBaseMva { .. }
173            | Error::InvalidNormalizeOption { .. }
174            | Error::UngroundedComponent { .. }
175            | Error::UnlocatedElements { .. } => C::Data,
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn category_pins_the_intended_buckets() {
186        use ErrorCategory::{Data, Io, Parse, UnknownFormat};
187        // The parser/format readers raise these.
188        assert_eq!(Error::MissingField("bus").category(), Parse);
189        assert_eq!(
190            Error::FormatRead {
191                format: "psse",
192                message: "bad record".into()
193            }
194            .category(),
195            Parse
196        );
197        // An unmet operation precondition on an already-parsed case. UnknownBus
198        // surfaces mid-build, not at parse time, so it is Data, not Parse —
199        // regression guard for that classification.
200        assert_eq!(Error::InvalidBaseMva { base: 0.0 }.category(), Data);
201        assert_eq!(
202            Error::UngroundedComponent { components: 1 }.category(),
203            Data
204        );
205        assert_eq!(
206            Error::UnknownBus {
207                bus_id: BusId(7),
208                element_index: 0
209            }
210            .category(),
211            Data
212        );
213        // Format selection and underlying I/O. Output-side serialization
214        // failures belong to the crate that writes, so `Output` has no hub
215        // variant; `powerio_matrix::Error` carries it.
216        assert_eq!(Error::UnknownFormat("xyz".into()).category(), UnknownFormat);
217        assert_eq!(
218            Error::Io(std::io::Error::from(std::io::ErrorKind::NotFound)).category(),
219            Io
220        );
221    }
222}
223
224#[cfg(test)]
225mod category_token_tests {
226    use super::ErrorCategory;
227
228    // TOKENS is written out so C consumers get the closed set without a
229    // parser; this keeps it from drifting from token().
230    #[test]
231    fn tokens_lists_every_category_exactly_once() {
232        let every = [
233            ErrorCategory::Io,
234            ErrorCategory::UnknownFormat,
235            ErrorCategory::Parse,
236            ErrorCategory::Data,
237            ErrorCategory::Output,
238        ];
239        let from_tokens: Vec<&str> = every.iter().map(|c| c.token()).collect();
240        assert_eq!(from_tokens, ErrorCategory::TOKENS.to_vec());
241    }
242}