Skip to main content

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}