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}