Skip to main content

tempoch_core/period/
series.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! [`TimeSeries`] — exact-step iterator over `Time<S>`.
5//!
6//! Generates a uniform sequence of typed instants over a half-open range
7//! `[start, end)` with an [`crate::ExactDuration`] step. The step is exact in
8//! nanoseconds; the produced `Time<S>` values inherit the split-f64 storage of
9//! `Time<S>` (see `foundation::duration` for the W1 caveat that exactness
10//! lives in the duration container, not in instant storage).
11//!
12//! # Examples
13//!
14//! ```
15//! use tempoch_core::{ExactDuration, Time, TimeSeries, TT};
16//! use qtty::Second;
17//!
18//! let start = Time::<TT>::from_raw_j2000_seconds(Second::new(0.0)).unwrap();
19//! let end = Time::<TT>::from_raw_j2000_seconds(Second::new(10.0)).unwrap();
20//! let series = TimeSeries::new(start, end, ExactDuration::SECOND).unwrap();
21//! assert_eq!(series.count(), 10);
22//! ```
23
24use crate::format::TimeFormat;
25use crate::foundation::duration::{DurationError, ExactDuration};
26use crate::model::scale::CoordinateScale;
27use crate::model::time::Time;
28
29/// Error returned when a [`TimeSeries`] cannot be constructed.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum TimeSeriesError {
32    /// Step was zero — the iterator would not terminate.
33    ZeroStep,
34    /// `end < start` — the half-open range is empty in the forward direction;
35    /// callers wanting reverse iteration should use [`TimeSeries::new_with_step`]
36    /// with a negative [`ExactDuration`].
37    EmptyForwardRange,
38    /// The end-start duration overflows the i128 nanosecond representation.
39    DurationOverflow,
40}
41
42impl core::fmt::Display for TimeSeriesError {
43    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
44        match self {
45            Self::ZeroStep => f.write_str("TimeSeries step must be non-zero"),
46            Self::EmptyForwardRange => {
47                f.write_str("TimeSeries::new requires end >= start; use new_with_step with a negative step for descending series")
48            }
49            Self::DurationOverflow => {
50                f.write_str("TimeSeries range exceeds i128 nanosecond capacity")
51            }
52        }
53    }
54}
55
56impl std::error::Error for TimeSeriesError {}
57
58impl From<DurationError> for TimeSeriesError {
59    fn from(_: DurationError) -> Self {
60        Self::DurationOverflow
61    }
62}
63
64/// Half-open iterator `[start, end)` stepping by an [`ExactDuration`].
65///
66/// Iteration is **deterministic by index**: the `n`th item is
67/// `start.add_exact(step * n)` computed in i128 nanoseconds, NOT by repeated
68/// addition. This avoids accumulating split-f64 drift over long ranges.
69#[derive(Debug, Clone)]
70pub struct TimeSeries<S: CoordinateScale, F: TimeFormat = crate::format::J2000s> {
71    start: Time<S, F>,
72    #[allow(dead_code)]
73    /// Total nanoseconds covered by the half-open range; retained for debug
74    /// inspection and future range introspection helpers.
75    span_nanos: i128,
76    step_nanos: i128,
77    /// Items already produced.
78    cursor: u64,
79    /// Total number of items in this series (precomputed).
80    len: u64,
81}
82
83impl<S: CoordinateScale, F: TimeFormat> TimeSeries<S, F> {
84    /// Build a forward-stepping series `[start, end)` with positive step.
85    ///
86    /// Returns [`TimeSeriesError::EmptyForwardRange`] if `end < start`,
87    /// [`TimeSeriesError::ZeroStep`] if `step.is_zero()`.
88    pub fn new(
89        start: Time<S, F>,
90        end: Time<S, F>,
91        step: ExactDuration,
92    ) -> Result<Self, TimeSeriesError> {
93        if step.is_zero() {
94            return Err(TimeSeriesError::ZeroStep);
95        }
96        let span = end.diff_exact(start)?;
97        let span_nanos = span.as_nanos_i128();
98        let step_nanos = step.as_nanos_i128();
99        if span_nanos == 0 {
100            // Empty but valid (zero-length half-open range).
101            return Ok(Self {
102                start,
103                span_nanos: 0,
104                step_nanos,
105                cursor: 0,
106                len: 0,
107            });
108        }
109        // Forward iteration requires the signs of span and step to agree, and
110        // the magnitude of |span| / |step| to bound the count.
111        if span_nanos.signum() != step_nanos.signum() {
112            return Err(TimeSeriesError::EmptyForwardRange);
113        }
114        // Number of items: ceil(|span| / |step|) for a half-open range with
115        // strict containment of every step beyond start, BUT half-open
116        // semantics on `end` means we use floor and exclude any final point
117        // that would land exactly on `end`. Formally:
118        //   n = ceil(span / step)  if step doesn't divide span
119        //   n = span / step        otherwise (the last sample equals end, excluded)
120        let len = {
121            let span_abs = span_nanos.unsigned_abs();
122            let step_abs = step_nanos.unsigned_abs();
123            let q = span_abs / step_abs;
124            let r = span_abs % step_abs;
125            if r == 0 {
126                if q > u64::MAX as u128 {
127                    return Err(TimeSeriesError::DurationOverflow);
128                }
129                q as u64
130            } else {
131                if q >= u64::MAX as u128 {
132                    return Err(TimeSeriesError::DurationOverflow);
133                }
134                (q + 1) as u64
135            }
136        };
137        Ok(Self {
138            start,
139            span_nanos,
140            step_nanos,
141            cursor: 0,
142            len,
143        })
144    }
145
146    /// Build a series allowing reverse iteration via a negative step.
147    /// Range semantics: items satisfy `step > 0 ⇒ start + k·step < end`, or
148    /// `step < 0 ⇒ start + k·step > end`.
149    pub fn new_with_step(
150        start: Time<S, F>,
151        end: Time<S, F>,
152        step: ExactDuration,
153    ) -> Result<Self, TimeSeriesError> {
154        Self::new(start, end, step)
155    }
156
157    /// Number of items remaining in the series.
158    #[inline]
159    pub fn remaining(&self) -> u64 {
160        self.len.saturating_sub(self.cursor)
161    }
162
163    /// Total number of items in the series (independent of cursor).
164    #[inline]
165    pub fn len_total(&self) -> u64 {
166        self.len
167    }
168
169    /// True iff this series has produced all items.
170    #[inline]
171    pub fn is_exhausted(&self) -> bool {
172        self.cursor >= self.len
173    }
174
175    /// The `n`th item, computed from `start` (NOT by repeated addition).
176    /// Returns `None` if `n >= len_total()` or if the computed offset overflows
177    /// the `i64` seconds range (extremely large series only).
178    pub fn nth_item(&self, n: u64) -> Option<Time<S, F>> {
179        if n >= self.len {
180            return None;
181        }
182        let total_nanos = (n as i128).checked_mul(self.step_nanos)?;
183        self.start
184            .try_add_exact(ExactDuration::from_nanos(total_nanos))
185            .ok()
186    }
187}
188
189impl<S: CoordinateScale, F: TimeFormat> Iterator for TimeSeries<S, F> {
190    type Item = Time<S, F>;
191
192    fn next(&mut self) -> Option<Self::Item> {
193        if self.is_exhausted() {
194            return None;
195        }
196        let item = self.nth_item(self.cursor)?;
197        self.cursor += 1;
198        Some(item)
199    }
200
201    fn size_hint(&self) -> (usize, Option<usize>) {
202        let remaining = self.remaining();
203        let cap = remaining.min(usize::MAX as u64) as usize;
204        (cap, Some(cap))
205    }
206
207    fn count(self) -> usize {
208        self.remaining().min(usize::MAX as u64) as usize
209    }
210
211    fn nth(&mut self, n: usize) -> Option<Self::Item> {
212        self.cursor = self.cursor.saturating_add(n as u64);
213        self.next()
214    }
215}
216
217impl<S: CoordinateScale, F: TimeFormat> ExactSizeIterator for TimeSeries<S, F> {}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::{Time, TT};
223    use qtty::Second;
224
225    fn t(s: f64) -> Time<TT> {
226        Time::<TT>::from_raw_j2000_seconds(Second::new(s)).unwrap()
227    }
228
229    #[test]
230    fn ten_second_series() {
231        let s = TimeSeries::new(t(0.0), t(10.0), ExactDuration::SECOND).unwrap();
232        assert_eq!(s.len_total(), 10);
233        assert_eq!(s.count(), 10);
234    }
235
236    #[test]
237    fn zero_step_rejected() {
238        assert!(matches!(
239            TimeSeries::new(t(0.0), t(10.0), ExactDuration::ZERO),
240            Err(TimeSeriesError::ZeroStep)
241        ));
242    }
243
244    #[test]
245    fn empty_forward_range_rejected() {
246        assert!(matches!(
247            TimeSeries::new(t(10.0), t(0.0), ExactDuration::SECOND),
248            Err(TimeSeriesError::EmptyForwardRange)
249        ));
250    }
251
252    #[test]
253    fn empty_zero_span_returns_empty() {
254        let s = TimeSeries::new(t(5.0), t(5.0), ExactDuration::SECOND).unwrap();
255        assert_eq!(s.len_total(), 0);
256        assert_eq!(s.count(), 0);
257    }
258
259    #[test]
260    fn half_open_excludes_endpoint() {
261        let s = TimeSeries::new(t(0.0), t(3.0), ExactDuration::SECOND).unwrap();
262        let items: Vec<_> = s.collect();
263        assert_eq!(items.len(), 3);
264        // Last item should be at t=2 s, not t=3.
265        let last = items.last().unwrap();
266        let secs = (last.raw_seconds_pair().0 + last.raw_seconds_pair().1).value();
267        assert!((secs - 2.0).abs() < 1e-9);
268    }
269
270    #[test]
271    fn non_dividing_step_yields_ceiling_count() {
272        // [0, 3.5) step 1 s → 4 samples at 0, 1, 2, 3
273        let s = TimeSeries::new(t(0.0), t(3.5), ExactDuration::SECOND).unwrap();
274        assert_eq!(s.len_total(), 4);
275    }
276
277    #[test]
278    fn nth_item_is_deterministic() {
279        let s = TimeSeries::new(t(0.0), t(100.0), ExactDuration::SECOND).unwrap();
280        let got = s.nth_item(50).unwrap();
281        let secs = (got.raw_seconds_pair().0 + got.raw_seconds_pair().1).value();
282        assert!((secs - 50.0).abs() < 1e-9);
283        assert!(s.nth_item(100).is_none());
284    }
285
286    #[test]
287    fn reverse_step_iterates_downward() {
288        let s =
289            TimeSeries::new_with_step(t(10.0), t(0.0), ExactDuration::from_nanos(-1_000_000_000))
290                .unwrap();
291        assert_eq!(s.len_total(), 10);
292        let items: Vec<_> = s.collect();
293        let first = items.first().unwrap();
294        let last = items.last().unwrap();
295        let first_s = (first.raw_seconds_pair().0 + first.raw_seconds_pair().1).value();
296        let last_s = (last.raw_seconds_pair().0 + last.raw_seconds_pair().1).value();
297        assert!((first_s - 10.0).abs() < 1e-9);
298        assert!((last_s - 1.0).abs() < 1e-9);
299    }
300
301    #[test]
302    fn skip_via_nth() {
303        let mut s = TimeSeries::new(t(0.0), t(10.0), ExactDuration::SECOND).unwrap();
304        let third = s.nth(2).unwrap();
305        let secs = (third.raw_seconds_pair().0 + third.raw_seconds_pair().1).value();
306        assert!((secs - 2.0).abs() < 1e-9);
307    }
308
309    /// Iterate 100 items and verify each matches `nth_item` exactly (no drift).
310    #[test]
311    fn no_drift_versus_nth_item() {
312        let series = TimeSeries::new(t(0.0), t(100.0), ExactDuration::SECOND).unwrap();
313        let items: Vec<_> = series.collect();
314        let fresh = TimeSeries::new(t(0.0), t(100.0), ExactDuration::SECOND).unwrap();
315        for (i, item) in items.iter().enumerate() {
316            let direct = fresh.nth_item(i as u64).unwrap();
317            let a = (item.raw_seconds_pair().0 + item.raw_seconds_pair().1).value();
318            let b = (direct.raw_seconds_pair().0 + direct.raw_seconds_pair().1).value();
319            assert_eq!(
320                a, b,
321                "iterator vs nth_item mismatch at index {i}: {a} vs {b}"
322            );
323        }
324    }
325
326    /// Out-of-bounds `nth_item` returns None.
327    #[test]
328    fn nth_item_out_of_bounds_is_none() {
329        let s = TimeSeries::new(t(0.0), t(10.0), ExactDuration::SECOND).unwrap();
330        assert_eq!(s.len_total(), 10);
331        assert!(s.nth_item(10).is_none(), "expected None at len boundary");
332        assert!(s.nth_item(100).is_none(), "expected None well past end");
333    }
334}