elevator-core 5.5.1

Engine-agnostic elevator simulation library with pluggable dispatch strategies
Documentation
//! Aggregate simulation metrics (wait times, throughput, distance).

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::VecDeque;
use std::fmt;

/// Aggregated simulation metrics, updated each tick from events.
///
/// Games query this via `sim.metrics()` for HUD display, scoring,
/// or scenario evaluation.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Metrics {
    // -- Queryable metrics (accessed via getters) --
    /// Average wait time in ticks (spawn to board).
    pub(crate) avg_wait_time: f64,
    /// Average ride time in ticks (board to exit).
    pub(crate) avg_ride_time: f64,
    /// Maximum wait time observed (ticks).
    pub(crate) max_wait_time: u64,
    /// Riders delivered in the current throughput window.
    pub(crate) throughput: u64,
    /// Riders delivered total.
    pub(crate) total_delivered: u64,
    /// Riders who abandoned.
    pub(crate) total_abandoned: u64,
    /// Total riders spawned.
    pub(crate) total_spawned: u64,
    /// Abandonment rate (0.0 - 1.0).
    pub(crate) abandonment_rate: f64,
    /// Total distance traveled by all elevators.
    pub(crate) total_distance: f64,
    /// Per-group instantaneous elevator utilization: fraction of elevators
    /// currently moving (either `MovingToStop` or `Repositioning`) vs total
    /// enabled elevators. Overwritten each tick. Key is group name (String
    /// for serialization).
    #[serde(default)]
    pub(crate) utilization_by_group: HashMap<String, f64>,
    /// Total distance traveled by elevators while repositioning.
    #[serde(default)]
    pub(crate) reposition_distance: f64,
    /// Total rounded-floor transitions across all elevators
    /// (passing-floor crossings plus arrivals).
    #[serde(default)]
    pub(crate) total_moves: u64,
    /// Total riders settled as residents.
    pub(crate) total_settled: u64,
    /// Total riders rerouted from resident phase.
    pub(crate) total_rerouted: u64,

    /// Total energy consumed by all elevators.
    #[cfg(feature = "energy")]
    #[serde(default)]
    pub(crate) total_energy_consumed: f64,
    /// Total energy regenerated by all elevators.
    #[cfg(feature = "energy")]
    #[serde(default)]
    pub(crate) total_energy_regenerated: f64,

    // -- Internal accumulators --
    /// Running sum of wait ticks across all boarded riders.
    sum_wait_ticks: u64,
    /// Running sum of ride ticks across all delivered riders.
    sum_ride_ticks: u64,
    /// Number of riders that have boarded.
    boarded_count: u64,
    /// Number of riders that have been delivered.
    delivered_count: u64,
    /// Sliding window of delivery ticks, sorted ascending.
    delivery_window: VecDeque<u64>,
    /// Window size for throughput calculation.
    pub(crate) throughput_window_ticks: u64,
}

impl Metrics {
    /// Create a new `Metrics` with default throughput window (3600 ticks).
    #[must_use]
    pub fn new() -> Self {
        Self {
            throughput_window_ticks: 3600, // default: 1 minute at 60 tps
            ..Default::default()
        }
    }

    /// Set the throughput window size (builder pattern).
    #[must_use]
    pub const fn with_throughput_window(mut self, window_ticks: u64) -> Self {
        self.throughput_window_ticks = window_ticks;
        self
    }

    // ── Getters ──────────────────────────────────��───────────────────

    /// Average wait time in ticks (spawn to board).
    #[must_use]
    pub const fn avg_wait_time(&self) -> f64 {
        self.avg_wait_time
    }

    /// Average ride time in ticks (board to exit).
    #[must_use]
    pub const fn avg_ride_time(&self) -> f64 {
        self.avg_ride_time
    }

    /// Maximum wait time observed (ticks).
    #[must_use]
    pub const fn max_wait_time(&self) -> u64 {
        self.max_wait_time
    }

    /// Riders delivered in the current throughput window.
    #[must_use]
    pub const fn throughput(&self) -> u64 {
        self.throughput
    }

    /// Riders delivered total.
    #[must_use]
    pub const fn total_delivered(&self) -> u64 {
        self.total_delivered
    }

    /// Riders who abandoned.
    #[must_use]
    pub const fn total_abandoned(&self) -> u64 {
        self.total_abandoned
    }

    /// Total riders spawned.
    #[must_use]
    pub const fn total_spawned(&self) -> u64 {
        self.total_spawned
    }

    /// Abandonment rate (0.0 - 1.0).
    #[must_use]
    pub const fn abandonment_rate(&self) -> f64 {
        self.abandonment_rate
    }

    /// Total distance traveled by all elevators.
    #[must_use]
    pub const fn total_distance(&self) -> f64 {
        self.total_distance
    }

    /// Per-group instantaneous elevator utilization (fraction of elevators moving).
    #[must_use]
    pub const fn utilization_by_group(&self) -> &HashMap<String, f64> {
        &self.utilization_by_group
    }

    /// Total distance traveled by elevators while repositioning.
    #[must_use]
    pub const fn reposition_distance(&self) -> f64 {
        self.reposition_distance
    }

