Skip to main content

pleiades_data/coverage/
threshold.rs

1use super::*;
2
3#[derive(Clone, Debug, PartialEq)]
4pub struct PackagedArtifactTargetThresholdScopeSummary {
5    /// Release-scoped body class that the fit envelope applies to.
6    pub scope: &'static str,
7    /// Bundled bodies that contribute to the scope envelope.
8    pub bodies: Vec<CelestialBody>,
9    /// Bundled bodies that contribute to the scope envelope.
10    pub body_count: usize,
11    /// Measured fit envelope for the scoped body set.
12    pub fit_envelope: PackagedArtifactFitEnvelopeSummary,
13}
14
15impl PackagedArtifactTargetThresholdScopeSummary {
16    /// Returns the scope-specific fit posture as a compact human-readable line.
17    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    /// Returns the validated scope-specific fit posture as a compact human-readable line.
27    pub fn validated_summary_line(
28        &self,
29    ) -> Result<String, PackagedArtifactFitEnvelopeSummaryValidationError> {
30        self.validate()?;
31        Ok(self.summary_line())
32    }
33
34    /// Returns `Ok(())` when the scope summary still matches the current packaged-artifact posture.
35    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    /// Scope-specific fit envelopes that make up the current packaged-artifact posture.
59    pub scope_envelopes: Vec<PackagedArtifactTargetThresholdScopeSummary>,
60}
61
62/// Validation error for a packaged-artifact target-threshold scope envelopes summary that drifted from the current posture.
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
64pub enum PackagedArtifactTargetThresholdScopeEnvelopesSummaryValidationError {
65    /// A summary field is out of sync with the current packaged-artifact posture.
66    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    /// Returns the scope-envelope posture as a compact human-readable line.
84    pub fn summary_line(&self) -> String {
85        format!("scope envelopes: {}", join_display(&self.scope_envelopes))
86    }
87
88    /// Returns `Ok(())` when the scope-envelope posture still matches the current packaged-artifact posture.
89    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    /// Returns the validated scope-envelope posture as a compact human-readable line.
113    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    /// Returns the packaged-artifact fit evidence as a compact human-readable line.
129    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    /// Returns `Ok(())` when the fit envelope still matches the current packaged artifact.
145    pub fn validate(&self) -> Result<(), PackagedArtifactFitEnvelopeSummaryValidationError> {
146        let artifact = packaged_artifact();
147        let expected = packaged_artifact_fit_envelope_summary_details();
148        // NOTE: `expected_sample_count` is defined as the realized coverable count (planned
149        // fractions for which both the fit-truth backend and the packaged backend yield an
150        // ecliptic+distance state). The fit-truth backend (see `FitTruthBackend`) measures major
151        // bodies against the dense de440 production reference corpus (full 1900–2100 window, ≥3
152        // entries/body, brackets every sampled epoch) and asteroid/custom bodies against the
153        // JplSnapshotBackend they were fit from. Both `expected_sample_count` and `sample_count`
154        // are derived from the same realized sample set, so the two checks below are informational
155        // consistency guards (they confirm the live summary was built from the same realized set)
156        // rather than a strict planned-vs-realized invariant.
157        // The meaningful drift gate is the value comparison via `self != &expected` below.
158        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
370/// Builds the ecliptic request for a (body, segment, fraction) fit sample.
371fn 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    // The envelope compares the de440-fit artifact against the kernel-free
401    // `FitTruthBackend`: major bodies against the dense de440 production reference
402    // corpus (interior ∪ boundary ∪ fast_clusters, full 1900–2100 window, ≥3
403    // entries/body so it brackets every sampled epoch and never extrapolates), and
404    // asteroid/custom bodies against the `JplSnapshotBackend` they were fit from.
405    //
406    // The expected count is DEFINED as the number of GENUINELY COVERABLE planned
407    // samples for the artifact's window — i.e. exactly the realized fit samples
408    // (each passed the reference + packaged ecliptic+distance gate in
409    // `packaged_artifact_fit_samples_with_filter`). So `expected == actual` holds
410    // by construction for a legitimate reason (it counts what is genuinely
411    // coverable), not by loosening an equality. Counting the cached realized
412    // samples also avoids a second backend pass over the dense artifact.
413    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
423/// Fit-envelope truth backend that measures each bundled body against the SAME
424/// source the generator fit that body against, so the envelope deltas are a true
425/// generator-vs-source residual rather than a generator-vs-mismatched-source
426/// artifact.
427///
428/// The artifact generator (`regenerate.rs`) fits the ten major bodies from the
429/// de440 kernel and fits the selected-asteroid / custom bodies from the narrow
430/// `JplSnapshotBackend` reference snapshot (`regenerate.rs:162–182`,
431/// "major bodies are fit from the kernel, never from the snapshot"). This truth
432/// backend mirrors that split:
433/// - major bodies → the dense de440-derived production reference corpus
434///   (interior ∪ boundary ∪ fast_clusters), the kernel-free analogue of the de440
435///   kernel they were fit from. It spans the full 1900–2100 window with ≥3
436///   entries/body so Lagrange interpolation never extrapolates.
437/// - selected-asteroid / custom bodies → `JplSnapshotBackend`, the exact source
438///   they were fit against. Measuring asteroids against the corpus instead would
439///   compare them to a body they were never fit from, and the constrained asteroid
440///   corpus is too coarse for fast movers (Eros at ~180-day spacing) so cubic
441///   interpolation overshoots into non-physical multi-million-AU deltas — an
442///   interpolation artifact, not a real residual.
443///
444/// Kernel-free: the corpus and the snapshot both read committed CSVs via
445/// `include_str!`. This backend only *measures* the committed artifact and is
446/// never a generation input, so byte-identity is preserved.
447///
448/// Corpus de-duplication: the three major-body slices overlap at their shared
449/// anchor epochs (the boundary slice repeats interior/fast-cluster anchor rows),
450/// so the merged corpus holds a handful of EXACT-DUPLICATE `(body, epoch)` rows
451/// with identical coordinates. `SnapshotCorpusBackend`'s Lagrange interpolation
452/// ranks nearest nodes by `|epoch − target|` without de-duplicating, so a
453/// duplicated node yields two selected samples at the same epoch and a zero
454/// `(xi − xj)` denominator → `inf`/`NaN` deltas. We de-duplicate identical
455/// `(body, epoch)` rows here before building the corpus backend. This is lossless
456/// (the dropped rows are byte-identical to the kept ones — verified) and never
457/// touches the committed CSVs.
458struct 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
496/// Returns the once-cached fit-truth backend (see [`FitTruthBackend`]).
497fn 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
726/// Returns the current packaged-artifact fit envelope summary record.
727pub 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
845/// Returns the current packaged-artifact body/channel fit outlier summary record.
846pub 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
856/// Returns the calibrated packaged-artifact fit threshold summary record.
857pub fn packaged_artifact_fit_threshold_summary_details() -> PackagedArtifactFitThresholdSummary {
858    PACKAGED_ARTIFACT_FIT_THRESHOLD_SUMMARY
859}
860
861/// Returns the current packaged-artifact fit margins relative to the calibrated thresholds.
862pub 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
871/// Returns the current packaged-artifact fit threshold violations summary record.
872pub 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}