Skip to main content

tempoch_core/period/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Generic periods and interval-list operations.
5//!
6//! [`Interval`] is a half-open range `[start, end)` over any totally-ordered
7//! instant type. It is parameterised on `T: Copy + PartialOrd` so that the
8//! same type works for `Time<A>` on any axis as well as for
9//! `chrono::DateTime<Utc>`.
10
11use core::fmt;
12
13use alloc::vec::Vec;
14
15use crate::Time;
16
17mod error;
18pub mod series;
19pub use error::{InvalidIntervalError, PeriodListError};
20pub use series::{TimeSeries, TimeSeriesError};
21
22#[inline]
23fn partial_max<T: PartialOrd + Copy>(a: T, b: T) -> T {
24    if a >= b {
25        a
26    } else {
27        b
28    }
29}
30
31#[inline]
32fn partial_min<T: PartialOrd + Copy>(a: T, b: T) -> T {
33    if a <= b {
34        a
35    } else {
36        b
37    }
38}
39
40/// Half-open time interval `[start, end)`.
41///
42/// Half-open intervals are convenient for period arithmetic because adjacent
43/// intervals such as `[a, b)` and `[b, c)` touch without overlapping.
44#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct Interval<T: Copy + PartialOrd> {
46    /// Inclusive lower bound.
47    pub start: T,
48    /// Exclusive upper bound.
49    pub end: T,
50}
51
52impl<T> fmt::Display for Interval<T>
53where
54    T: Copy + PartialOrd + fmt::Display,
55{
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "[{}, {})", self.start, self.end)
58    }
59}
60
61/// Typed time period on a given scale.
62///
63/// This is an alias of [`Interval<Time<S>>`](Interval), so all interval
64/// operations are available directly on `Period`.
65pub type Period<S> = Interval<Time<S>>;
66
67impl<T: Copy + PartialOrd> Interval<T> {
68    /// Construct without validation.
69    ///
70    /// This accepts any `start`/`end` pair, including reversed or NaN-like
71    /// endpoints. Prefer [`try_new`](Self::try_new) when the bounds come from
72    /// computation or external input.
73    #[inline]
74    pub fn new<S: Into<T>, E: Into<T>>(start: S, end: E) -> Self {
75        Self {
76            start: start.into(),
77            end: end.into(),
78        }
79    }
80
81    /// Validating constructor: rejects `!(start <= end)` (e.g. reversed bounds or unordered floats such as NaN).
82    ///
83    /// Zero-length intervals where `start == end` are allowed.
84    #[inline]
85    pub fn try_new<S: Into<T>, E: Into<T>>(start: S, end: E) -> Result<Self, InvalidIntervalError> {
86        let start = start.into();
87        let end = end.into();
88        if start <= end {
89            Ok(Self { start, end })
90        } else {
91            Err(InvalidIntervalError::StartAfterEnd)
92        }
93    }
94
95    // ── Pair operations ──────────────────────────────────────────────────────
96
97    /// Overlap as a half-open range. Returns `None` if the intervals touch
98    /// only at a point or do not overlap.
99    #[inline]
100    pub fn intersection(&self, other: &Self) -> Option<Self> {
101        let start = partial_max(self.start, other.start);
102        let end = partial_min(self.end, other.end);
103        if start < end {
104            Some(Self::new(start, end))
105        } else {
106            None
107        }
108    }
109
110    /// Smallest interval covering both `self` and `other`, together with any
111    /// gap between them.
112    ///
113    /// Returns one interval when they overlap or touch, two when they are
114    /// strictly disjoint (sorted by start time).
115    ///
116    /// This pairwise API preserves disjointness instead of forcing a merged
117    /// interval that would invent coverage through the gap.
118    #[inline]
119    pub fn union(&self, other: &Self) -> Vec<Self> {
120        if self.start <= other.end && other.start <= self.end {
121            vec![Self::new(
122                partial_min(self.start, other.start),
123                partial_max(self.end, other.end),
124            )]
125        } else if self.start <= other.start {
126            vec![*self, *other]
127        } else {
128            vec![*other, *self]
129        }
130    }
131
132    // ── Self-as-outer complement ─────────────────────────────────────────────
133
134    /// Gaps inside `self` that are not covered by any interval in `periods`.
135    ///
136    /// `periods` must be sorted and non-overlapping
137    /// (see [`Interval::validate`]). Intervals outside `self` are effectively
138    /// ignored except for how they advance the internal cursor.
139    #[inline]
140    pub fn complement(&self, periods: &[Self]) -> Vec<Self> {
141        let mut gaps = Vec::with_capacity(periods.len().saturating_add(1));
142        let mut cursor = self.start;
143        for p in periods {
144            if p.end <= cursor {
145                continue;
146            }
147            if p.start >= self.end {
148                break;
149            }
150            if p.start > cursor {
151                gaps.push(Self::new(cursor, p.start));
152            }
153            if p.end >= self.end {
154                return gaps;
155            }
156            cursor = p.end;
157        }
158        if cursor < self.end {
159            gaps.push(Self::new(cursor, self.end));
160        }
161        gaps
162    }
163
164    /// Checked variant of [`complement`](Self::complement).
165    ///
166    /// Validates that `self` is well ordered and that `periods` is sorted,
167    /// non-overlapping, and internally valid before computing the complement.
168    #[inline]
169    pub fn try_complement(&self, periods: &[Self]) -> Result<Vec<Self>, PeriodListError> {
170        if self
171            .start
172            .partial_cmp(&self.end)
173            .is_none_or(|ordering| ordering == core::cmp::Ordering::Greater)
174        {
175            return Err(PeriodListError::InvalidInterval { index: 0 });
176        }
177        Self::validate(periods)?;
178        Ok(self.complement(periods))
179    }
180
181    // ── List operations (associated functions) ───────────────────────────────
182
183    /// Check that a list is sorted, non-overlapping, and each `start <= end`.
184    ///
185    /// Touching intervals such as `[a, b)` followed by `[b, c)` are valid.
186    pub fn validate(periods: &[Self]) -> Result<(), PeriodListError> {
187        let mut prev: Option<Interval<T>> = None;
188        for (i, period) in periods.iter().copied().enumerate() {
189            if period
190                .start
191                .partial_cmp(&period.end)
192                .is_none_or(|ordering| ordering == core::cmp::Ordering::Greater)
193            {
194                return Err(PeriodListError::InvalidInterval { index: i });
195            }
196            if let Some(previous) = prev {
197                if previous
198                    .start
199                    .partial_cmp(&period.start)
200                    .is_none_or(|ordering| ordering == core::cmp::Ordering::Greater)
201                {
202                    return Err(PeriodListError::Unsorted { index: i });
203                }
204                if previous.end > period.start {
205                    return Err(PeriodListError::Overlapping { index: i });
206                }
207            }
208            prev = Some(period);
209        }
210        Ok(())
211    }
212
213    /// Intersection of two sorted, non-overlapping period lists. `O(n + m)`.
214    pub fn intersect_many(a: &[Self], b: &[Self]) -> Vec<Self> {
215        let mut result = Vec::with_capacity(a.len().min(b.len()));
216        let (mut i, mut j) = (0, 0);
217        while i < a.len() && j < b.len() {
218            let start = partial_max(a[i].start, b[j].start);
219            let end = partial_min(a[i].end, b[j].end);
220            if start < end {
221                result.push(Self::new(start, end));
222            }
223            if a[i].end <= b[j].end {
224                i += 1;
225            } else {
226                j += 1;
227            }
228        }
229        result
230    }
231
232    /// Checked variant of [`intersect_many`](Self::intersect_many).
233    ///
234    /// Validates both input lists before computing their intersection.
235    pub fn try_intersect_many(a: &[Self], b: &[Self]) -> Result<Vec<Self>, PeriodListError> {
236        Self::validate(a)?;
237        Self::validate(b)?;
238        Ok(Self::intersect_many(a, b))
239    }
240
241    /// Union of two sorted period lists.
242    ///
243    /// Overlapping and adjacent intervals are merged. The result is sorted and
244    /// non-overlapping.
245    pub fn union_many(a: &[Self], b: &[Self]) -> Vec<Self> {
246        let mut combined: Vec<Self> = a.iter().chain(b.iter()).copied().collect();
247        combined.sort_unstable_by(|x, y| {
248            x.start
249                .partial_cmp(&y.start)
250                .unwrap_or(core::cmp::Ordering::Equal)
251        });
252        Self::normalize(&combined)
253    }
254
255    /// Sort and merge overlapping or adjacent intervals.
256    ///
257    /// This is useful for normalizing hand-built or concatenated period lists
258    /// before later set operations.
259    pub fn normalize(periods: &[Self]) -> Vec<Self> {
260        if periods.is_empty() {
261            return Vec::new();
262        }
263        let mut sorted: Vec<_> = periods.to_vec();
264        sorted.sort_unstable_by(|a, b| {
265            a.start
266                .partial_cmp(&b.start)
267                .unwrap_or(core::cmp::Ordering::Equal)
268        });
269        let mut merged = Vec::with_capacity(sorted.len());
270        merged.push(sorted[0]);
271        for period in sorted.into_iter().skip(1) {
272            if let Some(last) = merged.last_mut() {
273                if period.start <= last.end {
274                    if period.end > last.end {
275                        last.end = period.end;
276                    }
277                } else {
278                    merged.push(period);
279                }
280            }
281        }
282        merged
283    }
284
285    pub fn length<U>(&self) -> crate::qtty::Quantity<U>
286    where
287        T: core::ops::Sub<Output = crate::qtty::Quantity<U>>,
288        U: crate::qtty::time::TimeUnit,
289    {
290        self.end - self.start
291    }
292
293    /// Backward-compatible alias for [`Self::length`].
294    #[inline]
295    pub fn duration<U>(&self) -> crate::qtty::Quantity<U>
296    where
297        T: core::ops::Sub<Output = crate::qtty::Quantity<U>>,
298        U: crate::qtty::time::TimeUnit,
299    {
300        self.length()
301    }
302}
303
304/// Gaps inside `outer` that are not covered by any interval in `periods`.
305///
306/// This is a free-function wrapper around [`Interval::complement`].  `periods`
307/// must be sorted and non-overlapping (see [`Interval::validate`]).
308///
309/// # Example
310///
311/// ```
312/// use tempoch_core::{complement_within, JulianDate, TT, Interval};
313///
314/// let outer = Interval::<JulianDate<TT>>::new(
315///     JulianDate::new(2_451_545.0),
316///     JulianDate::new(2_451_550.0),
317/// );
318/// let filled = vec![Interval::new(
319///     JulianDate::new(2_451_546.0),
320///     JulianDate::new(2_451_548.0),
321/// )];
322/// let gaps = complement_within(outer, &filled);
323/// assert_eq!(gaps.len(), 2);
324/// ```
325#[inline]
326pub fn complement_within<T: Copy + PartialOrd>(
327    outer: Interval<T>,
328    periods: &[Interval<T>],
329) -> Vec<Interval<T>> {
330    outer.complement(periods)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    #[cfg(feature = "serde")]
337    use crate::format::JulianDate;
338    use crate::format::{ModifiedJulianDate, MJD};
339    use crate::qtty::Day;
340    #[cfg(feature = "serde")]
341    use crate::Time;
342    use crate::TT;
343    #[cfg(feature = "serde")]
344    use serde::de::IntoDeserializer;
345    #[cfg(feature = "serde")]
346    use serde::Deserialize;
347    #[cfg(feature = "serde")]
348    use serde_json::json;
349
350    #[test]
351    fn try_new_rejects_reversed() {
352        assert_eq!(
353            Interval::<f64>::try_new(2.0_f64, 1.0).unwrap_err(),
354            InvalidIntervalError::StartAfterEnd
355        );
356    }
357
358    #[test]
359    fn try_new_rejects_nan() {
360        assert!(Interval::<f64>::try_new(f64::NAN, 0.0).is_err());
361    }
362
363    #[test]
364    fn intersection_half_open() {
365        let a = Interval::<f64>::new(0.0_f64, 10.0);
366        let b = Interval::<f64>::new(10.0, 20.0);
367        assert!(a.intersection(&b).is_none());
368        let c = Interval::<f64>::new(5.0_f64, 15.0);
369        let x = a.intersection(&c).unwrap();
370        assert_eq!(x.start, 5.0);
371        assert_eq!(x.end, 10.0);
372    }
373
374    #[test]
375    fn complement_covers_gaps() {
376        let outer = Interval::<f64>::new(0.0_f64, 10.0);
377        let inside = vec![
378            Interval::<f64>::new(1.0_f64, 2.0),
379            Interval::<f64>::new(4.0, 6.0),
380        ];
381        let gaps = outer.complement(&inside);
382        assert_eq!(gaps.len(), 3);
383        assert_eq!(gaps[0], Interval::<f64>::new(0.0, 1.0));
384        assert_eq!(gaps[1], Interval::<f64>::new(2.0, 4.0));
385        assert_eq!(gaps[2], Interval::<f64>::new(6.0, 10.0));
386    }
387
388    #[test]
389    fn complement_ignores_periods_after_outer_interval() {
390        let outer = Interval::<f64>::new(10.0_f64, 20.0);
391        let inside = vec![Interval::<f64>::new(25.0_f64, 30.0)];
392
393        assert_eq!(
394            outer.complement(&inside),
395            vec![Interval::<f64>::new(10.0, 20.0)]
396        );
397    }
398
399    #[test]
400    fn complement_clips_periods_spanning_outer_end() {
401        let outer = Interval::<f64>::new(10.0_f64, 20.0);
402        let inside = vec![Interval::<f64>::new(12.0_f64, 30.0)];
403
404        assert_eq!(
405            outer.complement(&inside),
406            vec![Interval::<f64>::new(10.0, 12.0)]
407        );
408    }
409
410    #[test]
411    fn intersect_merge() {
412        let a = vec![
413            Interval::<f64>::new(0.0_f64, 5.0),
414            Interval::<f64>::new(10.0, 15.0),
415        ];
416        let b = vec![Interval::<f64>::new(3.0_f64, 12.0)];
417        let ix = Interval::intersect_many(&a, &b);
418        assert_eq!(ix.len(), 2);
419        assert_eq!(ix[0], Interval::<f64>::new(3.0, 5.0));
420        assert_eq!(ix[1], Interval::<f64>::new(10.0, 12.0));
421    }
422
423    #[test]
424    fn normalize_merges_overlap() {
425        let input = vec![
426            Interval::<f64>::new(5.0_f64, 8.0),
427            Interval::<f64>::new(0.0, 3.0),
428            Interval::<f64>::new(2.0, 6.0),
429        ];
430        let merged = Interval::normalize(&input);
431        assert_eq!(merged.len(), 1);
432        assert_eq!(merged[0], Interval::<f64>::new(0.0, 8.0));
433    }
434
435    #[test]
436    fn validate_detects_overlap() {
437        let periods = vec![
438            Interval::<f64>::new(0.0_f64, 5.0),
439            Interval::<f64>::new(3.0, 8.0),
440        ];
441        assert_eq!(
442            Interval::validate(&periods),
443            Err(PeriodListError::Overlapping { index: 1 })
444        );
445    }
446
447    #[test]
448    fn period_accepts_typed_times() {
449        let p = Period::<TT>::new(
450            ModifiedJulianDate::<TT>::new(51_544.5).to_j2000s(),
451            ModifiedJulianDate::<TT>::new(51_545.25).to_j2000s(),
452        );
453        assert_eq!(p.start.to::<MJD>().raw(), Day::new(51_544.5));
454        assert_eq!(p.end.to::<MJD>().raw(), Day::new(51_545.25));
455    }
456
457    #[test]
458    fn period_length_returns_seconds() {
459        let p = Period::<TT>::new(
460            ModifiedJulianDate::<TT>::new(51_544.5).to_j2000s(),
461            ModifiedJulianDate::<TT>::new(51_544.75).to_j2000s(),
462        );
463
464        assert_eq!(p.length(), crate::qtty::Second::new(21_600.0));
465    }
466
467    #[test]
468    fn period_length_returns_days_for_mjd() {
469        let p = Period::<TT>::new(
470            ModifiedJulianDate::<TT>::new(51_544.5),
471            ModifiedJulianDate::<TT>::new(51_545.25),
472        );
473
474        assert_eq!(p.length(), Day::new(0.75));
475    }
476
477    #[test]
478    fn display_invalid_interval_error() {
479        let e = InvalidIntervalError::StartAfterEnd;
480        assert!(e.to_string().contains("start"));
481    }
482
483    #[test]
484    fn display_period_list_errors() {
485        assert!(PeriodListError::InvalidInterval { index: 2 }
486            .to_string()
487            .contains("2"));
488        assert!(PeriodListError::Unsorted { index: 3 }
489            .to_string()
490            .contains("3"));
491        assert!(PeriodListError::Overlapping { index: 4 }
492            .to_string()
493            .contains("4"));
494    }
495
496    #[test]
497    fn normalize_empty_returns_empty() {
498        let result = Interval::<f64>::normalize(&[]);
499        assert!(result.is_empty());
500    }
501
502    #[test]
503    fn validate_detects_invalid_interval() {
504        // Interval::new does not validate; create one with start > end directly
505        let periods = vec![Interval::<f64> {
506            start: 5.0,
507            end: 1.0,
508        }];
509        assert_eq!(
510            Interval::validate(&periods),
511            Err(PeriodListError::InvalidInterval { index: 0 })
512        );
513    }
514
515    #[test]
516    fn validate_detects_unsorted() {
517        let periods = vec![
518            Interval::<f64>::new(5.0_f64, 8.0),
519            Interval::<f64>::new(1.0_f64, 4.0),
520        ];
521        assert_eq!(
522            Interval::validate(&periods),
523            Err(PeriodListError::Unsorted { index: 1 })
524        );
525    }
526
527    #[test]
528    fn checked_complement_rejects_invalid_inputs() {
529        let outer = Interval::<f64>::new(0.0_f64, 10.0);
530        let periods = vec![
531            Interval::<f64>::new(5.0_f64, 8.0),
532            Interval::<f64>::new(1.0_f64, 4.0),
533        ];
534        assert_eq!(
535            outer.try_complement(&periods),
536            Err(PeriodListError::Unsorted { index: 1 })
537        );
538
539        let invalid_outer = Interval::<f64>::new(10.0_f64, 0.0);
540        assert_eq!(
541            invalid_outer.try_complement(&[]),
542            Err(PeriodListError::InvalidInterval { index: 0 })
543        );
544    }
545
546    #[test]
547    fn checked_intersection_matches_unchecked_for_valid_inputs() {
548        let a = vec![Interval::<f64>::new(0.0_f64, 5.0)];
549        let b = vec![Interval::<f64>::new(3.0_f64, 7.0)];
550        assert_eq!(
551            Interval::try_intersect_many(&a, &b).unwrap(),
552            Interval::intersect_many(&a, &b)
553        );
554    }
555
556    #[test]
557    fn union_pair_overlapping() {
558        let a = Interval::<f64>::new(0.0_f64, 5.0);
559        let b = Interval::<f64>::new(3.0_f64, 8.0);
560        let u = a.union(&b);
561        assert_eq!(u.len(), 1);
562        assert_eq!(u[0], Interval::<f64>::new(0.0, 8.0));
563    }
564
565    #[test]
566    fn union_pair_disjoint() {
567        let a = Interval::<f64>::new(0.0_f64, 3.0);
568        let b = Interval::<f64>::new(5.0_f64, 8.0);
569        let u = a.union(&b);
570        assert_eq!(u.len(), 2);
571        assert_eq!(u[0], Interval::<f64>::new(0.0, 3.0));
572        assert_eq!(u[1], Interval::<f64>::new(5.0, 8.0));
573    }
574
575    #[test]
576    fn union_many_merges_two_lists() {
577        let a = vec![
578            Interval::<f64>::new(0.0_f64, 3.0),
579            Interval::<f64>::new(7.0, 9.0),
580        ];
581        let b = vec![Interval::<f64>::new(2.0_f64, 5.0)];
582        let u = Interval::union_many(&a, &b);
583        assert_eq!(u.len(), 2);
584        assert_eq!(u[0], Interval::<f64>::new(0.0, 5.0));
585        assert_eq!(u[1], Interval::<f64>::new(7.0, 9.0));
586    }
587
588    #[test]
589    fn display_formats_periods_via_endpoint_display() {
590        let mjd = Period::<TT>::new(
591            ModifiedJulianDate::<TT>::new(51_544.5).to_j2000s(),
592            ModifiedJulianDate::<TT>::new(51_545.25).to_j2000s(),
593        );
594
595        assert!(mjd.to_string().contains("TT"));
596    }
597
598    #[cfg(feature = "serde")]
599    #[test]
600    fn serde_roundtrips_period_shapes() {
601        let mjd = Period::<TT>::new(
602            ModifiedJulianDate::<TT>::new(51_544.5).to_j2000s(),
603            ModifiedJulianDate::<TT>::new(51_545.25).to_j2000s(),
604        );
605        let jd = Period::<TT>::new(
606            JulianDate::<TT>::new(2_451_545.0).to_j2000s(),
607            JulianDate::<TT>::new(2_451_546.0).to_j2000s(),
608        );
609        let native = Period::<TT>::new(
610            Time::<TT>::from_raw_j2000_seconds(crate::qtty::Second::new(100.0)).unwrap(),
611            Time::<TT>::from_raw_j2000_seconds(crate::qtty::Second::new(200.0)).unwrap(),
612        );
613
614        let mjd_json = serde_json::to_value(mjd).unwrap();
615        assert_eq!(serde_json::from_value::<Period<TT>>(mjd_json).unwrap(), mjd);
616
617        let jd_json = serde_json::to_value(jd).unwrap();
618        assert_eq!(serde_json::from_value::<Period<TT>>(jd_json).unwrap(), jd);
619
620        let native_json = serde_json::to_value(native).unwrap();
621        assert_eq!(
622            serde_json::from_value::<Period<TT>>(native_json).unwrap(),
623            native
624        );
625    }
626
627    #[cfg(feature = "serde")]
628    #[test]
629    fn serde_rejects_reversed_periods() {
630        let err = serde_json::from_value::<Period<TT>>(json!({
631            "start": 10.0,
632            "end": 9.0,
633        }))
634        .unwrap_err();
635        assert!(err.to_string().contains("start"));
636    }
637
638    #[cfg(feature = "serde")]
639    #[test]
640    fn serde_rejects_nan_scalar_time_values() {
641        let res: Result<Time<TT>, serde::de::value::Error> =
642            Time::<TT>::deserialize(f64::NAN.into_deserializer());
643        let err = res.expect_err("nan scalar");
644        assert!(
645            err.to_string().contains("NaN"),
646            "unexpected serde error message: {err}"
647        );
648
649        let err = serde_json::from_value::<Period<TT>>(json!({
650            "start": serde_json::Value::Null,
651            "end": 5.0,
652        }))
653        .expect_err("null start");
654        assert!(
655            err.to_string().contains("null"),
656            "unexpected serde error message: {err}"
657        );
658    }
659}