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}