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}