Skip to main content

elevator_core/
metrics.rs

1//! Aggregate simulation metrics (wait times, throughput, distance).
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::collections::VecDeque;
6use std::fmt;
7
8/// Aggregated simulation metrics, updated each tick from events.
9///
10/// Games query this via `sim.metrics()` for HUD display, scoring,
11/// or scenario evaluation.
12#[derive(Debug, Clone, Default, Serialize, Deserialize)]
13#[non_exhaustive]
14pub struct Metrics {
15    // -- Queryable metrics (accessed via getters) --
16    /// Average wait time in ticks (spawn to board).
17    pub(crate) avg_wait_time: f64,
18    /// Average ride time in ticks (board to exit).
19    pub(crate) avg_ride_time: f64,
20    /// Maximum wait time observed (ticks).
21    pub(crate) max_wait_time: u64,
22    /// Riders delivered in the current throughput window.
23    pub(crate) throughput: u64,
24    /// Riders delivered total.
25    pub(crate) total_delivered: u64,
26    /// Riders who abandoned.
27    pub(crate) total_abandoned: u64,
28    /// Total riders spawned.
29    pub(crate) total_spawned: u64,
30    /// Abandonment rate (0.0 - 1.0).
31    pub(crate) abandonment_rate: f64,
32    /// Total distance traveled by all elevators.
33    pub(crate) total_distance: f64,
34    /// Per-group instantaneous elevator utilization: fraction of elevators
35    /// currently moving (either `MovingToStop` or `Repositioning`) vs total
36    /// enabled elevators. Overwritten each tick. Key is group name (String
37    /// for serialization).
38    #[serde(default)]
39    pub(crate) utilization_by_group: HashMap<String, f64>,
40    /// Total distance traveled by elevators while repositioning.
41    #[serde(default)]
42    pub(crate) reposition_distance: f64,
43    /// Total rounded-floor transitions across all elevators
44    /// (passing-floor crossings plus arrivals).
45    #[serde(default)]
46    pub(crate) total_moves: u64,
47    /// Total riders settled as residents.
48    pub(crate) total_settled: u64,
49    /// Total riders rerouted from resident phase.
50    pub(crate) total_rerouted: u64,
51
52    /// Total energy consumed by all elevators.
53    #[cfg(feature = "energy")]
54    #[serde(default)]
55    pub(crate) total_energy_consumed: f64,
56    /// Total energy regenerated by all elevators.
57    #[cfg(feature = "energy")]
58    #[serde(default)]
59    pub(crate) total_energy_regenerated: f64,
60
61    // -- Internal accumulators --
62    /// Running sum of wait ticks across all boarded riders.
63    sum_wait_ticks: u64,
64    /// Running sum of ride ticks across all delivered riders.
65    sum_ride_ticks: u64,
66    /// Number of riders that have boarded.
67    boarded_count: u64,
68    /// Number of riders that have been delivered.
69    delivered_count: u64,
70    /// Sliding window of delivery ticks, sorted ascending.
71    delivery_window: VecDeque<u64>,
72    /// Window size for throughput calculation.
73    pub(crate) throughput_window_ticks: u64,
74}
75
76impl Metrics {
77    /// Create a new `Metrics` with default throughput window (3600 ticks).
78    #[must_use]
79    pub fn new() -> Self {
80        Self {
81            throughput_window_ticks: 3600, // default: 1 minute at 60 tps
82            ..Default::default()
83        }
84    }
85
86    /// Set the throughput window size (builder pattern).
87    #[must_use]
88    pub const fn with_throughput_window(mut self, window_ticks: u64) -> Self {
89        self.throughput_window_ticks = window_ticks;
90        self
91    }
92
93    // ── Getters ──────────────────────────────────��───────────────────
94
95    /// Average wait time in ticks (spawn to board).
96    #[must_use]
97    pub const fn avg_wait_time(&self) -> f64 {
98        self.avg_wait_time
99    }
100
101    /// Average ride time in ticks (board to exit).
102    #[must_use]
103    pub const fn avg_ride_time(&self) -> f64 {
104        self.avg_ride_time
105    }
106
107    /// Maximum wait time observed (ticks).
108    #[must_use]
109    pub const fn max_wait_time(&self) -> u64 {
110        self.max_wait_time
111    }
112
113    /// Riders delivered in the current throughput window.
114    #[must_use]
115    pub const fn throughput(&self) -> u64 {
116        self.throughput
117    }
118
119    /// Riders delivered total.
120    #[must_use]
121    pub const fn total_delivered(&self) -> u64 {
122        self.total_delivered
123    }
124
125    /// Riders who abandoned.
126    #[must_use]
127    pub const fn total_abandoned(&self) -> u64 {
128        self.total_abandoned
129    }
130
131    /// Total riders spawned.
132    #[must_use]
133    pub const fn total_spawned(&self) -> u64 {
134        self.total_spawned
135    }
136
137    /// Abandonment rate (0.0 - 1.0).
138    #[must_use]
139    pub const fn abandonment_rate(&self) -> f64 {
140        self.abandonment_rate
141    }
142
143    /// Total distance traveled by all elevators.
144    #[must_use]
145    pub const fn total_distance(&self) -> f64 {
146        self.total_distance
147    }
148
149    /// Per-group instantaneous elevator utilization (fraction of elevators moving).
150    #[must_use]
151    pub const fn utilization_by_group(&self) -> &HashMap<String, f64> {
152        &self.utilization_by_group
153    }
154
155    /// Total distance traveled by elevators while repositioning.
156    #[must_use]
157    pub const fn reposition_distance(&self) -> f64 {
158        self.reposition_distance
159    }
160
161    /// Total rounded-floor transitions across all elevators (passing-floor
162    /// crossings plus arrivals).
163    #[must_use]
164    pub const fn total_moves(&self) -> u64 {
165        self.total_moves
166    }
167
168    /// Total riders settled as residents.
169    #[must_use]
170    pub const fn total_settled(&self) -> u64 {
171        self.total_settled
172    }
173
174    /// Total riders rerouted from resident phase.
175    #[must_use]
176    pub const fn total_rerouted(&self) -> u64 {
177        self.total_rerouted
178    }
179
180    /// Window size for throughput calculation (ticks).
181    #[must_use]
182    pub const fn throughput_window_ticks(&self) -> u64 {
183        self.throughput_window_ticks
184    }
185
186    /// Total energy consumed by all elevators (requires `energy` feature).
187    #[cfg(feature = "energy")]
188    #[must_use]
189    pub const fn total_energy_consumed(&self) -> f64 {
190        self.total_energy_consumed
191    }
192
193    /// Total energy regenerated by all elevators (requires `energy` feature).
194    #[cfg(feature = "energy")]
195    #[must_use]
196    pub const fn total_energy_regenerated(&self) -> f64 {
197        self.total_energy_regenerated
198    }
199
200    /// Net energy: consumed minus regenerated (requires `energy` feature).
201    #[cfg(feature = "energy")]
202    #[must_use]
203    pub const fn net_energy(&self) -> f64 {
204        self.total_energy_consumed - self.total_energy_regenerated
205    }
206
207    // ── Recording ───────────────────���────────────────────────────────
208
209    /// Overall utilization: average across all groups.
210    #[must_use]
211    pub fn avg_utilization(&self) -> f64 {
212        if self.utilization_by_group.is_empty() {
213            return 0.0;
214        }
215        let sum: f64 = self.utilization_by_group.values().sum();
216        sum / self.utilization_by_group.len() as f64
217    }
218
219    /// Record a rider spawning.
220    pub(crate) const fn record_spawn(&mut self) {
221        self.total_spawned += 1;
222    }
223
224    /// Record a rider boarding. `wait_ticks` = `tick_boarded` - `tick_spawned`.
225    #[allow(clippy::cast_precision_loss)] // rider counts fit in f64 mantissa
226    pub(crate) fn record_board(&mut self, wait_ticks: u64) {
227        self.boarded_count += 1;
228        self.sum_wait_ticks += wait_ticks;
229        self.avg_wait_time = self.sum_wait_ticks as f64 / self.boarded_count as f64;
230        if wait_ticks > self.max_wait_time {
231            self.max_wait_time = wait_ticks;
232        }
233    }
234
235    /// Record a rider exiting. `ride_ticks` = `tick_exited` - `tick_boarded`.
236    #[allow(clippy::cast_precision_loss)] // rider counts fit in f64 mantissa
237    pub(crate) fn record_delivery(&mut self, ride_ticks: u64, tick: u64) {
238        self.delivered_count += 1;
239        self.total_delivered += 1;
240        self.sum_ride_ticks += ride_ticks;
241        self.avg_ride_time = self.sum_ride_ticks as f64 / self.delivered_count as f64;
242        self.delivery_window.push_back(tick);
243    }
244
245    /// Record a rider abandoning.
246    #[allow(clippy::cast_precision_loss)] // rider counts fit in f64 mantissa
247    pub(crate) fn record_abandonment(&mut self) {
248        self.total_abandoned += 1;
249        if self.total_spawned > 0 {
250            self.abandonment_rate = self.total_abandoned as f64 / self.total_spawned as f64;
251        }
252    }
253
254    /// Record a rider settling as a resident.
255    pub(crate) const fn record_settle(&mut self) {
256        self.total_settled += 1;
257    }
258
259    /// Record a resident rider being rerouted.
260    pub(crate) const fn record_reroute(&mut self) {
261        self.total_rerouted += 1;
262    }
263
264    /// Record elevator distance traveled this tick.
265    pub(crate) fn record_distance(&mut self, distance: f64) {
266        self.total_distance += distance;
267    }
268
269    /// Record elevator distance traveled while repositioning.
270    pub(crate) fn record_reposition_distance(&mut self, distance: f64) {
271        self.reposition_distance += distance;
272    }
273
274    /// Record energy consumption and regeneration.
275    #[cfg(feature = "energy")]
276    pub(crate) fn record_energy(&mut self, consumed: f64, regenerated: f64) {
277        self.total_energy_consumed += consumed;
278        self.total_energy_regenerated += regenerated;
279    }
280
281    /// Update windowed throughput. Call once per tick.
282    #[allow(clippy::cast_possible_truncation)] // window len always fits in u64
283    pub(crate) fn update_throughput(&mut self, current_tick: u64) {
284        let cutoff = current_tick.saturating_sub(self.throughput_window_ticks);
285        // Delivery ticks are inserted in order, so expired entries are at the front.
286        while self.delivery_window.front().is_some_and(|&t| t <= cutoff) {
287            self.delivery_window.pop_front();
288        }
289        self.throughput = self.delivery_window.len() as u64;
290    }
291}
292
293impl fmt::Display for Metrics {
294    /// Compact one-line summary for HUDs and logs.
295    ///
296    /// ```
297    /// # use elevator_core::metrics::Metrics;
298    /// let m = Metrics::new();
299    /// assert_eq!(format!("{m}"), "0 delivered, avg wait 0.0t, 0% util");
300    /// ```
301    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302        write!(
303            f,
304            "{} delivered, avg wait {:.1}t, {:.0}% util",
305            self.total_delivered,
306            self.avg_wait_time,
307            self.avg_utilization() * 100.0,
308        )
309    }
310}