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