Skip to main content

elevator_core/
arrival_log.rs

1//! Rolling per-stop arrival log.
2//!
3//! Carries a `CurrentTick` sibling resource that mirrors
4//! [`Simulation::current_tick`](crate::sim::Simulation::current_tick)
5//! so strategies reading the log from phases without direct access to
6//! `PhaseContext` (e.g. [`RepositionStrategy`](crate::dispatch::RepositionStrategy))
7//! can still compute windowed queries.
8//!
9//!
10//! Commercial group controllers sample per-stop arrival rates to pick
11//! traffic modes (up-peak, down-peak) and to pre-position idle cars
12//! ahead of expected demand (Otis Compass Infinity's *predictive
13//! parking*, KONE Polaris's pattern-driven mode switch). This log is
14//! the signal source: dispatch strategies read it via
15//! [`DispatchManifest::arrivals_at`](crate::dispatch::DispatchManifest::arrivals_at),
16//! reposition strategies read it directly from `World` resources.
17//!
18//! The log is append-only during a tick and pruned at the start of each
19//! tick to keep memory bounded under long runs. Stored entries are
20//! `(tick, stop)` pairs; queries are by stop and time window only.
21
22use crate::entity::EntityId;
23use serde::{Deserialize, Serialize};
24
25/// World resource mirroring the current simulation tick.
26///
27/// Kept in sync by [`Simulation::step`](crate::sim::Simulation::step).
28/// Lets phases that don't receive a `PhaseContext` (reposition
29/// strategies, custom `World` consumers) compute rolling-window queries
30/// against [`ArrivalLog`] without plumbing tick through their
31/// signatures.
32#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
33pub struct CurrentTick(
34    /// Tick value at the start of the last `step()` entry.
35    pub u64,
36);
37
38/// Default rolling window (in ticks).
39///
40/// Used by [`DispatchManifest::arrivals_at`](crate::dispatch::DispatchManifest::arrivals_at)
41/// when the sim doesn't override it. Five minutes of real time at the
42/// default 60 Hz tick rate, matching the window commercial controllers
43/// use to detect up-peak / down-peak transitions.
44pub const DEFAULT_ARRIVAL_WINDOW_TICKS: u64 = 18_000;
45
46/// World resource controlling how far back the [`ArrivalLog`] retains
47/// entries before `Simulation::advance_tick` prunes them.
48///
49/// Defaults to [`DEFAULT_ARRIVAL_WINDOW_TICKS`]. Strategies that query
50/// a longer window (e.g.
51/// [`PredictiveParking::with_window_ticks`](crate::dispatch::reposition::PredictiveParking::with_window_ticks)
52/// with a value greater than the default) will silently see only the
53/// last `DEFAULT_ARRIVAL_WINDOW_TICKS` unless this retention is
54/// widened via [`Simulation::set_arrival_log_retention_ticks`](crate::sim::Simulation::set_arrival_log_retention_ticks).
55#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
56pub struct ArrivalLogRetention(pub u64);
57
58impl Default for ArrivalLogRetention {
59    fn default() -> Self {
60        Self(DEFAULT_ARRIVAL_WINDOW_TICKS)
61    }
62}
63
64/// Append-only log of per-stop arrival events used to compute rolling
65/// arrival-rate signals.
66///
67/// Stored as a `Vec<(tick, stop)>` sorted by tick (records are appended
68/// in tick order during normal sim execution). Queries are `O(n)` worst
69/// case but typically `O(window_size)`; `prune_before` keeps the tail
70/// short enough that this is a non-issue for practical window sizes.
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
72pub struct ArrivalLog {
73    /// `(tick, stop)` pairs. Entries are appended in tick order.
74    entries: Vec<(u64, EntityId)>,
75}
76
77impl ArrivalLog {
78    /// Record an arrival at `stop` on `tick`.
79    pub fn record(&mut self, tick: u64, stop: EntityId) {
80        self.entries.push((tick, stop));
81    }
82
83    /// Count arrivals at `stop` within the window `[now - window, now]`
84    /// inclusive. `window_ticks = 0` always returns 0.
85    #[must_use]
86    pub fn arrivals_in_window(&self, stop: EntityId, now: u64, window_ticks: u64) -> u64 {
87        if window_ticks == 0 {
88            return 0;
89        }
90        let lower = now.saturating_sub(window_ticks);
91        self.entries
92            .iter()
93            .filter(|(t, s)| *s == stop && *t >= lower && *t <= now)
94            .count() as u64
95    }
96
97    /// Drop every entry with tick strictly before `cutoff`. Called each
98    /// tick by the sim with `cutoff = current_tick - max_window` so the
99    /// log can't grow without bound.
100    pub fn prune_before(&mut self, cutoff: u64) {
101        self.entries.retain(|(t, _)| *t >= cutoff);
102    }
103
104    /// Number of recorded events currently in the log. Intended for
105    /// snapshot inspection and tests.
106    #[must_use]
107    pub const fn len(&self) -> usize {
108        self.entries.len()
109    }
110
111    /// Whether the log has no recorded events.
112    #[must_use]
113    pub const fn is_empty(&self) -> bool {
114        self.entries.is_empty()
115    }
116
117    /// Rewrite every entry's stop `EntityId` through `id_remap`, dropping
118    /// entries whose stop isn't present in the map. Used by snapshot
119    /// restore to translate pre-restore stop IDs to the new allocations.
120    pub fn remap_entity_ids(&mut self, id_remap: &std::collections::HashMap<EntityId, EntityId>) {
121        self.entries
122            .retain_mut(|(_, stop)| match id_remap.get(stop) {
123                Some(&new) => {
124                    *stop = new;
125                    true
126                }
127                None => false,
128            });
129    }
130}
131
132/// Append-only log of rider *destinations*, mirror of [`ArrivalLog`]
133/// for the outgoing side of a trip.
134///
135/// Enables destination-aware signals that the origin-only
136/// `ArrivalLog` can't produce — specifically
137/// [`TrafficMode::DownPeak`](crate::traffic_detector::TrafficMode::DownPeak)
138/// detection, which triggers on "lots of riders heading *to* the
139/// lobby" rather than "lots of riders arriving *from* it."
140///
141/// Auto-installed alongside [`ArrivalLog`] by `Simulation::new` and
142/// appended to in the same rider-spawn path. Shares
143/// [`ArrivalLogRetention`]'s retention window so the two logs can't
144/// drift against each other's time horizon.
145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146pub struct DestinationLog {
147    /// `(tick, destination_stop)` pairs. Entries are appended in
148    /// tick order; all queries go through
149    /// [`destinations_in_window`](Self::destinations_in_window).
150    entries: Vec<(u64, EntityId)>,
151}
152
153impl DestinationLog {
154    /// Record that a rider spawned at `tick` heading to `destination`.
155    pub fn record(&mut self, tick: u64, destination: EntityId) {
156        self.entries.push((tick, destination));
157    }
158
159    /// Count rides to `stop` within the window `[now - window, now]`
160    /// inclusive. `window_ticks = 0` always returns 0.
161    #[must_use]
162    pub fn destinations_in_window(&self, stop: EntityId, now: u64, window_ticks: u64) -> u64 {
163        if window_ticks == 0 {
164            return 0;
165        }
166        let lower = now.saturating_sub(window_ticks);
167        self.entries
168            .iter()
169            .filter(|(t, s)| *s == stop && *t >= lower && *t <= now)
170            .count() as u64
171    }
172
173    /// Prune entries older than `cutoff` ticks. Called from
174    /// `Simulation::advance_tick` alongside [`ArrivalLog::prune_before`].
175    pub fn prune_before(&mut self, cutoff: u64) {
176        self.entries.retain(|(t, _)| *t >= cutoff);
177    }
178
179    /// Number of recorded events (diagnostic / tests).
180    #[must_use]
181    pub const fn len(&self) -> usize {
182        self.entries.len()
183    }
184
185    /// Whether the log has no recorded events.
186    #[must_use]
187    pub const fn is_empty(&self) -> bool {
188        self.entries.is_empty()
189    }
190
191    /// Remap entity IDs for snapshot restore. Mirrors
192    /// [`ArrivalLog::remap_entity_ids`].
193    pub fn remap_entity_ids(&mut self, id_remap: &std::collections::HashMap<EntityId, EntityId>) {
194        self.entries
195            .retain_mut(|(_, stop)| match id_remap.get(stop) {
196                Some(&new) => {
197                    *stop = new;
198                    true
199                }
200                None => false,
201            });
202    }
203}