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, 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/// A packaged compressed-data backend.
35#[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    /// Creates a new packaged-data backend backed by the checked-in fixture.
48    pub fn new() -> Self {
49        Self::from_artifact(packaged_artifact().clone())
50    }
51
52    /// Creates a packaged-data backend from an explicit artifact.
53    pub fn from_artifact(artifact: CompressedArtifact) -> Self {
54        Self {
55            artifact: Arc::new(artifact),
56        }
57    }
58
59    /// Creates a packaged-data backend from decoded artifact bytes.
60    ///
61    /// See [`crate::packaged_backend_from_bytes`] for an end-to-end example that
62    /// encodes the checked-in artifact and reloads it through this constructor.
63    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    /// Creates a packaged-data backend from an artifact file.
71    ///
72    /// See [`crate::packaged_backend_from_path`] for an end-to-end example that writes
73    /// the checked-in artifact to a temporary file and reloads it through this
74    /// constructor.
75    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    /// Assembles a derived lunar point (osculating apsis or node) into a
84    /// backend result: J2000 ecliptic from `eval`, mean-obliquity equatorial,
85    /// central-difference motion, `Interpolated` quality.
86    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    /// The packaged Moon's geocentric J2000 Cartesian state at `instant`: the
111    /// shared input of every derived lunar point.
112    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    /// Osculating ascending node of the geocentric lunar orbit, J2000 boundary
170    /// frame.
171    ///
172    /// The node is the intersection of the orbit plane with the *reference*
173    /// plane, so it must be formed in the plane consumers will read it in: the
174    /// mean ecliptic of date (spec §1; forming it in J2000 and rotating the
175    /// point would misplace it by ≈ tilt/sin(i) ≈ 0.04°). Position and velocity
176    /// are rotated J2000 → mean-of-date, the ellipse is formed there, and the
177    /// node point is precessed back to J2000 like the ELP point channels
178    /// (issue #57). The chart layer's forward precession + Δψ then reproduces
179    /// Swiss Ephemeris `SE_TRUE_NODE`.
180    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    /// Central-difference motion of a derived lunar point over ±0.5 day. A
209    /// probe that falls outside the packaged window degrades to `None`
210    /// channels rather than failing the position.
211    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
260/// Rotates a J2000 mean-ecliptic vector into the mean ecliptic of date at
261/// `jd_tt`, preserving its magnitude. Precession is a rotation, so the same
262/// map applies to position and velocity vectors alike (the events engine's
263/// osculating path does the same).
264fn 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    /// Golden fixture pinning `PackagedDataBackend::metadata()`'s
424    /// `provenance.data_sources` to its pre-Slice-C rendered values.
425    ///
426    /// This test exists to prove byte-identity when the five report-prose
427    /// renderers backing `data_sources` are replaced with an inline rebuild
428    /// from retained structured accessors (Slice C, Task 2). It must keep
429    /// passing after that rebuild — if it fails, the rebuild drifted; fix
430    /// the rebuild, never this fixture.
431    #[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}