elevator_core/metrics.rs
1//! Aggregate simulation metrics (wait times, throughput, distance).
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
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, 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: BTreeMap<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 /// Ring buffer of the most-recent wait samples (spawn-to-board ticks).
75 /// Powers [`percentile_wait_time`](Metrics::percentile_wait_time). Capped
76 /// by [`wait_sample_capacity`](Self::wait_sample_capacity); oldest samples
77 /// are evicted when full.
78 #[serde(default)]
79 wait_samples: VecDeque<u64>,
80 /// Maximum number of wait samples retained. `0` disables sampling; the
81 /// default matches [`default_wait_sample_capacity`], ~80 KB of RAM.
82 #[serde(default = "default_wait_sample_capacity")]
83 pub(crate) wait_sample_capacity: usize,
84}
85
86/// Default capacity for the wait-sample ring buffer. 10 000 samples is
87/// enough to keep a stable p95 over multi-hour simulations while bounding
88/// memory at ~80 KB. Exposed as a named function so serde's
89/// `#[serde(default = "...")]` can reference it for schema evolution.
90const fn default_wait_sample_capacity() -> usize {
91 10_000
92}
93
94impl Default for Metrics {
95 /// Delegates to [`Metrics::new`] so `#[derive(Default)]`-on-holders and
96 /// direct `Metrics::default()` callers get the same 3600-tick throughput
97 /// window and 10 000-sample wait buffer that `new()` wires up. Without
98 /// this, the derived `Default` would zero every field — silently
99 /// disabling `p95_wait_time` sampling and collapsing the throughput
100 /// window to an empty range (greptile review of #347).
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl Metrics {
107 /// Create a new `Metrics` with default throughput window (3600 ticks)
108 /// and default wait-sample capacity (10 000).
109 #[must_use]
110 pub const fn new() -> Self {
111 Self {
112 avg_wait_time: 0.0,
113 avg_ride_time: 0.0,
114 max_wait_time: 0,
115 throughput: 0,
116 total_delivered: 0,
117 total_abandoned: 0,
118 total_spawned: 0,
119 abandonment_rate: 0.0,
120 total_distance: 0.0,
121 utilization_by_group: BTreeMap::new(),
122 reposition_distance: 0.0,
123 total_moves: 0,
124 total_settled: 0,
125 total_rerouted: 0,
126 #[cfg(feature = "energy")]
127 total_energy_consumed: 0.0,
128 #[cfg(feature = "energy")]
129 total_energy_regenerated: 0.0,
130 sum_wait_ticks: 0,
131 sum_ride_ticks: 0,
132 boarded_count: 0,
133 delivered_count: 0,
134 delivery_window: VecDeque::new(),
135 throughput_window_ticks: 3600, // default: 1 minute at 60 tps
136 wait_samples: VecDeque::new(),
137 wait_sample_capacity: default_wait_sample_capacity(),
138 }
139 }
140
141 /// Set the throughput window size (builder pattern).
142 #[must_use]
143 pub const fn with_throughput_window(mut self, window_ticks: u64) -> Self {
144 self.throughput_window_ticks = window_ticks;
145 self
146 }
147
148 /// Set the wait-sample ring buffer capacity (builder pattern). `0`
149 /// disables wait sampling entirely; [`percentile_wait_time`](Self::percentile_wait_time)
150 /// will return 0 once the buffer is empty.
151 ///
152 /// Shrinking below the current sample count truncates from the front
153 /// (oldest samples evicted first) so the retained window always
154 /// represents the most recent waits.
155 #[must_use]
156 pub fn with_wait_sample_capacity(mut self, capacity: usize) -> Self {
157 self.wait_sample_capacity = capacity;
158 while self.wait_samples.len() > capacity {
159 self.wait_samples.pop_front();
160 }
161 self
162 }
163
164 // ── Getters ──────────────────────────────────��───────────────────
165
166 /// Average wait time in ticks (spawn to board).
167 #[must_use]
168 pub const fn avg_wait_time(&self) -> f64 {
169 self.avg_wait_time
170 }
171
172 /// Average ride time in ticks (board to exit).
173 #[must_use]
174 pub const fn avg_ride_time(&self) -> f64 {
175 self.avg_ride_time
176 }
177
178 /// Maximum wait time observed (ticks).
179 #[must_use]
180 pub const fn max_wait_time(&self) -> u64 {
181 self.max_wait_time
182 }
183
184 /// 95th-percentile wait time over the last `wait_sample_capacity`
185 /// boardings (ticks). Shorthand for `percentile_wait_time(95.0)`.
186 /// Returns `0` when no samples have been recorded.
187 ///
188 /// CIBSE and community-benchmark traffic studies score against
189 /// percentiles rather than the `(avg, max)` pair alone — max is
190 /// dominated by rare outliers, avg hides the tail.
191 #[must_use]
192 pub fn p95_wait_time(&self) -> u64 {
193 self.percentile_wait_time(95.0)
194 }
195
196 /// Wait-time percentile over the retained sample window (ticks).
197 ///
198 /// Uses the "nearest-rank" method: `ceil(p/100 * n)`-th smallest
199 /// sample, clamped into `0..n`. `p = 0.0` returns the minimum,
200 /// `p = 100.0` returns the maximum. Returns `0` when the buffer
201 /// is empty.
202 ///
203 /// # Panics
204 /// Panics if `p` is NaN or outside `[0.0, 100.0]`.
205 #[must_use]
206 pub fn percentile_wait_time(&self, p: f64) -> u64 {
207 assert!(
208 p.is_finite() && (0.0..=100.0).contains(&p),
209 "percentile must be finite and in [0, 100], got {p}"
210 );
211 if self.wait_samples.is_empty() {
212 return 0;
213 }
214 let mut sorted: Vec<u64> = self.wait_samples.iter().copied().collect();
215 sorted.sort_unstable();
216 let n = sorted.len();
217 #[allow(clippy::cast_precision_loss)] // n ≤ capacity, set by user
218 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
219 let rank = ((p / 100.0) * n as f64).ceil() as usize;
220 let idx = rank.saturating_sub(1).min(n - 1);
221 sorted[idx]
222 }
223
224 /// Number of wait samples currently retained in the ring buffer.
225 /// Useful for tests and HUDs that want to display "5 000 samples
226 /// over last window" style diagnostics.
227 #[must_use]
228 pub fn wait_sample_count(&self) -> usize {
229 self.wait_samples.len()
230 }
231
232 /// Riders delivered in the current throughput window.
233 #[must_use]
234 pub const fn throughput(&self) -> u64 {
235 self.throughput
236 }
237
238 /// Riders delivered total.
239 #[must_use]
240 pub const fn total_delivered(&self) -> u64 {
241 self.total_delivered
242 }
243
244 /// Riders who abandoned.
245 #[must_use]
246 pub const fn total_abandoned(&self) -> u64 {
247 self.total_abandoned
248 }
249
250 /// Total riders spawned.
251 #[must_use]
252 pub const fn total_spawned(&self) -> u64 {
253 self.total_spawned
254 }
255
256 /// Abandonment rate (0.0 - 1.0).
257 #[must_use]
258 pub const fn abandonment_rate(&self) -> f64 {
259 self.abandonment_rate
260 }
261
262 /// Total distance traveled by all elevators.
263 #[must_use]
264 pub const fn total_distance(&self) -> f64 {
265 self.total_distance
266 }
267
268 /// Per-group instantaneous elevator utilization (fraction of elevators moving).
269 #[must_use]
270 pub const fn utilization_by_group(&self) -> &BTreeMap<String, f64> {
271 &self.utilization_by_group
272 }
273
274 /// Total distance traveled by elevators while repositioning.
275 #[must_use]
276 pub const fn reposition_distance(&self) -> f64 {
277 self.reposition_distance
278 }
279
280 /// Total rounded-floor transitions across all elevators (passing-floor
281 /// crossings plus arrivals).
282 #[must_use]
283 pub const fn total_moves(&self) -> u64 {
284 self.total_moves
285 }
286
287 /// Total riders settled as residents.
288 #[must_use]
289 pub const fn total_settled(&self) -> u64 {
290 self.total_settled
291 }
292
293 /// Total riders rerouted from resident phase.
294 #[must_use]
295 pub const fn total_rerouted(&self) -> u64 {
296 self.total_rerouted
297 }
298
299 /// Window size for throughput calculation (ticks).
300 #[must_use]
301 pub const fn throughput_window_ticks(&self) -> u64 {
302 self.throughput_window_ticks
303 }
304
305 /// Total energy consumed by all elevators (requires `energy` feature).
306 #[cfg(feature = "energy")]
307 #[must_use]
308 pub const fn total_energy_consumed(&self) -> f64 {
309 self.total_energy_consumed
310 }
311
312 /// Total energy regenerated by all elevators (requires `energy` feature).
313 #[cfg(feature = "energy")]
314 #[must_use]
315 pub const fn total_energy_regenerated(&self) -> f64 {
316 self.total_energy_regenerated
317 }
318
319 /// Net energy: consumed minus regenerated (requires `energy` feature).
320 #[cfg(feature = "energy")]
321 #[must_use]
322 pub const fn net_energy(&self) -> f64 {
323 self.total_energy_consumed - self.total_energy_regenerated
324 }
325
326 // ── Recording ───────────────────���────────────────────────────────
327
328 /// Overall utilization: average across all groups.
329 #[must_use]
330 pub fn avg_utilization(&self) -> f64 {
331 if self.utilization_by_group.is_empty() {
332 return 0.0;
333 }
334 let sum: f64 = self.utilization_by_group.values().sum();
335 sum / self.utilization_by_group.len() as f64
336 }
337
338 /// Record a rider spawning.
339 pub(crate) const fn record_spawn(&mut self) {
340 self.total_spawned += 1;
341 }
342
343 /// Record a rider boarding. `wait_ticks` = `tick_boarded` - `tick_spawned`.
344 #[allow(clippy::cast_precision_loss)] // rider counts fit in f64 mantissa
345 pub(crate) fn record_board(&mut self, wait_ticks: u64) {
346 self.boarded_count += 1;
347 self.sum_wait_ticks += wait_ticks;
348 self.avg_wait_time = self.sum_wait_ticks as f64 / self.boarded_count as f64;
349 if wait_ticks > self.max_wait_time {
350 self.max_wait_time = wait_ticks;
351 }
352 if self.wait_sample_capacity > 0 {
353 if self.wait_samples.len() == self.wait_sample_capacity {
354 self.wait_samples.pop_front();
355 }
356 self.wait_samples.push_back(wait_ticks);
357 }
358 }
359
360 /// Record a rider exiting. `ride_ticks` = `tick_exited` - `tick_boarded`.
361 #[allow(clippy::cast_precision_loss)] // rider counts fit in f64 mantissa
362 pub(crate) fn record_delivery(&mut self, ride_ticks: u64, tick: u64) {
363 self.delivered_count += 1;
364 self.total_delivered += 1;
365 self.sum_ride_ticks += ride_ticks;
366 self.avg_ride_time = self.sum_ride_ticks as f64 / self.delivered_count as f64;
367 self.delivery_window.push_back(tick);
368 }
369
370 /// Record a rider abandoning.
371 #[allow(clippy::cast_precision_loss)] // rider counts fit in f64 mantissa
372 pub(crate) fn record_abandonment(&mut self) {
373 self.total_abandoned += 1;
374 if self.total_spawned > 0 {
375 self.abandonment_rate = self.total_abandoned as f64 / self.total_spawned as f64;
376 }
377 }
378
379 /// Record a rider settling as a resident.
380 pub(crate) const fn record_settle(&mut self) {
381 self.total_settled += 1;
382 }
383
384 /// Record a resident rider being rerouted.
385 pub(crate) const fn record_reroute(&mut self) {
386 self.total_rerouted += 1;
387 }
388
389 /// Record elevator distance traveled this tick.
390 pub(crate) fn record_distance(&mut self, distance: f64) {
391 self.total_distance += distance;
392 }
393
394 /// Record elevator distance traveled while repositioning.
395 pub(crate) fn record_reposition_distance(&mut self, distance: f64) {
396 self.reposition_distance += distance;
397 }
398
399 /// Record energy consumption and regeneration.
400 #[cfg(feature = "energy")]
401 pub(crate) fn record_energy(&mut self, consumed: f64, regenerated: f64) {
402 self.total_energy_consumed += consumed;
403 self.total_energy_regenerated += regenerated;
404 }
405
406 /// Update windowed throughput. Call once per tick.
407 #[allow(clippy::cast_possible_truncation)] // window len always fits in u64
408 pub(crate) fn update_throughput(&mut self, current_tick: u64) {
409 let cutoff = current_tick.saturating_sub(self.throughput_window_ticks);
410 // Delivery ticks are inserted in order, so expired entries are at the front.
411 while self.delivery_window.front().is_some_and(|&t| t <= cutoff) {
412 self.delivery_window.pop_front();
413 }
414 self.throughput = self.delivery_window.len() as u64;
415 }
416}
417
418impl fmt::Display for Metrics {
419 /// Compact one-line summary for HUDs and logs.
420 ///
421 /// ```
422 /// # use elevator_core::metrics::Metrics;
423 /// let m = Metrics::new();
424 /// assert_eq!(format!("{m}"), "0 delivered, avg wait 0.0t, 0% util");
425 /// ```
426 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427 write!(
428 f,
429 "{} delivered, avg wait {:.1}t, {:.0}% util",
430 self.total_delivered,
431 self.avg_wait_time,
432 self.avg_utilization() * 100.0,
433 )
434 }
435}