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
9pub trait EphemerisBackend: Send + Sync {
16 fn metadata(&self) -> BackendMetadata;
18
19 fn supports_body(&self, body: CelestialBody) -> bool;
21
22 fn position(&self, req: &EphemerisRequest) -> Result<EphemerisResult, EphemerisError>;
24
25 fn positions(&self, reqs: &[EphemerisRequest]) -> Result<Vec<EphemerisResult>, EphemerisError> {
32 reqs.iter().map(|req| self.position(req)).collect()
33 }
34}
35
36#[derive(Debug)]
41pub struct CompositeBackend<A, B> {
42 primary: A,
43 secondary: B,
44}
45
46impl<A, B> CompositeBackend<A, B> {
47 pub const fn new(primary: A, secondary: B) -> Self {
49 Self { primary, secondary }
50 }
51
52 pub const fn primary(&self) -> &A {
54 &self.primary
55 }
56
57 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#[derive(Default)]
147pub struct RoutingBackend {
148 backends: Vec<Box<dyn EphemerisBackend>>,
149}
150
151impl RoutingBackend {
152 pub fn new(backends: Vec<Box<dyn EphemerisBackend>>) -> Self {
154 Self { backends }
155 }
156
157 pub fn backends(&self) -> &[Box<dyn EphemerisBackend>] {
159 &self.backends
160 }
161
162 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;