Skip to main content

elevator_core/
traffic.rs

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