Skip to main content

ixa/triggers/
periodic_time.rs

1use super::TriggerCriterion;
2use crate::{Context, ExecutionPhase};
3
4/// Trigger criterion for regular simulation time intervals.
5///
6/// [`PeriodicTimeTrigger`] observes the simulation clock and emits repeatedly at a configured
7/// period and execution phase.
8///
9/// ## Construction
10///
11/// ```rust,ignore
12/// PeriodicTimeTrigger::every(period)
13/// PeriodicTimeTrigger::every_with_phase(period, phase)
14/// PeriodicTimeTrigger::every(period).with_phase(phase) // Equivalent to `every_with_phase`
15/// PeriodicTimeTrigger::every(period).start_with_delay(delay)
16/// PeriodicTimeTrigger::every(period).start_at(start_time)
17/// ```
18///
19/// ## Observation
20///
21/// The observation data passed to
22/// [`TriggerCriterion::emit_with`](super::TriggerCriterion::emit_with) is
23/// [`PeriodicTimeTriggerEvent`]. It contains the simulation time observed when the scheduled
24/// periodic plan runs, the configured period, and the phase used to schedule it:
25///
26/// ```rust,ignore
27/// pub struct PeriodicTimeTriggerEvent {
28///     pub time: f64,
29///     pub period: f64,
30///     pub phase: ExecutionPhase,
31/// }
32/// ```
33///
34/// ## Semantics
35///
36/// This trigger uses the same rescheduling behavior as periodic plans: when the scheduled callback
37/// runs, the next occurrence is scheduled at `current_time + period` if there are still plans in the
38/// queue. Unlike [`Context::add_periodic_plan_with_phase`](crate::Context::add_periodic_plan_with_phase),
39/// the first occurrence is seeded explicitly so it can start at the current time, after a delay, or
40/// at an absolute simulation time.
41///
42/// By default, the first occurrence is scheduled at `context.get_current_time()` when the trigger is
43/// installed, and the execution phase is
44/// [`ExecutionPhase::Normal`](crate::ExecutionPhase::Normal).
45///
46/// The period must be positive, finite, and not NaN. A delay must be non-negative, finite, and not
47/// NaN. An absolute start time must be finite and not NaN; the context validates at trigger
48/// installation that it is not in the past.
49/// Builder inputs are converted to `f64` before these checks are applied.
50///
51/// Since time is monotonic, this criterion does not use [`Direction`](super::Direction) or
52/// [`TriggerMode`](super::TriggerMode). It emits whenever its periodic schedule executes. If several
53/// plans are scheduled for the same time, the selected [`ExecutionPhase`](crate::ExecutionPhase)
54/// controls phase ordering.
55///
56/// ## Example
57///
58/// ```rust
59/// use ixa::{Context, ExecutionPhase, IxaEvent};
60/// use ixa::triggers::{ContextTriggersExt, PeriodicTimeTrigger, TriggerCriterion};
61///
62/// #[derive(IxaEvent)]
63/// struct ReportTimeReached {
64///     time: f64,
65///     period: f64,
66///     phase: ExecutionPhase,
67/// }
68///
69/// let mut context = Context::new();
70///
71/// context.register_trigger(
72///     PeriodicTimeTrigger::every(7.0)
73///         .with_phase(ExecutionPhase::Last)
74///         .start_with_delay(7.0)
75///         .emit_with(|observation| ReportTimeReached {
76///             time: observation.time,
77///             period: observation.period,
78///             phase: observation.phase,
79///         }),
80/// );
81///
82/// context.subscribe_to_event(|_context, _event: ReportTimeReached| {
83///     // collect periodic reports
84/// });
85/// ```
86///
87pub struct PeriodicTimeTrigger {
88    period: f64,
89    start: PeriodicTimeTriggerStart,
90    phase: ExecutionPhase,
91}
92
93enum PeriodicTimeTriggerStart {
94    CurrentTime,
95    Delay(f64),
96    At(f64),
97}
98
99#[derive(Clone, Copy, Debug)]
100pub struct PeriodicTimeTriggerEvent {
101    pub time: f64,
102    pub period: f64,
103    pub phase: ExecutionPhase,
104}
105
106impl PeriodicTimeTrigger {
107    /// Creates a trigger that fires every `period` units of simulation time.
108    ///
109    /// # Panics
110    ///
111    /// Panics if `period` is not a strictly positive finite number.
112    #[must_use]
113    pub fn every(period: impl Into<f64>) -> Self {
114        let period = period.into();
115        validate_period(period);
116        Self {
117            period,
118            start: PeriodicTimeTriggerStart::CurrentTime,
119            phase: ExecutionPhase::Normal,
120        }
121    }
122
123    /// Creates a trigger that fires every `period` in the specified execution phase.
124    ///
125    /// # Panics
126    ///
127    /// Panics if `period` is not a strictly positive finite number.
128    #[must_use]
129    pub fn every_with_phase(period: impl Into<f64>, phase: ExecutionPhase) -> Self {
130        let period = period.into();
131        validate_period(period);
132        Self {
133            period,
134            start: PeriodicTimeTriggerStart::CurrentTime,
135            phase,
136        }
137    }
138
139    #[must_use]
140    pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
141        self.phase = phase;
142        self
143    }
144
145    /// Delays the first trigger occurrence by `delay`.
146    ///
147    /// # Panics
148    ///
149    /// Panics if `delay` is negative, infinite, or NaN.
150    #[must_use]
151    pub fn start_with_delay(mut self, delay: impl Into<f64>) -> Self {
152        let delay = delay.into();
153        assert!(
154            delay >= 0.0 && !delay.is_nan() && !delay.is_infinite(),
155            "delay must be greater than or equal to 0"
156        );
157        self.start = PeriodicTimeTriggerStart::Delay(delay);
158        self
159    }
160
161    /// Sets the absolute simulation time of the first trigger occurrence.
162    ///
163    /// # Panics
164    ///
165    /// Panics if `start_time` is infinite or NaN.
166    #[must_use]
167    pub fn start_at(mut self, start_time: impl Into<f64>) -> Self {
168        let start_time = start_time.into();
169        assert!(
170            !start_time.is_nan(),
171            "start_time {start_time} is invalid: cannot be NaN"
172        );
173        assert!(
174            !start_time.is_infinite(),
175            "start_time {start_time} is invalid: cannot be infinite"
176        );
177        self.start = PeriodicTimeTriggerStart::At(start_time);
178        self
179    }
180}
181
182impl TriggerCriterion for PeriodicTimeTrigger {
183    type Observation = PeriodicTimeTriggerEvent;
184
185    fn install<F>(self, context: &mut Context, on_match: F)
186    where
187        F: Fn(&mut Context, Self::Observation) + 'static,
188    {
189        let start_time = match self.start {
190            PeriodicTimeTriggerStart::CurrentTime => context.get_current_time(),
191            PeriodicTimeTriggerStart::Delay(delay) => context.get_current_time() + delay,
192            PeriodicTimeTriggerStart::At(start_time) => start_time,
193        };
194        let period = self.period;
195        let phase = self.phase;
196
197        context.add_plan_with_phase(
198            start_time,
199            move |context| {
200                context.evaluate_periodic_and_schedule_next(
201                    period,
202                    move |context| {
203                        on_match(
204                            context,
205                            PeriodicTimeTriggerEvent {
206                                time: context.get_current_time(),
207                                period,
208                                phase,
209                            },
210                        );
211                    },
212                    phase,
213                );
214            },
215            phase,
216        );
217    }
218}
219
220fn validate_period(period: f64) {
221    assert!(
222        period > 0.0 && !period.is_nan() && !period.is_infinite(),
223        "period must be greater than 0"
224    );
225}