Skip to main content

pleiades_types/
time_range.rs

1//! Time interval types: [`TimeRange`] and [`TimeRangeValidationError`].
2
3use core::fmt;
4
5use crate::time::Instant;
6
7/// A Julian-day interval.
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct TimeRange {
11    /// Inclusive lower bound.
12    pub start: Option<Instant>,
13    /// Inclusive upper bound.
14    pub end: Option<Instant>,
15}
16
17impl TimeRange {
18    /// Creates a new time range.
19    pub const fn new(start: Option<Instant>, end: Option<Instant>) -> Self {
20        Self { start, end }
21    }
22
23    /// Returns `true` if the given instant is inside the range.
24    ///
25    /// Containment requires the instant to share each present bound's
26    /// [`TimeScale`](crate::TimeScale): an instant tagged with a different time
27    /// scale than a present bound, or one carrying a non-finite Julian day, is
28    /// treated as not contained. An unbounded side imposes no constraint.
29    pub fn contains(&self, instant: Instant) -> bool {
30        let after_start = self.start.is_none_or(|start| {
31            same_scale_and_jd(instant, start)
32                && instant.julian_day.days() >= start.julian_day.days()
33        });
34        let before_end = self.end.is_none_or(|end| {
35            same_scale_and_jd(instant, end) && instant.julian_day.days() <= end.julian_day.days()
36        });
37        after_start && before_end
38    }
39
40    /// Validates the range bounds and ordering.
41    ///
42    /// Unbounded ranges are valid. When one or both bounds are present, the
43    /// finite Julian-day requirement applies to each bound, the two bounds must
44    /// use the same time scale, and the upper bound must not precede the lower
45    /// bound.
46    pub fn validate(self) -> Result<(), TimeRangeValidationError> {
47        if let Some(start) = self.start {
48            if !start.julian_day.days().is_finite() {
49                return Err(TimeRangeValidationError::non_finite_bound("start", start));
50            }
51        }
52        if let Some(end) = self.end {
53            if !end.julian_day.days().is_finite() {
54                return Err(TimeRangeValidationError::non_finite_bound("end", end));
55            }
56        }
57        if let (Some(start), Some(end)) = (self.start, self.end) {
58            if start.scale != end.scale {
59                return Err(TimeRangeValidationError::scale_mismatch(start, end));
60            }
61            if start.julian_day.days() > end.julian_day.days() {
62                return Err(TimeRangeValidationError::out_of_order(start, end));
63            }
64        }
65
66        Ok(())
67    }
68
69    /// Returns a compact one-line rendering of the range.
70    pub fn summary_line(&self) -> String {
71        match (self.start, self.end) {
72            (Some(start), Some(end)) => format!(
73                "{} → {}",
74                format_time_range_instant(start),
75                format_time_range_instant(end)
76            ),
77            (Some(start), None) => format!("from {}", format_time_range_instant(start)),
78            (None, Some(end)) => format!("through {}", format_time_range_instant(end)),
79            (None, None) => "unbounded".to_string(),
80        }
81    }
82}
83
84impl fmt::Display for TimeRange {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str(&self.summary_line())
87    }
88}
89
90pub(crate) fn format_time_range_instant(instant: Instant) -> String {
91    format!("JD {:.1} ({})", instant.julian_day.days(), instant.scale)
92}
93
94pub(crate) fn same_scale_and_jd(a: Instant, b: Instant) -> bool {
95    a.scale == b.scale && a.julian_day.days().is_finite() && b.julian_day.days().is_finite()
96}
97
98/// Shared validation errors for [`TimeRange`].
99#[derive(Clone, Copy, Debug, PartialEq)]
100pub enum TimeRangeValidationError {
101    /// A required bound contains a non-finite Julian day.
102    NonFiniteBound {
103        /// Which bound failed validation.
104        bound: &'static str,
105        /// The offending instant.
106        instant: Instant,
107    },
108    /// The lower and upper bounds use different time scales.
109    ScaleMismatch {
110        /// The lower bound.
111        start: Instant,
112        /// The upper bound.
113        end: Instant,
114    },
115    /// The upper bound precedes the lower bound.
116    OutOfOrder {
117        /// The lower bound.
118        start: Instant,
119        /// The upper bound.
120        end: Instant,
121    },
122}
123
124impl TimeRangeValidationError {
125    pub(crate) const fn non_finite_bound(bound: &'static str, instant: Instant) -> Self {
126        Self::NonFiniteBound { bound, instant }
127    }
128
129    pub(crate) const fn scale_mismatch(start: Instant, end: Instant) -> Self {
130        Self::ScaleMismatch { start, end }
131    }
132
133    pub(crate) const fn out_of_order(start: Instant, end: Instant) -> Self {
134        Self::OutOfOrder { start, end }
135    }
136
137    /// Returns a compact one-line rendering of the range validation failure.
138    pub fn summary_line(&self) -> String {
139        match self {
140            Self::NonFiniteBound { bound, instant } => format!(
141                "time range bound `{bound}` must be finite: {}",
142                format_time_range_instant(*instant)
143            ),
144            Self::ScaleMismatch { start, end } => format!(
145                "time range bounds must use the same time scale: start={}; end={}",
146                format_time_range_instant(*start),
147                format_time_range_instant(*end)
148            ),
149            Self::OutOfOrder { start, end } => format!(
150                "time range end must not precede the start: start={}; end={}",
151                format_time_range_instant(*start),
152                format_time_range_instant(*end)
153            ),
154        }
155    }
156}
157
158impl fmt::Display for TimeRangeValidationError {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        f.write_str(&self.summary_line())
161    }
162}
163
164impl std::error::Error for TimeRangeValidationError {}