Skip to main content

pleiades_backend/
result.rs

1use crate::identity::BackendId;
2use core::fmt;
3use pleiades_types::{
4    Apparentness, CelestialBody, CoordinateFrame, CoordinateValidationError, EclipticCoordinates,
5    EquatorialCoordinates, Instant, Motion, MotionValidationError, ZodiacMode,
6};
7
8/// Quality annotation for a backend result.
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11#[non_exhaustive]
12pub enum QualityAnnotation {
13    /// Exact or source-equivalent data.
14    Exact,
15    /// Interpolated from source samples.
16    Interpolated,
17    /// Approximate but still useful.
18    Approximate,
19    /// Quality is not yet published.
20    Unknown,
21}
22
23impl QualityAnnotation {
24    /// Returns a stable human-readable label for the quality annotation.
25    pub const fn label(self) -> &'static str {
26        match self {
27            Self::Exact => "Exact",
28            Self::Interpolated => "Interpolated",
29            Self::Approximate => "Approximate",
30            Self::Unknown => "Unknown",
31        }
32    }
33}
34
35impl fmt::Display for QualityAnnotation {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(self.label())
38    }
39}
40
41/// A backend result containing the requested coordinates where available.
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43#[derive(Clone, Debug, PartialEq)]
44pub struct EphemerisResult {
45    /// Backend that produced the result.
46    pub backend_id: BackendId,
47    /// Body that was queried.
48    pub body: CelestialBody,
49    /// Instant that was queried.
50    pub instant: Instant,
51    /// Coordinate frame of the result.
52    pub frame: CoordinateFrame,
53    /// Zodiac mode of the result.
54    pub zodiac_mode: ZodiacMode,
55    /// Whether apparent or mean values were requested.
56    pub apparent: Apparentness,
57    /// Ecliptic coordinates when available.
58    pub ecliptic: Option<EclipticCoordinates>,
59    /// Equatorial coordinates when available, in the frame the backend
60    /// documents. The first-party backends give the J2000 mean equator and
61    /// equinox (their J2000 ecliptic rotated by
62    /// [`OBLIQUITY_J2000_DEG`](pleiades_types::OBLIQUITY_J2000_DEG)), except
63    /// `ElpBackend`, whose channel is right ascension and declination of
64    /// date. A chart computes a mean placement's J2000 coordinates itself
65    /// when the backend gives both an ecliptic and an equatorial channel and
66    /// does not serve the sidereal zodiac natively; otherwise the backend's
67    /// own channel, or none, is reported (issue #210).
68    pub equatorial: Option<EquatorialCoordinates>,
69    /// Apparent motion when available.
70    pub motion: Option<Motion>,
71    /// Quality annotation for the result.
72    pub quality: QualityAnnotation,
73}
74
75/// Errors returned when a backend result record no longer matches its stored data.
76#[derive(Clone, Debug, PartialEq)]
77pub enum EphemerisResultValidationError {
78    /// The stored ecliptic coordinates are invalid.
79    InvalidEcliptic(CoordinateValidationError),
80    /// The stored equatorial coordinates are invalid.
81    InvalidEquatorial(CoordinateValidationError),
82    /// The stored motion sample is invalid.
83    InvalidMotion(MotionValidationError),
84}
85
86impl fmt::Display for EphemerisResultValidationError {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::InvalidEcliptic(error) => {
90                write!(f, "backend result ecliptic is invalid: {error}")
91            }
92            Self::InvalidEquatorial(error) => {
93                write!(f, "backend result equatorial is invalid: {error}")
94            }
95            Self::InvalidMotion(error) => write!(f, "backend result motion is invalid: {error}"),
96        }
97    }
98}
99
100impl std::error::Error for EphemerisResultValidationError {}
101
102impl EphemerisResult {
103    /// Creates an empty result shell with the request metadata filled in.
104    pub fn new(
105        backend_id: BackendId,
106        body: CelestialBody,
107        instant: Instant,
108        frame: CoordinateFrame,
109        zodiac_mode: ZodiacMode,
110        apparent: Apparentness,
111    ) -> Self {
112        Self {
113            backend_id,
114            body,
115            instant,
116            frame,
117            zodiac_mode,
118            apparent,
119            ecliptic: None,
120            equatorial: None,
121            motion: None,
122            quality: QualityAnnotation::Unknown,
123        }
124    }
125
126    /// Validates the stored coordinate and motion samples.
127    pub fn validate(&self) -> Result<(), EphemerisResultValidationError> {
128        if let Some(ecliptic) = &self.ecliptic {
129            ecliptic
130                .validate()
131                .map_err(EphemerisResultValidationError::InvalidEcliptic)?;
132        }
133
134        if let Some(equatorial) = &self.equatorial {
135            equatorial
136                .validate()
137                .map_err(EphemerisResultValidationError::InvalidEquatorial)?;
138        }
139
140        if let Some(motion) = &self.motion {
141            motion
142                .validate()
143                .map_err(EphemerisResultValidationError::InvalidMotion)?;
144        }
145
146        Ok(())
147    }
148
149    /// Returns a compact one-line rendering of the backend result.
150    ///
151    /// The summary keeps the request-shape metadata alongside the available
152    /// coordinate, motion, and quality fields so callers can compare a backend
153    /// result without drilling into each optional channel manually.
154    pub fn summary_line(&self) -> String {
155        format!(
156            "backend={}; body={}; instant={}; frame={}; zodiac={}; apparent={}; quality={}; ecliptic={}; equatorial={}; motion={}",
157            self.backend_id,
158            self.body,
159            self.instant,
160            self.frame,
161            self.zodiac_mode,
162            self.apparent,
163            self.quality,
164            format_optional_ecliptic_coordinates(self.ecliptic.as_ref()),
165            format_optional_equatorial_coordinates(self.equatorial.as_ref()),
166            format_optional_motion(self.motion.as_ref()),
167        )
168    }
169
170    /// Returns a compact one-line rendering after validating the stored samples.
171    pub fn validated_summary_line(&self) -> Result<String, EphemerisResultValidationError> {
172        self.validate()?;
173        Ok(self.summary_line())
174    }
175}
176
177impl fmt::Display for EphemerisResult {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        f.write_str(&self.summary_line())
180    }
181}
182
183pub(crate) fn format_optional_ecliptic_coordinates(value: Option<&EclipticCoordinates>) -> String {
184    value
185        .map(|coordinates| {
186            let distance = coordinates
187                .distance_au
188                .map(|distance| format!("{distance} AU"))
189                .unwrap_or_else(|| "n/a".to_string());
190
191            format!(
192                "longitude={}, latitude={}, distance={}",
193                coordinates.longitude, coordinates.latitude, distance
194            )
195        })
196        .unwrap_or_else(|| "absent".to_string())
197}
198
199pub(crate) fn format_optional_equatorial_coordinates(
200    value: Option<&EquatorialCoordinates>,
201) -> String {
202    value
203        .map(|coordinates| {
204            let distance = coordinates
205                .distance_au
206                .map(|distance| format!("{distance} AU"))
207                .unwrap_or_else(|| "n/a".to_string());
208
209            format!(
210                "right_ascension={}, declination={}, distance={}",
211                coordinates.right_ascension, coordinates.declination, distance
212            )
213        })
214        .unwrap_or_else(|| "absent".to_string())
215}
216
217pub(crate) fn format_optional_motion(value: Option<&Motion>) -> String {
218    value
219        .map(|motion| {
220            let longitude_speed = motion
221                .longitude_deg_per_day
222                .map(|speed| format!("{speed} deg/day"))
223                .unwrap_or_else(|| "n/a".to_string());
224            let latitude_speed = motion
225                .latitude_deg_per_day
226                .map(|speed| format!("{speed} deg/day"))
227                .unwrap_or_else(|| "n/a".to_string());
228            let distance_speed = motion
229                .distance_au_per_day
230                .map(|speed| format!("{speed} AU/day"))
231                .unwrap_or_else(|| "n/a".to_string());
232
233            format!(
234                "longitude_speed={}, latitude_speed={}, distance_speed={}",
235                longitude_speed, latitude_speed, distance_speed
236            )
237        })
238        .unwrap_or_else(|| "absent".to_string())
239}
240
241#[cfg(test)]
242#[path = "result_tests.rs"]
243mod tests;