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 riders must be delivered by this tick.
35    AllDeliveredByTick(u64),
36    /// Abandonment rate must be below this value (0.0 - 1.0).
37    AbandonmentRateBelow(f64),
38}
39
40/// A complete scenario: config + timed spawns + success conditions.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Scenario {
43    /// Human-readable scenario name.
44    pub name: String,
45    /// Simulation configuration.
46    pub config: SimConfig,
47    /// Timed rider spawn events.
48    pub spawns: Vec<TimedSpawn>,
49    /// Pass/fail conditions for evaluation.
50    pub conditions: Vec<Condition>,
51    /// Max ticks to run before declaring timeout.
52    pub max_ticks: u64,
53}
54
55/// Result of evaluating a single condition.
56#[derive(Debug, Clone)]
57pub struct ConditionResult {
58    /// The condition that was evaluated.
59    pub condition: Condition,
60    /// Whether the condition passed.
61    pub passed: bool,
62    /// The actual observed value.
63    pub actual_value: f64,
64}
65
66/// Result of running a complete scenario.
67#[derive(Debug, Clone)]
68pub struct ScenarioResult {
69    /// Scenario name.
70    pub name: String,
71    /// Whether all conditions passed.
72    pub passed: bool,
73    /// Number of ticks run.
74    pub ticks_run: u64,
75    /// Per-condition results.
76    pub conditions: Vec<ConditionResult>,
77    /// Final simulation metrics.
78    pub metrics: Metrics,
79}
80
81/// Runs a scenario to completion and evaluates conditions.
82pub struct ScenarioRunner {
83    /// The underlying simulation.
84    sim: Simulation,
85    /// Timed spawn events.
86    spawns: Vec<TimedSpawn>,
87    /// Index of the next spawn to process.
88    spawn_cursor: usize,
89    /// Pass/fail conditions.
90    conditions: Vec<Condition>,
91    /// Maximum ticks before timeout.
92    max_ticks: u64,
93    /// Scenario name.
94    name: String,
95}
96
97impl ScenarioRunner {
98    /// Create a new runner from a scenario definition and dispatch strategy.
99    ///
100    /// Returns `Err` if the scenario's config is invalid.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`SimError::InvalidConfig`] if the scenario's simulation config is invalid.
105    pub fn new(
106        scenario: Scenario,
107        dispatch: impl DispatchStrategy + 'static,
108    ) -> Result<Self, SimError> {
109        let sim = Simulation::new(&scenario.config, dispatch)?;
110        Ok(Self {
111            sim,
112            spawns: scenario.spawns,
113            spawn_cursor: 0,
114            conditions: scenario.conditions,
115            max_ticks: scenario.max_ticks,
116            name: scenario.name,
117        })
118    }
119
120    /// Access the underlying simulation.
121    #[must_use]
122    pub const fn sim(&self) -> &Simulation {
123        &self.sim
124    }
125
126    /// Run one tick: spawn scheduled riders, then tick simulation.
127    pub fn tick(&mut self) {
128        // Spawn any riders scheduled for this tick.
129        while self.spawn_cursor < self.spawns.len()
130            && self.spawns[self.spawn_cursor].tick <= self.sim.current_tick()
131        {
132            let spawn = &self.spawns[self.spawn_cursor];
133            // Deliberately ignore spawn errors: scenario files may reference stops
134            // that were removed or disabled during the scenario run. Silently
135            // skipping invalid spawns is the correct replay behavior.
136            let _ = self
137                .sim
138                .spawn_rider_by_stop_id(spawn.origin, spawn.destination, spawn.weight);
139            self.spawn_cursor += 1;
140        }
141
142        self.sim.step();
143    }
144
145    /// Run to completion (all riders delivered or `max_ticks` reached).
146    pub fn run_to_completion(&mut self) -> ScenarioResult {
147        use crate::components::RiderPhase;
148
149        for _ in 0..self.max_ticks {
150            self.tick();
151
152            // Check if all spawns have happened and all riders are done.
153            if self.spawn_cursor >= self.spawns.len() {
154                let all_done =
155                    self.sim.world().iter_riders().all(|(_, r)| {
156                        matches!(r.phase, RiderPhase::Arrived | RiderPhase::Abandoned)
157                    });
158                if all_done {
159                    break;
160                }
161            }
162        }
163
164        self.evaluate()
165    }
166
167    /// Evaluate conditions against current metrics.
168    #[must_use]
169    pub fn evaluate(&self) -> ScenarioResult {
170        let metrics = self.sim.metrics().clone();
171        let condition_results: Vec<ConditionResult> = self
172            .conditions
173            .iter()
174            .map(|cond| evaluate_condition(cond, &metrics, self.sim.current_tick()))
175            .collect();
176
177        let passed = condition_results.iter().all(|r| r.passed);
178
179        ScenarioResult {
180            name: self.name.clone(),
181            passed,
182            ticks_run: self.sim.current_tick(),
183            conditions: condition_results,
184            metrics,
185        }
186    }
187}
188
189/// Evaluate a single condition against metrics and the current tick.
190fn evaluate_condition(
191    condition: &Condition,
192    metrics: &Metrics,
193    current_tick: u64,
194) -> ConditionResult {
195    match condition {
196        Condition::AvgWaitBelow(threshold) => ConditionResult {
197            condition: condition.clone(),
198            passed: metrics.avg_wait_time() < *threshold,
199            actual_value: metrics.avg_wait_time(),
200        },
201        Condition::MaxWaitBelow(threshold) => ConditionResult {
202            condition: condition.clone(),
203            passed: metrics.max_wait_time() < *threshold,
204            actual_value: metrics.max_wait_time() as f64,
205        },
206        Condition::ThroughputAbove(threshold) => ConditionResult {
207            condition: condition.clone(),
208            passed: metrics.throughput() > *threshold,
209            actual_value: metrics.throughput() as f64,
210        },
211        Condition::AllDeliveredByTick(deadline) => ConditionResult {
212            condition: condition.clone(),
213            passed: current_tick <= *deadline
214                && metrics.total_delivered() == metrics.total_spawned(),
215            actual_value: current_tick as f64,
216        },
217        Condition::AbandonmentRateBelow(threshold) => ConditionResult {
218            condition: condition.clone(),
219            passed: metrics.abandonment_rate() < *threshold,
220            actual_value: metrics.abandonment_rate(),
221        },
222    }
223}