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        const NANOS_PER_DAY: f64 = 86_400_000_000_000.0;
398        const SECONDS_PER_DAY: f64 = 86_400.0;
399
400        let duration = self.duration();
401        match duration.num_nanoseconds() {
402            Some(ns) => ns as f64 / NANOS_PER_DAY,
403            // Fallback for exceptionally large durations that do not fit in i64 nanoseconds.
404            None => duration.num_seconds() as f64 / SECONDS_PER_DAY,
405        }
406    }
407
408    /// Returns the duration in seconds.
409    pub fn duration_seconds(&self) -> i64 {
410        self.duration().num_seconds()
411    }
412}
413
414// Serde support for Period<MJD> (= Interval<Time<MJD>>)
415//
416// Uses the historical field names `start_mjd` / `end_mjd` for backward
417// compatibility with existing JSON reference data.
418#[cfg(feature = "serde")]
419impl Serialize for Interval<crate::ModifiedJulianDate> {
420    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
421    where
422        S: Serializer,
423    {
424        let mut s = serializer.serialize_struct("Period", 2)?;
425        s.serialize_field("start_mjd", &self.start.value())?;
426        s.serialize_field("end_mjd", &self.end.value())?;
427        s.end()
428    }
429}
430
431#[cfg(feature = "serde")]
432impl<'de> Deserialize<'de> for Interval<crate::ModifiedJulianDate> {
433    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
434    where
435        D: Deserializer<'de>,
436    {
437        #[derive(Deserialize)]
438        struct Raw {
439            start_mjd: f64,
440            end_mjd: f64,
441        }
442
443        let raw = Raw::deserialize(deserializer)?;
444        if !raw.start_mjd.is_finite() || !raw.end_mjd.is_finite() {
445            return Err(serde::de::Error::custom(
446                "period MJD values must be finite (not NaN or infinity)",
447            ));
448        }
449        if raw.start_mjd > raw.end_mjd {
450            return Err(serde::de::Error::custom(
451                "period start must not be after end",
452            ));
453        }
454        Ok(Interval::new(
455            crate::ModifiedJulianDate::new(raw.start_mjd),
456            crate::ModifiedJulianDate::new(raw.end_mjd),
457        ))
458    }
459}
460
461// Serde support for Period<JD> (= Interval<Time<JD>>)
462#[cfg(feature = "serde")]
463impl Serialize for Interval<crate::JulianDate> {
464    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
465    where
466        S: Serializer,
467    {
468        let mut s = serializer.serialize_struct("Period", 2)?;
469        s.serialize_field("start_jd", &self.start.value())?;
470        s.serialize_field("end_jd", &self.end.value())?;
471        s.end()
472    }
473}
474
475#[cfg(feature = "serde")]
476impl<'de> Deserialize<'de> for Interval<crate::JulianDate> {
477    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
478    where
479        D: Deserializer<'de>,
480    {
481        #[derive(Deserialize)]
482        struct Raw {
483            start_jd: f64,
484            end_jd: f64,
485        }
486
487        let raw = Raw::deserialize(deserializer)?;
488        if !raw.start_jd.is_finite() || !raw.end_jd.is_finite() {
489            return Err(serde::de::Error::custom(
490                "period JD values must be finite (not NaN or infinity)",
491            ));
492        }
493        if raw.start_jd > raw.end_jd {
494            return Err(serde::de::Error::custom(
495                "period start must not be after end",
496            ));
497        }
498        Ok(Interval::new(
499            crate::JulianDate::new(raw.start_jd),
500            crate::JulianDate::new(raw.end_jd),
501        ))
502    }
503}
504
505/// Returns the gaps (complement) of `periods` within the bounding `outer` period.
506///
507/// Given a sorted, non-overlapping list of sub-periods and a bounding period,
508/// this returns the time intervals NOT covered by any sub-period.
509///
510/// Both `outer` and every element of `periods` must have `start <= end`.
511/// The function runs in O(n) time with a single pass.
512///
513/// # Arguments
514/// * `outer` - The bounding period
515/// * `periods` - Sorted, non-overlapping sub-periods within `outer`
516///
517/// # Returns
518/// The complement periods (gaps) in chronological order.
519pub fn complement_within<T: TimeInstant>(
520    outer: Interval<T>,
521    periods: &[Interval<T>],
522) -> Vec<Interval<T>> {
523    let mut gaps = Vec::new();
524    let mut cursor = outer.start;
525    for p in periods {
526        if p.start > cursor {
527            gaps.push(Interval::new(cursor, p.start));
528        }
529        if p.end > cursor {
530            cursor = p.end;
531        }
532    }
533    if cursor < outer.end {
534        gaps.push(Interval::new(cursor, outer.end));
535    }
536    gaps
537}
538
539/// Returns the intersection of two sorted, non-overlapping period lists.
540///
541/// Uses an O(n+m) merge algorithm to find all overlapping spans.
542///
543/// # Arguments
544/// * `a` - First sorted, non-overlapping period list
545/// * `b` - Second sorted, non-overlapping period list
546///
547/// # Returns
548/// Periods where both `a` and `b` overlap, in chronological order.
549pub fn intersect_periods<T: TimeInstant>(a: &[Interval<T>], b: &[Interval<T>]) -> Vec<Interval<T>> {
550    let mut result = Vec::new();
551    let (mut i, mut j) = (0, 0);
552    while i < a.len() && j < b.len() {
553        let start = if a[i].start >= b[j].start {
554            a[i].start
555        } else {
556            b[j].start
557        };
558        let end = if a[i].end <= b[j].end {
559            a[i].end
560        } else {
561            b[j].end
562        };
563        if start < end {
564            result.push(Interval::new(start, end));
565        }
566        if a[i].end <= b[j].end {
567            i += 1;
568        } else {
569            j += 1;
570        }
571    }
572    result
573}
574
575/// Validate that a period list is sorted by start time and non-overlapping.
576///
577/// Checks three invariants on every element:
578/// 1. Each interval has `start <= end`.
579/// 2. Intervals are sorted by start time (monotonically non-decreasing).
580/// 3. Adjacent intervals do not overlap (previous `end <= next start`).
581///
582/// Returns `Ok(())` if all invariants hold, or the first violation found.
583///
584/// # Examples
585///
586/// ```
587/// # use tempoch_core as tempoch;
588/// use tempoch::{validate_period_list, Interval, ModifiedJulianDate};
589///
590/// let sorted = vec![
591///     Interval::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0)),
592///     Interval::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0)),
593/// ];
594/// assert!(validate_period_list(&sorted).is_ok());
595/// ```
596pub fn validate_period_list<T: TimeInstant>(
597    periods: &[Interval<T>],
598) -> Result<(), PeriodListError> {
599    for (i, p) in periods.iter().enumerate() {
600        if p.start
601            .partial_cmp(&p.end)
602            .is_none_or(|o| o == std::cmp::Ordering::Greater)
603        {
604            return Err(PeriodListError::InvalidInterval { index: i });
605        }
606    }
607    for i in 1..periods.len() {
608        if periods[i - 1]
609            .start
610            .partial_cmp(&periods[i].start)
611            .is_none_or(|o| o == std::cmp::Ordering::Greater)
612        {
613            return Err(PeriodListError::Unsorted { index: i });
614        }
615        if periods[i - 1].end > periods[i].start {
616            return Err(PeriodListError::Overlapping { index: i });
617        }
618    }
619    Ok(())
620}
621
622/// Sort periods by start time and merge overlapping/adjacent intervals.
623///
624/// Produces a sorted, non-overlapping list suitable for [`complement_within`]
625/// and [`intersect_periods`].
626///
627/// # Examples
628///
629/// ```
630/// # use tempoch_core as tempoch;
631/// use tempoch::{normalize_periods, Interval, ModifiedJulianDate};
632///
633/// let periods = vec![
634///     Interval::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0)),
635///     Interval::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0)),
636///     Interval::new(ModifiedJulianDate::new(2.0), ModifiedJulianDate::new(6.0)),
637/// ];
638/// let merged = normalize_periods(&periods);
639/// assert_eq!(merged.len(), 1); // [0, 8)
640/// ```
641pub fn normalize_periods<T: TimeInstant>(periods: &[Interval<T>]) -> Vec<Interval<T>> {
642    if periods.is_empty() {
643        return Vec::new();
644    }
645    let mut sorted: Vec<_> = periods.to_vec();
646    sorted.sort_by(|a, b| {
647        a.start
648            .partial_cmp(&b.start)
649            .unwrap_or(std::cmp::Ordering::Equal)
650    });
651    let mut merged = vec![sorted[0]];
652    for p in &sorted[1..] {
653        let last = merged.last_mut().unwrap();
654        if p.start <= last.end {
655            // Overlapping or adjacent — extend
656            if p.end > last.end {
657                last.end = p.end;
658            }
659        } else {
660            merged.push(*p);
661        }
662    }
663    merged
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use crate::{JulianDate, ModifiedJulianDate, JD, MJD};
670
671    #[test]
672    fn test_try_new_valid() {
673        let p = Interval::try_new(
674            ModifiedJulianDate::new(59000.0),
675            ModifiedJulianDate::new(59001.0),
676        );
677        assert!(p.is_ok());
678    }
679
680    #[test]
681    fn test_try_new_equal_bounds() {
682        let p = Interval::try_new(
683            ModifiedJulianDate::new(59000.0),
684            ModifiedJulianDate::new(59000.0),
685        );
686        assert!(p.is_ok()); // zero-length interval is valid
687    }
688
689    #[test]
690    fn test_try_new_invalid() {
691        let p = Interval::try_new(
692            ModifiedJulianDate::new(59001.0),
693            ModifiedJulianDate::new(59000.0),
694        );
695        assert_eq!(p, Err(InvalidIntervalError::StartAfterEnd));
696    }
697
698    #[test]
699    fn test_try_new_nan_rejected() {
700        let p = Interval::try_new(
701            ModifiedJulianDate::new(f64::NAN),
702            ModifiedJulianDate::new(59000.0),
703        );
704        assert!(p.is_err());
705    }
706
707    #[test]
708    fn test_validate_period_list_ok() {
709        let periods = vec![
710            Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0)),
711            Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0)),
712        ];
713        assert!(validate_period_list(&periods).is_ok());
714    }
715
716    #[test]
717    fn test_validate_period_list_unsorted() {
718        let periods = vec![
719            Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0)),
720            Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0)),
721        ];
722        assert_eq!(
723            validate_period_list(&periods),
724            Err(PeriodListError::Unsorted { index: 1 })
725        );
726    }
727
728    #[test]
729    fn test_validate_period_list_overlapping() {
730        let periods = vec![
731            Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(5.0)),
732            Period::new(ModifiedJulianDate::new(3.0), ModifiedJulianDate::new(8.0)),
733        ];
734        assert_eq!(
735            validate_period_list(&periods),
736            Err(PeriodListError::Overlapping { index: 1 })
737        );
738    }
739
740    #[test]
741    fn test_validate_period_list_invalid_interval() {
742        let periods = vec![Period::new(
743            ModifiedJulianDate::new(5.0),
744            ModifiedJulianDate::new(3.0),
745        )];
746        assert_eq!(
747            validate_period_list(&periods),
748            Err(PeriodListError::InvalidInterval { index: 0 })
749        );
750    }
751
752    #[test]
753    fn test_normalize_periods_empty() {
754        let periods: Vec<Period<MJD>> = vec![];
755        assert!(normalize_periods(&periods).is_empty());
756    }
757
758    #[test]
759    fn test_normalize_periods_unsorted_and_overlapping() {
760        let periods = vec![
761            Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0)),
762            Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0)),
763            Period::new(ModifiedJulianDate::new(2.0), ModifiedJulianDate::new(6.0)),
764        ];
765        let merged = normalize_periods(&periods);
766        assert_eq!(merged.len(), 1);
767        assert_eq!(merged[0].start.quantity(), Days::new(0.0));
768        assert_eq!(merged[0].end.quantity(), Days::new(8.0));
769    }
770
771    #[test]
772    fn test_normalize_periods_disjoint() {
773        let periods = vec![
774            Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(6.0)),
775            Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(2.0)),
776        ];
777        let merged = normalize_periods(&periods);
778        assert_eq!(merged.len(), 2);
779        assert_eq!(merged[0].start.quantity(), Days::new(0.0));
780        assert_eq!(merged[1].start.quantity(), Days::new(5.0));
781    }
782
783    #[test]
784    fn test_period_creation_jd() {
785        let start = JulianDate::new(2451545.0);
786        let end = JulianDate::new(2451546.0);
787        let period = Period::new(start, end);
788
789        assert_eq!(period.start, start);
790        assert_eq!(period.end, end);
791    }
792
793    #[test]
794    fn test_period_scale_conversion_jd_to_mjd() {
795        let period_jd = Period::new(Time::<JD>::new(2_451_545.0), Time::<JD>::new(2_451_546.0));
796        let period_mjd = period_jd.to::<MJD>().unwrap();
797
798        assert!((period_mjd.start.value() - 51_544.5).abs() < 1e-12);
799        assert!((period_mjd.end.value() - 51_545.5).abs() < 1e-12);
800    }
801
802    #[test]
803    fn test_period_scale_conversion_roundtrip() {
804        let original = Period::new(Time::<MJD>::new(59_000.125), Time::<MJD>::new(59_001.75));
805        let roundtrip = original.to::<JD>().unwrap().to::<MJD>().unwrap();
806
807        assert!((roundtrip.start.value() - original.start.value()).abs() < 1e-12);
808        assert!((roundtrip.end.value() - original.end.value()).abs() < 1e-12);
809    }
810
811    #[test]
812    fn test_period_scale_conversion_to_utc() {
813        let start_utc = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
814        let end_utc = DateTime::from_timestamp(1_700_000_600, 0).unwrap();
815        let period_jd = Period::new(
816            Time::<JD>::from_utc(start_utc),
817            Time::<JD>::from_utc(end_utc),
818        );
819
820        let period_utc = period_jd.to::<DateTime<Utc>>().unwrap();
821        let start_delta_ns = period_utc.start.timestamp_nanos_opt().unwrap()
822            - start_utc.timestamp_nanos_opt().unwrap();
823        let end_delta_ns =
824            period_utc.end.timestamp_nanos_opt().unwrap() - end_utc.timestamp_nanos_opt().unwrap();
825        assert!(start_delta_ns.abs() < 10_000);
826        assert!(end_delta_ns.abs() < 10_000);
827    }
828
829    #[test]
830    fn test_period_creation_mjd() {
831        let start = ModifiedJulianDate::new(59000.0);
832        let end = ModifiedJulianDate::new(59001.0);
833        let period = Period::new(start, end);
834
835        assert_eq!(period.start, start);
836        assert_eq!(period.end, end);
837    }
838
839    #[test]
840    fn test_period_duration_jd() {
841        let start = JulianDate::new(2451545.0);
842        let end = JulianDate::new(2451546.5);
843        let period = Period::new(start, end);
844
845        assert_eq!(period.duration_days(), Days::new(1.5));
846    }
847
848    #[test]
849    fn test_period_duration_mjd() {
850        let start = ModifiedJulianDate::new(59000.0);
851        let end = ModifiedJulianDate::new(59001.5);
852        let period = Period::new(start, end);
853
854        assert_eq!(period.duration_days(), Days::new(1.5));
855    }
856
857    #[test]
858    fn test_period_duration_utc() {
859        let start = DateTime::from_timestamp(0, 0).unwrap();
860        let end = DateTime::from_timestamp(86400, 0).unwrap(); // 1 day later
861        let period = Interval::new(start, end);
862
863        assert_eq!(period.duration_days(), 1.0);
864        assert_eq!(period.duration_seconds(), 86400);
865    }
866
867    #[test]
868    fn test_period_duration_utc_subsecond_precision() {
869        let start = DateTime::from_timestamp(0, 0).unwrap();
870        let end = DateTime::from_timestamp(0, 500_000_000).unwrap();
871        let period = Interval::new(start, end);
872
873        let expected_days = 0.5 / 86_400.0;
874        assert!((period.duration_days() - expected_days).abs() < 1e-15);
875        assert_eq!(period.duration_seconds(), 0);
876    }
877
878    #[test]
879    fn test_period_to_conversion() {
880        let mjd_start = ModifiedJulianDate::new(59000.0);
881        let mjd_end = ModifiedJulianDate::new(59001.0);
882        let mjd_period = Period::new(mjd_start, mjd_end);
883
884        let utc_period = mjd_period.to::<DateTime<Utc>>().unwrap();
885
886        // The converted period should have approximately the same duration (within 1 second due to ΔT)
887        let duration_secs = utc_period.duration().num_seconds();
888        assert!(
889            (duration_secs - 86400).abs() <= 1,
890            "Duration was {} seconds",
891            duration_secs
892        );
893
894        // Convert back and check that it's close (within small tolerance due to floating point)
895        let back_to_mjd = utc_period.to::<ModifiedJulianDate>();
896        let start_diff = (back_to_mjd.start.quantity() - mjd_start.quantity())
897            .value()
898            .abs();
899        let end_diff = (back_to_mjd.end.quantity() - mjd_end.quantity())
900            .value()
901            .abs();
902        assert!(start_diff < 1e-6, "Start difference: {}", start_diff);
903        assert!(end_diff < 1e-6, "End difference: {}", end_diff);
904    }
905
906    #[test]
907    fn test_period_display() {
908        let start = ModifiedJulianDate::new(59000.0);
909        let end = ModifiedJulianDate::new(59001.0);
910        let period = Period::new(start, end);
911
912        let display = format!("{}", period);
913        assert!(display.contains("MJD 59000"));
914        assert!(display.contains("MJD 59001"));
915        assert!(display.contains("to"));
916    }
917
918    #[test]
919    fn test_period_intersection_overlap() {
920        let a = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(5.0));
921        let b = Period::new(ModifiedJulianDate::new(3.0), ModifiedJulianDate::new(8.0));
922
923        let overlap = a.intersection(&b).expect("expected overlap");
924        assert_eq!(overlap.start.quantity(), Days::new(3.0));
925        assert_eq!(overlap.end.quantity(), Days::new(5.0));
926    }
927
928    #[test]
929    fn test_period_intersection_disjoint() {
930        let a = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0));
931        let b = Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(8.0));
932
933        assert_eq!(a.intersection(&b), None);
934    }
935
936    #[test]
937    fn test_period_intersection_touching_edges() {
938        let a = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(3.0));
939        let b = Period::new(ModifiedJulianDate::new(3.0), ModifiedJulianDate::new(8.0));
940
941        assert_eq!(a.intersection(&b), None);
942    }
943
944    #[test]
945    fn test_complement_within_gaps() {
946        let outer = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(10.0));
947        let periods = vec![
948            Period::new(ModifiedJulianDate::new(2.0), ModifiedJulianDate::new(4.0)),
949            Period::new(ModifiedJulianDate::new(6.0), ModifiedJulianDate::new(8.0)),
950        ];
951        let gaps = complement_within(outer, &periods);
952        assert_eq!(gaps.len(), 3);
953        assert_eq!(gaps[0].start.quantity(), Days::new(0.0));
954        assert_eq!(gaps[0].end.quantity(), Days::new(2.0));
955        assert_eq!(gaps[1].start.quantity(), Days::new(4.0));
956        assert_eq!(gaps[1].end.quantity(), Days::new(6.0));
957        assert_eq!(gaps[2].start.quantity(), Days::new(8.0));
958        assert_eq!(gaps[2].end.quantity(), Days::new(10.0));
959    }
960
961    #[test]
962    fn test_complement_within_empty() {
963        let outer = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(10.0));
964        let gaps = complement_within(outer, &[]);
965        assert_eq!(gaps.len(), 1);
966        assert_eq!(gaps[0].start.quantity(), Days::new(0.0));
967        assert_eq!(gaps[0].end.quantity(), Days::new(10.0));
968    }
969
970    #[test]
971    fn test_complement_within_full() {
972        let outer = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(10.0));
973        let periods = vec![Period::new(
974            ModifiedJulianDate::new(0.0),
975            ModifiedJulianDate::new(10.0),
976        )];
977        let gaps = complement_within(outer, &periods);
978        assert!(gaps.is_empty());
979    }
980
981    #[test]
982    fn test_intersect_periods_overlap() {
983        let a = vec![Period::new(
984            ModifiedJulianDate::new(0.0),
985            ModifiedJulianDate::new(5.0),
986        )];
987        let b = vec![Period::new(
988            ModifiedJulianDate::new(3.0),
989            ModifiedJulianDate::new(8.0),
990        )];
991        let overlap = intersect_periods(&a, &b);
992        assert_eq!(overlap.len(), 1);
993        assert_eq!(overlap[0].start.quantity(), Days::new(3.0));
994        assert_eq!(overlap[0].end.quantity(), Days::new(5.0));
995    }
996
997    #[test]
998    fn test_intersect_periods_no_overlap() {
999        let a = vec![Period::new(
1000            ModifiedJulianDate::new(0.0),
1001            ModifiedJulianDate::new(3.0),
1002        )];
1003        let b = vec![Period::new(
1004            ModifiedJulianDate::new(5.0),
1005            ModifiedJulianDate::new(8.0),
1006        )];
1007        let overlap = intersect_periods(&a, &b);
1008        assert!(overlap.is_empty());
1009    }
1010
1011    #[test]
1012    fn test_complement_intersect_roundtrip() {
1013        // above(min) ∩ complement(above(max)) = between(min, max)
1014        let outer = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(10.0));
1015        let above_min = vec![
1016            Period::new(ModifiedJulianDate::new(1.0), ModifiedJulianDate::new(3.0)),
1017            Period::new(ModifiedJulianDate::new(5.0), ModifiedJulianDate::new(9.0)),
1018        ];
1019        let above_max = vec![
1020            Period::new(ModifiedJulianDate::new(2.0), ModifiedJulianDate::new(4.0)),
1021            Period::new(ModifiedJulianDate::new(7.0), ModifiedJulianDate::new(8.0)),
1022        ];
1023        let below_max = complement_within(outer, &above_max);
1024        let between = intersect_periods(&above_min, &below_max);
1025        // above_min: [1,3), [5,9)
1026        // above_max: [2,4), [7,8)
1027        // below_max (complement): [0,2), [4,7), [8,10)
1028        // intersection: [1,2), [5,7), [8,9)
1029        assert_eq!(between.len(), 3);
1030        assert_eq!(between[0].start.quantity(), Days::new(1.0));
1031        assert_eq!(between[0].end.quantity(), Days::new(2.0));
1032        assert_eq!(between[1].start.quantity(), Days::new(5.0));
1033        assert_eq!(between[1].end.quantity(), Days::new(7.0));
1034        assert_eq!(between[2].start.quantity(), Days::new(8.0));
1035        assert_eq!(between[2].end.quantity(), Days::new(9.0));
1036    }
1037
1038    // ── New coverage tests ────────────────────────────────────────────
1039
1040    #[test]
1041    fn test_conversion_error_display() {
1042        let err = ConversionError::OutOfRange;
1043        let msg = format!("{err}");
1044        assert!(msg.contains("out of representable range"), "got: {msg}");
1045    }
1046
1047    #[test]
1048    fn test_conversion_error_is_error() {
1049        let err = ConversionError::OutOfRange;
1050        // Verify it satisfies std::error::Error
1051        let _: &dyn std::error::Error = &err;
1052    }
1053
1054    #[test]
1055    fn test_invalid_interval_error_display() {
1056        let err = InvalidIntervalError::StartAfterEnd;
1057        let msg = format!("{err}");
1058        assert!(msg.contains("start must not be after end"), "got: {msg}");
1059    }
1060
1061    #[test]
1062    fn test_invalid_interval_error_is_error() {
1063        let err = InvalidIntervalError::StartAfterEnd;
1064        let _: &dyn std::error::Error = &err;
1065    }
1066
1067    #[test]
1068    fn test_period_list_error_invalid_interval_display() {
1069        let e = PeriodListError::InvalidInterval { index: 0 };
1070        let msg = format!("{e}");
1071        assert!(msg.contains("index 0"), "got: {msg}");
1072    }
1073
1074    #[test]
1075    fn test_period_list_error_unsorted_display() {
1076        let e = PeriodListError::Unsorted { index: 2 };
1077        let msg = format!("{e}");
1078        assert!(msg.contains("index 2"), "got: {msg}");
1079    }
1080
1081    #[test]
1082    fn test_period_list_error_overlapping_display() {
1083        let e = PeriodListError::Overlapping { index: 3 };
1084        let msg = format!("{e}");
1085        assert!(msg.contains("index 3"), "got: {msg}");
1086    }
1087
1088    #[test]
1089    fn test_period_list_error_is_error() {
1090        let e = PeriodListError::InvalidInterval { index: 0 };
1091        let _: &dyn std::error::Error = &e;
1092    }
1093
1094    #[test]
1095    fn test_intersection_self_larger_than_other() {
1096        // a.start > b.start  AND  a.end > b.end  → intersection picks a.start and b.end.
1097        // Exercises the `self.start` branch (line 284) and the `other.end` branch (line 291).
1098        let a = Period::new(ModifiedJulianDate::new(2.0), ModifiedJulianDate::new(8.0));
1099        let b = Period::new(ModifiedJulianDate::new(0.0), ModifiedJulianDate::new(5.0));
1100        let overlap = a.intersection(&b).expect("should overlap");
1101        assert_eq!(overlap.start.quantity(), Days::new(2.0));
1102        assert_eq!(overlap.end.quantity(), Days::new(5.0));
1103    }
1104
1105    #[test]
1106    fn test_period_time_target_for_time_type() {
1107        // Use `ModifiedJulianDate` (= Time<MJD>) as the Target type parameter,
1108        // not the bare `MJD` marker, to exercise the PeriodTimeTarget impl for Time<T>.
1109        let period_jd = Period::new(Time::<JD>::new(2_451_545.0), Time::<JD>::new(2_451_546.0));
1110        let period_mjd: Interval<ModifiedJulianDate> =
1111            period_jd.to::<ModifiedJulianDate>().unwrap();
1112        assert!((period_mjd.start.value() - 51_544.5).abs() < 1e-12);
1113        assert!((period_mjd.end.value() - 51_545.5).abs() < 1e-12);
1114    }
1115
1116    #[test]
1117    fn test_utc_period_to_datetime_utc_identity() {
1118        // Converting an Interval<DateTime<Utc>> to DateTime<Utc> again is a
1119        // no-op; exercises PeriodUtcTarget for DateTime<Utc>.
1120        let start = DateTime::from_timestamp(0, 0).unwrap();
1121        let end = DateTime::from_timestamp(86400, 0).unwrap();
1122        let utc_period = Interval::new(start, end);
1123        let same: Interval<DateTime<Utc>> = utc_period.to::<DateTime<Utc>>();
1124        assert_eq!(same.start, start);
1125        assert_eq!(same.end, end);
1126    }
1127
1128    #[cfg(feature = "serde")]
1129    #[test]
1130    fn test_period_mjd_serde_roundtrip() {
1131        let p = Period::new(
1132            ModifiedJulianDate::new(59000.0),
1133            ModifiedJulianDate::new(59001.0),
1134        );
1135        let json = serde_json::to_string(&p).unwrap();
1136        assert!(json.contains("start_mjd"), "serialized: {json}");
1137        let back: Period<MJD> = serde_json::from_str(&json).unwrap();
1138        assert!((back.start.value() - 59000.0).abs() < 1e-12);
1139        assert!((back.end.value() - 59001.0).abs() < 1e-12);
1140    }
1141
1142    #[cfg(feature = "serde")]
1143    #[test]
1144    fn test_period_mjd_deserialize_start_after_end_rejected() {
1145        let json = r#"{"start_mjd": 59001.0, "end_mjd": 59000.0}"#;
1146        let result: Result<Period<MJD>, _> = serde_json::from_str(json);
1147        assert!(result.is_err());
1148    }
1149
1150    #[cfg(feature = "serde")]
1151    #[test]
1152    fn test_period_jd_serde_roundtrip() {
1153        let p = Period::new(JulianDate::new(2_451_545.0), JulianDate::new(2_451_546.0));
1154        let json = serde_json::to_string(&p).unwrap();
1155        assert!(json.contains("start_jd"), "serialized: {json}");
1156        let back: Period<JD> = serde_json::from_str(&json).unwrap();
1157        assert!((back.start.value() - 2_451_545.0).abs() < 1e-12);
1158        assert!((back.end.value() - 2_451_546.0).abs() < 1e-12);
1159    }
1160
1161    #[cfg(feature = "serde")]
1162    #[test]
1163    fn test_period_jd_deserialize_start_after_end_rejected() {
1164        let json = r#"{"start_jd": 2451546.0, "end_jd": 2451545.0}"#;
1165        let result: Result<Period<JD>, _> = serde_json::from_str(json);
1166        assert!(result.is_err());
1167    }
1168}