Skip to main content

tempoch_core/
interval.rs

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