1use super::*;
2
3#[derive(Clone, Debug, PartialEq)]
4pub struct PackagedArtifactTargetThresholdScopeSummary {
5 pub scope: &'static str,
7 pub bodies: Vec<CelestialBody>,
9 pub body_count: usize,
11 pub fit_envelope: PackagedArtifactFitEnvelopeSummary,
13}
14
15impl PackagedArtifactTargetThresholdScopeSummary {
16 pub fn summary_line(&self) -> String {
18 format!(
19 "scope={}; bodies={}; {}",
20 self.scope,
21 format_scope_bodies(&self.bodies),
22 self.fit_envelope.summary_line(),
23 )
24 }
25
26 pub fn validated_summary_line(
28 &self,
29 ) -> Result<String, PackagedArtifactFitEnvelopeSummaryValidationError> {
30 self.validate()?;
31 Ok(self.summary_line())
32 }
33
34 pub fn validate(&self) -> Result<(), PackagedArtifactFitEnvelopeSummaryValidationError> {
36 let expected =
37 packaged_artifact_target_threshold_scope_envelope_summary_details(self.scope);
38 if self != &expected {
39 return Err(
40 PackagedArtifactFitEnvelopeSummaryValidationError::FieldOutOfSync {
41 field: "scope fit envelope",
42 },
43 );
44 }
45
46 Ok(())
47 }
48}
49
50impl fmt::Display for PackagedArtifactTargetThresholdScopeSummary {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.write_str(&self.summary_line())
53 }
54}
55
56#[derive(Clone, Debug, PartialEq)]
57pub struct PackagedArtifactTargetThresholdScopeEnvelopesSummary {
58 pub scope_envelopes: Vec<PackagedArtifactTargetThresholdScopeSummary>,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
64pub enum PackagedArtifactTargetThresholdScopeEnvelopesSummaryValidationError {
65 FieldOutOfSync { field: &'static str },
67}
68
69impl fmt::Display for PackagedArtifactTargetThresholdScopeEnvelopesSummaryValidationError {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {
72 Self::FieldOutOfSync { field } => write!(
73 f,
74 "the packaged-artifact target-threshold scope envelopes summary field `{field}` is out of sync with the current posture"
75 ),
76 }
77 }
78}
79
80impl std::error::Error for PackagedArtifactTargetThresholdScopeEnvelopesSummaryValidationError {}
81
82impl PackagedArtifactTargetThresholdScopeEnvelopesSummary {
83 pub fn summary_line(&self) -> String {
85 format!("scope envelopes: {}", join_display(&self.scope_envelopes))
86 }
87
88 pub fn validate(
90 &self,
91 ) -> Result<(), PackagedArtifactTargetThresholdScopeEnvelopesSummaryValidationError> {
92 let expected = packaged_artifact_target_threshold_scope_envelopes_summary_details();
93 if self != &expected {
94 return Err(
95 PackagedArtifactTargetThresholdScopeEnvelopesSummaryValidationError::FieldOutOfSync {
96 field: "scope_envelopes",
97 },
98 );
99 }
100
101 for scope_envelope in &self.scope_envelopes {
102 scope_envelope.validate().map_err(|_| {
103 PackagedArtifactTargetThresholdScopeEnvelopesSummaryValidationError::FieldOutOfSync {
104 field: "scope_envelopes",
105 }
106 })?;
107 }
108
109 Ok(())
110 }
111
112 pub fn validated_summary_line(
114 &self,
115 ) -> Result<String, PackagedArtifactTargetThresholdScopeEnvelopesSummaryValidationError> {
116 self.validate()?;
117 Ok(self.summary_line())
118 }
119}
120
121impl fmt::Display for PackagedArtifactTargetThresholdScopeEnvelopesSummary {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 f.write_str(&self.summary_line())
124 }
125}
126
127impl PackagedArtifactFitEnvelopeSummary {
128 pub fn summary_line(&self) -> String {
130 format!(
131 "fit envelope: {}/{} segment samples across {} bundled bodies; mean Δlon={:.12}°, mean Δlat={:.12}°, mean Δdist={:.12} AU; max Δlon={:.12}°, max Δlat={:.12}°, max Δdist={:.12} AU",
132 self.sample_count,
133 self.expected_sample_count,
134 self.body_count,
135 self.mean_longitude_delta_degrees,
136 self.mean_latitude_delta_degrees,
137 self.mean_distance_delta_au,
138 self.max_longitude_delta_degrees,
139 self.max_latitude_delta_degrees,
140 self.max_distance_delta_au,
141 )
142 }
143
144 pub fn validate(&self) -> Result<(), PackagedArtifactFitEnvelopeSummaryValidationError> {
146 let artifact = packaged_artifact();
147 let expected = packaged_artifact_fit_envelope_summary_details();
148 let expected_sample_count = packaged_artifact_fit_expected_sample_count(artifact);
159 let expected_body_count = artifact.bodies.len();
160
161 if self.expected_sample_count != expected_sample_count {
162 return Err(
163 PackagedArtifactFitEnvelopeSummaryValidationError::FieldOutOfSync {
164 field: "expected_sample_count",
165 },
166 );
167 }
168 if self.sample_count != expected_sample_count {
169 return Err(
170 PackagedArtifactFitEnvelopeSummaryValidationError::FieldOutOfSync {
171 field: "sample_count",
172 },
173 );
174 }
175 if self.body_count != expected_body_count {
176 return Err(
177 PackagedArtifactFitEnvelopeSummaryValidationError::FieldOutOfSync {
178 field: "body_count",
179 },
180 );
181 }
182
183 if self != &expected {
184 for (field, matches) in [
185 (
186 "mean_longitude_delta_degrees",
187 self.mean_longitude_delta_degrees == expected.mean_longitude_delta_degrees,
188 ),
189 (
190 "mean_latitude_delta_degrees",
191 self.mean_latitude_delta_degrees == expected.mean_latitude_delta_degrees,
192 ),
193 (
194 "mean_distance_delta_au",
195 self.mean_distance_delta_au == expected.mean_distance_delta_au,
196 ),
197 (
198 "max_longitude_delta_degrees",
199 self.max_longitude_delta_degrees == expected.max_longitude_delta_degrees,
200 ),
201 (
202 "max_latitude_delta_degrees",
203 self.max_latitude_delta_degrees == expected.max_latitude_delta_degrees,
204 ),
205 (
206 "max_distance_delta_au",
207 self.max_distance_delta_au == expected.max_distance_delta_au,
208 ),
209 ] {
210 if !matches {
211 return Err(
212 PackagedArtifactFitEnvelopeSummaryValidationError::FieldOutOfSync { field },
213 );
214 }
215 }
216 }
217
218 Ok(())
219 }
220}
221
222impl fmt::Display for PackagedArtifactFitEnvelopeSummary {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 f.write_str(&self.summary_line())
225 }
226}
227
228#[cfg(test)]
229pub(crate) fn packaged_artifact_fit_sample_fractions(segment: &Segment) -> &'static [f64] {
230 if segment.start.julian_day.days() == segment.end.julian_day.days() {
231 &[0.0]
232 } else {
233 &[0.25, 0.5, 0.75]
234 }
235}
236
237pub(crate) fn packaged_artifact_fit_sample_fractions_for_body(
238 body: &CelestialBody,
239 segment: &Segment,
240) -> &'static [f64] {
241 if segment.start.julian_day.days() == segment.end.julian_day.days() {
242 &[0.0]
243 } else {
244 match packaged_artifact_body_cadence(body) {
245 PackagedArtifactBodyCadence::Luminaries
246 | PackagedArtifactBodyCadence::LunarPoints
247 | PackagedArtifactBodyCadence::SelectedAsteroids
248 | PackagedArtifactBodyCadence::Pluto
249 | PackagedArtifactBodyCadence::CustomBodies => {
250 packaged_artifact_segment_validation_fractions_for_body(body)
251 }
252 PackagedArtifactBodyCadence::InnerPlanets
253 | PackagedArtifactBodyCadence::OuterPlanets => {
254 PACKAGED_ARTIFACT_MEDIUM_VALIDATION_SAMPLE_FRACTIONS
255 }
256 }
257 }
258}
259
260pub(crate) fn distance_channel_from_samples(
261 start: f64,
262 midpoint: Option<f64>,
263 end: f64,
264) -> PolynomialChannel {
265 midpoint
266 .map(|midpoint| {
267 PolynomialChannel::quadratic(ChannelKind::DistanceAu, 10, start, midpoint, end, 0.5)
268 })
269 .unwrap_or_else(|| PolynomialChannel::linear(ChannelKind::DistanceAu, 10, start, end))
270}
271
272pub(crate) fn distance_channel_from_four_point_control_points(
273 start: f64,
274 first_third: f64,
275 second_third: f64,
276 end: f64,
277) -> Option<PolynomialChannel> {
278 polynomial_channel_from_samples(
279 ChannelKind::DistanceAu,
280 10,
281 &[
282 (0.0, start),
283 (1.0 / 3.0, first_third),
284 (2.0 / 3.0, second_third),
285 (1.0, end),
286 ],
287 )
288}
289
290fn channel_from_fit_control_points(
291 kind: ChannelKind,
292 scale_exponent: u8,
293 samples: &[(f64, f64)],
294) -> Option<PolynomialChannel> {
295 const TARGET_FRACTIONS: [f64; 4] = [0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0];
296
297 if samples.len() < TARGET_FRACTIONS.len() {
298 return None;
299 }
300
301 let mut selected_samples = Vec::with_capacity(TARGET_FRACTIONS.len());
302 let mut used_indices = vec![false; samples.len()];
303
304 for target_fraction in TARGET_FRACTIONS {
305 let mut best_index = None;
306 let mut best_distance = f64::INFINITY;
307
308 for (index, (fraction, _)) in samples.iter().enumerate() {
309 if used_indices[index] {
310 continue;
311 }
312
313 let distance = (*fraction - target_fraction).abs();
314 if distance < best_distance {
315 best_distance = distance;
316 best_index = Some(index);
317 }
318 }
319
320 let index = best_index?;
321
322 used_indices[index] = true;
323 selected_samples.push(samples[index]);
324 }
325
326 polynomial_channel_from_samples(kind, scale_exponent, &selected_samples)
327}
328
329pub(crate) fn channel_from_fit_samples_with_control_points(
330 kind: ChannelKind,
331 scale_exponent: u8,
332 samples: &[(f64, f64)],
333) -> Option<PolynomialChannel> {
334 polynomial_channel_from_samples(kind, scale_exponent, samples)
335 .or_else(|| channel_from_fit_control_points(kind, scale_exponent, samples))
336}
337
338pub(crate) fn channel_from_dense_fit_samples_with_control_points(
339 kind: ChannelKind,
340 scale_exponent: u8,
341 samples: &[(f64, f64)],
342) -> Option<PolynomialChannel> {
343 channel_from_fit_control_points(kind, scale_exponent, samples)
344 .or_else(|| polynomial_channel_from_samples(kind, scale_exponent, samples))
345}
346
347pub(crate) fn distance_channel_from_dense_fit_samples(
348 samples: &[(f64, f64)],
349 start: f64,
350 midpoint: Option<f64>,
351 end: f64,
352) -> PolynomialChannel {
353 channel_from_fit_control_points(ChannelKind::DistanceAu, 10, samples)
354 .or_else(|| {
355 channel_from_fit_samples_with_control_points(ChannelKind::DistanceAu, 10, samples)
356 })
357 .unwrap_or_else(|| distance_channel_from_samples(start, midpoint, end))
358}
359
360pub(crate) fn distance_channel_from_fit_samples(
361 samples: &[(f64, f64)],
362 start: f64,
363 midpoint: Option<f64>,
364 end: f64,
365) -> PolynomialChannel {
366 channel_from_fit_samples_with_control_points(ChannelKind::DistanceAu, 10, samples)
367 .unwrap_or_else(|| distance_channel_from_samples(start, midpoint, end))
368}
369
370fn packaged_artifact_fit_sample_request(
372 body: &CelestialBody,
373 segment: &Segment,
374 fraction: f64,
375) -> (Instant, EphemerisRequest) {
376 let start = segment.start.julian_day.days();
377 let span = segment.end.julian_day.days() - start;
378 let instant = Instant::new(
379 JulianDay::from_days(start + span * fraction),
380 segment.start.scale,
381 );
382 let request = EphemerisRequest {
383 body: body.clone(),
384 instant,
385 observer: None,
386 frame: CoordinateFrame::Ecliptic,
387 zodiac_mode: ZodiacMode::Tropical,
388 apparent: Apparentness::Mean,
389 };
390 (instant, request)
391}
392
393pub(crate) fn packaged_artifact_fit_expected_sample_count_with_filter<F>(
394 _artifact: &CompressedArtifact,
395 mut include_body: F,
396) -> usize
397where
398 F: FnMut(&CelestialBody) -> bool,
399{
400 packaged_artifact_fit_samples_for_current_artifact()
414 .iter()
415 .filter(|sample| include_body(&sample.body))
416 .count()
417}
418
419fn packaged_artifact_fit_expected_sample_count(artifact: &CompressedArtifact) -> usize {
420 packaged_artifact_fit_expected_sample_count_with_filter(artifact, |_| true)
421}
422
423struct FitTruthBackend {
459 corpus: SnapshotCorpusBackend,
460 snapshot: JplSnapshotBackend,
461}
462
463impl FitTruthBackend {
464 fn fits_from_snapshot(body: &CelestialBody) -> bool {
465 use crate::coverage::PackagedArtifactBodyCadence;
466 matches!(
467 crate::coverage::packaged_artifact_body_cadence(body),
468 PackagedArtifactBodyCadence::SelectedAsteroids
469 | PackagedArtifactBodyCadence::CustomBodies
470 )
471 }
472}
473
474impl EphemerisBackend for FitTruthBackend {
475 fn metadata(&self) -> pleiades_backend::BackendMetadata {
476 self.corpus.metadata()
477 }
478
479 fn supports_body(&self, body: CelestialBody) -> bool {
480 if Self::fits_from_snapshot(&body) {
481 self.snapshot.supports_body(body)
482 } else {
483 self.corpus.supports_body(body)
484 }
485 }
486
487 fn position(&self, req: &EphemerisRequest) -> Result<EphemerisResult, EphemerisError> {
488 if Self::fits_from_snapshot(&req.body) {
489 self.snapshot.position(req)
490 } else {
491 self.corpus.position(req)
492 }
493 }
494}
495
496fn fit_truth_backend() -> &'static FitTruthBackend {
498 static BACKEND: OnceLock<FitTruthBackend> = OnceLock::new();
499 BACKEND.get_or_init(|| {
500 let mut seen = std::collections::HashSet::new();
501 let entries = production_reference_corpus()
502 .iter()
503 .filter(|entry| {
504 seen.insert((entry.body.clone(), entry.epoch.julian_day.days().to_bits()))
505 })
506 .cloned()
507 .collect::<Vec<_>>();
508 FitTruthBackend {
509 corpus: SnapshotCorpusBackend::from_entries(entries),
510 snapshot: JplSnapshotBackend,
511 }
512 })
513}
514
515fn packaged_artifact_fit_samples_with_filter<F>(
516 artifact: &CompressedArtifact,
517 mut include_body: F,
518) -> Vec<PackagedArtifactFitSample>
519where
520 F: FnMut(&CelestialBody) -> bool,
521{
522 let reference_backend = fit_truth_backend();
523 let packaged_backend = packaged_backend();
524 let mut samples = Vec::new();
525
526 for body_artifact in &artifact.bodies {
527 if !include_body(&body_artifact.body) {
528 continue;
529 }
530
531 for segment in &body_artifact.segments {
532 for fraction in
533 packaged_artifact_fit_sample_fractions_for_body(&body_artifact.body, segment)
534 {
535 let (instant, request) =
536 packaged_artifact_fit_sample_request(&body_artifact.body, segment, *fraction);
537 let expected = match reference_backend.position(&request) {
538 Ok(result) => result,
539 Err(_) => continue,
540 };
541 let actual = match packaged_backend.position(&request) {
542 Ok(result) => result,
543 Err(_) => continue,
544 };
545
546 let (Some(expected_ecliptic), Some(actual_ecliptic)) =
547 (expected.ecliptic, actual.ecliptic)
548 else {
549 continue;
550 };
551 let (Some(expected_distance), Some(actual_distance)) =
552 (expected_ecliptic.distance_au, actual_ecliptic.distance_au)
553 else {
554 continue;
555 };
556
557 samples.push(PackagedArtifactFitSample {
558 body: body_artifact.body.clone(),
559 segment_start: segment.start,
560 segment_end: segment.end,
561 sample_instant: instant,
562 sample_fraction: *fraction,
563 longitude_delta_degrees: Angle::from_degrees(
564 actual_ecliptic.longitude.degrees() - expected_ecliptic.longitude.degrees(),
565 )
566 .normalized_signed()
567 .degrees()
568 .abs(),
569 latitude_delta_degrees: (actual_ecliptic.latitude.degrees()
570 - expected_ecliptic.latitude.degrees())
571 .abs(),
572 distance_delta_au: (actual_distance - expected_distance).abs(),
573 });
574 }
575 }
576 }
577
578 samples
579}
580
581pub(crate) fn packaged_artifact_fit_samples_for_current_artifact(
582) -> &'static [PackagedArtifactFitSample] {
583 static SAMPLES: OnceLock<Vec<PackagedArtifactFitSample>> = OnceLock::new();
584 SAMPLES
585 .get_or_init(|| {
586 let artifact = packaged_artifact();
587 packaged_artifact_fit_samples_with_filter(artifact, |_| true)
588 })
589 .as_slice()
590}
591
592pub(crate) fn packaged_artifact_fit_outlier_sample_fractions(
593 body: &CelestialBody,
594 segment: &Segment,
595) -> &'static [f64] {
596 if segment.start.julian_day.days() == segment.end.julian_day.days() {
597 &[0.0]
598 } else {
599 packaged_artifact_segment_validation_fractions_for_body(body)
600 }
601}
602
603fn packaged_artifact_fit_outlier_samples_with_filter<F>(
604 artifact: &CompressedArtifact,
605 mut include_body: F,
606) -> Vec<PackagedArtifactFitSample>
607where
608 F: FnMut(&CelestialBody) -> bool,
609{
610 let reference_backend = fit_truth_backend();
611 let packaged_backend = packaged_backend();
612 let mut samples = Vec::new();
613
614 for body_artifact in &artifact.bodies {
615 if !include_body(&body_artifact.body) {
616 continue;
617 }
618
619 for segment in &body_artifact.segments {
620 for fraction in
621 packaged_artifact_fit_outlier_sample_fractions(&body_artifact.body, segment)
622 {
623 let (instant, request) =
624 packaged_artifact_fit_sample_request(&body_artifact.body, segment, *fraction);
625 let expected = match reference_backend.position(&request) {
626 Ok(result) => result,
627 Err(_) => continue,
628 };
629 let actual = match packaged_backend.position(&request) {
630 Ok(result) => result,
631 Err(_) => continue,
632 };
633
634 let (Some(expected_ecliptic), Some(actual_ecliptic)) =
635 (expected.ecliptic, actual.ecliptic)
636 else {
637 continue;
638 };
639 let (Some(expected_distance), Some(actual_distance)) =
640 (expected_ecliptic.distance_au, actual_ecliptic.distance_au)
641 else {
642 continue;
643 };
644
645 samples.push(PackagedArtifactFitSample {
646 body: body_artifact.body.clone(),
647 segment_start: segment.start,
648 segment_end: segment.end,
649 sample_instant: instant,
650 sample_fraction: *fraction,
651 longitude_delta_degrees: Angle::from_degrees(
652 actual_ecliptic.longitude.degrees() - expected_ecliptic.longitude.degrees(),
653 )
654 .normalized_signed()
655 .degrees()
656 .abs(),
657 latitude_delta_degrees: (actual_ecliptic.latitude.degrees()
658 - expected_ecliptic.latitude.degrees())
659 .abs(),
660 distance_delta_au: (actual_distance - expected_distance).abs(),
661 });
662 }
663 }
664 }
665
666 samples
667}
668
669pub(crate) fn packaged_artifact_fit_outlier_samples_for_current_artifact(
670) -> &'static [PackagedArtifactFitSample] {
671 static SAMPLES: OnceLock<Vec<PackagedArtifactFitSample>> = OnceLock::new();
672 SAMPLES
673 .get_or_init(|| {
674 let artifact = packaged_artifact();
675 packaged_artifact_fit_outlier_samples_with_filter(artifact, |_| true)
676 })
677 .as_slice()
678}
679
680pub(crate) fn packaged_artifact_fit_envelope_summary_from_samples(
681 samples: &[PackagedArtifactFitSample],
682 expected_sample_count: usize,
683) -> PackagedArtifactFitEnvelopeSummary {
684 let sample_count = samples.len();
685 let mut observed_bodies = Vec::new();
686 let mut mean_longitude_delta_degrees: f64 = 0.0;
687 let mut mean_latitude_delta_degrees: f64 = 0.0;
688 let mut mean_distance_delta_au: f64 = 0.0;
689 let mut max_longitude_delta_degrees: f64 = 0.0;
690 let mut max_latitude_delta_degrees: f64 = 0.0;
691 let mut max_distance_delta_au: f64 = 0.0;
692
693 for sample in samples {
694 if !observed_bodies.contains(&sample.body) {
695 observed_bodies.push(sample.body.clone());
696 }
697 mean_longitude_delta_degrees += sample.longitude_delta_degrees;
698 mean_latitude_delta_degrees += sample.latitude_delta_degrees;
699 mean_distance_delta_au += sample.distance_delta_au;
700 max_longitude_delta_degrees =
701 max_longitude_delta_degrees.max(sample.longitude_delta_degrees);
702 max_latitude_delta_degrees = max_latitude_delta_degrees.max(sample.latitude_delta_degrees);
703 max_distance_delta_au = max_distance_delta_au.max(sample.distance_delta_au);
704 }
705
706 if sample_count > 0 {
707 let sample_count = sample_count as f64;
708 mean_longitude_delta_degrees /= sample_count;
709 mean_latitude_delta_degrees /= sample_count;
710 mean_distance_delta_au /= sample_count;
711 }
712
713 PackagedArtifactFitEnvelopeSummary {
714 sample_count,
715 expected_sample_count,
716 body_count: observed_bodies.len(),
717 mean_longitude_delta_degrees,
718 mean_latitude_delta_degrees,
719 mean_distance_delta_au,
720 max_longitude_delta_degrees,
721 max_latitude_delta_degrees,
722 max_distance_delta_au,
723 }
724}
725
726pub fn packaged_artifact_fit_envelope_summary_details() -> PackagedArtifactFitEnvelopeSummary {
728 static SUMMARY: OnceLock<PackagedArtifactFitEnvelopeSummary> = OnceLock::new();
729 SUMMARY
730 .get_or_init(|| {
731 let artifact = packaged_artifact();
732 let samples = packaged_artifact_fit_samples_for_current_artifact();
733 packaged_artifact_fit_envelope_summary_from_samples(
734 samples,
735 packaged_artifact_fit_expected_sample_count(artifact),
736 )
737 })
738 .clone()
739}
740
741fn packaged_artifact_fit_channel_rank(channel: ChannelKind) -> usize {
742 match channel {
743 ChannelKind::DistanceAu => 0,
744 ChannelKind::Longitude => 1,
745 ChannelKind::Latitude => 2,
746 _ => unreachable!("unsupported packaged-artifact channel kind"),
747 }
748}
749
750pub(crate) fn packaged_artifact_fit_channel_delta(
751 sample: &PackagedArtifactFitSample,
752 channel: ChannelKind,
753) -> f64 {
754 match channel {
755 ChannelKind::Longitude => sample.longitude_delta_degrees,
756 ChannelKind::Latitude => sample.latitude_delta_degrees,
757 ChannelKind::DistanceAu => sample.distance_delta_au,
758 _ => unreachable!("unsupported packaged-artifact channel kind"),
759 }
760}
761
762fn packaged_artifact_fit_outlier_summary_from_samples(
763 samples: &[PackagedArtifactFitSample],
764) -> PackagedArtifactFitOutlierSummary {
765 let mut families: HashMap<
766 (
767 CelestialBody,
768 ChannelKind,
769 PackagedArtifactFitSegmentFamilyKey,
770 ),
771 PackagedArtifactFitChannelFamilyAccumulator,
772 > = HashMap::new();
773
774 for sample in samples {
775 let family_key = PackagedArtifactFitSegmentFamilyKey::from_sample(sample);
776
777 for channel in [
778 ChannelKind::DistanceAu,
779 ChannelKind::Longitude,
780 ChannelKind::Latitude,
781 ] {
782 let entry = families
783 .entry((sample.body.clone(), channel, family_key))
784 .or_insert_with(PackagedArtifactFitChannelFamilyAccumulator::new);
785 entry.push(sample, channel);
786 }
787 }
788
789 let mut body_channel_outliers: HashMap<
790 CelestialBody,
791 [Option<PackagedArtifactFitChannelOutlier>; 3],
792 > = HashMap::new();
793
794 for ((body, channel, _family_key), family) in families {
795 let Some(outlier) = family.finish(channel) else {
796 continue;
797 };
798 let entry = body_channel_outliers
799 .entry(body)
800 .or_insert_with(|| [None, None, None]);
801 let channel_index = packaged_artifact_fit_channel_rank(channel);
802 let should_replace = entry[channel_index]
803 .as_ref()
804 .map(|existing| {
805 outlier.delta > existing.delta
806 || (outlier.delta == existing.delta
807 && outlier.segment_span_days < existing.segment_span_days)
808 })
809 .unwrap_or(true);
810
811 if should_replace {
812 entry[channel_index] = Some(outlier);
813 }
814 }
815
816 let mut body_summaries = body_channel_outliers
817 .into_iter()
818 .map(|(body, outliers)| {
819 let mut channel_outliers = Vec::new();
820 for channel in [
821 ChannelKind::DistanceAu,
822 ChannelKind::Longitude,
823 ChannelKind::Latitude,
824 ] {
825 if let Some(outlier) = outliers[packaged_artifact_fit_channel_rank(channel)].clone()
826 {
827 channel_outliers.push(outlier);
828 }
829 }
830 PackagedArtifactFitBodyOutlierSummary {
831 body,
832 channel_outliers,
833 }
834 })
835 .collect::<Vec<_>>();
836
837 body_summaries.sort_by_key(|summary| summary.body.to_string());
838
839 PackagedArtifactFitOutlierSummary {
840 body_count: body_summaries.len(),
841 body_summaries,
842 }
843}
844
845pub fn packaged_artifact_fit_outlier_summary_details() -> PackagedArtifactFitOutlierSummary {
847 static SUMMARY: OnceLock<PackagedArtifactFitOutlierSummary> = OnceLock::new();
848 SUMMARY
849 .get_or_init(|| {
850 let samples = packaged_artifact_fit_outlier_samples_for_current_artifact();
851 packaged_artifact_fit_outlier_summary_from_samples(samples)
852 })
853 .clone()
854}
855
856pub fn packaged_artifact_fit_threshold_summary_details() -> PackagedArtifactFitThresholdSummary {
858 PACKAGED_ARTIFACT_FIT_THRESHOLD_SUMMARY
859}
860
861pub fn packaged_artifact_fit_margin_summary_details() -> PackagedArtifactFitMarginSummary {
863 let summary = PackagedArtifactFitMarginSummary {
864 envelope: packaged_artifact_fit_envelope_summary_details(),
865 thresholds: packaged_artifact_fit_threshold_summary_details(),
866 };
867 debug_assert!(summary.validate().is_ok());
868 summary
869}
870
871pub fn packaged_artifact_fit_threshold_violation_summary_details(
873) -> PackagedArtifactFitThresholdViolationsSummary {
874 let envelope = packaged_artifact_fit_envelope_summary_details();
875 let thresholds = packaged_artifact_fit_threshold_summary_details();
876 let violations = packaged_artifact_fit_threshold_violations_from_envelope_and_thresholds(
877 &envelope,
878 &thresholds,
879 );
880
881 PackagedArtifactFitThresholdViolationsSummary { violations }
882}
883
884pub(crate) fn packaged_artifact_body_scope(body: &CelestialBody) -> &'static str {
885 match body {
886 CelestialBody::Sun | CelestialBody::Moon => "luminaries",
887 CelestialBody::Mercury
888 | CelestialBody::Venus
889 | CelestialBody::Mars
890 | CelestialBody::Jupiter
891 | CelestialBody::Saturn
892 | CelestialBody::Uranus
893 | CelestialBody::Neptune => "major planets",
894 CelestialBody::Pluto => "pluto",
895 CelestialBody::MeanNode
896 | CelestialBody::TrueNode
897 | CelestialBody::MeanApogee
898 | CelestialBody::TrueApogee
899 | CelestialBody::MeanPerigee
900 | CelestialBody::TruePerigee => "lunar points",
901 CelestialBody::Ceres
902 | CelestialBody::Pallas
903 | CelestialBody::Juno
904 | CelestialBody::Vesta => "selected asteroids",
905 CelestialBody::Custom(custom) if custom.catalog.eq_ignore_ascii_case("asteroid") => {
906 "selected asteroids"
907 }
908 CelestialBody::Custom(_) => "custom bodies",
909 _ => "custom bodies",
910 }
911}