Skip to main content

oxiphysics_core/
error.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Error types for oxiphysics-core
5//!
6//! Provides a unified error hierarchy covering all physics domains (FEM, LBM,
7//! SPH, MD), with error context chaining, recovery suggestions, and diagnostic
8//! information.
9
10#![allow(dead_code)]
11
12use thiserror::Error;
13
14/// Main error type for the core module (legacy)
15#[derive(Debug, Error)]
16pub enum Error {
17    /// Generic error
18    #[error("{0}")]
19    General(String),
20}
21
22/// Result type alias (legacy)
23pub type Result<T> = std::result::Result<T, Error>;
24
25/// Unified error type for the physics engine.
26#[derive(Debug, Clone, PartialEq)]
27pub enum PhysicsError {
28    /// Solver diverged.
29    NumericalDivergence {
30        /// Description of the divergence.
31        message: String,
32    },
33    /// Invalid parameter.
34    InvalidInput {
35        /// Name of the field that was invalid.
36        field: String,
37        /// Reason it was invalid.
38        reason: String,
39    },
40    /// Index out of bounds.
41    OutOfBounds {
42        /// The index that was accessed.
43        index: usize,
44        /// The exclusive upper bound.
45        max: usize,
46    },
47    /// Iterative solver failed to converge.
48    ConvergenceFailed {
49        /// Number of iterations performed.
50        iterations: usize,
51        /// Target tolerance.
52        tolerance: f64,
53        /// Final residual achieved.
54        residual: f64,
55    },
56    /// Mesh topology error.
57    MeshError {
58        /// Description of the mesh error.
59        message: String,
60    },
61    /// Invalid material state.
62    MaterialError {
63        /// Description of the material error.
64        message: String,
65    },
66    /// Collision detection failure.
67    CollisionError {
68        /// Description of the collision error.
69        message: String,
70    },
71    /// I/O error wrapper.
72    IoError {
73        /// Description of the I/O error.
74        message: String,
75    },
76
77    // ── FEM domain errors ────────────────────────────────────────────────
78    /// FEM element error (degenerate element, negative Jacobian, etc.)
79    FemElementError {
80        /// Element index.
81        element_id: usize,
82        /// Description.
83        message: String,
84    },
85
86    /// FEM assembly error (stiffness matrix assembly failed).
87    FemAssemblyError {
88        /// Description.
89        message: String,
90    },
91
92    /// FEM boundary condition error.
93    FemBoundaryConditionError {
94        /// Node or DOF index.
95        node_id: usize,
96        /// Description.
97        message: String,
98    },
99
100    // ── LBM domain errors ────────────────────────────────────────────────
101    /// LBM lattice configuration error.
102    LbmLatticeError {
103        /// Description.
104        message: String,
105    },
106
107    /// LBM distribution function error (negative density, non-physical state).
108    LbmDistributionError {
109        /// Cell index.
110        cell_id: usize,
111        /// Description.
112        message: String,
113    },
114
115    /// LBM stability violation (CFL, Mach number exceeded).
116    LbmStabilityError {
117        /// The violated parameter name.
118        parameter: String,
119        /// The value that caused the violation.
120        value: f64,
121        /// The allowed limit.
122        limit: f64,
123    },
124
125    // ── SPH domain errors ────────────────────────────────────────────────
126    /// SPH kernel error (radius too small, invalid kernel type).
127    SphKernelError {
128        /// Description.
129        message: String,
130    },
131
132    /// SPH neighbor search error.
133    SphNeighborError {
134        /// Particle index.
135        particle_id: usize,
136        /// Description.
137        message: String,
138    },
139
140    /// SPH density computation error (negative density, tensile instability).
141    SphDensityError {
142        /// Particle index.
143        particle_id: usize,
144        /// Computed density value.
145        density: f64,
146    },
147
148    // ── MD domain errors ─────────────────────────────────────────────────
149    /// MD force field error (missing parameters, invalid potential).
150    MdForceFieldError {
151        /// Atom type pair.
152        atom_types: (String, String),
153        /// Description.
154        message: String,
155    },
156
157    /// MD integration error (energy drift, temperature blowup).
158    MdIntegrationError {
159        /// Time step at which the error occurred.
160        step: usize,
161        /// Description.
162        message: String,
163    },
164
165    /// MD neighbor list error.
166    MdNeighborListError {
167        /// Description.
168        message: String,
169    },
170
171    // ── Chained / contextual error ───────────────────────────────────────
172    /// Error with additional context.
173    WithContext {
174        /// The original error.
175        source: Box<PhysicsError>,
176        /// Additional context message.
177        context: String,
178    },
179}
180
181impl std::fmt::Display for PhysicsError {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        match self {
184            PhysicsError::NumericalDivergence { message } => {
185                write!(f, "Numerical divergence: {}", message)
186            }
187            PhysicsError::InvalidInput { field, reason } => {
188                write!(f, "Invalid input for '{}': {}", field, reason)
189            }
190            PhysicsError::OutOfBounds { index, max } => {
191                write!(f, "Index {} out of bounds (max {})", index, max)
192            }
193            PhysicsError::ConvergenceFailed {
194                iterations,
195                tolerance,
196                residual,
197            } => write!(
198                f,
199                "Convergence failed after {} iterations (tolerance={}, residual={})",
200                iterations, tolerance, residual
201            ),
202            PhysicsError::MeshError { message } => write!(f, "Mesh error: {}", message),
203            PhysicsError::MaterialError { message } => write!(f, "Material error: {}", message),
204            PhysicsError::CollisionError { message } => write!(f, "Collision error: {}", message),
205            PhysicsError::IoError { message } => write!(f, "I/O error: {}", message),
206
207            // FEM
208            PhysicsError::FemElementError {
209                element_id,
210                message,
211            } => write!(f, "FEM element {} error: {}", element_id, message),
212            PhysicsError::FemAssemblyError { message } => {
213                write!(f, "FEM assembly error: {}", message)
214            }
215            PhysicsError::FemBoundaryConditionError { node_id, message } => {
216                write!(f, "FEM BC error at node {}: {}", node_id, message)
217            }
218
219            // LBM
220            PhysicsError::LbmLatticeError { message } => {
221                write!(f, "LBM lattice error: {}", message)
222            }
223            PhysicsError::LbmDistributionError { cell_id, message } => {
224                write!(f, "LBM distribution error at cell {}: {}", cell_id, message)
225            }
226            PhysicsError::LbmStabilityError {
227                parameter,
228                value,
229                limit,
230            } => write!(
231                f,
232                "LBM stability violation: {} = {} exceeds limit {}",
233                parameter, value, limit
234            ),
235
236            // SPH
237            PhysicsError::SphKernelError { message } => {
238                write!(f, "SPH kernel error: {}", message)
239            }
240            PhysicsError::SphNeighborError {
241                particle_id,
242                message,
243            } => write!(
244                f,
245                "SPH neighbor error for particle {}: {}",
246                particle_id, message
247            ),
248            PhysicsError::SphDensityError {
249                particle_id,
250                density,
251            } => write!(
252                f,
253                "SPH density error: particle {} has invalid density {}",
254                particle_id, density
255            ),
256
257            // MD
258            PhysicsError::MdForceFieldError {
259                atom_types,
260                message,
261            } => write!(
262                f,
263                "MD force field error for pair ({}, {}): {}",
264                atom_types.0, atom_types.1, message
265            ),
266            PhysicsError::MdIntegrationError { step, message } => {
267                write!(f, "MD integration error at step {}: {}", step, message)
268            }
269            PhysicsError::MdNeighborListError { message } => {
270                write!(f, "MD neighbor list error: {}", message)
271            }
272
273            // Context chain
274            PhysicsError::WithContext { source, context } => {
275                write!(f, "{}: {}", context, source)
276            }
277        }
278    }
279}
280
281impl std::error::Error for PhysicsError {}
282
283impl PhysicsError {
284    /// Wrap this error with additional context.
285    pub fn with_context(self, context: impl Into<String>) -> Self {
286        PhysicsError::WithContext {
287            source: Box::new(self),
288            context: context.into(),
289        }
290    }
291
292    /// Get the root cause error, unwrapping all context layers.
293    pub fn root_cause(&self) -> &PhysicsError {
294        match self {
295            PhysicsError::WithContext { source, .. } => source.root_cause(),
296            other => other,
297        }
298    }
299
300    /// Get the chain of context messages from outermost to innermost.
301    pub fn context_chain(&self) -> Vec<&str> {
302        let mut chain = Vec::new();
303        let mut current = self;
304        while let PhysicsError::WithContext { source, context } = current {
305            chain.push(context.as_str());
306            current = source;
307        }
308        chain
309    }
310
311    /// Returns the domain name for this error variant.
312    pub fn domain(&self) -> &'static str {
313        match self {
314            PhysicsError::FemElementError { .. }
315            | PhysicsError::FemAssemblyError { .. }
316            | PhysicsError::FemBoundaryConditionError { .. } => "FEM",
317
318            PhysicsError::LbmLatticeError { .. }
319            | PhysicsError::LbmDistributionError { .. }
320            | PhysicsError::LbmStabilityError { .. } => "LBM",
321
322            PhysicsError::SphKernelError { .. }
323            | PhysicsError::SphNeighborError { .. }
324            | PhysicsError::SphDensityError { .. } => "SPH",
325
326            PhysicsError::MdForceFieldError { .. }
327            | PhysicsError::MdIntegrationError { .. }
328            | PhysicsError::MdNeighborListError { .. } => "MD",
329
330            PhysicsError::MeshError { .. } => "Mesh",
331            PhysicsError::MaterialError { .. } => "Material",
332            PhysicsError::CollisionError { .. } => "Collision",
333            PhysicsError::IoError { .. } => "IO",
334
335            PhysicsError::NumericalDivergence { .. } => "Numerical",
336            PhysicsError::InvalidInput { .. } => "Input",
337            PhysicsError::OutOfBounds { .. } => "Bounds",
338            PhysicsError::ConvergenceFailed { .. } => "Solver",
339
340            PhysicsError::WithContext { source, .. } => source.domain(),
341        }
342    }
343
344    /// Returns a recovery suggestion for this error.
345    pub fn recovery_suggestion(&self) -> &'static str {
346        match self {
347            PhysicsError::NumericalDivergence { .. } => {
348                "Try reducing the time step or using a more stable integration scheme."
349            }
350            PhysicsError::InvalidInput { .. } => {
351                "Check the input parameters against the documentation."
352            }
353            PhysicsError::OutOfBounds { .. } => "Verify array sizes and index calculations.",
354            PhysicsError::ConvergenceFailed { .. } => {
355                "Try increasing max iterations, relaxing tolerance, or improving the initial guess."
356            }
357            PhysicsError::MeshError { .. } => {
358                "Check mesh quality metrics and repair degenerate elements."
359            }
360            PhysicsError::MaterialError { .. } => {
361                "Verify material parameters (e.g., positive moduli, valid Poisson ratio)."
362            }
363            PhysicsError::CollisionError { .. } => {
364                "Check collision geometry and broadphase settings."
365            }
366            PhysicsError::IoError { .. } => "Check file paths, permissions, and disk space.",
367            PhysicsError::FemElementError { .. } => {
368                "Check element connectivity and node positions for degenerate configurations."
369            }
370            PhysicsError::FemAssemblyError { .. } => {
371                "Verify element stiffness matrices are positive semi-definite."
372            }
373            PhysicsError::FemBoundaryConditionError { .. } => {
374                "Ensure boundary conditions are consistent and the system is not over-constrained."
375            }
376            PhysicsError::LbmLatticeError { .. } => {
377                "Check lattice dimensions and velocity model configuration."
378            }
379            PhysicsError::LbmDistributionError { .. } => {
380                "Reduce the time step or check inlet/outlet boundary conditions."
381            }
382            PhysicsError::LbmStabilityError { .. } => {
383                "Reduce flow velocity or increase lattice resolution to satisfy stability constraints."
384            }
385            PhysicsError::SphKernelError { .. } => {
386                "Check smoothing length and kernel radius parameters."
387            }
388            PhysicsError::SphNeighborError { .. } => {
389                "Increase neighbor search radius or check spatial hashing configuration."
390            }
391            PhysicsError::SphDensityError { .. } => {
392                "Add tensile instability correction or increase particle count in sparse regions."
393            }
394            PhysicsError::MdForceFieldError { .. } => {
395                "Check force field parameter files and atom type definitions."
396            }
397            PhysicsError::MdIntegrationError { .. } => {
398                "Reduce time step, check for overlapping atoms, or use a thermostat."
399            }
400            PhysicsError::MdNeighborListError { .. } => {
401                "Increase skin distance or rebuild neighbor list more frequently."
402            }
403            PhysicsError::WithContext { source, .. } => source.recovery_suggestion(),
404        }
405    }
406
407    /// Returns the severity level of this error.
408    pub fn severity(&self) -> ErrorSeverity {
409        match self {
410            PhysicsError::NumericalDivergence { .. } => ErrorSeverity::Critical,
411            PhysicsError::ConvergenceFailed { .. } => ErrorSeverity::Warning,
412            PhysicsError::InvalidInput { .. } => ErrorSeverity::Error,
413            PhysicsError::OutOfBounds { .. } => ErrorSeverity::Error,
414            PhysicsError::LbmStabilityError { .. } => ErrorSeverity::Critical,
415            PhysicsError::MdIntegrationError { .. } => ErrorSeverity::Critical,
416            PhysicsError::SphDensityError { .. } => ErrorSeverity::Warning,
417            PhysicsError::FemElementError { .. } => ErrorSeverity::Error,
418            PhysicsError::WithContext { source, .. } => source.severity(),
419            _ => ErrorSeverity::Error,
420        }
421    }
422}
423
424/// Severity level for physics errors.
425#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
426pub enum ErrorSeverity {
427    /// Informational — the simulation can continue.
428    Info,
429    /// Warning — the simulation may produce inaccurate results.
430    Warning,
431    /// Error — the current operation cannot complete.
432    Error,
433    /// Critical — the entire simulation state may be corrupt.
434    Critical,
435}
436
437impl std::fmt::Display for ErrorSeverity {
438    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439        match self {
440            ErrorSeverity::Info => write!(f, "INFO"),
441            ErrorSeverity::Warning => write!(f, "WARNING"),
442            ErrorSeverity::Error => write!(f, "ERROR"),
443            ErrorSeverity::Critical => write!(f, "CRITICAL"),
444        }
445    }
446}
447
448/// Result alias using `PhysicsError`.
449pub type PhysicsResult<T> = std::result::Result<T, PhysicsError>;
450
451/// Returns `Ok(value)` if `value > 0`, otherwise `Err(InvalidInput)`.
452pub fn check_positive(value: f64, name: &str) -> PhysicsResult<f64> {
453    if value > 0.0 {
454        Ok(value)
455    } else {
456        Err(PhysicsError::InvalidInput {
457            field: name.to_string(),
458            reason: format!("expected positive value, got {}", value),
459        })
460    }
461}
462
463/// Returns `Ok(value)` if `value >= 0`, otherwise `Err(InvalidInput)`.
464pub fn check_non_negative(value: f64, name: &str) -> PhysicsResult<f64> {
465    if value >= 0.0 {
466        Ok(value)
467    } else {
468        Err(PhysicsError::InvalidInput {
469            field: name.to_string(),
470            reason: format!("expected non-negative value, got {}", value),
471        })
472    }
473}
474
475/// Returns `Ok(value)` if `min <= value <= max`, otherwise `Err(InvalidInput)`.
476pub fn check_range(value: f64, min: f64, max: f64, name: &str) -> PhysicsResult<f64> {
477    if value >= min && value <= max {
478        Ok(value)
479    } else {
480        Err(PhysicsError::InvalidInput {
481            field: name.to_string(),
482            reason: format!("expected value in [{}, {}], got {}", min, max, value),
483        })
484    }
485}
486
487/// Returns `Ok(value)` if finite, otherwise `Err(NumericalDivergence)`.
488pub fn check_finite(value: f64, name: &str) -> PhysicsResult<f64> {
489    if value.is_finite() {
490        Ok(value)
491    } else {
492        Err(PhysicsError::NumericalDivergence {
493            message: format!("'{}' is not finite: {}", name, value),
494        })
495    }
496}
497
498/// Returns `Ok(index)` if `index < max`, otherwise `Err(OutOfBounds)`.
499pub fn check_index(index: usize, max: usize) -> PhysicsResult<usize> {
500    if index < max {
501        Ok(index)
502    } else {
503        Err(PhysicsError::OutOfBounds { index, max })
504    }
505}
506
507/// Check that a 3D vector has finite components.
508pub fn check_finite_vec3(v: [f64; 3], name: &str) -> PhysicsResult<[f64; 3]> {
509    for (i, &c) in v.iter().enumerate() {
510        if !c.is_finite() {
511            return Err(PhysicsError::NumericalDivergence {
512                message: format!("'{}'[{}] is not finite: {}", name, i, c),
513            });
514        }
515    }
516    Ok(v)
517}
518
519/// Check that a slice contains no NaN or infinite values.
520pub fn check_finite_slice(values: &[f64], name: &str) -> PhysicsResult<()> {
521    for (i, &v) in values.iter().enumerate() {
522        if !v.is_finite() {
523            return Err(PhysicsError::NumericalDivergence {
524                message: format!("'{}'[{}] is not finite: {}", name, i, v),
525            });
526        }
527    }
528    Ok(())
529}
530
531/// Check Poisson's ratio is in the valid range (-1, 0.5) for 3D.
532pub fn check_poisson_ratio(nu: f64) -> PhysicsResult<f64> {
533    if nu > -1.0 && nu < 0.5 {
534        Ok(nu)
535    } else {
536        Err(PhysicsError::MaterialError {
537            message: format!("Poisson's ratio must be in (-1, 0.5) for 3D, got {}", nu),
538        })
539    }
540}
541
542/// Check that the LBM Mach number is below the stability limit.
543pub fn check_lbm_mach(velocity: f64, cs: f64, limit: f64) -> PhysicsResult<f64> {
544    let mach = velocity / cs;
545    if mach <= limit {
546        Ok(mach)
547    } else {
548        Err(PhysicsError::LbmStabilityError {
549            parameter: "Mach number".to_string(),
550            value: mach,
551            limit,
552        })
553    }
554}
555
556/// Check that SPH density is positive.
557pub fn check_sph_density(particle_id: usize, density: f64) -> PhysicsResult<f64> {
558    if density > 0.0 {
559        Ok(density)
560    } else {
561        Err(PhysicsError::SphDensityError {
562            particle_id,
563            density,
564        })
565    }
566}
567
568/// Check that an MD time step is reasonable (positive and not too large).
569pub fn check_md_timestep(dt: f64, max_dt: f64) -> PhysicsResult<f64> {
570    if dt <= 0.0 {
571        Err(PhysicsError::InvalidInput {
572            field: "timestep".to_string(),
573            reason: format!("must be positive, got {}", dt),
574        })
575    } else if dt > max_dt {
576        Err(PhysicsError::MdIntegrationError {
577            step: 0,
578            message: format!("timestep {} exceeds maximum {}", dt, max_dt),
579        })
580    } else {
581        Ok(dt)
582    }
583}
584
585/// Tracks solver convergence history and diagnostics.
586#[derive(Debug, Clone)]
587pub struct SolverDiagnostics {
588    /// Number of iterations performed.
589    pub iterations: usize,
590    /// Final residual after last iteration.
591    pub final_residual: f64,
592    /// Whether the solver converged.
593    pub converged: bool,
594    /// Per-iteration residual history.
595    pub history: Vec<f64>,
596}
597
598impl SolverDiagnostics {
599    /// Creates a new, empty `SolverDiagnostics`.
600    pub fn new() -> Self {
601        Self {
602            iterations: 0,
603            final_residual: f64::INFINITY,
604            converged: false,
605            history: Vec::new(),
606        }
607    }
608
609    /// Records a residual for the current iteration.
610    pub fn record(&mut self, residual: f64) {
611        self.history.push(residual);
612        self.iterations = self.history.len();
613        self.final_residual = residual;
614    }
615
616    /// Marks convergence and returns `Ok(())`, or `Err(ConvergenceFailed)`.
617    pub fn check_convergence(&self, tolerance: f64) -> PhysicsResult<()> {
618        if self.converged || self.final_residual <= tolerance {
619            Ok(())
620        } else {
621            Err(PhysicsError::ConvergenceFailed {
622                iterations: self.iterations,
623                tolerance,
624                residual: self.final_residual,
625            })
626        }
627    }
628
629    /// Returns the geometric-mean convergence rate from the last 5 residuals,
630    /// or `None` if fewer than 2 residuals have been recorded.
631    pub fn convergence_rate(&self) -> Option<f64> {
632        let n = self.history.len();
633        if n < 2 {
634            return None;
635        }
636        let window_size = 5.min(n);
637        let window = &self.history[n - window_size..];
638        // Geometric mean of successive ratios: (r_{i+1}/r_i)
639        let steps = window_size - 1;
640        let log_sum: f64 = window
641            .windows(2)
642            .map(|w| {
643                if w[0].abs() < f64::EPSILON {
644                    0.0
645                } else {
646                    (w[1].abs() / w[0].abs()).ln()
647                }
648            })
649            .sum();
650        Some((log_sum / steps as f64).exp())
651    }
652
653    /// Check if the solver is diverging (residual increasing over the last few iterations).
654    pub fn is_diverging(&self) -> bool {
655        let n = self.history.len();
656        if n < 3 {
657            return false;
658        }
659        // Check if the last 3 residuals are monotonically increasing
660        let last3 = &self.history[n - 3..];
661        last3[0] < last3[1] && last3[1] < last3[2]
662    }
663
664    /// Returns a summary string for diagnostics output.
665    pub fn summary(&self) -> String {
666        let status = if self.converged {
667            "CONVERGED"
668        } else if self.is_diverging() {
669            "DIVERGING"
670        } else {
671            "NOT CONVERGED"
672        };
673        format!(
674            "[{}] {} iterations, residual = {:.6e}",
675            status, self.iterations, self.final_residual
676        )
677    }
678
679    /// Reset the diagnostics for a new solve.
680    pub fn reset(&mut self) {
681        self.iterations = 0;
682        self.final_residual = f64::INFINITY;
683        self.converged = false;
684        self.history.clear();
685    }
686}
687
688impl Default for SolverDiagnostics {
689    fn default() -> Self {
690        Self::new()
691    }
692}
693
694/// Diagnostic information attached to an error for debugging.
695#[derive(Debug, Clone)]
696pub struct DiagnosticInfo {
697    /// The error that triggered the diagnostic.
698    pub error: PhysicsError,
699    /// Time step or simulation time at which the error occurred.
700    pub time: Option<f64>,
701    /// Step number at which the error occurred.
702    pub step: Option<usize>,
703    /// Solver diagnostics if available.
704    pub solver_diag: Option<SolverDiagnostics>,
705    /// Additional key-value metadata.
706    pub metadata: Vec<(String, String)>,
707}
708
709impl DiagnosticInfo {
710    /// Create diagnostic info from an error.
711    pub fn from_error(error: PhysicsError) -> Self {
712        Self {
713            error,
714            time: None,
715            step: None,
716            solver_diag: None,
717            metadata: Vec::new(),
718        }
719    }
720
721    /// Attach a simulation time.
722    pub fn at_time(mut self, t: f64) -> Self {
723        self.time = Some(t);
724        self
725    }
726
727    /// Attach a step number.
728    pub fn at_step(mut self, step: usize) -> Self {
729        self.step = Some(step);
730        self
731    }
732
733    /// Attach solver diagnostics.
734    pub fn with_solver_diag(mut self, diag: SolverDiagnostics) -> Self {
735        self.solver_diag = Some(diag);
736        self
737    }
738
739    /// Add a metadata key-value pair.
740    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
741        self.metadata.push((key.into(), value.into()));
742        self
743    }
744
745    /// Format a full diagnostic report.
746    pub fn report(&self) -> String {
747        let mut lines = Vec::new();
748        lines.push(format!("[{}] {}", self.error.severity(), self.error));
749        lines.push(format!("Domain: {}", self.error.domain()));
750        lines.push(format!("Suggestion: {}", self.error.recovery_suggestion()));
751        if let Some(t) = self.time {
752            lines.push(format!("Time: {:.6e}", t));
753        }
754        if let Some(s) = self.step {
755            lines.push(format!("Step: {}", s));
756        }
757        if let Some(ref diag) = self.solver_diag {
758            lines.push(format!("Solver: {}", diag.summary()));
759        }
760        for (k, v) in &self.metadata {
761            lines.push(format!("{}: {}", k, v));
762        }
763        lines.join("\n")
764    }
765}
766
767/// Collect multiple errors during a batch operation.
768#[derive(Debug, Clone, Default)]
769pub struct ErrorCollector {
770    /// Collected errors.
771    pub errors: Vec<PhysicsError>,
772    /// Maximum number of errors to collect before stopping.
773    pub max_errors: usize,
774}
775
776impl ErrorCollector {
777    /// Create a new collector with default capacity (100).
778    pub fn new() -> Self {
779        Self {
780            errors: Vec::new(),
781            max_errors: 100,
782        }
783    }
784
785    /// Create a collector with a specific max error limit.
786    pub fn with_limit(max: usize) -> Self {
787        Self {
788            errors: Vec::new(),
789            max_errors: max,
790        }
791    }
792
793    /// Add an error. Returns `true` if the error limit has been reached.
794    pub fn push(&mut self, error: PhysicsError) -> bool {
795        self.errors.push(error);
796        self.errors.len() >= self.max_errors
797    }
798
799    /// Returns `true` if any errors were collected.
800    pub fn has_errors(&self) -> bool {
801        !self.errors.is_empty()
802    }
803
804    /// Number of collected errors.
805    pub fn count(&self) -> usize {
806        self.errors.len()
807    }
808
809    /// Drain all errors, returning them.
810    pub fn drain(&mut self) -> Vec<PhysicsError> {
811        std::mem::take(&mut self.errors)
812    }
813
814    /// Convert into a result: Ok if no errors, Err with first error otherwise.
815    pub fn into_result(self) -> PhysicsResult<()> {
816        if self.errors.is_empty() {
817            Ok(())
818        } else {
819            let count = self.count();
820            let first = self
821                .errors
822                .into_iter()
823                .next()
824                .expect("errors is non-empty after check");
825            Err(first.with_context(format!("First of {} errors", count)))
826        }
827    }
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    #[test]
835    fn test_check_positive() {
836        assert!(check_positive(1.0, "x").is_ok());
837        assert!(check_positive(-1.0, "x").is_err());
838        assert!(check_positive(0.0, "x").is_err());
839    }
840
841    #[test]
842    fn test_check_range() {
843        assert!(check_range(0.5, 0.0, 1.0, "t").is_ok());
844        assert!(check_range(0.0, 0.0, 1.0, "t").is_ok());
845        assert!(check_range(1.0, 0.0, 1.0, "t").is_ok());
846        assert!(check_range(-0.1, 0.0, 1.0, "t").is_err());
847        assert!(check_range(1.1, 0.0, 1.0, "t").is_err());
848    }
849
850    #[test]
851    fn test_check_finite() {
852        assert!(check_finite(f64::NAN, "v").is_err());
853        assert!(check_finite(f64::INFINITY, "v").is_err());
854        assert!(check_finite(f64::NEG_INFINITY, "v").is_err());
855        assert!(check_finite(1.0, "v").is_ok());
856    }
857
858    #[test]
859    fn test_check_index() {
860        assert!(check_index(0, 5).is_ok());
861        assert!(check_index(4, 5).is_ok());
862        assert!(check_index(5, 5).is_err());
863        assert!(check_index(100, 5).is_err());
864    }
865
866    #[test]
867    fn test_solver_diagnostics_convergence() {
868        let mut diag = SolverDiagnostics::new();
869        let tolerance = 1e-6;
870        // Simulate 10 iterations converging from 1.0 down to below tolerance
871        let mut residual = 1.0_f64;
872        for _ in 0..10 {
873            residual *= 0.1;
874            diag.record(residual);
875        }
876        // After 10 iterations residual is 1e-10, well below 1e-6
877        assert!(diag.check_convergence(tolerance).is_ok());
878    }
879
880    #[test]
881    fn test_solver_diagnostics_not_converged() {
882        let mut diag = SolverDiagnostics::new();
883        diag.record(1.0);
884        diag.record(0.9);
885        assert!(diag.check_convergence(1e-6).is_err());
886    }
887
888    #[test]
889    fn test_convergence_rate_none_when_too_few() {
890        let mut diag = SolverDiagnostics::new();
891        assert!(diag.convergence_rate().is_none());
892        diag.record(1.0);
893        assert!(diag.convergence_rate().is_none());
894    }
895
896    #[test]
897    fn test_convergence_rate_some() {
898        let mut diag = SolverDiagnostics::new();
899        for i in 1..=6 {
900            diag.record(1.0 / (10_f64.powi(i)));
901        }
902        let rate = diag.convergence_rate();
903        assert!(rate.is_some());
904        // Each step reduces by factor 10, so rate ≈ 0.1
905        let r = rate.unwrap();
906        assert!((r - 0.1).abs() < 1e-9, "rate was {}", r);
907    }
908
909    #[test]
910    fn test_display_physics_error() {
911        let e = PhysicsError::InvalidInput {
912            field: "density".to_string(),
913            reason: "must be positive".to_string(),
914        };
915        let s = format!("{}", e);
916        assert!(s.contains("density"));
917        assert!(s.contains("must be positive"));
918    }
919
920    // ── New tests ────────────────────────────────────────────────────────────
921
922    #[test]
923    fn test_check_non_negative() {
924        assert!(check_non_negative(0.0, "x").is_ok());
925        assert!(check_non_negative(1.0, "x").is_ok());
926        assert!(check_non_negative(-0.01, "x").is_err());
927    }
928
929    #[test]
930    fn test_check_finite_vec3() {
931        assert!(check_finite_vec3([1.0, 2.0, 3.0], "v").is_ok());
932        assert!(check_finite_vec3([f64::NAN, 0.0, 0.0], "v").is_err());
933        assert!(check_finite_vec3([0.0, f64::INFINITY, 0.0], "v").is_err());
934    }
935
936    #[test]
937    fn test_check_finite_slice() {
938        assert!(check_finite_slice(&[1.0, 2.0, 3.0], "arr").is_ok());
939        assert!(check_finite_slice(&[1.0, f64::NAN], "arr").is_err());
940    }
941
942    #[test]
943    fn test_check_poisson_ratio() {
944        assert!(check_poisson_ratio(0.3).is_ok());
945        assert!(check_poisson_ratio(0.0).is_ok());
946        assert!(check_poisson_ratio(-0.5).is_ok());
947        assert!(check_poisson_ratio(0.5).is_err()); // boundary excluded
948        assert!(check_poisson_ratio(-1.0).is_err()); // boundary excluded
949        assert!(check_poisson_ratio(0.6).is_err());
950    }
951
952    #[test]
953    fn test_check_lbm_mach() {
954        assert!(check_lbm_mach(0.1, 1.0 / 3.0_f64.sqrt(), 0.3).is_ok());
955        // High velocity triggers error
956        let result = check_lbm_mach(10.0, 1.0 / 3.0_f64.sqrt(), 0.3);
957        assert!(result.is_err());
958        if let Err(PhysicsError::LbmStabilityError { parameter, .. }) = result {
959            assert_eq!(parameter, "Mach number");
960        }
961    }
962
963    #[test]
964    fn test_check_sph_density() {
965        assert!(check_sph_density(0, 1000.0).is_ok());
966        assert!(check_sph_density(5, -1.0).is_err());
967        assert!(check_sph_density(5, 0.0).is_err());
968    }
969
970    #[test]
971    fn test_check_md_timestep() {
972        assert!(check_md_timestep(0.001, 0.01).is_ok());
973        assert!(check_md_timestep(-0.001, 0.01).is_err());
974        assert!(check_md_timestep(0.1, 0.01).is_err());
975    }
976
977    #[test]
978    fn test_error_with_context() {
979        let e = PhysicsError::InvalidInput {
980            field: "mass".to_string(),
981            reason: "negative".to_string(),
982        };
983        let e2 = e.with_context("during particle initialization");
984        let s = format!("{}", e2);
985        assert!(s.contains("during particle initialization"));
986        assert!(s.contains("mass"));
987    }
988
989    #[test]
990    fn test_error_root_cause() {
991        let e = PhysicsError::NumericalDivergence {
992            message: "blowup".to_string(),
993        };
994        let e2 = e.with_context("in solver").with_context("in simulation");
995        let root = e2.root_cause();
996        match root {
997            PhysicsError::NumericalDivergence { message } => {
998                assert_eq!(message, "blowup");
999            }
1000            other => panic!("expected NumericalDivergence, got {:?}", other),
1001        }
1002    }
1003
1004    #[test]
1005    fn test_error_context_chain() {
1006        let e = PhysicsError::MeshError {
1007            message: "bad".to_string(),
1008        };
1009        let e2 = e.with_context("step 1").with_context("step 2");
1010        let chain = e2.context_chain();
1011        assert_eq!(chain.len(), 2);
1012        assert_eq!(chain[0], "step 2");
1013        assert_eq!(chain[1], "step 1");
1014    }
1015
1016    #[test]
1017    fn test_error_domain() {
1018        assert_eq!(
1019            PhysicsError::FemElementError {
1020                element_id: 0,
1021                message: "x".into()
1022            }
1023            .domain(),
1024            "FEM"
1025        );
1026        assert_eq!(
1027            PhysicsError::LbmLatticeError {
1028                message: "x".into()
1029            }
1030            .domain(),
1031            "LBM"
1032        );
1033        assert_eq!(
1034            PhysicsError::SphKernelError {
1035                message: "x".into()
1036            }
1037            .domain(),
1038            "SPH"
1039        );
1040        assert_eq!(
1041            PhysicsError::MdForceFieldError {
1042                atom_types: ("A".into(), "B".into()),
1043                message: "x".into()
1044            }
1045            .domain(),
1046            "MD"
1047        );
1048    }
1049
1050    #[test]
1051    fn test_error_severity() {
1052        assert_eq!(
1053            PhysicsError::NumericalDivergence {
1054                message: "x".into()
1055            }
1056            .severity(),
1057            ErrorSeverity::Critical
1058        );
1059        assert_eq!(
1060            PhysicsError::ConvergenceFailed {
1061                iterations: 10,
1062                tolerance: 1e-6,
1063                residual: 1e-3
1064            }
1065            .severity(),
1066            ErrorSeverity::Warning
1067        );
1068        assert_eq!(
1069            PhysicsError::InvalidInput {
1070                field: "x".into(),
1071                reason: "y".into()
1072            }
1073            .severity(),
1074            ErrorSeverity::Error
1075        );
1076    }
1077
1078    #[test]
1079    fn test_error_recovery_suggestion() {
1080        let e = PhysicsError::ConvergenceFailed {
1081            iterations: 100,
1082            tolerance: 1e-6,
1083            residual: 1e-3,
1084        };
1085        let suggestion = e.recovery_suggestion();
1086        assert!(suggestion.contains("iterations") || suggestion.contains("tolerance"));
1087    }
1088
1089    #[test]
1090    fn test_severity_display() {
1091        assert_eq!(format!("{}", ErrorSeverity::Critical), "CRITICAL");
1092        assert_eq!(format!("{}", ErrorSeverity::Warning), "WARNING");
1093        assert_eq!(format!("{}", ErrorSeverity::Error), "ERROR");
1094        assert_eq!(format!("{}", ErrorSeverity::Info), "INFO");
1095    }
1096
1097    #[test]
1098    fn test_severity_ordering() {
1099        assert!(ErrorSeverity::Info < ErrorSeverity::Warning);
1100        assert!(ErrorSeverity::Warning < ErrorSeverity::Error);
1101        assert!(ErrorSeverity::Error < ErrorSeverity::Critical);
1102    }
1103
1104    #[test]
1105    fn test_solver_diagnostics_is_diverging() {
1106        let mut diag = SolverDiagnostics::new();
1107        diag.record(1.0);
1108        diag.record(2.0);
1109        diag.record(3.0);
1110        assert!(diag.is_diverging());
1111
1112        let mut diag2 = SolverDiagnostics::new();
1113        diag2.record(3.0);
1114        diag2.record(2.0);
1115        diag2.record(1.0);
1116        assert!(!diag2.is_diverging());
1117    }
1118
1119    #[test]
1120    fn test_solver_diagnostics_summary() {
1121        let mut diag = SolverDiagnostics::new();
1122        diag.record(1.0);
1123        diag.converged = true;
1124        let s = diag.summary();
1125        assert!(s.contains("CONVERGED"));
1126    }
1127
1128    #[test]
1129    fn test_solver_diagnostics_reset() {
1130        let mut diag = SolverDiagnostics::new();
1131        diag.record(1.0);
1132        diag.record(0.5);
1133        diag.converged = true;
1134        diag.reset();
1135        assert_eq!(diag.iterations, 0);
1136        assert!(diag.history.is_empty());
1137        assert!(!diag.converged);
1138    }
1139
1140    #[test]
1141    fn test_diagnostic_info_report() {
1142        let e = PhysicsError::MdIntegrationError {
1143            step: 1000,
1144            message: "energy drift".to_string(),
1145        };
1146        let report = DiagnosticInfo::from_error(e)
1147            .at_time(1.5e-12)
1148            .at_step(1000)
1149            .with_metadata("ensemble", "NVT")
1150            .report();
1151        assert!(report.contains("CRITICAL"));
1152        assert!(report.contains("MD"));
1153        assert!(report.contains("energy drift"));
1154        assert!(report.contains("ensemble: NVT"));
1155    }
1156
1157    #[test]
1158    fn test_error_collector_basic() {
1159        let mut collector = ErrorCollector::new();
1160        assert!(!collector.has_errors());
1161        assert_eq!(collector.count(), 0);
1162
1163        collector.push(PhysicsError::InvalidInput {
1164            field: "x".into(),
1165            reason: "bad".into(),
1166        });
1167        assert!(collector.has_errors());
1168        assert_eq!(collector.count(), 1);
1169    }
1170
1171    #[test]
1172    fn test_error_collector_limit() {
1173        let mut collector = ErrorCollector::with_limit(3);
1174        collector.push(PhysicsError::MeshError {
1175            message: "e1".into(),
1176        });
1177        collector.push(PhysicsError::MeshError {
1178            message: "e2".into(),
1179        });
1180        let limit_reached = collector.push(PhysicsError::MeshError {
1181            message: "e3".into(),
1182        });
1183        assert!(limit_reached);
1184    }
1185
1186    #[test]
1187    fn test_error_collector_drain() {
1188        let mut collector = ErrorCollector::new();
1189        collector.push(PhysicsError::IoError {
1190            message: "test".into(),
1191        });
1192        let errors = collector.drain();
1193        assert_eq!(errors.len(), 1);
1194        assert!(!collector.has_errors());
1195    }
1196
1197    #[test]
1198    fn test_error_collector_into_result_ok() {
1199        let collector = ErrorCollector::new();
1200        assert!(collector.into_result().is_ok());
1201    }
1202
1203    #[test]
1204    fn test_error_collector_into_result_err() {
1205        let mut collector = ErrorCollector::new();
1206        collector.push(PhysicsError::IoError {
1207            message: "fail".into(),
1208        });
1209        let result = collector.into_result();
1210        assert!(result.is_err());
1211    }
1212
1213    #[test]
1214    fn test_display_fem_errors() {
1215        let e = PhysicsError::FemElementError {
1216            element_id: 42,
1217            message: "negative Jacobian".to_string(),
1218        };
1219        let s = format!("{}", e);
1220        assert!(s.contains("42"));
1221        assert!(s.contains("negative Jacobian"));
1222    }
1223
1224    #[test]
1225    fn test_display_lbm_errors() {
1226        let e = PhysicsError::LbmStabilityError {
1227            parameter: "CFL".to_string(),
1228            value: 1.5,
1229            limit: 1.0,
1230        };
1231        let s = format!("{}", e);
1232        assert!(s.contains("CFL"));
1233        assert!(s.contains("1.5"));
1234        assert!(s.contains("1"));
1235    }
1236
1237    #[test]
1238    fn test_display_sph_errors() {
1239        let e = PhysicsError::SphDensityError {
1240            particle_id: 7,
1241            density: -0.5,
1242        };
1243        let s = format!("{}", e);
1244        assert!(s.contains("7"));
1245        assert!(s.contains("-0.5"));
1246    }
1247
1248    #[test]
1249    fn test_display_md_errors() {
1250        let e = PhysicsError::MdForceFieldError {
1251            atom_types: ("C".to_string(), "O".to_string()),
1252            message: "missing LJ parameters".to_string(),
1253        };
1254        let s = format!("{}", e);
1255        assert!(s.contains("C"));
1256        assert!(s.contains("O"));
1257        assert!(s.contains("missing LJ parameters"));
1258    }
1259
1260    #[test]
1261    fn test_context_chain_preserves_domain() {
1262        let e = PhysicsError::SphKernelError {
1263            message: "bad kernel".into(),
1264        };
1265        let e2 = e.with_context("in step 5");
1266        assert_eq!(e2.domain(), "SPH");
1267    }
1268}