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