Skip to main content

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}