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;
9use serde::{Deserialize, Serialize};
10
11/// A timed rider spawn event within a scenario.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TimedSpawn {
14    /// Tick at which to spawn this rider.
15    pub tick: u64,
16    /// Origin stop for the rider.
17    pub origin: StopId,
18    /// Destination stop for the rider.
19    pub destination: StopId,
20    /// Weight of the rider.
21    pub weight: f64,
22}
23
24/// A pass/fail condition for scenario evaluation.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[non_exhaustive]
27pub enum Condition {
28    /// Average wait time must be below this value (ticks).
29    AvgWaitBelow(f64),
30    /// Maximum wait time must be below this value (ticks).
31    MaxWaitBelow(u64),
32    /// Throughput must be above this value (riders per window).
33    ThroughputAbove(u64),
34    /// All spawned riders must reach a terminal state (delivered or abandoned)
35    /// by this tick. Riders that failed to spawn (see
36    /// [`ScenarioRunner::skipped_spawns`]) are not counted — check that
37    /// value separately when replay fidelity matters.
38    AllDeliveredByTick(u64),
39    /// Abandonment rate must be below this value (0.0 - 1.0).
40    AbandonmentRateBelow(f64),
41}
42
43/// A complete scenario: config + timed spawns + success conditions.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Scenario {
46    /// Human-readable scenario name.
47    pub name: String,
48    /// Simulation configuration.
49    pub config: SimConfig,
50    /// Timed rider spawn events.
51    pub spawns: Vec<TimedSpawn>,
52    /// Pass/fail conditions for evaluation.
53    pub conditions: Vec<Condition>,
54    /// Max ticks to run before declaring timeout.
55    pub max_ticks: u64,
56}
57
58/// Result of evaluating a single condition.
59#[derive(Debug, Clone)]
60pub struct ConditionResult {
61    /// The condition that was evaluated.
62    pub condition: Condition,
63    /// Whether the condition passed.
64    pub passed: bool,
65    /// The actual observed value.
66    pub actual_value: f64,
67}
68
69/// Result of running a complete scenario.
70#[derive(Debug, Clone)]
71pub struct ScenarioResult {
72    /// Scenario name.
73    pub name: String,
74    /// Whether all conditions passed.
75    pub passed: bool,
76    /// Number of ticks run.
77    pub ticks_run: u64,
78    /// Per-condition results.
79    pub conditions: Vec<ConditionResult>,
80    /// Final simulation metrics.
81    pub metrics: Metrics,
82}
83
84/// Runs a scenario to completion and evaluates conditions.
85pub struct ScenarioRunner {
86    /// The underlying simulation.
87    sim: Simulation,
88    /// Timed spawn events.
89    spawns: Vec<TimedSpawn>,
90    /// Index of the next spawn to process.
91    spawn_cursor: usize,
92    /// Pass/fail conditions.
93    conditions: Vec<Condition>,
94    /// Maximum ticks before timeout.
95    max_ticks: u64,
96    /// Scenario name.
97    name: String,
98    /// Number of spawn attempts that failed (e.g. disabled/removed stops).
99    skipped_spawns: u64,
100}
101
102impl ScenarioRunner {
103    /// Create a new runner from a scenario definition and dispatch strategy.
104    ///
105    /// Returns `Err` if the scenario's config is invalid.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`SimError::InvalidConfig`] if the scenario's simulation config is invalid.
110    pub fn new(
111        scenario: Scenario,
112        dispatch: impl DispatchStrategy + 'static,
113    ) -> Result<Self, SimError> {
114        let sim = Simulation::new(&scenario.config, dispatch)?;
115        // Sort spawns by tick so the cursor advance in `tick()` cannot
116        // gate an earlier spawn behind a later one. `sort_by_key` is
117        // stable, so spawns with the same tick keep their declaration
118        // order — important for replay determinism (#271).
119        let mut spawns = scenario.spawns;
120        spawns.sort_by_key(|s| s.tick);
121        Ok(Self {
122            sim,
123            spawns,
124            spawn_cursor: 0,
125            conditions: scenario.conditions,
126            max_ticks: scenario.max_ticks,
127            name: scenario.name,
128            skipped_spawns: 0,
129        })
130    }
131
132    /// Access the underlying simulation.
133    #[must_use]
134    pub const fn sim(&self) -> &Simulation {
135        &self.sim
136    }
137
138    /// Mutable access to the underlying simulation.
139    ///
140    /// Lets scenario drivers toggle service modes, set manual velocities, or
141    /// tweak per-elevator state between ticks — for example, switching a car
142    /// to [`ServiceMode::Inspection`](crate::components::ServiceMode::Inspection)
143    /// mid-run before continuing to call [`tick`](Self::tick).
144    pub const fn sim_mut(&mut self) -> &mut Simulation {
145        &mut self.sim
146    }
147
148    /// Number of rider spawn attempts that were skipped due to errors
149    /// (e.g. referencing disabled or removed stops).
150    #[must_use]
151    pub const fn skipped_spawns(&self) -> u64 {
152        self.skipped_spawns
153    }
154
155    /// Run one tick: spawn scheduled riders, then tick simulation.
156    pub fn tick(&mut self) {
157        // Spawn any riders scheduled for this tick.
158        while self.spawn_cursor < self.spawns.len()
159            && self.spawns[self.spawn_cursor].tick <= self.sim.current_tick()
160        {
161            let spawn = &self.spawns[self.spawn_cursor];
162            // Spawn errors are expected: scenario files may reference stops
163            // that were removed or disabled during the run. We skip the
164            // spawn but track the count so callers can detect divergence.
165            if self
166                .sim
167                .spawn_rider(spawn.origin, spawn.destination, spawn.weight)
168                .is_err()
169            {
170                self.skipped_spawns += 1;
171            }
172            self.spawn_cursor += 1;
173        }
174
175        self.sim.step();
176    }
177
178    /// Run to completion (all riders delivered or `max_ticks` reached).
179    pub fn run_to_completion(&mut self) -> ScenarioResult {
180        use crate::components::RiderPhase;
181
182        for _ in 0..self.max_ticks {
183            self.tick();
184
185            // Check if all spawns have happened and all riders are done.
186            if self.spawn_cursor >= self.spawns.len() {
187                let all_done =
188                    self.sim.world().iter_riders().all(|(_, r)| {
189                        matches!(r.phase, RiderPhase::Arrived | RiderPhase::Abandoned)
190                    });
191                if all_done {
192                    break;
193                }
194            }
195        }
196
197        self.evaluate()
198    }
199
200    /// Evaluate conditions against current metrics.
201    #[must_use]
202    pub fn evaluate(&self) -> ScenarioResult {
203        let metrics = self.sim.metrics().clone();
204        let condition_results: Vec<ConditionResult> = self
205            .conditions
206            .iter()
207            .map(|cond| evaluate_condition(cond, &metrics, self.sim.current_tick()))
208            .collect();
209
210        let passed = condition_results.iter().all(|r| r.passed);
211
212        ScenarioResult {
213            name: self.name.clone(),
214            passed,
215            ticks_run: self.sim.current_tick(),
216            conditions: condition_results,
217            metrics,
218        }
219    }
220}
221
222/// Evaluate a single condition against metrics and the current tick.
223fn evaluate_condition(
224    condition: &Condition,
225    metrics: &Metrics,
226    current_tick: u64,
227) -> ConditionResult {
228    match condition {
229        Condition::AvgWaitBelow(threshold) => ConditionResult {
230            condition: condition.clone(),
231            passed: metrics.avg_wait_time() < *threshold,
232            actual_value: metrics.avg_wait_time(),
233        },
234        Condition::MaxWaitBelow(threshold) => ConditionResult {
235            condition: condition.clone(),
236            passed: metrics.max_wait_time() < *threshold,
237            actual_value: metrics.max_wait_time() as f64,
238        },
239        Condition::ThroughputAbove(threshold) => ConditionResult {
240            condition: condition.clone(),
241            passed: metrics.throughput() > *threshold,
242            actual_value: metrics.throughput() as f64,
243        },
244        Condition::AllDeliveredByTick(deadline) => ConditionResult {
245            condition: condition.clone(),
246            passed: current_tick <= *deadline
247                && metrics.total_delivered() + metrics.total_abandoned() == metrics.total_spawned(),
248            actual_value: current_tick as f64,
249        },
250        Condition::AbandonmentRateBelow(threshold) => ConditionResult {
251            condition: condition.clone(),
252            passed: metrics.abandonment_rate() < *threshold,
253            actual_value: metrics.abandonment_rate(),
254        },
255    }
256}