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