    /// Total rounded-floor transitions across all elevators (passing-floor
    /// crossings plus arrivals).
    #[must_use]
    pub const fn total_moves(&self) -> u64 {
        self.total_moves
    }

    /// Total riders settled as residents.
    #[must_use]
    pub const fn total_settled(&self) -> u64 {
        self.total_settled
    }

    /// Total riders rerouted from resident phase.
    #[must_use]
    pub const fn total_rerouted(&self) -> u64 {
        self.total_rerouted
    }

    /// Window size for throughput calculation (ticks).
    #[must_use]
    pub const fn throughput_window_ticks(&self) -> u64 {
        self.throughput_window_ticks
    }

    /// Total energy consumed by all elevators (requires `energy` feature).
    #[cfg(feature = "energy")]
    #[must_use]
    pub const fn total_energy_consumed(&self) -> f64 {
        self.total_energy_consumed
    }

    /// Total energy regenerated by all elevators (requires `energy` feature).
    #[cfg(feature = "energy")]
    #[must_use]
    pub const fn total_energy_regenerated(&self) -> f64 {
        self.total_energy_regenerated
    }

    /// Net energy: consumed minus regenerated (requires `energy` feature).
    #[cfg(feature = "energy")]
    #[must_use]
    pub const fn net_energy(&self) -> f64 {
        self.total_energy_consumed - self.total_energy_regenerated
    }

    // ── Recording ───────────────────���────────────────────────────────

    /// Overall utilization: average across all groups.
    #[must_use]
    pub fn avg_utilization(&self) -> f64 {
        if self.utilization_by_group.is_empty() {
            return 0.0;
        }
        let sum: f64 = self.utilization_by_group.values().sum();
        sum / self.utilization_by_group.len() as f64
    }

    /// Record a rider spawning.
    pub(crate) const fn record_spawn(&mut self) {
        self.total_spawned += 1;
    }

    /// Record a rider boarding. `wait_ticks` = `tick_boarded` - `tick_spawned`.
    #[allow(clippy::cast_precision_loss)] // rider counts fit in f64 mantissa
    pub(crate) fn record_board(&mut self, wait_ticks: u64) {
        self.boarded_count += 1;
        self.sum_wait_ticks += wait_ticks;
        self.avg_wait_time = self.sum_wait_ticks as f64 / self.boarded_count as f64;
        if wait_ticks > self.max_wait_time {
            self.max_wait_time = wait_ticks;
        }
    }

    /// Record a rider exiting. `ride_ticks` = `tick_exited` - `tick_boarded`.
    #[allow(clippy::cast_precision_loss)] // rider counts fit in f64 mantissa
    pub(crate) fn record_delivery(&mut self, ride_ticks: u64, tick: u64) {
        self.delivered_count += 1;
        self.total_delivered += 1;
        self.sum_ride_ticks += ride_ticks;
        self.avg_ride_time = self.sum_ride_ticks as f64 / self.delivered_count as f64;
        self.delivery_window.push_back(tick);
    }

    /// Record a rider abandoning.
    #[allow(clippy::cast_precision_loss)] // rider counts fit in f64 mantissa
    pub(crate) fn record_abandonment(&mut self) {
        self.total_abandoned += 1;
        if self.total_spawned > 0 {
            self.abandonment_rate = self.total_abandoned as f64 / self.total_spawned as f64;
        }
    }

    /// Record a rider settling as a resident.
    pub(crate) const fn record_settle(&mut self) {
        self.total_settled += 1;
    }

    /// Record a resident rider being rerouted.
    pub(crate) const fn record_reroute(&mut self) {
        self.total_rerouted += 1;
    }

    /// Record elevator distance traveled this tick.
    pub(crate) fn record_distance(&mut self, distance: f64) {
        self.total_distance += distance;
    }

    /// Record elevator distance traveled while repositioning.
    pub(crate) fn record_reposition_distance(&mut self, distance: f64) {
        self.reposition_distance += distance;
    }

    /// Record energy consumption and regeneration.
    #[cfg(feature = "energy")]
    pub(crate) fn record_energy(&mut self, consumed: f64, regenerated: f64) {
        self.total_energy_consumed += consumed;
        self.total_energy_regenerated += regenerated;
    }

    /// Update windowed throughput. Call once per tick.
    #[allow(clippy::cast_possible_truncation)] // window len always fits in u64
    pub(crate) fn update_throughput(&mut self, current_tick: u64) {
        let cutoff = current_tick.saturating_sub(self.throughput_window_ticks);
        // Delivery ticks are inserted in order, so expired entries are at the front.
        while self.delivery_window.front().is_some_and(|&t| t <= cutoff) {
            self.delivery_window.pop_front();
        }
        self.throughput = self.delivery_window.len() as u64;
    }
}

impl fmt::Display for Metrics {
    /// Compact one-line summary for HUDs and logs.
    ///
    /// ```
    /// # use elevator_core::metrics::Metrics;
    /// let m = Metrics::new();
    /// assert_eq!(format!("{m}"), "0 delivered, avg wait 0.0t, 0% util");
    /// ```
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} delivered, avg wait {:.1}t, {:.0}% util",
            self.total_delivered,
            self.avg_wait_time,
            self.avg_utilization() * 100.0,
        )
    }
}