Skip to main content

oxiphysics_materials/
error.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Error types for oxiphysics-materials.
5//!
6//! Provides a rich error taxonomy covering material parameter validation,
7//! numerical issues, EOS failures, phase-transform convergence, and I/O.
8
9use thiserror::Error;
10
11/// Main error type for the materials module.
12#[derive(Debug, Error)]
13pub enum Error {
14    /// Generic error message.
15    #[error("{0}")]
16    General(String),
17
18    /// A required material parameter is out of its physical range.
19    ///
20    /// `param` is the parameter name, `value` is what was supplied,
21    /// `min` / `max` are the valid bounds (use ±`f64::INFINITY` for open ends).
22    #[error("parameter '{param}' = {value} is out of range [{min}, {max}]")]
23    ParameterOutOfRange {
24        /// Parameter name.
25        param: &'static str,
26        /// Supplied value.
27        value: f64,
28        /// Minimum allowed value.
29        min: f64,
30        /// Maximum allowed value.
31        max: f64,
32    },
33
34    /// A required material property was not set.
35    #[error("required material property '{0}' is missing")]
36    MissingProperty(&'static str),
37
38    /// Numerical divergence during an iterative algorithm.
39    ///
40    /// Typically triggered when an implicit solver does not converge.
41    #[error(
42        "numerical divergence in '{solver}' after {iterations} iterations (residual {residual:.3e})"
43    )]
44    NumericalDivergence {
45        /// Name of the solver or algorithm that diverged.
46        solver: &'static str,
47        /// Number of iterations performed.
48        iterations: usize,
49        /// Final residual or error norm.
50        residual: f64,
51    },
52
53    /// Singularity encountered (e.g. zero determinant, zero density).
54    #[error("singularity in '{context}': {detail}")]
55    Singularity {
56        /// Where the singularity occurred.
57        context: &'static str,
58        /// Human-readable detail.
59        detail: String,
60    },
61
62    /// The requested deformation state is physically inadmissible
63    /// (e.g. negative volume, compressive stretch beyond locking).
64    #[error("inadmissible deformation state: {0}")]
65    InadmissibleState(String),
66
67    /// An equation of state returned an unphysical pressure.
68    #[error(
69        "EOS '{eos}' returned unphysical pressure {pressure:.3e} Pa at rho={density:.3e} kg/m³"
70    )]
71    UnphysicalEosPressure {
72        /// Equation of state identifier.
73        eos: &'static str,
74        /// Computed pressure (Pa).
75        pressure: f64,
76        /// Density at which the EOS was evaluated (kg/m³).
77        density: f64,
78    },
79
80    /// Phase-transformation model failed to converge.
81    #[error("phase transform '{model}' did not converge: {detail}")]
82    PhaseTransformConvergence {
83        /// Model identifier (e.g. "JMAK", "martensitic").
84        model: &'static str,
85        /// Additional diagnostic information.
86        detail: String,
87    },
88
89    /// Table look-up out of bounds (e.g. tabulated EOS, TTT diagram).
90    #[error(
91        "table look-up for '{table}' out of range: {variable} = {value:.3e} (range [{lo:.3e}, {hi:.3e}])"
92    )]
93    TableOutOfRange {
94        /// Table identifier.
95        table: &'static str,
96        /// Variable name being looked up.
97        variable: &'static str,
98        /// Queried value.
99        value: f64,
100        /// Lower bound of table.
101        lo: f64,
102        /// Upper bound of table.
103        hi: f64,
104    },
105
106    /// Fatigue model error (e.g. invalid cycle count, negative stress amplitude).
107    #[error("fatigue model error in '{model}': {detail}")]
108    FatigueModel {
109        /// Model name.
110        model: &'static str,
111        /// Description.
112        detail: String,
113    },
114
115    /// Fracture mechanics error (e.g. stress intensity factor not finite).
116    #[error("fracture mechanics error: {0}")]
117    FractureMechanics(String),
118
119    /// Incompatible units or dimension mismatch.
120    #[error("unit/dimension error: expected '{expected}', got '{actual}'")]
121    DimensionMismatch {
122        /// Expected dimension string.
123        expected: String,
124        /// Actual dimension string.
125        actual: String,
126    },
127
128    /// I/O error when loading material data from a file.
129    #[error("I/O error loading material data from '{path}': {message}")]
130    Io {
131        /// File path.
132        path: String,
133        /// Error message.
134        message: String,
135    },
136
137    /// Parse error when decoding material data.
138    #[error("parse error in '{context}': {message}")]
139    Parse {
140        /// Parsing context.
141        context: String,
142        /// Error message.
143        message: String,
144    },
145}
146
147/// Result type alias for the materials module.
148pub type Result<T> = std::result::Result<T, Error>;
149
150// ─────────────────────────────────────────────────────────────────────────────
151// Convenience constructors
152// ─────────────────────────────────────────────────────────────────────────────
153
154impl Error {
155    /// Create a [`Error::ParameterOutOfRange`] for a lower-bound violation.
156    pub fn below_minimum(param: &'static str, value: f64, min: f64) -> Self {
157        Self::ParameterOutOfRange {
158            param,
159            value,
160            min,
161            max: f64::INFINITY,
162        }
163    }
164
165    /// Create a [`Error::ParameterOutOfRange`] for an upper-bound violation.
166    pub fn above_maximum(param: &'static str, value: f64, max: f64) -> Self {
167        Self::ParameterOutOfRange {
168            param,
169            value,
170            min: f64::NEG_INFINITY,
171            max,
172        }
173    }
174
175    /// Create a [`Error::NumericalDivergence`] with a simple description.
176    pub fn diverged(solver: &'static str, iterations: usize, residual: f64) -> Self {
177        Self::NumericalDivergence {
178            solver,
179            iterations,
180            residual,
181        }
182    }
183
184    /// Create a [`Error::Singularity`] error.
185    pub fn singular(context: &'static str, detail: impl Into<String>) -> Self {
186        Self::Singularity {
187            context,
188            detail: detail.into(),
189        }
190    }
191
192    /// Create an [`Error::InadmissibleState`] error.
193    pub fn inadmissible(detail: impl Into<String>) -> Self {
194        Self::InadmissibleState(detail.into())
195    }
196
197    /// Create a [`Error::TableOutOfRange`] error.
198    #[allow(clippy::too_many_arguments)]
199    pub fn table_out_of_range(
200        table: &'static str,
201        variable: &'static str,
202        value: f64,
203        lo: f64,
204        hi: f64,
205    ) -> Self {
206        Self::TableOutOfRange {
207            table,
208            variable,
209            value,
210            lo,
211            hi,
212        }
213    }
214
215    /// Create a [`Error::FatigueModel`] error.
216    pub fn fatigue(model: &'static str, detail: impl Into<String>) -> Self {
217        Self::FatigueModel {
218            model,
219            detail: detail.into(),
220        }
221    }
222
223    /// Create an [`Error::UnphysicalEosPressure`] error.
224    pub fn unphysical_pressure(eos: &'static str, pressure: f64, density: f64) -> Self {
225        Self::UnphysicalEosPressure {
226            eos,
227            pressure,
228            density,
229        }
230    }
231
232    /// Returns `true` if this is a numerical issue (divergence or singularity).
233    pub fn is_numerical(&self) -> bool {
234        matches!(
235            self,
236            Self::NumericalDivergence { .. } | Self::Singularity { .. }
237        )
238    }
239
240    /// Returns `true` if this is a parameter validation error.
241    pub fn is_parameter_error(&self) -> bool {
242        matches!(
243            self,
244            Self::ParameterOutOfRange { .. } | Self::MissingProperty(_)
245        )
246    }
247
248    /// Returns `true` if this is an EOS-specific error.
249    pub fn is_eos_error(&self) -> bool {
250        matches!(self, Self::UnphysicalEosPressure { .. })
251    }
252}
253
254// ─────────────────────────────────────────────────────────────────────────────
255// Validation helpers
256// ─────────────────────────────────────────────────────────────────────────────
257
258/// Assert that `value` is strictly positive; return `Err` otherwise.
259///
260/// ```no_run
261/// use oxiphysics_materials::require_positive;
262/// assert!(require_positive("density", 1000.0).is_ok());
263/// assert!(require_positive("density", -1.0).is_err());
264/// ```
265pub fn require_positive(param: &'static str, value: f64) -> Result<f64> {
266    if value > 0.0 {
267        Ok(value)
268    } else {
269        Err(Error::below_minimum(param, value, 0.0))
270    }
271}
272
273/// Assert that `value` is non-negative; return `Err` otherwise.
274///
275/// ```no_run
276/// use oxiphysics_materials::require_non_negative;
277/// assert!(require_non_negative("strain", 0.0).is_ok());
278/// assert!(require_non_negative("strain", -0.1).is_err());
279/// ```
280pub fn require_non_negative(param: &'static str, value: f64) -> Result<f64> {
281    if value >= 0.0 {
282        Ok(value)
283    } else {
284        Err(Error::below_minimum(param, value, 0.0))
285    }
286}
287
288/// Assert that `value` lies in the closed interval `[lo, hi]`.
289///
290/// ```no_run
291/// use oxiphysics_materials::require_in_range;
292/// assert!(require_in_range("nu", 0.3, 0.0, 0.5).is_ok());
293/// assert!(require_in_range("nu", 0.6, 0.0, 0.5).is_err());
294/// ```
295pub fn require_in_range(param: &'static str, value: f64, lo: f64, hi: f64) -> Result<f64> {
296    if value >= lo && value <= hi {
297        Ok(value)
298    } else {
299        Err(Error::ParameterOutOfRange {
300            param,
301            value,
302            min: lo,
303            max: hi,
304        })
305    }
306}
307
308/// Assert that `value` is finite (not NaN or ±infinity).
309pub fn require_finite(param: &'static str, value: f64) -> Result<f64> {
310    if value.is_finite() {
311        Ok(value)
312    } else {
313        Err(Error::General(format!(
314            "parameter '{param}' is not finite: {value}"
315        )))
316    }
317}
318
319// ─────────────────────────────────────────────────────────────────────────────
320// Tests
321// ─────────────────────────────────────────────────────────────────────────────
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    // ── Display / formatting ──────────────────────────────────────────────────
328
329    #[test]
330    fn test_general_error_display() {
331        let e = Error::General("test message".to_string());
332        assert_eq!(format!("{e}"), "test message");
333    }
334
335    #[test]
336    fn test_parameter_out_of_range_display() {
337        let e = Error::ParameterOutOfRange {
338            param: "density",
339            value: -1.0,
340            min: 0.0,
341            max: f64::INFINITY,
342        };
343        let s = format!("{e}");
344        assert!(s.contains("density"), "display: {s}");
345        assert!(s.contains("-1"), "display: {s}");
346    }
347
348    #[test]
349    fn test_numerical_divergence_display() {
350        let e = Error::diverged("Newton", 100, 1e-3);
351        let s = format!("{e}");
352        assert!(s.contains("Newton"), "display: {s}");
353        assert!(s.contains("100"), "display: {s}");
354    }
355
356    #[test]
357    fn test_singularity_display() {
358        let e = Error::singular("det(F)", "F is rank-deficient");
359        let s = format!("{e}");
360        assert!(s.contains("det(F)"), "display: {s}");
361    }
362
363    #[test]
364    fn test_inadmissible_state_display() {
365        let e = Error::inadmissible("negative volume");
366        let s = format!("{e}");
367        assert!(s.contains("negative volume"), "display: {s}");
368    }
369
370    #[test]
371    fn test_unphysical_eos_pressure_display() {
372        let e = Error::unphysical_pressure("IdealGas", -1e10, 1.0);
373        let s = format!("{e}");
374        assert!(s.contains("IdealGas"), "display: {s}");
375        assert!(s.contains("pressure"), "display: {s}");
376    }
377
378    #[test]
379    fn test_phase_transform_convergence_display() {
380        let e = Error::PhaseTransformConvergence {
381            model: "JMAK",
382            detail: "temperature out of range".to_string(),
383        };
384        let s = format!("{e}");
385        assert!(s.contains("JMAK"), "display: {s}");
386    }
387
388    #[test]
389    fn test_table_out_of_range_display() {
390        let e = Error::table_out_of_range("TTT", "temperature", 1500.0, 300.0, 1200.0);
391        let s = format!("{e}");
392        assert!(s.contains("TTT"), "display: {s}");
393        assert!(s.contains("temperature"), "display: {s}");
394    }
395
396    #[test]
397    fn test_fatigue_error_display() {
398        let e = Error::fatigue("Basquin", "negative stress amplitude");
399        let s = format!("{e}");
400        assert!(s.contains("Basquin"), "display: {s}");
401    }
402
403    #[test]
404    fn test_fracture_mechanics_display() {
405        let e = Error::FractureMechanics("K_I is NaN".to_string());
406        let s = format!("{e}");
407        assert!(s.contains("K_I"), "display: {s}");
408    }
409
410    // ── Convenience constructors ──────────────────────────────────────────────
411
412    #[test]
413    fn test_below_minimum() {
414        let e = Error::below_minimum("E", -1.0, 0.0);
415        assert!(matches!(e, Error::ParameterOutOfRange { .. }));
416    }
417
418    #[test]
419    fn test_above_maximum() {
420        let e = Error::above_maximum("nu", 0.6, 0.5);
421        assert!(matches!(e, Error::ParameterOutOfRange { .. }));
422    }
423
424    // ── Classification helpers ────────────────────────────────────────────────
425
426    #[test]
427    fn test_is_numerical_divergence() {
428        let e = Error::diverged("solver", 10, 1e-2);
429        assert!(e.is_numerical());
430        assert!(!e.is_parameter_error());
431        assert!(!e.is_eos_error());
432    }
433
434    #[test]
435    fn test_is_numerical_singularity() {
436        let e = Error::singular("ctx", "detail");
437        assert!(e.is_numerical());
438    }
439
440    #[test]
441    fn test_is_parameter_error_out_of_range() {
442        let e = Error::below_minimum("x", -1.0, 0.0);
443        assert!(e.is_parameter_error());
444        assert!(!e.is_numerical());
445    }
446
447    #[test]
448    fn test_is_parameter_error_missing() {
449        let e = Error::MissingProperty("viscosity");
450        assert!(e.is_parameter_error());
451    }
452
453    #[test]
454    fn test_is_eos_error() {
455        let e = Error::unphysical_pressure("JWL", -1e9, 2000.0);
456        assert!(e.is_eos_error());
457        assert!(!e.is_numerical());
458    }
459
460    // ── Validation helpers ────────────────────────────────────────────────────
461
462    #[test]
463    fn test_require_positive_ok() {
464        assert_eq!(require_positive("rho", 1000.0).unwrap(), 1000.0);
465    }
466
467    #[test]
468    fn test_require_positive_fail_zero() {
469        assert!(require_positive("rho", 0.0).is_err());
470    }
471
472    #[test]
473    fn test_require_positive_fail_negative() {
474        assert!(require_positive("E", -1.0).is_err());
475    }
476
477    #[test]
478    fn test_require_non_negative_zero_ok() {
479        assert_eq!(require_non_negative("strain", 0.0).unwrap(), 0.0);
480    }
481
482    #[test]
483    fn test_require_non_negative_fail() {
484        assert!(require_non_negative("strain", -0.001).is_err());
485    }
486
487    #[test]
488    fn test_require_in_range_ok() {
489        assert_eq!(require_in_range("nu", 0.3, 0.0, 0.5).unwrap(), 0.3);
490    }
491
492    #[test]
493    fn test_require_in_range_boundary_ok() {
494        assert!(require_in_range("nu", 0.0, 0.0, 0.5).is_ok());
495        assert!(require_in_range("nu", 0.5, 0.0, 0.5).is_ok());
496    }
497
498    #[test]
499    fn test_require_in_range_fail_high() {
500        assert!(require_in_range("nu", 0.6, 0.0, 0.5).is_err());
501    }
502
503    #[test]
504    fn test_require_in_range_fail_low() {
505        assert!(require_in_range("nu", -0.1, 0.0, 0.5).is_err());
506    }
507
508    #[test]
509    fn test_require_finite_ok() {
510        assert_eq!(require_finite("x", 1.0).unwrap(), 1.0);
511    }
512
513    #[test]
514    fn test_require_finite_nan() {
515        assert!(require_finite("x", f64::NAN).is_err());
516    }
517
518    #[test]
519    fn test_require_finite_inf() {
520        assert!(require_finite("x", f64::INFINITY).is_err());
521    }
522
523    #[test]
524    fn test_dimension_mismatch_display() {
525        let e = Error::DimensionMismatch {
526            expected: "Pa".to_string(),
527            actual: "MPa".to_string(),
528        };
529        let s = format!("{e}");
530        assert!(s.contains("Pa"), "display: {s}");
531        assert!(s.contains("MPa"), "display: {s}");
532    }
533
534    #[test]
535    fn test_io_error_display() {
536        let e = Error::Io {
537            path: "/tmp/mat.json".to_string(),
538            message: "file not found".to_string(),
539        };
540        let s = format!("{e}");
541        assert!(s.contains("/tmp/mat.json"), "display: {s}");
542    }
543
544    #[test]
545    fn test_parse_error_display() {
546        let e = Error::Parse {
547            context: "JSON".to_string(),
548            message: "unexpected token".to_string(),
549        };
550        let s = format!("{e}");
551        assert!(s.contains("JSON"), "display: {s}");
552    }
553
554    // ── Result type alias ─────────────────────────────────────────────────────
555
556    #[test]
557    fn test_result_alias_ok() {
558        let r: Result<f64> = Ok(2.72);
559        assert!(r.is_ok());
560    }
561
562    #[test]
563    fn test_result_alias_err() {
564        let r: Result<f64> = Err(Error::inadmissible("test"));
565        assert!(r.is_err());
566    }
567}