Skip to main content

elevator_core/
traffic.rs

1//! Traffic generation for rider arrivals.
2//!
3//! This module provides:
4//!
5//! - [`TrafficPattern`] — origin/destination distribution presets (up-peak, down-peak, etc.).
6//! - [`TrafficSchedule`] — time-varying pattern selection across a simulated day.
7//! - [`TrafficSource`] — trait for external traffic generators that feed riders into
8//!   a [`Simulation`](crate::sim::Simulation) each tick.
9//! - [`PoissonSource`] — Poisson-arrival traffic generator using schedules and spawn config.
10//! - [`SpawnRequest`] — a single rider spawn instruction returned by a traffic source.
11//!
12//! # Design
13//!
14//! Traffic generation is **external to the simulation loop**. A [`TrafficSource`]
15//! produces [`SpawnRequest`]s each tick; the consumer feeds them into
16//! [`Simulation::spawn_rider_by_stop_id`](crate::sim::Simulation::spawn_rider_by_stop_id)
17//! (or the [`RiderBuilder`](crate::sim::RiderBuilder) for richer configuration).
18//!
19//! ```rust,ignore
20//! use elevator_core::prelude::*;
21//! use elevator_core::traffic::{PoissonSource, SpawnRequest};
22//!
23//! let config: SimConfig = /* load from RON */;
24//! let mut sim = SimulationBuilder::from_config(&config).build().unwrap();
25//! let mut source = PoissonSource::from_config(&config);
26//!
27//! for _ in 0..10_000 {
28//!     let tick = sim.current_tick();
29//!     for req in source.generate(tick) {
30//!         let _ = sim.spawn_rider_by_stop_id(req.origin, req.destination, req.weight);
31//!     }
32//!     sim.step();
33//! }
34//! ```
35
36use crate::config::SimConfig;
37use crate::entity::EntityId;
38use crate::stop::StopId;
39use rand::Rng;
40use serde::{Deserialize, Serialize};
41
42// ── TrafficPattern ───────────────────────────────────────────────────
43
44/// Traffic pattern for generating realistic rider origin/destination distributions.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[non_exhaustive]
47pub enum TrafficPattern {
48    /// Uniform random: equal probability for all origin/destination pairs.
49    Uniform,
50    /// Morning rush: most riders originate from the lobby (first stop) going up.
51    UpPeak,
52    /// Evening rush: most riders head to the lobby (first stop) from upper stops.
53    DownPeak,
54    /// Lunch rush: riders go from upper stops to a mid-range stop and back.
55    Lunchtime,
56    /// Mixed: combination of up-peak, down-peak, and inter-floor traffic.
57    Mixed,
58}
59
60/// Sample an (origin, destination) index pair from `n` stops.
61///
62/// Returns indices into the stops slice. All pattern logic lives here;
63/// public methods just map indices to their concrete ID types.
64fn sample_indices(pattern: TrafficPattern, n: usize, rng: &mut impl Rng) -> Option<(usize, usize)> {
65    if n < 2 {
66        return None;
67    }
68
69    let lobby = 0;
70    let mid = n / 2;
71
72    match pattern {
73        TrafficPattern::Uniform => Some(uniform_pair_indices(n, rng)),
74
75        TrafficPattern::UpPeak => {
76            // 80% from lobby, 20% inter-floor.
77            if rng.random_range(0.0..1.0) < 0.8 {
78                Some((lobby, rng.random_range(1..n)))
79            } else {
80                Some(uniform_pair_indices(n, rng))
81            }
82        }
83
84        TrafficPattern::DownPeak => {
85            // 80% heading to lobby, 20% inter-floor.
86            if rng.random_range(0.0..1.0) < 0.8 {
87                Some((rng.random_range(1..n), lobby))
88            } else {
89                Some(uniform_pair_indices(n, rng))
90            }
91        }
92
93        TrafficPattern::Lunchtime => {
94            // 40% upper→mid, 40% mid→upper, 20% random.
95            let r: f64 = rng.random_range(0.0..1.0);
96            let upper_start = n / 2 + 1;
97            if r < 0.4 && upper_start < n {
98                Some((rng.random_range(upper_start..n), mid))
99            } else if r < 0.8 && upper_start < n {
100                Some((mid, rng.random_range(upper_start..n)))
101            } else {
102                Some(uniform_pair_indices(n, rng))
103            }
104        }
105
106        TrafficPattern::Mixed => {
107            // 30% up-peak, 30% down-peak, 40% inter-floor.
108            let r: f64 = rng.random_range(0.0..1.0);
109            if r < 0.3 {
110                Some((lobby, rng.random_range(1..n)))
111            } else if r < 0.6 {
112                Some((rng.random_range(1..n), lobby))
113            } else {
114                Some(uniform_pair_indices(n, rng))
115            }
116        }
117    }
118}
119
120/// Pick two distinct random indices from `0..n`.
121fn uniform_pair_indices(n: usize, rng: &mut impl Rng) -> (usize, usize) {
122    let o = rng.random_range(0..n);
123    let mut d = rng.random_range(0..n);
124    while d == o {
125        d = rng.random_range(0..n);
126    }
127    (o, d)
128}
129
130impl TrafficPattern {
131    /// Sample an (origin, destination) pair from the given stops.
132    ///
133    /// `stops` must be sorted by position (lowest first). The first stop
134    /// is treated as the "lobby" for peak patterns.
135    ///
136    /// Returns `None` if fewer than 2 stops are provided.
137    pub fn sample(&self, stops: &[EntityId], rng: &mut impl Rng) -> Option<(EntityId, EntityId)> {
138        let (o, d) = sample_indices(*self, stops.len(), rng)?;
139        Some((stops[o], stops[d]))
140    }
141
142    /// Sample an (origin, destination) pair using config [`StopId`]s.
143    ///
144    /// Same as [`sample`](Self::sample) but works with `StopId` slices for
145    /// use outside the simulation (no `EntityId` resolution needed).
146    pub fn sample_stop_ids(
147        &self,
148        stops: &[StopId],
149        rng: &mut impl Rng,
150    ) -> Option<(StopId, StopId)> {
151        let (o, d) = sample_indices(*self, stops.len(), rng)?;
152        Some((stops[o], stops[d]))
153    }
154}
155
156// ── TrafficSchedule ──────────────────────────────────────────────────
157
158/// A time-varying traffic schedule that selects patterns based on tick count.
159///
160/// Maps tick ranges to traffic patterns, enabling realistic daily cycles
161/// (e.g., up-peak in the morning, lunchtime at noon, down-peak in evening).
162///
163/// # Example
164///
165/// ```rust,ignore
166/// use elevator_core::traffic::{TrafficPattern, TrafficSchedule};
167///
168/// let schedule = TrafficSchedule::new(vec![
169///     (0..3600, TrafficPattern::UpPeak),      // First hour: morning rush
170///     (3600..7200, TrafficPattern::Uniform),   // Second hour: normal
171///     (7200..10800, TrafficPattern::Lunchtime), // Third hour: lunch
172///     (10800..14400, TrafficPattern::DownPeak), // Fourth hour: evening rush
173/// ]);
174///
175/// // Sampling uses the pattern active at the given tick
176/// let stops = vec![/* ... */];
177/// let (origin, dest) = schedule.sample(tick, &stops, &mut rng).unwrap();
178/// ```
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct TrafficSchedule {
181    /// Tick ranges mapped to traffic patterns, in order.
182    segments: Vec<(std::ops::Range<u64>, TrafficPattern)>,
183    /// Pattern to use when tick falls outside all segments.
184    fallback: TrafficPattern,
185}
186
187impl TrafficSchedule {
188    /// Create a schedule from segments.
189    ///
190    /// Segments are `(tick_range, pattern)` pairs. If the current tick
191    /// doesn't fall within any segment, the fallback `Uniform` pattern is used.
192    #[must_use]
193    pub const fn new(segments: Vec<(std::ops::Range<u64>, TrafficPattern)>) -> Self {
194        Self {
195            segments,
196            fallback: TrafficPattern::Uniform,
197        }
198    }
199
200    /// Set the fallback pattern for ticks outside all segments.
201    #[must_use]
202    pub const fn with_fallback(mut self, pattern: TrafficPattern) -> Self {
203        self.fallback = pattern;
204        self
205    }
206
207    /// Get the active traffic pattern for the given tick.
208    #[must_use]
209    pub fn pattern_at(&self, tick: u64) -> &TrafficPattern {
210        self.segments
211            .iter()
212            .find(|(range, _)| range.contains(&tick))
213            .map_or(&self.fallback, |(_, pattern)| pattern)
214    }
215
216    /// Sample an (origin, destination) pair using the pattern active at `tick`.
217    ///
218    /// Delegates to [`TrafficPattern::sample()`] for the active pattern.
219    pub fn sample(
220        &self,
221        tick: u64,
222        stops: &[EntityId],
223        rng: &mut impl Rng,
224    ) -> Option<(EntityId, EntityId)> {
225        self.pattern_at(tick).sample(stops, rng)
226    }
227
228    /// Sample an (origin, destination) pair by [`StopId`] using the active pattern.
229    pub fn sample_stop_ids(
230        &self,
231        tick: u64,
232        stops: &[StopId],
233        rng: &mut impl Rng,
234    ) -> Option<(StopId, StopId)> {
235        self.pattern_at(tick).sample_stop_ids(stops, rng)
236    }
237
238    /// Create a typical office-building daily schedule.
239    ///
240    /// Assumes `ticks_per_hour` ticks per real-world hour:
241    /// - Hours 0-1: Up-peak (morning rush)
242    /// - Hours 1-4: Uniform (normal traffic)
243    /// - Hours 4-5: Lunchtime
244    /// - Hours 5-8: Uniform (afternoon)
245    /// - Hours 8-9: Down-peak (evening rush)
246    /// - Hours 9+: Uniform (fallback)
247    #[must_use]
248    pub fn office_day(ticks_per_hour: u64) -> Self {
249        Self::new(vec![
250            (0..ticks_per_hour, TrafficPattern::UpPeak),
251            (ticks_per_hour..4 * ticks_per_hour, TrafficPattern::Uniform),
252            (
253                4 * ticks_per_hour..5 * ticks_per_hour,
254                TrafficPattern::Lunchtime,
255            ),
256            (
257                5 * ticks_per_hour..8 * ticks_per_hour,
258                TrafficPattern::Uniform,
259            ),
260            (
261                8 * ticks_per_hour..9 * ticks_per_hour,
262                TrafficPattern::DownPeak,
263            ),
264        ])
265    }
266
267    /// Create a constant schedule that uses the same pattern for all ticks.
268    #[must_use]
269    pub const fn constant(pattern: TrafficPattern) -> Self {
270        Self {
271            segments: Vec::new(),
272            fallback: pattern,
273        }
274    }
275}
276
277// ── TrafficSource + SpawnRequest ─────────────────────────────────────
278
279/// A request to spawn a single rider, produced by a [`TrafficSource`].
280///
281/// Feed these into [`Simulation::spawn_rider_by_stop_id`](crate::sim::Simulation::spawn_rider_by_stop_id)
282/// or the [`RiderBuilder`](crate::sim::RiderBuilder) each tick.
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284pub struct SpawnRequest {
285    /// Origin stop (config ID).
286    pub origin: StopId,
287    /// Destination stop (config ID).
288    pub destination: StopId,
289    /// Rider weight.
290    pub weight: f64,
291}
292
293/// Trait for external traffic generators.
294///
295/// Implementors produce zero or more [`SpawnRequest`]s per tick. The consumer
296/// is responsible for feeding them into the simulation:
297///
298/// ```rust,ignore
299/// for req in source.generate(tick) {
300///     sim.spawn_rider_by_stop_id(req.origin, req.destination, req.weight)?;
301/// }
302/// ```
303///
304/// This design keeps traffic generation external to the simulation loop,
305/// giving consumers full control over when and how riders are spawned.
306pub trait TrafficSource {
307    /// Generate spawn requests for the given tick.
308    ///
309    /// May return an empty vec (no arrivals this tick) or multiple requests
310    /// (burst arrivals). The implementation controls the arrival process.
311    fn generate(&mut self, tick: u64) -> Vec<SpawnRequest>;
312}
313
314// ── PoissonSource ────────────────────────────────────────────────────
315
316/// Poisson-arrival traffic generator with time-varying patterns.
317///
318/// Uses an exponential inter-arrival time model: each tick, the generator
319/// checks whether enough time has elapsed since the last spawn. The mean
320/// interval comes from
321/// [`PassengerSpawnConfig::mean_interval_ticks`](crate::config::PassengerSpawnConfig::mean_interval_ticks).
322///
323/// Origin/destination pairs are sampled from a [`TrafficSchedule`] that
324/// selects the active [`TrafficPattern`] based on the current tick.
325///
326/// # Example
327///
328/// ```rust,ignore
329/// use elevator_core::traffic::PoissonSource;
330///
331/// // From a SimConfig (reads stops and spawn parameters).
332/// let mut source = PoissonSource::from_config(&config);
333///
334/// // Or build manually.
335/// let mut source = PoissonSource::new(
336///     stops,
337///     TrafficSchedule::office_day(3600),
338///     120,           // mean_interval_ticks
339///     (60.0, 90.0),  // weight_range
340/// );
341/// ```
342pub struct PoissonSource {
343    /// Sorted stop IDs (lowest position first).
344    stops: Vec<StopId>,
345    /// Time-varying pattern schedule.
346    schedule: TrafficSchedule,
347    /// Mean ticks between arrivals (lambda = 1/mean).
348    mean_interval: u32,
349    /// Weight range `(min, max)` for spawned riders.
350    weight_range: (f64, f64),
351    /// RNG for sampling.
352    rng: rand::rngs::ThreadRng,
353    /// Tick of the next scheduled arrival.
354    next_arrival_tick: u64,
355}
356
357impl PoissonSource {
358    /// Create a new Poisson traffic source.
359    ///
360    /// `stops` should be sorted by position (lowest first) to match
361    /// [`TrafficPattern`] expectations (first stop = lobby).
362    ///
363    /// If `weight_range.0 > weight_range.1`, the values are swapped.
364    #[must_use]
365    pub fn new(
366        stops: Vec<StopId>,
367        schedule: TrafficSchedule,
368        mean_interval_ticks: u32,
369        weight_range: (f64, f64),
370    ) -> Self {
371        let weight_range = if weight_range.0 > weight_range.1 {
372            (weight_range.1, weight_range.0)
373        } else {
374            weight_range
375        };
376        let mut rng = rand::rng();
377        let next = sample_next_arrival(0, mean_interval_ticks, &mut rng);
378        Self {
379            stops,
380            schedule,
381            mean_interval: mean_interval_ticks,
382            weight_range,
383            rng,
384            next_arrival_tick: next,
385        }
386    }
387
388    /// Create a Poisson source from a [`SimConfig`].
389    ///
390    /// Reads stop IDs from the building config and spawn parameters from
391    /// `passenger_spawning`. Uses a constant [`TrafficPattern::Uniform`] schedule
392    /// by default — call [`with_schedule`](Self::with_schedule) to override.
393    #[must_use]
394    pub fn from_config(config: &SimConfig) -> Self {
395        // Sort by position so stops[0] is the lobby (lowest position),
396        // matching TrafficPattern's assumption.
397        let mut stop_entries: Vec<_> = config.building.stops.iter().collect();
398        stop_entries.sort_by(|a, b| {
399            a.position
400                .partial_cmp(&b.position)
401                .unwrap_or(std::cmp::Ordering::Equal)
402        });
403        let stops: Vec<StopId> = stop_entries.iter().map(|s| s.id).collect();
404        let spawn = &config.passenger_spawning;
405        Self::new(
406            stops,
407            TrafficSchedule::constant(TrafficPattern::Uniform),
408            spawn.mean_interval_ticks,
409            spawn.weight_range,
410        )
411    }
412
413    /// Replace the traffic schedule.
414    #[must_use]
415    pub fn with_schedule(mut self, schedule: TrafficSchedule) -> Self {
416        self.schedule = schedule;
417        self
418    }
419
420    /// Replace the mean arrival interval.
421    #[must_use]
422    pub const fn with_mean_interval(mut self, ticks: u32) -> Self {
423        self.mean_interval = ticks;
424        self
425    }
426
427    /// Replace the weight range.
428    ///
429    /// If `range.0 > range.1`, the values are swapped.
430    #[must_use]
431    pub const fn with_weight_range(mut self, range: (f64, f64)) -> Self {
432        if range.0 > range.1 {
433            self.weight_range = (range.1, range.0);
434        } else {
435            self.weight_range = range;
436        }
437        self
438    }
439}
440
441impl TrafficSource for PoissonSource {
442    fn generate(&mut self, tick: u64) -> Vec<SpawnRequest> {
443        let mut requests = Vec::new();
444
445        while tick >= self.next_arrival_tick {
446            // Use the scheduled arrival tick (not the current tick) so catch-up
447            // arrivals sample from the pattern that was active when they were due.
448            let arrival_tick = self.next_arrival_tick;
449            if let Some((origin, destination)) =
450                self.schedule
451                    .sample_stop_ids(arrival_tick, &self.stops, &mut self.rng)
452            {
453                let weight = self
454                    .rng
455                    .random_range(self.weight_range.0..=self.weight_range.1);
456                requests.push(SpawnRequest {
457                    origin,
458                    destination,
459                    weight,
460                });
461            }
462            self.next_arrival_tick =
463                sample_next_arrival(self.next_arrival_tick, self.mean_interval, &mut self.rng);
464        }
465
466        requests
467    }
468}
469
470impl std::fmt::Debug for PoissonSource {
471    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
472        f.debug_struct("PoissonSource")
473            .field("stops", &self.stops)
474            .field("schedule", &self.schedule)
475            .field("mean_interval", &self.mean_interval)
476            .field("weight_range", &self.weight_range)
477            .field("next_arrival_tick", &self.next_arrival_tick)
478            .finish_non_exhaustive()
479    }
480}
481
482/// Sample the next arrival tick using exponential inter-arrival time.
483///
484/// The uniform sample is clamped to `[0.0001, 1.0)` to avoid `ln(0) = -inf`.
485/// This caps the maximum inter-arrival time at ~9.2× the mean interval,
486/// truncating the exponential tail to prevent rare extreme gaps.
487fn sample_next_arrival(current: u64, mean_interval: u32, rng: &mut impl Rng) -> u64 {
488    if mean_interval == 0 {
489        return current + 1;
490    }
491    let u: f64 = rng.random_range(0.0001..1.0);
492    let interval = -(f64::from(mean_interval)) * u.ln();
493    current + (interval as u64).max(1)
494}