Skip to main content

rustsim_crowd/
social_force.rs

1//! Social Force Model (Helbing & Molnár 1995; Helbing, Farkas & Vicsek 2000).
2//!
3//! Each pedestrian experiences a sum of three forces:
4//!
5//! - a driving force pulling it toward its destination at its desired speed,
6//! - a repulsive social force from every other pedestrian,
7//! - a repulsive force from every static obstacle (wall segment).
8//!
9//! The resulting acceleration is integrated with **semi-implicit
10//! (symplectic) Euler**: each tick first updates velocity from the
11//! current acceleration, then advances position using the *new*
12//! velocity (`v ← v + a·dt; p ← p + v·dt`). This is the classic
13//! stability fix for stiff pair interactions — it preserves energy
14//! far better than fully explicit Euler at identical `dt` and is
15//! the same integration scheme used by JuPedSim and PedSim for the
16//! Social Force family. Combined with the `Params::max_accel` cap
17//! and the `dt · max_accel ≤ max_speed` CFL check in
18//! [`Params::validate`], this keeps the integrator stable at the
19//! default 30 Hz timestep even under panic-flow parameterisation.
20//! The speed is clipped to `params.max_speed` after the velocity
21//! update, before the position advance.
22//!
23//! This implementation follows Eq. 1–3 of
24//! Helbing, Farkas & Vicsek (2000), "Simulating dynamical features of
25//! escape panic", *Nature* 407, 487–490, with the elliptical repulsion
26//! shape from Helbing & Molnár (1995).
27//!
28//! # References
29//!
30//! - Helbing, D., & Molnár, P. (1995). "Social force model for pedestrian
31//!   dynamics". *Physical Review E*, 51(5), 4282–4286.
32//! - Helbing, D., Farkas, I., & Vicsek, T. (2000). "Simulating dynamical
33//!   features of escape panic". *Nature*, 407(6803), 487–490.
34
35use crate::broadphase::{NeighborGrid, Scratch};
36use crate::common::{add, clamp_speed, closest_point_on_segment, norm, scale, sub};
37use crate::common::{Pedestrian, PedestrianModel, Vec2, WallSegment};
38
39/// Parameters for the Social Force model.
40///
41/// Defaults are taken from Helbing, Farkas & Vicsek (2000) Table 1.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct Params {
44    /// Relaxation time toward the desired velocity (s).
45    pub tau: f64,
46    /// Interaction strength between pedestrians (N).
47    pub a_ped: f64,
48    /// Interaction range between pedestrians (m).
49    pub b_ped: f64,
50    /// Interaction strength with walls (N).
51    pub a_wall: f64,
52    /// Interaction range with walls (m).
53    pub b_wall: f64,
54    /// Pedestrian mass (kg).
55    pub mass: f64,
56    /// Hard upper bound on speed after integration (m/s).
57    pub max_speed: f64,
58    /// Arrival radius (m). Inside this distance the desired speed
59    /// tapers linearly to zero so the agent decelerates into its
60    /// destination instead of overshooting and oscillating. Set to
61    /// `0.0` to disable the taper. Default: 0.3 m.
62    pub arrival_radius: f64,
63    /// Hard upper bound on acceleration magnitude (m/s²).
64    ///
65    /// The Helbing exponential repulsion is numerically stiff: at a
66    /// light body overlap of 0.1 m the pair force with default `a_ped
67    /// = 2000 N` and `b_ped = 0.08 m` is already ~5.4 kN, i.e.
68    /// ~68 m/s² for an 80 kg pedestrian. Explicit integration at
69    /// `dt = 0.05 s` would push the velocity by 3.4 m/s in a single
70    /// tick, relying entirely on [`max_speed`](Self::max_speed) to
71    /// keep the trajectory physical. Clamping `|a| ≤ max_accel`
72    /// before integration gives a well-behaved CFL-like bound: the
73    /// default of 20 m/s² (≈ 2 g) is enough headroom for crowded
74    /// panic flows but low enough that `dt * max_accel ≤ max_speed`
75    /// at typical simulation rates. JuPedSim and PedSim apply the
76    /// same cap; this is a standard production hardening for SFM.
77    pub max_accel: f64,
78}
79
80impl Default for Params {
81    fn default() -> Self {
82        Self {
83            tau: 0.5,
84            a_ped: 2000.0,
85            b_ped: 0.08,
86            a_wall: 2000.0,
87            b_wall: 0.08,
88            mass: 80.0,
89            max_speed: 2.5,
90            arrival_radius: 0.3,
91            max_accel: 20.0,
92        }
93    }
94}
95
96impl Params {
97    /// Validate this parameter set against `dt`.
98    ///
99    /// Returns `Ok(())` if every invariant holds, otherwise the first
100    /// offending constraint as a [`CrowdError`]. Guards the same
101    /// failure modes as the other force-based models:
102    /// non-positive physical parameters, non-negative arrival radius,
103    /// finite positive `dt`, and the explicit-Euler CFL-like condition
104    /// `dt * max_accel <= max_speed`.
105    ///
106    /// Cheap: a handful of comparisons, no allocations.
107    pub fn validate(&self, dt: f64) -> Result<(), crate::error::CrowdError> {
108        use crate::error::{require_dt, require_nonneg, require_positive, CrowdError};
109        const M: &str = "SocialForce";
110        require_dt(M, dt)?;
111        require_positive(M, "tau", self.tau)?;
112        require_positive(M, "b_ped", self.b_ped)?;
113        require_positive(M, "b_wall", self.b_wall)?;
114        require_positive(M, "mass", self.mass)?;
115        require_positive(M, "max_speed", self.max_speed)?;
116        require_positive(M, "max_accel", self.max_accel)?;
117        require_nonneg(M, "a_ped", self.a_ped)?;
118        require_nonneg(M, "a_wall", self.a_wall)?;
119        require_nonneg(M, "arrival_radius", self.arrival_radius)?;
120        let product = dt * self.max_accel;
121        if product > self.max_speed {
122            return Err(CrowdError::CflViolation {
123                model: M,
124                product,
125                max_speed: self.max_speed,
126                max_dt: self.max_speed / self.max_accel,
127            });
128        }
129        Ok(())
130    }
131}
132
133/// Unit marker type implementing [`PedestrianModel`] for Social Force.
134#[derive(Debug, Clone, Copy, Default)]
135pub struct SocialForce;
136
137impl PedestrianModel for SocialForce {
138    type Params = Params;
139
140    fn name(&self) -> &'static str {
141        "Social Force"
142    }
143
144    fn step(&self, peds: &mut [Pedestrian], walls: &[WallSegment], params: &Params, dt: f64) {
145        #[allow(deprecated)]
146        step(peds, walls, params, dt);
147    }
148}
149
150/// Free-function step for callers that do not need trait dispatch.
151///
152/// **Deprecated.** This is the O(n²) reference path retained for
153/// numerical comparisons and CPU ↔ CUDA equivalence tests. Production
154/// callers should use [`step_scratch`] (zero-allocation broadphase
155/// hot path) or [`step_with_grid`] (broadphase, allocates per call).
156/// Keeping `step` for parity is fine; routing fresh code through it
157/// is not, because at N ≥ ~1 000 it scales as O(n²) with
158/// per-tick heap allocation. See `docs/rustsim-crowd.md` P1-7.
159#[deprecated(
160    since = "0.0.3",
161    note = "O(n²) reference path with per-tick heap allocation; use \
162            `step_scratch` (zero-alloc) or `step_with_grid` (broadphase) \
163            instead. See docs/rustsim-crowd.md P1-7."
164)]
165#[allow(clippy::needless_range_loop)]
166pub fn step(peds: &mut [Pedestrian], walls: &[WallSegment], params: &Params, dt: f64) {
167    let n = peds.len();
168    // Compute all accelerations first so pairwise forces see the old state.
169    let mut accels = vec![[0.0f64; 2]; n];
170
171    for i in 0..n {
172        let p = &peds[i];
173        let mut f = driving_force(p, params);
174
175        for j in 0..n {
176            if i == j {
177                continue;
178            }
179            let q = &peds[j];
180            let f_ij = ped_repulsion(p, q, params);
181            f = add(f, f_ij);
182        }
183
184        for w in walls {
185            let f_iw = wall_repulsion(p, w, params);
186            f = add(f, f_iw);
187        }
188
189        // a = F / m, clamped to `max_accel` for Euler stability.
190        accels[i] = cap_accel(scale(f, 1.0 / params.mass), params.max_accel);
191    }
192
193    // Integrate.
194    for (p, a) in peds.iter_mut().zip(accels.iter()) {
195        p.vel = add(p.vel, scale(*a, dt));
196        p.vel = clamp_speed(p.vel, params.max_speed);
197        p.pos = add(p.pos, scale(p.vel, dt));
198    }
199}
200
201/// Recommended neighbour cutoff radius for grid queries (metres).
202///
203/// At distance `r_sum + 8 * b_ped` the pairwise repulsion has decayed
204/// to `a_ped * e^-8 ≈ 3.4e-4 * a_ped`, which contributes well under
205/// 1 mN at default parameters. The returned cutoff adds a fixed 1 m
206/// buffer for generous safety regardless of `b_ped`.
207#[inline]
208pub fn neighbor_cutoff(params: &Params) -> f64 {
209    8.0 * params.b_ped + 1.0
210}
211
212/// Grid-accelerated step variant. Semantically equivalent to [`step`]
213/// up to numerical floating-point noise for interaction pairs inside
214/// `neighbor_cutoff(params)`; pairs outside that radius are pruned
215/// because their contribution is below 1 mN at default parameters.
216///
217/// The caller owns `grid`; rebuild it once per tick with
218/// [`NeighborGrid::rebuild`] using the *current* pedestrian positions.
219///
220/// Use this variant for populations above ~64 agents. Below that
221/// threshold the grid's setup cost exceeds the O(n²) savings and
222/// [`step`] is faster.
223#[allow(clippy::needless_range_loop)]
224pub fn step_with_grid(
225    peds: &mut [Pedestrian],
226    walls: &[WallSegment],
227    params: &Params,
228    dt: f64,
229    grid: &NeighborGrid,
230) {
231    let n = peds.len();
232    let cutoff = neighbor_cutoff(params);
233    let mut accels = vec![[0.0f64; 2]; n];
234
235    for i in 0..n {
236        let p = &peds[i];
237        let mut f = driving_force(p, params);
238
239        grid.for_each_neighbor(i, cutoff, peds, |_j, q| {
240            f = add(f, ped_repulsion(p, q, params));
241        });
242
243        for w in walls {
244            f = add(f, wall_repulsion(p, w, params));
245        }
246
247        accels[i] = cap_accel(scale(f, 1.0 / params.mass), params.max_accel);
248    }
249
250    for (p, a) in peds.iter_mut().zip(accels.iter()) {
251        p.vel = add(p.vel, scale(*a, dt));
252        p.vel = clamp_speed(p.vel, params.max_speed);
253        p.pos = add(p.pos, scale(p.vel, dt));
254    }
255}
256
257/// Zero-allocation step variant. Reuses `scratch.buf` and rebuilds
258/// `scratch.grid` against `peds`, then runs the same math as
259/// [`step_with_grid`] without any per-tick allocation.
260///
261/// This is the **hot-path variant**: allocate one [`Scratch`] per
262/// simulation and call `step_scratch` every tick. Allocation-sensitive
263/// callers (ECS integration, real-time 30–60 Hz loops) should prefer
264/// this over [`step`] / [`step_with_grid`].
265#[allow(clippy::needless_range_loop)]
266pub fn step_scratch(
267    peds: &mut [Pedestrian],
268    walls: &[WallSegment],
269    params: &Params,
270    dt: f64,
271    scratch: &mut Scratch,
272) {
273    let n = peds.len();
274    let cutoff = neighbor_cutoff(params);
275    scratch.prepare(peds);
276    let (accels, grid) = scratch.split();
277
278    for i in 0..n {
279        let p = &peds[i];
280        let mut f = driving_force(p, params);
281        grid.for_each_neighbor(i, cutoff, peds, |_j, q| {
282            f = add(f, ped_repulsion(p, q, params));
283        });
284        for w in walls {
285            f = add(f, wall_repulsion(p, w, params));
286        }
287        accels[i] = cap_accel(scale(f, 1.0 / params.mass), params.max_accel);
288    }
289
290    for (p, a) in peds.iter_mut().zip(accels.iter()) {
291        p.vel = add(p.vel, scale(*a, dt));
292        p.vel = clamp_speed(p.vel, params.max_speed);
293        p.pos = add(p.pos, scale(p.vel, dt));
294    }
295}
296
297/// Rayon-parallel drop-in replacement for [`step_scratch`].
298///
299/// Semantically **bit-exact** with [`step_scratch`] on the same
300/// inputs: each rayon worker writes only to its own `accels[i]` slot
301/// from an immutable view of the pedestrian slice, the per-agent
302/// force composition is evaluated in the same order as the serial
303/// loop (`driving → grid neighbours in grid order → walls in wall
304/// order`), and the position/velocity writeback is still a single
305/// serial pass (cheap, O(n), and order-dependent only if a future
306/// integrator introduces cross-agent coupling — which symplectic
307/// Euler does not). The parallel speedup kicks in above ~5 000
308/// agents on typical many-core x86; below that the rayon dispatch
309/// cost dominates and [`step_scratch`] wins.
310///
311/// Enable the `rayon` feature of `rustsim-crowd` to use this entry
312/// point. For CPU deployments lacking a CUDA GPU this closes the
313/// multi-core gap left by the serial hot path and removes the
314/// remaining "CPU = single core" bottleneck called out in the
315/// production-readiness review.
316#[cfg(feature = "rayon")]
317#[allow(clippy::needless_range_loop)]
318pub fn step_scratch_par(
319    peds: &mut [Pedestrian],
320    walls: &[WallSegment],
321    params: &Params,
322    dt: f64,
323    scratch: &mut Scratch,
324) {
325    use rayon::prelude::*;
326
327    let cutoff = neighbor_cutoff(params);
328    scratch.prepare(peds);
329    let (accels, grid) = scratch.split();
330    // Borrow immutably for the parallel pass. Each worker reads
331    // `peds` (shared) and writes a distinct `accels[i]` slot.
332    let peds_ro: &[Pedestrian] = peds;
333
334    accels.par_iter_mut().enumerate().for_each(|(i, a_slot)| {
335        let p = &peds_ro[i];
336        let mut f = driving_force(p, params);
337        grid.for_each_neighbor(i, cutoff, peds_ro, |_j, q| {
338            f = add(f, ped_repulsion(p, q, params));
339        });
340        for w in walls {
341            f = add(f, wall_repulsion(p, w, params));
342        }
343        *a_slot = cap_accel(scale(f, 1.0 / params.mass), params.max_accel);
344    });
345
346    for (p, a) in peds.iter_mut().zip(accels.iter()) {
347        p.vel = add(p.vel, scale(*a, dt));
348        p.vel = clamp_speed(p.vel, params.max_speed);
349        p.pos = add(p.pos, scale(p.vel, dt));
350    }
351}
352
353/// SIMD-vectorised drop-in replacement for [`step_scratch`].
354///
355/// Lifts [`crate::simd::pair_force_x4`] into the per-agent inner loop:
356/// neighbours returned by the broadphase grid are buffered in 4-wide
357/// chunks and the per-chunk pair-repulsion sum is computed across
358/// four `f64x4` lanes at once. Driving force, wall repulsion,
359/// `cap_accel`, and the integrator stay scalar — only the inner
360/// pair-force sum changes.
361///
362/// # Numerical contract
363///
364/// Lane summation re-orders the per-pair contributions, so the SIMD
365/// path is **not** bit-exact with [`step_scratch`] — only
366/// tolerance-equivalent. `tests/simd_tolerance.rs` pins the bound at
367/// `1e-9` per agent over a single tick of a representative
368/// counter-flow fixture, matching the unit-test envelope on
369/// [`crate::simd::pair_force_x4`] itself. This mirrors the same
370/// associativity caveat that [`step_scratch_par`] documents for the
371/// rayon path (which sums neighbours in grid order but accumulates
372/// across threads).
373///
374/// Enable the `simd` feature of `rustsim-crowd` to use this entry
375/// point. The SIMD speedup is consistently visible on x86_64 SSE/AVX
376/// and aarch64 NEON above ~2 000 agents; below that the lane-buffer
377/// flush overhead dominates and [`step_scratch`] wins.
378#[cfg(feature = "simd")]
379#[allow(clippy::needless_range_loop)]
380pub fn step_scratch_simd(
381    peds: &mut [Pedestrian],
382    walls: &[WallSegment],
383    params: &Params,
384    dt: f64,
385    scratch: &mut Scratch,
386) {
387    let n = peds.len();
388    let cutoff = neighbor_cutoff(params);
389    scratch.prepare(peds);
390    let (accels, grid) = scratch.split();
391
392    for i in 0..n {
393        let p = &peds[i];
394        let mut f = driving_force(p, params);
395
396        // 4-wide neighbour-index buffer; flushed whenever full.
397        // Indices are used (rather than `&Pedestrian` refs) so the
398        // borrow into `peds` does not have to escape the
399        // `for_each_neighbor` closure.
400        let mut idxs: [Option<usize>; 4] = [None, None, None, None];
401        let mut filled: usize = 0;
402        grid.for_each_neighbor(i, cutoff, peds, |j, _q| {
403            idxs[filled] = Some(j);
404            filled += 1;
405            if filled == 4 {
406                let buf: [Option<&Pedestrian>; 4] = [
407                    Some(&peds[idxs[0].unwrap()]),
408                    Some(&peds[idxs[1].unwrap()]),
409                    Some(&peds[idxs[2].unwrap()]),
410                    Some(&peds[idxs[3].unwrap()]),
411                ];
412                let pf = crate::simd::pair_force_x4(p, buf, params);
413                f = add(f, pf);
414                idxs = [None, None, None, None];
415                filled = 0;
416            }
417        });
418        if filled > 0 {
419            // Flush the trailing partial chunk; `None` lanes contribute zero.
420            let buf: [Option<&Pedestrian>; 4] = [
421                idxs[0].map(|k| &peds[k]),
422                idxs[1].map(|k| &peds[k]),
423                idxs[2].map(|k| &peds[k]),
424                idxs[3].map(|k| &peds[k]),
425            ];
426            let pf = crate::simd::pair_force_x4(p, buf, params);
427            f = add(f, pf);
428        }
429
430        for w in walls {
431            f = add(f, wall_repulsion(p, w, params));
432        }
433        accels[i] = cap_accel(scale(f, 1.0 / params.mass), params.max_accel);
434    }
435
436    for (p, a) in peds.iter_mut().zip(accels.iter()) {
437        p.vel = add(p.vel, scale(*a, dt));
438        p.vel = clamp_speed(p.vel, params.max_speed);
439        p.pos = add(p.pos, scale(p.vel, dt));
440    }
441}
442
443/// Clamp the magnitude of an acceleration vector to `cap`.
444///
445/// Applied to every agent's net acceleration before Euler integration
446/// to keep the stiff Helbing repulsion numerically stable. See
447/// [`Params::max_accel`] for the full rationale.
448#[inline]
449pub fn cap_accel(a: Vec2, cap: f64) -> Vec2 {
450    let m = (a[0] * a[0] + a[1] * a[1]).sqrt();
451    if m > cap && m > 0.0 {
452        scale(a, cap / m)
453    } else {
454        a
455    }
456}
457
458/// `f_drive = m * (v0 * e_dest - v) / tau`
459#[inline]
460pub fn driving_force(p: &Pedestrian, params: &Params) -> Vec2 {
461    let e = p.desired_direction();
462    let target = scale(e, p.effective_desired_speed(params.arrival_radius));
463    let delta = sub(target, p.vel);
464    scale(delta, params.mass / params.tau)
465}
466
467/// Pairwise repulsion `A * exp((r_ij - d_ij) / B) * e_ij` along the line
468/// connecting `q` to `p`, where `r_ij = p.radius + q.radius`.
469#[inline]
470pub fn ped_repulsion(p: &Pedestrian, q: &Pedestrian, params: &Params) -> Vec2 {
471    let diff = sub(p.pos, q.pos);
472    let d = norm(diff);
473    if d < 1e-9 {
474        return [0.0, 0.0];
475    }
476    let r_sum = p.radius + q.radius;
477    let e = scale(diff, 1.0 / d);
478    let magnitude = params.a_ped * ((r_sum - d) / params.b_ped).exp();
479    scale(e, magnitude)
480}
481
482/// Repulsion from the closest point on a wall segment.
483#[inline]
484pub fn wall_repulsion(p: &Pedestrian, wall: &WallSegment, params: &Params) -> Vec2 {
485    let closest = closest_point_on_segment(p.pos, wall.a, wall.b);
486    let diff = sub(p.pos, closest);
487    let d = norm(diff);
488    if d < 1e-9 {
489        return [0.0, 0.0];
490    }
491    let e = scale(diff, 1.0 / d);
492    let magnitude = params.a_wall * ((p.radius - d) / params.b_wall).exp();
493    scale(e, magnitude)
494}
495
496#[cfg(test)]
497#[allow(deprecated)] // intentional: pins grid/scratch equivalence vs the deprecated O(n²) `step`.
498mod tests {
499    use super::*;
500
501    fn single_agent_toward(dest: Vec2) -> Pedestrian {
502        Pedestrian {
503            pos: [0.0, 0.0],
504            vel: [0.0, 0.0],
505            radius: 0.25,
506            desired_speed: 1.34,
507            destination: dest,
508        }
509    }
510
511    #[test]
512    fn integrator_is_semi_implicit_euler() {
513        // Pin the integrator contract: the position advance uses the
514        // POST-update velocity, not the pre-update one. Setup:
515        // * Agent starts at rest at the origin, desired destination
516        //   at [+x, 0], no neighbours, no walls.
517        // * Single tick of length `dt`.
518        //
519        // With `tau`, `desired_speed`, `dt` chosen so no clamp fires:
520        //   a_x     = desired_speed / tau
521        //   v_new_x = 0 + a_x * dt          = desired_speed * dt / tau
522        //   p_new_x (symplectic) = 0 + v_new_x * dt = a_x * dt²
523        //   p_new_x (explicit)   = 0 + 0     * dt   = 0
524        //
525        // The symplectic prediction differs from the explicit one by
526        // exactly a_x * dt²; a drift-prone explicit integrator would
527        // leave the agent at the origin after one tick.
528        let mut peds = vec![Pedestrian {
529            pos: [0.0, 0.0],
530            vel: [0.0, 0.0],
531            radius: 0.25,
532            desired_speed: 1.34,
533            destination: [100.0, 0.0],
534        }];
535        let params = Params::default();
536        let dt = 0.05;
537        step(&mut peds, &[], &params, dt);
538
539        // Destination is far (100 m) so the arrival taper is inactive;
540        // driving force reduces to `(desired_speed * e_hat - v) / tau`.
541        let expected_a_x = peds[0].desired_speed / params.tau;
542        let expected_v_x = expected_a_x * dt;
543        let expected_p_x = expected_v_x * dt; // symplectic
544        let explicit_p_x = 0.0; // pre-update velocity was zero
545
546        // Symplectic move is a_x * dt² above the explicit result.
547        assert!(
548            peds[0].pos[0] > explicit_p_x + 0.5 * expected_a_x * dt * dt,
549            "position advance must use post-update velocity (symplectic Euler), got p_x = {}",
550            peds[0].pos[0]
551        );
552        assert!(
553            (peds[0].pos[0] - expected_p_x).abs() < 1e-12,
554            "symplectic Euler position should equal a_x * dt² = {}, got {}",
555            expected_p_x,
556            peds[0].pos[0]
557        );
558        assert!(
559            (peds[0].vel[0] - expected_v_x).abs() < 1e-12,
560            "velocity should equal a_x * dt = {}, got {}",
561            expected_v_x,
562            peds[0].vel[0]
563        );
564    }
565
566    #[test]
567    fn single_agent_moves_toward_destination() {
568        let mut peds = vec![single_agent_toward([10.0, 0.0])];
569        for _ in 0..100 {
570            step(&mut peds, &[], &Params::default(), 0.05);
571        }
572        assert!(peds[0].pos[0] > 1.0, "agent should have advanced in +x");
573        assert!(peds[0].pos[1].abs() < 0.05, "no lateral drift");
574    }
575
576    #[test]
577    fn two_agents_head_on_do_not_overlap() {
578        let mut peds = vec![
579            Pedestrian {
580                pos: [-5.0, 0.05],
581                vel: [0.0, 0.0],
582                radius: 0.25,
583                desired_speed: 1.34,
584                destination: [5.0, 0.05],
585            },
586            Pedestrian {
587                pos: [5.0, -0.05],
588                vel: [0.0, 0.0],
589                radius: 0.25,
590                desired_speed: 1.34,
591                destination: [-5.0, -0.05],
592            },
593        ];
594        for _ in 0..400 {
595            step(&mut peds, &[], &Params::default(), 0.02);
596        }
597        let dx = peds[0].pos[0] - peds[1].pos[0];
598        let dy = peds[0].pos[1] - peds[1].pos[1];
599        let d = (dx * dx + dy * dy).sqrt();
600        assert!(
601            d >= peds[0].radius + peds[1].radius,
602            "agents overlapped: d={d}"
603        );
604    }
605
606    #[test]
607    fn trait_impl_reports_name() {
608        let m = SocialForce;
609        assert_eq!(m.name(), "Social Force");
610    }
611
612    #[test]
613    fn cap_accel_clamps_magnitude() {
614        let a = cap_accel([30.0, 40.0], 10.0); // |a|=50
615        let m = (a[0] * a[0] + a[1] * a[1]).sqrt();
616        assert!((m - 10.0).abs() < 1e-12);
617        // Direction preserved.
618        assert!((a[0] / a[1] - 30.0 / 40.0).abs() < 1e-12);
619        // Below cap is a no-op.
620        let b = cap_accel([3.0, 4.0], 10.0);
621        assert_eq!(b, [3.0, 4.0]);
622    }
623
624    #[test]
625    fn agent_settles_inside_arrival_radius() {
626        // Walk straight at a destination; after enough ticks the agent
627        // must sit inside the arrival radius without overshooting past
628        // the goal. Without the arrival taper the agent would overshoot
629        // and keep turning around.
630        let mut peds = vec![Pedestrian {
631            pos: [0.0, 0.0],
632            vel: [0.0, 0.0],
633            radius: 0.25,
634            desired_speed: 1.34,
635            destination: [5.0, 0.0],
636        }];
637        let params = Params::default();
638        let mut max_overshoot: f64 = 0.0;
639        for _ in 0..1000 {
640            step(&mut peds, &[], &params, 0.05);
641            let overshoot = peds[0].pos[0] - peds[0].destination[0];
642            if overshoot > max_overshoot {
643                max_overshoot = overshoot;
644            }
645        }
646        assert!(peds[0].has_arrived(params.arrival_radius + 1e-6));
647        assert!(
648            max_overshoot <= params.arrival_radius + 1e-3,
649            "agent overshot destination by {max_overshoot:.3} m (arrival_radius={})",
650            params.arrival_radius
651        );
652    }
653
654    #[test]
655    fn stiff_pair_does_not_blow_up_with_accel_cap() {
656        // Place two agents with heavy overlap so the raw Helbing
657        // repulsion is >> max_accel * m. Without the cap, explicit
658        // Euler would push velocity to double-digit m/s in one tick.
659        let mut peds = vec![
660            Pedestrian {
661                pos: [0.0, 0.0],
662                vel: [0.0, 0.0],
663                radius: 0.25,
664                desired_speed: 0.0,
665                destination: [0.0, 0.0],
666            },
667            Pedestrian {
668                pos: [0.1, 0.0], // 0.4 m overlap
669                vel: [0.0, 0.0],
670                radius: 0.25,
671                desired_speed: 0.0,
672                destination: [0.0, 0.0],
673            },
674        ];
675        let params = Params::default();
676        step(&mut peds, &[], &params, 0.05);
677        // After one tick: |v| must be bounded by max_accel * dt = 1.0 m/s,
678        // then further by max_speed = 2.5 m/s. Without the cap the raw
679        // force (~2 kN * e^(0.4/0.08)) / 80 kg * dt ≈ hundreds of m/s.
680        for p in &peds {
681            let v = (p.vel[0] * p.vel[0] + p.vel[1] * p.vel[1]).sqrt();
682            assert!(
683                v <= params.max_accel * 0.05 + 1e-9,
684                "velocity {v} exceeded max_accel*dt={} — accel cap not applied",
685                params.max_accel * 0.05
686            );
687        }
688    }
689
690    #[test]
691    fn step_with_grid_matches_step_within_tolerance() {
692        // Seedable deterministic scatter of 32 agents in a 6 x 6 box.
693        let mut a: Vec<Pedestrian> = Vec::new();
694        for k in 0..32 {
695            let x = ((k * 2654435761u64) % 6_000_000) as f64 / 1_000_000.0;
696            let y = ((k * 40503u64) % 6_000_000) as f64 / 1_000_000.0;
697            a.push(Pedestrian {
698                pos: [x, y],
699                vel: [0.0, 0.0],
700                radius: 0.25,
701                desired_speed: 1.2,
702                destination: [x + 5.0, y],
703            });
704        }
705        let mut b = a.clone();
706        let params = Params::default();
707        let mut grid = crate::broadphase::NeighborGrid::new(neighbor_cutoff(&params));
708
709        for _ in 0..50 {
710            step(&mut a, &[], &params, 0.05);
711            grid.rebuild(&b);
712            step_with_grid(&mut b, &[], &params, 0.05, &grid);
713        }
714
715        // After 50 steps the two paths must agree to within a tight
716        // tolerance: only pairs outside the cutoff differ, and at
717        // defaults their force is below 1 mN.
718        for i in 0..a.len() {
719            let dx = a[i].pos[0] - b[i].pos[0];
720            let dy = a[i].pos[1] - b[i].pos[1];
721            let d = (dx * dx + dy * dy).sqrt();
722            assert!(
723                d < 1e-3,
724                "agent {i}: grid path diverged from O(n^2) by {d:.3e} m"
725            );
726        }
727    }
728    #[test]
729    fn step_scratch_matches_step_with_grid_bit_exact() {
730        // `step_scratch` is algebraically identical to `step_with_grid`
731        // — it only eliminates per-tick allocations. The two trajectories
732        // must therefore agree to machine precision on the same fixture.
733        let mut a: Vec<Pedestrian> = Vec::new();
734        for k in 0..24 {
735            let x = ((k * 2654435761u64) % 6_000_000) as f64 / 1_000_000.0;
736            let y = ((k * 40503u64) % 6_000_000) as f64 / 1_000_000.0;
737            a.push(Pedestrian {
738                pos: [x, y],
739                vel: [0.0, 0.0],
740                radius: 0.25,
741                desired_speed: 1.2,
742                destination: [x + 5.0, y],
743            });
744        }
745        let mut b = a.clone();
746        let params = Params::default();
747        let cutoff = neighbor_cutoff(&params);
748        let mut grid = crate::broadphase::NeighborGrid::new(cutoff);
749        let mut scratch = crate::broadphase::Scratch::with_capacity(a.len(), cutoff);
750
751        for _ in 0..40 {
752            grid.rebuild(&a);
753            step_with_grid(&mut a, &[], &params, 0.05, &grid);
754            step_scratch(&mut b, &[], &params, 0.05, &mut scratch);
755        }
756
757        for i in 0..a.len() {
758            assert_eq!(a[i].pos, b[i].pos, "scratch path diverged at {i}");
759            assert_eq!(a[i].vel, b[i].vel);
760        }
761    }
762
763    #[cfg(feature = "rayon")]
764    #[test]
765    fn step_scratch_par_matches_step_scratch_bit_exact() {
766        // Pin the parallel contract: `step_scratch_par` must produce
767        // bit-exact (Rust `==` on `[f64; 2]`) trajectories vs the
768        // serial `step_scratch`. Each rayon worker writes its own
769        // `accels[i]` slot from an immutable view of `peds`, so no
770        // non-associative float reduction can sneak in; the only
771        // ordering choice is deterministic (grid-major).
772        let mut a: Vec<Pedestrian> = Vec::new();
773        for k in 0..64 {
774            let x = ((k * 2654435761u64) % 6_000_000) as f64 / 1_000_000.0;
775            let y = ((k * 40503u64) % 6_000_000) as f64 / 1_000_000.0;
776            a.push(Pedestrian {
777                pos: [x, y],
778                vel: [0.0, 0.0],
779                radius: 0.25,
780                desired_speed: 1.2,
781                destination: [x + 5.0, y],
782            });
783        }
784        let mut b = a.clone();
785        let walls = vec![WallSegment {
786            a: [-1.0, -1.0],
787            b: [20.0, -1.0],
788        }];
789        let params = Params::default();
790        let cutoff = neighbor_cutoff(&params);
791        let mut scratch_a = crate::broadphase::Scratch::with_capacity(a.len(), cutoff);
792        let mut scratch_b = crate::broadphase::Scratch::with_capacity(b.len(), cutoff);
793
794        for _ in 0..40 {
795            step_scratch(&mut a, &walls, &params, 0.05, &mut scratch_a);
796            step_scratch_par(&mut b, &walls, &params, 0.05, &mut scratch_b);
797        }
798
799        for i in 0..a.len() {
800            assert_eq!(
801                a[i].pos, b[i].pos,
802                "parallel path diverged in position at {i}"
803            );
804            assert_eq!(
805                a[i].vel, b[i].vel,
806                "parallel path diverged in velocity at {i}"
807            );
808        }
809    }
810}