Skip to main content

rustsim_crowd/
error.rs

1//! Parameter and runtime errors for [`rustsim-crowd`](crate).
2//!
3//! The primary use site is [`CrowdError`] returned by each model's
4//! `Params::validate(dt)` method. Validating once at the start of a
5//! simulation catches the three classes of misconfiguration that
6//! otherwise surface as silent physics corruption:
7//!
8//! 1. **Non-positive physical parameters** (mass ≤ 0, range ≤ 0, etc.):
9//!    division by zero or negative relaxation, producing NaN
10//!    velocities within a few ticks.
11//! 2. **CFL-like instability** (`dt * max_accel > max_speed`): a single
12//!    explicit-Euler tick can exceed the speed cap in one integration
13//!    step, which is survivable thanks to `clamp_speed` but is a
14//!    smell suggesting either a too-coarse `dt` or a too-stiff
15//!    interaction.
16//! 3. **Non-finite `dt`**: forwarded straight into the integrator.
17//!
18//! All validators are cheap (a handful of comparisons) and deterministic.
19
20use thiserror::Error;
21
22/// Errors surfaced by [`rustsim-crowd`](crate) parameter validation.
23///
24/// This enum is deliberately flat (no nested error types, no `Box<dyn
25/// Error>`) so callers can match on it exhaustively. Every variant
26/// carries the model name as its first field so a downstream error
27/// message tells the operator which model's `Params` is misconfigured
28/// without having to walk a stack trace.
29#[derive(Debug, Clone, PartialEq, Error)]
30pub enum CrowdError {
31    /// A strictly-positive parameter was given a zero or negative value.
32    #[error(
33        "{model}: parameter `{param}` must be > 0, got {value} (zero/negative values \
34         produce NaN velocities within a few ticks)"
35    )]
36    NonPositiveParam {
37        /// Which model's `Params` failed validation (e.g. `"SocialForce"`).
38        model: &'static str,
39        /// Name of the offending parameter (e.g. `"mass"`, `"tau"`).
40        param: &'static str,
41        /// The actual value that was supplied.
42        value: f64,
43    },
44
45    /// A non-negative parameter was given a negative value.
46    #[error("{model}: parameter `{param}` must be >= 0, got {value}")]
47    NegativeParam {
48        /// Which model's `Params` failed validation.
49        model: &'static str,
50        /// Name of the offending parameter.
51        param: &'static str,
52        /// The actual value that was supplied.
53        value: f64,
54    },
55
56    /// A count-like parameter (number of candidates, number of directions)
57    /// was given a zero value when it must be ≥ 1.
58    #[error("{model}: parameter `{param}` must be >= 1, got 0")]
59    ZeroCount {
60        /// Which model's `Params` failed validation.
61        model: &'static str,
62        /// Name of the offending parameter.
63        param: &'static str,
64    },
65
66    /// Explicit-Euler CFL-like condition violated.
67    ///
68    /// For force-based models (Social Force, Generalized Centrifugal
69    /// Force) the per-tick velocity change is bounded by
70    /// `dt * max_accel`. If that bound exceeds `max_speed` the model
71    /// relies entirely on the post-integration `clamp_speed` call to
72    /// keep trajectories physical, which masks stiff interactions and
73    /// hides numerical blow-ups.
74    #[error(
75        "{model}: CFL violation — dt * max_accel = {product} m/s exceeds \
76         max_speed = {max_speed} m/s; reduce `dt` to at most {max_dt} s or \
77         raise `max_speed`"
78    )]
79    CflViolation {
80        /// Which model's `Params` failed validation.
81        model: &'static str,
82        /// `dt * max_accel` for the offending configuration (m/s).
83        product: f64,
84        /// `max_speed` for the offending configuration (m/s).
85        max_speed: f64,
86        /// The largest `dt` that satisfies `dt * max_accel <= max_speed`.
87        max_dt: f64,
88    },
89
90    /// `dt` is NaN, infinite, or non-positive.
91    #[error(
92        "{model}: dt must be a finite positive number, got {dt}; every \
93         step_* entry point assumes a real-time tick duration"
94    )]
95    InvalidDt {
96        /// Which model's `Params` was being validated.
97        model: &'static str,
98        /// The actual `dt` that was supplied.
99        dt: f64,
100    },
101}
102
103/// Internal helper: assert a parameter is `> 0` or return a
104/// [`CrowdError::NonPositiveParam`].
105#[inline]
106pub(crate) fn require_positive(
107    model: &'static str,
108    param: &'static str,
109    value: f64,
110) -> Result<(), CrowdError> {
111    if value.is_finite() && value > 0.0 {
112        Ok(())
113    } else {
114        Err(CrowdError::NonPositiveParam {
115            model,
116            param,
117            value,
118        })
119    }
120}
121
122/// Internal helper: assert a parameter is `>= 0` or return a
123/// [`CrowdError::NegativeParam`].
124#[inline]
125pub(crate) fn require_nonneg(
126    model: &'static str,
127    param: &'static str,
128    value: f64,
129) -> Result<(), CrowdError> {
130    if value.is_finite() && value >= 0.0 {
131        Ok(())
132    } else {
133        Err(CrowdError::NegativeParam {
134            model,
135            param,
136            value,
137        })
138    }
139}
140
141/// Internal helper: assert a count is `>= 1`.
142#[inline]
143pub(crate) fn require_count(
144    model: &'static str,
145    param: &'static str,
146    value: usize,
147) -> Result<(), CrowdError> {
148    if value >= 1 {
149        Ok(())
150    } else {
151        Err(CrowdError::ZeroCount { model, param })
152    }
153}
154
155/// Internal helper: assert `dt` is a finite positive number.
156#[inline]
157pub(crate) fn require_dt(model: &'static str, dt: f64) -> Result<(), CrowdError> {
158    if dt.is_finite() && dt > 0.0 {
159        Ok(())
160    } else {
161        Err(CrowdError::InvalidDt { model, dt })
162    }
163}