Skip to main content

pleiades_data/
backend.rs

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/// A packaged compressed-data backend.
36#[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    /// Creates a new packaged-data backend backed by the checked-in fixture.
49    pub fn new() -> Self {
50        Self::from_artifact(packaged_artifact().clone())
51    }
52
53    /// Creates a packaged-data backend from an explicit artifact.
54    pub fn from_artifact(artifact: CompressedArtifact) -> Self {
55        Self {
56            artifact: Arc::new(artifact),
57        }
58    }
59
60    /// Creates a packaged-data backend from decoded artifact bytes.
61    ///
62    /// See [`crate::packaged_backend_from_bytes`] for an end-to-end example that
63    /// encodes the checked-in artifact and reloads it through this constructor.
64    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    /// Creates a packaged-data backend from an artifact file.
72    ///
73    /// See [`crate::packaged_backend_from_path`] for an end-to-end example that writes
74    /// the checked-in artifact to a temporary file and reloads it through this
75    /// constructor.
76    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    /// Assembles a derived lunar point (osculating or mean apsis or node) into a
85    /// backend result: J2000 ecliptic from `eval`, mean-obliquity equatorial,
86    /// central-difference motion, `Interpolated` quality.
87    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    /// The packaged Moon's geocentric J2000 Cartesian state at `instant`: the
112    /// shared input of every derived lunar point.
113    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    /// Osculating ascending node of the geocentric lunar orbit, J2000 boundary
171    /// frame.
172    ///
173    /// The node is the intersection of the orbit plane with the *reference*
174    /// plane, so it must be formed in the plane consumers will read it in: the
175    /// mean ecliptic of date (spec §1; forming it in J2000 and rotating the
176    /// point would misplace it by ≈ tilt/sin(i) ≈ 0.04°). Position and velocity
177    /// are rotated J2000 → mean-of-date, the ellipse is formed there, and the
178    /// node point is precessed back to J2000 like the ELP point channels
179    /// (issue #57). The chart layer's forward precession + Δψ then reproduces
180    /// Swiss Ephemeris `SE_TRUE_NODE`.
181    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    /// A mean lunar point (mean node, mean apogee or mean perigee), J2000
210    /// boundary frame.
211    ///
212    /// The point is placed on the Moon's mean orbit in the mean ecliptic of
213    /// date — the apsides through the inclined orbit, as Swiss Ephemeris'
214    /// `SE_MEAN_APOG` is, not as the raw longitude-of-perigee element — and
215    /// precessed back to J2000 like the osculating node. The elements are
216    /// analytic, but the point is served only inside the packaged window so
217    /// the backend's advertised range holds for every body it answers.
218    fn mean_lunar_point_ecliptic(
219        &self,
220        body: &CelestialBody,
221        instant: Instant,
222    ) -> Result<EclipticCoordinates, EphemerisError> {
223        // Window probe: the Moon series spans the packaged window.
224        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            // Swiss Ephemeris reports the mean lunar distance for SE_MEAN_NODE,
238            // not the orbit radius at the node.
239            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    /// Central-difference motion of a derived lunar point over ±0.5 day. A
259    /// probe that falls outside the packaged window degrades to `None`
260    /// channels rather than failing the position.
261    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
310/// Rotates a J2000 mean-ecliptic vector into the mean ecliptic of date at
311/// `jd_tt`, preserving its magnitude. Precession is a rotation, so the same
312/// map applies to position and velocity vectors alike (the events engine's
313/// osculating path does the same).
314fn 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    /// Golden fixture pinning `PackagedDataBackend::metadata()`'s
487    /// `provenance.data_sources` to its pre-Slice-C rendered values.
488    ///
489    /// This test exists to prove byte-identity when the five report-prose
490    /// renderers backing `data_sources` are replaced with an inline rebuild
491    /// from retained structured accessors (Slice C, Task 2). It must keep
492    /// passing after that rebuild — if it fails, the rebuild drifted; fix
493    /// the rebuild, never this fixture.
494    #[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}