rustsim-traffic 0.0.1

Transport-domain semantics for rustsim: multimodal movement, controls, and routing metadata
Documentation
//! Signal phase timing engine for transport simulations.
//!
//! Provides reusable signal timing semantics that can be used by any transport
//! simulation without coupling to a specific simulation engine.
//!
//! # Model
//!
//! A fixed-time signal cycle is divided into green and red intervals:
//!
//! ```text
//! ├── green_1 ──┤── green_2 ──┤───── red ─────┤
//! 0             g₁            g₁+g₂           cycle_s
//! ```
//!
//! The effective time within the cycle accounts for an optional offset:
//!
//!   `effective_t = (sim_time_s - offset_s) mod cycle_s`
//!
//! # Example
//!
//! ```
//! use rustsim_traffic::signal::{SignalPhase, SignalTiming};
//!
//! let timing = SignalTiming::new(60.0, 0.0, vec![30.0]);
//! let (phase, remaining) = timing.phase_at(0.0);
//! assert_eq!(phase, SignalPhase::Green);
//! assert!((remaining - 30.0).abs() < 1e-6);
//!
//! let (phase, remaining) = timing.phase_at(45.0);
//! assert_eq!(phase, SignalPhase::Red);
//! assert!((remaining - 15.0).abs() < 1e-6);
//! ```

/// The current phase of a traffic signal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SignalPhase {
    /// Agents may proceed (no signal delay).
    Green,
    /// Agents must wait.
    Red,
}

/// Fixed-time signal timing parameters.
///
/// Describes a repeating cycle of green and red intervals. Multiple green
/// phases can be specified (their durations are summed to form the total
/// green window at the start of each cycle).
#[derive(Debug, Clone, PartialEq)]
pub struct SignalTiming {
    /// Total cycle length in seconds.
    pub cycle_s: f64,
    /// Offset in seconds (shifts the start of the green window).
    pub offset_s: f64,
    /// Durations of each green phase in seconds. The sum of these values
    /// defines the total green window at the start of each cycle.
    pub green_phases_s: Vec<f64>,
}

impl SignalTiming {
    /// Create a new signal timing with the given cycle, offset, and green
    /// phase durations.
    pub fn new(cycle_s: f64, offset_s: f64, green_phases_s: Vec<f64>) -> Self {
        Self {
            cycle_s,
            offset_s,
            green_phases_s,
        }
    }

    /// Determine the signal phase at the given simulation time.
    ///
    /// Returns `(phase, remaining_s)` where `remaining_s` is the time in
    /// seconds until the phase changes.
    ///
    /// - If `cycle_s <= 0`, returns `(Green, 0.0)`.
    /// - If total green >= cycle, returns `(Green, 0.0)` (always permissive).
    /// - If total green <= 0, returns `(Red, cycle_s)` (always blocked).
    pub fn phase_at(&self, sim_time_s: f64) -> (SignalPhase, f64) {
        if self.cycle_s <= 0.0 {
            return (SignalPhase::Green, 0.0);
        }
        let total_green: f64 = self.green_phases_s.iter().sum();
        if total_green <= 0.0 {
            return (SignalPhase::Red, self.cycle_s);
        }
        if total_green >= self.cycle_s {
            return (SignalPhase::Green, 0.0);
        }

        let effective = ((sim_time_s - self.offset_s) % self.cycle_s + self.cycle_s) % self.cycle_s;

        if effective < total_green {
            let remaining_green = total_green - effective;
            (SignalPhase::Green, remaining_green)
        } else {
            let remaining_red = self.cycle_s - effective;
            (SignalPhase::Red, remaining_red)
        }
    }

    /// Total green time per cycle (sum of all green phase durations).
    pub fn total_green_s(&self) -> f64 {
        self.green_phases_s.iter().sum()
    }

    /// Total red time per cycle.
    pub fn total_red_s(&self) -> f64 {
        (self.cycle_s - self.total_green_s()).max(0.0)
    }

    /// Green ratio (g/C). Returns 0.0 if cycle is zero.
    pub fn green_ratio(&self) -> f64 {
        if self.cycle_s <= 0.0 {
            0.0
        } else {
            (self.total_green_s() / self.cycle_s).clamp(0.0, 1.0)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn green_at_start_of_cycle() {
        let timing = SignalTiming::new(60.0, 0.0, vec![30.0]);
        let (phase, remaining) = timing.phase_at(0.0);
        assert_eq!(phase, SignalPhase::Green);
        assert!((remaining - 30.0).abs() < 1e-6);
    }

    #[test]
    fn red_in_second_half() {
        let timing = SignalTiming::new(60.0, 0.0, vec![30.0]);
        let (phase, remaining) = timing.phase_at(45.0);
        assert_eq!(phase, SignalPhase::Red);
        assert!((remaining - 15.0).abs() < 1e-6);
    }

    #[test]
    fn wraps_around_cycle() {
        let timing = SignalTiming::new(60.0, 0.0, vec![30.0]);
        // t=65 → effective=5 → green
        let (phase, _) = timing.phase_at(65.0);
        assert_eq!(phase, SignalPhase::Green);
        // t=95 → effective=35 → red
        let (phase, _) = timing.phase_at(95.0);
        assert_eq!(phase, SignalPhase::Red);
    }

    #[test]
    fn offset_shifts_phase() {
        let timing = SignalTiming::new(60.0, 10.0, vec![30.0]);
        // At sim_time=10: effective = (10-10)%60 = 0 → green
        let (phase, _) = timing.phase_at(10.0);
        assert_eq!(phase, SignalPhase::Green);
        // At sim_time=5: effective = (5-10)%60 = 55 → red
        let (phase, _) = timing.phase_at(5.0);
        assert_eq!(phase, SignalPhase::Red);
    }

    #[test]
    fn zero_cycle_always_green() {
        let timing = SignalTiming::new(0.0, 0.0, vec![30.0]);
        let (phase, _) = timing.phase_at(100.0);
        assert_eq!(phase, SignalPhase::Green);
    }

    #[test]
    fn all_green_cycle() {
        let timing = SignalTiming::new(60.0, 0.0, vec![60.0]);
        for t in [0.0, 30.0, 59.9] {
            let (phase, _) = timing.phase_at(t);
            assert_eq!(phase, SignalPhase::Green);
        }
    }

    #[test]
    fn zero_green_always_red() {
        let timing = SignalTiming::new(60.0, 0.0, vec![]);
        let (phase, _) = timing.phase_at(0.0);
        assert_eq!(phase, SignalPhase::Red);
    }

    #[test]
    fn multiple_green_phases_summed() {
        let timing = SignalTiming::new(60.0, 0.0, vec![10.0, 10.0]);
        // Total green = 20s. At t=15 → green.
        let (phase, _) = timing.phase_at(15.0);
        assert_eq!(phase, SignalPhase::Green);
        // At t=25 → red.
        let (phase, _) = timing.phase_at(25.0);
        assert_eq!(phase, SignalPhase::Red);
    }

    #[test]
    fn green_ratio_computed_correctly() {
        let timing = SignalTiming::new(60.0, 0.0, vec![30.0]);
        assert!((timing.green_ratio() - 0.5).abs() < 1e-6);
        assert!((timing.total_red_s() - 30.0).abs() < 1e-6);
    }
}