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