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
273#[allow(dead_code)]
285pub(crate) fn eros_self_consistency_max_longitude_arcsec() -> f64 {
286 let eros_body = CelestialBody::Custom(CustomBodyId::new("asteroid", "433-Eros"));
287 let artifact = build_packaged_artifact();
288 let mut max_lon_arcsec: f64 = 0.0;
289
290 for entry in reference_snapshot() {
291 if entry.body != eros_body {
292 continue;
293 }
294 let lookup_instant = normalize_lookup_instant(entry.epoch);
295 let artifact_coords = match artifact.lookup_ecliptic(&eros_body, lookup_instant) {
296 Ok(c) => c,
297 Err(_) => continue,
298 };
299 let snapshot_coords = coordinates(entry);
300
301 let lon_diff_deg = Angle::from_degrees(
304 artifact_coords.longitude.degrees() - snapshot_coords.longitude.degrees(),
305 )
306 .normalized_signed()
307 .degrees();
308 let lon_arcsec = lon_diff_deg.abs() * 3600.0;
309 max_lon_arcsec = max_lon_arcsec.max(lon_arcsec);
310 }
311
312 max_lon_arcsec
313}
314
315#[cfg(test)]
316mod tests {
317 use pleiades_backend::{Instant, JulianDay, TimeScale};
318 use pleiades_compression::{
319 ArtifactHeader, BodyArtifact, ChannelKind, CompressedArtifact, PolynomialChannel, Segment,
320 };
321
322 use super::*;
323
324 fn synthetic_holdout_with_velocity() -> Vec<SnapshotEntry> {
334 let au = AU_IN_KM;
335 let vy_km_s = 0.01 * au / 86400.0;
337 vec![SnapshotEntry {
338 body: CelestialBody::Sun,
339 epoch: Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
340 x_km: au,
341 y_km: 0.0,
342 z_km: 0.0,
343 vx_km_s: Some(0.0),
344 vy_km_s: Some(vy_km_s),
345 vz_km_s: Some(0.0),
346 }]
347 }
348
349 fn synthetic_artifact_linear() -> CompressedArtifact {
355 let jd0 = 2_451_545.0_f64;
356 let span_days = 10.0_f64;
357 let start = Instant::new(JulianDay::from_days(jd0), TimeScale::Tt);
358 let end = Instant::new(JulianDay::from_days(jd0 + span_days), TimeScale::Tt);
359
360 let lon_rate_deg_per_day = 0.01_f64.to_degrees();
363 let lon_end = lon_rate_deg_per_day * span_days;
364
365 let segment = Segment::new(
366 start,
367 end,
368 vec![
369 PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, lon_end),
370 PolynomialChannel::linear(ChannelKind::Latitude, 9, 0.0, 0.0),
371 PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
372 ],
373 );
374 CompressedArtifact::new(
375 ArtifactHeader::new(
376 "synthetic-linear-test",
377 "synthetic linear velocity test source",
378 ),
379 vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
380 )
381 }
382
383 fn synthetic_holdout() -> Vec<SnapshotEntry> {
384 let au = AU_IN_KM;
387 vec![SnapshotEntry {
388 body: CelestialBody::Sun,
389 epoch: Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
390 x_km: au,
391 y_km: 0.0,
392 z_km: 0.0,
393 vx_km_s: None,
394 vy_km_s: None,
395 vz_km_s: None,
396 }]
397 }
398
399 fn synthetic_artifact() -> CompressedArtifact {
400 let jd = JulianDay::from_days(2_451_545.0);
402 let instant = Instant::new(jd, TimeScale::Tt);
403 let segment = Segment::new(
405 instant,
406 instant,
407 vec![
408 PolynomialChannel::linear(ChannelKind::Longitude, 9, 0.0, 0.0),
409 PolynomialChannel::linear(ChannelKind::Latitude, 9, 0.0, 0.0),
410 PolynomialChannel::linear(ChannelKind::DistanceAu, 10, 1.0, 1.0),
411 ],
412 );
413 CompressedArtifact::new(
414 ArtifactHeader::new("synthetic-test", "synthetic test source"),
415 vec![BodyArtifact::new(CelestialBody::Sun, vec![segment])],
416 )
417 }
418
419 #[test]
420 fn baseline_reports_speed_error_fields() {
421 let errors = accuracy_baseline_against(
422 &synthetic_holdout_with_velocity(),
423 &synthetic_artifact_linear(),
424 );
425 assert_eq!(
426 errors.len(),
427 1,
428 "expected exactly 1 body in synthetic speed baseline"
429 );
430 let sun = &errors[0];
431 assert_eq!(sun.comparison_count, 1, "Sun should have 1 comparison");
432 assert!(
434 sun.max_lon_speed_arcsec_per_day < 1e-3,
435 "Sun max longitude speed error too large: {} arcsec/day",
436 sun.max_lon_speed_arcsec_per_day
437 );
438 assert!(
439 sun.max_lat_speed_arcsec_per_day < 1e-3,
440 "Sun max latitude speed error too large: {} arcsec/day",
441 sun.max_lat_speed_arcsec_per_day
442 );
443 assert!(
444 sun.max_radial_speed_au_per_day < 1e-6,
445 "Sun max radial speed error too large: {} AU/day",
446 sun.max_radial_speed_au_per_day
447 );
448 }
449
450 #[test]
451 fn baseline_reports_zero_error_for_an_artifact_that_matches_holdout() {
452 let errors = accuracy_baseline_against(&synthetic_holdout(), &synthetic_artifact());
453 assert_eq!(
455 errors.len(),
456 1,
457 "expected exactly 1 body in synthetic baseline"
458 );
459 let sun = &errors[0];
460 assert_eq!(sun.comparison_count, 1, "Sun should have 1 comparison");
461 assert!(
462 sun.max_longitude_arcsec < 1e-3,
463 "Sun max longitude error too large: {} arcsec",
464 sun.max_longitude_arcsec
465 );
466 assert!(
467 sun.max_latitude_arcsec < 1e-3,
468 "Sun max latitude error too large: {} arcsec",
469 sun.max_latitude_arcsec
470 );
471 assert!(
472 sun.max_distance_km < 1.0,
473 "Sun max distance error too large: {} km",
474 sun.max_distance_km
475 );
476 }
477
478 #[test]
479 fn baseline_excludes_body_when_all_lookups_fail() {
480 let holdout = synthetic_holdout(); let empty_artifact = CompressedArtifact::new(
485 ArtifactHeader::new("empty-test", "empty test source"),
486 vec![],
487 );
488 let errors = accuracy_baseline_against(&holdout, &empty_artifact);
489 assert!(
490 errors.is_empty(),
491 "vacuity guard must exclude bodies with zero successful comparisons; got {} entries",
492 errors.len()
493 );
494 }
495
496 #[test]
498 fn packaged_artifact_baseline_is_non_vacuous() {
499 let errors = packaged_artifact_accuracy_baseline();
504
505 let expected_bodies = [
507 CelestialBody::Sun,
508 CelestialBody::Moon,
509 CelestialBody::Mercury,
510 CelestialBody::Venus,
511 CelestialBody::Mars,
512 CelestialBody::Jupiter,
513 CelestialBody::Saturn,
514 CelestialBody::Uranus,
515 CelestialBody::Neptune,
516 CelestialBody::Pluto,
517 ];
518 assert_eq!(
519 errors.len(),
520 10,
521 "expected 10 base bodies in packaged baseline; got {}: {:?}",
522 errors.len(),
523 errors
524 .iter()
525 .map(|e| format!("{:?}", e.body))
526 .collect::<Vec<_>>()
527 );
528 for body in &expected_bodies {
529 let entry = errors
530 .iter()
531 .find(|e| &e.body == body)
532 .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
533 assert!(
534 entry.comparison_count > 0,
535 "{body:?} must have at least one successful comparison (got 0 — vacuous baseline)"
536 );
537 }
538
539 let sub_arcsec_bodies = [
541 CelestialBody::Sun,
542 CelestialBody::Moon,
543 CelestialBody::Mercury,
544 CelestialBody::Venus,
545 CelestialBody::Mars,
546 ];
547 for body in &sub_arcsec_bodies {
548 let entry = errors.iter().find(|e| &e.body == body).unwrap();
549 assert!(
550 entry.max_longitude_arcsec < 1.0,
551 "{body:?} max longitude error must be <1 arcsec (got {:.6}\")",
552 entry.max_longitude_arcsec
553 );
554 }
555
556 let outer_bodies = [
558 CelestialBody::Jupiter,
559 CelestialBody::Saturn,
560 CelestialBody::Uranus,
561 CelestialBody::Neptune,
562 CelestialBody::Pluto,
563 ];
564 for body in &outer_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 after SP2 heliocentric reframe (got {:.6}\")",
569 entry.max_longitude_arcsec
570 );
571 }
572
573 let uranus = errors
576 .iter()
577 .find(|e| e.body == CelestialBody::Uranus)
578 .expect("Uranus must appear in the packaged baseline");
579 assert!(
580 uranus.max_longitude_arcsec > 0.0001,
581 "Uranus max longitude error must be >0.0001\" (got {:.6}\" — baseline may be vacuous)",
582 uranus.max_longitude_arcsec
583 );
584 assert!(
585 uranus.max_longitude_arcsec < 1.0,
586 "Uranus max longitude error must be <1\" after SP2 heliocentric reframe (got {:.4}\" — reframe may be broken)",
587 uranus.max_longitude_arcsec
588 );
589 }
590
591 #[test]
602 fn outer_planet_longitude_meets_astrology_grade_envelope() {
603 let baseline = crate::accuracy_baseline::packaged_artifact_accuracy_baseline();
604 for body_error in &baseline {
609 let c = crate::thresholds::accuracy_ceiling(&body_error.body).lon_arcsec;
610 assert!(
611 body_error.max_longitude_arcsec <= c,
612 "{:?} longitude {:.3}\" exceeds ceiling {:.1}\"",
613 body_error.body,
614 body_error.max_longitude_arcsec,
615 c
616 );
617 }
618 }
619
620 #[test]
625 fn all_channels_within_published_ceilings_for_major_bodies() {
626 let baseline = packaged_artifact_accuracy_baseline();
627 for e in &baseline {
628 let c = crate::thresholds::accuracy_ceiling(&e.body);
629 assert!(
630 e.max_longitude_arcsec <= c.lon_arcsec,
631 "{:?} lon {:.4}\" exceeds ceiling {:.1}\"",
632 e.body,
633 e.max_longitude_arcsec,
634 c.lon_arcsec
635 );
636 assert!(
637 e.max_latitude_arcsec <= c.lat_arcsec,
638 "{:?} lat {:.4}\" exceeds ceiling {:.1}\"",
639 e.body,
640 e.max_latitude_arcsec,
641 c.lat_arcsec
642 );
643 assert!(
644 e.max_distance_km <= c.dist_km,
645 "{:?} dist {:.3} km exceeds ceiling {:.0} km",
646 e.body,
647 e.max_distance_km,
648 c.dist_km
649 );
650 assert!(
651 e.max_lon_speed_arcsec_per_day <= c.lon_speed_arcsec_per_day,
652 "{:?} lon speed {:.4} arcsec/day exceeds ceiling {:.2} arcsec/day",
653 e.body,
654 e.max_lon_speed_arcsec_per_day,
655 c.lon_speed_arcsec_per_day
656 );
657 assert!(
658 e.max_lat_speed_arcsec_per_day <= c.lat_speed_arcsec_per_day,
659 "{:?} lat speed {:.4} arcsec/day exceeds ceiling {:.2} arcsec/day",
660 e.body,
661 e.max_lat_speed_arcsec_per_day,
662 c.lat_speed_arcsec_per_day
663 );
664 assert!(
665 e.max_radial_speed_au_per_day <= c.radial_speed_au_per_day,
666 "{:?} radial speed {:.6} AU/day exceeds ceiling {:.2e} AU/day",
667 e.body,
668 e.max_radial_speed_au_per_day,
669 c.radial_speed_au_per_day
670 );
671 }
672 }
673
674 #[test]
677 fn encoded_artifact_within_size_budget() {
678 let bytes_len = crate::data::packaged_artifact_bytes().len();
679 assert!(
680 bytes_len <= crate::thresholds::PACKAGED_BUDGETS.max_encoded_bytes,
681 "encoded artifact {} bytes exceeds budget {} bytes",
682 bytes_len,
683 crate::thresholds::PACKAGED_BUDGETS.max_encoded_bytes
684 );
685 }
686
687 #[test]
703 fn speed_channels_are_non_vacuous_for_major_bodies() {
704 let errors = packaged_artifact_accuracy_baseline();
705
706 let inner_bodies = [
708 CelestialBody::Sun,
709 CelestialBody::Mercury,
710 CelestialBody::Venus,
711 CelestialBody::Mars,
712 ];
713 for body in &inner_bodies {
714 let e = errors
715 .iter()
716 .find(|e| &e.body == body)
717 .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
718 assert!(
719 e.max_lon_speed_arcsec_per_day > 0.0001,
720 "{body:?} max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
721 e.max_lon_speed_arcsec_per_day
722 );
723 }
724
725 let moon = errors
727 .iter()
728 .find(|e| e.body == CelestialBody::Moon)
729 .expect("Moon must appear in the packaged baseline");
730 assert!(
731 moon.max_lon_speed_arcsec_per_day > 0.005,
732 "Moon max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
733 moon.max_lon_speed_arcsec_per_day
734 );
735
736 let outer_bodies = [
738 CelestialBody::Jupiter,
739 CelestialBody::Saturn,
740 CelestialBody::Uranus,
741 CelestialBody::Neptune,
742 CelestialBody::Pluto,
743 ];
744 for body in &outer_bodies {
745 let e = errors
746 .iter()
747 .find(|e| &e.body == body)
748 .unwrap_or_else(|| panic!("{body:?} must appear in the packaged baseline"));
749 assert!(
750 e.max_lon_speed_arcsec_per_day > 0.0001,
751 "{body:?} max_lon_speed {:.6} arcsec/day is not strictly positive (speed channel may be vacuous — all velocity rows skipped?)",
752 e.max_lon_speed_arcsec_per_day
753 );
754 }
755 }
756
757 #[test]
758 #[ignore = "maintainer helper: prints the Eros self-consistency max longitude error"]
759 fn print_eros_self_consistency_max_longitude_arcsec() {
760 let v = crate::accuracy_baseline::eros_self_consistency_max_longitude_arcsec();
761 eprintln!("EROS_SELF_CONSISTENCY_MAX_LON_ARCSEC = {v:.6}\"");
762 }
763
764 #[test]
771 fn eros_round_trips_against_its_reference_snapshot_within_documented_target() {
772 let eros_body = pleiades_backend::CelestialBody::Custom(
773 pleiades_backend::CustomBodyId::new("asteroid", "433-Eros"),
774 );
775 let eros_row_count = pleiades_jpl::reference_snapshot()
780 .iter()
781 .filter(|e| e.body == eros_body)
782 .count();
783 assert!(
784 eros_row_count > 0,
785 "reference snapshot contains no Eros rows — self-consistency check would be vacuous (iterate zero rows → max=0.0 → trivially passes)"
786 );
787
788 let ceiling = crate::thresholds::accuracy_ceiling(&eros_body);
789 let max_lon_arcsec = crate::accuracy_baseline::eros_self_consistency_max_longitude_arcsec();
790 assert!(
791 max_lon_arcsec <= ceiling.lon_arcsec,
792 "Eros self-consistency {max_lon_arcsec:.4}\" > {:.1}\" (artifact does not reproduce the reference snapshot it was fit from within the Asteroid-class ceiling)",
793 ceiling.lon_arcsec
794 );
795 }
796}