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        // reference snapshot rows (`snapshot_fit_source`) 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 reference snapshot rows
405    // (`snapshot_fit_source`) they were fit from.
406    //
407    // The expected count is DEFINED as the number of GENUINELY COVERABLE planned
408    // samples for the artifact's window — i.e. exactly the realized fit samples
409    // (each passed the reference + packaged ecliptic+distance gate in
410    // `packaged_artifact_fit_samples_with_filter`). So `expected == actual` holds
411    // by construction for a legitimate reason (it counts what is genuinely
412    // coverable), not by loosening an equality. Counting the cached realized
413    // samples also avoids a second backend pass over the dense artifact.
414    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
424/// Fit-envelope truth backend that measures each bundled body against the SAME
425/// source the generator fit that body against, so the envelope deltas are a true
426/// generator-vs-source residual rather than a generator-vs-mismatched-source
427/// artifact.
428///
429/// The artifact generator (`regenerate.rs`) fits the ten major bodies from the
430/// de440 kernel and fits the selected-asteroid / custom bodies from the narrow
431/// reference snapshot rows (`body_segments_from_entries` over
432/// `snapshot_fit_source`; "major bodies are fit from the kernel, never from the
433/// snapshot"). This truth backend mirrors that split:
434/// - major bodies → the dense de440-derived production reference corpus
435///   (interior ∪ boundary ∪ fast_clusters), the kernel-free analogue of the de440
436///   kernel they were fit from. It spans the full 1900–2100 window with ≥3
437///   entries/body so Lagrange interpolation never extrapolates.
438/// - selected-asteroid / custom bodies → the reference snapshot rows through
439///   `snapshot_fit_source`, the exact source they were fit against. Measuring asteroids against the corpus instead would
440///   compare them to a body they were never fit from, and the constrained asteroid
441///   corpus is too coarse for fast movers (Eros at ~180-day spacing) so cubic
442///   interpolation overshoots into non-physical multi-million-AU deltas — an
443///   interpolation artifact, not a real residual.
444///
445/// Kernel-free: the corpus and the snapshot both read committed CSVs via
446/// `include_str!`. This backend only *measures* the committed artifact and is
447/// never a generation input, so byte-identity is preserved.
448///
449/// Corpus de-duplication: the three major-body slices overlap at their shared
450/// anchor epochs (the boundary slice repeats interior/fast-cluster anchor rows),
451/// so the merged corpus holds a handful of EXACT-DUPLICATE `(body, epoch)` rows
452/// with identical coordinates. `SnapshotCorpusBackend`'s Lagrange interpolation
453/// ranks nearest nodes by `|epoch − target|` without de-duplicating, so a
454/// duplicated node yields two selected samples at the same epoch and a zero
455/// `(xi − xj)` denominator → `inf`/`NaN` deltas. We de-duplicate identical
456/// `(body, epoch)` rows here before building the corpus backend. This is lossless
457/// (the dropped rows are byte-identical to the kept ones — verified) and never
458/// touches the committed CSVs.
459struct 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
497/// Returns the once-cached fit-truth backend (see [`FitTruthBackend`]).
498fn 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                // The artifact is read directly, not through the backend, so a body the
542                // artifact carries but the backend declines is still measured.
543                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                // The artifact is read directly, not through the backend, so a body the
631                // artifact carries but the backend declines is still measured.
632                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
729/// Returns the current packaged-artifact fit envelope summary record.
730pub 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
848/// Returns the current packaged-artifact body/channel fit outlier summary record.
849pub 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
859/// Returns the calibrated packaged-artifact fit threshold summary record.
860pub fn packaged_artifact_fit_threshold_summary_details() -> PackagedArtifactFitThresholdSummary {
861    PACKAGED_ARTIFACT_FIT_THRESHOLD_SUMMARY
862}
863
864/// Returns the current packaged-artifact fit margins relative to the calibrated thresholds.
865pub 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
874/// Returns the current packaged-artifact fit threshold violations summary record.
875pub 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}