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}