elevator_core/traffic_detector.rs
1//! Traffic-mode detector.
2//!
3//! Classifies the current simulation moment into one of a small set
4//! of `TrafficMode` variants by reading the
5//! [`ArrivalLog`](crate::arrival_log::ArrivalLog)'s rolling window.
6//! Consumers — dispatch tuning, adaptive reposition, HUD narration —
7//! read [`crate::traffic_detector::TrafficDetector::current_mode`]
8//! each tick to get a cheap, pre-computed answer instead of
9//! re-deriving it themselves.
10//!
11//! V1 implements the hard-rule classifier from Siikonen's
12//! fuzzy-labelled traffic patterns (the fuzzy membership math reduces
13//! to the same threshold crossings once defuzzified): up-peak is
14//! triggered when the lobby-origin fraction crosses a threshold over
15//! a rolling window; idle when the total arrival rate is below a
16//! noise floor; everything else is inter-floor. Down-peak detection
17//! needs a destination signal (rides heading *to* the lobby, not
18//! arrivals *from* it) which the base
19//! [`ArrivalLog`](crate::arrival_log::ArrivalLog) doesn't carry;
20//! [`crate::traffic_detector::TrafficMode::DownPeak`] is in the enum
21//! for API stability and will flip on in a follow-up.
22
23use crate::arrival_log::{ArrivalLog, DestinationLog};
24use crate::entity::EntityId;
25use serde::{Deserialize, Serialize};
26
27/// Detected traffic mode. Consumers read this via
28/// [`TrafficDetector::current_mode`] each tick.
29///
30/// `#[non_exhaustive]` so adding variants (`DownPeak`, `Lunch`) in
31/// follow-up PRs is a minor-version bump, not a break.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
33#[non_exhaustive]
34pub enum TrafficMode {
35 /// Total arrival rate is below the detector's `idle_rate_threshold`.
36 /// Reposition strategies should stay put; dispatch can drop
37 /// starvation-avoidance bonuses.
38 #[default]
39 Idle,
40 /// Lobby-origin fraction is above the detector's `up_peak_fraction`.
41 /// Classic morning rush — reposition to lobby, prefer faster
42 /// lobby↔upper cycles.
43 UpPeak,
44 /// Arrivals are distributed across stops without a strong lobby
45 /// skew. Default non-rush state.
46 InterFloor,
47 /// Reserved for a future destination-aware classifier (rides to
48 /// the lobby dominate). Never emitted by the V1 implementation;
49 /// present in the enum so downstream match arms can compile once
50 /// the down-peak signal lands.
51 DownPeak,
52}
53
54/// Rolling-window classifier for the sim's current traffic pattern.
55///
56/// Stored as a world resource under `World::resource::<TrafficDetector>()`
57/// and auto-refreshed each tick in the metrics phase. Manual
58/// reconstruction is supported via the `with_*` builders for tests
59/// that bypass `Simulation`.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct TrafficDetector {
62 /// Window over which arrivals are counted (ticks).
63 window_ticks: u64,
64 /// Minimum per-tick arrival rate to leave [`TrafficMode::Idle`].
65 /// `arrivals_total / window_ticks` must exceed this.
66 idle_rate_threshold: f64,
67 /// Lobby-origin fraction at or above which we flip to
68 /// [`TrafficMode::UpPeak`]. 0.6 matches the Siikonen-Aalto
69 /// threshold for "clear up-peak" from the rolling-average
70 /// classifier lineage.
71 up_peak_fraction: f64,
72 /// Lobby-destination fraction at or above which we flip to
73 /// [`TrafficMode::DownPeak`]. Uses the same 0.6 default as
74 /// `up_peak_fraction` — the two peaks are mirror statistical
75 /// events and the clarity-threshold literature treats them
76 /// symmetrically.
77 down_peak_fraction: f64,
78 /// Last classified mode; returned by [`current_mode`](Self::current_mode).
79 current: TrafficMode,
80 /// Tick of the most recent `update` call. Read by snapshot
81 /// inspectors; not used for staleness (the metrics phase always
82 /// refreshes before consumers read).
83 last_update_tick: u64,
84}
85
86impl Default for TrafficDetector {
87 fn default() -> Self {
88 Self {
89 window_ticks: crate::arrival_log::DEFAULT_ARRIVAL_WINDOW_TICKS,
90 // Two arrivals per minute at 60 Hz = ~0.000555 per tick.
91 // Below that the sim is effectively idle from a traffic
92 // perspective — empty overnight or cold-start scenarios.
93 idle_rate_threshold: 2.0 / 3600.0,
94 up_peak_fraction: 0.6,
95 down_peak_fraction: 0.6,
96 current: TrafficMode::Idle,
97 last_update_tick: 0,
98 }
99 }
100}
101
102impl TrafficDetector {
103 /// Create with default thresholds.
104 #[must_use]
105 pub fn new() -> Self {
106 Self::default()
107 }
108
109 /// Override the rolling-window size (ticks).
110 ///
111 /// # Panics
112 /// Panics on `window_ticks = 0` — the classifier would divide by
113 /// zero when computing the rate.
114 #[must_use]
115 pub const fn with_window_ticks(mut self, window_ticks: u64) -> Self {
116 assert!(
117 window_ticks > 0,
118 "TrafficDetector::with_window_ticks requires a positive window"
119 );
120 self.window_ticks = window_ticks;
121 self
122 }
123
124 /// Override the idle-rate threshold (arrivals per tick).
125 ///
126 /// # Panics
127 /// Panics on non-finite or negative values.
128 #[must_use]
129 pub fn with_idle_rate_threshold(mut self, rate: f64) -> Self {
130 assert!(
131 rate.is_finite() && rate >= 0.0,
132 "idle_rate_threshold must be finite and non-negative, got {rate}"
133 );
134 self.idle_rate_threshold = rate;
135 self
136 }
137
138 /// Override the lobby-origin fraction that trips up-peak.
139 ///
140 /// # Panics
141 /// Panics if `fraction` is NaN or outside `[0.0, 1.0]`.
142 #[must_use]
143 pub fn with_up_peak_fraction(mut self, fraction: f64) -> Self {
144 assert!(
145 fraction.is_finite() && (0.0..=1.0).contains(&fraction),
146 "up_peak_fraction must be finite and in [0, 1], got {fraction}"
147 );
148 self.up_peak_fraction = fraction;
149 self
150 }
151
152 /// Override the lobby-destination fraction that trips
153 /// down-peak.
154 ///
155 /// # Panics
156 /// Panics if `fraction` is NaN or outside `[0.0, 1.0]`.
157 #[must_use]
158 pub fn with_down_peak_fraction(mut self, fraction: f64) -> Self {
159 assert!(
160 fraction.is_finite() && (0.0..=1.0).contains(&fraction),
161 "down_peak_fraction must be finite and in [0, 1], got {fraction}"
162 );
163 self.down_peak_fraction = fraction;
164 self
165 }
166
167 /// The most recently classified mode.
168 #[must_use]
169 pub const fn current_mode(&self) -> TrafficMode {
170 self.current
171 }
172
173 /// Tick of the last `update` call (diagnostic only).
174 #[must_use]
175 pub const fn last_update_tick(&self) -> u64 {
176 self.last_update_tick
177 }
178
179 /// Rolling-window size (ticks).
180 #[must_use]
181 pub const fn window_ticks(&self) -> u64 {
182 self.window_ticks
183 }
184
185 /// Re-classify using arrivals and destinations as of tick
186 /// `now`. `stops` is the list of stop entities to aggregate
187 /// over; the *first* entry is treated as the lobby for both
188 /// up-peak and down-peak classification, so callers must pass
189 /// them in position order (lobby first). `destinations` may be
190 /// an empty (default-constructed) `DestinationLog` — in that
191 /// case down-peak detection silently no-ops and only the
192 /// origin-driven modes fire.
193 ///
194 /// Precedence when both peaks meet their thresholds (rare —
195 /// lobby-origins and lobby-destinations can't both be ≥60% in a
196 /// 100-arrival window without overlap): `UpPeak` wins because
197 /// the origin signal arrives first in time (rider spawn is what
198 /// logs the arrival; the destination signal arrives at the same
199 /// instant but the dispatcher sees up-peak's lobby queue before
200 /// down-peak's upper-floor queue absorbs the car).
201 ///
202 /// Idempotent — calling twice with the same inputs yields the
203 /// same mode. Called once per tick by the metrics phase;
204 /// callers driving the sim manually can invoke it directly.
205 pub fn update(
206 &mut self,
207 arrivals: &ArrivalLog,
208 destinations: &DestinationLog,
209 now: u64,
210 stops: &[EntityId],
211 ) {
212 self.last_update_tick = now;
213 if stops.is_empty() || self.window_ticks == 0 {
214 self.current = TrafficMode::Idle;
215 return;
216 }
217 let lobby = stops[0];
218 let lobby_origin_count = arrivals.arrivals_in_window(lobby, now, self.window_ticks);
219 let lobby_dest_count = destinations.destinations_in_window(lobby, now, self.window_ticks);
220 let mut total_origin: u64 = 0;
221 let mut total_dest: u64 = 0;
222 for &s in stops {
223 total_origin =
224 total_origin.saturating_add(arrivals.arrivals_in_window(s, now, self.window_ticks));
225 total_dest = total_dest.saturating_add(destinations.destinations_in_window(
226 s,
227 now,
228 self.window_ticks,
229 ));
230 }
231 // An empty arrivals window is always `Idle`, independent of
232 // the configured threshold. Guards the `idle_rate_threshold
233 // = 0.0` edge case where the strict `<` comparison below
234 // wouldn't catch `total == 0`.
235 if total_origin == 0 {
236 self.current = TrafficMode::Idle;
237 return;
238 }
239 #[allow(clippy::cast_precision_loss)] // counts fit in f64 mantissa
240 let rate_per_tick = total_origin as f64 / self.window_ticks as f64;
241 if rate_per_tick < self.idle_rate_threshold {
242 self.current = TrafficMode::Idle;
243 return;
244 }
245 #[allow(clippy::cast_precision_loss)]
246 let up_fraction = lobby_origin_count as f64 / total_origin as f64;
247 if up_fraction >= self.up_peak_fraction {
248 self.current = TrafficMode::UpPeak;
249 return;
250 }
251 // Down-peak: check only if the destination log has seen
252 // any activity; a zero-destination window (old snapshot or
253 // pure rider-less hall calls) would divide by zero and
254 // spuriously match the threshold at `0/0 = NaN` — falling
255 // through to `InterFloor` is the safer default.
256 if total_dest > 0 {
257 #[allow(clippy::cast_precision_loss)]
258 let down_fraction = lobby_dest_count as f64 / total_dest as f64;
259 if down_fraction >= self.down_peak_fraction {
260 self.current = TrafficMode::DownPeak;
261 return;
262 }
263 }
264 self.current = TrafficMode::InterFloor;
265 }
266}