Skip to main content

powerio_matrix/
error.rs

1//! Failures the matrix and dataset builders raise.
2//!
3//! [`Error`] carries what this crate constructs and wraps [`powerio::Error`]
4//! for everything the hub raises underneath, so `?` moves a hub failure across
5//! the boundary without restating it. A caller that only wants the coarse
6//! split reads [`Error::category`], which is the same taxonomy the hub uses.
7
8use thiserror::Error as ThisError;
9
10/// A matrix, sensitivity, or dataset failure.
11#[derive(Debug, ThisError)]
12#[non_exhaustive]
13pub enum Error {
14    /// A failure from the balanced model, its readers, or its writers.
15    #[error(transparent)]
16    Core(#[from] powerio::Error),
17
18    /// An underlying I/O failure reading or writing a file.
19    ///
20    /// Routed through the hub's variant so the message a caller sees is the
21    /// same one every other powerio surface prints for the same failure.
22    #[error(transparent)]
23    Io(#[from] std::io::Error),
24
25    #[error("output dimension mismatch: matrix is {n}x{n} but RHS has length {b_len}")]
26    DimensionMismatch { n: usize, b_len: usize },
27
28    #[error("dimension mismatch: `{what}` expected length {expected}, got {got}")]
29    ShapeMismatch {
30        what: &'static str,
31        expected: usize,
32        got: usize,
33    },
34
35    #[error(
36        "DC sensitivity solve failed: the reference-grounded Laplacian is singular even though every component is grounded"
37    )]
38    SingularNetwork,
39
40    #[error("invalid DC sensitivity option: {reason}")]
41    InvalidSensitivityOptions { reason: String },
42
43    #[error(
44        "DC sensitivity iterative solve did not converge after {iterations} iterations (relative residual {relative_residual:.3e})"
45    )]
46    SensitivitySolveDidNotConverge {
47        iterations: usize,
48        relative_residual: f64,
49    },
50
51    #[error("matrix-market I/O: {0}")]
52    Mtx(String),
53
54    #[error("gridfm Parquet export: {0}")]
55    Parquet(String),
56
57    #[error("gridfm scenario batch is empty; provide at least one snapshot")]
58    EmptyScenarioBatch,
59
60    #[error("gridfm scenario id overflows i64 when numbering snapshot {index} from base {base}")]
61    ScenarioIdOverflow {
62        base: i64,
63        /// 0-based position of the snapshot whose `base + index` overflowed.
64        index: usize,
65    },
66
67    #[error(
68        "gridfm snapshot scenario {scenario} is normalized; gridfm export expects raw MW and degree fields"
69    )]
70    NormalizedGridfmSnapshot { scenario: i64 },
71
72    #[error(
73        "gridfm snapshot scenario {scenario} has non-finite {element} row {row} field `{field}`: {value}"
74    )]
75    NonFiniteGridfmValue {
76        scenario: i64,
77        element: &'static str,
78        row: usize,
79        field: &'static str,
80        value: f64,
81    },
82
83    #[error(
84        "gridfm snapshot {index} doesn't match the first snapshot's element set: {reason}; \
85         a scenario batch shares one base element set (same bus/branch/gen counts and bus-id order)"
86    )]
87    ScenarioShapeMismatch {
88        /// 0-based position of the offending snapshot in the batch (independent
89        /// of the snapshot's scenario id).
90        index: usize,
91        reason: ScenarioMismatch,
92    },
93}
94
95impl Error {
96    /// Classify this error, using the hub's taxonomy.
97    ///
98    /// The match is exhaustive over the variant set, so a new variant must be
99    /// classified here before it compiles.
100    #[must_use]
101    pub fn category(&self) -> powerio::ErrorCategory {
102        use powerio::ErrorCategory as C;
103        match self {
104            Error::Core(inner) => inner.category(),
105            Error::Io(_) => C::Io,
106            // A well-formed case that cannot satisfy a requested operation.
107            Error::DimensionMismatch { .. }
108            | Error::ShapeMismatch { .. }
109            | Error::SingularNetwork
110            | Error::InvalidSensitivityOptions { .. }
111            | Error::SensitivitySolveDidNotConverge { .. }
112            | Error::EmptyScenarioBatch
113            | Error::ScenarioIdOverflow { .. }
114            | Error::NormalizedGridfmSnapshot { .. }
115            | Error::NonFiniteGridfmValue { .. }
116            | Error::ScenarioShapeMismatch { .. } => C::Data,
117            // Output-side serialization write failures.
118            Error::Mtx(_) | Error::Parquet(_) => C::Output,
119        }
120    }
121}
122
123/// The element counts that define a scenario batch's shared base shape. Named
124/// (rather than a bare `(usize, usize, usize)`) so the three same-typed fields
125/// can't be transposed silently in an error message or a comparison.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct ElementCounts {
128    pub buses: usize,
129    pub branches: usize,
130    pub gens: usize,
131}
132
133impl std::fmt::Display for ElementCounts {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        write!(
136            f,
137            "{} buses, {} branches, {} gens",
138            self.buses, self.branches, self.gens
139        )
140    }
141}
142
143/// Why a gridfm scenario snapshot doesn't line up with the first snapshot's
144/// base element set (the row-stack keeps every table schema-consistent by
145/// requiring the same element counts and bus-id ordering across snapshots).
146///
147/// This enum is `#[non_exhaustive]`; downstream matches must include a wildcard
148/// arm.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150#[non_exhaustive]
151pub enum ScenarioMismatch {
152    /// Element counts differ.
153    Counts {
154        expected: ElementCounts,
155        got: ElementCounts,
156    },
157    /// Counts match, but the buses are listed in a different order (so the dense
158    /// bus index wouldn't mean the same bus across snapshots).
159    BusOrder,
160}
161
162impl std::fmt::Display for ScenarioMismatch {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        match self {
165            Self::Counts { expected, got } => {
166                write!(f, "got ({got}) vs the first snapshot's ({expected})")
167            }
168            Self::BusOrder => {
169                write!(f, "counts match but the bus ids are in a different order")
170            }
171        }
172    }
173}
174
175/// The result type every fallible entry point in this crate returns.
176pub type Result<T> = std::result::Result<T, Error>;
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use powerio::ErrorCategory::{Data, Output, Parse};
182
183    #[test]
184    fn category_pins_the_intended_buckets() {
185        assert_eq!(Error::SingularNetwork.category(), Data);
186        assert_eq!(Error::EmptyScenarioBatch.category(), Data);
187        assert_eq!(Error::Mtx("write failed".into()).category(), Output);
188        assert_eq!(Error::Parquet("write failed".into()).category(), Output);
189    }
190
191    #[test]
192    fn a_wrapped_hub_error_keeps_its_own_category() {
193        let wrapped: Error = powerio::Error::MissingField("bus").into();
194        assert_eq!(wrapped.category(), Parse);
195        // And its message, byte for byte: the C ABI reports errors as text, so
196        // a wrapper that restated the message would change what a binding sees.
197        assert_eq!(
198            wrapped.to_string(),
199            powerio::Error::MissingField("bus").to_string()
200        );
201    }
202}