1use std::collections::HashMap;
9
10use pleiades_backend::{Angle, CelestialBody, CustomBodyId};
11use pleiades_compression::{cartesian_state_to_spherical, CartesianState, CompressedArtifact};
12use pleiades_jpl::{production_holdout_corpus, reference_snapshot, SnapshotEntry};
13
14use crate::regenerate::{build_packaged_artifact, coordinates, normalize_lookup_instant};
15use crate::AU_IN_KM;
16
17#[derive(Clone, Debug)]
19pub struct BodyChannelError {
20 pub body: CelestialBody,
22 pub comparison_count: usize,
26 pub max_longitude_arcsec: f64,
28 pub rms_longitude_arcsec: f64,
30 pub max_latitude_arcsec: f64,
32 pub rms_latitude_arcsec: f64,
34 pub max_distance_km: f64,
36 pub rms_distance_km: f64,
38 pub max_lon_speed_arcsec_per_day: f64,
41 pub max_lat_speed_arcsec_per_day: f64,
44 pub max_radial_speed_au_per_day: f64,
47}
48
49impl BodyChannelError {
50 fn label(&self) -> String {
51 format!("{:?}", self.body)
52 }
53
54 pub fn summary_line(&self) -> String {
56 format!(
57 "{}: n={} max_lon={:.4} arcsec rms_lon={:.4} arcsec max_lat={:.4} arcsec rms_lat={:.4} arcsec max_dist={:.3} km rms_dist={:.3} km max_lon_speed={:.4} arcsec/day max_lat_speed={:.4} arcsec/day max_radial_speed={:.6} AU/day",
58 self.label(),
59 self.comparison_count,
60 self.max_longitude_arcsec,
61 self.rms_longitude_arcsec,
62 self.max_latitude_arcsec,
63 self.rms_latitude_arcsec,
64 self.max_distance_km,
65 self.rms_distance_km,
66 self.max_lon_speed_arcsec_per_day,
67 self.max_lat_speed_arcsec_per_day,
68 self.max_radial_speed_au_per_day,
69 )
70 }
71}
72
73struct BodyAccumulator {
74 body: CelestialBody,
75 count: usize,
76 max_lon_arcsec: f64,
77 sum_sq_lon_arcsec: f64,
78 max_lat_arcsec: f64,
79 sum_sq_lat_arcsec: f64,
80 max_dist_km: f64,
81 sum_sq_dist_km: f64,
82 max_lon_speed_arcsec_per_day: f64,
83 max_lat_speed_arcsec_per_day: f64,
84 max_radial_speed_au_per_day: f64,
85}
86
87impl BodyAccumulator {
88 fn new(body: CelestialBody) -> Self {
89 Self {
90 body,
91 count: 0,
92 max_lon_arcsec: 0.0,
93 sum_sq_lon_arcsec: 0.0,
94 max_lat_arcsec: 0.0,
95 sum_sq_lat_arcsec: 0.0,
96 max_dist_km: 0.0,
97 sum_sq_dist_km: 0.0,
98 max_lon_speed_arcsec_per_day: 0.0,
99 max_lat_speed_arcsec_per_day: 0.0,
100 max_radial_speed_au_per_day: 0.0,
101 }
102 }
103
104 fn accumulate(&mut self, lon_arcsec: f64, lat_arcsec: f64, dist_km: Option<f64>) {
105 self.count += 1;
106 self.max_lon_arcsec = self.max_lon_arcsec.max(lon_arcsec);
107 self.sum_sq_lon_arcsec += lon_arcsec * lon_arcsec;
108 self.max_lat_arcsec = self.max_lat_arcsec.max(lat_arcsec);
109 self.sum_sq_lat_arcsec += lat_arcsec * lat_arcsec;
110 if let Some(d) = dist_km {
111 self.max_dist_km = self.max_dist_km.max(d);
112 self.sum_sq_dist_km += d * d;
113 }
114 }
115
116 fn accumulate_speed(
117 &mut self,
118 lon_speed_arcsec_per_day: f64,
119 lat_speed_arcsec_per_day: f64,
120 radial_speed_au_per_day: f64,
121 ) {
122 self.max_lon_speed_arcsec_per_day = self
123 .max_lon_speed_arcsec_per_day
124 .max(lon_speed_arcsec_per_day);
125 self.max_lat_speed_arcsec_per_day = self
126 .max_lat_speed_arcsec_per_day
127 .max(lat_speed_arcsec_per_day);
128 self.max_radial_speed_au_per_day = self
129 .max_radial_speed_au_per_day
130 .max(radial_speed_au_per_day);
131 }
132
133 fn finish(self) -> BodyChannelError {
134 let n = self.count as f64;
135 let rms = |sum_sq: f64| if n > 0.0 { (sum_sq / n).sqrt() } else { 0.0 };
136 BodyChannelError {
137 body: self.body,
138 comparison_count: self.count,
139 max_longitude_arcsec: self.max_lon_arcsec,
140 rms_longitude_arcsec: rms(self.sum_sq_lon_arcsec),
141 max_latitude_arcsec: self.max_lat_arcsec,
142 rms_latitude_arcsec: rms(self.sum_sq_lat_arcsec),
143 max_distance_km: self.max_dist_km,
144 rms_distance_km: rms(self.sum_sq_dist_km),
145 max_lon_speed_arcsec_per_day: self.max_lon_speed_arcsec_per_day,
146 max_lat_speed_arcsec_per_day: self.max_lat_speed_arcsec_per_day,
147 max_radial_speed_au_per_day: self.max_radial_speed_au_per_day,
148 }
149 }
150}
151
152pub fn accuracy_baseline_against(
169 holdout: &[SnapshotEntry],
170 artifact: &CompressedArtifact,
171) -> Vec<BodyChannelError> {
172 let mut accumulators: HashMap<String, BodyAccumulator> = HashMap::new();
173
174 for entry in holdout {
175 let key = format!("{:?}", entry.body);
176 let acc = accumulators
177 .entry(key)
178 .or_insert_with(|| BodyAccumulator::new(entry.body.clone()));
179
180 let lookup_instant = normalize_lookup_instant(entry.epoch);
183
184 let artifact_result = artifact.lookup_ecliptic(&entry.body, lookup_instant);
185 let artifact_coords = match artifact_result {
186 Ok(coords) => coords,
187 Err(_) => continue, };
189
190 let holdout_coords = coordinates(entry);
191
192 let lon_diff_deg = Angle::from_degrees(
194 artifact_coords.longitude.degrees() - holdout_coords.longitude.degrees(),
195 )
196 .normalized_signed()
197 .degrees();
198 let lon_arcsec = lon_diff_deg.abs() * 3600.0;
199
200 let lat_arcsec =
202 (artifact_coords.latitude.degrees() - holdout_coords.latitude.degrees()).abs() * 3600.0;
203
204 let dist_km = match (artifact_coords.distance_au, holdout_coords.distance_au) {
208 (Some(a), Some(h)) => Some((a - h).abs() * AU_IN_KM),
209 _ => None,
210 };
211
212 acc.accumulate(lon_arcsec, lat_arcsec, dist_km);
213
214 if let (Some(vx), Some(vy), Some(vz)) = (entry.vx_km_s, entry.vy_km_s, entry.vz_km_s) {
216 let pos_au = [
218 entry.x_km / AU_IN_KM,
219 entry.y_km / AU_IN_KM,
220 entry.z_km / AU_IN_KM,
221 ];
222 let vel_au_per_day = [
223 vx * 86400.0 / AU_IN_KM,
224 vy * 86400.0 / AU_IN_KM,
225 vz * 86400.0 / AU_IN_KM,
226 ];
227 let truth_spherical = cartesian_state_to_spherical(CartesianState {
228 pos_au,
229 vel_au_per_day,
230 });
231
232 let truth_lon_deg_per_day = truth_spherical.lon_rate_rad_per_day.to_degrees();
234 let truth_lat_deg_per_day = truth_spherical.lat_rate_rad_per_day.to_degrees();
235 let truth_radial_au_per_day = truth_spherical.dist_rate_au_per_day;
236
237 if let Ok(motion) = artifact.lookup_motion(&entry.body, lookup_instant) {
238 let art_lon = motion.longitude_deg_per_day.unwrap_or(0.0);
239 let art_lat = motion.latitude_deg_per_day.unwrap_or(0.0);
240 let art_radial = motion.distance_au_per_day.unwrap_or(0.0);
241
242 let lon_speed_arcsec = (art_lon - truth_lon_deg_per_day).abs() * 3600.0;
243 let lat_speed_arcsec = (art_lat - truth_lat_deg_per_day).abs() * 3600.0;
244 let radial_speed_au = (art_radial - truth_radial_au_per_day).abs();
245
246 acc.accumulate_speed(lon_speed_arcsec, lat_speed_arcsec, radial_speed_au);
247 }
248 }
249 }
250
251 let mut results: Vec<BodyChannelError> = accumulators
255 .into_values()
256 .filter(|acc| acc.count > 0)
257 .map(|acc| acc.finish())
258 .collect();
259 results.sort_by_key(|a| a.label());
260 results
261}
262
263pub fn packaged_artifact_accuracy_baseline() -> Vec<BodyChannelError> {
268 let holdout = production_holdout_corpus();
269 let artifact = build_packaged_artifact();
270 accuracy_baseline_against(holdout, &artifact)
271}
272
273pub fn packaged_artifact_accuracy_baseline_summary_for_report() -> String {
279 let errors = packaged_artifact_accuracy_baseline();
280 if errors.is_empty() {
281 return "Packaged-artifact accuracy baseline: no hold-out rows matched".to_string();
282 }
283 let lines: Vec<String> = errors.iter().map(|e| e.summary_line()).collect();
284 format!(
285 "Packaged-artifact accuracy baseline ({} bodies):\n{}",
286 errors.len(),
287 lines.join("\n")
288 )
289}
290
291pub fn eros_self_consistency_max_longitude_arcsec() -> f64 {
303 let eros_body = CelestialBody::Custom(CustomBodyId::new("asteroid", "433-Eros"));
304 let artifact = build_packaged_artifact();
305 let mut max_lon_arcsec: f64 = 0.0;
306
307 for entry in reference_snapshot() {
308 if entry.body != eros_body {
309 continue;
310 }
311 let lookup_instant = normalize_lookup_instant(entry.epoch);
312 let artifact_coords = match artifact.lookup_ecliptic(&eros_body, lookup_instant) {
313 Ok(c) => c,
314 Err(_) => continue,
315 };
316 let snapshot_coords = coordinates(entry);
317
318 let lon_diff_deg = Angle::from_degrees(
321 artifact_coords.longitude.degrees() - snapshot_coords.longitude.degrees(),
322 )
323 .normalized_signed()
324 .degrees();
325 let lon_arcsec = lon_diff_deg.abs() * 3600.0;
326 max_lon_arcsec = max_lon_arcsec.max(lon_arcsec);
327 }
328
329 max_lon_arcsec
330}
331
332#[cfg(test)]
333mod tests {
334 use pleiades_backend::{Instant, JulianDay, TimeScale};
335 use pleiades_compression::{
336 ArtifactHeader, BodyArtifact, ChannelKind, CompressedArtifact, PolynomialChannel, Segment,
337 };
338
339 use super::*;
340
341 fn synthetic_holdout_with_velocity() -> Vec<SnapshotEntry> {
351 let au = AU_IN_KM;
352 let vy_km_s = 0.01 * au / 86400.0;
354 vec![SnapshotEntry {
355 body: CelestialBody::Sun,
356 epoch: Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
357 x_km: au,
358 y_km: 0.0,
359 z_km: 0.0,
360 vx_km_s: Some(0.0),
361 vy_km_s: Some(vy_km_s),
362 vz_km_s: Some(0.0),
363 }]
364 }
365
366 fn synthetic_artifact_linear() -> CompressedArtifact {
372 let jd0 = 2_451_545.0_f64;
373 let span_days = 10.0_f64;
374 let start = Instant::new(JulianDay::from_days(jd0), TimeScale::Tt);
375 let end = Instant::new(JulianDay::from_days(jd0 + span_days), TimeScale::Tt);
376
377 let lon_rate_deg_per_day = 0.01_f64.to_degrees();
380 let lon_end = lon_rate_deg_per_day * span_days;
381
382 let segment = Segment::new(
383 start,
384 end,
385 vec![
386 PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, lon_end),
387 PolynomialChannel::linear(ChannelKind::Latitude, 9, 0.0, 0.0),
388 PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
389 ],
390 );
391 CompressedArtifact::new(
392 ArtifactHeader::new(
393 "synthetic-linear-test",
394 "synthetic linear velocity test source",
395 ),
396 vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
397 )
398 }
399
400 fn synthetic_holdout() -> Vec<SnapshotEntry> {
401 let au = AU_IN_KM;
404 vec![SnapshotEntry {
405 body: CelestialBody::Sun,
406 epoch: Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
407 x_km: au,
408 y_km: 0.0,
409 z_km: 0.0,
410 vx_km_s: None,
411 vy_km_s: None,
412 vz_km_s: None,
413 }]
414 }
415
416 fn synthetic_artifact() -> CompressedArtifact {
417 let jd = JulianDay::from_days(2_451_545.0);
419 let instant = Instant::new(jd, TimeScale::Tt);
420 let segment = Segment::new(
422 instant,
423 instant,
424 vec![
425 PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 0.0),
426 PolynomialChannel::linear(ChannelKind::Latitude, 9, 0.0, 0.0),
427 PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
428 ],
429 );
430 CompressedArtifact::new(
431 ArtifactHeader::new("synthetic-test", "synthetic test source"),
432 vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
433 )
434 }
435
436 #[test]
437 fn baseline_reports_speed_error_fields() {
438 let errors = accuracy_baseline_against(
439 &synthetic_holdout_with_velocity(),
440 &synthetic_artifact_linear(),
441 );
442 assert_eq!(
443 errors.len(),
444 1,
445 "expected exactly 1 body in synthetic speed baseline"
446 );
447 let sun = &errors[0];
448 assert_eq!(sun.comparison_count, 1, "Sun should have 1 comparison");
449 assert!(
451 sun.max_lon_speed_arcsec_per_day < 1e-3,
452 "Sun max longitude speed error too large: {} arcsec/day",
453 sun.max_lon_speed_arcsec_per_day
454 );
455 assert!(
456 sun.max_lat_speed_arcsec_per_day < 1e-3,
457 "Sun max latitude speed error too large: {} arcsec/day",
458 sun.max_lat_speed_arcsec_per_day
459 );
460 assert!(
461 sun.max_radial_speed_au_per_day < 1e-6,
462 "Sun max radial speed error too large: {} AU/day",
463 sun.max_radial_speed_au_per_day
464 );
465 }
466
467 #[test]
468 fn baseline_reports_zero_error_for_an_artifact_that_matches_holdout() {
469 let errors = accuracy_baseline_against(&synthetic_holdout(), &synthetic_artifact());
470 assert_eq!(
472 errors.len(),
473 1,
474 "expected exactly 1 body in synthetic baseline"
475 );
476 let sun = &errors[0];
477 assert_eq!(sun.comparison_count, 1, "Sun should have 1 comparison");
478 assert!(
479 sun.max_longitude_arcsec < 1e-3,
480 "Sun max longitude error too large: {} arcsec",
481 sun.max_longitude_arcsec
482 );
483 assert!(
484 sun.max_latitude_arcsec < 1e-3,
485 "Sun max latitude error too large: {} arcsec",
486 sun.max_latitude_arcsec
487 );
488 assert!(
489 sun.max_distance_km < 1.0,
490 "Sun max distance error too large: {} km",
491 sun.max_distance_km
492 );
493 }
494
495 #[test]
496 fn baseline_excludes_body_when_all_lookups_fail() {
497 let holdout = synthetic_holdout(); let empty_artifact = CompressedArtifact::new(
502 ArtifactHeader::new("empty-test", "empty test source"),
503 vec![],
504 );
505 let errors = accuracy_baseline_against(&holdout, &empty_artifact);
506 assert!(
507 errors.is_empty(),
508 "vacuity guard must exclude bodies with zero successful comparisons; got {} entries",
509 errors.len()
510 );
511 }
512
513 #[test]
515 fn packaged_artifact_baseline_is_non_vacuous() {
516 let errors = packaged_artifact_accuracy_baseline();
521
522 let expected_bodies = [
524 CelestialBody::Sun,
525 CelestialBody::Moon,
526 CelestialBody::Mercury,
527 CelestialBody::Venus,
528 CelestialBody::Mars,
529 CelestialBody::Jupiter,
530 CelestialBody::Saturn,
531 CelestialBody::Uranus,
532 CelestialBody::Neptune,
533 CelestialBody::Pluto,
534 ];
535 assert_eq!(
536 errors.len(),
537 10,
538 "expected 10 base bodies in packaged baseline; got {}: {:?}",
539 errors.len(),
540 errors
541 .iter()
542 .map(|e| format!("{:?}", e.body))
543 .collect::<Vec<_>>()
544 );
545 for body in &expected_bodies {
546 let entry = errors
547 .iter()
548 .find(|e| &e.body == body)
549 .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
550 assert!(
551 entry.comparison_count > 0,
552 "{body:?} must have at least one successful comparison (got 0 — vacuous baseline)"
553 );
554 }
555
556 let sub_arcsec_bodies = [
558 CelestialBody::Sun,
559 CelestialBody::Moon,
560 CelestialBody::Mercury,
561 CelestialBody::Venus,
562 CelestialBody::Mars,
563 ];
564 for body in &sub_arcsec_bodies {
565 let entry = errors.iter().find(|e| &e.body == body).unwrap();
566 assert!(
567 entry.max_longitude_arcsec < 1.0,
568 "{body:?} max longitude error must be <1 arcsec (got {:.6}\")",
569 entry.max_longitude_arcsec
570 );
571 }
572
573 let outer_bodies = [
575 CelestialBody::Jupiter,
576 CelestialBody::Saturn,
577 CelestialBody::Uranus,
578 CelestialBody::Neptune,
579 CelestialBody::Pluto,
580 ];
581 for body in &outer_bodies {
582 let entry = errors.iter().find(|e| &e.body == body).unwrap();
583 assert!(
584 entry.max_longitude_arcsec < 1.0,
585 "{body:?} max longitude error must be <1 arcsec after SP2 heliocentric reframe (got {:.6}\")",
586 entry.max_longitude_arcsec
587 );
588 }
589
590 let uranus = errors
593 .iter()
594 .find(|e| e.body == CelestialBody::Uranus)
595 .expect("Uranus must appear in the packaged baseline");
596 assert!(
597 uranus.max_longitude_arcsec > 0.0001,
598 "Uranus max longitude error must be >0.0001\" (got {:.6}\" — baseline may be vacuous)",
599 uranus.max_longitude_arcsec
600 );
601 assert!(
602 uranus.max_longitude_arcsec < 1.0,
603 "Uranus max longitude error must be <1\" after SP2 heliocentric reframe (got {:.4}\" — reframe may be broken)",
604 uranus.max_longitude_arcsec
605 );
606 }
607
608 #[test]
619 fn outer_planet_longitude_meets_astrology_grade_envelope() {
620 let baseline = crate::accuracy_baseline::packaged_artifact_accuracy_baseline();
621 for body_error in &baseline {
626 let c = crate::thresholds::accuracy_ceiling(&body_error.body).lon_arcsec;
627 assert!(
628 body_error.max_longitude_arcsec <= c,
629 "{:?} longitude {:.3}\" exceeds ceiling {:.1}\"",
630 body_error.body,
631 body_error.max_longitude_arcsec,
632 c
633 );
634 }
635 }
636
637 #[test]
642 fn all_channels_within_published_ceilings_for_major_bodies() {
643 let baseline = packaged_artifact_accuracy_baseline();
644 for e in &baseline {
645 let c = crate::thresholds::accuracy_ceiling(&e.body);
646 assert!(
647 e.max_longitude_arcsec <= c.lon_arcsec,
648 "{:?} lon {:.4}\" exceeds ceiling {:.1}\"",
649 e.body,
650 e.max_longitude_arcsec,
651 c.lon_arcsec
652 );
653 assert!(
654 e.max_latitude_arcsec <= c.lat_arcsec,
655 "{:?} lat {:.4}\" exceeds ceiling {:.1}\"",
656 e.body,
657 e.max_latitude_arcsec,
658 c.lat_arcsec
659 );
660 assert!(
661 e.max_distance_km <= c.dist_km,
662 "{:?} dist {:.3} km exceeds ceiling {:.0} km",
663 e.body,
664 e.max_distance_km,
665 c.dist_km
666 );
667 assert!(
668 e.max_lon_speed_arcsec_per_day <= c.lon_speed_arcsec_per_day,
669 "{:?} lon speed {:.4} arcsec/day exceeds ceiling {:.2} arcsec/day",
670 e.body,
671 e.max_lon_speed_arcsec_per_day,
672 c.lon_speed_arcsec_per_day
673 );
674 assert!(
675 e.max_lat_speed_arcsec_per_day <= c.lat_speed_arcsec_per_day,
676 "{:?} lat speed {:.4} arcsec/day exceeds ceiling {:.2} arcsec/day",
677 e.body,
678 e.max_lat_speed_arcsec_per_day,
679 c.lat_speed_arcsec_per_day
680 );
681 assert!(
682 e.max_radial_speed_au_per_day <= c.radial_speed_au_per_day,
683 "{:?} radial speed {:.6} AU/day exceeds ceiling {:.2e} AU/day",
684 e.body,
685 e.max_radial_speed_au_per_day,
686 c.radial_speed_au_per_day
687 );
688 }
689 }
690
691 #[test]
694 fn encoded_artifact_within_size_budget() {
695 let bytes_len = crate::data::packaged_artifact_bytes().len();
696 assert!(
697 bytes_len <= crate::thresholds::PACKAGED_BUDGETS.max_encoded_bytes,
698 "encoded artifact {} bytes exceeds budget {} bytes",
699 bytes_len,
700 crate::thresholds::PACKAGED_BUDGETS.max_encoded_bytes
701 );
702 }
703
704 #[test]
705 #[ignore = "maintainer helper: prints the accuracy baseline summary to regenerate the golden"]
706 fn print_packaged_artifact_baseline_summary() {
707 eprintln!(
708 "{}",
709 packaged_artifact_accuracy_baseline_summary_for_report()
710 );
711 }
712
713 #[test]
723 fn packaged_artifact_baseline_summary_matches_committed_golden() {
724 let report = packaged_artifact_accuracy_baseline_summary_for_report();
725
726 assert!(
728 report.contains("Packaged-artifact accuracy baseline (10 bodies)"),
729 "baseline report header drift: {report}"
730 );
731
732 assert!(
735 report.contains("Sun: n=50 max_lon=0.000"),
736 "Sun max_lon bucket drift (expected ~0.0009\"): {report}"
737 );
738 assert!(
739 report.contains("Moon: n=50 max_lon=0.000"),
740 "Moon max_lon bucket drift (expected ~0.0001\"): {report}"
741 );
742 assert!(
743 report.contains("Mercury: n=50 max_lon=0.000"),
744 "Mercury max_lon bucket drift (expected ~0.0009\"): {report}"
745 );
746 assert!(
747 report.contains("Venus: n=50 max_lon=0.001"),
748 "Venus max_lon bucket drift (expected ~0.0011\"): {report}"
749 );
750 assert!(
751 report.contains("Mars: n=50 max_lon=0.000"),
752 "Mars max_lon bucket drift (expected ~0.0005\"): {report}"
753 );
754 assert!(
756 report.contains("Jupiter: n=50 max_lon=0.000"),
757 "Jupiter max_lon bucket drift (expected ~0.0004\"): {report}"
758 );
759 assert!(
760 report.contains("Saturn: n=50 max_lon=0.000"),
761 "Saturn max_lon bucket drift (expected ~0.0009\"): {report}"
762 );
763 assert!(
764 report.contains("Uranus: n=50 max_lon=0.003"),
765 "Uranus max_lon bucket drift (expected ~0.0036\"): {report}"
766 );
767 assert!(
768 report.contains("Neptune: n=50 max_lon=0.002"),
769 "Neptune max_lon bucket drift (expected ~0.0020\"): {report}"
770 );
771 assert!(
772 report.contains("Pluto: n=50 max_lon=0.001"),
773 "Pluto max_lon bucket drift (expected ~0.0018\"): {report}"
774 );
775
776 assert!(
781 report.contains("Moon: n=50") && report.contains("max_lon_speed=0.023"),
782 "Moon max_lon_speed bucket drift (expected ~0.0230 arcsec/day): {report}"
783 );
784 assert!(
785 report.contains("Moon: n=50") && report.contains("max_lat_speed=0.025"),
786 "Moon max_lat_speed bucket drift (expected ~0.0253 arcsec/day): {report}"
787 );
788 assert!(
789 report.contains("Sun: n=50") && report.contains("max_lon_speed=0.001"),
790 "Sun max_lon_speed bucket drift (expected ~0.0013 arcsec/day): {report}"
791 );
792 assert!(
793 report.contains("Mercury: n=50") && report.contains("max_lon_speed=0.001"),
794 "Mercury max_lon_speed bucket drift (expected ~0.0013 arcsec/day): {report}"
795 );
796 assert!(
797 report.contains("Venus: n=50") && report.contains("max_lon_speed=0.001"),
798 "Venus max_lon_speed bucket drift (expected ~0.0014 arcsec/day): {report}"
799 );
800 assert!(
801 report.contains("Mars: n=50") && report.contains("max_lon_speed=0.001"),
802 "Mars max_lon_speed bucket drift (expected ~0.0011 arcsec/day): {report}"
803 );
804 assert!(
806 report.contains("Jupiter: n=50") && report.contains("max_lon_speed=0.000"),
807 "Jupiter max_lon_speed bucket drift (expected ~0.0002 arcsec/day): {report}"
808 );
809 assert!(
810 report.contains("Saturn: n=50") && report.contains("max_lon_speed=0.000"),
811 "Saturn max_lon_speed bucket drift (expected ~0.0002 arcsec/day): {report}"
812 );
813 assert!(
814 report.contains("Uranus: n=50") && report.contains("max_lon_speed=0.000"),
815 "Uranus max_lon_speed bucket drift (expected ~0.0007 arcsec/day): {report}"
816 );
817 assert!(
818 report.contains("Neptune: n=50") && report.contains("max_lon_speed=0.000"),
819 "Neptune max_lon_speed bucket drift (expected ~0.0002 arcsec/day): {report}"
820 );
821 assert!(
822 report.contains("Pluto: n=50") && report.contains("max_lon_speed=0.000"),
823 "Pluto max_lon_speed bucket drift (expected ~0.0005 arcsec/day): {report}"
824 );
825 for body_name in &[
827 "Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune",
828 "Pluto",
829 ] {
830 assert!(
831 report.contains(&format!("{body_name}: n=50"))
832 && report.contains("max_radial_speed=0.000000"),
833 "{body_name} max_radial_speed not anchored at 0.000000 AU/day: {report}"
834 );
835 }
836 }
837
838 #[test]
854 fn speed_channels_are_non_vacuous_for_major_bodies() {
855 let errors = packaged_artifact_accuracy_baseline();
856
857 let inner_bodies = [
859 CelestialBody::Sun,
860 CelestialBody::Mercury,
861 CelestialBody::Venus,
862 CelestialBody::Mars,
863 ];
864 for body in &inner_bodies {
865 let e = errors
866 .iter()
867 .find(|e| &e.body == body)
868 .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
869 assert!(
870 e.max_lon_speed_arcsec_per_day > 0.0001,
871 "{body:?} max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
872 e.max_lon_speed_arcsec_per_day
873 );
874 }
875
876 let moon = errors
878 .iter()
879 .find(|e| e.body == CelestialBody::Moon)
880 .expect("Moon must appear in the packaged baseline");
881 assert!(
882 moon.max_lon_speed_arcsec_per_day > 0.005,
883 "Moon max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
884 moon.max_lon_speed_arcsec_per_day
885 );
886
887 let outer_bodies = [
889 CelestialBody::Jupiter,
890 CelestialBody::Saturn,
891 CelestialBody::Uranus,
892 CelestialBody::Neptune,
893 CelestialBody::Pluto,
894 ];
895 for body in &outer_bodies {
896 let e = errors
897 .iter()
898 .find(|e| &e.body == body)
899 .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
900 assert!(
901 e.max_lon_speed_arcsec_per_day > 0.0001,
902 "{body:?} max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
903 e.max_lon_speed_arcsec_per_day
904 );
905 }
906 }
907
908 #[test]
909 #[ignore = "maintainer helper: prints the Eros self-consistency max longitude error"]
910 fn print_eros_self_consistency_max_longitude_arcsec() {
911 let v = crate::accuracy_baseline::eros_self_consistency_max_longitude_arcsec();
912 eprintln!("EROS_SELF_CONSISTENCY_MAX_LON_ARCSEC = {v:.6}\"");
913 }
914
915 #[test]
922 fn eros_round_trips_against_its_reference_snapshot_within_documented_target() {
923 let eros_body = pleiades_backend::CelestialBody::Custom(
924 pleiades_backend::CustomBodyId::new("asteroid", "433-Eros"),
925 );
926 let eros_row_count = pleiades_jpl::reference_snapshot()
931 .iter()
932 .filter(|e| e.body == eros_body)
933 .count();
934 assert!(
935 eros_row_count > 0,
936 "reference snapshot contains no Eros rows — self-consistency check would be vacuous (iterate zero rows → max=0.0 → trivially passes)"
937 );
938
939 let ceiling = crate::thresholds::accuracy_ceiling(&eros_body);
940 let max_lon_arcsec = crate::accuracy_baseline::eros_self_consistency_max_longitude_arcsec();
941 assert!(
942 max_lon_arcsec <= ceiling.lon_arcsec,
943 "Eros self-consistency {max_lon_arcsec:.4}\" > {:.1}\" (artifact does not reproduce the reference snapshot it was fit from within the Asteroid-class ceiling)",
944 ceiling.lon_arcsec
945 );
946 }
947}