Skip to main content

powerio_tx/
error.rs

1use thiserror::Error;
2
3use crate::diagnostics::{DiagnosticInfo, codes};
4use crate::network::BusId;
5
6pub type Result<T> = std::result::Result<T, Error>;
7
8#[derive(Debug, Error)]
9#[non_exhaustive]
10pub enum Error {
11    #[error("missing required MATPOWER field `{0}`")]
12    MissingField(&'static str),
13
14    #[error(
15        "malformed MATPOWER `{field}` row {row}: expected at least {expected} columns, got {got}"
16    )]
17    ShortRow {
18        field: &'static str,
19        row: usize,
20        expected: usize,
21        got: usize,
22    },
23
24    #[error("could not parse `{field}` row {row} value `{value}` as f64")]
25    BadFloat {
26        field: &'static str,
27        row: usize,
28        value: String,
29    },
30
31    #[error("malformed MATPOWER `{field}` row {row}: {message}")]
32    BadId {
33        field: &'static str,
34        row: usize,
35        message: String,
36    },
37
38    #[error("unbalanced brackets in MATPOWER `{0}` matrix")]
39    UnbalancedBrackets(&'static str),
40
41    #[error("element references unknown bus id {bus_id} (in-service index {element_index})")]
42    UnknownBus { bus_id: BusId, element_index: usize },
43
44    #[error("branch row {row} has a zero matrix denominator under the selected build options")]
45    ZeroImpedance { row: usize },
46
47    #[error(
48        "branch row {row} has a non-finite susceptance (r or x is NaN or Inf, or the four terminal admittances overflow)"
49    )]
50    NonFiniteSusceptance { row: usize },
51
52    // Raised from incidence assembly and both OPF builders too, not only from
53    // Y_bus, so the message names the division rather than one caller.
54    #[error("branch row {row} has a tap ratio of {tap} too small to divide by")]
55    DegenerateTap { row: usize, tap: f64 },
56
57    #[error("generator {gen_index} has no cost data")]
58    MissingGenCost { gen_index: usize },
59
60    #[error("default generator cost field `{field}` is not finite: {value}")]
61    NonFiniteGenCost { field: &'static str, value: f64 },
62
63    #[error("invalid generator cost patch row {row}: {reason}")]
64    InvalidGenCostPatch { row: usize, reason: String },
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(
70        "`dcline` has {dclines} rows but `dclinecost` has {dclinecost}; expected one cost row per dcline"
71    )]
72    DcLineCostCountMismatch { dclines: usize, dclinecost: usize },
73
74    /// Normalization could not establish a reference bus. The reference set is
75    /// derived from generator presence: a bus keeps `REF` only while it hosts
76    /// an in-service generator, so a `REF` typed bus with no generator does not
77    /// count, and with no in-service generator there is nothing to promote.
78    #[error(
79        "cannot establish a reference bus: a reference bus must host an in-service generator, and this case has none"
80    )]
81    NoReferenceBus,
82
83    /// An index or solver table build needs exactly one reference bus.
84    #[error("expected exactly one reference (slack) bus, found {found}")]
85    ReferenceBusCount { found: usize },
86
87    #[error("base MVA must be a positive, finite number, got {base}")]
88    InvalidBaseMva { base: f64 },
89
90    #[error("invalid normalize option `{field}`: {value}")]
91    InvalidNormalizeOption { field: &'static str, value: f64 },
92
93    #[error(
94        "{components} connected component(s) have no reference (slack) bus to ground; DC sensitivities need at least one reference per island"
95    )]
96    UngroundedComponent { components: usize },
97
98    #[error(transparent)]
99    Io(#[from] std::io::Error),
100
101    #[error(
102        "geo apply left {buses} bus(es) with no location and {branches} branch(es) with no route"
103    )]
104    UnlocatedElements { buses: usize, branches: usize },
105
106    #[error("{format} read error: {message}")]
107    FormatRead {
108        format: &'static str,
109        message: String,
110    },
111
112    #[error("unknown or unsupported case format: {0}")]
113    UnknownFormat(String),
114
115    /// The target format is recognized but read only: it has no writer. A
116    /// same-format write can still echo retained source; everything else is
117    /// refused with this error rather than a misleading [`Error::UnknownFormat`].
118    #[error("{format} is a read only format with no writer")]
119    WriteUnsupported { format: &'static str },
120}
121
122/// Coarse classification of an [`enum@Error`], for callers that map onto their
123/// own taxonomy. Defined in `powerio-core` so every crate in the workspace
124/// projects onto the same five tokens.
125pub use powerio_core::ErrorCategory;
126
127impl Error {
128    /// An index or solver table build needs exactly one reference bus and the
129    /// case states `found`.
130    #[must_use]
131    pub fn reference_bus_count(found: usize) -> Self {
132        Error::ReferenceBusCount { found }
133    }
134
135    /// The registry entry for this error. The match is exhaustive over the
136    /// variant set (no wildcard), so adding an `Error` variant is a compile
137    /// error here until it is coded.
138    pub fn code(&self) -> &'static DiagnosticInfo {
139        match self {
140            Error::MissingField(_)
141            | Error::ShortRow { .. }
142            | Error::BadFloat { .. }
143            | Error::BadId { .. }
144            | Error::UnbalancedBrackets(_) => &codes::PARSE_MATPOWER_MALFORMED,
145            Error::FormatRead { .. } => &codes::PARSE_SOURCE_MALFORMED,
146            Error::Io(_) => &codes::READ_IO_FAILED,
147            Error::UnknownBus { .. } => &codes::BUILD_INDEX_UNKNOWN_BUS,
148            Error::ZeroImpedance { .. } => &codes::BUILD_BRANCH_ZERO_IMPEDANCE,
149            Error::NonFiniteSusceptance { .. } => &codes::BUILD_BRANCH_NOT_A_NUMBER,
150            Error::DegenerateTap { .. } => &codes::BUILD_BRANCH_DEGENERATE_TAP,
151            Error::MissingGenCost { .. } => &codes::VALIDATE_GEN_COST_MISSING,
152            Error::NonFiniteGenCost { .. } => &codes::VALIDATE_GEN_COST_NOT_A_NUMBER,
153            Error::InvalidGenCostPatch { .. } => &codes::VALIDATE_GEN_COST_PATCH_INVALID,
154            Error::GenCostCountMismatch { .. } => &codes::VALIDATE_GEN_COST_COUNT_MISMATCH,
155            Error::DcLineCostCountMismatch { .. } => &codes::VALIDATE_DC_LINE_COST_COUNT_MISMATCH,
156            Error::NoReferenceBus => &codes::CANONICALIZE_NORMALIZE_NO_REFERENCE_BUS,
157            Error::ReferenceBusCount { .. } => &codes::BUILD_INDEX_REFERENCE_BUS_COUNT,
158            Error::InvalidBaseMva { .. } => &codes::CANONICALIZE_NORMALIZE_INVALID_BASE_MVA,
159            Error::InvalidNormalizeOption { .. } => &codes::CANONICALIZE_NORMALIZE_INVALID_OPTION,
160            Error::UngroundedComponent { .. } => &codes::BUILD_INDEX_UNGROUNDED_COMPONENT,
161            Error::UnlocatedElements { .. } => &codes::BUILD_GEO_UNLOCATED_ELEMENTS,
162            Error::UnknownFormat(_) => &codes::REQUEST_FORMAT_UNKNOWN,
163            Error::WriteUnsupported { .. } => &codes::REQUEST_FORMAT_WRITE_UNSUPPORTED,
164        }
165    }
166
167    /// Classify this error. The match is exhaustive over the variant set (no
168    /// wildcard), so adding an `Error` variant is a compile error here until it
169    /// is categorized — categorization can't silently drift as the enum grows.
170    pub fn category(&self) -> ErrorCategory {
171        use ErrorCategory as C;
172        match self {
173            Error::Io(_) => C::Io,
174            // WriteUnsupported keeps the Request category so bindings
175            // surface it the same way (a ValueError, not a data error): the
176            // request named a format the writer can't produce.
177            Error::UnknownFormat(_) | Error::WriteUnsupported { .. } => C::Request,
178            // Malformed or unparseable input. Only the parser/format readers
179            // raise these.
180            Error::MissingField(_)
181            | Error::ShortRow { .. }
182            | Error::BadFloat { .. }
183            | Error::BadId { .. }
184            | Error::UnbalancedBrackets(_)
185            | Error::FormatRead { .. } => C::Parse,
186            // A well-formed case that can't satisfy a requested operation. These
187            // surface mid-build (matrix/OPF/gridfm), not at parse time —
188            // `UnknownBus` and the scenario batch checks included: the file
189            // parsed, the operation can't proceed.
190            Error::UnknownBus { .. }
191            | Error::ZeroImpedance { .. }
192            | Error::NonFiniteSusceptance { .. }
193            | Error::DegenerateTap { .. }
194            | Error::MissingGenCost { .. }
195            | Error::NonFiniteGenCost { .. }
196            | Error::InvalidGenCostPatch { .. }
197            | Error::GenCostCountMismatch { .. }
198            | Error::DcLineCostCountMismatch { .. }
199            | Error::NoReferenceBus
200            | Error::ReferenceBusCount { .. }
201            | Error::InvalidBaseMva { .. }
202            | Error::InvalidNormalizeOption { .. }
203            | Error::UngroundedComponent { .. }
204            | Error::UnlocatedElements { .. } => C::Data,
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    // Every error is a diagnostic that ended the operation, so the code's
214    // published category and `category()` are one fact. Two spellings that can
215    // disagree is what this refuses.
216    #[test]
217    fn every_error_code_publishes_the_category_the_variant_reports() {
218        let every: Vec<Error> = vec![
219            Error::MissingField("bus"),
220            Error::ShortRow {
221                field: "bus",
222                row: 1,
223                expected: 13,
224                got: 3,
225            },
226            Error::BadFloat {
227                field: "bus",
228                row: 1,
229                value: "x".into(),
230            },
231            Error::BadId {
232                field: "bus",
233                row: 1,
234                message: "`BUS_I` value 1e300 is outside the id range 0..2^63".into(),
235            },
236            Error::UnbalancedBrackets("bus"),
237            Error::FormatRead {
238                format: "psse",
239                message: "bad record".into(),
240            },
241            Error::Io(std::io::Error::from(std::io::ErrorKind::NotFound)),
242            Error::UnknownBus {
243                bus_id: BusId(7),
244                element_index: 0,
245            },
246            Error::ZeroImpedance { row: 1 },
247            Error::NonFiniteSusceptance { row: 1 },
248            Error::DegenerateTap { row: 1, tap: 0.0 },
249            Error::MissingGenCost { gen_index: 0 },
250            Error::NonFiniteGenCost {
251                field: "c2",
252                value: f64::NAN,
253            },
254            Error::InvalidGenCostPatch {
255                row: 1,
256                reason: "empty".into(),
257            },
258            Error::GenCostCountMismatch {
259                gens: 2,
260                gencost: 3,
261            },
262            Error::DcLineCostCountMismatch {
263                dclines: 1,
264                dclinecost: 2,
265            },
266            Error::NoReferenceBus,
267            Error::reference_bus_count(2),
268            Error::InvalidBaseMva { base: 0.0 },
269            Error::InvalidNormalizeOption {
270                field: "angle_bound_pad",
271                value: 0.0,
272            },
273            Error::UngroundedComponent { components: 1 },
274            Error::UnlocatedElements {
275                buses: 1,
276                branches: 0,
277            },
278            Error::UnknownFormat("xyz".into()),
279            Error::WriteUnsupported { format: "goc3" },
280        ];
281        for error in &every {
282            let info = error.code();
283            assert_eq!(
284                info.category,
285                Some(error.category()),
286                "{} publishes {:?} but the variant reports {:?}",
287                info.code,
288                info.category,
289                error.category()
290            );
291        }
292    }
293
294    #[test]
295    fn the_two_reference_bus_stages_carry_different_codes() {
296        assert_eq!(
297            Error::NoReferenceBus.code().code,
298            "CANONICALIZE.NORMALIZE.NO_REFERENCE_BUS"
299        );
300        assert_eq!(
301            Error::reference_bus_count(2).code().code,
302            "BUILD.INDEX.REFERENCE_BUS_COUNT"
303        );
304        // The canonicalize refusal states the generating rule; "found 0" would
305        // contradict a case whose bus table types a generator-less bus REF.
306        assert!(Error::NoReferenceBus.to_string().contains("generator"));
307    }
308
309    #[test]
310    fn category_pins_the_intended_buckets() {
311        use ErrorCategory::{Data, Io, Parse, Request};
312        // The parser/format readers raise these.
313        assert_eq!(Error::MissingField("bus").category(), Parse);
314        assert_eq!(
315            Error::FormatRead {
316                format: "psse",
317                message: "bad record".into()
318            }
319            .category(),
320            Parse
321        );
322        // An unmet operation precondition on an already-parsed case. UnknownBus
323        // surfaces mid-build, not at parse time, so it is Data, not Parse —
324        // regression guard for that classification.
325        assert_eq!(Error::InvalidBaseMva { base: 0.0 }.category(), Data);
326        assert_eq!(
327            Error::UngroundedComponent { components: 1 }.category(),
328            Data
329        );
330        assert_eq!(
331            Error::UnknownBus {
332                bus_id: BusId(7),
333                element_index: 0
334            }
335            .category(),
336            Data
337        );
338        // Format selection and underlying I/O. Output-side serialization
339        // failures belong to the crate that writes, so `Output` has no hub
340        // variant; `powerio_matrix::Error` carries it.
341        assert_eq!(Error::UnknownFormat("xyz".into()).category(), Request);
342        assert_eq!(
343            Error::Io(std::io::Error::from(std::io::ErrorKind::NotFound)).category(),
344            Io
345        );
346    }
347}
348
349#[cfg(test)]
350mod category_token_tests {
351    use super::ErrorCategory;
352
353    // TOKENS is written out so C consumers get the closed set without a
354    // parser; this keeps it from drifting from token().
355    #[test]
356    fn tokens_lists_every_category_exactly_once() {
357        let every = [
358            ErrorCategory::Io,
359            ErrorCategory::Request,
360            ErrorCategory::Parse,
361            ErrorCategory::Data,
362            ErrorCategory::Output,
363        ];
364        let from_tokens: Vec<&str> = every.iter().map(|c| c.as_str()).collect();
365        assert_eq!(from_tokens, ErrorCategory::TOKENS.to_vec());
366    }
367}