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 let rank = ((p / 100.0) * n as f64).ceil() as usize;
218 let idx = rank.saturating_sub(1).min(n - 1);
219 sorted[idx]
220 }
221
222 /// Number of wait samples currently retained in the ring buffer.
223 /// Useful for tests and HUDs that want to display "5 000 samples
224 /// over last window" style diagnostics.
225 #[must_use]
226 pub fn wait_sample_count(&self) -> usize {
227 self.wait_samples.len()
228 }
229
230 /// Riders delivered in the current throughput window.
231 #[must_use]
232 pub const fn throughput(&self) -> u64 {
233 self.throughput
234 }
235
236 /// Riders delivered total.
237 #[must_use]
238 pub const fn total_delivered(&self) -> u64 {
239 self.total_delivered
240 }
241
242 /// Riders who abandoned.
243 #[must_use]
244 pub const fn total_abandoned(&self) -> u64 {
245 self.total_abandoned
246 }
247
248 /// Total riders spawned.
249 #[must_use]
250 pub const fn total_spawned(&self) -> u64 {
251 self.total_spawned
252 }
253
254 /// Abandonment rate (0.0 - 1.0).
255 #[must_use]
256 pub const fn abandonment_rate(&self) -> f64 {
257 self.abandonment_rate
258 }
259
260 /// Total distance traveled by all elevators.
261 #[must_use]
262 pub const fn total_distance(&self) -> f64 {
263 self.total_distance
264 }
265
266 /// Per-group instantaneous elevator utilization (fraction of elevators moving).
267 #[must_use]
268 pub const fn utilization_by_group(&self) -> &BTreeMap<String, f64> {
269 &self.utilization_by_group
270 }
271
272 /// Total distance traveled by elevators while repositioning.
273 #[must_use]
274 pub const fn reposition_distance(&self) -> f64 {
275 self.reposition_distance
276 }
277
278 /// Total rounded-floor transitions across all elevators (passing-floor
279 /// crossings plus arrivals).
280 #[must_use]
281 pub const fn total_moves(&self) -> u64 {
282 self.total_moves
283 }
284
285 /// Total riders settled as residents.
286 #[must_use]
287 pub const fn total_settled(&self) -> u64 {
288 self.total_settled
289 }
290
291 /// Total riders rerouted from resident phase.
292 #[must_use]
293 pub const fn total_rerouted(&self) -> u64 {
294 self.total_rerouted
295 }
296
297 /// Window size for throughput calculation (ticks).
298 #[must_use]
299 pub const fn throughput_window_ticks(&self) -> u64 {
300 self.throughput_window_ticks
301 }
302
303 /// Total energy consumed by all elevators (requires `energy` feature).
304 #[cfg(feature = "energy")]
305 #[must_use]
306 pub const fn total_energy_consumed(&self) -> f64 {
307 self.total_energy_consumed
308 }
309
310 /// Total energy regenerated by all elevators (requires `energy` feature).
311 #[cfg(feature = "energy")]
312 #[must_use]
313 pub const fn total_energy_regenerated(&self) -> f64 {
314 self.total_energy_regenerated
315 }
316
317 /// Net energy: consumed minus regenerated (requires `energy` feature).
318 #[cfg(feature = "energy")]
319 #[must_use]
320 pub const fn net_energy(&self) -> f64 {
321 self.total_energy_consumed - self.total_energy_regenerated
322 }
323
324 // ── Recording ───────────────────���────────────────────────────────
325
326 /// Overall utilization: average across all groups.
327 #[must_use]
328 pub fn avg_utilization(&self) -> f64 {
329 if self.utilization_by_group.is_empty() {
330 return 0.0;
331 }
332 let sum: f64 = self.utilization_by_group.values().sum();
333 sum / self.utilization_by_group.len() as f64
334 }
335
336 /// Record a rider spawning.
337 pub(crate) const fn record_spawn(&mut self) {
338 self.total_spawned += 1;
339 }
340
341 /// Record a rider boarding. `wait_ticks` = `tick_boarded` - `tick_spawned`.
342 pub(crate) fn record_board(&mut self, wait_ticks: u64) {
343 self.boarded_count += 1;
344 self.sum_wait_ticks += wait_ticks;
345 self.avg_wait_time = self.sum_wait_ticks as f64 / self.boarded_count as f64;
346 if wait_ticks > self.max_wait_time {
347 self.max_wait_time = wait_ticks;
348 }
349 if self.wait_sample_capacity > 0 {
350 if self.wait_samples.len() == self.wait_sample_capacity {
351 self.wait_samples.pop_front();
352 }
353 self.wait_samples.push_back(wait_ticks);
354 }
355 }
356
357 /// Record a rider exiting. `ride_ticks` = `tick_exited` - `tick_boarded`.
358 pub(crate) fn record_delivery(&mut self, ride_ticks: u64, tick: u64) {
359 self.delivered_count += 1;
360 self.total_delivered += 1;
361 self.sum_ride_ticks += ride_ticks;
362 self.avg_ride_time = self.sum_ride_ticks as f64 / self.delivered_count as f64;
363 self.delivery_window.push_back(tick);
364 }
365
366 /// Record a rider abandoning.
367 pub(crate) fn record_abandonment(&mut self) {
368 self.total_abandoned += 1;
369 if self.total_spawned > 0 {
370 self.abandonment_rate = self.total_abandoned as f64 / self.total_spawned as f64;
371 }
372 }
373
374 /// Record a rider settling as a resident.
375 pub(crate) const fn record_settle(&mut self) {
376 self.total_settled += 1;
377 }
378
379 /// Record a resident rider being rerouted.
380 pub(crate) const fn record_reroute(&mut self) {
381 self.total_rerouted += 1;
382 }
383
384 /// Record elevator distance traveled this tick.
385 pub(crate) fn record_distance(&mut self, distance: f64) {
386 self.total_distance += distance;
387 }
388
389 /// Record elevator distance traveled while repositioning.
390 pub(crate) fn record_reposition_distance(&mut self, distance: f64) {
391 self.reposition_distance += distance;
392 }
393
394 /// Record energy consumption and regeneration.
395 #[cfg(feature = "energy")]
396 pub(crate) fn record_energy(&mut self, consumed: f64, regenerated: f64) {
397 self.total_energy_consumed += consumed;
398 self.total_energy_regenerated += regenerated;
399 }
400
401 /// Update windowed throughput. Call once per tick.
402 pub(crate) fn update_throughput(&mut self, current_tick: u64) {
403 let cutoff = current_tick.saturating_sub(self.throughput_window_ticks);
404 // Delivery ticks are inserted in order, so expired entries are at the front.
405 while self.delivery_window.front().is_some_and(|&t| t <= cutoff) {
406 self.delivery_window.pop_front();
407 }
408 self.throughput = self.delivery_window.len() as u64;
409 }
410}
411
412impl fmt::Display for Metrics {
413 /// Compact one-line summary for HUDs and logs.
414 ///
415 /// ```
416 /// # use elevator_core::metrics::Metrics;
417 /// let m = Metrics::new();
418 /// assert_eq!(format!("{m}"), "0 delivered, avg wait 0.0t, 0% util");
419 /// ```
420 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421 write!(
422 f,
423 "{} delivered, avg wait {:.1}t, {:.0}% util",
424 self.total_delivered,
425 self.avg_wait_time,
426 self.avg_utilization() * 100.0,
427 )
428 }
429}