Skip to main content

elevator_core/
scenario.rs

1//! Scenario replay: timed rider spawns with pass/fail conditions.
2
3use crate::config::SimConfig;
4use crate::dispatch::DispatchStrategy;
5use crate::error::SimError;
6use crate::metrics::Metrics;
7use crate::sim::Simulation;
8use crate::stop::StopId;
9#[cfg(feature = "traffic")]
10use crate::traffic::TrafficPattern;
11#[cfg(feature = "traffic")]
12use rand::RngExt;
13use serde::{Deserialize, Serialize};
14
15/// A timed rider spawn event within a scenario.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct TimedSpawn {
18    /// Tick at which to spawn this rider.
19    pub tick: u64,
20    /// Origin stop for the rider.
21    pub origin: StopId,
22    /// Destination stop for the rider.
23    pub destination: StopId,
24    /// Weight of the rider.
25    pub weight: f64,
26}
27
28/// A pass/fail condition for scenario evaluation.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[non_exhaustive]
31pub enum Condition {
32    /// Average wait time must be below this value (ticks).
33    AvgWaitBelow(f64),
34    /// Maximum wait time must be below this value (ticks).
35    MaxWaitBelow(u64),
36    /// 95th-percentile wait time must be below this value (ticks).
37    /// Uses [`Metrics::p95_wait_time`](crate::metrics::Metrics::p95_wait_time);
38    /// returns 0 (vacuously passing) when the wait-sample buffer is
39    /// empty or disabled.
40    P95WaitBelow(u64),
41    /// Throughput must be above this value (riders per window).
42    ThroughputAbove(u64),
43    /// All spawned riders must reach a terminal state (delivered or abandoned)
44    /// by this tick. Riders that failed to spawn (see
45    /// [`ScenarioRunner::skipped_spawns`]) are not counted — check that
46    /// value separately when replay fidelity matters.
47    AllDeliveredByTick(u64),
48    /// Abandonment rate must be below this value (0.0 - 1.0).
49    AbandonmentRateBelow(f64),
50}
51
52/// A complete scenario: config + timed spawns + success conditions.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Scenario {
55    /// Human-readable scenario name.
56    pub name: String,
57    /// Simulation configuration.
58    pub config: SimConfig,
59    /// Timed rider spawn events.
60    pub spawns: Vec<TimedSpawn>,
61    /// Pass/fail conditions for evaluation.
62    pub conditions: Vec<Condition>,
63    /// Max ticks to run before declaring timeout.
64    pub max_ticks: u64,
65}
66
67/// Result of evaluating a single condition.
68#[derive(Debug, Clone)]
69pub struct ConditionResult {
70    /// The condition that was evaluated.
71    pub condition: Condition,
72    /// Whether the condition passed.
73    pub passed: bool,
74    /// The actual observed value.
75    pub actual_value: f64,
76}
77
78/// Result of running a complete scenario.
79#[derive(Debug, Clone)]
80pub struct ScenarioResult {
81    /// Scenario name.
82    pub name: String,
83    /// Whether all conditions passed.
84    pub passed: bool,
85    /// Number of ticks run.
86    pub ticks_run: u64,
87    /// Per-condition results.
88    pub conditions: Vec<ConditionResult>,
89    /// Final simulation metrics.
90    pub metrics: Metrics,
91}
92
93/// Runs a scenario to completion and evaluates conditions.
94pub struct ScenarioRunner {
95    /// The underlying simulation.
96    sim: Simulation,
97    /// Timed spawn events.
98    spawns: Vec<TimedSpawn>,
99    /// Index of the next spawn to process.
100    spawn_cursor: usize,
101    /// Pass/fail conditions.
102    conditions: Vec<Condition>,
103    /// Maximum ticks before timeout.
104    max_ticks: u64,
105    /// Scenario name.
106    name: String,
107    /// Number of spawn attempts that failed (e.g. disabled/removed stops).
108    skipped_spawns: u64,
109}
110
111impl ScenarioRunner {
112    /// Create a new runner from a scenario definition and dispatch strategy.
113    ///
114    /// Returns `Err` if the scenario's config is invalid.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`SimError::InvalidConfig`] if the scenario's simulation config is invalid.
119    pub fn new(
120        scenario: Scenario,
121        dispatch: impl DispatchStrategy + 'static,
122    ) -> Result<Self, SimError> {
123        let sim = Simulation::new(&scenario.config, dispatch)?;
124        // Sort spawns by tick so the cursor advance in `tick()` cannot
125        // gate an earlier spawn behind a later one. `sort_by_key` is
126        // stable, so spawns with the same tick keep their declaration
127        // order — important for replay determinism (#271).
128        let mut spawns = scenario.spawns;
129        spawns.sort_by_key(|s| s.tick);
130        Ok(Self {
131            sim,
132            spawns,
133            spawn_cursor: 0,
134            conditions: scenario.conditions,
135            max_ticks: scenario.max_ticks,
136            name: scenario.name,
137            skipped_spawns: 0,
138        })
139    }
140
141    /// Access the underlying simulation.
142    #[must_use]
143    pub const fn sim(&self) -> &Simulation {
144        &self.sim
145    }
146
147    /// Mutable access to the underlying simulation.
148    ///
149    /// Lets scenario drivers toggle service modes, set manual velocities, or
150    /// tweak per-elevator state between ticks — for example, switching a car
151    /// to [`ServiceMode::Inspection`](crate::components::ServiceMode::Inspection)
152    /// mid-run before continuing to call [`tick`](Self::tick).
153    pub const fn sim_mut(&mut self) -> &mut Simulation {
154        &mut self.sim
155    }
156
157    /// Number of rider spawn attempts that were skipped due to errors
158    /// (e.g. referencing disabled or removed stops).
159    #[must_use]
160    pub const fn skipped_spawns(&self) -> u64 {
161        self.skipped_spawns
162    }
163
164    /// Run one tick: spawn scheduled riders, then tick simulation.
165    pub fn tick(&mut self) {
166        // Spawn any riders scheduled for this tick.
167        while self.spawn_cursor < self.spawns.len()
168            && self.spawns[self.spawn_cursor].tick <= self.sim.current_tick()
169        {
170            let spawn = &self.spawns[self.spawn_cursor];
171            // Spawn errors are expected: scenario files may reference stops
172            // that were removed or disabled during the run. We skip the
173            // spawn but track the count so callers can detect divergence.
174            if self
175                .sim
176                .spawn_rider(spawn.origin, spawn.destination, spawn.weight)
177                .is_err()
178            {
179                self.skipped_spawns += 1;
180            }
181            self.spawn_cursor += 1;
182        }
183
184        self.sim.step();
185    }
186
187    /// Run to completion (all riders delivered or `max_ticks` reached).
188    pub fn run_to_completion(&mut self) -> ScenarioResult {
189        use crate::components::RiderPhase;
190
191        for _ in 0..self.max_ticks {
192            self.tick();
193
194            // Check if all spawns have happened and all riders are done.
195            if self.spawn_cursor >= self.spawns.len() {
196                let all_done =
197                    self.sim.world().iter_riders().all(|(_, r)| {
198                        matches!(r.phase, RiderPhase::Arrived | RiderPhase::Abandoned)
199                    });
200                if all_done {
201                    break;
202                }
203            }
204        }
205
206        self.evaluate()
207    }
208
209    /// Evaluate conditions against current metrics.
210    #[must_use]
211    pub fn evaluate(&self) -> ScenarioResult {
212        let metrics = self.sim.metrics().clone();
213        let condition_results: Vec<ConditionResult> = self
214            .conditions
215            .iter()
216            .map(|cond| evaluate_condition(cond, &metrics, self.sim.current_tick()))
217            .collect();
218
219        let passed = condition_results.iter().all(|r| r.passed);
220
221        ScenarioResult {
222            name: self.name.clone(),
223            passed,
224            ticks_run: self.sim.current_tick(),
225            conditions: condition_results,
226            metrics,
227        }
228    }
229}
230
231/// Evaluate a single condition against metrics and the current tick.
232fn evaluate_condition(
233    condition: &Condition,
234    metrics: &Metrics,
235    current_tick: u64,
236) -> ConditionResult {
237    match condition {
238        Condition::AvgWaitBelow(threshold) => ConditionResult {
239            condition: condition.clone(),
240            passed: metrics.avg_wait_time() < *threshold,
241            actual_value: metrics.avg_wait_time(),
242        },
243        Condition::MaxWaitBelow(threshold) => ConditionResult {
244            condition: condition.clone(),
245            passed: metrics.max_wait_time() < *threshold,
246            actual_value: metrics.max_wait_time() as f64,
247        },
248        Condition::P95WaitBelow(threshold) => ConditionResult {
249            condition: condition.clone(),
250            passed: metrics.p95_wait_time() < *threshold,
251            actual_value: metrics.p95_wait_time() as f64,
252        },
253        Condition::ThroughputAbove(threshold) => ConditionResult {
254            condition: condition.clone(),
255            passed: metrics.throughput() > *threshold,
256            actual_value: metrics.throughput() as f64,
257        },
258        Condition::AllDeliveredByTick(deadline) => ConditionResult {
259            condition: condition.clone(),
260            passed: current_tick <= *deadline
261                && metrics.total_delivered() + metrics.total_abandoned() == metrics.total_spawned(),
262            actual_value: current_tick as f64,
263        },
264        Condition::AbandonmentRateBelow(threshold) => ConditionResult {
265            condition: condition.clone(),
266            passed: metrics.abandonment_rate() < *threshold,
267            actual_value: metrics.abandonment_rate(),
268        },
269    }
270}
271
272// ── SpawnSchedule builder ───────────────────────────────────────────
273
274/// Fluent builder for [`TimedSpawn`] sequences that feed [`Scenario::spawns`].
275///
276/// Unifies two common authoring shapes in one place:
277/// - Deterministic bursts (fixed origin/destination, fixed tick or regular
278///   cadence), where exact tick counts matter — e.g. "20 riders leave the
279///   lobby at tick 0", "1 rider every 600 ticks for 10 minutes".
280/// - Poisson draws from a [`TrafficPattern`], where the origin/destination
281///   pair is stochastic but the arrival process is exponential.
282///
283/// The final [`Vec<TimedSpawn>`] is extracted via [`into_spawns`](Self::into_spawns)
284/// and handed to [`Scenario::spawns`]. Scenarios with mixed shapes chain
285/// builders via [`merge`](Self::merge):
286///
287/// ```
288/// use elevator_core::scenario::SpawnSchedule;
289/// use elevator_core::stop::StopId;
290///
291/// let schedule = SpawnSchedule::new()
292///     .burst(StopId(0), StopId(5), 10, 0, 70.0)
293///     .staggered(StopId(0), StopId(3), 5, 1_000, 300, 70.0);
294/// assert_eq!(schedule.len(), 15);
295/// ```
296#[derive(Debug, Clone, Default)]
297pub struct SpawnSchedule {
298    /// Accumulated spawns. Order is authoring order;
299    /// [`ScenarioRunner::new`] sorts by tick on construction.
300    spawns: Vec<TimedSpawn>,
301}
302
303impl SpawnSchedule {
304    /// Create an empty schedule.
305    #[must_use]
306    pub const fn new() -> Self {
307        Self { spawns: Vec::new() }
308    }
309
310    /// Append `count` identical spawns, all firing on `at_tick`. Use this
311    /// for the classic "crowd appears simultaneously" shape (morning
312    /// stand-up, event dismissal).
313    #[must_use]
314    pub fn burst(
315        mut self,
316        origin: StopId,
317        destination: StopId,
318        count: usize,
319        at_tick: u64,
320        weight: f64,
321    ) -> Self {
322        self.spawns.reserve(count);
323        for _ in 0..count {
324            self.spawns.push(TimedSpawn {
325                tick: at_tick,
326                origin,
327                destination,
328                weight,
329            });
330        }
331        self
332    }
333
334    /// Append `count` spawns starting at `start_tick`, each `stagger_ticks`
335    /// apart. A `stagger_ticks = 0` degenerates to [`burst`](Self::burst).
336    /// Use this for deterministic arrival cadences — e.g. "one rider every
337    /// 10 seconds" — where Poisson variance would obscure the test signal.
338    #[must_use]
339    pub fn staggered(
340        mut self,
341        origin: StopId,
342        destination: StopId,
343        count: usize,
344        start_tick: u64,
345        stagger_ticks: u64,
346        weight: f64,
347    ) -> Self {
348        self.spawns.reserve(count);
349        for i in 0..count as u64 {
350            self.spawns.push(TimedSpawn {
351                tick: start_tick + i * stagger_ticks,
352                origin,
353                destination,
354                weight,
355            });
356        }
357        self
358    }
359
360    /// Append Poisson-distributed spawns from a [`TrafficPattern`] over
361    /// `duration_ticks`, with exponential inter-arrival times of mean
362    /// `mean_interval_ticks`. `weight_range` is a uniform draw per spawn.
363    /// The supplied `rng` advances but is not taken — callers can continue
364    /// using it for other deterministic draws.
365    ///
366    /// `stops` must be sorted by position (lobby first) to match
367    /// [`TrafficPattern`]'s lobby-origin peak-pattern assumption. See
368    /// [`TrafficPattern::sample_stop_ids`].
369    ///
370    /// Returns the schedule with generated spawns appended. If `stops`
371    /// has fewer than 2 entries, no spawns are generated (pattern
372    /// sampling requires at least two stops).
373    ///
374    /// Requires the `traffic` feature — gated so no-default-features
375    /// builds (notably the wasm32 gate CI job) don't pull in `rand`
376    /// and the `traffic` module transitively.
377    #[cfg(feature = "traffic")]
378    #[must_use]
379    pub fn from_pattern(
380        mut self,
381        pattern: TrafficPattern,
382        stops: &[StopId],
383        duration_ticks: u64,
384        mean_interval_ticks: u32,
385        weight_range: (f64, f64),
386        rng: &mut impl RngExt,
387    ) -> Self {
388        if stops.len() < 2 || mean_interval_ticks == 0 {
389            return self;
390        }
391        let (wlo, whi) = if weight_range.0 > weight_range.1 {
392            (weight_range.1, weight_range.0)
393        } else {
394            weight_range
395        };
396        let mut tick = 0u64;
397        loop {
398            // Exponential inter-arrival time, clamped to avoid ln(0).
399            let u: f64 = rng.random_range(0.0001..1.0);
400            let interval = -(f64::from(mean_interval_ticks)) * u.ln();
401            let step = (interval as u64).max(1);
402            tick = tick.saturating_add(step);
403            if tick >= duration_ticks {
404                break;
405            }
406            if let Some((origin, destination)) = pattern.sample_stop_ids(stops, rng) {
407                let weight = rng.random_range(wlo..=whi);
408                self.spawns.push(TimedSpawn {
409                    tick,
410                    origin,
411                    destination,
412                    weight,
413                });
414            }
415        }
416        self
417    }
418
419    /// Append a single spawn. Useful for one-off riders mixed into a
420    /// larger pattern (e.g. a "stranded top-floor" rider sitting atop
421    /// a [`from_pattern`](Self::from_pattern) stream).
422    #[must_use]
423    pub fn push(mut self, spawn: TimedSpawn) -> Self {
424        self.spawns.push(spawn);
425        self
426    }
427
428    /// Absorb another schedule's spawns. Chainable drop-in for
429    /// composing heterogeneous arrival shapes — e.g. up-peak burst
430    /// plus a uniform inter-floor background:
431    ///
432    /// ```
433    /// # #[cfg(not(feature = "traffic"))]
434    /// # fn main() {}
435    /// # #[cfg(feature = "traffic")]
436    /// # fn main() {
437    /// use elevator_core::scenario::SpawnSchedule;
438    /// use elevator_core::stop::StopId;
439    /// use elevator_core::traffic::TrafficPattern;
440    /// use rand::SeedableRng;
441    /// let mut rng = rand::rngs::StdRng::seed_from_u64(7);
442    /// let stops = vec![StopId(0), StopId(1), StopId(2)];
443    /// let background = SpawnSchedule::new().from_pattern(
444    ///     TrafficPattern::Uniform, &stops, 10_000, 300, (70.0, 80.0), &mut rng,
445    /// );
446    /// let up_peak = SpawnSchedule::new().burst(StopId(0), StopId(2), 20, 0, 70.0);
447    /// let combined = up_peak.merge(background);
448    /// assert!(combined.len() >= 20);
449    /// # }
450    /// ```
451    #[must_use]
452    pub fn merge(mut self, other: Self) -> Self {
453        self.spawns.extend(other.spawns);
454        self
455    }
456
457    /// Number of spawns currently in the schedule.
458    #[must_use]
459    pub const fn len(&self) -> usize {
460        self.spawns.len()
461    }
462
463    /// Whether the schedule has no spawns.
464    #[must_use]
465    pub const fn is_empty(&self) -> bool {
466        self.spawns.is_empty()
467    }
468
469    /// Borrow the underlying spawns (useful for inspection in tests).
470    #[must_use]
471    pub fn spawns(&self) -> &[TimedSpawn] {
472        &self.spawns
473    }
474
475    /// Consume the builder and return the spawns, ready to drop into
476    /// [`Scenario::spawns`]. [`ScenarioRunner::new`] sorts them by tick
477    /// on construction, so builders don't need to maintain a sorted
478    /// invariant.
479    #[must_use]
480    pub fn into_spawns(self) -> Vec<TimedSpawn> {
481        self.spawns
482    }
483}