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