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`].
50/// [`Simulation::set_reposition`](crate::sim::Simulation::set_reposition)
51/// auto-widens this to the installed strategy's
52/// [`min_arrival_log_window`](crate::dispatch::RepositionStrategy::min_arrival_log_window)
53/// so e.g. `PredictiveParking::with_window_ticks(50_000)` keeps
54/// `50_000` ticks of arrivals retained without a separate setter call.
55/// Override manually via
56/// [`Simulation::set_arrival_log_retention_ticks`](crate::sim::Simulation::set_arrival_log_retention_ticks)
57/// when retention should differ from any strategy's window (e.g. tests
58/// or custom consumers reading the log directly).
59#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
60pub struct ArrivalLogRetention(pub u64);
61
62impl Default for ArrivalLogRetention {
63    fn default() -> Self {
64        Self(DEFAULT_ARRIVAL_WINDOW_TICKS)
65    }
66}
67
68/// Append-only log of per-stop arrival events used to compute rolling
69/// arrival-rate signals.
70///
71/// Stored as a `Vec<(tick, stop)>` sorted by tick (records are appended
72/// in tick order during normal sim execution). Queries are `O(n)` worst
73/// case but typically `O(window_size)`; `prune_before` keeps the tail
74/// short enough that this is a non-issue for practical window sizes.
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct ArrivalLog {
77    /// `(tick, stop)` pairs. Entries are appended in tick order.
78    entries: Vec<(u64, EntityId)>,
79}
80
81impl ArrivalLog {
82    /// Record an arrival at `stop` on `tick`.
83    pub fn record(&mut self, tick: u64, stop: EntityId) {
84        self.entries.push((tick, stop));
85    }
86
87    /// Count arrivals at `stop` within the window `[now - window, now]`
88    /// inclusive. `window_ticks = 0` always returns 0.
89    #[must_use]
90    pub fn arrivals_in_window(&self, stop: EntityId, now: u64, window_ticks: u64) -> u64 {
91        if window_ticks == 0 {
92            return 0;
93        }
94        let lower = now.saturating_sub(window_ticks);
95        self.entries
96            .iter()
97            .filter(|(t, s)| *s == stop && *t >= lower && *t <= now)
98            .count() as u64
99    }
100
101    /// Drop every entry with tick strictly before `cutoff`. Called each
102    /// tick by the sim with `cutoff = current_tick - max_window` so the
103    /// log can't grow without bound.
104    pub fn prune_before(&mut self, cutoff: u64) {
105        self.entries.retain(|(t, _)| *t >= cutoff);
106    }
107
108    /// Number of recorded events currently in the log. Intended for
109    /// snapshot inspection and tests.
110    #[must_use]
111    pub const fn len(&self) -> usize {
112        self.entries.len()
113    }
114
115    /// Whether the log has no recorded events.
116    #[must_use]
117    pub const fn is_empty(&self) -> bool {
118        self.entries.is_empty()
119    }
120
121    /// Rewrite every entry's stop `EntityId` through `id_remap`, dropping
122    /// entries whose stop isn't present in the map. Used by snapshot
123    /// restore to translate pre-restore stop IDs to the new allocations.
124    pub fn remap_entity_ids(&mut self, id_remap: &std::collections::HashMap<EntityId, EntityId>) {
125        self.entries
126            .retain_mut(|(_, stop)| match id_remap.get(stop) {
127                Some(&new) => {
128                    *stop = new;
129                    true
130                }
131                None => false,
132            });
133    }
134}
135
136/// Append-only log of rider *destinations*, mirror of [`ArrivalLog`]
137/// for the outgoing side of a trip.
138///
139/// Enables destination-aware signals that the origin-only
140/// `ArrivalLog` can't produce — specifically
141/// [`TrafficMode::DownPeak`](crate::traffic_detector::TrafficMode::DownPeak)
142/// detection, which triggers on "lots of riders heading *to* the
143/// lobby" rather than "lots of riders arriving *from* it."
144///
145/// Auto-installed alongside [`ArrivalLog`] by `Simulation::new` and
146/// appended to in the same rider-spawn path. Shares
147/// [`ArrivalLogRetention`]'s retention window so the two logs can't
148/// drift against each other's time horizon.
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
150pub struct DestinationLog {
151    /// `(tick, destination_stop)` pairs. Entries are appended in
152    /// tick order; all queries go through
153    /// [`destinations_in_window`](Self::destinations_in_window).
154    entries: Vec<(u64, EntityId)>,
155}
156
157impl DestinationLog {
158    /// Record that a rider spawned at `tick` heading to `destination`.
159    pub fn record(&mut self, tick: u64, destination: EntityId) {
160        self.entries.push((tick, destination));
161    }
162
163    /// Count rides to `stop` within the window `[now - window, now]`
164    /// inclusive. `window_ticks = 0` always returns 0.
165    #[must_use]
166    pub fn destinations_in_window(&self, stop: EntityId, now: u64, window_ticks: u64) -> u64 {
167        if window_ticks == 0 {
168            return 0;
169        }
170        let lower = now.saturating_sub(window_ticks);
171        self.entries
172            .iter()
173            .filter(|(t, s)| *s == stop && *t >= lower && *t <= now)
174            .count() as u64
175    }
176
177    /// Prune entries older than `cutoff` ticks. Called from
178    /// `Simulation::advance_tick` alongside [`ArrivalLog::prune_before`].
179    pub fn prune_before(&mut self, cutoff: u64) {
180        self.entries.retain(|(t, _)| *t >= cutoff);
181    }
182
183    /// Number of recorded events (diagnostic / tests).
184    #[must_use]
185    pub const fn len(&self) -> usize {
186        self.entries.len()
187    }
188
189    /// Whether the log has no recorded events.
190    #[must_use]
191    pub const fn is_empty(&self) -> bool {
192        self.entries.is_empty()
193    }
194
195    /// Remap entity IDs for snapshot restore. Mirrors
196    /// [`ArrivalLog::remap_entity_ids`].
197    pub fn remap_entity_ids(&mut self, id_remap: &std::collections::HashMap<EntityId, EntityId>) {
198        self.entries
199            .retain_mut(|(_, stop)| match id_remap.get(stop) {
200                Some(&new) => {
201                    *stop = new;
202                    true
203                }
204                None => false,
205            });
206    }
207}