Skip to main content

rustsim_traffic/
signal.rs

1//! Signal phase timing engine for transport simulations.
2//!
3//! Provides reusable signal timing semantics that can be used by any transport
4//! simulation without coupling to a specific simulation engine.
5//!
6//! # Model
7//!
8//! A fixed-time signal cycle is divided into green and red intervals:
9//!
10//! ```text
11//! ├── green_1 ──┤── green_2 ──┤───── red ─────┤
12//! 0             g₁            g₁+g₂           cycle_s
13//! ```
14//!
15//! The effective time within the cycle accounts for an optional offset:
16//!
17//!   `effective_t = (sim_time_s - offset_s) mod cycle_s`
18//!
19//! # Example
20//!
21//! ```
22//! use rustsim_traffic::signal::{SignalPhase, SignalTiming};
23//!
24//! let timing = SignalTiming::new(60.0, 0.0, vec![30.0]);
25//! let (phase, remaining) = timing.phase_at(0.0);
26//! assert_eq!(phase, SignalPhase::Green);
27//! assert!((remaining - 30.0).abs() < 1e-6);
28//!
29//! let (phase, remaining) = timing.phase_at(45.0);
30//! assert_eq!(phase, SignalPhase::Red);
31//! assert!((remaining - 15.0).abs() < 1e-6);
32//! ```
33
34/// The current phase of a traffic signal.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum SignalPhase {
37    /// Agents may proceed (no signal delay).
38    Green,
39    /// Agents must wait.
40    Red,
41}
42
43/// Fixed-time signal timing parameters.
44///
45/// Describes a repeating cycle of green and red intervals. Multiple green
46/// phases can be specified (their durations are summed to form the total
47/// green window at the start of each cycle).
48#[derive(Debug, Clone, PartialEq)]
49pub struct SignalTiming {
50    /// Total cycle length in seconds.
51    pub cycle_s: f64,
52    /// Offset in seconds (shifts the start of the green window).
53    pub offset_s: f64,
54    /// Durations of each green phase in seconds. The sum of these values
55    /// defines the total green window at the start of each cycle.
56    pub green_phases_s: Vec<f64>,
57}
58
59impl SignalTiming {
60    /// Create a new signal timing with the given cycle, offset, and green
61    /// phase durations.
62    pub fn new(cycle_s: f64, offset_s: f64, green_phases_s: Vec<f64>) -> Self {
63        Self {
64            cycle_s,
65            offset_s,
66            green_phases_s,
67        }
68    }
69
70    /// Determine the signal phase at the given simulation time.
71    ///
72    /// Returns `(phase, remaining_s)` where `remaining_s` is the time in
73    /// seconds until the phase changes.
74    ///
75    /// - If `cycle_s <= 0`, returns `(Green, 0.0)`.
76    /// - If total green >= cycle, returns `(Green, 0.0)` (always permissive).
77    /// - If total green <= 0, returns `(Red, cycle_s)` (always blocked).
78    pub fn phase_at(&self, sim_time_s: f64) -> (SignalPhase, f64) {
79        if self.cycle_s <= 0.0 {
80            return (SignalPhase::Green, 0.0);
81        }
82        let total_green: f64 = self.green_phases_s.iter().sum();
83        if total_green <= 0.0 {
84            return (SignalPhase::Red, self.cycle_s);
85        }
86        if total_green >= self.cycle_s {
87            return (SignalPhase::Green, 0.0);
88        }
89
90        let effective = ((sim_time_s - self.offset_s) % self.cycle_s + self.cycle_s) % self.cycle_s;
91
92        if effective < total_green {
93            let remaining_green = total_green - effective;
94            (SignalPhase::Green, remaining_green)
95        } else {
96            let remaining_red = self.cycle_s - effective;
97            (SignalPhase::Red, remaining_red)
98        }
99    }
100
101    /// Total green time per cycle (sum of all green phase durations).
102    pub fn total_green_s(&self) -> f64 {
103        self.green_phases_s.iter().sum()
104    }
105
106    /// Total red time per cycle.
107    pub fn total_red_s(&self) -> f64 {
108        (self.cycle_s - self.total_green_s()).max(0.0)
109    }
110
111    /// Green ratio (g/C). Returns 0.0 if cycle is zero.
112    pub fn green_ratio(&self) -> f64 {
113        if self.cycle_s <= 0.0 {
114            0.0
115        } else {
116            (self.total_green_s() / self.cycle_s).clamp(0.0, 1.0)
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn green_at_start_of_cycle() {
127        let timing = SignalTiming::new(60.0, 0.0, vec![30.0]);
128        let (phase, remaining) = timing.phase_at(0.0);
129        assert_eq!(phase, SignalPhase::Green);
130        assert!((remaining - 30.0).abs() < 1e-6);
131    }
132
133    #[test]
134    fn red_in_second_half() {
135        let timing = SignalTiming::new(60.0, 0.0, vec![30.0]);
136        let (phase, remaining) = timing.phase_at(45.0);
137        assert_eq!(phase, SignalPhase::Red);
138        assert!((remaining - 15.0).abs() < 1e-6);
139    }
140
141    #[test]
142    fn wraps_around_cycle() {
143        let timing = SignalTiming::new(60.0, 0.0, vec![30.0]);
144        // t=65 → effective=5 → green
145        let (phase, _) = timing.phase_at(65.0);
146        assert_eq!(phase, SignalPhase::Green);
147        // t=95 → effective=35 → red
148        let (phase, _) = timing.phase_at(95.0);
149        assert_eq!(phase, SignalPhase::Red);
150    }
151
152    #[test]
153    fn offset_shifts_phase() {
154        let timing = SignalTiming::new(60.0, 10.0, vec![30.0]);
155        // At sim_time=10: effective = (10-10)%60 = 0 → green
156        let (phase, _) = timing.phase_at(10.0);
157        assert_eq!(phase, SignalPhase::Green);
158        // At sim_time=5: effective = (5-10)%60 = 55 → red
159        let (phase, _) = timing.phase_at(5.0);
160        assert_eq!(phase, SignalPhase::Red);
161    }
162
163    #[test]
164    fn zero_cycle_always_green() {
165        let timing = SignalTiming::new(0.0, 0.0, vec![30.0]);
166        let (phase, _) = timing.phase_at(100.0);
167        assert_eq!(phase, SignalPhase::Green);
168    }
169
170    #[test]
171    fn all_green_cycle() {
172        let timing = SignalTiming::new(60.0, 0.0, vec![60.0]);
173        for t in [0.0, 30.0, 59.9] {
174            let (phase, _) = timing.phase_at(t);
175            assert_eq!(phase, SignalPhase::Green);
176        }
177    }
178
179    #[test]
180    fn zero_green_always_red() {
181        let timing = SignalTiming::new(60.0, 0.0, vec![]);
182        let (phase, _) = timing.phase_at(0.0);
183        assert_eq!(phase, SignalPhase::Red);
184    }
185
186    #[test]
187    fn multiple_green_phases_summed() {
188        let timing = SignalTiming::new(60.0, 0.0, vec![10.0, 10.0]);
189        // Total green = 20s. At t=15 → green.
190        let (phase, _) = timing.phase_at(15.0);
191        assert_eq!(phase, SignalPhase::Green);
192        // At t=25 → red.
193        let (phase, _) = timing.phase_at(25.0);
194        assert_eq!(phase, SignalPhase::Red);
195    }
196
197    #[test]
198    fn green_ratio_computed_correctly() {
199        let timing = SignalTiming::new(60.0, 0.0, vec![30.0]);
200        assert!((timing.green_ratio() - 0.5).abs() < 1e-6);
201        assert!((timing.total_red_s() - 30.0).abs() < 1e-6);
202    }
203}