Skip to main content

rustsim_crowd/
anticipation_velocity.rs

1//! Anticipation Velocity Model (Xu, Chraibi & Seyfried 2021).
2//!
3//! An extension of the Collision-Free Speed model that chooses the walking
4//! direction by *anticipating* the future positions of neighbours over a
5//! look-ahead time horizon. For a discrete set of candidate directions
6//! inside a forward cone around the desired direction, the model computes:
7//!
8//! 1. the *anticipated* clearance along that direction, assuming every
9//!    neighbour keeps its current velocity for `anticipation_time` seconds,
10//! 2. a smooth penalty for deviating from the goal direction.
11//!
12//! The candidate with the maximum score (clearance – deviation penalty) is
13//! chosen, and speed is then set as in the Collision-Free Speed model with
14//! the anticipated clearance replacing the instantaneous headroom.
15//!
16//! # References
17//!
18//! - Xu, Q., Chraibi, M., & Seyfried, A. (2021). "Anticipation in a
19//!   velocity-based model for pedestrian dynamics". *Transportation
20//!   Research Part C: Emerging Technologies*, 133, 103464.
21
22use crate::broadphase::{NeighborGrid, Scratch};
23use crate::common::{
24    add, closest_point_on_segment, dot, norm, scale, sub, Pedestrian, PedestrianModel, Vec2,
25    WallSegment,
26};
27
28/// Parameters for the Anticipation Velocity model.
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct Params {
31    /// Safety time gap (s), as in the Collision-Free Speed model.
32    pub time_gap: f64,
33    /// Look-ahead horizon for neighbour motion prediction (s).
34    pub anticipation_time: f64,
35    /// Number of candidate directions sampled in the forward cone.
36    pub num_directions: usize,
37    /// Half-angle of the forward search cone (rad).
38    pub cone_half_angle: f64,
39    /// Penalty weight for deviating from the desired direction (per rad).
40    pub deviation_weight: f64,
41    /// Wall safety margin (m). Added to the agent's body radius when
42    /// deciding whether a wall clips the anticipated free distance; i.e.
43    /// a ray is considered blocked when its closest wall point sits
44    /// within `radius + wall_range` of the ray. This matches the clean
45    /// geometric treatment in Xu, Chraibi & Seyfried (2021) Eq. 7, in
46    /// which walls act as static point obstacles with a small padding.
47    ///
48    /// Note: a `wall_strength` exponential-penalty field existed in
49    /// versions ≤ 0.0.2; it was deprecated in 0.0.2 (unused since the
50    /// Eq. 7 rewrite) and removed in 0.0.3. The geometric padding
51    /// above is the only wall lever the AVM exposes.
52    pub wall_range: f64,
53    /// Arrival radius (m). See
54    /// [`social_force::Params::arrival_radius`](crate::social_force::Params::arrival_radius).
55    /// Default: 0.3 m.
56    pub arrival_radius: f64,
57}
58
59impl Default for Params {
60    fn default() -> Self {
61        Self {
62            time_gap: 1.06,
63            anticipation_time: 1.0,
64            num_directions: 13,
65            cone_half_angle: std::f64::consts::FRAC_PI_2,
66            deviation_weight: 0.5,
67            wall_range: 0.08,
68            arrival_radius: 0.3,
69        }
70    }
71}
72
73impl Params {
74    /// Validate this parameter set against `dt`.
75    ///
76    /// AVM is a first-order velocity-based model like CFS; there is no
77    /// explicit-Euler CFL condition to check. Validation focuses on
78    /// strictly-positive physical parameters, a non-zero candidate count,
79    /// and a finite positive `dt`.
80    pub fn validate(&self, dt: f64) -> Result<(), crate::error::CrowdError> {
81        use crate::error::{require_count, require_dt, require_nonneg, require_positive};
82        const M: &str = "AnticipationVelocity";
83        require_dt(M, dt)?;
84        require_positive(M, "time_gap", self.time_gap)?;
85        require_positive(M, "anticipation_time", self.anticipation_time)?;
86        require_count(M, "num_directions", self.num_directions)?;
87        require_positive(M, "cone_half_angle", self.cone_half_angle)?;
88        require_nonneg(M, "deviation_weight", self.deviation_weight)?;
89        require_positive(M, "wall_range", self.wall_range)?;
90        require_nonneg(M, "arrival_radius", self.arrival_radius)?;
91        Ok(())
92    }
93}
94
95/// Unit marker type implementing [`PedestrianModel`] for the Anticipation
96/// Velocity model.
97#[derive(Debug, Clone, Copy, Default)]
98pub struct AnticipationVelocity;
99
100impl PedestrianModel for AnticipationVelocity {
101    type Params = Params;
102
103    fn name(&self) -> &'static str {
104        "Anticipation Velocity"
105    }
106
107    fn step(&self, peds: &mut [Pedestrian], walls: &[WallSegment], params: &Params, dt: f64) {
108        #[allow(deprecated)]
109        step(peds, walls, params, dt);
110    }
111}
112
113/// Free-function step for callers that do not need trait dispatch.
114///
115/// **Deprecated.** O(n²) reference path with per-tick heap allocation.
116/// Use [`step_scratch`] (zero-alloc) or [`step_with_grid`] (broadphase)
117/// in production. Retained for parity tests and back-compat.
118#[deprecated(
119    since = "0.0.3",
120    note = "O(n²) reference path with per-tick heap allocation; use \
121            `step_scratch` (zero-alloc) or `step_with_grid` (broadphase) \
122            instead. See docs/rustsim-crowd.md P1-7."
123)]
124#[allow(clippy::needless_range_loop)]
125pub fn step(peds: &mut [Pedestrian], walls: &[WallSegment], params: &Params, dt: f64) {
126    let n = peds.len();
127    let mut new_vel = vec![[0.0f64; 2]; n];
128
129    for i in 0..n {
130        let (e, s) = best_direction_and_headroom(i, peds, walls, params);
131        let speed_cap = ((s - peds[i].radius) / params.time_gap).max(0.0);
132        let v = peds[i]
133            .effective_desired_speed(params.arrival_radius)
134            .min(speed_cap);
135        new_vel[i] = scale(e, v);
136    }
137
138    for (p, v) in peds.iter_mut().zip(new_vel.iter()) {
139        p.vel = *v;
140        p.pos = add(p.pos, scale(p.vel, dt));
141    }
142}
143
144/// Recommended neighbour cutoff radius for grid queries (metres).
145///
146/// The anticipated-headroom scan only considers neighbours whose
147/// predicted future position (`q.pos + q.vel * anticipation_time`)
148/// projects onto the forward ray inside the safety envelope. In
149/// practice `anticipation_time * max_speed + 2 * radius + 1 m`
150/// provides a generous margin; this function assumes a 2.5 m/s
151/// upper bound on pedestrian speed and a 0.5 m body-radius headroom.
152#[inline]
153pub fn neighbor_cutoff(params: &Params) -> f64 {
154    params.anticipation_time * 2.5 + 1.5
155}
156
157/// Grid-accelerated step variant. Semantically equivalent to [`step`]
158/// up to numerical noise for pairs inside `neighbor_cutoff(params)`.
159#[allow(clippy::needless_range_loop)]
160pub fn step_with_grid(
161    peds: &mut [Pedestrian],
162    walls: &[WallSegment],
163    params: &Params,
164    dt: f64,
165    grid: &NeighborGrid,
166) {
167    let n = peds.len();
168    let cutoff = neighbor_cutoff(params);
169    let mut new_vel = vec![[0.0f64; 2]; n];
170
171    for i in 0..n {
172        let (e, s) = best_direction_and_headroom_grid(i, peds, walls, params, grid, cutoff);
173        let speed_cap = ((s - peds[i].radius) / params.time_gap).max(0.0);
174        let v = peds[i]
175            .effective_desired_speed(params.arrival_radius)
176            .min(speed_cap);
177        new_vel[i] = scale(e, v);
178    }
179
180    for (p, v) in peds.iter_mut().zip(new_vel.iter()) {
181        p.vel = *v;
182        p.pos = add(p.pos, scale(p.vel, dt));
183    }
184}
185
186/// Zero-allocation step variant. See
187/// [`social_force::step_scratch`](crate::social_force::step_scratch)
188/// for the motivation.
189#[allow(clippy::needless_range_loop)]
190pub fn step_scratch(
191    peds: &mut [Pedestrian],
192    walls: &[WallSegment],
193    params: &Params,
194    dt: f64,
195    scratch: &mut Scratch,
196) {
197    let n = peds.len();
198    let cutoff = neighbor_cutoff(params);
199    scratch.prepare(peds);
200    let (new_vel, grid) = scratch.split();
201
202    for i in 0..n {
203        let (e, s) = best_direction_and_headroom_grid(i, peds, walls, params, grid, cutoff);
204        let speed_cap = ((s - peds[i].radius) / params.time_gap).max(0.0);
205        let v = peds[i]
206            .effective_desired_speed(params.arrival_radius)
207            .min(speed_cap);
208        new_vel[i] = scale(e, v);
209    }
210
211    for (p, v) in peds.iter_mut().zip(new_vel.iter()) {
212        p.vel = *v;
213        p.pos = add(p.pos, scale(p.vel, dt));
214    }
215}
216
217/// SIMD-vectorised drop-in replacement for [`step_scratch`].
218///
219/// Lifts the AVM anticipated-neighbour headroom scan into 4-wide SIMD
220/// chunks via [`crate::simd::avm_headroom_x4`]. Candidate-direction
221/// sampling, wall clipping, speed tapering, and position integration stay
222/// scalar so the public model contract matches the scalar scratch path.
223///
224/// Numerical contract: neighbour scan chunking can reorder equal-score edge
225/// cases only through floating-point lane grouping, so this path is
226/// tolerance-equivalent to [`step_scratch`]. `tests/simd_tolerance.rs` pins
227/// the current envelope.
228#[cfg(feature = "simd")]
229#[allow(clippy::needless_range_loop)]
230pub fn step_scratch_simd(
231    peds: &mut [Pedestrian],
232    walls: &[WallSegment],
233    params: &Params,
234    dt: f64,
235    scratch: &mut Scratch,
236) {
237    let n = peds.len();
238    let cutoff = neighbor_cutoff(params);
239    scratch.prepare(peds);
240    let (new_vel, grid) = scratch.split();
241
242    for i in 0..n {
243        let (e, s) = best_direction_and_headroom_grid_simd(i, peds, walls, params, grid, cutoff);
244        let speed_cap = ((s - peds[i].radius) / params.time_gap).max(0.0);
245        let v = peds[i]
246            .effective_desired_speed(params.arrival_radius)
247            .min(speed_cap);
248        new_vel[i] = scale(e, v);
249    }
250
251    for (p, v) in peds.iter_mut().zip(new_vel.iter()) {
252        p.vel = *v;
253        p.pos = add(p.pos, scale(p.vel, dt));
254    }
255}
256
257/// Rayon-parallel drop-in replacement for [`step_scratch`].
258///
259/// See [`social_force::step_scratch_par`](crate::social_force::step_scratch_par)
260/// for the contract. Enable the `rayon` feature to use this entry
261/// point.
262#[cfg(feature = "rayon")]
263#[allow(clippy::needless_range_loop)]
264pub fn step_scratch_par(
265    peds: &mut [Pedestrian],
266    walls: &[WallSegment],
267    params: &Params,
268    dt: f64,
269    scratch: &mut Scratch,
270) {
271    use rayon::prelude::*;
272
273    let cutoff = neighbor_cutoff(params);
274    scratch.prepare(peds);
275    let (new_vel, grid) = scratch.split();
276    let peds_ro: &[Pedestrian] = peds;
277
278    new_vel.par_iter_mut().enumerate().for_each(|(i, v_slot)| {
279        let (e, s) = best_direction_and_headroom_grid(i, peds_ro, walls, params, grid, cutoff);
280        let speed_cap = ((s - peds_ro[i].radius) / params.time_gap).max(0.0);
281        let v = peds_ro[i]
282            .effective_desired_speed(params.arrival_radius)
283            .min(speed_cap);
284        *v_slot = scale(e, v);
285    });
286
287    for (p, v) in peds.iter_mut().zip(new_vel.iter()) {
288        p.vel = *v;
289        p.pos = add(p.pos, scale(p.vel, dt));
290    }
291}
292
293/// Grid-backed version of [`best_direction_and_headroom`].
294fn best_direction_and_headroom_grid(
295    i: usize,
296    peds: &[Pedestrian],
297    walls: &[WallSegment],
298    params: &Params,
299    grid: &NeighborGrid,
300    cutoff: f64,
301) -> (Vec2, f64) {
302    let p = &peds[i];
303    let e_dest = p.desired_direction();
304    if e_dest == [0.0, 0.0] {
305        return ([0.0, 0.0], 0.0);
306    }
307
308    let base_angle = e_dest[1].atan2(e_dest[0]);
309    let m = params.num_directions.max(1);
310    let half = params.cone_half_angle;
311
312    let mut best_dir = e_dest;
313    let mut best_headroom =
314        anticipated_headroom_grid(i, peds, walls, &e_dest, params, grid, cutoff);
315    let mut best_score = best_headroom;
316
317    for k in 0..m {
318        let t = if m == 1 {
319            0.0
320        } else {
321            -1.0 + 2.0 * (k as f64) / ((m - 1) as f64)
322        };
323        let theta = base_angle + t * half;
324        let dir = [theta.cos(), theta.sin()];
325        let headroom = anticipated_headroom_grid(i, peds, walls, &dir, params, grid, cutoff);
326        let deviation = params.deviation_weight * t.abs() * half;
327        let score = headroom - deviation;
328        if score > best_score {
329            best_score = score;
330            best_headroom = headroom;
331            best_dir = dir;
332        }
333    }
334
335    (best_dir, best_headroom)
336}
337
338#[cfg(feature = "simd")]
339fn best_direction_and_headroom_grid_simd(
340    i: usize,
341    peds: &[Pedestrian],
342    walls: &[WallSegment],
343    params: &Params,
344    grid: &NeighborGrid,
345    cutoff: f64,
346) -> (Vec2, f64) {
347    let p = &peds[i];
348    let e_dest = p.desired_direction();
349    if e_dest == [0.0, 0.0] {
350        return ([0.0, 0.0], 0.0);
351    }
352
353    let base_angle = e_dest[1].atan2(e_dest[0]);
354    let m = params.num_directions.max(1);
355    let half = params.cone_half_angle;
356
357    let mut best_dir = e_dest;
358    let mut best_headroom =
359        anticipated_headroom_grid_simd(i, peds, walls, &e_dest, params, grid, cutoff);
360    let mut best_score = best_headroom;
361
362    for k in 0..m {
363        let t = if m == 1 {
364            0.0
365        } else {
366            -1.0 + 2.0 * (k as f64) / ((m - 1) as f64)
367        };
368        let theta = base_angle + t * half;
369        let dir = [theta.cos(), theta.sin()];
370        let headroom = anticipated_headroom_grid_simd(i, peds, walls, &dir, params, grid, cutoff);
371        let deviation = params.deviation_weight * t.abs() * half;
372        let score = headroom - deviation;
373        if score > best_score {
374            best_score = score;
375            best_headroom = headroom;
376            best_dir = dir;
377        }
378    }
379
380    (best_dir, best_headroom)
381}
382
383/// Grid-backed version of [`anticipated_headroom`].
384fn anticipated_headroom_grid(
385    i: usize,
386    peds: &[Pedestrian],
387    walls: &[WallSegment],
388    e: &Vec2,
389    params: &Params,
390    grid: &NeighborGrid,
391    cutoff: f64,
392) -> f64 {
393    let p = &peds[i];
394    let mut s = f64::INFINITY;
395
396    grid.for_each_neighbor(i, cutoff, peds, |_j, q| {
397        let q_future = add(q.pos, scale(q.vel, params.anticipation_time));
398        let rel = sub(q_future, p.pos);
399        let forward = dot(rel, *e);
400        if forward <= 0.0 {
401            return;
402        }
403        let proj = scale(*e, forward);
404        let lat = norm(sub(rel, proj));
405        if lat > p.radius + q.radius {
406            return;
407        }
408        let d = norm(rel);
409        if d < s {
410            s = d;
411        }
412    });
413
414    for w in walls {
415        // See the non-grid [`anticipated_headroom`] for the
416        // paper-aligned rationale.
417        let probe = add(p.pos, scale(*e, p.desired_speed * params.anticipation_time));
418        let closest = closest_point_on_segment(probe, w.a, w.b);
419        let rel = sub(closest, p.pos);
420        let forward = dot(rel, *e);
421        if forward <= 0.0 {
422            continue;
423        }
424        let proj = scale(*e, forward);
425        let lat = norm(sub(rel, proj));
426        if lat > p.radius + params.wall_range {
427            continue;
428        }
429        let d = norm(rel);
430        if d < s {
431            s = d;
432        }
433    }
434
435    s
436}
437
438#[cfg(feature = "simd")]
439fn anticipated_headroom_grid_simd(
440    i: usize,
441    peds: &[Pedestrian],
442    walls: &[WallSegment],
443    e: &Vec2,
444    params: &Params,
445    grid: &NeighborGrid,
446    cutoff: f64,
447) -> f64 {
448    let p = &peds[i];
449    let mut s = f64::INFINITY;
450    let mut idxs: [Option<usize>; 4] = [None, None, None, None];
451    let mut filled: usize = 0;
452    grid.for_each_neighbor(i, cutoff, peds, |j, _q| {
453        idxs[filled] = Some(j);
454        filled += 1;
455        if filled == 4 {
456            let buf: [Option<&Pedestrian>; 4] = [
457                Some(&peds[idxs[0].unwrap()]),
458                Some(&peds[idxs[1].unwrap()]),
459                Some(&peds[idxs[2].unwrap()]),
460                Some(&peds[idxs[3].unwrap()]),
461            ];
462            s = s.min(crate::simd::avm_headroom_x4(p, *e, buf, params));
463            idxs = [None, None, None, None];
464            filled = 0;
465        }
466    });
467    if filled > 0 {
468        let buf: [Option<&Pedestrian>; 4] = [
469            idxs[0].map(|k| &peds[k]),
470            idxs[1].map(|k| &peds[k]),
471            idxs[2].map(|k| &peds[k]),
472            idxs[3].map(|k| &peds[k]),
473        ];
474        s = s.min(crate::simd::avm_headroom_x4(p, *e, buf, params));
475    }
476
477    for w in walls {
478        let probe = add(p.pos, scale(*e, p.desired_speed * params.anticipation_time));
479        let closest = closest_point_on_segment(probe, w.a, w.b);
480        let rel = sub(closest, p.pos);
481        let forward = dot(rel, *e);
482        if forward <= 0.0 {
483            continue;
484        }
485        let proj = scale(*e, forward);
486        let lat = norm(sub(rel, proj));
487        if lat > p.radius + params.wall_range {
488            continue;
489        }
490        let d = norm(rel);
491        if d < s {
492            s = d;
493        }
494    }
495
496    s
497}
498
499/// Returns the best forward direction and its anticipated free distance.
500pub fn best_direction_and_headroom(
501    i: usize,
502    peds: &[Pedestrian],
503    walls: &[WallSegment],
504    params: &Params,
505) -> (Vec2, f64) {
506    let p = &peds[i];
507    let e_dest = p.desired_direction();
508    if e_dest == [0.0, 0.0] {
509        return ([0.0, 0.0], 0.0);
510    }
511
512    let base_angle = e_dest[1].atan2(e_dest[0]);
513    let m = params.num_directions.max(1);
514    let half = params.cone_half_angle;
515
516    let mut best_dir = e_dest;
517    let mut best_headroom = anticipated_headroom(i, peds, walls, &e_dest, params);
518    let mut best_score = best_headroom;
519
520    for k in 0..m {
521        let t = if m == 1 {
522            0.0
523        } else {
524            -1.0 + 2.0 * (k as f64) / ((m - 1) as f64)
525        };
526        let theta = base_angle + t * half;
527        let dir = [theta.cos(), theta.sin()];
528        let headroom = anticipated_headroom(i, peds, walls, &dir, params);
529        let deviation = params.deviation_weight * t.abs() * half;
530        let score = headroom - deviation;
531        if score > best_score {
532            best_score = score;
533            best_headroom = headroom;
534            best_dir = dir;
535        }
536    }
537
538    (best_dir, best_headroom)
539}
540
541/// Distance to the closest predicted collision along direction `e`, where
542/// every neighbour is extrapolated with constant velocity for
543/// `anticipation_time` seconds. Walls are treated as static.
544pub fn anticipated_headroom(
545    i: usize,
546    peds: &[Pedestrian],
547    walls: &[WallSegment],
548    e: &Vec2,
549    params: &Params,
550) -> f64 {
551    let p = &peds[i];
552    let mut s = f64::INFINITY;
553
554    for (j, q) in peds.iter().enumerate() {
555        if i == j {
556            continue;
557        }
558        // Predict neighbour position after the anticipation horizon.
559        let q_future = add(q.pos, scale(q.vel, params.anticipation_time));
560        let rel = sub(q_future, p.pos);
561        let forward = dot(rel, *e);
562        if forward <= 0.0 {
563            continue;
564        }
565        let proj = scale(*e, forward);
566        let lat = norm(sub(rel, proj));
567        if lat > p.radius + q.radius {
568            continue;
569        }
570        let d = norm(rel);
571        if d < s {
572            s = d;
573        }
574    }
575
576    for w in walls {
577        // Treat the closest point on the wall segment as a static
578        // point obstacle; block the ray iff its lateral distance to
579        // that point is within the padded body radius. Matches the
580        // geometric wall handling in Xu, Chraibi & Seyfried (2021)
581        // Eq. 7.
582        let probe = add(p.pos, scale(*e, p.desired_speed * params.anticipation_time));
583        let closest = closest_point_on_segment(probe, w.a, w.b);
584        let rel = sub(closest, p.pos);
585        let forward = dot(rel, *e);
586        if forward <= 0.0 {
587            continue;
588        }
589        let proj = scale(*e, forward);
590        let lat = norm(sub(rel, proj));
591        if lat > p.radius + params.wall_range {
592            continue;
593        }
594        let d = norm(rel);
595        if d < s {
596            s = d;
597        }
598    }
599
600    s
601}
602
603#[cfg(test)]
604#[allow(deprecated)] // intentional: pins grid/scratch equivalence vs the deprecated O(n²) `step`.
605mod tests {
606    use super::*;
607
608    #[test]
609    fn single_agent_advances_at_free_flow() {
610        let mut peds = vec![Pedestrian {
611            pos: [0.0, 0.0],
612            vel: [0.0, 0.0],
613            radius: 0.2,
614            desired_speed: 1.34,
615            destination: [20.0, 0.0],
616        }];
617        step(&mut peds, &[], &Params::default(), 0.1);
618        let v = norm(peds[0].vel);
619        assert!((v - 1.34).abs() < 1e-6);
620    }
621
622    #[test]
623    fn deviates_around_static_obstacle() {
624        // Agent walking east, with a static blocker directly in front.
625        let mut peds = vec![
626            Pedestrian {
627                pos: [0.0, 0.0],
628                vel: [0.0, 0.0],
629                radius: 0.2,
630                desired_speed: 1.34,
631                destination: [10.0, 0.0],
632            },
633            Pedestrian {
634                pos: [3.0, 0.0],
635                vel: [0.0, 0.0],
636                radius: 0.2,
637                desired_speed: 0.0,
638                destination: [3.0, 0.0],
639            },
640        ];
641        for _ in 0..200 {
642            step(&mut peds, &[], &Params::default(), 0.05);
643        }
644        // The agent must pass the blocker (x > 3.5) without overlap.
645        assert!(peds[0].pos[0] > 3.5);
646        let d = norm(sub(peds[0].pos, peds[1].pos));
647        assert!(d >= peds[0].radius + peds[1].radius - 0.05);
648    }
649
650    #[test]
651    fn two_agents_head_on_do_not_overlap() {
652        let mut peds = vec![
653            Pedestrian {
654                pos: [-4.0, 0.05],
655                vel: [0.0, 0.0],
656                radius: 0.2,
657                desired_speed: 1.34,
658                destination: [4.0, 0.05],
659            },
660            Pedestrian {
661                pos: [4.0, -0.05],
662                vel: [0.0, 0.0],
663                radius: 0.2,
664                desired_speed: 1.34,
665                destination: [-4.0, -0.05],
666            },
667        ];
668        for _ in 0..400 {
669            step(&mut peds, &[], &Params::default(), 0.02);
670        }
671        let d = norm(sub(peds[0].pos, peds[1].pos));
672        assert!(d >= peds[0].radius + peds[1].radius - 0.05);
673    }
674}