Skip to main content

pleiades_backend/
traits.rs

1use crate::capabilities::BackendCapabilities;
2use crate::errors::{EphemerisError, EphemerisErrorKind};
3use crate::identity::{AccuracyClass, BackendFamily, BackendId};
4use crate::metadata::{BackendMetadata, BackendProvenance};
5use crate::request::EphemerisRequest;
6use crate::result::EphemerisResult;
7use pleiades_types::{CelestialBody, Instant, TimeRange, TimeScale};
8
9/// The shared backend contract.
10///
11/// Implementations must support one-request/one-result queries. Batch querying
12/// is provided as a default all-or-error adapter that fail-fast stops on the
13/// first structured error so callers can build chart-style workflows without
14/// hand-rolling request loops.
15pub trait EphemerisBackend: Send + Sync {
16    /// Returns backend metadata.
17    fn metadata(&self) -> BackendMetadata;
18
19    /// Returns whether the backend supports the requested body.
20    fn supports_body(&self, body: CelestialBody) -> bool;
21
22    /// Computes a single ephemeris result.
23    fn position(&self, req: &EphemerisRequest) -> Result<EphemerisResult, EphemerisError>;
24
25    /// Computes a single ephemeris result for a caller that does not need
26    /// its motion.
27    ///
28    /// The returned place is bit-identical to [`Self::position`]'s and
29    /// `motion` is `None`. A backend that derives motion from extra
30    /// evaluations (a finite difference, say) overrides this to skip them;
31    /// the light-time iteration of an apparent place re-queries a body and
32    /// discards the motion each time. An override may succeed where
33    /// [`Self::position`] fails only because the motion could not be
34    /// computed. A wrapper that transforms results should override this too,
35    /// or it falls back to its own [`Self::position`], which stays correct
36    /// but pays for the motion.
37    ///
38    /// In a [`CompositeBackend`] or [`RoutingBackend`], if a provider's
39    /// `position` fails only because its motion failed, with a retryable error
40    /// kind, `position` falls back to the next provider's place while this
41    /// method returns that provider's own place.
42    fn position_without_motion(
43        &self,
44        req: &EphemerisRequest,
45    ) -> Result<EphemerisResult, EphemerisError> {
46        let mut result = self.position(req)?;
47        result.motion = None;
48        Ok(result)
49    }
50
51    /// Computes multiple ephemeris results.
52    ///
53    /// The default adapter calls [`Self::position`] for each request in order and
54    /// preserves each request's own instant and time-scale label exactly as
55    /// supplied, so mixed TT/TDB batches remain mixed in the returned results
56    /// instead of being normalized to a batch-wide scale.
57    fn positions(&self, reqs: &[EphemerisRequest]) -> Result<Vec<EphemerisResult>, EphemerisError> {
58        reqs.iter().map(|req| self.position(req)).collect()
59    }
60}
61
62/// A simple composite backend that routes requests to one of two providers.
63///
64/// The primary backend is consulted first. If it does not advertise support for
65/// the requested body, the secondary backend is tried instead.
66#[derive(Debug)]
67pub struct CompositeBackend<A, B> {
68    primary: A,
69    secondary: B,
70}
71
72impl<A, B> CompositeBackend<A, B> {
73    /// Creates a new routing backend.
74    pub const fn new(primary: A, secondary: B) -> Self {
75        Self { primary, secondary }
76    }
77
78    /// Returns the primary backend.
79    pub const fn primary(&self) -> &A {
80        &self.primary
81    }
82
83    /// Returns the secondary backend.
84    pub const fn secondary(&self) -> &B {
85        &self.secondary
86    }
87}
88
89impl<A: EphemerisBackend, B: EphemerisBackend> CompositeBackend<A, B> {
90    /// Routes `req` to the primary or secondary backend through `query`, so
91    /// every entry point shares one fallback policy.
92    fn dispatch(
93        &self,
94        req: &EphemerisRequest,
95        query: impl Fn(&dyn EphemerisBackend) -> Result<EphemerisResult, EphemerisError>,
96    ) -> Result<EphemerisResult, EphemerisError> {
97        let primary_supports = self.primary.supports_body(req.body.clone());
98        let secondary_supports = self.secondary.supports_body(req.body.clone());
99
100        if primary_supports {
101            match query(&self.primary) {
102                Ok(result) => Ok(result),
103                Err(error) if secondary_supports && should_fallback_to_secondary(&error.kind) => {
104                    query(&self.secondary)
105                }
106                Err(error) => Err(error),
107            }
108        } else if secondary_supports {
109            query(&self.secondary)
110        } else {
111            Err(EphemerisError::new(
112                EphemerisErrorKind::UnsupportedBody,
113                "no backend in the composite router supports the requested body",
114            ))
115        }
116    }
117}
118
119impl<A: EphemerisBackend, B: EphemerisBackend> EphemerisBackend for CompositeBackend<A, B> {
120    fn metadata(&self) -> BackendMetadata {
121        let primary = self.primary.metadata();
122        let secondary = self.secondary.metadata();
123        BackendMetadata {
124            id: BackendId::new(format!(
125                "composite:{}+{}",
126                primary.id.as_str(),
127                secondary.id.as_str()
128            )),
129            version: primary.version.clone(),
130            family: BackendFamily::Composite,
131            provenance: BackendProvenance {
132                summary: format!(
133                    "Composite routing backend combining {} and {}.",
134                    primary.provenance.summary, secondary.provenance.summary
135                ),
136                data_sources: combine_sources(
137                    &primary.provenance.data_sources,
138                    &secondary.provenance.data_sources,
139                ),
140            },
141            nominal_range: intersect_ranges(primary.nominal_range, secondary.nominal_range),
142            supported_time_scales: intersect_strings(
143                &primary.supported_time_scales,
144                &secondary.supported_time_scales,
145            ),
146            body_claims: crate::metadata::merge_body_claims(
147                &primary.body_claims,
148                &secondary.body_claims,
149            ),
150            supported_frames: intersect_strings(
151                &primary.supported_frames,
152                &secondary.supported_frames,
153            ),
154            capabilities: BackendCapabilities {
155                geocentric: primary.capabilities.geocentric && secondary.capabilities.geocentric,
156                topocentric: primary.capabilities.topocentric && secondary.capabilities.topocentric,
157                apparent: primary.capabilities.apparent && secondary.capabilities.apparent,
158                mean: primary.capabilities.mean && secondary.capabilities.mean,
159                batch: primary.capabilities.batch && secondary.capabilities.batch,
160                native_sidereal: primary.capabilities.native_sidereal
161                    && secondary.capabilities.native_sidereal,
162            },
163            accuracy: min_accuracy(primary.accuracy, secondary.accuracy),
164            deterministic: primary.deterministic && secondary.deterministic,
165            offline: primary.offline && secondary.offline,
166        }
167    }
168
169    fn supports_body(&self, body: CelestialBody) -> bool {
170        self.primary.supports_body(body.clone()) || self.secondary.supports_body(body)
171    }
172
173    fn position(&self, req: &EphemerisRequest) -> Result<EphemerisResult, EphemerisError> {
174        self.dispatch(req, |backend| backend.position(req))
175    }
176
177    fn position_without_motion(
178        &self,
179        req: &EphemerisRequest,
180    ) -> Result<EphemerisResult, EphemerisError> {
181        self.dispatch(req, |backend| backend.position_without_motion(req))
182    }
183}
184
185/// A routing backend that can chain any number of providers.
186///
187/// The router queries providers in priority order and falls back to later
188/// backends when the earlier ones report a retryable routing error. This makes
189/// it convenient to compose packaged, algorithmic, and reference-data
190/// backends without nesting multiple binary composites.
191#[derive(Default)]
192pub struct RoutingBackend {
193    backends: Vec<Box<dyn EphemerisBackend>>,
194}
195
196impl RoutingBackend {
197    /// Creates a new routing backend from a prioritized list of providers.
198    pub fn new(backends: Vec<Box<dyn EphemerisBackend>>) -> Self {
199        Self { backends }
200    }
201
202    /// Returns the configured provider chain.
203    pub fn backends(&self) -> &[Box<dyn EphemerisBackend>] {
204        &self.backends
205    }
206
207    /// Returns `true` if no providers are configured.
208    pub fn is_empty(&self) -> bool {
209        self.backends.is_empty()
210    }
211
212    /// Walks the chain through `query`, so every entry point shares one
213    /// fallback policy.
214    fn dispatch(
215        &self,
216        req: &EphemerisRequest,
217        query: impl Fn(&dyn EphemerisBackend) -> Result<EphemerisResult, EphemerisError>,
218    ) -> Result<EphemerisResult, EphemerisError> {
219        let mut saw_support = false;
220        let mut last_retryable_error = None;
221
222        for backend in &self.backends {
223            if !backend.supports_body(req.body.clone()) {
224                continue;
225            }
226
227            saw_support = true;
228            match query(backend.as_ref()) {
229                Ok(result) => return Ok(result),
230                Err(error) if should_fallback_to_secondary(&error.kind) => {
231                    last_retryable_error = Some(error);
232                }
233                Err(error) => return Err(error),
234            }
235        }
236
237        if let Some(error) = last_retryable_error {
238            Err(error)
239        } else if saw_support {
240            Err(EphemerisError::new(
241                EphemerisErrorKind::InvalidRequest,
242                "configured providers could not satisfy the requested body and request shape",
243            ))
244        } else {
245            Err(EphemerisError::new(
246                EphemerisErrorKind::UnsupportedBody,
247                "no backend in the routing chain supports the requested body",
248            ))
249        }
250    }
251}
252
253impl EphemerisBackend for RoutingBackend {
254    fn metadata(&self) -> BackendMetadata {
255        let backends: Vec<&dyn EphemerisBackend> = self
256            .backends
257            .iter()
258            .map(|backend| backend.as_ref())
259            .collect();
260        let metadatas: Vec<BackendMetadata> =
261            backends.iter().map(|backend| backend.metadata()).collect();
262
263        if metadatas.is_empty() {
264            return BackendMetadata {
265                id: BackendId::new("routing:empty"),
266                version: "routing[none]".to_string(),
267                family: BackendFamily::Composite,
268                provenance: BackendProvenance::new("Routing backend with no configured providers."),
269                nominal_range: TimeRange::new(None, None),
270                supported_time_scales: Vec::new(),
271                body_claims: Vec::new(),
272                supported_frames: Vec::new(),
273                capabilities: BackendCapabilities {
274                    geocentric: false,
275                    topocentric: false,
276                    apparent: false,
277                    mean: false,
278                    batch: false,
279                    native_sidereal: false,
280                },
281                accuracy: AccuracyClass::Unknown,
282                deterministic: true,
283                offline: true,
284            };
285        }
286
287        let mut id_parts = Vec::with_capacity(metadatas.len());
288        let mut version_parts = Vec::with_capacity(metadatas.len());
289        let mut provenance_parts = Vec::with_capacity(metadatas.len());
290        let mut data_sources = Vec::new();
291        let mut nominal_range = metadatas[0].nominal_range;
292        let mut supported_time_scales = metadatas[0].supported_time_scales.clone();
293        let mut body_claims = metadatas[0].body_claims.clone();
294        let mut supported_frames = metadatas[0].supported_frames.clone();
295        let mut capabilities = metadatas[0].capabilities.clone();
296        let mut accuracy = metadatas[0].accuracy;
297        let mut deterministic = metadatas[0].deterministic;
298        let mut offline = metadatas[0].offline;
299
300        for metadata in &metadatas {
301            id_parts.push(metadata.id.as_str().to_string());
302            version_parts.push(metadata.version.clone());
303            provenance_parts.push(metadata.provenance.summary.clone());
304            data_sources = combine_sources(&data_sources, &metadata.provenance.data_sources);
305            nominal_range = intersect_ranges(nominal_range, metadata.nominal_range);
306            supported_time_scales =
307                intersect_strings(&supported_time_scales, &metadata.supported_time_scales);
308            body_claims = crate::metadata::merge_body_claims(&body_claims, &metadata.body_claims);
309            supported_frames = intersect_strings(&supported_frames, &metadata.supported_frames);
310            capabilities.geocentric &= metadata.capabilities.geocentric;
311            capabilities.topocentric &= metadata.capabilities.topocentric;
312            capabilities.apparent &= metadata.capabilities.apparent;
313            capabilities.mean &= metadata.capabilities.mean;
314            capabilities.batch &= metadata.capabilities.batch;
315            capabilities.native_sidereal &= metadata.capabilities.native_sidereal;
316            accuracy = min_accuracy(accuracy, metadata.accuracy);
317            deterministic &= metadata.deterministic;
318            offline &= metadata.offline;
319        }
320
321        BackendMetadata {
322            id: BackendId::new(format!("routing:{}", id_parts.join("+"))),
323            version: format!("routing[{}]", version_parts.join("+")),
324            family: BackendFamily::Composite,
325            provenance: BackendProvenance {
326                summary: format!(
327                    "Routing backend combining {} provider(s): {}.",
328                    metadatas.len(),
329                    provenance_parts.join("; ")
330                ),
331                data_sources,
332            },
333            nominal_range,
334            supported_time_scales,
335            body_claims,
336            supported_frames,
337            capabilities,
338            accuracy,
339            deterministic,
340            offline,
341        }
342    }
343
344    fn supports_body(&self, body: CelestialBody) -> bool {
345        self.backends
346            .iter()
347            .any(|backend| backend.supports_body(body.clone()))
348    }
349
350    fn position(&self, req: &EphemerisRequest) -> Result<EphemerisResult, EphemerisError> {
351        self.dispatch(req, |backend| backend.position(req))
352    }
353
354    fn position_without_motion(
355        &self,
356        req: &EphemerisRequest,
357    ) -> Result<EphemerisResult, EphemerisError> {
358        self.dispatch(req, |backend| backend.position_without_motion(req))
359    }
360}
361
362fn combine_sources(primary: &[String], secondary: &[String]) -> Vec<String> {
363    let mut combined = primary.to_vec();
364    for source in secondary {
365        if !combined.iter().any(|existing| existing == source) {
366            combined.push(source.clone());
367        }
368    }
369    combined
370}
371
372fn intersect_strings<T: Clone + PartialEq>(primary: &[T], secondary: &[T]) -> Vec<T> {
373    primary
374        .iter()
375        .filter(|value| secondary.contains(value))
376        .cloned()
377        .collect()
378}
379
380fn intersect_ranges(primary: TimeRange, secondary: TimeRange) -> TimeRange {
381    let start = match (primary.start, secondary.start) {
382        (Some(a), Some(b)) => Some(if a.julian_day.days() >= b.julian_day.days() {
383            a
384        } else {
385            b
386        }),
387        (Some(a), None) => Some(a),
388        (None, Some(b)) => Some(b),
389        (None, None) => None,
390    };
391    let end = match (primary.end, secondary.end) {
392        (Some(a), Some(b)) => Some(if a.julian_day.days() <= b.julian_day.days() {
393            a
394        } else {
395            b
396        }),
397        (Some(a), None) => Some(a),
398        (None, Some(b)) => Some(b),
399        (None, None) => None,
400    };
401
402    let canonical_scale = primary
403        .start
404        .or(primary.end)
405        .or(secondary.start)
406        .or(secondary.end)
407        .map(|instant| instant.scale);
408
409    TimeRange::new(
410        start.map(|instant| retag_instant(instant, canonical_scale)),
411        end.map(|instant| retag_instant(instant, canonical_scale)),
412    )
413}
414
415fn retag_instant(instant: Instant, scale: Option<TimeScale>) -> Instant {
416    match scale {
417        Some(scale) if instant.scale != scale => Instant::new(instant.julian_day, scale),
418        _ => instant,
419    }
420}
421
422fn min_accuracy(primary: AccuracyClass, secondary: AccuracyClass) -> AccuracyClass {
423    use AccuracyClass::*;
424
425    match (primary, secondary) {
426        (Unknown, _) | (_, Unknown) => Unknown,
427        (Approximate, _) | (_, Approximate) => Approximate,
428        (Moderate, _) | (_, Moderate) => Moderate,
429        (High, _) | (_, High) => High,
430        (Exact, Exact) => Exact,
431    }
432}
433
434fn should_fallback_to_secondary(kind: &EphemerisErrorKind) -> bool {
435    matches!(
436        kind,
437        EphemerisErrorKind::UnsupportedBody
438            | EphemerisErrorKind::UnsupportedCoordinateFrame
439            | EphemerisErrorKind::UnsupportedTimeScale
440            | EphemerisErrorKind::InvalidObserver
441            | EphemerisErrorKind::UnsupportedObserver
442            | EphemerisErrorKind::MissingDataset
443            | EphemerisErrorKind::UnsupportedApparentness
444            | EphemerisErrorKind::UnsupportedZodiacMode
445            | EphemerisErrorKind::InvalidRequest
446    )
447}
448
449#[cfg(test)]
450#[path = "traits_tests.rs"]
451mod tests;