Skip to main content

ph_curves/
tickless.rs

1//! Tickless scheduling helpers for monotonic curves.
2//!
3//! Instead of polling a curve at a fixed tick rate, the tickless scheduler
4//! computes the exact wall-clock deadline at which the *quantized* output
5//! value will next change.  This lets interrupt-driven firmware sleep between
6//! transitions, saving power and CPU cycles.
7//!
8//! # Wrapping clocks
9//!
10//! Timestamps are free-running `u32` milliseconds. All schedule math uses
11//! wrapping arithmetic so a segment that starts near `u32::MAX` can cross the
12//! ~49.7-day rollover.
13//!
14//! For durations up to `i32::MAX` milliseconds (~24.85 days), before-start /
15//! past-end classification uses the usual half-range signed-delta convention
16//! (`now.wrapping_sub(t0) as i32`). Longer durations remain supported when
17//! `now_ms` is a segment-relative elapsed time with `t0_ms == 0` and
18//! `now_ms <= duration_ms` — the mode used for multi-day relative ramps.
19//! A wrapping wall clock cannot unambiguously represent a single segment
20//! longer than half the clock period.
21//!
22//! Deadline clamping works on *offsets from `t0_ms`*, never on absolute
23//! timestamps. Every offset is bounded by `duration_ms`, so the comparisons
24//! stay ordinary `u32` ones and remain correct across the full `u32` duration
25//! range. The half-range convention appears only where it is unavoidable —
26//! deciding whether `now_ms` precedes the segment at all. Clamping absolute
27//! timestamps against a half-range convention instead would misread any
28//! segment longer than ~24.85 days as already finished.
29
30use crate::MonotonicCurve;
31use crate::math::{Rounding, UnitValue, next_target_value, quantize};
32
33/// Half the `u32` range. Deltas larger than this are treated as negative under
34/// the signed wrapping convention used by free-running embedded clocks.
35const HALF_RANGE_MS: u32 = i32::MAX as u32;
36
37/// Progress of `now_ms` relative to a segment `[t0, t0+duration)`.
38#[derive(Copy, Clone, Debug, Eq, PartialEq)]
39enum SegmentProgress {
40    BeforeStart,
41    InSegment(u32),
42    PastEnd,
43}
44
45/// Classify `now` against a segment start/duration with wrap-safe elapsed math.
46///
47/// When `duration_ms <= HALF_RANGE_MS`, elapsed values above the half-range are
48/// "before start". Longer durations skip that check so relative `t0 == 0`
49/// schedules can still use the full `u32` duration range.
50fn segment_progress(t0_ms: u32, duration_ms: u32, now_ms: u32) -> SegmentProgress {
51    if duration_ms == 0 {
52        return SegmentProgress::PastEnd;
53    }
54    let elapsed = now_ms.wrapping_sub(t0_ms);
55    if duration_ms <= HALF_RANGE_MS && elapsed > HALF_RANGE_MS {
56        SegmentProgress::BeforeStart
57    } else if elapsed >= duration_ms {
58        SegmentProgress::PastEnd
59    } else {
60        SegmentProgress::InSegment(elapsed)
61    }
62}
63
64/// Repeat behaviour for a tickless schedule.
65#[derive(Copy, Clone, Debug, Eq, PartialEq)]
66pub enum RepeatMode {
67    /// Play once and stop.
68    Once,
69    /// Loop back to the start value after each cycle.
70    Repeat,
71    /// Reverse direction after each cycle (start→end, end→start, …).
72    PingPong,
73}
74
75/// A single output produced by the tickless scheduler.
76///
77/// Each deadline tells the caller what the current quantized output value is
78/// and when the *next* transition will occur, so the caller can set a timer
79/// and go to sleep.
80#[derive(Copy, Clone, Debug, Eq, PartialEq)]
81pub struct TicklessDeadline {
82    /// Wall-clock time (in milliseconds) at which the output will next change.
83    ///
84    /// Set a hardware timer or `sleep_until` to this value. On a free-running
85    /// `u32` clock the value may be numerically less than `now` when the
86    /// deadline crosses the rollover; compare with wrapping remaining-time
87    /// (`deadline.wrapping_sub(now)`), not signed absolute order.
88    pub deadline_ms: u32,
89    /// The quantized output value that should be applied *now* (at the time
90    /// this deadline was computed).
91    pub current_val: u16,
92}
93
94/// A tickless schedule bound to a monotonic curve and segment parameters.
95///
96/// `C` is the curve type and `T` is the curve's normalised value type
97/// (e.g. `u8`). Use [`Tickless::tickless_schedule`] to construct one
98/// fluently, or build it directly with [`TicklessSchedule::new`].
99#[derive(Copy, Clone, Debug)]
100pub struct TicklessSchedule<C, T: UnitValue = u8> {
101    curve: C,
102    t0_ms: u32,
103    duration_ms: u32,
104    start_val: u16,
105    end_val: u16,
106    step: u16,
107    rounding: Rounding,
108    min_dt_ms: u32,
109    repeat: RepeatMode,
110    _marker: core::marker::PhantomData<T>,
111}
112
113impl<C, T> TicklessSchedule<C, T>
114where
115    C: MonotonicCurve<T, T>,
116    T: UnitValue,
117{
118    /// Create a new tickless schedule.
119    ///
120    /// # Parameters
121    ///
122    /// - `curve` — the monotonic curve that shapes the transition.
123    /// - `t0_ms` — wall-clock start time of the segment in milliseconds.
124    /// - `duration_ms` — total duration of the segment in milliseconds.
125    /// - `start_val` — raw output value at `t = 0` (before quantization).
126    /// - `end_val` — raw output value at `t = 1` (before quantization).
127    /// - `step` — quantization step size (clamped to a minimum of 1).
128    /// - `rounding` — how values are snapped to the quantization grid.
129    /// - `min_dt_ms` — minimum time between successive deadlines.  Useful for
130    ///   rate-limiting hardware updates.  Set to `0` for no limit.
131    pub fn new(
132        curve: C,
133        t0_ms: u32,
134        duration_ms: u32,
135        start_val: u16,
136        end_val: u16,
137        step: u16,
138        rounding: Rounding,
139        min_dt_ms: u32,
140    ) -> Self {
141        Self {
142            curve,
143            t0_ms,
144            duration_ms,
145            start_val,
146            end_val,
147            step: step.max(1),
148            rounding,
149            min_dt_ms,
150            repeat: RepeatMode::Once,
151            _marker: core::marker::PhantomData,
152        }
153    }
154
155    /// Set the repeat mode, consuming and returning `self` for chaining.
156    pub fn with_repeat(mut self, mode: RepeatMode) -> Self {
157        self.repeat = mode;
158        self
159    }
160
161    /// The end time of the current segment in milliseconds.
162    ///
163    /// Computed with wrapping addition so a segment that starts near
164    /// `u32::MAX` can end after the clock rolls over.
165    pub fn end_ms(&self) -> u32 {
166        self.t0_ms.wrapping_add(self.duration_ms)
167    }
168
169    /// Compute the next deadline after `now_ms`.
170    ///
171    /// Returns the quantized output value that should be applied *now* and the
172    /// wall-clock time at which the next quantized transition will occur.
173    ///
174    /// When `now_ms` is at or past the end of the segment, the returned
175    /// deadline is `now_ms` (already due) and the final quantized value.
176    pub fn next_deadline(&self, now_ms: u32) -> TicklessDeadline {
177        let end_ms = self.end_ms();
178        let progress = segment_progress(self.t0_ms, self.duration_ms, now_ms);
179
180        let current_t = match progress {
181            SegmentProgress::BeforeStart => T::zero(),
182            SegmentProgress::PastEnd => T::one(),
183            SegmentProgress::InSegment(elapsed) => {
184                if elapsed == 0 {
185                    T::zero()
186                } else {
187                    T::from_time_frac(elapsed, self.duration_ms)
188                }
189            }
190        };
191
192        let w = self.curve.eval(current_t);
193        let raw_val = w.lerp_u16(self.start_val, self.end_val);
194        let current_val = quantize(raw_val, self.step, self.rounding);
195        let end_val_q = quantize(self.end_val, self.step, self.rounding);
196
197        if matches!(progress, SegmentProgress::PastEnd) || current_val == end_val_q {
198            let deadline_ms = if matches!(progress, SegmentProgress::PastEnd) {
199                // Already due. Prefer `now` over a numerically-smaller wrapped
200                // `end_ms` so callers do not arm a nearly-full-period sleep.
201                now_ms
202            } else {
203                end_ms
204            };
205            return TicklessDeadline {
206                deadline_ms,
207                current_val,
208            };
209        }
210
211        let increasing = self.end_val >= self.start_val;
212        let target_val = next_target_value(current_val, end_val_q, self.step, increasing);
213        let w_target = T::inv_lerp_u16(self.start_val, self.end_val, target_val);
214        let u_target = self.curve.inv(w_target);
215
216        // Clamp in offset-from-`t0` space. `PastEnd` already returned above, so
217        // `now` is either inside the segment or ahead of it, and every offset
218        // below is bounded by `duration_ms` — plain `u32` comparisons hold even
219        // when the segment is longer than half the clock period.
220        let (now_off, min_off) = match progress {
221            SegmentProgress::InSegment(elapsed) => {
222                (elapsed, elapsed.saturating_add(self.min_dt_ms))
223            }
224            // `now` precedes `t0`; its offset is negative, so it can never floor
225            // a non-negative deadline. Only the `min_dt` window reaches into the
226            // segment, and only by whatever is left after covering the gap.
227            SegmentProgress::BeforeStart => (
228                0,
229                self.min_dt_ms
230                    .saturating_sub(self.t0_ms.wrapping_sub(now_ms)),
231            ),
232            SegmentProgress::PastEnd => (self.duration_ms, self.duration_ms),
233        };
234
235        let mut dl_off = u_target.to_time_offset(self.duration_ms);
236        if dl_off < min_off {
237            dl_off = min_off;
238        }
239        if dl_off > self.duration_ms {
240            dl_off = self.duration_ms;
241        }
242        if dl_off < now_off {
243            dl_off = now_off;
244        }
245
246        TicklessDeadline {
247            deadline_ms: self.t0_ms.wrapping_add(dl_off),
248            current_val,
249        }
250    }
251
252    /// Return an iterator that yields successive [`TicklessDeadline`] values
253    /// starting from `now_ms`, automatically advancing to each deadline.
254    ///
255    /// For [`RepeatMode::Once`] the iterator finishes when the segment ends.
256    /// For [`RepeatMode::Repeat`] and [`RepeatMode::PingPong`] it cycles
257    /// indefinitely.
258    pub fn iter(&self, now_ms: u32) -> TicklessIter<'_, C, T> {
259        TicklessIter {
260            schedule: self,
261            t0_ms: self.t0_ms,
262            start_val: self.start_val,
263            end_val: self.end_val,
264            now_ms,
265            done: false,
266        }
267    }
268}
269
270/// Iterator over successive [`TicklessDeadline`] values produced by a
271/// [`TicklessSchedule`].
272#[derive(Debug)]
273pub struct TicklessIter<'a, C, T: UnitValue = u8> {
274    schedule: &'a TicklessSchedule<C, T>,
275    t0_ms: u32,
276    start_val: u16,
277    end_val: u16,
278    now_ms: u32,
279    done: bool,
280}
281
282impl<C, T> TicklessIter<'_, C, T>
283where
284    C: MonotonicCurve<T, T> + Copy,
285    T: UnitValue,
286{
287    /// Build a single-cycle schedule from the iterator's current state.
288    fn cycle_schedule(&self) -> TicklessSchedule<C, T> {
289        TicklessSchedule {
290            curve: self.schedule.curve,
291            t0_ms: self.t0_ms,
292            duration_ms: self.schedule.duration_ms,
293            start_val: self.start_val,
294            end_val: self.end_val,
295            step: self.schedule.step,
296            rounding: self.schedule.rounding,
297            min_dt_ms: self.schedule.min_dt_ms,
298            repeat: RepeatMode::Once,
299            _marker: core::marker::PhantomData,
300        }
301    }
302
303    /// Advance to the next cycle, returning `true` if the iterator continues.
304    fn advance_cycle(&mut self) -> bool {
305        match self.schedule.repeat {
306            RepeatMode::Once => false,
307            RepeatMode::Repeat => {
308                self.t0_ms = self.t0_ms.wrapping_add(self.schedule.duration_ms);
309                true
310            }
311            RepeatMode::PingPong => {
312                self.t0_ms = self.t0_ms.wrapping_add(self.schedule.duration_ms);
313                core::mem::swap(&mut self.start_val, &mut self.end_val);
314                true
315            }
316        }
317    }
318}
319
320impl<C, T> Iterator for TicklessIter<'_, C, T>
321where
322    C: MonotonicCurve<T, T> + Copy,
323    T: UnitValue,
324{
325    type Item = TicklessDeadline;
326
327    fn next(&mut self) -> Option<TicklessDeadline> {
328        if self.done {
329            return None;
330        }
331
332        let cycle = self.cycle_schedule();
333        let dl = cycle.next_deadline(self.now_ms);
334        let end_ms = cycle.end_ms();
335        let end_val_q = quantize(self.end_val, self.schedule.step, self.schedule.rounding);
336
337        // Offset space again: comparing `deadline_ms` against `end_ms` under the
338        // half-range convention reports "finished" on the first iteration of any
339        // segment longer than ~24.85 days.
340        let duration_ms = self.schedule.duration_ms;
341        let cycle_finished =
342            dl.deadline_ms.wrapping_sub(self.t0_ms) >= duration_ms || dl.current_val == end_val_q;
343
344        if cycle_finished {
345            if !self.advance_cycle() {
346                self.done = true;
347            } else {
348                self.now_ms = end_ms;
349            }
350        } else {
351            self.now_ms = dl.deadline_ms;
352        }
353
354        Some(dl)
355    }
356}
357
358/// Extension trait that adds tickless scheduling to any
359/// [`MonotonicCurve<T, T>`] where `T: UnitValue`.
360///
361/// This is the primary entry point for building a [`TicklessSchedule`].
362/// It is automatically implemented for every type that satisfies the bounds.
363pub trait Tickless<T: UnitValue>: MonotonicCurve<T, T> + Sized + Copy {
364    /// Build a [`TicklessSchedule`] for this curve.
365    ///
366    /// See [`TicklessSchedule::new`] for parameter descriptions.
367    ///
368    /// # Example
369    ///
370    /// ```ignore
371    /// use ph_curves::{Tickless, Rounding};
372    ///
373    /// let schedule = curve.tickless_schedule(
374    ///     0,     // t0_ms: start time
375    ///     1000,  // duration_ms
376    ///     0,     // start_val
377    ///     255,   // end_val
378    ///     10,    // step (quantization)
379    ///     Rounding::Nearest,
380    ///     0,     // min_dt_ms
381    /// );
382    ///
383    /// for deadline in schedule.iter(0) {
384    ///     set_timer(deadline.deadline_ms);
385    ///     set_output(deadline.current_val);
386    /// }
387    /// ```
388    fn tickless_schedule(
389        self,
390        t0_ms: u32,
391        duration_ms: u32,
392        start_val: u16,
393        end_val: u16,
394        step: u16,
395        rounding: Rounding,
396        min_dt_ms: u32,
397    ) -> TicklessSchedule<Self, T> {
398        TicklessSchedule::new(
399            self,
400            t0_ms,
401            duration_ms,
402            start_val,
403            end_val,
404            step,
405            rounding,
406            min_dt_ms,
407        )
408    }
409}
410
411impl<C, T> Tickless<T> for C
412where
413    C: MonotonicCurve<T, T> + Copy,
414    T: UnitValue,
415{
416}