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