Skip to main content

rustsim_crowd/
optimal_steps.rs

1//! Optimal Steps Model (Seitz & Köster 2012).
2//!
3//! A *discrete step* locomotion model. On each update each pedestrian
4//! samples a set of candidate foot-placement points on a circle of radius
5//! equal to its stride length. The candidate with the lowest scalar
6//! *utility* is chosen as the next position. Utility is the sum of:
7//!
8//! - a target-attraction term proportional to the candidate's distance
9//!   to the destination,
10//! - a pairwise repulsion from every neighbour (monotonic in clearance),
11//! - a wall penalty from the closest point on every wall segment.
12//!
13//! Unlike force-based models, position updates are step-sized rather than
14//! velocity-integrated. This implementation uses a deterministic polar
15//! sampling grid so the model is reproducible without an external RNG;
16//! randomised sampling can be layered on by shuffling the result of
17//! [`candidate_offsets`] before calling [`best_candidate`].
18//!
19//! # References
20//!
21//! - Seitz, M. J., & Köster, G. (2012). "Natural discretization of
22//!   pedestrian movement in continuous space". *Physical Review E*, 86(4),
23//!   046108.
24
25use crate::broadphase::{NeighborGrid, Scratch};
26use crate::common::{
27    add, closest_point_on_segment, norm, normalize, scale, sub, Pedestrian, PedestrianModel, Vec2,
28    WallSegment,
29};
30
31/// Parameters for the Optimal Steps model.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct Params {
34    /// Number of candidate points sampled on the stride circle.
35    pub num_candidates: usize,
36    /// Stride length for a pedestrian walking at `desired_speed` (m).
37    /// Candidate radius is clamped to `stride_at_free_flow * v/v0`.
38    pub stride_at_free_flow: f64,
39    /// Half-angle of the forward search cone (rad). Candidates outside this
40    /// cone relative to the desired direction are discarded.
41    pub cone_half_angle: f64,
42    /// Weight of the destination-attraction term (per m).
43    pub target_weight: f64,
44    /// Strength of the pairwise pedestrian repulsion penalty.
45    pub ped_strength: f64,
46    /// Range of the pairwise pedestrian repulsion penalty (m).
47    pub ped_range: f64,
48    /// Strength of the wall repulsion penalty.
49    pub wall_strength: f64,
50    /// Range of the wall repulsion penalty (m).
51    pub wall_range: f64,
52    /// Arrival radius (m). See
53    /// [`social_force::Params::arrival_radius`](crate::social_force::Params::arrival_radius).
54    /// Default: 0.3 m.
55    pub arrival_radius: f64,
56}
57
58impl Default for Params {
59    fn default() -> Self {
60        Self {
61            num_candidates: 24,
62            stride_at_free_flow: 0.5,
63            cone_half_angle: std::f64::consts::FRAC_PI_2,
64            target_weight: 1.0,
65            ped_strength: 6.0,
66            ped_range: 0.4,
67            wall_strength: 8.0,
68            wall_range: 0.3,
69            arrival_radius: 0.3,
70        }
71    }
72}
73
74impl Params {
75    /// Validate this parameter set against `dt`.
76    ///
77    /// OSM is a discrete-step kinematic model; there is no
78    /// explicit-Euler CFL condition to check. Validation focuses on
79    /// strictly-positive physical ranges and a non-zero candidate count.
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 = "OptimalSteps";
83        require_dt(M, dt)?;
84        require_count(M, "num_candidates", self.num_candidates)?;
85        require_positive(M, "stride_at_free_flow", self.stride_at_free_flow)?;
86        require_positive(M, "cone_half_angle", self.cone_half_angle)?;
87        require_nonneg(M, "target_weight", self.target_weight)?;
88        require_nonneg(M, "ped_strength", self.ped_strength)?;
89        require_positive(M, "ped_range", self.ped_range)?;
90        require_nonneg(M, "wall_strength", self.wall_strength)?;
91        require_positive(M, "wall_range", self.wall_range)?;
92        require_nonneg(M, "arrival_radius", self.arrival_radius)?;
93        Ok(())
94    }
95}
96
97/// Unit marker type implementing [`PedestrianModel`] for the Optimal Steps model.
98#[derive(Debug, Clone, Copy, Default)]
99pub struct OptimalSteps;
100
101impl PedestrianModel for OptimalSteps {
102    type Params = Params;
103
104    fn name(&self) -> &'static str {
105        "Optimal Steps"
106    }
107
108    fn step(&self, peds: &mut [Pedestrian], walls: &[WallSegment], params: &Params, dt: f64) {
109        #[allow(deprecated)]
110        step(peds, walls, params, dt);
111    }
112}
113
114/// Free-function step for callers that do not need trait dispatch.
115///
116/// **Deprecated.** O(n²) reference path with per-tick heap allocation.
117/// Use [`step_scratch`] (zero-alloc) or [`step_with_grid`] (broadphase)
118/// in production. Retained for parity tests and back-compat.
119///
120/// `dt` is the duration represented by a single step; it drives stride
121/// length as `v * dt` but is otherwise unused (the model is kinematic).
122#[deprecated(
123    since = "0.0.3",
124    note = "O(n²) reference path with per-tick heap allocation; use \
125            `step_scratch` (zero-alloc) or `step_with_grid` (broadphase) \
126            instead. See docs/rustsim-crowd.md P1-7."
127)]
128#[allow(clippy::needless_range_loop)]
129pub fn step(peds: &mut [Pedestrian], walls: &[WallSegment], params: &Params, dt: f64) {
130    let n = peds.len();
131    let mut new_pos = vec![[0.0f64; 2]; n];
132
133    for i in 0..n {
134        let p = &peds[i];
135        let stride = stride_length(p, params, dt);
136        new_pos[i] = best_candidate(i, peds, walls, params, stride);
137    }
138
139    for (p, np) in peds.iter_mut().zip(new_pos.iter()) {
140        let delta = sub(*np, p.pos);
141        // Back-derive an effective velocity so other systems can inspect it.
142        if dt > 0.0 {
143            p.vel = scale(delta, 1.0 / dt);
144        }
145        p.pos = *np;
146    }
147}
148
149/// Recommended neighbour cutoff radius for grid queries (metres).
150///
151/// The pairwise penalty is `ped_strength * exp(-clearance/ped_range)`.
152/// At a clearance of `8 * ped_range` it has decayed to `~3.4e-4 *
153/// ped_strength`, which is negligible next to the stride's target
154/// weight. The cutoff adds the free-flow stride length plus a 1 m
155/// buffer so candidate points sampled at stride distance still see
156/// their neighbours inside the grid.
157#[inline]
158pub fn neighbor_cutoff(params: &Params) -> f64 {
159    8.0 * params.ped_range + params.stride_at_free_flow + 1.0
160}
161
162/// Grid-accelerated step variant. Semantically equivalent to [`step`]
163/// up to numerical noise for pairs inside `neighbor_cutoff(params)`.
164#[allow(clippy::needless_range_loop)]
165pub fn step_with_grid(
166    peds: &mut [Pedestrian],
167    walls: &[WallSegment],
168    params: &Params,
169    dt: f64,
170    grid: &NeighborGrid,
171) {
172    let n = peds.len();
173    let cutoff = neighbor_cutoff(params);
174    let mut new_pos = vec![[0.0f64; 2]; n];
175
176    for i in 0..n {
177        let p = &peds[i];
178        let stride = stride_length(p, params, dt);
179        new_pos[i] = best_candidate_grid(i, peds, walls, params, stride, grid, cutoff);
180    }
181
182    for (p, np) in peds.iter_mut().zip(new_pos.iter()) {
183        let delta = sub(*np, p.pos);
184        if dt > 0.0 {
185            p.vel = scale(delta, 1.0 / dt);
186        }
187        p.pos = *np;
188    }
189}
190
191/// Zero-allocation step variant. See
192/// [`social_force::step_scratch`](crate::social_force::step_scratch)
193/// for the motivation.
194#[allow(clippy::needless_range_loop)]
195pub fn step_scratch(
196    peds: &mut [Pedestrian],
197    walls: &[WallSegment],
198    params: &Params,
199    dt: f64,
200    scratch: &mut Scratch,
201) {
202    let n = peds.len();
203    let cutoff = neighbor_cutoff(params);
204    scratch.prepare(peds);
205    let (new_pos, grid) = scratch.split();
206
207    for i in 0..n {
208        let p = &peds[i];
209        let stride = stride_length(p, params, dt);
210        new_pos[i] = best_candidate_grid(i, peds, walls, params, stride, grid, cutoff);
211    }
212
213    for (p, np) in peds.iter_mut().zip(new_pos.iter()) {
214        let delta = sub(*np, p.pos);
215        if dt > 0.0 {
216            p.vel = scale(delta, 1.0 / dt);
217        }
218        p.pos = *np;
219    }
220}
221
222/// Rayon-parallel drop-in replacement for [`step_scratch`].
223///
224/// See [`social_force::step_scratch_par`](crate::social_force::step_scratch_par)
225/// for the contract. Each worker writes a distinct `new_pos[i]`
226/// from an immutable view of `peds`. Enable the `rayon` feature to
227/// use this entry point.
228#[cfg(feature = "rayon")]
229#[allow(clippy::needless_range_loop)]
230pub fn step_scratch_par(
231    peds: &mut [Pedestrian],
232    walls: &[WallSegment],
233    params: &Params,
234    dt: f64,
235    scratch: &mut Scratch,
236) {
237    use rayon::prelude::*;
238
239    let cutoff = neighbor_cutoff(params);
240    scratch.prepare(peds);
241    let (new_pos, grid) = scratch.split();
242    let peds_ro: &[Pedestrian] = peds;
243
244    new_pos.par_iter_mut().enumerate().for_each(|(i, p_slot)| {
245        let p = &peds_ro[i];
246        let stride = stride_length(p, params, dt);
247        *p_slot = best_candidate_grid(i, peds_ro, walls, params, stride, grid, cutoff);
248    });
249
250    for (p, np) in peds.iter_mut().zip(new_pos.iter()) {
251        let delta = sub(*np, p.pos);
252        if dt > 0.0 {
253            p.vel = scale(delta, 1.0 / dt);
254        }
255        p.pos = *np;
256    }
257}
258
259/// Grid-backed version of [`utility`].
260fn utility_grid(
261    candidate: Vec2,
262    self_idx: usize,
263    peds: &[Pedestrian],
264    walls: &[WallSegment],
265    params: &Params,
266    grid: &NeighborGrid,
267    cutoff: f64,
268) -> f64 {
269    let p = &peds[self_idx];
270    let to_target = norm(sub(p.destination, candidate));
271    let mut score = params.target_weight * to_target;
272
273    grid.for_each_neighbor(self_idx, cutoff, peds, |_j, q| {
274        let d = norm(sub(candidate, q.pos));
275        let clearance = (d - (p.radius + q.radius)).max(0.0);
276        score += params.ped_strength * (-clearance / params.ped_range).exp();
277    });
278
279    for w in walls {
280        let closest = closest_point_on_segment(candidate, w.a, w.b);
281        let d = norm(sub(candidate, closest));
282        let clearance = (d - p.radius).max(0.0);
283        score += params.wall_strength * (-clearance / params.wall_range).exp();
284    }
285
286    score
287}
288
289/// Grid-backed version of [`best_candidate`].
290fn best_candidate_grid(
291    self_idx: usize,
292    peds: &[Pedestrian],
293    walls: &[WallSegment],
294    params: &Params,
295    stride: f64,
296    grid: &NeighborGrid,
297    cutoff: f64,
298) -> Vec2 {
299    let p = &peds[self_idx];
300    let mut best_pos = p.pos;
301    let mut best_score = utility_grid(p.pos, self_idx, peds, walls, params, grid, cutoff);
302
303    let e_dest = p.desired_direction();
304    let cos_cutoff = params.cone_half_angle.cos();
305    let dest_present = e_dest != [0.0, 0.0];
306
307    for offset in candidate_offsets(stride, params.num_candidates) {
308        if dest_present {
309            let dir = normalize(offset);
310            if dir[0] * e_dest[0] + dir[1] * e_dest[1] < cos_cutoff {
311                continue;
312            }
313        }
314        let candidate = add(p.pos, offset);
315        let score = utility_grid(candidate, self_idx, peds, walls, params, grid, cutoff);
316        if score < best_score {
317            best_score = score;
318            best_pos = candidate;
319        }
320    }
321
322    best_pos
323}
324
325/// Effective stride length for pedestrian `p` over one `dt`-second step.
326#[inline]
327pub fn stride_length(p: &Pedestrian, params: &Params, dt: f64) -> f64 {
328    // Stride scales with actual intent speed; default to the
329    // arrival-tapered desired speed if the pedestrian is stationary
330    // (e.g. first tick). Inside the arrival radius this shrinks
331    // smoothly toward zero so agents do not overshoot their goal.
332    let tapered = p.effective_desired_speed(params.arrival_radius);
333    let intended = tapered.max(p.speed());
334    let stride_free = params.stride_at_free_flow.max(1e-3);
335    let stride = stride_free * (intended / p.desired_speed.max(1e-6));
336    // Clamp stride so a huge `dt` does not let the pedestrian teleport.
337    stride.min(intended * dt)
338}
339
340/// Deterministic polar sampling of candidate offsets around the origin on a
341/// circle of the given `stride` radius.
342pub fn candidate_offsets(stride: f64, num: usize) -> Vec<Vec2> {
343    let mut out = Vec::with_capacity(num);
344    if num == 0 || stride <= 0.0 {
345        return out;
346    }
347    let two_pi = std::f64::consts::TAU;
348    for k in 0..num {
349        let theta = (k as f64) * two_pi / (num as f64);
350        out.push([stride * theta.cos(), stride * theta.sin()]);
351    }
352    out
353}
354
355/// Utility score for a candidate point (lower is better).
356pub fn utility(
357    candidate: Vec2,
358    self_idx: usize,
359    peds: &[Pedestrian],
360    walls: &[WallSegment],
361    params: &Params,
362) -> f64 {
363    let p = &peds[self_idx];
364    let to_target = norm(sub(p.destination, candidate));
365    let mut score = params.target_weight * to_target;
366
367    for (j, q) in peds.iter().enumerate() {
368        if j == self_idx {
369            continue;
370        }
371        let d = norm(sub(candidate, q.pos));
372        let clearance = (d - (p.radius + q.radius)).max(0.0);
373        score += params.ped_strength * (-clearance / params.ped_range).exp();
374    }
375
376    for w in walls {
377        let closest = closest_point_on_segment(candidate, w.a, w.b);
378        let d = norm(sub(candidate, closest));
379        let clearance = (d - p.radius).max(0.0);
380        score += params.wall_strength * (-clearance / params.wall_range).exp();
381    }
382
383    score
384}
385
386/// Returns the best candidate position for pedestrian `self_idx`.
387pub fn best_candidate(
388    self_idx: usize,
389    peds: &[Pedestrian],
390    walls: &[WallSegment],
391    params: &Params,
392    stride: f64,
393) -> Vec2 {
394    let p = &peds[self_idx];
395    // Always evaluate the "stand still" candidate so that a pedestrian at
396    // its destination does not wander off the discrete ring.
397    let mut best_pos = p.pos;
398    let mut best_score = utility(p.pos, self_idx, peds, walls, params);
399
400    let e_dest = p.desired_direction();
401    let cos_cutoff = params.cone_half_angle.cos();
402    let dest_present = e_dest != [0.0, 0.0];
403
404    for offset in candidate_offsets(stride, params.num_candidates) {
405        // Restrict to forward cone toward the goal when a goal exists.
406        if dest_present {
407            let dir = normalize(offset);
408            if dir[0] * e_dest[0] + dir[1] * e_dest[1] < cos_cutoff {
409                continue;
410            }
411        }
412        let candidate = add(p.pos, offset);
413        let score = utility(candidate, self_idx, peds, walls, params);
414        if score < best_score {
415            best_score = score;
416            best_pos = candidate;
417        }
418    }
419
420    best_pos
421}
422
423#[cfg(test)]
424#[allow(deprecated)] // intentional: pins grid/scratch equivalence vs the deprecated O(n²) `step`.
425mod tests {
426    use super::*;
427
428    #[test]
429    fn single_agent_advances_toward_goal() {
430        let mut peds = vec![Pedestrian {
431            pos: [0.0, 0.0],
432            vel: [0.0, 0.0],
433            radius: 0.2,
434            desired_speed: 1.34,
435            destination: [10.0, 0.0],
436        }];
437        let start = peds[0].pos[0];
438        for _ in 0..40 {
439            step(&mut peds, &[], &Params::default(), 0.4);
440        }
441        assert!(peds[0].pos[0] > start + 1.0);
442        assert!(peds[0].pos[1].abs() < 0.2);
443    }
444
445    #[test]
446    fn wall_repels_in_utility() {
447        let peds = vec![Pedestrian {
448            pos: [0.0, 0.0],
449            vel: [0.0, 0.0],
450            radius: 0.2,
451            desired_speed: 1.0,
452            destination: [5.0, 0.0],
453        }];
454        let walls = vec![WallSegment {
455            a: [1.0, -0.1],
456            b: [1.0, 0.1],
457        }];
458        let params = Params::default();
459        let near = utility([0.9, 0.0], 0, &peds, &walls, &params);
460        let far = utility([0.0, 0.0], 0, &peds, &walls, &params);
461        assert!(near > far, "wall penalty should make near-wall worse");
462    }
463
464    #[test]
465    fn stationary_agent_at_destination_does_not_drift() {
466        let mut peds = vec![Pedestrian {
467            pos: [1.0, 1.0],
468            vel: [0.0, 0.0],
469            radius: 0.2,
470            desired_speed: 1.0,
471            destination: [1.0, 1.0],
472        }];
473        for _ in 0..10 {
474            step(&mut peds, &[], &Params::default(), 0.4);
475        }
476        let d = norm(sub(peds[0].pos, [1.0, 1.0]));
477        assert!(d < 0.05);
478    }
479}