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("{format} emission failed: {message}")]
113    Emit {
114        format: &'static str,
115        message: String,
116    },
117
118    #[error("unknown or unsupported case format: {0}")]
119    UnknownFormat(String),
120
121    /// The target format is recognized but read only: it has no writer. A
122    /// same-format write can still echo retained source; everything else is
123    /// refused with this error rather than a misleading [`Error::UnknownFormat`].
124    #[error("{format} is a read only format with no writer")]
125    WriteUnsupported { format: &'static str },
126}
127
128/// Coarse classification of an [`enum@Error`], for callers that map onto their
129/// own taxonomy. Defined in `powerio-core` so every crate in the workspace
130/// projects onto the same five tokens.
131pub use powerio_core::ErrorCategory;
132
133impl Error {
134    /// An index or solver table build needs exactly one reference bus and the
135    /// case states `found`.
136    #[must_use]
137    pub fn reference_bus_count(found: usize) -> Self {
138        Error::ReferenceBusCount { found }
139    }
140
141    /// The registry entry for this error. The match is exhaustive over the
142    /// variant set (no wildcard), so adding an `Error` variant is a compile
143    /// error here until it is coded.
144    pub fn code(&self) -> &'static DiagnosticInfo {
145        match self {
146            Error::MissingField(_)
147            | Error::ShortRow { .. }
148            | Error::BadFloat { .. }
149            | Error::BadId { .. }
150            | Error::UnbalancedBrackets(_) => &codes::PARSE_MATPOWER_MALFORMED,
151            Error::FormatRead { .. } => &codes::PARSE_SOURCE_MALFORMED,
152            Error::Emit { .. } => &codes::EMIT_FORMAT_REQUIRED_VALUE_MISSING,
153            Error::Io(_) => &codes::READ_IO_FAILED,
154            Error::UnknownBus { .. } => &codes::BUILD_INDEX_UNKNOWN_BUS,
155            Error::ZeroImpedance { .. } => &codes::BUILD_BRANCH_ZERO_IMPEDANCE,
156            Error::NonFiniteSusceptance { .. } => &codes::BUILD_BRANCH_NOT_A_NUMBER,
157            Error::DegenerateTap { .. } => &codes::BUILD_BRANCH_DEGENERATE_TAP,
158            Error::MissingGenCost { .. } => &codes::VALIDATE_GEN_COST_MISSING,
159            Error::NonFiniteGenCost { .. } => &codes::VALIDATE_GEN_COST_NOT_A_NUMBER,
160            Error::InvalidGenCostPatch { .. } => &codes::VALIDATE_GEN_COST_PATCH_INVALID,
161            Error::GenCostCountMismatch { .. } => &codes::VALIDATE_GEN_COST_COUNT_MISMATCH,
162            Error::DcLineCostCountMismatch { .. } => &codes::VALIDATE_DC_LINE_COST_COUNT_MISMATCH,
163            Error::NoReferenceBus => &codes::CANONICALIZE_NORMALIZE_NO_REFERENCE_BUS,
164            Error::ReferenceBusCount { .. } => &codes::BUILD_INDEX_REFERENCE_BUS_COUNT,
165            Error::InvalidBaseMva { .. } => &codes::CANONICALIZE_NORMALIZE_INVALID_BASE_MVA,
166            Error::InvalidNormalizeOption { .. } => &codes::CANONICALIZE_NORMALIZE_INVALID_OPTION,
167            Error::UngroundedComponent { .. } => &codes::BUILD_INDEX_UNGROUNDED_COMPONENT,
168            Error::UnlocatedElements { .. } => &codes::BUILD_GEO_UNLOCATED_ELEMENTS,
169            Error::UnknownFormat(_) => &codes::REQUEST_FORMAT_UNKNOWN,
170            Error::WriteUnsupported { .. } => &codes::REQUEST_FORMAT_WRITE_UNSUPPORTED,
171        }
172    }
173
174    /// Classify this error. The match is exhaustive over the variant set (no
175    /// wildcard), so adding an `Error` variant is a compile error here until it
176    /// is categorized — categorization can't silently drift as the enum grows.
177    pub fn category(&self) -> ErrorCategory {
178        use ErrorCategory as C;
179        match self {
180            Error::Io(_) => C::Io,
181            // WriteUnsupported keeps the Request category so bindings
182            // surface it the same way (a ValueError, not a data error): the
183            // request named a format the writer can't produce.
184            Error::UnknownFormat(_) | Error::WriteUnsupported { .. } => C::Request,
185            Error::Emit { .. } => C::Output,
186            // Malformed or unparseable input. Only the parser/format readers
187            // raise these.
188            Error::MissingField(_)
189            | Error::ShortRow { .. }
190            | Error::BadFloat { .. }
191            | Error::BadId { .. }
192            | Error::UnbalancedBrackets(_)
193            | Error::FormatRead { .. } => C::Parse,
194            // A well-formed case that can't satisfy a requested operation. These
195            // surface mid-build (matrix/OPF/gridfm), not at parse time —
196            // `UnknownBus` and the scenario batch checks included: the file
197            // parsed, the operation can't proceed.
198            Error::UnknownBus { .. }
199            | Error::ZeroImpedance { .. }
200            | Error::NonFiniteSusceptance { .. }
201            | Error::DegenerateTap { .. }
202            | Error::MissingGenCost { .. }
203            | Error::NonFiniteGenCost { .. }
204            | Error::InvalidGenCostPatch { .. }
205            | Error::GenCostCountMismatch { .. }
206            | Error::DcLineCostCountMismatch { .. }
207            | Error::NoReferenceBus
208            | Error::ReferenceBusCount { .. }
209            | Error::InvalidBaseMva { .. }
210            | Error::InvalidNormalizeOption { .. }
211            | Error::UngroundedComponent { .. }
212            | Error::UnlocatedElements { .. } => C::Data,
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    // Every error is a diagnostic that ended the operation, so the code's
222    // published category and `category()` are one fact. Two spellings that can
223    // disagree is what this refuses.
224    #[test]
225    fn every_error_code_publishes_the_category_the_variant_reports() {
226        let every: Vec<Error> = vec![
227            Error::MissingField("bus"),
228            Error::ShortRow {
229                field: "bus",
230                row: 1,
231                expected: 13,
232                got: 3,
233            },
234            Error::BadFloat {
235                field: "bus",
236                row: 1,
237                value: "x".into(),
238            },
239            Error::BadId {
240                field: "bus",
241                row: 1,
242                message: "`BUS_I` value 1e300 is outside the id range 0..2^63".into(),
243            },
244            Error::UnbalancedBrackets("bus"),
245            Error::FormatRead {
246                format: "psse",
247                message: "bad record".into(),
248            },
249            Error::Emit {
250                format: "xiidm",
251                message: "missing nominalV".into(),
252            },
253            Error::Io(std::io::Error::from(std::io::ErrorKind::NotFound)),
254            Error::UnknownBus {
255                bus_id: BusId(7),
256                element_index: 0,
257            },
258            Error::ZeroImpedance { row: 1 },
259            Error::NonFiniteSusceptance { row: 1 },
260            Error::DegenerateTap { row: 1, tap: 0.0 },
261            Error::MissingGenCost { gen_index: 0 },
262            Error::NonFiniteGenCost {
263                field: "c2",
264                value: f64::NAN,
265            },
266            Error::InvalidGenCostPatch {
267                row: 1,
268                reason: "empty".into(),
269            },
270            Error::GenCostCountMismatch {
271                gens: 2,
272                gencost: 3,
273            },
274            Error::DcLineCostCountMismatch {
275                dclines: 1,
276                dclinecost: 2,
277            },
278            Error::NoReferenceBus,
279            Error::reference_bus_count(2),
280            Error::InvalidBaseMva { base: 0.0 },
281            Error::InvalidNormalizeOption {
282                field: "angle_bound_pad",
283                value: 0.0,
284            },
285            Error::UngroundedComponent { components: 1 },
286            Error::UnlocatedElements {
287                buses: 1,
288                branches: 0,
289            },
290            Error::UnknownFormat("xyz".into()),
291            Error::WriteUnsupported { format: "goc3" },
292        ];
293        for error in &every {
294            let info = error.code();
295            assert_eq!(
296                info.category,
297                Some(error.category()),
298                "{} publishes {:?} but the variant reports {:?}",
299                info.code,
300                info.category,
301                error.category()
302            );
303        }
304    }
305
306    #[test]
307    fn the_two_reference_bus_stages_carry_different_codes() {
308        assert_eq!(
309            Error::NoReferenceBus.code().code,
310            "CANONICALIZE.NORMALIZE.NO_REFERENCE_BUS"
311        );
312        assert_eq!(
313            Error::reference_bus_count(2).code().code,
314            "BUILD.INDEX.REFERENCE_BUS_COUNT"
315        );
316        // The canonicalize refusal states the generating rule; "found 0" would
317        // contradict a case whose bus table types a generator-less bus REF.
318        assert!(Error::NoReferenceBus.to_string().contains("generator"));
319    }
320
321    #[test]
322    fn category_pins_the_intended_buckets() {
323        use ErrorCategory::{Data, Io, Output, Parse, Request};
324        // The parser/format readers raise these.
325        assert_eq!(Error::MissingField("bus").category(), Parse);
326        assert_eq!(
327            Error::FormatRead {
328                format: "psse",
329                message: "bad record".into()
330            }
331            .category(),
332            Parse
333        );
334        // An unmet operation precondition on an already-parsed case. UnknownBus
335        // surfaces mid-build, not at parse time, so it is Data, not Parse —
336        // regression guard for that classification.
337        assert_eq!(Error::InvalidBaseMva { base: 0.0 }.category(), Data);
338        assert_eq!(
339            Error::UngroundedComponent { components: 1 }.category(),
340            Data
341        );
342        assert_eq!(
343            Error::UnknownBus {
344                bus_id: BusId(7),
345                element_index: 0
346            }
347            .category(),
348            Data
349        );
350        // Format selection, output requirements, and underlying I/O remain
351        // distinct for bindings and exit statuses.
352        assert_eq!(Error::UnknownFormat("xyz".into()).category(), Request);
353        assert_eq!(
354            Error::Emit {
355                format: "xiidm",
356                message: "missing nominalV".into()
357            }
358            .category(),
359            Output
360        );
361        assert_eq!(
362            Error::Io(std::io::Error::from(std::io::ErrorKind::NotFound)).category(),
363            Io
364        );
365    }
366}
367
368#[cfg(test)]
369mod category_token_tests {
370    use super::ErrorCategory;
371
372    // TOKENS is written out so C consumers get the closed set without a
373    // parser; this keeps it from drifting from token().
374    #[test]
375    fn tokens_lists_every_category_exactly_once() {
376        let every = [
377            ErrorCategory::Io,
378            ErrorCategory::Request,
379            ErrorCategory::Parse,
380            ErrorCategory::Data,
381            ErrorCategory::Output,
382        ];
383        let from_tokens: Vec<&str> = every.iter().map(|c| c.as_str()).collect();
384        assert_eq!(from_tokens, ErrorCategory::TOKENS.to_vec());
385    }
386}