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}