Skip to main content

rustsim_crowd/
generalized_centrifugal_force.rs

1//! Generalized Centrifugal Force Model (Chraibi, Seyfried & Schadschneider 2010).
2//!
3//! A force-based 2nd-order model where the repulsive force between two
4//! pedestrians depends on their relative velocity and an elliptical body
5//! radius that grows with the pedestrian's own speed:
6//!
7//! ```text
8//! r_i(v_i) = a + b * v_i
9//! F_ij     = -m_i * (v0_i + v_ij·)^2 / (d_ij - r_i - r_j) * ê_ij
10//! ```
11//!
12//! where `v_ij·` is the positive part of the approach speed along `ê_ji`.
13//! A standard linear driving force pulls the pedestrian toward its goal.
14//! Walls are handled with a similar centrifugal term against the closest
15//! point on the segment.
16//!
17//! # Integrator
18//!
19//! Accelerations are integrated with **semi-implicit (symplectic)
20//! Euler** (`v ← v + a·dt; p ← p + v·dt`): the velocity update
21//! precedes the position advance and the new velocity is the one
22//! that moves the agent. Together with `Params::max_accel` and the
23//! `dt · max_accel ≤ max_speed` CFL gate in [`Params::validate`],
24//! this keeps the stiff centrifugal pair repulsion stable at the
25//! default timestep without sub-stepping.
26//!
27//! # References
28//!
29//! - Chraibi, M., Seyfried, A., & Schadschneider, A. (2010). "Generalized
30//!   centrifugal-force model for pedestrian dynamics". *Physical Review E*,
31//!   82(4), 046111.
32
33use crate::broadphase::{NeighborGrid, Scratch};
34use crate::common::{
35    add, clamp_speed, closest_point_on_segment, dot, norm, scale, sub, Pedestrian, PedestrianModel,
36    Vec2, WallSegment,
37};
38
39/// Parameters for the Generalized Centrifugal Force model.
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct Params {
42    /// Relaxation time of the driving force (s).
43    pub tau: f64,
44    /// Pedestrian mass (kg).
45    pub mass: f64,
46    /// Constant term of the speed-dependent body radius (m).
47    pub a: f64,
48    /// Linear term of the speed-dependent body radius (s).
49    pub b: f64,
50    /// Wall repulsion gain (N·m).
51    pub wall_gain: f64,
52    /// Hard upper bound on speed after integration (m/s).
53    pub max_speed: f64,
54    /// Minimum clearance inside denominators (m). Prevents singularity at
55    /// direct contact.
56    pub min_clearance: f64,
57    /// Arrival radius (m). See
58    /// [`social_force::Params::arrival_radius`](crate::social_force::Params::arrival_radius).
59    /// Default: 0.3 m.
60    pub arrival_radius: f64,
61    /// Hard upper bound on acceleration magnitude (m/s²).
62    ///
63    /// GCF's repulsion has a `1/clearance` tail that becomes very
64    /// stiff near contact. Capping the net acceleration gives a
65    /// CFL-like stability bound for explicit integration; see
66    /// [`social_force::Params::max_accel`](crate::social_force::Params::max_accel)
67    /// for the full rationale.
68    pub max_accel: f64,
69}
70
71impl Default for Params {
72    fn default() -> Self {
73        Self {
74            tau: 0.5,
75            mass: 80.0,
76            a: 0.18,
77            b: 0.08,
78            wall_gain: 5.0,
79            max_speed: 2.5,
80            min_clearance: 0.02,
81            arrival_radius: 0.3,
82            max_accel: 20.0,
83        }
84    }
85}
86
87impl Params {
88    /// Validate this parameter set against `dt`.
89    ///
90    /// See [`social_force::Params::validate`](crate::social_force::Params::validate)
91    /// for the full rationale. GCF additionally requires a strictly
92    /// positive [`min_clearance`](Self::min_clearance) because the
93    /// `1/clearance` repulsion tail would otherwise diverge at direct
94    /// contact.
95    pub fn validate(&self, dt: f64) -> Result<(), crate::error::CrowdError> {
96        use crate::error::{require_dt, require_nonneg, require_positive, CrowdError};
97        const M: &str = "GeneralizedCentrifugalForce";
98        require_dt(M, dt)?;
99        require_positive(M, "tau", self.tau)?;
100        require_positive(M, "mass", self.mass)?;
101        require_positive(M, "a", self.a)?;
102        require_nonneg(M, "b", self.b)?;
103        require_nonneg(M, "wall_gain", self.wall_gain)?;
104        require_positive(M, "max_speed", self.max_speed)?;
105        require_positive(M, "min_clearance", self.min_clearance)?;
106        require_nonneg(M, "arrival_radius", self.arrival_radius)?;
107        require_positive(M, "max_accel", self.max_accel)?;
108        let product = dt * self.max_accel;
109        if product > self.max_speed {
110            return Err(CrowdError::CflViolation {
111                model: M,
112                product,
113                max_speed: self.max_speed,
114                max_dt: self.max_speed / self.max_accel,
115            });
116        }
117        Ok(())
118    }
119}
120
121/// Unit marker type implementing [`PedestrianModel`] for the Generalized
122/// Centrifugal Force model.
123#[derive(Debug, Clone, Copy, Default)]
124pub struct GeneralizedCentrifugalForce;
125
126impl PedestrianModel for GeneralizedCentrifugalForce {
127    type Params = Params;
128
129    fn name(&self) -> &'static str {
130        "Generalized Centrifugal Force"
131    }
132
133    fn step(&self, peds: &mut [Pedestrian], walls: &[WallSegment], params: &Params, dt: f64) {
134        #[allow(deprecated)]
135        step(peds, walls, params, dt);
136    }
137}
138
139/// Free-function step for callers that do not need trait dispatch.
140///
141/// **Deprecated.** O(n²) reference path with per-tick heap allocation.
142/// Use [`step_scratch`] (zero-alloc) or [`step_with_grid`] (broadphase)
143/// in production. Retained for parity tests and back-compat.
144#[deprecated(
145    since = "0.0.3",
146    note = "O(n²) reference path with per-tick heap allocation; use \
147            `step_scratch` (zero-alloc) or `step_with_grid` (broadphase) \
148            instead. See docs/rustsim-crowd.md P1-7."
149)]
150#[allow(clippy::needless_range_loop)]
151pub fn step(peds: &mut [Pedestrian], walls: &[WallSegment], params: &Params, dt: f64) {
152    let n = peds.len();
153    let mut accels = vec![[0.0f64; 2]; n];
154
155    for i in 0..n {
156        let p = &peds[i];
157        let mut f = driving_force(p, params);
158
159        for j in 0..n {
160            if i == j {
161                continue;
162            }
163            f = add(f, ped_force(p, &peds[j], params));
164        }
165
166        for w in walls {
167            f = add(f, wall_force(p, w, params));
168        }
169
170        accels[i] = crate::social_force::cap_accel(scale(f, 1.0 / params.mass), params.max_accel);
171    }
172
173    for (p, a) in peds.iter_mut().zip(accels.iter()) {
174        p.vel = add(p.vel, scale(*a, dt));
175        p.vel = clamp_speed(p.vel, params.max_speed);
176        p.pos = add(p.pos, scale(p.vel, dt));
177    }
178}
179
180/// Recommended neighbour cutoff radius for grid queries (metres).
181///
182/// Unlike the exponential-decay models, the centrifugal repulsion
183/// `m * (v0 + v_approach)^2 / clearance` has only a `1/clearance`
184/// tail, so truncation always introduces a small bias. The default
185/// of `5 m` makes that bias negligible: at 5 m the magnitude is
186/// `~28 N` (compared to `~14 kN` inside 0.1 m), i.e. well below the
187/// driving force. Callers who need exact parity with the O(n²) path
188/// should pass a cutoff of `f64::INFINITY` and use [`step_with_grid`]
189/// only as a speed-up on dense populations.
190#[inline]
191pub fn neighbor_cutoff(_params: &Params) -> f64 {
192    5.0
193}
194
195/// Grid-accelerated step variant. Approximates [`step`] for pairs
196/// further than `neighbor_cutoff(params)`, which introduces a small
197/// truncation bias on the `1/clearance` tail — see
198/// [`neighbor_cutoff`] for the default justification.
199#[allow(clippy::needless_range_loop)]
200pub fn step_with_grid(
201    peds: &mut [Pedestrian],
202    walls: &[WallSegment],
203    params: &Params,
204    dt: f64,
205    grid: &NeighborGrid,
206) {
207    let n = peds.len();
208    let cutoff = neighbor_cutoff(params);
209    let mut accels = vec![[0.0f64; 2]; n];
210
211    for i in 0..n {
212        let p = &peds[i];
213        let mut f = driving_force(p, params);
214
215        grid.for_each_neighbor(i, cutoff, peds, |_j, q| {
216            f = add(f, ped_force(p, q, params));
217        });
218
219        for w in walls {
220            f = add(f, wall_force(p, w, params));
221        }
222
223        accels[i] = crate::social_force::cap_accel(scale(f, 1.0 / params.mass), params.max_accel);
224    }
225
226    for (p, a) in peds.iter_mut().zip(accels.iter()) {
227        p.vel = add(p.vel, scale(*a, dt));
228        p.vel = clamp_speed(p.vel, params.max_speed);
229        p.pos = add(p.pos, scale(p.vel, dt));
230    }
231}
232
233/// Zero-allocation step variant. See
234/// [`social_force::step_scratch`](crate::social_force::step_scratch)
235/// for the motivation.
236#[allow(clippy::needless_range_loop)]
237pub fn step_scratch(
238    peds: &mut [Pedestrian],
239    walls: &[WallSegment],
240    params: &Params,
241    dt: f64,
242    scratch: &mut Scratch,
243) {
244    let n = peds.len();
245    let cutoff = neighbor_cutoff(params);
246    scratch.prepare(peds);
247    let (accels, grid) = scratch.split();
248
249    for i in 0..n {
250        let p = &peds[i];
251        let mut f = driving_force(p, params);
252        grid.for_each_neighbor(i, cutoff, peds, |_j, q| {
253            f = add(f, ped_force(p, q, params));
254        });
255        for w in walls {
256            f = add(f, wall_force(p, w, params));
257        }
258        accels[i] = crate::social_force::cap_accel(scale(f, 1.0 / params.mass), params.max_accel);
259    }
260
261    for (p, a) in peds.iter_mut().zip(accels.iter()) {
262        p.vel = add(p.vel, scale(*a, dt));
263        p.vel = clamp_speed(p.vel, params.max_speed);
264        p.pos = add(p.pos, scale(p.vel, dt));
265    }
266}
267
268/// Rayon-parallel drop-in replacement for [`step_scratch`].
269///
270/// See [`social_force::step_scratch_par`](crate::social_force::step_scratch_par)
271/// for the contract: bit-exact with the serial path, each worker
272/// writes only its own `accels[i]` slot from an immutable view of
273/// `peds`. Enable the `rayon` feature of `rustsim-crowd` to use this.
274#[cfg(feature = "rayon")]
275#[allow(clippy::needless_range_loop)]
276pub fn step_scratch_par(
277    peds: &mut [Pedestrian],
278    walls: &[WallSegment],
279    params: &Params,
280    dt: f64,
281    scratch: &mut Scratch,
282) {
283    use rayon::prelude::*;
284
285    let cutoff = neighbor_cutoff(params);
286    scratch.prepare(peds);
287    let (accels, grid) = scratch.split();
288    let peds_ro: &[Pedestrian] = peds;
289
290    accels.par_iter_mut().enumerate().for_each(|(i, a_slot)| {
291        let p = &peds_ro[i];
292        let mut f = driving_force(p, params);
293        grid.for_each_neighbor(i, cutoff, peds_ro, |_j, q| {
294            f = add(f, ped_force(p, q, params));
295        });
296        for w in walls {
297            f = add(f, wall_force(p, w, params));
298        }
299        *a_slot = crate::social_force::cap_accel(scale(f, 1.0 / params.mass), params.max_accel);
300    });
301
302    for (p, a) in peds.iter_mut().zip(accels.iter()) {
303        p.vel = add(p.vel, scale(*a, dt));
304        p.vel = clamp_speed(p.vel, params.max_speed);
305        p.pos = add(p.pos, scale(p.vel, dt));
306    }
307}
308
309/// SIMD-vectorised drop-in replacement for [`step_scratch`].
310///
311/// Lifts [`crate::simd::gcf_pair_force_x4`] into the per-agent inner
312/// loop: broadphase neighbours are buffered in 4-wide index chunks
313/// (indices, not refs, to keep the `for_each_neighbor` closure
314/// borrow-checker-clean), the lane-summed pair-force is added to the
315/// running force total, and the trailing partial chunk is flushed
316/// via the helper's `None`-lane masking. Driving force, wall
317/// repulsion, `cap_accel`, and the semi-implicit Euler integrator
318/// stay scalar — only the inner pair-force sum changes.
319///
320/// Numerical contract: lane re-ordering is the only divergence
321/// source vs [`step_scratch`], so this path is **tolerance-equivalent**,
322/// not bit-exact — the same associativity caveat that
323/// [`step_scratch_par`] documents for the rayon path. Pinned by
324/// `tests/simd_tolerance.rs::gcf_step_scratch_simd_matches_scalar_within_tolerance`.
325#[cfg(feature = "simd")]
326#[allow(clippy::needless_range_loop)]
327pub fn step_scratch_simd(
328    peds: &mut [Pedestrian],
329    walls: &[WallSegment],
330    params: &Params,
331    dt: f64,
332    scratch: &mut Scratch,
333) {
334    let n = peds.len();
335    let cutoff = neighbor_cutoff(params);
336    scratch.prepare(peds);
337    let (accels, grid) = scratch.split();
338
339    for i in 0..n {
340        let p = &peds[i];
341        let mut f = driving_force(p, params);
342
343        let mut idxs: [Option<usize>; 4] = [None, None, None, None];
344        let mut filled: usize = 0;
345        grid.for_each_neighbor(i, cutoff, peds, |j, _q| {
346            idxs[filled] = Some(j);
347            filled += 1;
348            if filled == 4 {
349                let buf: [Option<&Pedestrian>; 4] = [
350                    Some(&peds[idxs[0].unwrap()]),
351                    Some(&peds[idxs[1].unwrap()]),
352                    Some(&peds[idxs[2].unwrap()]),
353                    Some(&peds[idxs[3].unwrap()]),
354                ];
355                let pf = crate::simd::gcf_pair_force_x4(p, buf, params);
356                f = add(f, pf);
357                idxs = [None, None, None, None];
358                filled = 0;
359            }
360        });
361        if filled > 0 {
362            let buf: [Option<&Pedestrian>; 4] = [
363                idxs[0].map(|k| &peds[k]),
364                idxs[1].map(|k| &peds[k]),
365                idxs[2].map(|k| &peds[k]),
366                idxs[3].map(|k| &peds[k]),
367            ];
368            let pf = crate::simd::gcf_pair_force_x4(p, buf, params);
369            f = add(f, pf);
370        }
371
372        for w in walls {
373            f = add(f, wall_force(p, w, params));
374        }
375        accels[i] = crate::social_force::cap_accel(scale(f, 1.0 / params.mass), params.max_accel);
376    }
377
378    for (p, a) in peds.iter_mut().zip(accels.iter()) {
379        p.vel = add(p.vel, scale(*a, dt));
380        p.vel = clamp_speed(p.vel, params.max_speed);
381        p.pos = add(p.pos, scale(p.vel, dt));
382    }
383}
384
385/// Driving force `m * (v0 * e_dest - v) / tau`.
386#[inline]
387pub fn driving_force(p: &Pedestrian, params: &Params) -> Vec2 {
388    let target = scale(
389        p.desired_direction(),
390        p.effective_desired_speed(params.arrival_radius),
391    );
392    let delta = sub(target, p.vel);
393    scale(delta, params.mass / params.tau)
394}
395
396/// Speed-dependent body radius `r(v) = a + b * |v|`.
397#[inline]
398pub fn body_radius(p: &Pedestrian, params: &Params) -> f64 {
399    params.a + params.b * p.speed()
400}
401
402/// Centrifugal repulsion between two pedestrians.
403#[inline]
404pub fn ped_force(p: &Pedestrian, q: &Pedestrian, params: &Params) -> Vec2 {
405    let diff = sub(p.pos, q.pos);
406    let d = norm(diff);
407    if d < 1e-9 {
408        return [0.0, 0.0];
409    }
410    let e_ji = scale(diff, 1.0 / d);
411
412    // Closing speed along e_ji (0 if receding or parallel).
413    let v_rel = sub(p.vel, q.vel);
414    let approach = (-dot(v_rel, e_ji)).max(0.0);
415
416    let r_i = body_radius(p, params);
417    let r_j = body_radius(q, params);
418    let clearance = (d - r_i - r_j).max(params.min_clearance);
419
420    let magnitude = params.mass * (p.desired_speed + approach).powi(2) / clearance;
421    scale(e_ji, magnitude)
422}
423
424/// Centrifugal repulsion from the closest point on a wall segment.
425#[inline]
426pub fn wall_force(p: &Pedestrian, wall: &WallSegment, params: &Params) -> Vec2 {
427    let closest = closest_point_on_segment(p.pos, wall.a, wall.b);
428    let diff = sub(p.pos, closest);
429    let d = norm(diff);
430    if d < 1e-9 {
431        return [0.0, 0.0];
432    }
433    let e = scale(diff, 1.0 / d);
434    let r = body_radius(p, params);
435    let clearance = (d - r).max(params.min_clearance);
436    let magnitude = params.wall_gain * params.mass * (p.desired_speed).powi(2) / clearance;
437    scale(e, magnitude)
438}
439
440#[cfg(test)]
441#[allow(deprecated)] // intentional: pins grid/scratch equivalence vs the deprecated O(n²) `step`.
442mod tests {
443    use super::*;
444
445    #[test]
446    fn single_agent_accelerates_toward_goal() {
447        let mut peds = vec![Pedestrian {
448            pos: [0.0, 0.0],
449            vel: [0.0, 0.0],
450            radius: 0.25,
451            desired_speed: 1.34,
452            destination: [10.0, 0.0],
453        }];
454        for _ in 0..50 {
455            step(&mut peds, &[], &Params::default(), 0.05);
456        }
457        assert!(peds[0].vel[0] > 0.5);
458    }
459
460    #[test]
461    fn head_on_pair_does_not_overlap() {
462        let mut peds = vec![
463            Pedestrian {
464                pos: [-4.0, 0.1],
465                vel: [0.0, 0.0],
466                radius: 0.25,
467                desired_speed: 1.34,
468                destination: [4.0, 0.1],
469            },
470            Pedestrian {
471                pos: [4.0, -0.1],
472                vel: [0.0, 0.0],
473                radius: 0.25,
474                desired_speed: 1.34,
475                destination: [-4.0, -0.1],
476            },
477        ];
478        for _ in 0..400 {
479            step(&mut peds, &[], &Params::default(), 0.02);
480        }
481        let d = norm(sub(peds[0].pos, peds[1].pos));
482        let min_sep =
483            body_radius(&peds[0], &Params::default()) + body_radius(&peds[1], &Params::default());
484        assert!(
485            d + 0.02 >= min_sep,
486            "agents too close: d={d}, min={min_sep}"
487        );
488    }
489}