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