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, &[], ¶ms, 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, &[], ¶ms, 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, &[], ¶ms, 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(¶ms));
708
709 for _ in 0..50 {
710 step(&mut a, &[], ¶ms, 0.05);
711 grid.rebuild(&b);
712 step_with_grid(&mut b, &[], ¶ms, 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(¶ms);
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, &[], ¶ms, 0.05, &grid);
754 step_scratch(&mut b, &[], ¶ms, 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(¶ms);
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, ¶ms, 0.05, &mut scratch_a);
796 step_scratch_par(&mut b, &walls, ¶ms, 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}