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 multiple ephemeris results.
26    ///
27    /// The default adapter calls [`Self::position`] for each request in order and
28    /// preserves each request's own instant and time-scale label exactly as
29    /// supplied, so mixed TT/TDB batches remain mixed in the returned results
30    /// instead of being normalized to a batch-wide scale.
31    fn positions(&self, reqs: &[EphemerisRequest]) -> Result<Vec<EphemerisResult>, EphemerisError> {
32        reqs.iter().map(|req| self.position(req)).collect()
33    }
34}
35
36/// A simple composite backend that routes requests to one of two providers.
37///
38/// The primary backend is consulted first. If it does not advertise support for
39/// the requested body, the secondary backend is tried instead.
40#[derive(Debug)]
41pub struct CompositeBackend<A, B> {
42    primary: A,
43    secondary: B,
44}
45
46impl<A, B> CompositeBackend<A, B> {
47    /// Creates a new routing backend.
48    pub const fn new(primary: A, secondary: B) -> Self {
49        Self { primary, secondary }
50    }
51
52    /// Returns the primary backend.
53    pub const fn primary(&self) -> &A {
54        &self.primary
55    }
56
57    /// Returns the secondary backend.
58    pub const fn secondary(&self) -> &B {
59        &self.secondary
60    }
61}
62
63impl<A: EphemerisBackend, B: EphemerisBackend> EphemerisBackend for CompositeBackend<A, B> {
64    fn metadata(&self) -> BackendMetadata {
65        let primary = self.primary.metadata();
66        let secondary = self.secondary.metadata();
67        BackendMetadata {
68            id: BackendId::new(format!(
69                "composite:{}+{}",
70                primary.id.as_str(),
71                secondary.id.as_str()
72            )),
73            version: primary.version.clone(),
74            family: BackendFamily::Composite,
75            provenance: BackendProvenance {
76                summary: format!(
77                    "Composite routing backend combining {} and {}.",
78                    primary.provenance.summary, secondary.provenance.summary
79                ),
80                data_sources: combine_sources(
81                    &primary.provenance.data_sources,
82                    &secondary.provenance.data_sources,
83                ),
84            },
85            nominal_range: intersect_ranges(primary.nominal_range, secondary.nominal_range),
86            supported_time_scales: intersect_strings(
87                &primary.supported_time_scales,
88                &secondary.supported_time_scales,
89            ),
90            body_claims: crate::metadata::merge_body_claims(
91                &primary.body_claims,
92                &secondary.body_claims,
93            ),
94            supported_frames: intersect_strings(
95                &primary.supported_frames,
96                &secondary.supported_frames,
97            ),
98            capabilities: BackendCapabilities {
99                geocentric: primary.capabilities.geocentric && secondary.capabilities.geocentric,
100                topocentric: primary.capabilities.topocentric && secondary.capabilities.topocentric,
101                apparent: primary.capabilities.apparent && secondary.capabilities.apparent,
102                mean: primary.capabilities.mean && secondary.capabilities.mean,
103                batch: primary.capabilities.batch && secondary.capabilities.batch,
104                native_sidereal: primary.capabilities.native_sidereal
105                    && secondary.capabilities.native_sidereal,
106            },
107            accuracy: min_accuracy(primary.accuracy, secondary.accuracy),
108            deterministic: primary.deterministic && secondary.deterministic,
109            offline: primary.offline && secondary.offline,
110        }
111    }
112
113    fn supports_body(&self, body: CelestialBody) -> bool {
114        self.primary.supports_body(body.clone()) || self.secondary.supports_body(body)
115    }
116
117    fn position(&self, req: &EphemerisRequest) -> Result<EphemerisResult, EphemerisError> {
118        let primary_supports = self.primary.supports_body(req.body.clone());
119        let secondary_supports = self.secondary.supports_body(req.body.clone());
120
121        if primary_supports {
122            match self.primary.position(req) {
123                Ok(result) => Ok(result),
124                Err(error) if secondary_supports && should_fallback_to_secondary(&error.kind) => {
125                    self.secondary.position(req)
126                }
127                Err(error) => Err(error),
128            }
129        } else if secondary_supports {
130            self.secondary.position(req)
131        } else {
132            Err(EphemerisError::new(
133                EphemerisErrorKind::UnsupportedBody,
134                "no backend in the composite router supports the requested body",
135            ))
136        }
137    }
138}
139
140/// A routing backend that can chain any number of providers.
141///
142/// The router queries providers in priority order and falls back to later
143/// backends when the earlier ones report a retryable routing error. This makes
144/// it convenient to compose packaged, algorithmic, and reference-data
145/// backends without nesting multiple binary composites.
146#[derive(Default)]
147pub struct RoutingBackend {
148    backends: Vec<Box<dyn EphemerisBackend>>,
149}
150
151impl RoutingBackend {
152    /// Creates a new routing backend from a prioritized list of providers.
153    pub fn new(backends: Vec<Box<dyn EphemerisBackend>>) -> Self {
154        Self { backends }
155    }
156
157    /// Returns the configured provider chain.
158    pub fn backends(&self) -> &[Box<dyn EphemerisBackend>] {
159        &self.backends
160    }
161
162    /// Returns `true` if no providers are configured.
163    pub fn is_empty(&self) -> bool {
164        self.backends.is_empty()
165    }
166}
167
168impl EphemerisBackend for RoutingBackend {
169    fn metadata(&self) -> BackendMetadata {
170        let backends: Vec<&dyn EphemerisBackend> = self
171            .backends
172            .iter()
173            .map(|backend| backend.as_ref())
174            .collect();
175        let metadatas: Vec<BackendMetadata> =
176            backends.iter().map(|backend| backend.metadata()).collect();
177
178        if metadatas.is_empty() {
179            return BackendMetadata {
180                id: BackendId::new("routing:empty"),
181                version: "routing[none]".to_string(),
182                family: BackendFamily::Composite,
183                provenance: BackendProvenance::new("Routing backend with no configured providers."),
184                nominal_range: TimeRange::new(None, None),
185                supported_time_scales: Vec::new(),
186                body_claims: Vec::new(),
187                supported_frames: Vec::new(),
188                capabilities: BackendCapabilities {
189                    geocentric: false,
190                    topocentric: false,
191                    apparent: false,
192                    mean: false,
193                    batch: false,
194                    native_sidereal: false,
195                },
196                accuracy: AccuracyClass::Unknown,
197                deterministic: true,
198                offline: true,
199            };
200        }
201
202        let mut id_parts = Vec::with_capacity(metadatas.len());
203        let mut version_parts = Vec::with_capacity(metadatas.len());
204        let mut provenance_parts = Vec::with_capacity(metadatas.len());
205        let mut data_sources = Vec::new();
206        let mut nominal_range = metadatas[0].nominal_range;
207        let mut supported_time_scales = metadatas[0].supported_time_scales.clone();
208        let mut body_claims = metadatas[0].body_claims.clone();
209        let mut supported_frames = metadatas[0].supported_frames.clone();
210        let mut capabilities = metadatas[0].capabilities.clone();
211        let mut accuracy = metadatas[0].accuracy;
212        let mut deterministic = metadatas[0].deterministic;
213        let mut offline = metadatas[0].offline;
214
215        for metadata in &metadatas {
216            id_parts.push(metadata.id.as_str().to_string());
217            version_parts.push(metadata.version.clone());
218            provenance_parts.push(metadata.provenance.summary.clone());
219            data_sources = combine_sources(&data_sources, &metadata.provenance.data_sources);
220            nominal_range = intersect_ranges(nominal_range, metadata.nominal_range);
221            supported_time_scales =
222                intersect_strings(&supported_time_scales, &metadata.supported_time_scales);
223            body_claims = crate::metadata::merge_body_claims(&body_claims, &metadata.body_claims);
224            supported_frames = intersect_strings(&supported_frames, &metadata.supported_frames);
225            capabilities.geocentric &= metadata.capabilities.geocentric;
226            capabilities.topocentric &= metadata.capabilities.topocentric;
227            capabilities.apparent &= metadata.capabilities.apparent;
228            capabilities.mean &= metadata.capabilities.mean;
229            capabilities.batch &= metadata.capabilities.batch;
230            capabilities.native_sidereal &= metadata.capabilities.native_sidereal;
231            accuracy = min_accuracy(accuracy, metadata.accuracy);
232            deterministic &= metadata.deterministic;
233            offline &= metadata.offline;
234        }
235
236        BackendMetadata {
237            id: BackendId::new(format!("routing:{}", id_parts.join("+"))),
238            version: format!("routing[{}]", version_parts.join("+")),
239            family: BackendFamily::Composite,
240            provenance: BackendProvenance {
241                summary: format!(
242                    "Routing backend combining {} provider(s): {}.",
243                    metadatas.len(),
244                    provenance_parts.join("; ")
245                ),
246                data_sources,
247            },
248            nominal_range,
249            supported_time_scales,
250            body_claims,
251            supported_frames,
252            capabilities,
253            accuracy,
254            deterministic,
255            offline,
256        }
257    }
258
259    fn supports_body(&self, body: CelestialBody) -> bool {
260        self.backends
261            .iter()
262            .any(|backend| backend.supports_body(body.clone()))
263    }
264
265    fn position(&self, req: &EphemerisRequest) -> Result<EphemerisResult, EphemerisError> {
266        let mut saw_support = false;
267        let mut last_retryable_error = None;
268
269        for backend in &self.backends {
270            if !backend.supports_body(req.body.clone()) {
271                continue;
272            }
273
274            saw_support = true;
275            match backend.position(req) {
276                Ok(result) => return Ok(result),
277                Err(error) if should_fallback_to_secondary(&error.kind) => {
278                    last_retryable_error = Some(error);
279                }
280                Err(error) => return Err(error),
281            }
282        }
283
284        if let Some(error) = last_retryable_error {
285            Err(error)
286        } else if saw_support {
287            Err(EphemerisError::new(
288                EphemerisErrorKind::InvalidRequest,
289                "configured providers could not satisfy the requested body and request shape",
290            ))
291        } else {
292            Err(EphemerisError::new(
293                EphemerisErrorKind::UnsupportedBody,
294                "no backend in the routing chain supports the requested body",
295            ))
296        }
297    }
298}
299
300fn combine_sources(primary: &[String], secondary: &[String]) -> Vec<String> {
301    let mut combined = primary.to_vec();
302    for source in secondary {
303        if !combined.iter().any(|existing| existing == source) {
304            combined.push(source.clone());
305        }
306    }
307    combined
308}
309
310fn intersect_strings<T: Clone + PartialEq>(primary: &[T], secondary: &[T]) -> Vec<T> {
311    primary
312        .iter()
313        .filter(|value| secondary.contains(value))
314        .cloned()
315        .collect()
316}
317
318fn intersect_ranges(primary: TimeRange, secondary: TimeRange) -> TimeRange {
319    let start = match (primary.start, secondary.start) {
320        (Some(a), Some(b)) => Some(if a.julian_day.days() >= b.julian_day.days() {
321            a
322        } else {
323            b
324        }),
325        (Some(a), None) => Some(a),
326        (None, Some(b)) => Some(b),
327        (None, None) => None,
328    };
329    let end = match (primary.end, secondary.end) {
330        (Some(a), Some(b)) => Some(if a.julian_day.days() <= b.julian_day.days() {
331            a
332        } else {
333            b
334        }),
335        (Some(a), None) => Some(a),
336        (None, Some(b)) => Some(b),
337        (None, None) => None,
338    };
339
340    let canonical_scale = primary
341        .start
342        .or(primary.end)
343        .or(secondary.start)
344        .or(secondary.end)
345        .map(|instant| instant.scale);
346
347    TimeRange::new(
348        start.map(|instant| retag_instant(instant, canonical_scale)),
349        end.map(|instant| retag_instant(instant, canonical_scale)),
350    )
351}
352
353fn retag_instant(instant: Instant, scale: Option<TimeScale>) -> Instant {
354    match scale {
355        Some(scale) if instant.scale != scale => Instant::new(instant.julian_day, scale),
356        _ => instant,
357    }
358}
359
360fn min_accuracy(primary: AccuracyClass, secondary: AccuracyClass) -> AccuracyClass {
361    use AccuracyClass::*;
362
363    match (primary, secondary) {
364        (Unknown, _) | (_, Unknown) => Unknown,
365        (Approximate, _) | (_, Approximate) => Approximate,
366        (Moderate, _) | (_, Moderate) => Moderate,
367        (High, _) | (_, High) => High,
368        (Exact, Exact) => Exact,
369    }
370}
371
372fn should_fallback_to_secondary(kind: &EphemerisErrorKind) -> bool {
373    matches!(
374        kind,
375        EphemerisErrorKind::UnsupportedBody
376            | EphemerisErrorKind::UnsupportedCoordinateFrame
377            | EphemerisErrorKind::UnsupportedTimeScale
378            | EphemerisErrorKind::InvalidObserver
379            | EphemerisErrorKind::UnsupportedObserver
380            | EphemerisErrorKind::MissingDataset
381            | EphemerisErrorKind::UnsupportedApparentness
382            | EphemerisErrorKind::UnsupportedZodiacMode
383            | EphemerisErrorKind::InvalidRequest
384    )
385}
386
387#[cfg(test)]
388#[path = "traits_tests.rs"]
389mod tests;