1use thiserror::Error;
10
11#[derive(Debug, Error)]
13pub enum Error {
14 #[error("{0}")]
16 General(String),
17
18 #[error("parameter '{param}' = {value} is out of range [{min}, {max}]")]
23 ParameterOutOfRange {
24 param: &'static str,
26 value: f64,
28 min: f64,
30 max: f64,
32 },
33
34 #[error("required material property '{0}' is missing")]
36 MissingProperty(&'static str),
37
38 #[error(
42 "numerical divergence in '{solver}' after {iterations} iterations (residual {residual:.3e})"
43 )]
44 NumericalDivergence {
45 solver: &'static str,
47 iterations: usize,
49 residual: f64,
51 },
52
53 #[error("singularity in '{context}': {detail}")]
55 Singularity {
56 context: &'static str,
58 detail: String,
60 },
61
62 #[error("inadmissible deformation state: {0}")]
65 InadmissibleState(String),
66
67 #[error(
69 "EOS '{eos}' returned unphysical pressure {pressure:.3e} Pa at rho={density:.3e} kg/m³"
70 )]
71 UnphysicalEosPressure {
72 eos: &'static str,
74 pressure: f64,
76 density: f64,
78 },
79
80 #[error("phase transform '{model}' did not converge: {detail}")]
82 PhaseTransformConvergence {
83 model: &'static str,
85 detail: String,
87 },
88
89 #[error(
91 "table look-up for '{table}' out of range: {variable} = {value:.3e} (range [{lo:.3e}, {hi:.3e}])"
92 )]
93 TableOutOfRange {
94 table: &'static str,
96 variable: &'static str,
98 value: f64,
100 lo: f64,
102 hi: f64,
104 },
105
106 #[error("fatigue model error in '{model}': {detail}")]
108 FatigueModel {
109 model: &'static str,
111 detail: String,
113 },
114
115 #[error("fracture mechanics error: {0}")]
117 FractureMechanics(String),
118
119 #[error("unit/dimension error: expected '{expected}', got '{actual}'")]
121 DimensionMismatch {
122 expected: String,
124 actual: String,
126 },
127
128 #[error("I/O error loading material data from '{path}': {message}")]
130 Io {
131 path: String,
133 message: String,
135 },
136
137 #[error("parse error in '{context}': {message}")]
139 Parse {
140 context: String,
142 message: String,
144 },
145}
146
147pub type Result<T> = std::result::Result<T, Error>;
149
150impl Error {
155 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 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 pub fn diverged(solver: &'static str, iterations: usize, residual: f64) -> Self {
177 Self::NumericalDivergence {
178 solver,
179 iterations,
180 residual,
181 }
182 }
183
184 pub fn singular(context: &'static str, detail: impl Into<String>) -> Self {
186 Self::Singularity {
187 context,
188 detail: detail.into(),
189 }
190 }
191
192 pub fn inadmissible(detail: impl Into<String>) -> Self {
194 Self::InadmissibleState(detail.into())
195 }
196
197 #[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 pub fn fatigue(model: &'static str, detail: impl Into<String>) -> Self {
217 Self::FatigueModel {
218 model,
219 detail: detail.into(),
220 }
221 }
222
223 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 pub fn is_numerical(&self) -> bool {
234 matches!(
235 self,
236 Self::NumericalDivergence { .. } | Self::Singularity { .. }
237 )
238 }
239
240 pub fn is_parameter_error(&self) -> bool {
242 matches!(
243 self,
244 Self::ParameterOutOfRange { .. } | Self::MissingProperty(_)
245 )
246 }
247
248 pub fn is_eos_error(&self) -> bool {
250 matches!(self, Self::UnphysicalEosPressure { .. })
251 }
252}
253
254pub 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
273pub 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
288pub 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
308pub 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#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[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 #[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 #[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 #[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 #[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}