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()
415 .iter()
416 .filter(|sample| include_body(&sample.body))
417 .count()
418}
419
420fn packaged_artifact_fit_expected_sample_count(artifact: &CompressedArtifact) -> usize {
421 packaged_artifact_fit_expected_sample_count_with_filter(artifact, |_| true)
422}
423
424struct FitTruthBackend {
460 corpus: SnapshotCorpusBackend,
461 snapshot: &'static SnapshotCorpusBackend,
462}
463
464impl FitTruthBackend {
465 fn fits_from_snapshot(body: &CelestialBody) -> bool {
466 use crate::coverage::PackagedArtifactBodyCadence;
467 matches!(
468 crate::coverage::packaged_artifact_body_cadence(body),
469 PackagedArtifactBodyCadence::SelectedAsteroids
470 | PackagedArtifactBodyCadence::CustomBodies
471 )
472 }
473}
474
475impl EphemerisBackend for FitTruthBackend {
476 fn metadata(&self) -> pleiades_backend::BackendMetadata {
477 self.corpus.metadata()
478 }
479
480 fn supports_body(&self, body: CelestialBody) -> bool {
481 if Self::fits_from_snapshot(&body) {
482 self.snapshot.supports_body(body)
483 } else {
484 self.corpus.supports_body(body)
485 }
486 }
487
488 fn position(&self, req: &EphemerisRequest) -> Result<EphemerisResult, EphemerisError> {
489 if Self::fits_from_snapshot(&req.body) {
490 self.snapshot.position(req)
491 } else {
492 self.corpus.position(req)
493 }
494 }
495}
496
497fn fit_truth_backend() -> &'static FitTruthBackend {
499 static BACKEND: OnceLock<FitTruthBackend> = OnceLock::new();
500 BACKEND.get_or_init(|| {
501 let mut seen = std::collections::HashSet::new();
502 let entries = production_reference_corpus()
503 .iter()
504 .filter(|entry| {
505 seen.insert((entry.body.clone(), entry.epoch.julian_day.days().to_bits()))
506 })
507 .cloned()
508 .collect::<Vec<_>>();
509 FitTruthBackend {
510 corpus: SnapshotCorpusBackend::from_entries(entries),
511 snapshot: crate::regenerate::snapshot_fit_source(),
512 }
513 })
514}
515
516fn packaged_artifact_fit_samples_with_filter<F>(
517 artifact: &CompressedArtifact,
518 mut include_body: F,
519) -> Vec<PackagedArtifactFitSample>
520where
521 F: FnMut(&CelestialBody) -> bool,
522{
523 let reference_backend = fit_truth_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_ecliptic = match artifact.lookup_ecliptic(
544 &body_artifact.body,
545 crate::regenerate::normalize_lookup_instant(request.instant),
546 ) {
547 Ok(ecliptic) => ecliptic,
548 Err(_) => continue,
549 };
550 let Some(expected_ecliptic) = expected.ecliptic else {
551 continue;
552 };
553 let (Some(expected_distance), Some(actual_distance)) =
554 (expected_ecliptic.distance_au, actual_ecliptic.distance_au)
555 else {
556 continue;
557 };
558
559 samples.push(PackagedArtifactFitSample {
560 body: body_artifact.body.clone(),
561 segment_start: segment.start,
562 segment_end: segment.end,
563 sample_instant: instant,
564 sample_fraction: *fraction,
565 longitude_delta_degrees: Angle::from_degrees(
566 actual_ecliptic.longitude.degrees() - expected_ecliptic.longitude.degrees(),
567 )
568 .normalized_signed()
569 .degrees()
570 .abs(),
571 latitude_delta_degrees: (actual_ecliptic.latitude.degrees()
572 - expected_ecliptic.latitude.degrees())
573 .abs(),
574 distance_delta_au: (actual_distance - expected_distance).abs(),
575 });
576 }
577 }
578 }
579
580 samples
581}
582
583pub(crate) fn packaged_artifact_fit_samples_for_current_artifact(
584) -> &'static [PackagedArtifactFitSample] {
585 static SAMPLES: OnceLock<Vec<PackagedArtifactFitSample>> = OnceLock::new();
586 SAMPLES
587 .get_or_init(|| {
588 let artifact = packaged_artifact();
589 packaged_artifact_fit_samples_with_filter(artifact, |_| true)
590 })
591 .as_slice()
592}
593
594pub(crate) fn packaged_artifact_fit_outlier_sample_fractions(
595 body: &CelestialBody,
596 segment: &Segment,
597) -> &'static [f64] {
598 if segment.start.julian_day.days() == segment.end.julian_day.days() {
599 &[0.0]
600 } else {
601 packaged_artifact_segment_validation_fractions_for_body(body)
602 }
603}
604
605fn packaged_artifact_fit_outlier_samples_with_filter<F>(
606 artifact: &CompressedArtifact,
607 mut include_body: F,
608) -> Vec<PackagedArtifactFitSample>
609where
610 F: FnMut(&CelestialBody) -> bool,
611{
612 let reference_backend = fit_truth_backend();
613 let mut samples = Vec::new();
614
615 for body_artifact in &artifact.bodies {
616 if !include_body(&body_artifact.body) {
617 continue;
618 }
619
620 for segment in &body_artifact.segments {
621 for fraction in
622 packaged_artifact_fit_outlier_sample_fractions(&body_artifact.body, segment)
623 {
624 let (instant, request) =
625 packaged_artifact_fit_sample_request(&body_artifact.body, segment, *fraction);
626 let expected = match reference_backend.position(&request) {
627 Ok(result) => result,
628 Err(_) => continue,
629 };
630 let actual_ecliptic = match artifact.lookup_ecliptic(
633 &body_artifact.body,
634 crate::regenerate::normalize_lookup_instant(request.instant),
635 ) {
636 Ok(ecliptic) => ecliptic,
637 Err(_) => continue,
638 };
639 let Some(expected_ecliptic) = expected.ecliptic else {
640 continue;
641 };
642 let (Some(expected_distance), Some(actual_distance)) =
643 (expected_ecliptic.distance_au, actual_ecliptic.distance_au)
644 else {
645 continue;
646 };
647
648 samples.push(PackagedArtifactFitSample {
649 body: body_artifact.body.clone(),
650 segment_start: segment.start,
651 segment_end: segment.end,
652 sample_instant: instant,
653 sample_fraction: *fraction,
654 longitude_delta_degrees: Angle::from_degrees(
655 actual_ecliptic.longitude.degrees() - expected_ecliptic.longitude.degrees(),
656 )
657 .normalized_signed()
658 .degrees()
659 .abs(),
660 latitude_delta_degrees: (actual_ecliptic.latitude.degrees()
661 - expected_ecliptic.latitude.degrees())
662 .abs(),
663 distance_delta_au: (actual_distance - expected_distance).abs(),
664 });
665 }
666 }
667 }
668
669 samples
670}
671
672pub(crate) fn packaged_artifact_fit_outlier_samples_for_current_artifact(
673) -> &'static [PackagedArtifactFitSample] {
674 static SAMPLES: OnceLock<Vec<PackagedArtifactFitSample>> = OnceLock::new();
675 SAMPLES
676 .get_or_init(|| {
677 let artifact = packaged_artifact();
678 packaged_artifact_fit_outlier_samples_with_filter(artifact, |_| true)
679 })
680 .as_slice()
681}
682
683pub(crate) fn packaged_artifact_fit_envelope_summary_from_samples(
684 samples: &[PackagedArtifactFitSample],
685 expected_sample_count: usize,
686) -> PackagedArtifactFitEnvelopeSummary {
687 let sample_count = samples.len();
688 let mut observed_bodies = Vec::new();
689 let mut mean_longitude_delta_degrees: f64 = 0.0;
690 let mut mean_latitude_delta_degrees: f64 = 0.0;
691 let mut mean_distance_delta_au: f64 = 0.0;
692 let mut max_longitude_delta_degrees: f64 = 0.0;
693 let mut max_latitude_delta_degrees: f64 = 0.0;
694 let mut max_distance_delta_au: f64 = 0.0;
695
696 for sample in samples {
697 if !observed_bodies.contains(&sample.body) {
698 observed_bodies.push(sample.body.clone());
699 }
700 mean_longitude_delta_degrees += sample.longitude_delta_degrees;
701 mean_latitude_delta_degrees += sample.latitude_delta_degrees;
702 mean_distance_delta_au += sample.distance_delta_au;
703 max_longitude_delta_degrees =
704 max_longitude_delta_degrees.max(sample.longitude_delta_degrees);
705 max_latitude_delta_degrees = max_latitude_delta_degrees.max(sample.latitude_delta_degrees);
706 max_distance_delta_au = max_distance_delta_au.max(sample.distance_delta_au);
707 }
708
709 if sample_count > 0 {
710 let sample_count = sample_count as f64;
711 mean_longitude_delta_degrees /= sample_count;
712 mean_latitude_delta_degrees /= sample_count;
713 mean_distance_delta_au /= sample_count;
714 }
715
716 PackagedArtifactFitEnvelopeSummary {
717 sample_count,
718 expected_sample_count,
719 body_count: observed_bodies.len(),
720 mean_longitude_delta_degrees,
721 mean_latitude_delta_degrees,
722 mean_distance_delta_au,
723 max_longitude_delta_degrees,
724 max_latitude_delta_degrees,
725 max_distance_delta_au,
726 }
727}
728
729pub fn packaged_artifact_fit_envelope_summary_details() -> PackagedArtifactFitEnvelopeSummary {
731 static SUMMARY: OnceLock<PackagedArtifactFitEnvelopeSummary> = OnceLock::new();
732 SUMMARY
733 .get_or_init(|| {
734 let artifact = packaged_artifact();
735 let samples = packaged_artifact_fit_samples_for_current_artifact();
736 packaged_artifact_fit_envelope_summary_from_samples(
737 samples,
738 packaged_artifact_fit_expected_sample_count(artifact),
739 )
740 })
741 .clone()
742}
743
744fn packaged_artifact_fit_channel_rank(channel: ChannelKind) -> usize {
745 match channel {
746 ChannelKind::DistanceAu => 0,
747 ChannelKind::Longitude => 1,
748 ChannelKind::Latitude => 2,
749 _ => unreachable!("unsupported packaged-artifact channel kind"),
750 }
751}
752
753pub(crate) fn packaged_artifact_fit_channel_delta(
754 sample: &PackagedArtifactFitSample,
755 channel: ChannelKind,
756) -> f64 {
757 match channel {
758 ChannelKind::Longitude => sample.longitude_delta_degrees,
759 ChannelKind::Latitude => sample.latitude_delta_degrees,
760 ChannelKind::DistanceAu => sample.distance_delta_au,
761 _ => unreachable!("unsupported packaged-artifact channel kind"),
762 }
763}
764
765fn packaged_artifact_fit_outlier_summary_from_samples(
766 samples: &[PackagedArtifactFitSample],
767) -> PackagedArtifactFitOutlierSummary {
768 let mut families: HashMap<
769 (
770 CelestialBody,
771 ChannelKind,
772 PackagedArtifactFitSegmentFamilyKey,
773 ),
774 PackagedArtifactFitChannelFamilyAccumulator,
775 > = HashMap::new();
776
777 for sample in samples {
778 let family_key = PackagedArtifactFitSegmentFamilyKey::from_sample(sample);
779
780 for channel in [
781 ChannelKind::DistanceAu,
782 ChannelKind::Longitude,
783 ChannelKind::Latitude,
784 ] {
785 let entry = families
786 .entry((sample.body.clone(), channel, family_key))
787 .or_insert_with(PackagedArtifactFitChannelFamilyAccumulator::new);
788 entry.push(sample, channel);
789 }
790 }
791
792 let mut body_channel_outliers: HashMap<
793 CelestialBody,
794 [Option<PackagedArtifactFitChannelOutlier>; 3],
795 > = HashMap::new();
796
797 for ((body, channel, _family_key), family) in families {
798 let Some(outlier) = family.finish(channel) else {
799 continue;
800 };
801 let entry = body_channel_outliers
802 .entry(body)
803 .or_insert_with(|| [None, None, None]);
804 let channel_index = packaged_artifact_fit_channel_rank(channel);
805 let should_replace = entry[channel_index]
806 .as_ref()
807 .map(|existing| {
808 outlier.delta > existing.delta
809 || (outlier.delta == existing.delta
810 && outlier.segment_span_days < existing.segment_span_days)
811 })
812 .unwrap_or(true);
813
814 if should_replace {
815 entry[channel_index] = Some(outlier);
816 }
817 }
818
819 let mut body_summaries = body_channel_outliers
820 .into_iter()
821 .map(|(body, outliers)| {
822 let mut channel_outliers = Vec::new();
823 for channel in [
824 ChannelKind::DistanceAu,
825 ChannelKind::Longitude,
826 ChannelKind::Latitude,
827 ] {
828 if let Some(outlier) = outliers[packaged_artifact_fit_channel_rank(channel)].clone()
829 {
830 channel_outliers.push(outlier);
831 }
832 }
833 PackagedArtifactFitBodyOutlierSummary {
834 body,
835 channel_outliers,
836 }
837 })
838 .collect::<Vec<_>>();
839
840 body_summaries.sort_by_key(|summary| summary.body.to_string());
841
842 PackagedArtifactFitOutlierSummary {
843 body_count: body_summaries.len(),
844 body_summaries,
845 }
846}
847
848pub fn packaged_artifact_fit_outlier_summary_details() -> PackagedArtifactFitOutlierSummary {
850 static SUMMARY: OnceLock<PackagedArtifactFitOutlierSummary> = OnceLock::new();
851 SUMMARY
852 .get_or_init(|| {
853 let samples = packaged_artifact_fit_outlier_samples_for_current_artifact();
854 packaged_artifact_fit_outlier_summary_from_samples(samples)
855 })
856 .clone()
857}
858
859pub fn packaged_artifact_fit_threshold_summary_details() -> PackagedArtifactFitThresholdSummary {
861 PACKAGED_ARTIFACT_FIT_THRESHOLD_SUMMARY
862}
863
864pub fn packaged_artifact_fit_margin_summary_details() -> PackagedArtifactFitMarginSummary {
866 let summary = PackagedArtifactFitMarginSummary {
867 envelope: packaged_artifact_fit_envelope_summary_details(),
868 thresholds: packaged_artifact_fit_threshold_summary_details(),
869 };
870 debug_assert!(summary.validate().is_ok());
871 summary
872}
873
874pub fn packaged_artifact_fit_threshold_violation_summary_details(
876) -> PackagedArtifactFitThresholdViolationsSummary {
877 let envelope = packaged_artifact_fit_envelope_summary_details();
878 let thresholds = packaged_artifact_fit_threshold_summary_details();
879 let violations = packaged_artifact_fit_threshold_violations_from_envelope_and_thresholds(
880 &envelope,
881 &thresholds,
882 );
883
884 PackagedArtifactFitThresholdViolationsSummary { violations }
885}
886
887pub(crate) fn packaged_artifact_body_scope(body: &CelestialBody) -> &'static str {
888 match body {
889 CelestialBody::Sun | CelestialBody::Moon => "luminaries",
890 CelestialBody::Mercury
891 | CelestialBody::Venus
892 | CelestialBody::Mars
893 | CelestialBody::Jupiter
894 | CelestialBody::Saturn
895 | CelestialBody::Uranus
896 | CelestialBody::Neptune => "major planets",
897 CelestialBody::Pluto => "pluto",
898 CelestialBody::MeanNode
899 | CelestialBody::TrueNode
900 | CelestialBody::MeanApogee
901 | CelestialBody::TrueApogee
902 | CelestialBody::MeanPerigee
903 | CelestialBody::TruePerigee => "lunar points",
904 CelestialBody::Ceres
905 | CelestialBody::Pallas
906 | CelestialBody::Juno
907 | CelestialBody::Vesta => "selected asteroids",
908 CelestialBody::Custom(custom) if custom.catalog.eq_ignore_ascii_case("asteroid") => {
909 "selected asteroids"
910 }
911 CelestialBody::Custom(_) => "custom bodies",
912 _ => "custom bodies",
913 }
914}