Skip to main content

rustsim_crowd/
collision_free_speed.rs

1//! Collision-Free Speed Model (Tordeux, Chraibi & Seyfried 2016).
2//!
3//! A first-order velocity-based model. Each pedestrian *i* chooses:
4//!
5//! 1. A direction `e_i` that blends the desired direction toward its goal
6//!    with a weighted sum of repulsive directions from every neighbour.
7//! 2. A scalar speed `v_i = min(v0_i, max(0, (s_i - l_i) / T))`, where
8//!    `s_i` is the free headroom along `e_i` toward the nearest neighbour
9//!    and `T` is the safety time gap.
10//!
11//! The integration is explicit Euler on position only (the model is
12//! kinematic, not dynamic).
13//!
14//! # References
15//!
16//! - Tordeux, A., Chraibi, M., & Seyfried, A. (2016). "Collision-free speed
17//!   model for pedestrian dynamics". *Traffic and Granular Flow '15*,
18//!   225–232.
19
20use crate::broadphase::{NeighborGrid, Scratch};
21use crate::common::{
22    add, closest_point_on_segment, dot, norm, normalize, scale, sub, Pedestrian, PedestrianModel,
23    Vec2, WallSegment,
24};
25
26/// Parameters for the Collision-Free Speed model.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct Params {
29    /// Safety time gap (s). Larger values produce more conservative walkers.
30    pub time_gap: f64,
31    /// Direction blending weight on the desired (goal) direction.
32    pub alpha: f64,
33    /// Interaction range for neighbour repulsion (m).
34    pub interaction_range: f64,
35    /// Interaction strength for neighbour repulsion (dimensionless).
36    pub interaction_strength: f64,
37    /// Wall interaction range (m).
38    pub wall_range: f64,
39    /// Wall interaction strength (dimensionless).
40    pub wall_strength: f64,
41    /// Arrival radius (m). See
42    /// [`social_force::Params::arrival_radius`](crate::social_force::Params::arrival_radius).
43    /// Default: 0.3 m.
44    pub arrival_radius: f64,
45}
46
47impl Default for Params {
48    fn default() -> Self {
49        Self {
50            time_gap: 1.06,
51            alpha: 3.0,
52            interaction_range: 0.1,
53            interaction_strength: 5.0,
54            wall_range: 0.08,
55            wall_strength: 5.0,
56            arrival_radius: 0.3,
57        }
58    }
59}
60
61impl Params {
62    /// Validate this parameter set against `dt`.
63    ///
64    /// CFS is a first-order kinematic model: speeds are directly
65    /// clamped from the collision-free headroom, so there is no
66    /// explicit-Euler CFL condition to check. Validation therefore
67    /// focuses on strictly-positive ranges and a finite positive `dt`.
68    pub fn validate(&self, dt: f64) -> Result<(), crate::error::CrowdError> {
69        use crate::error::{require_dt, require_nonneg, require_positive};
70        const M: &str = "CollisionFreeSpeed";
71        require_dt(M, dt)?;
72        require_positive(M, "time_gap", self.time_gap)?;
73        require_positive(M, "alpha", self.alpha)?;
74        require_positive(M, "interaction_range", self.interaction_range)?;
75        require_nonneg(M, "interaction_strength", self.interaction_strength)?;
76        require_positive(M, "wall_range", self.wall_range)?;
77        require_nonneg(M, "wall_strength", self.wall_strength)?;
78        require_nonneg(M, "arrival_radius", self.arrival_radius)?;
79        Ok(())
80    }
81}
82
83/// Unit marker type implementing [`PedestrianModel`] for the Collision-Free
84/// Speed model.
85#[derive(Debug, Clone, Copy, Default)]
86pub struct CollisionFreeSpeed;
87
88impl PedestrianModel for CollisionFreeSpeed {
89    type Params = Params;
90
91    fn name(&self) -> &'static str {
92        "Collision-Free Speed"
93    }
94
95    fn step(&self, peds: &mut [Pedestrian], walls: &[WallSegment], params: &Params, dt: f64) {
96        #[allow(deprecated)]
97        step(peds, walls, params, dt);
98    }
99}
100
101/// Free-function step for callers that do not need trait dispatch.
102///
103/// **Deprecated.** O(n²) reference path with per-tick heap allocation.
104/// Use [`step_scratch`] (zero-alloc) or [`step_with_grid`] (broadphase)
105/// in production. Retained for parity tests and back-compat.
106#[deprecated(
107    since = "0.0.3",
108    note = "O(n²) reference path with per-tick heap allocation; use \
109            `step_scratch` (zero-alloc) or `step_with_grid` (broadphase) \
110            instead. See docs/rustsim-crowd.md P1-7."
111)]
112#[allow(clippy::needless_range_loop)]
113pub fn step(peds: &mut [Pedestrian], walls: &[WallSegment], params: &Params, dt: f64) {
114    let n = peds.len();
115    let mut new_vel = vec![[0.0f64; 2]; n];
116
117    for i in 0..n {
118        let p = &peds[i];
119        let e = chosen_direction(i, peds, walls, params);
120        let s = free_headroom(i, peds, &e);
121        let effective_radius = p.radius;
122        let speed_cap = ((s - effective_radius) / params.time_gap).max(0.0);
123        let v = p
124            .effective_desired_speed(params.arrival_radius)
125            .min(speed_cap);
126        new_vel[i] = scale(e, v);
127    }
128
129    for (p, v) in peds.iter_mut().zip(new_vel.iter()) {
130        p.vel = *v;
131        p.pos = add(p.pos, scale(p.vel, dt));
132    }
133}
134
135/// Recommended neighbour cutoff radius for grid queries (metres).
136///
137/// Interaction weights decay as `exp(-clearance / interaction_range)`,
138/// so at a clearance of `8 * interaction_range` the contribution is
139/// below `5e-4 * interaction_strength`. The returned cutoff adds a
140/// 2 m buffer to also cover the forward headroom scan distance
141/// (`desired_speed * time_gap ≈ 1.4 m` at free-flow defaults).
142#[inline]
143pub fn neighbor_cutoff(params: &Params) -> f64 {
144    8.0 * params.interaction_range + 2.0
145}
146
147/// Grid-accelerated step variant. Semantically equivalent to [`step`]
148/// up to numerical noise for pairs inside `neighbor_cutoff(params)`.
149///
150/// The caller owns `grid`; rebuild it once per tick.
151#[allow(clippy::needless_range_loop)]
152pub fn step_with_grid(
153    peds: &mut [Pedestrian],
154    walls: &[WallSegment],
155    params: &Params,
156    dt: f64,
157    grid: &NeighborGrid,
158) {
159    let n = peds.len();
160    let cutoff = neighbor_cutoff(params);
161    let mut new_vel = vec![[0.0f64; 2]; n];
162
163    for i in 0..n {
164        let p = &peds[i];
165        let e = chosen_direction_grid(i, peds, walls, params, grid, cutoff);
166        let s = free_headroom_grid(i, peds, &e, grid, cutoff);
167        let effective_radius = p.radius;
168        let speed_cap = ((s - effective_radius) / params.time_gap).max(0.0);
169        let v = p
170            .effective_desired_speed(params.arrival_radius)
171            .min(speed_cap);
172        new_vel[i] = scale(e, v);
173    }
174
175    for (p, v) in peds.iter_mut().zip(new_vel.iter()) {
176        p.vel = *v;
177        p.pos = add(p.pos, scale(p.vel, dt));
178    }
179}
180
181/// Zero-allocation step variant. See
182/// [`social_force::step_scratch`](crate::social_force::step_scratch)
183/// for the motivation.
184#[allow(clippy::needless_range_loop)]
185pub fn step_scratch(
186    peds: &mut [Pedestrian],
187    walls: &[WallSegment],
188    params: &Params,
189    dt: f64,
190    scratch: &mut Scratch,
191) {
192    let n = peds.len();
193    let cutoff = neighbor_cutoff(params);
194    scratch.prepare(peds);
195    let (new_vel, grid) = scratch.split();
196
197    for i in 0..n {
198        let p = &peds[i];
199        let e = chosen_direction_grid(i, peds, walls, params, grid, cutoff);
200        let s = free_headroom_grid(i, peds, &e, grid, cutoff);
201        let speed_cap = ((s - p.radius) / params.time_gap).max(0.0);
202        let v = p
203            .effective_desired_speed(params.arrival_radius)
204            .min(speed_cap);
205        new_vel[i] = scale(e, v);
206    }
207
208    for (p, v) in peds.iter_mut().zip(new_vel.iter()) {
209        p.vel = *v;
210        p.pos = add(p.pos, scale(p.vel, dt));
211    }
212}
213
214/// SIMD-vectorised drop-in replacement for [`step_scratch`].
215///
216/// Lifts the neighbour scans used by CFS direction choice and forward
217/// headroom into 4-wide SIMD chunks via [`crate::simd::cfs_direction_x4`]
218/// and [`crate::simd::cfs_headroom_x4`]. Wall interaction, desired-speed
219/// tapering, and position integration stay scalar, matching the scalar
220/// zero-allocation path.
221///
222/// Numerical contract: lane summation can reorder the direction accumulator,
223/// so this path is tolerance-equivalent to [`step_scratch`], not bit-exact.
224/// `tests/simd_tolerance.rs` pins the current envelope.
225#[cfg(feature = "simd")]
226#[allow(clippy::needless_range_loop)]
227pub fn step_scratch_simd(
228    peds: &mut [Pedestrian],
229    walls: &[WallSegment],
230    params: &Params,
231    dt: f64,
232    scratch: &mut Scratch,
233) {
234    let n = peds.len();
235    let cutoff = neighbor_cutoff(params);
236    scratch.prepare(peds);
237    let (new_vel, grid) = scratch.split();
238
239    for i in 0..n {
240        let p = &peds[i];
241        let e = chosen_direction_grid_simd(i, peds, walls, params, grid, cutoff);
242        let s = free_headroom_grid_simd(i, peds, &e, grid, cutoff);
243        let speed_cap = ((s - p.radius) / params.time_gap).max(0.0);
244        let v = p
245            .effective_desired_speed(params.arrival_radius)
246            .min(speed_cap);
247        new_vel[i] = scale(e, v);
248    }
249
250    for (p, v) in peds.iter_mut().zip(new_vel.iter()) {
251        p.vel = *v;
252        p.pos = add(p.pos, scale(p.vel, dt));
253    }
254}
255
256/// Rayon-parallel drop-in replacement for [`step_scratch`].
257///
258/// See [`social_force::step_scratch_par`](crate::social_force::step_scratch_par)
259/// for the contract. Enable the `rayon` feature to use this entry
260/// point.
261#[cfg(feature = "rayon")]
262#[allow(clippy::needless_range_loop)]
263pub fn step_scratch_par(
264    peds: &mut [Pedestrian],
265    walls: &[WallSegment],
266    params: &Params,
267    dt: f64,
268    scratch: &mut Scratch,
269) {
270    use rayon::prelude::*;
271
272    let cutoff = neighbor_cutoff(params);
273    scratch.prepare(peds);
274    let (new_vel, grid) = scratch.split();
275    let peds_ro: &[Pedestrian] = peds;
276
277    new_vel.par_iter_mut().enumerate().for_each(|(i, v_slot)| {
278        let p = &peds_ro[i];
279        let e = chosen_direction_grid(i, peds_ro, walls, params, grid, cutoff);
280        let s = free_headroom_grid(i, peds_ro, &e, grid, cutoff);
281        let speed_cap = ((s - p.radius) / params.time_gap).max(0.0);
282        let v = p
283            .effective_desired_speed(params.arrival_radius)
284            .min(speed_cap);
285        *v_slot = scale(e, v);
286    });
287
288    for (p, v) in peds.iter_mut().zip(new_vel.iter()) {
289        p.vel = *v;
290        p.pos = add(p.pos, scale(p.vel, dt));
291    }
292}
293
294/// Grid-backed version of [`chosen_direction`].
295fn chosen_direction_grid(
296    i: usize,
297    peds: &[Pedestrian],
298    walls: &[WallSegment],
299    params: &Params,
300    grid: &NeighborGrid,
301    cutoff: f64,
302) -> Vec2 {
303    let p = &peds[i];
304    let e_dest = p.desired_direction();
305    let mut acc = scale(e_dest, params.alpha);
306
307    grid.for_each_neighbor(i, cutoff, peds, |_j, q| {
308        let diff = sub(p.pos, q.pos);
309        let d = norm(diff);
310        if d < 1e-9 {
311            return;
312        }
313        let e_ij = scale(diff, 1.0 / d);
314        let clearance = d - (p.radius + q.radius);
315        let weight =
316            params.interaction_strength * (-clearance.max(0.0) / params.interaction_range).exp();
317        acc = add(acc, scale(e_ij, weight));
318    });
319
320    for w in walls {
321        let closest = closest_point_on_segment(p.pos, w.a, w.b);
322        let diff = sub(p.pos, closest);
323        let d = norm(diff);
324        if d < 1e-9 {
325            continue;
326        }
327        let e_iw = scale(diff, 1.0 / d);
328        let clearance = d - p.radius;
329        let weight = params.wall_strength * (-clearance.max(0.0) / params.wall_range).exp();
330        acc = add(acc, scale(e_iw, weight));
331    }
332
333    normalize(acc)
334}
335
336#[cfg(feature = "simd")]
337fn chosen_direction_grid_simd(
338    i: usize,
339    peds: &[Pedestrian],
340    walls: &[WallSegment],
341    params: &Params,
342    grid: &NeighborGrid,
343    cutoff: f64,
344) -> Vec2 {
345    let p = &peds[i];
346    let e_dest = p.desired_direction();
347    let mut acc = scale(e_dest, params.alpha);
348
349    let mut idxs: [Option<usize>; 4] = [None, None, None, None];
350    let mut filled: usize = 0;
351    grid.for_each_neighbor(i, cutoff, peds, |j, _q| {
352        idxs[filled] = Some(j);
353        filled += 1;
354        if filled == 4 {
355            let buf: [Option<&Pedestrian>; 4] = [
356                Some(&peds[idxs[0].unwrap()]),
357                Some(&peds[idxs[1].unwrap()]),
358                Some(&peds[idxs[2].unwrap()]),
359                Some(&peds[idxs[3].unwrap()]),
360            ];
361            acc = add(acc, crate::simd::cfs_direction_x4(p, buf, params));
362            idxs = [None, None, None, None];
363            filled = 0;
364        }
365    });
366    if filled > 0 {
367        let buf: [Option<&Pedestrian>; 4] = [
368            idxs[0].map(|k| &peds[k]),
369            idxs[1].map(|k| &peds[k]),
370            idxs[2].map(|k| &peds[k]),
371            idxs[3].map(|k| &peds[k]),
372        ];
373        acc = add(acc, crate::simd::cfs_direction_x4(p, buf, params));
374    }
375
376    for w in walls {
377        let closest = closest_point_on_segment(p.pos, w.a, w.b);
378        let diff = sub(p.pos, closest);
379        let d = norm(diff);
380        if d < 1e-9 {
381            continue;
382        }
383        let e_iw = scale(diff, 1.0 / d);
384        let clearance = d - p.radius;
385        let weight = params.wall_strength * (-clearance.max(0.0) / params.wall_range).exp();
386        acc = add(acc, scale(e_iw, weight));
387    }
388
389    normalize(acc)
390}
391
392/// Grid-backed version of [`free_headroom`].
393fn free_headroom_grid(
394    i: usize,
395    peds: &[Pedestrian],
396    e: &Vec2,
397    grid: &NeighborGrid,
398    cutoff: f64,
399) -> f64 {
400    let p = &peds[i];
401    let mut s = f64::INFINITY;
402    grid.for_each_neighbor(i, cutoff, peds, |_j, q| {
403        let rel = sub(q.pos, p.pos);
404        let forward = dot(rel, *e);
405        if forward <= 0.0 {
406            return;
407        }
408        let proj = scale(*e, forward);
409        let lat = norm(sub(rel, proj));
410        if lat > p.radius + q.radius {
411            return;
412        }
413        let d = norm(rel);
414        if d < s {
415            s = d;
416        }
417    });
418    s
419}
420
421#[cfg(feature = "simd")]
422fn free_headroom_grid_simd(
423    i: usize,
424    peds: &[Pedestrian],
425    e: &Vec2,
426    grid: &NeighborGrid,
427    cutoff: f64,
428) -> f64 {
429    let p = &peds[i];
430    let mut s = f64::INFINITY;
431    let mut idxs: [Option<usize>; 4] = [None, None, None, None];
432    let mut filled: usize = 0;
433    grid.for_each_neighbor(i, cutoff, peds, |j, _q| {
434        idxs[filled] = Some(j);
435        filled += 1;
436        if filled == 4 {
437            let buf: [Option<&Pedestrian>; 4] = [
438                Some(&peds[idxs[0].unwrap()]),
439                Some(&peds[idxs[1].unwrap()]),
440                Some(&peds[idxs[2].unwrap()]),
441                Some(&peds[idxs[3].unwrap()]),
442            ];
443            s = s.min(crate::simd::cfs_headroom_x4(p, *e, buf));
444            idxs = [None, None, None, None];
445            filled = 0;
446        }
447    });
448    if filled > 0 {
449        let buf: [Option<&Pedestrian>; 4] = [
450            idxs[0].map(|k| &peds[k]),
451            idxs[1].map(|k| &peds[k]),
452            idxs[2].map(|k| &peds[k]),
453            idxs[3].map(|k| &peds[k]),
454        ];
455        s = s.min(crate::simd::cfs_headroom_x4(p, *e, buf));
456    }
457    s
458}
459
460/// Compute the blended direction for pedestrian `i`.
461#[inline]
462pub fn chosen_direction(
463    i: usize,
464    peds: &[Pedestrian],
465    walls: &[WallSegment],
466    params: &Params,
467) -> Vec2 {
468    let p = &peds[i];
469    let e_dest = p.desired_direction();
470    let mut acc = scale(e_dest, params.alpha);
471
472    for (j, q) in peds.iter().enumerate() {
473        if i == j {
474            continue;
475        }
476        let diff = sub(p.pos, q.pos);
477        let d = norm(diff);
478        if d < 1e-9 {
479            continue;
480        }
481        let e_ij = scale(diff, 1.0 / d);
482        let clearance = d - (p.radius + q.radius);
483        let weight =
484            params.interaction_strength * (-clearance.max(0.0) / params.interaction_range).exp();
485        acc = add(acc, scale(e_ij, weight));
486    }
487
488    for w in walls {
489        let closest = closest_point_on_segment(p.pos, w.a, w.b);
490        let diff = sub(p.pos, closest);
491        let d = norm(diff);
492        if d < 1e-9 {
493            continue;
494        }
495        let e_iw = scale(diff, 1.0 / d);
496        let clearance = d - p.radius;
497        let weight = params.wall_strength * (-clearance.max(0.0) / params.wall_range).exp();
498        acc = add(acc, scale(e_iw, weight));
499    }
500
501    normalize(acc)
502}
503
504/// Minimum free distance along direction `e` to the closest neighbour in front
505/// of pedestrian `i`. Returns `f64::INFINITY` if no neighbour is in front.
506#[inline]
507pub fn free_headroom(i: usize, peds: &[Pedestrian], e: &Vec2) -> f64 {
508    let p = &peds[i];
509    let mut s = f64::INFINITY;
510    for (j, q) in peds.iter().enumerate() {
511        if i == j {
512            continue;
513        }
514        let rel = sub(q.pos, p.pos);
515        let forward = dot(rel, *e);
516        if forward <= 0.0 {
517            continue;
518        }
519        // Lateral displacement at the projected point.
520        let proj = scale(*e, forward);
521        let lat = norm(sub(rel, proj));
522        if lat > p.radius + q.radius {
523            continue;
524        }
525        let d = norm(rel);
526        if d < s {
527            s = d;
528        }
529    }
530    s
531}
532
533#[cfg(test)]
534#[allow(deprecated)] // intentional: pins grid/scratch equivalence vs the deprecated O(n²) `step`.
535mod tests {
536    use super::*;
537
538    #[test]
539    fn single_agent_reaches_free_flow() {
540        let mut peds = vec![Pedestrian {
541            pos: [0.0, 0.0],
542            vel: [0.0, 0.0],
543            radius: 0.2,
544            desired_speed: 1.34,
545            destination: [20.0, 0.0],
546        }];
547        step(&mut peds, &[], &Params::default(), 0.1);
548        assert!((peds[0].vel[0] - 1.34).abs() < 1e-6);
549    }
550
551    #[test]
552    fn follower_slows_behind_leader() {
553        let mut peds = vec![
554            Pedestrian {
555                pos: [0.0, 0.0],
556                vel: [0.0, 0.0],
557                radius: 0.2,
558                desired_speed: 1.34,
559                destination: [100.0, 0.0],
560            },
561            Pedestrian {
562                pos: [-0.5, 0.0],
563                vel: [0.0, 0.0],
564                radius: 0.2,
565                desired_speed: 1.34,
566                destination: [100.0, 0.0],
567            },
568        ];
569        step(&mut peds, &[], &Params::default(), 0.1);
570        assert!(
571            peds[1].vel[0] < peds[0].vel[0],
572            "follower must be slower than free-flowing leader"
573        );
574    }
575
576    #[test]
577    fn head_on_pair_does_not_overlap() {
578        let mut peds = vec![
579            Pedestrian {
580                pos: [-4.0, 0.1],
581                vel: [0.0, 0.0],
582                radius: 0.2,
583                desired_speed: 1.34,
584                destination: [4.0, 0.1],
585            },
586            Pedestrian {
587                pos: [4.0, -0.1],
588                vel: [0.0, 0.0],
589                radius: 0.2,
590                desired_speed: 1.34,
591                destination: [-4.0, -0.1],
592            },
593        ];
594        for _ in 0..300 {
595            step(&mut peds, &[], &Params::default(), 0.05);
596        }
597        let d = norm(sub(peds[0].pos, peds[1].pos));
598        assert!(d >= peds[0].radius + peds[1].radius - 1e-3);
599    }
600}