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_tx::Error`]
4//! for everything the transmission model raises underneath, so `?` moves that 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 powerio_core::DiagnosticInfo;
9use thiserror::Error as ThisError;
10
11use crate::diagnostics::codes;
12
13/// A matrix, sensitivity, or dataset failure.
14#[derive(Debug, ThisError)]
15#[non_exhaustive]
16pub enum Error {
17    /// A failure from the balanced model, its readers, or its writers.
18    #[error(transparent)]
19    Transmission(#[from] powerio_tx::Error),
20
21    /// An underlying I/O failure this crate raised itself.
22    ///
23    /// One the transmission model raised arrives as [`Error::Transmission`] wrapping
24    /// `powerio_tx::Error::Io`, so a caller telling I/O apart from the rest reads
25    /// [`Error::category`] rather than matching this variant.
26    #[error(transparent)]
27    Io(#[from] std::io::Error),
28
29    /// A refused or failed destination commit (an output collision, an
30    /// invalid inventory, a staging failure), carrying the registered core
31    /// failure. Code and category delegate to it.
32    #[error(transparent)]
33    Commit(#[from] powerio_core::Error),
34
35    #[error("output dimension mismatch: matrix is {n}x{n} but RHS has length {b_len}")]
36    DimensionMismatch { n: usize, b_len: usize },
37
38    #[error("dimension mismatch: `{what}` expected length {expected}, got {got}")]
39    ShapeMismatch {
40        what: &'static str,
41        expected: usize,
42        got: usize,
43    },
44
45    #[error("unsupported OPF objective: {reason}")]
46    UnsupportedOpfObjective { reason: String },
47
48    #[error("unsupported AC power flow bus specification")]
49    UnsupportedAcPfSpecification,
50
51    #[error("invalid LinDist3Flow coefficient input: {reason}")]
52    InvalidLinDist3FlowCoefficients { reason: String },
53
54    #[error("{family} constraint selection names unknown identity `{identity}`")]
55    UnknownConstraintIdentity {
56        family: &'static str,
57        identity: String,
58    },
59
60    #[error("{family} has duplicate stable identity `{identity}`")]
61    DuplicateElementIdentity {
62        family: &'static str,
63        identity: String,
64    },
65
66    #[error(
67        "DC sensitivity solve failed: the reference-grounded Laplacian is singular even though every component is grounded"
68    )]
69    SingularNetwork,
70
71    #[error("invalid DC sensitivity option: {reason}")]
72    InvalidSensitivityOptions { reason: String },
73
74    #[error("case has no generators; DC-OPF requires an `mpc.gen` block")]
75    NoGenerators,
76
77    #[error(
78        "generator {gen_index} has an unsupported cost model (model {model}, ncost {ncost}); need polynomial model 2 with degree ≤ 2"
79    )]
80    UnsupportedCostModel {
81        gen_index: usize,
82        model: u8,
83        ncost: usize,
84    },
85
86    #[error("generator {gen_index} has an invalid piecewise linear cost: {reason}")]
87    InvalidPiecewiseCost {
88        gen_index: usize,
89        reason: PiecewiseCostInvalidity,
90    },
91
92    #[error(
93        "generator {gen_index} has a nonconvex piecewise linear cost: segment {segment} has a lower slope than the preceding segment"
94    )]
95    NonconvexPiecewiseCost { gen_index: usize, segment: usize },
96
97    #[error(
98        "generator {gen_index} has a piecewise linear cost that cannot be projected to one nodal quadratic cost"
99    )]
100    PiecewiseNodalCost { gen_index: usize },
101
102    #[error(
103        "generator {gen_index} has a concave cost row (c2 = {c2}); need a nonnegative quadratic coefficient"
104    )]
105    ConcaveCost { gen_index: usize, c2: f64 },
106
107    #[error("matrix-market I/O: {0}")]
108    Mtx(String),
109
110    #[error("gridfm Parquet export: {0}")]
111    Parquet(String),
112
113    #[error("gridfm scenario batch is empty; provide at least one snapshot")]
114    EmptyScenarioBatch,
115
116    #[error("gridfm scenario id overflows i64 when numbering snapshot {index} from base {base}")]
117    ScenarioIdOverflow {
118        base: i64,
119        /// 0-based position of the snapshot whose `base + index` overflowed.
120        index: usize,
121    },
122
123    #[error(
124        "gridfm snapshot scenario {scenario} is normalized; gridfm export expects raw MW and degree fields"
125    )]
126    NormalizedGridfmSnapshot { scenario: i64 },
127
128    #[error(
129        "gridfm snapshot scenario {scenario} has non-finite {element} row {row} field `{field}`: {value}"
130    )]
131    NonFiniteGridfmValue {
132        scenario: i64,
133        element: &'static str,
134        row: usize,
135        field: &'static str,
136        value: f64,
137    },
138
139    #[error(
140        "gridfm snapshot {index} doesn't align with the scenario batch: {reason}; \
141         a batch requires unique scenario ids, one system base, and fixed bus/branch/gen row identities"
142    )]
143    ScenarioShapeMismatch {
144        /// 0-based position of the offending snapshot in the batch (independent
145        /// of the snapshot's scenario id).
146        index: usize,
147        reason: ScenarioMismatch,
148    },
149}
150
151impl Error {
152    /// The registry entry for this error. The match is exhaustive over the
153    /// variant set, so a new variant must be coded here before it compiles.
154    ///
155    /// A hub failure keeps the hub's own code: restating it here would give one
156    /// failure two identities.
157    #[must_use]
158    pub fn code(&self) -> &'static DiagnosticInfo {
159        match self {
160            Error::Transmission(inner) => inner.code(),
161            Error::Commit(inner) => inner.info().unwrap_or(&codes::EMIT_MTX_FAILED),
162            Error::Io(_) => &codes::READ_MATRIX_IO_FAILED,
163            Error::DimensionMismatch { .. } | Error::ShapeMismatch { .. } => {
164                &codes::BUILD_MATRIX_SHAPE_MISMATCH
165            }
166            Error::UnsupportedOpfObjective { .. } => &codes::BUILD_OPF_OBJECTIVE_UNSUPPORTED,
167            Error::UnsupportedAcPfSpecification => &codes::BUILD_AC_PF_SPECIFICATION_UNSUPPORTED,
168            Error::InvalidLinDist3FlowCoefficients { .. } => {
169                &codes::BUILD_LINDIST3FLOW_COEFFICIENT_INVALID
170            }
171            Error::UnknownConstraintIdentity { .. } => {
172                &codes::BUILD_OPF_CONSTRAINT_IDENTITY_UNKNOWN
173            }
174            Error::DuplicateElementIdentity { .. } => &codes::BUILD_OPF_ELEMENT_IDENTITY_DUPLICATE,
175            Error::SingularNetwork => &codes::BUILD_SENSITIVITY_SINGULAR,
176            Error::InvalidSensitivityOptions { .. } => &codes::BUILD_SENSITIVITY_INVALID_OPTION,
177            Error::EmptyScenarioBatch => &codes::BUILD_GRIDFM_EMPTY_BATCH,
178            Error::ScenarioIdOverflow { .. } => &codes::BUILD_GRIDFM_SCENARIO_ID_OVERFLOW,
179            Error::NormalizedGridfmSnapshot { .. } => &codes::BUILD_GRIDFM_NORMALIZED_SNAPSHOT,
180            Error::NonFiniteGridfmValue { .. } => &codes::BUILD_GRIDFM_NOT_A_NUMBER,
181            Error::ScenarioShapeMismatch { .. } => &codes::BUILD_GRIDFM_SCENARIO_SHAPE_MISMATCH,
182            Error::NoGenerators => &powerio_prob::diagnostics::codes::BUILD_INSTANCE_NO_GENERATORS,
183            Error::UnsupportedCostModel { .. } => {
184                &powerio_prob::diagnostics::codes::BUILD_INSTANCE_UNSUPPORTED_COST_MODEL
185            }
186            Error::InvalidPiecewiseCost { .. } => {
187                &powerio_prob::diagnostics::codes::BUILD_INSTANCE_PIECEWISE_COST_INVALID
188            }
189            Error::NonconvexPiecewiseCost { .. } => {
190                &powerio_prob::diagnostics::codes::BUILD_INSTANCE_PIECEWISE_COST_NONCONVEX
191            }
192            Error::PiecewiseNodalCost { .. } => &codes::BUILD_OPF_NODAL_COST_UNSUPPORTED,
193            Error::ConcaveCost { .. } => {
194                &powerio_prob::diagnostics::codes::BUILD_INSTANCE_CONCAVE_COST
195            }
196            Error::Mtx(_) => &codes::EMIT_MTX_FAILED,
197            Error::Parquet(_) => &codes::EMIT_PARQUET_FAILED,
198        }
199    }
200
201    /// Classify this error, using the hub's taxonomy.
202    ///
203    /// The match is exhaustive over the variant set, so a new variant must be
204    /// classified here before it compiles.
205    #[must_use]
206    pub fn category(&self) -> powerio_tx::ErrorCategory {
207        use powerio_tx::ErrorCategory as C;
208        match self {
209            Error::Transmission(inner) => inner.category(),
210            Error::Commit(inner) => inner.category(),
211            Error::Io(_) => C::Io,
212            // A well-formed case that cannot satisfy a requested operation.
213            Error::DimensionMismatch { .. }
214            | Error::ShapeMismatch { .. }
215            | Error::UnsupportedOpfObjective { .. }
216            | Error::UnsupportedAcPfSpecification
217            | Error::InvalidLinDist3FlowCoefficients { .. }
218            | Error::UnknownConstraintIdentity { .. }
219            | Error::DuplicateElementIdentity { .. }
220            | Error::SingularNetwork
221            | Error::InvalidSensitivityOptions { .. }
222            | Error::EmptyScenarioBatch
223            | Error::ScenarioIdOverflow { .. }
224            | Error::NormalizedGridfmSnapshot { .. }
225            | Error::NonFiniteGridfmValue { .. }
226            | Error::ScenarioShapeMismatch { .. }
227            | Error::NoGenerators
228            | Error::UnsupportedCostModel { .. }
229            | Error::InvalidPiecewiseCost { .. }
230            | Error::NonconvexPiecewiseCost { .. }
231            | Error::ConcaveCost { .. } => C::Data,
232            Error::PiecewiseNodalCost { .. } => C::Request,
233            // Output-side serialization write failures.
234            Error::Mtx(_) | Error::Parquet(_) => C::Output,
235        }
236    }
237}
238
239/// Why a MATPOWER model 1 generator cost row is unusable.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241#[non_exhaustive]
242pub enum PiecewiseCostInvalidity {
243    FewerThanTwoBreakpoints { declared: usize },
244    Truncated { expected_values: usize, got: usize },
245    NonFinitePoint { point: usize },
246    NonIncreasingPower { point: usize },
247}
248
249impl std::fmt::Display for PiecewiseCostInvalidity {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        match self {
252            Self::FewerThanTwoBreakpoints { declared } => {
253                write!(f, "need at least two breakpoints, got {declared}")
254            }
255            Self::Truncated {
256                expected_values,
257                got,
258            } => write!(
259                f,
260                "declared breakpoints require {expected_values} values, got {got}"
261            ),
262            Self::NonFinitePoint { point } => {
263                write!(f, "breakpoint {point} has a non-finite coordinate")
264            }
265            Self::NonIncreasingPower { point } => write!(
266                f,
267                "breakpoint {point} does not have greater power than its predecessor"
268            ),
269        }
270    }
271}
272
273/// The element counts that define a scenario batch's shared base shape. Named
274/// (rather than a bare `(usize, usize, usize)`) so the three same-typed fields
275/// can't be transposed silently in an error message or a comparison.
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub struct ElementCounts {
278    pub buses: usize,
279    pub branches: usize,
280    pub gens: usize,
281}
282
283impl std::fmt::Display for ElementCounts {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        write!(
286            f,
287            "{} buses, {} branches, {} gens",
288            self.buses, self.branches, self.gens
289        )
290    }
291}
292
293/// Why a gridfm scenario snapshot doesn't line up with the first snapshot's
294/// batch (the row stack uses each table's row number as element identity).
295///
296/// This enum is `#[non_exhaustive]`; downstream matches must include a wildcard
297/// arm.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299#[non_exhaustive]
300pub enum ScenarioMismatch {
301    /// Element counts differ.
302    Counts {
303        expected: ElementCounts,
304        got: ElementCounts,
305    },
306    /// Counts match, but the buses are listed in a different order (so the dense
307    /// bus index wouldn't mean the same bus across snapshots).
308    BusOrder,
309    /// A branch row has a different endpoint pair or component identity.
310    BranchOrder,
311    /// A generator row has a different bus or component identity.
312    GeneratorOrder,
313    /// The snapshot uses a different system power base.
314    BaseMva,
315    /// Another snapshot already uses this scenario id.
316    DuplicateScenarioId { scenario: i64, first_index: usize },
317}
318
319impl std::fmt::Display for ScenarioMismatch {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        match self {
322            Self::Counts { expected, got } => {
323                write!(f, "got ({got}) vs the first snapshot's ({expected})")
324            }
325            Self::BusOrder => {
326                write!(f, "counts match but the bus ids are in a different order")
327            }
328            Self::BranchOrder => write!(
329                f,
330                "counts match but branch endpoints or identities differ by row"
331            ),
332            Self::GeneratorOrder => write!(
333                f,
334                "counts match but generator buses or identities differ by row"
335            ),
336            Self::BaseMva => write!(f, "base_mva differs from the first snapshot"),
337            Self::DuplicateScenarioId {
338                scenario,
339                first_index,
340            } => write!(
341                f,
342                "scenario id {scenario} is already used by snapshot {first_index}"
343            ),
344        }
345    }
346}
347
348/// The result type every fallible entry point in this crate returns.
349pub type Result<T> = std::result::Result<T, Error>;
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use powerio_tx::ErrorCategory::{Data, Output, Parse, Request};
355
356    #[test]
357    fn category_pins_the_intended_buckets() {
358        assert_eq!(Error::SingularNetwork.category(), Data);
359        assert_eq!(Error::EmptyScenarioBatch.category(), Data);
360        assert_eq!(Error::Mtx("write failed".into()).category(), Output);
361        assert_eq!(Error::Parquet("write failed".into()).category(), Output);
362    }
363
364    // Every error is a diagnostic that ended the operation, so the code's
365    // published category and `category()` are one fact.
366    #[test]
367    fn every_error_code_publishes_the_category_the_variant_reports() {
368        let every: Vec<Error> = vec![
369            powerio_tx::Error::MissingField("bus").into(),
370            std::io::Error::from(std::io::ErrorKind::NotFound).into(),
371            Error::DimensionMismatch { n: 2, b_len: 3 },
372            Error::ShapeMismatch {
373                what: "p",
374                expected: 2,
375                got: 3,
376            },
377            Error::UnsupportedOpfObjective {
378                reason: "term".into(),
379            },
380            Error::UnknownConstraintIdentity {
381                family: "branches",
382                identity: "missing".into(),
383            },
384            Error::DuplicateElementIdentity {
385                family: "branches",
386                identity: "duplicate".into(),
387            },
388            Error::SingularNetwork,
389            Error::InvalidSensitivityOptions {
390                reason: "tol".into(),
391            },
392            Error::EmptyScenarioBatch,
393            Error::ScenarioIdOverflow { base: 1, index: 0 },
394            Error::NormalizedGridfmSnapshot { scenario: 1 },
395            Error::NonFiniteGridfmValue {
396                scenario: 1,
397                element: "bus",
398                row: 0,
399                field: "vm",
400                value: f64::NAN,
401            },
402            Error::ScenarioShapeMismatch {
403                index: 1,
404                reason: ScenarioMismatch::BusOrder,
405            },
406            Error::NoGenerators,
407            Error::UnsupportedCostModel {
408                gen_index: 0,
409                model: 1,
410                ncost: 4,
411            },
412            Error::InvalidPiecewiseCost {
413                gen_index: 0,
414                reason: PiecewiseCostInvalidity::FewerThanTwoBreakpoints { declared: 1 },
415            },
416            Error::NonconvexPiecewiseCost {
417                gen_index: 0,
418                segment: 1,
419            },
420            Error::PiecewiseNodalCost { gen_index: 0 },
421            Error::ConcaveCost {
422                gen_index: 0,
423                c2: -0.5,
424            },
425            Error::Mtx("write failed".into()),
426            Error::Parquet("write failed".into()),
427        ];
428        for error in &every {
429            assert_eq!(
430                error.code().category,
431                Some(error.category()),
432                "{}",
433                error.code().code
434            );
435        }
436    }
437
438    #[test]
439    fn piecewise_cost_failures_keep_distinct_diagnostic_meanings() {
440        let malformed = Error::InvalidPiecewiseCost {
441            gen_index: 2,
442            reason: PiecewiseCostInvalidity::FewerThanTwoBreakpoints { declared: 1 },
443        };
444        let nonconvex = Error::NonconvexPiecewiseCost {
445            gen_index: 2,
446            segment: 1,
447        };
448        let nodal_projection = Error::PiecewiseNodalCost { gen_index: 2 };
449
450        assert_eq!(
451            malformed.code().code,
452            "BUILD.INSTANCE.PIECEWISE_COST_INVALID"
453        );
454        assert_eq!(
455            nonconvex.code().code,
456            "BUILD.INSTANCE.PIECEWISE_COST_NONCONVEX"
457        );
458        assert_eq!(
459            nodal_projection.code().code,
460            "BUILD.OPF.NODAL_COST_UNSUPPORTED"
461        );
462        assert_eq!(malformed.category(), Data);
463        assert_eq!(nonconvex.category(), Data);
464        assert_eq!(nodal_projection.category(), Request);
465    }
466
467    #[test]
468    fn a_wrapped_hub_error_keeps_its_own_category() {
469        let wrapped: Error = powerio_tx::Error::MissingField("bus").into();
470        assert_eq!(wrapped.category(), Parse);
471        // And its message, byte for byte: the C ABI reports errors as text, so
472        // a wrapper that restated the message would change what a binding sees.
473        assert_eq!(
474            wrapped.to_string(),
475            powerio_tx::Error::MissingField("bus").to_string()
476        );
477    }
478}