Skip to main content

pleiades_compression/
artifact.rs

1//! The top-level `CompressedArtifact` type with encode/decode/lookup.
2
3use core::fmt;
4
5use pleiades_types::{
6    Angle, CelestialBody, EclipticCoordinates, EquatorialCoordinates, Instant, Motion, TimeScale,
7};
8
9use crate::channels::{BodyArtifact, ChannelKind};
10use crate::codec::validate_body_artifacts;
11use crate::codec::{
12    decode_artifact_profile, decode_body, decode_endian_policy, encode_artifact_profile,
13    encode_body, encode_endian_policy, fnv1a64, write_u16, write_u64, Cursor,
14};
15use crate::error::{CompressionError, CompressionErrorKind};
16use crate::format::{
17    ArtifactHeader, ArtifactOutput, ArtifactProfileCoverageSummary,
18    ArtifactResidualBodyCoverageSummary,
19};
20
21/// A compressed ephemeris artifact.
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[derive(Clone, Debug, PartialEq)]
24pub struct CompressedArtifact {
25    /// File header metadata.
26    pub header: ArtifactHeader,
27    /// Checksum over the payload bytes.
28    pub checksum: u64,
29    /// Body series stored in the artifact.
30    pub bodies: Vec<BodyArtifact>,
31}
32
33impl CompressedArtifact {
34    /// Creates a new artifact with an unset checksum.
35    pub fn new(header: ArtifactHeader, bodies: Vec<BodyArtifact>) -> Self {
36        Self {
37            header,
38            checksum: 0,
39            bodies,
40        }
41    }
42
43    /// Validates the in-memory artifact before encoding or regeneration.
44    ///
45    /// This checks the header metadata, the bundled body/profile coverage,
46    /// canonical body ordering, duplicate body entries, and every body
47    /// segment's metadata. It is useful for generators that want to fail fast
48    /// before writing a deterministic binary payload.
49    pub fn validate(&self) -> Result<(), CompressionError> {
50        self.header.validate()?;
51        validate_body_artifacts(&self.bodies)?;
52        self.profile_coverage_summary().validate()?;
53        for body in &self.bodies {
54            body.validate()?;
55        }
56
57        // A heliocentric body is reconstructed against the geocentric Sun at lookup,
58        // so a Sun body must be present. Fail closed rather than mis-reconstruct.
59        let has_heliocentric = self
60            .bodies
61            .iter()
62            .any(|b| b.frame == crate::channels::StoredFrame::Heliocentric);
63        if has_heliocentric {
64            let sun = self.bodies.iter().find(|b| b.body == CelestialBody::Sun);
65            match sun {
66                Some(s) if s.frame == crate::channels::StoredFrame::Geocentric => {}
67                Some(_) => {
68                    return Err(CompressionError::new(
69                        CompressionErrorKind::InvalidFormat,
70                        "artifact has heliocentric bodies but the Sun is not stored geocentric",
71                    ));
72                }
73                None => {
74                    return Err(CompressionError::new(
75                        CompressionErrorKind::InvalidFormat,
76                        "artifact has heliocentric bodies but contains no Sun reference",
77                    ));
78                }
79            }
80        }
81
82        Ok(())
83    }
84
85    /// Returns the artifact profile paired with the bundled body list.
86    pub fn profile_coverage_summary(&self) -> ArtifactProfileCoverageSummary {
87        ArtifactProfileCoverageSummary::new(
88            self.header.profile.clone(),
89            self.bodies.iter().map(|body| body.body.clone()).collect(),
90        )
91    }
92
93    /// Returns the bodies that include at least one residual-correction segment.
94    pub fn residual_body_coverage_summary(&self) -> ArtifactResidualBodyCoverageSummary {
95        ArtifactResidualBodyCoverageSummary::new(self.residual_bodies())
96    }
97
98    /// Returns the body artifact for the requested body, if present.
99    pub fn body_artifact(&self, body: &CelestialBody) -> Option<&BodyArtifact> {
100        self.bodies.iter().find(|series| &series.body == body)
101    }
102
103    /// Returns the total number of segments stored in the artifact.
104    pub fn segment_count(&self) -> usize {
105        self.bodies.iter().map(|body| body.segments.len()).sum()
106    }
107
108    /// Returns the number of segments that carry residual-correction channels.
109    pub fn residual_segment_count(&self) -> usize {
110        self.bodies
111            .iter()
112            .flat_map(|body| body.segments.iter())
113            .filter(|segment| !segment.residual_channels.is_empty())
114            .count()
115    }
116
117    /// Returns the bundled bodies that include at least one residual-correction segment.
118    pub fn residual_bodies(&self) -> Vec<CelestialBody> {
119        self.bodies
120            .iter()
121            .filter(|body| {
122                body.segments
123                    .iter()
124                    .any(|segment| !segment.residual_channels.is_empty())
125            })
126            .map(|body| body.body.clone())
127            .collect()
128    }
129
130    /// Returns a compact one-line summary of the artifact body, segment, and
131    /// residual-correction coverage.
132    pub fn summary_line(&self) -> String {
133        let residual_bodies = self.residual_bodies();
134        let residual_bodies = if residual_bodies.is_empty() {
135            "none".to_string()
136        } else {
137            crate::join_display(&residual_bodies)
138        };
139
140        format!(
141            "bodies: {}; segments: {}; residual-bearing segments: {}; residual-bearing bodies: {}",
142            self.bodies.len(),
143            self.segment_count(),
144            self.residual_segment_count(),
145            residual_bodies,
146        )
147    }
148
149    /// Returns the body segment covering the requested instant.
150    pub fn segment_for(
151        &self,
152        body: &CelestialBody,
153        instant: Instant,
154    ) -> Result<&crate::channels::Segment, CompressionError> {
155        let series = self.body_artifact(body).ok_or_else(|| {
156            CompressionError::new(
157                CompressionErrorKind::MissingBody,
158                format!("no packed data exists for {body:?}"),
159            )
160        })?;
161
162        series.segment_at(instant).ok_or_else(|| {
163            CompressionError::new(
164                CompressionErrorKind::OutOfRangeInstant,
165                format!("no packed segment covers {body:?} at {instant:?}"),
166            )
167        })
168    }
169
170    /// Returns the on-disk checksum for this artifact.
171    pub fn checksum(&self) -> Result<u64, CompressionError> {
172        Ok(fnv1a64(&self.encode_payload()?))
173    }
174
175    /// Encodes the artifact as a deterministic binary blob.
176    pub fn encode(&self) -> Result<Vec<u8>, CompressionError> {
177        let payload = self.encode_payload()?;
178        let checksum = fnv1a64(&payload);
179
180        let mut bytes = Vec::new();
181        bytes.extend_from_slice(&crate::ARTIFACT_MAGIC);
182        write_u16(&mut bytes, self.header.version);
183        write_u64(&mut bytes, checksum);
184        bytes.extend_from_slice(&payload);
185        Ok(bytes)
186    }
187
188    /// Decodes an artifact from a binary blob.
189    ///
190    /// Checksum mismatches are rejected with [`CompressionErrorKind::ChecksumMismatch`]
191    /// before the payload is parsed.
192    pub fn decode(bytes: &[u8]) -> Result<Self, CompressionError> {
193        let mut cursor = Cursor::new(bytes);
194        let magic = cursor.read_array::<8>()?;
195        if magic != crate::ARTIFACT_MAGIC {
196            return Err(CompressionError::new(
197                CompressionErrorKind::InvalidMagic,
198                "compressed artifact magic header did not match",
199            ));
200        }
201
202        let version = cursor.read_u16()?;
203        if version != crate::ARTIFACT_VERSION {
204            return Err(CompressionError::new(
205                CompressionErrorKind::UnsupportedVersion,
206                format!("artifact version {version} is not supported"),
207            ));
208        }
209
210        let checksum = cursor.read_u64()?;
211        let payload = cursor.remaining();
212        if fnv1a64(payload) != checksum {
213            return Err(CompressionError::new(
214                CompressionErrorKind::ChecksumMismatch,
215                "compressed artifact checksum did not match",
216            ));
217        }
218
219        let mut payload_cursor = Cursor::new(payload);
220        let header = ArtifactHeader {
221            version,
222            generation_label: payload_cursor.read_string()?,
223            source: payload_cursor.read_string()?,
224            endian_policy: decode_endian_policy(payload_cursor.read_u8()?)?,
225            profile: decode_artifact_profile(&mut payload_cursor)?,
226        };
227        let body_count = payload_cursor.read_u16()? as usize;
228        let mut bodies = Vec::with_capacity(body_count);
229        for _ in 0..body_count {
230            let body = decode_body(&mut payload_cursor)?;
231            bodies.push(body);
232        }
233
234        if !payload_cursor.is_finished() {
235            return Err(CompressionError::new(
236                CompressionErrorKind::InvalidFormat,
237                "compressed artifact contained trailing bytes",
238            ));
239        }
240
241        let artifact = Self {
242            header,
243            checksum,
244            bodies,
245        };
246        artifact.validate()?;
247
248        Ok(artifact)
249    }
250
251    /// Returns the ecliptic coordinates for a body at a given instant.
252    ///
253    /// The artifact profile must advertise `EclipticCoordinates` as a derived
254    /// output before this helper will serve the result.
255    pub fn lookup_ecliptic(
256        &self,
257        body: &CelestialBody,
258        instant: Instant,
259    ) -> Result<EclipticCoordinates, CompressionError> {
260        self.require_output_support(ArtifactOutput::EclipticCoordinates)?;
261
262        if !matches!(instant.scale, TimeScale::Tt | TimeScale::Tdb) {
263            return Err(CompressionError::new(
264                CompressionErrorKind::UnsupportedTimeScale,
265                "packaged lookup only accepts TT or TDB instants",
266            ));
267        }
268
269        let segment = self.segment_for(body, instant)?;
270
271        let span = segment.span_days();
272        let x = if span == 0.0 {
273            0.0
274        } else {
275            (instant.julian_day.days() - segment.start.julian_day.days()) / span
276        };
277
278        use pleiades_types::{Latitude, Longitude};
279        let longitude = segment.evaluate_channel(ChannelKind::Longitude, x)?;
280        let latitude = segment.evaluate_channel(ChannelKind::Latitude, x)?;
281        let distance_au = segment.evaluate_channel(ChannelKind::DistanceAu, x)?;
282
283        let stored = EclipticCoordinates::new(
284            Longitude::from_degrees(longitude),
285            Latitude::from_degrees(latitude),
286            Some(distance_au),
287        );
288
289        let frame = self
290            .body_artifact(body)
291            .map(|b| b.frame)
292            .unwrap_or(crate::channels::StoredFrame::Geocentric);
293
294        match frame {
295            crate::channels::StoredFrame::Geocentric => Ok(stored),
296            crate::channels::StoredFrame::Heliocentric => {
297                let sun_geo = self.lookup_ecliptic(&CelestialBody::Sun, instant)?;
298                crate::frame_recombine::geocentric_from_heliocentric(&stored, &sun_geo).ok_or_else(
299                    || {
300                        CompressionError::new(
301                            CompressionErrorKind::InvalidFormat,
302                            "heliocentric reconstruction requires finite distances on body and Sun",
303                        )
304                    },
305                )
306            }
307        }
308    }
309
310    /// Returns equatorial coordinates reconstructed from the stored ecliptic channels.
311    ///
312    /// This keeps the artifact format focused on the stored channels while still allowing
313    /// the runtime to reconstruct a derived coordinate family when the caller supplies the
314    /// geometric obliquity used for the frame rotation (the packaged backend passes the
315    /// J2000 obliquity). The artifact profile must advertise `EquatorialCoordinates` as a
316    /// derived output before this helper will serve the result.
317    pub fn lookup_equatorial(
318        &self,
319        body: &CelestialBody,
320        instant: Instant,
321        obliquity: Angle,
322    ) -> Result<EquatorialCoordinates, CompressionError> {
323        self.require_output_support(ArtifactOutput::EquatorialCoordinates)?;
324        Ok(self
325            .lookup_ecliptic(body, instant)?
326            .to_equatorial(obliquity))
327    }
328
329    /// Returns the angular and radial speeds for a body at a given instant.
330    ///
331    /// Speeds are computed analytically from the fitted polynomial segment derivatives.
332    /// For geocentric-stored bodies (Sun, Moon, Eros) the derivative of the stored
333    /// channel is returned directly. For heliocentric-stored planets the heliocentric
334    /// spherical state is converted to Cartesian, added to the Sun's geocentric
335    /// Cartesian state, and converted back to geocentric spherical rates.
336    ///
337    /// The artifact profile must advertise `Motion` as a supported output (via a
338    /// non-`Unsupported` `SpeedPolicy`) before this helper will serve the result.
339    pub fn lookup_motion(
340        &self,
341        body: &CelestialBody,
342        instant: Instant,
343    ) -> Result<Motion, CompressionError> {
344        self.require_output_support(ArtifactOutput::Motion)?;
345
346        if !matches!(instant.scale, TimeScale::Tt | TimeScale::Tdb) {
347            return Err(CompressionError::new(
348                CompressionErrorKind::UnsupportedTimeScale,
349                "packaged lookup only accepts TT or TDB instants",
350            ));
351        }
352
353        let segment = self.segment_for(body, instant)?;
354
355        let span = segment.span_days();
356        let x = if span == 0.0 {
357            0.0
358        } else {
359            (instant.julian_day.days() - segment.start.julian_day.days()) / span
360        };
361
362        // dP/dx × (1/span_days) converts normalized derivative to per-day rate.
363        let dlon_dt = segment.evaluate_channel_derivative(ChannelKind::Longitude, x)? / span;
364        let dlat_dt = segment.evaluate_channel_derivative(ChannelKind::Latitude, x)? / span;
365        let ddist_dt = segment.evaluate_channel_derivative(ChannelKind::DistanceAu, x)? / span;
366
367        let frame = self
368            .body_artifact(body)
369            .map(|b| b.frame)
370            .unwrap_or(crate::channels::StoredFrame::Geocentric);
371
372        match frame {
373            crate::channels::StoredFrame::Geocentric => Ok(Motion {
374                longitude_deg_per_day: Some(dlon_dt),
375                latitude_deg_per_day: Some(dlat_dt),
376                distance_au_per_day: Some(ddist_dt),
377            }),
378            crate::channels::StoredFrame::Heliocentric => {
379                // Build the heliocentric spherical state (channels are in degrees,
380                // recombination functions use radians).
381                let lon = segment.evaluate_channel(ChannelKind::Longitude, x)?;
382                let lat = segment.evaluate_channel(ChannelKind::Latitude, x)?;
383                let dist = segment.evaluate_channel(ChannelKind::DistanceAu, x)?;
384                let helio = crate::frame_recombine::spherical_state_to_cartesian(
385                    crate::frame_recombine::SphericalState {
386                        lon_rad: lon.to_radians(),
387                        lat_rad: lat.to_radians(),
388                        dist_au: dist,
389                        lon_rate_rad_per_day: dlon_dt.to_radians(),
390                        lat_rate_rad_per_day: dlat_dt.to_radians(),
391                        dist_rate_au_per_day: ddist_dt,
392                    },
393                );
394                let sun = self.sun_cartesian_state(instant)?;
395                let geo = crate::frame_recombine::CartesianState {
396                    pos_au: [
397                        helio.pos_au[0] + sun.pos_au[0],
398                        helio.pos_au[1] + sun.pos_au[1],
399                        helio.pos_au[2] + sun.pos_au[2],
400                    ],
401                    vel_au_per_day: [
402                        helio.vel_au_per_day[0] + sun.vel_au_per_day[0],
403                        helio.vel_au_per_day[1] + sun.vel_au_per_day[1],
404                        helio.vel_au_per_day[2] + sun.vel_au_per_day[2],
405                    ],
406                };
407                let s = crate::frame_recombine::cartesian_state_to_spherical(geo);
408                Ok(Motion {
409                    longitude_deg_per_day: Some(s.lon_rate_rad_per_day.to_degrees()),
410                    latitude_deg_per_day: Some(s.lat_rate_rad_per_day.to_degrees()),
411                    distance_au_per_day: Some(s.dist_rate_au_per_day),
412                })
413            }
414        }
415    }
416
417    /// Returns the Sun's geocentric Cartesian position (AU) and velocity (AU/day)
418    /// at the given instant by evaluating the Sun's stored geocentric segment.
419    ///
420    /// The Sun is always stored geocentric per the SP2 Sun-presence invariant
421    /// enforced by `validate()`.
422    fn sun_cartesian_state(
423        &self,
424        instant: Instant,
425    ) -> Result<crate::frame_recombine::CartesianState, CompressionError> {
426        let sun_segment = self.segment_for(&CelestialBody::Sun, instant)?;
427        let span = sun_segment.span_days();
428        let x = if span == 0.0 {
429            0.0
430        } else {
431            (instant.julian_day.days() - sun_segment.start.julian_day.days()) / span
432        };
433        let lon = sun_segment.evaluate_channel(ChannelKind::Longitude, x)?;
434        let lat = sun_segment.evaluate_channel(ChannelKind::Latitude, x)?;
435        let dist = sun_segment.evaluate_channel(ChannelKind::DistanceAu, x)?;
436        let dlon_dt = sun_segment.evaluate_channel_derivative(ChannelKind::Longitude, x)? / span;
437        let dlat_dt = sun_segment.evaluate_channel_derivative(ChannelKind::Latitude, x)? / span;
438        let ddist_dt = sun_segment.evaluate_channel_derivative(ChannelKind::DistanceAu, x)? / span;
439        Ok(crate::frame_recombine::spherical_state_to_cartesian(
440            crate::frame_recombine::SphericalState {
441                lon_rad: lon.to_radians(),
442                lat_rad: lat.to_radians(),
443                dist_au: dist,
444                lon_rate_rad_per_day: dlon_dt.to_radians(),
445                lat_rate_rad_per_day: dlat_dt.to_radians(),
446                dist_rate_au_per_day: ddist_dt,
447            },
448        ))
449    }
450
451    fn require_output_support(&self, output: ArtifactOutput) -> Result<(), CompressionError> {
452        if self.header.profile.supports_output(output) {
453            Ok(())
454        } else {
455            Err(CompressionError::new(
456                CompressionErrorKind::InvalidFormat,
457                format!("artifact profile does not support {output}"),
458            ))
459        }
460    }
461
462    fn encode_payload(&self) -> Result<Vec<u8>, CompressionError> {
463        self.validate()?;
464
465        let mut bytes = Vec::new();
466        crate::codec::write_string(&mut bytes, &self.header.generation_label);
467        crate::codec::write_string(&mut bytes, &self.header.source);
468        crate::codec::write_u8(&mut bytes, encode_endian_policy(self.header.endian_policy));
469        encode_artifact_profile(&mut bytes, &self.header.profile)?;
470        write_u16(&mut bytes, self.bodies.len() as u16);
471        for body in &self.bodies {
472            encode_body(&mut bytes, body)?;
473        }
474        Ok(bytes)
475    }
476}
477
478impl fmt::Display for CompressedArtifact {
479    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
480        f.write_str(&self.summary_line())
481    }
482}
483
484#[cfg(test)]
485mod reframe_lookup_tests {
486    use super::*;
487    use crate::channels::{BodyArtifact, ChannelKind, PolynomialChannel, Segment, StoredFrame};
488    use crate::frame_recombine::heliocentric_from_geocentric;
489    use pleiades_types::{
490        CelestialBody, EclipticCoordinates, Instant, JulianDay, Latitude, Longitude, TimeScale,
491    };
492
493    // Builds a single-segment body whose three channels are constant (degree-0)
494    // equal to the given ecliptic coordinates across the whole span.
495    fn const_body(
496        body: CelestialBody,
497        frame: StoredFrame,
498        start: f64,
499        end: f64,
500        coords: &EclipticCoordinates,
501    ) -> BodyArtifact {
502        let channels = vec![
503            PolynomialChannel::new(ChannelKind::Longitude, 9, vec![coords.longitude.degrees()]),
504            PolynomialChannel::new(ChannelKind::Latitude, 9, vec![coords.latitude.degrees()]),
505            PolynomialChannel::new(
506                ChannelKind::DistanceAu,
507                10,
508                vec![coords.distance_au.unwrap()],
509            ),
510        ];
511        let seg = Segment::new(
512            Instant::new(JulianDay::from_days(start), TimeScale::Tt),
513            Instant::new(JulianDay::from_days(end), TimeScale::Tt),
514            channels,
515        );
516        BodyArtifact::with_frame(body, vec![seg], frame)
517    }
518
519    // Constructs a CompressedArtifact from the given bodies and validates it,
520    // panicking if validation fails.
521    fn build_test_artifact(bodies: Vec<BodyArtifact>) -> CompressedArtifact {
522        try_build_test_artifact(bodies).expect("test artifact should be valid")
523    }
524
525    // Constructs a CompressedArtifact from the given bodies and returns the
526    // validation Result so callers can assert on error cases.
527    fn try_build_test_artifact(
528        bodies: Vec<BodyArtifact>,
529    ) -> Result<CompressedArtifact, CompressionError> {
530        let artifact = CompressedArtifact::new(
531            ArtifactHeader::new("reframe-test", "synthetic test fixture"),
532            bodies,
533        );
534        artifact.validate().map(|_| artifact)
535    }
536
537    #[test]
538    fn heliocentric_body_reconstructs_geocentric() {
539        let sun_geo = EclipticCoordinates::new(
540            Longitude::from_degrees(95.0),
541            Latitude::from_degrees(0.0),
542            Some(1.0),
543        );
544        let jupiter_geo = EclipticCoordinates::new(
545            Longitude::from_degrees(200.0),
546            Latitude::from_degrees(1.2),
547            Some(5.4),
548        );
549        let jupiter_helio = heliocentric_from_geocentric(&jupiter_geo, &sun_geo).unwrap();
550
551        let artifact = build_test_artifact(vec![
552            const_body(
553                CelestialBody::Sun,
554                StoredFrame::Geocentric,
555                0.0,
556                100.0,
557                &sun_geo,
558            ),
559            const_body(
560                CelestialBody::Jupiter,
561                StoredFrame::Heliocentric,
562                0.0,
563                100.0,
564                &jupiter_helio,
565            ),
566        ]);
567
568        let at = Instant::new(JulianDay::from_days(50.0), TimeScale::Tt);
569        let out = artifact
570            .lookup_ecliptic(&CelestialBody::Jupiter, at)
571            .unwrap();
572        assert!((out.longitude.degrees() - 200.0).abs() < 1e-6);
573        assert!((out.latitude.degrees() - 1.2).abs() < 1e-6);
574        assert!((out.distance_au.unwrap() - 5.4).abs() < 1e-6);
575    }
576
577    #[test]
578    fn heliocentric_body_without_sun_fails_validation() {
579        let jupiter_helio = EclipticCoordinates::new(
580            Longitude::from_degrees(120.0),
581            Latitude::from_degrees(0.5),
582            Some(5.0),
583        );
584        let result = try_build_test_artifact(vec![const_body(
585            CelestialBody::Jupiter,
586            StoredFrame::Heliocentric,
587            0.0,
588            100.0,
589            &jupiter_helio,
590        )]);
591        assert!(
592            result.is_err(),
593            "heliocentric body without a Sun must fail validation"
594        );
595    }
596
597    #[test]
598    fn heliocentric_body_with_heliocentric_sun_fails_validation() {
599        // A Sun stored as Heliocentric would cause infinite recursion at lookup
600        // (lookup_ecliptic(Sun) → geocentric_from_heliocentric → lookup_ecliptic(Sun) → …).
601        // validate() must reject this configuration fail-closed.
602        let sun_coords = EclipticCoordinates::new(
603            Longitude::from_degrees(95.0),
604            Latitude::from_degrees(0.0),
605            Some(1.0),
606        );
607        let jupiter_helio = EclipticCoordinates::new(
608            Longitude::from_degrees(120.0),
609            Latitude::from_degrees(0.5),
610            Some(5.0),
611        );
612        let result = try_build_test_artifact(vec![
613            const_body(
614                CelestialBody::Sun,
615                StoredFrame::Heliocentric,
616                0.0,
617                100.0,
618                &sun_coords,
619            ),
620            const_body(
621                CelestialBody::Jupiter,
622                StoredFrame::Heliocentric,
623                0.0,
624                100.0,
625                &jupiter_helio,
626            ),
627        ]);
628        assert!(
629            result.is_err(),
630            "artifact with heliocentric Jupiter and heliocentric Sun must fail validation"
631        );
632    }
633}
634
635#[cfg(test)]
636mod motion_lookup_tests {
637    use super::*;
638    use crate::channels::{BodyArtifact, ChannelKind, PolynomialChannel, Segment, StoredFrame};
639    use crate::format::{ArtifactProfile, SpeedPolicy};
640    use pleiades_types::{CelestialBody, Instant, JulianDay, TimeScale};
641
642    /// Creates a header whose profile advertises Motion as a derived output
643    /// via FittedDerivative speed policy.
644    fn motion_header(label: &str) -> ArtifactHeader {
645        ArtifactHeader::with_profile(
646            label,
647            "motion test fixture",
648            ArtifactProfile::new(
649                vec![
650                    ChannelKind::Longitude,
651                    ChannelKind::Latitude,
652                    ChannelKind::DistanceAu,
653                ],
654                vec![
655                    ArtifactOutput::EclipticCoordinates,
656                    ArtifactOutput::EquatorialCoordinates,
657                ],
658                vec![
659                    ArtifactOutput::ApparentCorrections,
660                    ArtifactOutput::TopocentricCoordinates,
661                    ArtifactOutput::SiderealCoordinates,
662                ],
663                SpeedPolicy::FittedDerivative,
664            ),
665        )
666    }
667
668    #[test]
669    fn lookup_motion_for_geocentric_body_is_direct_derivative() {
670        // Sun segment: longitude linear from 100 to 102 over a 10-day span (x ∈ [0,1]).
671        // dλ/dx = 2 deg; dλ/dt = 2 / 10 days = 0.2 deg/day.
672        // latitude and distance are constant, so their rates are 0.
673        let start = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt);
674        let end = Instant::new(JulianDay::from_days(2_451_555.0), TimeScale::Tt);
675        let segment = Segment::new(
676            start,
677            end,
678            vec![
679                PolynomialChannel::linear(ChannelKind::Longitude, 9, 100.0, 102.0),
680                PolynomialChannel::linear(ChannelKind::Latitude, 9, 5.0, 5.0),
681                PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
682            ],
683        );
684        let artifact = CompressedArtifact::new(
685            motion_header("geocentric-derivative-test"),
686            vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
687        );
688        let at = Instant::new(JulianDay::from_days(2_451_550.0), TimeScale::Tt);
689        let m = artifact.lookup_motion(&CelestialBody::Sun, at).unwrap();
690        assert!(
691            (m.longitude_deg_per_day.unwrap() - 0.2).abs() < 1e-9,
692            "longitude rate should be 0.2 deg/day, got {:?}",
693            m.longitude_deg_per_day
694        );
695        assert!(
696            m.latitude_deg_per_day.unwrap().abs() < 1e-9,
697            "latitude rate should be 0, got {:?}",
698            m.latitude_deg_per_day
699        );
700        assert!(
701            m.distance_au_per_day.unwrap().abs() < 1e-9,
702            "distance rate should be 0, got {:?}",
703            m.distance_au_per_day
704        );
705    }
706
707    #[test]
708    fn lookup_motion_heliocentric_body_returns_finite_geocentric_rates() {
709        // Two-body artifact: Sun geocentric with known motion, Jupiter heliocentric with known motion.
710        // We only assert that the returned motion components are all Some(_) and finite —
711        // correctness of the vector recombination is covered by the velocity round-trip test
712        // in frame_recombine.
713        let t0 = 2_451_545.0_f64;
714        let t1 = t0 + 100.0;
715        let mid = (t0 + t1) / 2.0;
716
717        let make_seg = |body_lon: f64, body_lat: f64, body_dist: f64| {
718            Segment::new(
719                Instant::new(JulianDay::from_days(t0), TimeScale::Tt),
720                Instant::new(JulianDay::from_days(t1), TimeScale::Tt),
721                vec![
722                    // linear longitude: body_lon at x=0, body_lon+1 at x=1
723                    PolynomialChannel::linear(ChannelKind::Longitude, 9, body_lon, body_lon + 1.0),
724                    PolynomialChannel::linear(ChannelKind::Latitude, 9, body_lat, body_lat),
725                    PolynomialChannel::linear(ChannelKind::DistanceAu, 10, body_dist, body_dist),
726                ],
727            )
728        };
729
730        // Sun geocentric
731        let sun_seg = make_seg(95.0, 0.0, 1.0);
732        // Jupiter heliocentric (approximate heliocentric position)
733        let jup_seg = make_seg(120.0, 0.5, 5.2);
734
735        let artifact = CompressedArtifact::new(
736            motion_header("heliocentric-motion-smoke-test"),
737            vec![
738                BodyArtifact::new(CelestialBody::Sun, vec![sun_seg]),
739                BodyArtifact::with_frame(
740                    CelestialBody::Jupiter,
741                    vec![jup_seg],
742                    StoredFrame::Heliocentric,
743                ),
744            ],
745        );
746
747        let at = Instant::new(JulianDay::from_days(mid), TimeScale::Tt);
748        let m = artifact.lookup_motion(&CelestialBody::Jupiter, at).unwrap();
749        assert!(
750            m.longitude_deg_per_day.is_some(),
751            "longitude_deg_per_day must be Some"
752        );
753        assert!(
754            m.latitude_deg_per_day.is_some(),
755            "latitude_deg_per_day must be Some"
756        );
757        assert!(
758            m.distance_au_per_day.is_some(),
759            "distance_au_per_day must be Some"
760        );
761        assert!(
762            m.longitude_deg_per_day.unwrap().is_finite(),
763            "longitude_deg_per_day must be finite"
764        );
765        assert!(
766            m.latitude_deg_per_day.unwrap().is_finite(),
767            "latitude_deg_per_day must be finite"
768        );
769        assert!(
770            m.distance_au_per_day.unwrap().is_finite(),
771            "distance_au_per_day must be finite"
772        );
773    }
774}