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