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