1use 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#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[derive(Clone, Debug, PartialEq)]
24pub struct CompressedArtifact {
25 pub header: ArtifactHeader,
27 pub checksum: u64,
29 pub bodies: Vec<BodyArtifact>,
31}
32
33impl CompressedArtifact {
34 pub fn new(header: ArtifactHeader, bodies: Vec<BodyArtifact>) -> Self {
36 Self {
37 header,
38 checksum: 0,
39 bodies,
40 }
41 }
42
43 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 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 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 pub fn residual_body_coverage_summary(&self) -> ArtifactResidualBodyCoverageSummary {
95 ArtifactResidualBodyCoverageSummary::new(self.residual_bodies())
96 }
97
98 pub fn body_artifact(&self, body: &CelestialBody) -> Option<&BodyArtifact> {
100 self.bodies.iter().find(|series| &series.body == body)
101 }
102
103 pub fn segment_count(&self) -> usize {
105 self.bodies.iter().map(|body| body.segments.len()).sum()
106 }
107
108 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 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 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 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 pub fn checksum(&self) -> Result<u64, CompressionError> {
172 Ok(fnv1a64(&self.encode_payload()?))
173 }
174
175 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 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 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 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 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 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 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 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 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 fn build_test_artifact(bodies: Vec<BodyArtifact>) -> CompressedArtifact {
522 try_build_test_artifact(bodies).expect("test artifact should be valid")
523 }
524
525 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 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 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 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 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 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 let sun_seg = make_seg(95.0, 0.0, 1.0);
732 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}