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}