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