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}