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