Skip to main content

tempoch_core/
period.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Time period / interval implementation.
5//!
6//! This module provides:
7//! - [`Interval<T>`]: generic interval over any [`TimeInstant`]
8//! - [`Period<S>`]: scale-based alias for `Interval<Time<S>>`
9
10use super::{Time, TimeInstant, TimeScale};
11use chrono::{DateTime, Utc};
12use qtty::Days;
13use std::fmt;
14
15#[cfg(feature = "serde")]
16use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
17
18/// Error returned when a period time-scale conversion fails.
19///
20/// Currently the only failure mode is an out-of-range chrono conversion
21/// (e.g. a Julian Date too far in the past/future for `DateTime<Utc>`).
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ConversionError {
24    /// The time instant is outside the representable range of the target type.
25    OutOfRange,
26}
27
28impl fmt::Display for ConversionError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            ConversionError::OutOfRange => {
32                write!(f, "time instant out of representable range for target type")
33            }
34        }
35    }
36}
37
38impl std::error::Error for ConversionError {}
39
40/// Error returned when constructing an [`Interval`] with invalid bounds.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum InvalidIntervalError {
43    /// The start instant is after the end instant (`!(start <= end)`).
44    ///
45    /// This also triggers for `NaN` endpoints, since `NaN` comparisons
46    /// always return `false`.
47    StartAfterEnd,
48}
49
50impl fmt::Display for InvalidIntervalError {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            InvalidIntervalError::StartAfterEnd => {
54                write!(f, "interval start must not be after end")
55            }
56        }
57    }
58}
59
60impl std::error::Error for InvalidIntervalError {}
61
62/// Error indicating a period list violates sorted/non-overlapping invariants.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum PeriodListError {
65    /// An interval at the given index has `start > end`.
66    InvalidInterval {
67        /// Index of the malformed interval.
68        index: usize,
69    },
70    /// The interval at the given index has a start time earlier than its predecessor.
71    Unsorted {
72        /// Index of the out-of-order interval.
73        index: usize,
74    },
75    /// The interval at the given index overlaps with its predecessor.
76    Overlapping {
77        /// Index of the overlapping interval.
78        index: usize,
79    },
80}
81
82impl fmt::Display for PeriodListError {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            PeriodListError::InvalidInterval { index } => {
86                write!(f, "interval at index {index} has start > end")
87            }
88            PeriodListError::Unsorted { index } => {
89                write!(f, "interval at index {index} is not sorted by start time")
90            }
91            PeriodListError::Overlapping { index } => {
92                write!(f, "interval at index {index} overlaps with its predecessor")
93            }
94        }
95    }
96}
97
98impl std::error::Error for PeriodListError {}
99
100/// Target type adapter for [`Interval<Time<S>>::to`].
101///
102/// This allows converting a period of `Time<S>` either to another time scale
103/// marker (`MJD`, `JD`, `UT`, ...) or directly to `chrono::DateTime<Utc>`.
104pub trait PeriodTimeTarget<S: TimeScale> {
105    type Instant: TimeInstant;
106
107    fn convert(value: Time<S>) -> Result<Self::Instant, ConversionError>;
108}
109
110impl<S: TimeScale, T: TimeScale> PeriodTimeTarget<S> for T {
111    type Instant = Time<T>;
112
113    #[inline]
114    fn convert(value: Time<S>) -> Result<Self::Instant, ConversionError> {
115        Ok(value.to::<T>())
116    }
117}
118
119impl<S: TimeScale, T: TimeScale> PeriodTimeTarget<S> for Time<T> {
120    type Instant = Time<T>;
121
122    #[inline]
123    fn convert(value: Time<S>) -> Result<Self::Instant, ConversionError> {
124        Ok(value.to::<T>())
125    }
126}
127
128impl<S: TimeScale> PeriodTimeTarget<S> for DateTime<Utc> {
129    type Instant = DateTime<Utc>;
130
131    #[inline]
132    fn convert(value: Time<S>) -> Result<Self::Instant, ConversionError> {
133        value.to_utc().ok_or(ConversionError::OutOfRange)
134    }
135}
136
137/// Target type adapter for [`Interval<DateTime<Utc>>::to`].
138pub trait PeriodUtcTarget {
139    type Instant: TimeInstant;
140
141    fn convert(value: DateTime<Utc>) -> Self::Instant;
142}
143
144impl<S: TimeScale> PeriodUtcTarget for S {
145    type Instant = Time<S>;
146
147    #[inline]
148    fn convert(value: DateTime<Utc>) -> Self::Instant {
149        Time::<S>::from_utc(value)
150    }
151}
152
153impl<S: TimeScale> PeriodUtcTarget for Time<S> {
154    type Instant = Time<S>;
155
156    #[inline]
157    fn convert(value: DateTime<Utc>) -> Self::Instant {
158        Time::<S>::from_utc(value)
159    }
160}
161
162impl PeriodUtcTarget for DateTime<Utc> {
163    type Instant = DateTime<Utc>;
164
165    #[inline]
166    fn convert(value: DateTime<Utc>) -> Self::Instant {
167        value
168    }
169}
170
171/// Represents an interval between two instants.
172///
173/// An `Interval` is defined by a start and end time instant of type `T`,
174/// where `T` implements the `TimeInstant` trait. This allows for periods
175/// defined in different time systems (Julian Date, Modified Julian Date, UTC, etc.).
176///
177/// # Examples
178///
179/// ```
180/// # use tempoch_core as tempoch;
181/// use tempoch::{Interval, ModifiedJulianDate};
182///
183/// let start = ModifiedJulianDate::new(59000.0);
184/// let end = ModifiedJulianDate::new(59001.0);
185/// let period = Interval::new(start, end);
186///
187/// // Duration in days
188/// let duration = period.duration();
189/// ```
190#[derive(Debug, Clone, Copy, PartialEq)]
191pub struct Interval<T: TimeInstant> {
192    pub start: T,
193    pub end: T,
194}
195
196/// Time-scale period alias.
197///
198/// This follows the same marker pattern as [`Time<S>`], so callers can write:
199/// `Period<MJD>`, `Period<JD>`, etc.
200pub type Period<S> = Interval<Time<S>>;
201
202/// UTC interval alias.
203pub type UtcPeriod = Interval<DateTime<Utc>>;
204
205impl<T: TimeInstant> Interval<T> {
206    /// Creates a new period between two time instants.
207    ///
208    /// **Note:** this constructor does not validate that `start <= end`.
209    /// Prefer [`try_new`](Self::try_new) when endpoints come from untrusted
210    /// or computed input.
211    ///
212    /// # Arguments
213    ///
214    /// * `start` - The start time instant
215    /// * `end` - The end time instant
216    ///
217    /// # Examples
218    ///
219    /// ```
220    /// # use tempoch_core as tempoch;
221    /// use tempoch::{Interval, JulianDate};
222    ///
223    /// let start = JulianDate::new(2451545.0);
224    /// let end = JulianDate::new(2451546.0);
225    /// let period = Interval::new(start, end);
226    /// ```
227    pub fn new(start: T, end: T) -> Self {
228        Interval { start, end }
229    }
230
231    /// Creates a new interval, validating that `start <= end`.
232    ///
233    /// Returns [`InvalidIntervalError::StartAfterEnd`] if the start instant
234    /// is after the end instant.  This also rejects `NaN`-based instants,
235    /// since `NaN` comparisons always return `false`.
236    ///
237    /// # Examples
238    ///
239    /// ```
240    /// # use tempoch_core as tempoch;
241    /// use tempoch::{Interval, JulianDate};
242    ///
243    /// let ok = Interval::try_new(JulianDate::new(100.0), JulianDate::new(200.0));
244    /// assert!(ok.is_ok());
245    ///
246    /// let err = Interval::try_new(JulianDate::new(200.0), JulianDate::new(100.0));
247    /// assert!(err.is_err());
248    /// ```
249    pub fn try_new(start: T, end: T) -> Result<Self, InvalidIntervalError> {
250        if start <= end {
251            Ok(Interval { start, end })
252        } else {
253            Err(InvalidIntervalError::StartAfterEnd)
254        }
255    }
256
257    /// Returns the duration of the period as the difference between end and start.
258    ///
259    /// # Examples
260    ///
261    /// ```
262    /// # use tempoch_core as tempoch;
263    /// use tempoch::{Interval, JulianDate};
264    /// use qtty::Days;
265    ///
266    /// let start = JulianDate::new(2451545.0);
267    /// let end = JulianDate::new(2451546.5);
268    /// let period = Interval::new(start, end);
269    ///
270    /// let duration = period.duration();
271    /// assert_eq!(duration, Days::new(1.5));
272    /// ```
273    pub fn duration(&self) -> T::Duration {
274        self.end.difference(&self.start)
275    }
276
277    /// Returns the overlapping sub-period between `self` and `other`.
278    ///
279    /// Periods are treated as half-open ranges `[start, end)`: if one period
280    /// ends exactly when the other starts, the intersection is empty and `None`
281    /// is returned.
282    pub fn intersection(&self, other: &Self) -> Option<Self> {
283        let start = if self.start >= other.start {
284            self.start
285        } else {
286            other.start
287        };
288        let end = if self.end <= other.end {
289            self.end
290        } else {
291            other.end
292        };
293
294        if start < end {
295            Some(Self::new(start, end))
296        } else {
297            None
298        }
299    }
300}
301
302// Display implementation
303impl<T: TimeInstant + fmt::Display> fmt::Display for Interval<T> {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        write!(f, "{} to {}", self.start, self.end)
306    }
307}
308
309impl<S: TimeScale> Interval<Time<S>> {
310    /// Convert this period to another time scale.
311    ///
312    /// Each endpoint is converted preserving the represented absolute interval.
313    ///
314    /// Supported targets:
315    /// - Any time-scale marker (`JD`, `MJD`, `UT`, ...)
316    /// - `chrono::DateTime<Utc>`
317    ///
318    /// # Errors
319    ///
320    /// Returns [`ConversionError::OutOfRange`] if the endpoints fall outside
321    /// the representable range of the target type (only possible when
322    /// converting to `DateTime<Utc>`).
323    ///
324    /// # Examples
325    ///
326    /// ```
327    /// use chrono::{DateTime, Utc};
328    /// # use tempoch_core as tempoch;
329    /// use tempoch::{Interval, JD, MJD, Period, Time};
330    ///
331    /// let period_jd = Period::new(Time::<JD>::new(2451545.0), Time::<JD>::new(2451546.0));
332    /// let period_mjd = period_jd.to::<MJD>().unwrap();
333    /// let _period_utc: Interval<DateTime<Utc>> = period_jd.to::<DateTime<Utc>>().unwrap();
334    ///
335    /// assert!((period_mjd.start.value() - 51544.5).abs() < 1e-12);
336    /// assert!((period_mjd.end.value() - 51545.5).abs() < 1e-12);
337    /// ```
338    #[inline]
339    pub fn to<Target>(
340        &self,
341    ) -> Result<Interval<<Target as PeriodTimeTarget<S>>::Instant>, ConversionError>
342    where
343        Target: PeriodTimeTarget<S>,
344    {
345        Ok(Interval::new(
346            Target::convert(self.start)?,
347            Target::convert(self.end)?,
348        ))
349    }
350}
351
352// Specific implementation for periods with Days duration (JD and MJD)
353impl<T: TimeInstant<Duration = Days>> Interval<T> {
354    /// Returns the duration of the period in days as a floating-point value.
355    ///
356    /// This method is available for time instants with `Days` as their duration type
357    /// (e.g., `JulianDate` and `ModifiedJulianDate`).
358    ///
359    /// # Examples
360    ///
361    /// ```
362    /// # use tempoch_core as tempoch;
363    /// use tempoch::{Interval, ModifiedJulianDate};
364    /// use qtty::Days;
365    ///
366    /// let start = ModifiedJulianDate::new(59000.0);
367    /// let end = ModifiedJulianDate::new(59001.5);
368    /// let period = Interval::new(start, end);
369    ///
370    /// assert_eq!(period.duration_days(), Days::new(1.5));
371    /// ```
372    pub fn duration_days(&self) -> Days {
373        self.duration()
374    }
375}
376
377// Specific implementation for UTC periods
378impl Interval<DateTime<Utc>> {
379    /// Convert this UTC interval to another target.
380    ///
381    /// Supported targets:
382    /// - Any time-scale marker (`JD`, `MJD`, `UT`, ...)
383    /// - Any `Time<...>` alias (`JulianDate`, `ModifiedJulianDate`, ...)
384    /// - `chrono::DateTime<Utc>`
385    #[inline]
386    pub fn to<Target>(&self) -> Interval<<Target as PeriodUtcTarget>::Instant>
387    where
388        Target: PeriodUtcTarget,
389    {
390        Interval::new(Target::convert(self.start), Target::convert(self.end))
391    }
392
393    /// Returns the duration in days as a floating-point value.
394    ///
395    /// This converts the chrono::Duration to days.
396    pub fn duration_days(&self) -> f64 {
397        self.duration().num_seconds() as f64 / 86400.0
398    }
399
400    /// Returns the duration in seconds.
401    pub fn duration_seconds(&self) -> i64 {
402        self.duration().num_seconds()
403    }
404}
405
406// Serde support for Period<MJD> (= Interval<Time<MJD>>)
407//
408// Uses the historical field names `start_mjd` / `end_mjd` for backward
409// compatibility with existing JSON reference data.
410#[cfg(feature = "serde")]
411impl Serialize for Interval<crate::ModifiedJulianDate> {
412    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
413    where
414        S: Serializer,
415    {
416        let mut s = serializer.serialize_struct("Period", 2)?;
417        s.serialize_field("start_mjd", &self.start.value())?;
418        s.serialize_field("end_mjd", &self.end.value())?;
419        s.end()
420    }
421}
422
423#[cfg(feature = "serde")]
424impl<'de> Deserialize<'de> for Interval<crate::ModifiedJulianDate> {
425    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
426    where
427        D: Deserializer<'de>,
428    {
429        #[derive(Deserialize)]
430        struct Raw {
431            start_mjd: f64,
432            end_mjd: f64,
433        }
434
435        let raw = Raw::deserialize(deserializer)?;
436        if !raw.start_mjd.is_finite() || !raw.end_mjd.is_finite() {
437            return Err(serde::de::Error::custom(
438                "period MJD values must be finite (not NaN or infinity)",
439            ));
440        }
441        if raw.start_mjd > raw.end_mjd {
442            return Err(serde::de::Error::custom(
443                "period start must not be after end",
444            ));
445        }
446        Ok(Interval::new(
447            crate::ModifiedJulianDate::new(raw.start_mjd),
448            crate::ModifiedJulianDate::new(raw.end_mjd),
449        ))
450    }
451}
452
453// Serde support for Period<JD> (= Interval<Time<JD>>)
454#[cfg(feature = "serde")]
455impl Serialize for Interval<crate::JulianDate> {
456    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
457    where
458        S: Serializer,
459    {
460        let mut s = serializer.serialize_struct("Period", 2)?;
461        s.serialize_field("start_jd", &self.start.value())?;
462        s.serialize_field("end_jd", &self.end.value())?;
463        s.end()
464    }
465}
466
467#[cfg(feature = "serde")]
468impl<'de> Deserialize<'de> for Interval<crate::JulianDate> {
469    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
470    where
471        D: Deserializer<'de>,
472    {
473        #[derive(Deserialize)]
474        struct Raw {
475            start_jd: f64,
476            end_jd: f64,
477        }
478
479        let raw = Raw::deserialize(deserializer)?;
480        if !raw.start_jd.is_finite() || !raw.end_jd.is_finite() {
481            return Err(serde::de::Error::custom(
482                "period JD values must be finite (not NaN or infinity)",
483            ));
484        }
485        if raw.start_jd > raw.end_jd {
486            return Err(serde::de::Error::custom(
487                "period start must not be after end",
488            ));
489        }
490        Ok(Interval::new(
491            crate::JulianDate::new(raw.start_jd),
492            crate::JulianDate::new(raw.end_jd),
493        ))
494    }
495}
496
497/// Returns the gaps (complement) of `periods` within the bounding `outer` period.
498///
499/// Given a sorted, non-overlapping list of sub-periods and a bounding period,
500/// this returns the time intervals NOT covered by any sub-period.
501///
502/// Both `outer` and every element of `periods` must have `start <= end`.
503/// The function runs in O(n) time with a single pass.
504///
505/// # Arguments
506/// * `outer` - The bounding period
507/// * `periods` - Sorted, non-overlapping sub-periods within `outer`
508///
509/// # Returns
510/// The complement periods (gaps) in chronological order.
511pub fn complement_within<T: TimeInstant>(
512    outer: Interval<T>,
513    periods: &[Interval<T>],
514) -> Vec<Interval<T>> {
515    let mut gaps = Vec::new();
516    let mut cursor = outer.start;
517    for p in periods {
518        if p.start > cursor {
519            gaps.push(Interval::new(cursor, p.start));
520        }
521        if p.end > cursor {
522            cursor = p.end;
523        }
524    }
525    if cursor < outer.end {
526        gaps.push(Interval::new(cursor, outer.end));
527    }
528    gaps
529}
530
531/// Returns the intersection of two sorted, non-overlapping period lists.
532///
533/// Uses an O(n+m) merge algorithm to find all overlapping spans.
534///
535/// # Arguments
536/// * `a` - First sorted, non-overlapping period list
537/// * `b` - Second sorted, non-overlapping period list
538///
539/// # Returns
540/// Periods where both `a` and `b` overlap, in chronological order.
541pub fn intersect_periods<T: TimeInstant>(a: &[Interval<T>], b: &[Interval<T>]) -> Vec<Interval<T>> {
542    let mut result = Vec::new();
543    let (mut i, mut j) = (0, 0);
544    while i < a.len() && j < b.len() {
545        let start = if a[i].start >= b[j].start {
546            a[i].start
547        } else {
548            b[j].start
549        };
550        let end = if a[i].end <= b[j].end {
551            a[i].end
552        } else {
553            b[j].end
554        };
555        if start < end {
556            result.push(Interval::new(start, end));
557        }
558        if a[i].end <= b[j].end {
559            i += 1;
560        } else {
561            j += 1;
562        }
563    }
564    result
565}
566
567/// Validate that a period list is sorted by start time and non-overlapping.
568///
569/// Checks three invariants on every element:
570/// 1. Each interval has `start <= end`.
571/// 2. Intervals are sorted by start time (monotonically non-decreasing).
572/// 3. Adjacent intervals do not overlap (previous `end <= next start`).
573///
574/// Returns `Ok(())` if all invariants hold, or the first violation found.
575///
576/// # Examples
577///
578/// ```
579/// # use tempoch_core as tempoch;
580/// use tempoch::{validate_period_list, Interval, ModifiedJulianDate};
581///
582/// let sorted = vec![
583///     Interval::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0)),
584///     Interval::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0)),
585/// ];
586/// assert!(validate_period_list(&sorted).is_ok());
587/// ```
588pub fn validate_period_list<T: TimeInstant>(
589    periods: &[Interval<T>],
590) -> Result<(), PeriodListError> {
591    for (i, p) in periods.iter().enumerate() {
592        if p.start
593            .partial_cmp(&p.end)
594            .is_none_or(|o| o == std::cmp::Ordering::Greater)
595        {
596            return Err(PeriodListError::InvalidInterval { index: i });
597        }
598    }
599    for i in 1..periods.len() {
600        if periods[i - 1]
601            .start
602            .partial_cmp(&periods[i].start)
603            .is_none_or(|o| o == std::cmp::Ordering::Greater)
604        {
605            return Err(PeriodListError::Unsorted { index: i });
606        }
607        if periods[i - 1].end > periods[i].start {
608            return Err(PeriodListError::Overlapping { index: i });
609        }
610    }
611    Ok(())
612}
613
614/// Sort periods by start time and merge overlapping/adjacent intervals.
615///
616/// Produces a sorted, non-overlapping list suitable for [`complement_within`]
617/// and [`intersect_periods`].
618///
619/// # Examples
620///
621/// ```
622/// # use tempoch_core as tempoch;
623/// use tempoch::{normalize_periods, Interval, ModifiedJulianDate};
624///
625/// let periods = vec![
626///     Interval::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0)),
627///     Interval::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0)),
628///     Interval::new(ModifiedJulianDate::new(2.0), ModifiedJulianDate::new(6.0)),
629/// ];
630/// let merged = normalize_periods(&periods);
631/// assert_eq!(merged.len(), 1); // [0, 8)
632/// ```
633pub fn normalize_periods<T: TimeInstant>(periods: &[Interval<T>]) -> Vec<Interval<T>> {
634    if periods.is_empty() {
635        return Vec::new();
636    }
637    let mut sorted: Vec<_> = periods.to_vec();
638    sorted.sort_by(|a, b| {
639        a.start
640            .partial_cmp(&b.start)
641            .unwrap_or(std::cmp::Ordering::Equal)
642    });
643    let mut merged = vec![sorted[0]];
644    for p in &sorted[1..] {
645        let last = merged.last_mut().unwrap();
646        if p.start <= last.end {
647            // Overlapping or adjacent — extend
648            if p.end > last.end {
649                last.end = p.end;
650            }
651        } else {
652            merged.push(*p);
653        }
654    }
655    merged
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::{JulianDate, ModifiedJulianDate, JD, MJD};
662
663    #[test]
664    fn test_try_new_valid() {
665        let p = Interval::try_new(
666            ModifiedJulianDate::new(59000.0),
667            ModifiedJulianDate::new(59001.0),
668        );
669        assert!(p.is_ok());
670    }
671
672    #[test]
673    fn test_try_new_equal_bounds() {
674        let p = Interval::try_new(
675            ModifiedJulianDate::new(59000.0),
676            ModifiedJulianDate::new(59000.0),
677        );
678        assert!(p.is_ok()); // zero-length interval is valid
679    }
680
681    #[test]
682    fn test_try_new_invalid() {
683        let p = Interval::try_new(
684            ModifiedJulianDate::new(59001.0),
685            ModifiedJulianDate::new(59000.0),
686        );
687        assert_eq!(p, Err(InvalidIntervalError::StartAfterEnd));
688    }
689
690    #[test]
691    fn test_try_new_nan_rejected() {
692        let p = Interval::try_new(
693            ModifiedJulianDate::new(f64::NAN),
694            ModifiedJulianDate::new(59000.0),
695        );
696        assert!(p.is_err());
697    }
698
699    #[test]
700    fn test_validate_period_list_ok() {
701        let periods = vec![
702            Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0)),
703            Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0)),
704        ];
705        assert!(validate_period_list(&periods).is_ok());
706    }
707
708    #[test]
709    fn test_validate_period_list_unsorted() {
710        let periods = vec![
711            Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0)),
712            Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0)),
713        ];
714        assert_eq!(
715            validate_period_list(&periods),
716            Err(PeriodListError::Unsorted { index: 1 })
717        );
718    }
719
720    #[test]
721    fn test_validate_period_list_overlapping() {
722        let periods = vec![
723            Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(5.0)),
724            Period::new(ModifiedJulianDate::new(3.0), ModifiedJulianDate::new(8.0)),
725        ];
726        assert_eq!(
727            validate_period_list(&periods),
728            Err(PeriodListError::Overlapping { index: 1 })
729        );
730    }
731
732    #[test]
733    fn test_validate_period_list_invalid_interval() {
734        let periods = vec![Period::new(
735            ModifiedJulianDate::new(5.0),
736            ModifiedJulianDate::new(3.0),
737        )];
738        assert_eq!(
739            validate_period_list(&periods),
740            Err(PeriodListError::InvalidInterval { index: 0 })
741        );
742    }
743
744    #[test]
745    fn test_normalize_periods_empty() {
746        let periods: Vec<Period<MJD>> = vec![];
747        assert!(normalize_periods(&periods).is_empty());
748    }
749
750    #[test]
751    fn test_normalize_periods_unsorted_and_overlapping() {
752        let periods = vec![
753            Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0)),
754            Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0)),
755            Period::new(ModifiedJulianDate::new(2.0), ModifiedJulianDate::new(6.0)),
756        ];
757        let merged = normalize_periods(&periods);
758        assert_eq!(merged.len(), 1);
759        assert_eq!(merged[0].start.quantity(), Days::new(0.0));
760        assert_eq!(merged[0].end.quantity(), Days::new(8.0));
761    }
762
763    #[test]
764    fn test_normalize_periods_disjoint() {
765        let periods = vec![
766            Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(6.0)),
767            Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(2.0)),
768        ];
769        let merged = normalize_periods(&periods);
770        assert_eq!(merged.len(), 2);
771        assert_eq!(merged[0].start.quantity(), Days::new(0.0));
772        assert_eq!(merged[1].start.quantity(), Days::new(5.0));
773    }
774
775    #[test]
776    fn test_period_creation_jd() {
777        let start = JulianDate::new(2451545.0);
778        let end = JulianDate::new(2451546.0);
779        let period = Period::new(start, end);
780
781        assert_eq!(period.start, start);
782        assert_eq!(period.end, end);
783    }
784
785    #[test]
786    fn test_period_scale_conversion_jd_to_mjd() {
787        let period_jd = Period::new(Time::<JD>::new(2_451_545.0), Time::<JD>::new(2_451_546.0));
788        let period_mjd = period_jd.to::<MJD>().unwrap();
789
790        assert!((period_mjd.start.value() - 51_544.5).abs() < 1e-12);
791        assert!((period_mjd.end.value() - 51_545.5).abs() < 1e-12);
792    }
793
794    #[test]
795    fn test_period_scale_conversion_roundtrip() {
796        let original = Period::new(Time::<MJD>::new(59_000.125), Time::<MJD>::new(59_001.75));
797        let roundtrip = original.to::<JD>().unwrap().to::<MJD>().unwrap();
798
799        assert!((roundtrip.start.value() - original.start.value()).abs() < 1e-12);
800        assert!((roundtrip.end.value() - original.end.value()).abs() < 1e-12);
801    }
802
803    #[test]
804    fn test_period_scale_conversion_to_utc() {
805        let start_utc = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
806        let end_utc = DateTime::from_timestamp(1_700_000_600, 0).unwrap();
807        let period_jd = Period::new(
808            Time::<JD>::from_utc(start_utc),
809            Time::<JD>::from_utc(end_utc),
810        );
811
812        let period_utc = period_jd.to::<DateTime<Utc>>().unwrap();
813        let start_delta_ns = period_utc.start.timestamp_nanos_opt().unwrap()
814            - start_utc.timestamp_nanos_opt().unwrap();
815        let end_delta_ns =
816            period_utc.end.timestamp_nanos_opt().unwrap() - end_utc.timestamp_nanos_opt().unwrap();
817        assert!(start_delta_ns.abs() < 10_000);
818        assert!(end_delta_ns.abs() < 10_000);
819    }
820
821    #[test]
822    fn test_period_creation_mjd() {
823        let start = ModifiedJulianDate::new(59000.0);
824        let end = ModifiedJulianDate::new(59001.0);
825        let period = Period::new(start, end);
826
827        assert_eq!(period.start, start);
828        assert_eq!(period.end, end);
829    }
830
831    #[test]
832    fn test_period_duration_jd() {
833        let start = JulianDate::new(2451545.0);
834        let end = JulianDate::new(2451546.5);
835        let period = Period::new(start, end);
836
837        assert_eq!(period.duration_days(), Days::new(1.5));
838    }
839
840    #[test]
841    fn test_period_duration_mjd() {
842        let start = ModifiedJulianDate::new(59000.0);
843        let end = ModifiedJulianDate::new(59001.5);
844        let period = Period::new(start, end);
845
846        assert_eq!(period.duration_days(), Days::new(1.5));
847    }
848
849    #[test]
850    fn test_period_duration_utc() {
851        let start = DateTime::from_timestamp(0, 0).unwrap();
852        let end = DateTime::from_timestamp(86400, 0).unwrap(); // 1 day later
853        let period = Interval::new(start, end);
854
855        assert_eq!(period.duration_days(), 1.0);
856        assert_eq!(period.duration_seconds(), 86400);
857    }
858
859    #[test]
860    fn test_period_to_conversion() {
861        let mjd_start = ModifiedJulianDate::new(59000.0);
862        let mjd_end = ModifiedJulianDate::new(59001.0);
863        let mjd_period = Period::new(mjd_start, mjd_end);
864
865        let utc_period = mjd_period.to::<DateTime<Utc>>().unwrap();
866
867        // The converted period should have approximately the same duration (within 1 second due to ΔT)
868        let duration_secs = utc_period.duration().num_seconds();
869        assert!(
870            (duration_secs - 86400).abs() <= 1,
871            "Duration was {} seconds",
872            duration_secs
873        );
874
875        // Convert back and check that it's close (within small tolerance due to floating point)
876        let back_to_mjd = utc_period.to::<ModifiedJulianDate>();
877        let start_diff = (back_to_mjd.start.quantity() - mjd_start.quantity())
878            .value()
879            .abs();
880        let end_diff = (back_to_mjd.end.quantity() - mjd_end.quantity())
881            .value()
882            .abs();
883        assert!(start_diff < 1e-6, "Start difference: {}", start_diff);
884        assert!(end_diff < 1e-6, "End difference: {}", end_diff);
885    }
886
887    #[test]
888    fn test_period_display() {
889        let start = ModifiedJulianDate::new(59000.0);
890        let end = ModifiedJulianDate::new(59001.0);
891        let period = Period::new(start, end);
892
893        let display = format!("{}", period);
894        assert!(display.contains("MJD 59000"));
895        assert!(display.contains("MJD 59001"));
896        assert!(display.contains("to"));
897    }
898
899    #[test]
900    fn test_period_intersection_overlap() {
901        let a = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(5.0));
902        let b = Period::new(ModifiedJulianDate::new(3.0), ModifiedJulianDate::new(8.0));
903
904        let overlap = a.intersection(&b).expect("expected overlap");
905        assert_eq!(overlap.start.quantity(), Days::new(3.0));
906        assert_eq!(overlap.end.quantity(), Days::new(5.0));
907    }
908
909    #[test]
910    fn test_period_intersection_disjoint() {
911        let a = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0));
912        let b = Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0));
913
914        assert_eq!(a.intersection(&b), None);
915    }
916
917    #[test]
918    fn test_period_intersection_touching_edges() {
919        let a = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0));
920        let b = Period::new(ModifiedJulianDate::new(3.0), ModifiedJulianDate::new(8.0));
921
922        assert_eq!(a.intersection(&b), None);
923    }
924
925    #[test]
926    fn test_complement_within_gaps() {
927        let outer = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(10.0));
928        let periods = vec![
929            Period::new(ModifiedJulianDate::new(2.0), ModifiedJulianDate::new(4.0)),
930            Period::new(ModifiedJulianDate::new(6.0), ModifiedJulianDate::new(8.0)),
931        ];
932        let gaps = complement_within(outer, &periods);
933        assert_eq!(gaps.len(), 3);
934        assert_eq!(gaps[0].start.quantity(), Days::new(0.0));
935        assert_eq!(gaps[0].end.quantity(), Days::new(2.0));
936        assert_eq!(gaps[1].start.quantity(), Days::new(4.0));
937        assert_eq!(gaps[1].end.quantity(), Days::new(6.0));
938        assert_eq!(gaps[2].start.quantity(), Days::new(8.0));
939        assert_eq!(gaps[2].end.quantity(), Days::new(10.0));
940    }
941
942    #[test]
943    fn test_complement_within_empty() {
944        let outer = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(10.0));
945        let gaps = complement_within(outer, &[]);
946        assert_eq!(gaps.len(), 1);
947        assert_eq!(gaps[0].start.quantity(), Days::new(0.0));
948        assert_eq!(gaps[0].end.quantity(), Days::new(10.0));
949    }
950
951    #[test]
952    fn test_complement_within_full() {
953        let outer = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(10.0));
954        let periods = vec![Period::new(
955            ModifiedJulianDate::new(0.0),
956            ModifiedJulianDate::new(10.0),
957        )];
958        let gaps = complement_within(outer, &periods);
959        assert!(gaps.is_empty());
960    }
961
962    #[test]
963    fn test_intersect_periods_overlap() {
964        let a = vec![Period::new(
965            ModifiedJulianDate::new(0.0),
966            ModifiedJulianDate::new(5.0),
967        )];
968        let b = vec![Period::new(
969            ModifiedJulianDate::new(3.0),
970            ModifiedJulianDate::new(8.0),
971        )];
972        let overlap = intersect_periods(&a, &b);
973        assert_eq!(overlap.len(), 1);
974        assert_eq!(overlap[0].start.quantity(), Days::new(3.0));
975        assert_eq!(overlap[0].end.quantity(), Days::new(5.0));
976    }
977
978    #[test]
979    fn test_intersect_periods_no_overlap() {
980        let a = vec![Period::new(
981            ModifiedJulianDate::new(0.0),
982            ModifiedJulianDate::new(3.0),
983        )];
984        let b = vec![Period::new(
985            ModifiedJulianDate::new(5.0),
986            ModifiedJulianDate::new(8.0),
987        )];
988        let overlap = intersect_periods(&a, &b);
989        assert!(overlap.is_empty());
990    }
991
992    #[test]
993    fn test_complement_intersect_roundtrip() {
994        // above(min) ∩ complement(above(max)) = between(min, max)
995        let outer = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(10.0));
996        let above_min = vec![
997            Period::new(ModifiedJulianDate::new(1.0), ModifiedJulianDate::new(3.0)),
998            Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(9.0)),
999        ];
1000        let above_max = vec![
1001            Period::new(ModifiedJulianDate::new(2.0), ModifiedJulianDate::new(4.0)),
1002            Period::new(ModifiedJulianDate::new(7.0), ModifiedJulianDate::new(8.0)),
1003        ];
1004        let below_max = complement_within(outer, &above_max);
1005        let between = intersect_periods(&above_min, &below_max);
1006        // above_min: [1,3), [5,9)
1007        // above_max: [2,4), [7,8)
1008        // below_max (complement): [0,2), [4,7), [8,10)
1009        // intersection: [1,2), [5,7), [8,9)
1010        assert_eq!(between.len(), 3);
1011        assert_eq!(between[0].start.quantity(), Days::new(1.0));
1012        assert_eq!(between[0].end.quantity(), Days::new(2.0));
1013        assert_eq!(between[1].start.quantity(), Days::new(5.0));
1014        assert_eq!(between[1].end.quantity(), Days::new(7.0));
1015        assert_eq!(between[2].start.quantity(), Days::new(8.0));
1016        assert_eq!(between[2].end.quantity(), Days::new(9.0));
1017    }
1018}