1use std::sync::Arc;
2
3#[cfg(feature = "packaged-artifact-path")]
4use std::path::Path;
5
6use pleiades_apparent::{
7 precess_ecliptic_date_to_j2000, precess_ecliptic_j2000_to_date, ApparentPlaceError,
8};
9use pleiades_apsides::{
10 apsides, elements_from_state, mean_lunar_elements_of_date, points_from_elements,
11 MOON_MEAN_SEMI_MAJOR_AU, MU_EARTH_MOON_AU3_PER_DAY2,
12};
13use pleiades_backend::{
14 validate_observer_policy, validate_request_policy, validate_zodiac_policy, AccuracyClass,
15 BackendCapabilities, BackendFamily, BackendId, BackendMetadata, BackendProvenance,
16 CelestialBody, CoordinateFrame, EclipticCoordinates, EphemerisBackend, EphemerisError,
17 EphemerisErrorKind, EphemerisRequest, EphemerisResult, Instant, JulianDay, Latitude, Longitude,
18 Motion, QualityAnnotation, TimeScale, ZodiacMode,
19};
20use pleiades_compression::{
21 spherical_state_to_cartesian, CartesianState, CompressedArtifact, SphericalState,
22};
23
24use crate::coverage::packaged_body_coverage_summary_details;
25#[cfg(feature = "packaged-artifact-path")]
26use crate::data::packaged_artifact_from_path;
27use crate::data::{packaged_artifact, packaged_artifact_from_bytes, PackagedArtifactLoadError};
28use crate::lookup::{
29 packaged_artifact_access_summary, packaged_artifact_storage_summary,
30 packaged_frame_treatment_summary, packaged_request_policy_summary,
31};
32use crate::regenerate::{artifact_time_range, map_artifact_error, normalize_lookup_instant};
33use crate::PACKAGE_NAME;
34
35#[derive(Debug, Clone)]
37pub struct PackagedDataBackend {
38 artifact: Arc<CompressedArtifact>,
39}
40
41impl Default for PackagedDataBackend {
42 fn default() -> Self {
43 Self::new()
44 }
45}
46
47impl PackagedDataBackend {
48 pub fn new() -> Self {
50 Self::from_artifact(packaged_artifact().clone())
51 }
52
53 pub fn from_artifact(artifact: CompressedArtifact) -> Self {
55 Self {
56 artifact: Arc::new(artifact),
57 }
58 }
59
60 pub fn from_bytes(bytes: &[u8]) -> Result<Self, PackagedArtifactLoadError> {
65 Ok(Self::from_artifact(
66 packaged_artifact_from_bytes(bytes).map_err(PackagedArtifactLoadError::Decode)?,
67 ))
68 }
69
70 #[cfg(feature = "packaged-artifact-path")]
71 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, PackagedArtifactLoadError> {
77 Ok(Self::from_artifact(packaged_artifact_from_path(path)?))
78 }
79
80 fn artifact(&self) -> &CompressedArtifact {
81 &self.artifact
82 }
83
84 fn derived_point_position(
88 &self,
89 req: &EphemerisRequest,
90 eval: &dyn Fn(Instant) -> Result<EclipticCoordinates, EphemerisError>,
91 ) -> Result<EphemerisResult, EphemerisError> {
92 let ecliptic = eval(req.instant)?;
93 let equatorial = ecliptic.to_equatorial(req.instant.mean_obliquity());
94 let motion = self.derived_point_motion(req.instant, eval)?;
95
96 let mut result = EphemerisResult::new(
97 BackendId::new(PACKAGE_NAME),
98 req.body.clone(),
99 req.instant,
100 req.frame,
101 req.zodiac_mode.clone(),
102 req.apparent,
103 );
104 result.ecliptic = Some(ecliptic);
105 result.equatorial = Some(equatorial);
106 result.motion = Some(motion);
107 result.quality = QualityAnnotation::Interpolated;
108 Ok(result)
109 }
110
111 fn moon_state_j2000(&self, instant: Instant) -> Result<CartesianState, EphemerisError> {
114 let li = normalize_lookup_instant(instant);
115 let ecl = self
116 .artifact
117 .lookup_ecliptic(&CelestialBody::Moon, li)
118 .map_err(map_artifact_error)?;
119 let mot = self
120 .artifact
121 .lookup_motion(&CelestialBody::Moon, li)
122 .map_err(map_artifact_error)?;
123 let dist = ecl.distance_au.ok_or_else(|| {
124 EphemerisError::new(
125 EphemerisErrorKind::InvalidRequest,
126 "packaged Moon lacks distance for a derived lunar point",
127 )
128 })?;
129 Ok(spherical_state_to_cartesian(SphericalState {
130 lon_rad: ecl.longitude.degrees().to_radians(),
131 lat_rad: ecl.latitude.degrees().to_radians(),
132 dist_au: dist,
133 lon_rate_rad_per_day: mot.longitude_deg_per_day.unwrap_or(0.0).to_radians(),
134 lat_rate_rad_per_day: mot.latitude_deg_per_day.unwrap_or(0.0).to_radians(),
135 dist_rate_au_per_day: mot.distance_au_per_day.unwrap_or(0.0),
136 }))
137 }
138
139 fn osculating_apsis_ecliptic(
140 &self,
141 body: &CelestialBody,
142 instant: Instant,
143 ) -> Result<EclipticCoordinates, EphemerisError> {
144 let cart = self.moon_state_j2000(instant)?;
145 let aps = apsides(cart.pos_au, cart.vel_au_per_day, MU_EARTH_MOON_AU3_PER_DAY2).map_err(
146 |_| {
147 EphemerisError::new(
148 EphemerisErrorKind::InvalidRequest,
149 "osculating apsis undefined for the lunar state at this instant",
150 )
151 },
152 )?;
153 let point = match body {
154 CelestialBody::TrueApogee => aps.apogee,
155 CelestialBody::TruePerigee => aps.perigee,
156 _ => {
157 return Err(EphemerisError::new(
158 EphemerisErrorKind::InvalidRequest,
159 "not an osculating-apsis body",
160 ))
161 }
162 };
163 Ok(EclipticCoordinates::new(
164 Longitude::from_degrees(point.longitude_deg),
165 Latitude::from_degrees(point.latitude_deg),
166 Some(point.distance_au),
167 ))
168 }
169
170 fn osculating_node_ecliptic(
182 &self,
183 instant: Instant,
184 ) -> Result<EclipticCoordinates, EphemerisError> {
185 let jd_tt = instant.julian_day.days();
186 let cart = self.moon_state_j2000(instant)?;
187 let pos = rotate_j2000_to_mean_of_date(cart.pos_au, jd_tt)?;
188 let vel = rotate_j2000_to_mean_of_date(cart.vel_au_per_day, jd_tt)?;
189 let undefined = |_| {
190 EphemerisError::new(
191 EphemerisErrorKind::InvalidRequest,
192 "osculating node undefined for the lunar state at this instant",
193 )
194 };
195 let elements =
196 elements_from_state(pos, vel, MU_EARTH_MOON_AU3_PER_DAY2).map_err(undefined)?;
197 let node = points_from_elements(&elements, false)
198 .map_err(undefined)?
199 .ascending;
200 let j2000 = precess_ecliptic_date_to_j2000(node.longitude_deg, node.latitude_deg, jd_tt)
201 .map_err(map_precession_error)?;
202 Ok(EclipticCoordinates::new(
203 Longitude::from_degrees(j2000.longitude_deg),
204 Latitude::from_degrees(j2000.latitude_deg),
205 Some(node.distance_au),
206 ))
207 }
208
209 fn mean_lunar_point_ecliptic(
219 &self,
220 body: &CelestialBody,
221 instant: Instant,
222 ) -> Result<EclipticCoordinates, EphemerisError> {
223 self.artifact
225 .lookup_ecliptic(&CelestialBody::Moon, normalize_lookup_instant(instant))
226 .map_err(map_artifact_error)?;
227
228 let jd_tt = instant.julian_day.days();
229 let points =
230 points_from_elements(&mean_lunar_elements_of_date(jd_tt), false).map_err(|_| {
231 EphemerisError::new(
232 EphemerisErrorKind::InvalidRequest,
233 "mean lunar point undefined at this instant",
234 )
235 })?;
236 let (point, distance_au) = match body {
237 CelestialBody::MeanNode => (points.ascending, MOON_MEAN_SEMI_MAJOR_AU),
240 CelestialBody::MeanApogee => (points.aphelion, points.aphelion.distance_au),
241 CelestialBody::MeanPerigee => (points.perihelion, points.perihelion.distance_au),
242 _ => {
243 return Err(EphemerisError::new(
244 EphemerisErrorKind::InvalidRequest,
245 "not a mean lunar point",
246 ))
247 }
248 };
249 let j2000 = precess_ecliptic_date_to_j2000(point.longitude_deg, point.latitude_deg, jd_tt)
250 .map_err(map_precession_error)?;
251 Ok(EclipticCoordinates::new(
252 Longitude::from_degrees(j2000.longitude_deg),
253 Latitude::from_degrees(j2000.latitude_deg),
254 Some(distance_au),
255 ))
256 }
257
258 fn derived_point_motion(
262 &self,
263 instant: Instant,
264 eval: &dyn Fn(Instant) -> Result<EclipticCoordinates, EphemerisError>,
265 ) -> Result<Motion, EphemerisError> {
266 const HALF_SPAN_DAYS: f64 = 0.5;
267 let shift = |days: f64| {
268 Instant::new(
269 JulianDay::from_days(instant.julian_day.days() + days),
270 instant.scale,
271 )
272 };
273 let before = match eval(shift(-HALF_SPAN_DAYS)) {
274 Ok(e) => e,
275 Err(ref e) if e.kind == EphemerisErrorKind::OutOfRangeInstant => {
276 return Ok(Motion::new(None, None, None));
277 }
278 Err(e) => return Err(e),
279 };
280 let after = match eval(shift(HALF_SPAN_DAYS)) {
281 Ok(e) => e,
282 Err(ref e) if e.kind == EphemerisErrorKind::OutOfRangeInstant => {
283 return Ok(Motion::new(None, None, None));
284 }
285 Err(e) => return Err(e),
286 };
287 let span = 2.0 * HALF_SPAN_DAYS;
288
289 let mut dlon = after.longitude.degrees() - before.longitude.degrees();
290 while dlon > 180.0 {
291 dlon -= 360.0;
292 }
293 while dlon < -180.0 {
294 dlon += 360.0;
295 }
296 let dlon_per_day = dlon / span;
297 let dlat_per_day = (after.latitude.degrees() - before.latitude.degrees()) / span;
298 let ddist_per_day = match (before.distance_au, after.distance_au) {
299 (Some(b), Some(a)) => Some((a - b) / span),
300 _ => None,
301 };
302 Ok(Motion::new(
303 Some(dlon_per_day),
304 Some(dlat_per_day),
305 ddist_per_day,
306 ))
307 }
308}
309
310fn rotate_j2000_to_mean_of_date(v: [f64; 3], jd_tt: f64) -> Result<[f64; 3], EphemerisError> {
315 let r = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
316 if r == 0.0 {
317 return Ok(v);
318 }
319 let lon_deg = v[1].atan2(v[0]).to_degrees().rem_euclid(360.0);
320 let lat_deg = (v[2] / r).asin().to_degrees();
321 let p =
322 precess_ecliptic_j2000_to_date(lon_deg, lat_deg, jd_tt).map_err(map_precession_error)?;
323 let (sl, cl) = p.longitude_deg.to_radians().sin_cos();
324 let (sb, cb) = p.latitude_deg.to_radians().sin_cos();
325 Ok([r * cb * cl, r * cb * sl, r * sb])
326}
327
328fn map_precession_error(e: ApparentPlaceError) -> EphemerisError {
329 EphemerisError::new(
330 EphemerisErrorKind::InvalidRequest,
331 format!("precession failed for derived lunar point: {e}"),
332 )
333}
334
335impl EphemerisBackend for PackagedDataBackend {
336 fn metadata(&self) -> BackendMetadata {
337 let artifact = self.artifact();
338 let bodies = artifact
339 .bodies
340 .iter()
341 .map(|series| series.body.clone())
342 .collect::<Vec<_>>();
343 let range = artifact_time_range(artifact);
344
345 BackendMetadata {
346 id: BackendId::new(PACKAGE_NAME),
347 version: format!(
348 "{} checksum:{:016x}",
349 artifact.header.version, artifact.checksum
350 ),
351 family: BackendFamily::CompressedData,
352 provenance: BackendProvenance {
353 summary: artifact.header.source.clone(),
354 data_sources: vec![
355 packaged_body_coverage_summary_details()
356 .validated_summary_line()
357 .unwrap_or_else(|error| {
358 format!("Packaged body set: unavailable ({error})")
359 }),
360 packaged_request_policy_summary().to_string(),
361 packaged_frame_treatment_summary().to_string(),
362 packaged_artifact_storage_summary().to_string(),
363 packaged_artifact_access_summary().to_string(),
364 ],
365 },
366 nominal_range: range,
367 supported_time_scales: vec![TimeScale::Tt, TimeScale::Tdb],
368 body_claims: {
369 let declared = crate::packaged_body_claims();
370 let mut claims: Vec<_> = bodies
371 .iter()
372 .map(|body| {
373 declared
374 .iter()
375 .find(|c| &c.body == body)
376 .cloned()
377 .unwrap_or_else(|| pleiades_backend::BodyClaim::from(body.clone()))
378 })
379 .collect();
380 claims.extend(crate::apsis_body_claims());
381 claims.extend(crate::true_node_body_claims());
382 claims.extend(crate::mean_lunar_point_body_claims());
383 claims
384 },
385 supported_frames: vec![CoordinateFrame::Ecliptic, CoordinateFrame::Equatorial],
386 capabilities: BackendCapabilities {
387 geocentric: true,
388 topocentric: false,
389 apparent: false,
390 mean: true,
391 batch: true,
392 native_sidereal: false,
393 },
394 accuracy: AccuracyClass::Approximate,
395 deterministic: true,
396 offline: true,
397 }
398 }
399
400 fn supports_body(&self, body: CelestialBody) -> bool {
401 matches!(
402 body,
403 CelestialBody::TrueApogee
404 | CelestialBody::TruePerigee
405 | CelestialBody::TrueNode
406 | CelestialBody::MeanNode
407 | CelestialBody::MeanApogee
408 | CelestialBody::MeanPerigee
409 ) || self
410 .artifact
411 .bodies
412 .iter()
413 .any(|series| series.body == body)
414 }
415
416 fn position(&self, req: &EphemerisRequest) -> Result<EphemerisResult, EphemerisError> {
417 if !matches!(req.instant.scale, TimeScale::Tt | TimeScale::Tdb) {
418 return Err(EphemerisError::new(
419 EphemerisErrorKind::UnsupportedTimeScale,
420 "packaged data only supports TT or TDB requests",
421 ));
422 }
423
424 validate_request_policy(
425 req,
426 "packaged data",
427 &[TimeScale::Tt, TimeScale::Tdb],
428 &[CoordinateFrame::Ecliptic, CoordinateFrame::Equatorial],
429 true,
430 false,
431 )?;
432
433 validate_zodiac_policy(req, "packaged data", &[ZodiacMode::Tropical])?;
434
435 validate_observer_policy(req, "packaged data", false)?;
436
437 if matches!(
438 req.body,
439 CelestialBody::TrueApogee | CelestialBody::TruePerigee
440 ) {
441 let body = req.body.clone();
442 return self.derived_point_position(req, &|i| self.osculating_apsis_ecliptic(&body, i));
443 }
444 if req.body == CelestialBody::TrueNode {
445 return self.derived_point_position(req, &|i| self.osculating_node_ecliptic(i));
446 }
447 if matches!(
448 req.body,
449 CelestialBody::MeanNode | CelestialBody::MeanApogee | CelestialBody::MeanPerigee
450 ) {
451 let body = req.body.clone();
452 return self.derived_point_position(req, &|i| self.mean_lunar_point_ecliptic(&body, i));
453 }
454
455 let lookup_instant = normalize_lookup_instant(req.instant);
456 let ecliptic = self
457 .artifact
458 .lookup_ecliptic(&req.body, lookup_instant)
459 .map_err(map_artifact_error)?;
460 let equatorial = ecliptic.to_equatorial(req.instant.mean_obliquity());
461 let motion = self
462 .artifact
463 .lookup_motion(&req.body, lookup_instant)
464 .map_err(map_artifact_error)?;
465
466 let mut result = EphemerisResult::new(
467 BackendId::new(PACKAGE_NAME),
468 req.body.clone(),
469 req.instant,
470 req.frame,
471 req.zodiac_mode.clone(),
472 req.apparent,
473 );
474 result.ecliptic = Some(ecliptic);
475 result.equatorial = Some(equatorial);
476 result.motion = Some(motion);
477 result.quality = QualityAnnotation::Interpolated;
478 Ok(result)
479 }
480}
481
482#[cfg(test)]
483mod coupling_fixture_tests {
484 use super::*;
485
486 #[test]
495 fn backend_metadata_data_sources_is_stable() {
496 use pleiades_backend::EphemerisBackend;
497 let metadata = PackagedDataBackend::default().metadata();
498 let expected: &[&str] = &[
499 "Packaged body set: 11 bundled bodies (Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto, asteroid:433-Eros)",
500 "Packaged request policy: geocentric-only; frames=Ecliptic, Equatorial; time scales=TT, TDB; zodiac modes=Tropical; apparentness=Mean; topocentric observer=false; lookup epoch policy=TT-grid retag without relativistic correction; TDB lookup epochs are re-tagged onto the TT grid without applying a relativistic correction",
501 "checked-in compressed artifact stores J2000 ecliptic coordinates directly; equatorial coordinates are reconstructed from the stored channels and mean-obliquity transform",
502 "Quantized linear segments stored in pleiades-compression artifact format; body-indexed segment tables support random access by body and lookup time across the advertised range; ecliptic and equatorial coordinates are reconstructed at runtime from stored channels; apparent, topocentric, and sidereal outputs remain unsupported; motion/speed is derived from fitted segment derivatives",
503 "packaged artifact access: checked-in fixture only; explicit artifact-path loading disabled",
504 ];
505 assert_eq!(
506 metadata.provenance.data_sources, expected,
507 "backend metadata data_sources drifted:\n{:#?}",
508 metadata.provenance.data_sources
509 );
510 }
511}