Skip to main content

pleiades_data/
regenerate.rs

1use std::collections::HashMap;
2use std::{cmp::Ordering, fmt};
3
4use pleiades_backend::{
5    Angle, Apparentness, CelestialBody, CoordinateFrame, CustomBodyId, EclipticCoordinates,
6    EphemerisBackend, EphemerisError, EphemerisErrorKind, EphemerisRequest, Instant, JulianDay,
7    TimeRange, TimeScale, ZodiacMode,
8};
9use pleiades_compression::{
10    join_display, ArtifactHeader, BodyArtifact, ChannelKind, CompressedArtifact, PolynomialChannel,
11    Segment,
12};
13use pleiades_jpl::{
14    production_generation_source_summary, reference_snapshot, reference_snapshot_summary,
15    JplSnapshotBackend, SnapshotEntry,
16};
17
18use crate::coverage::{
19    channel_from_dense_fit_samples_with_control_points,
20    channel_from_fit_samples_with_control_points, distance_channel_from_dense_fit_samples,
21    distance_channel_from_fit_samples, distance_channel_from_four_point_control_points,
22    distance_channel_from_samples, packaged_artifact_body_cadence, PackagedArtifactBodyCadence,
23};
24use crate::data::{packaged_artifact_bytes, packaged_artifact_from_bytes};
25use crate::{packaged_artifact_source_text, packaged_bodies, ARTIFACT_LABEL, AU_IN_KM};
26
27pub(crate) fn build_packaged_artifact() -> CompressedArtifact {
28    packaged_artifact_from_bytes(packaged_artifact_bytes())
29        .expect("checked-in packaged artifact fixture should decode and validate")
30}
31
32pub(crate) fn validate_packaged_artifact_phase1_source_inputs(
33) -> Result<(), pleiades_compression::CompressionError> {
34    production_generation_source_summary().validate().map_err(|error| {
35        pleiades_compression::CompressionError::new(
36            pleiades_compression::CompressionErrorKind::InvalidFormat,
37            format!(
38                "packaged artifact regeneration phase-1 source inputs production-generation source summary is invalid: {error}"
39            ),
40        )
41    })?;
42
43    let reference_snapshot_summary = reference_snapshot_summary().ok_or_else(|| {
44        pleiades_compression::CompressionError::new(
45            pleiades_compression::CompressionErrorKind::InvalidFormat,
46            "packaged artifact regeneration phase-1 source inputs are missing reference snapshot coverage",
47        )
48    })?;
49    reference_snapshot_summary.validate().map_err(|error| {
50        pleiades_compression::CompressionError::new(
51            pleiades_compression::CompressionErrorKind::InvalidFormat,
52            format!(
53                "packaged artifact regeneration phase-1 source inputs reference snapshot summary is invalid: {error}"
54            ),
55        )
56    })?;
57
58    Ok(())
59}
60
61fn validate_packaged_artifact_reference_snapshot_inputs(
62    snapshot: &[SnapshotEntry],
63) -> Result<(), pleiades_compression::CompressionError> {
64    let reference_snapshot = reference_snapshot();
65    if snapshot.len() != reference_snapshot.len() {
66        return Err(pleiades_compression::CompressionError::new(
67            pleiades_compression::CompressionErrorKind::InvalidFormat,
68            format!(
69                "packaged artifact regeneration snapshot input length {} does not match the checked-in reference snapshot length {}",
70                snapshot.len(),
71                reference_snapshot.len()
72            ),
73        ));
74    }
75
76    for (index, (actual, expected)) in snapshot.iter().zip(reference_snapshot).enumerate() {
77        if actual != expected {
78            return Err(pleiades_compression::CompressionError::new(
79                pleiades_compression::CompressionErrorKind::InvalidFormat,
80                format!(
81                    "packaged artifact regeneration snapshot input at index {index} does not match the checked-in reference snapshot: expected {expected:?}; found {actual:?}",
82                ),
83            ));
84        }
85    }
86
87    Ok(())
88}
89
90/// Rebuilds the packaged artifact from validated JPL reference-snapshot inputs.
91///
92/// This helper is deterministic and pure Rust so maintainers can regenerate the
93/// checked-in fixture without relying on platform-specific tooling. Callers can
94/// supply the checked-in reference snapshot slice to make the generation inputs
95/// explicit while preserving the same bundled artifact layout.
96pub fn try_regenerate_packaged_artifact_from_snapshot(
97    snapshot: &[SnapshotEntry],
98) -> Result<CompressedArtifact, pleiades_compression::CompressionError> {
99    validate_packaged_artifact_phase1_source_inputs()?;
100    validate_packaged_artifact_reference_snapshot_inputs(snapshot)?;
101
102    let mut artifact = CompressedArtifact::new(
103        ArtifactHeader::new(ARTIFACT_LABEL, packaged_artifact_source_text()),
104        packaged_body_artifacts_from_snapshot(snapshot),
105    );
106    artifact.checksum = artifact
107        .checksum()
108        .expect("packaged artifact checksum should be reproducible");
109    artifact
110        .validate()
111        .expect("packaged artifact should validate before encoding");
112    Ok(artifact)
113}
114
115/// Rebuilds the packaged artifact from validated JPL reference-snapshot inputs.
116///
117/// This helper is deterministic and pure Rust so maintainers can regenerate the
118/// checked-in fixture without relying on platform-specific tooling. Callers can
119/// supply the checked-in reference snapshot slice to make the generation inputs
120/// explicit while preserving the same bundled artifact layout.
121pub fn regenerate_packaged_artifact_from_snapshot(
122    snapshot: &[SnapshotEntry],
123) -> CompressedArtifact {
124    try_regenerate_packaged_artifact_from_snapshot(snapshot)
125        .expect("checked-in reference snapshot inputs should validate")
126}
127
128/// Returns the packaged artifact for kernel-free callers.
129///
130/// Runtime decode of the committed bytes is the only kernel-free path: this
131/// decodes [`packaged_artifact_bytes()`] (via the crate-internal
132/// `build_packaged_artifact` helper)
133/// rather than refitting from the in-process snapshot. Kernel-gated
134/// regeneration from de440 lives in [`regenerate_packaged_artifact_from_kernel`].
135pub fn regenerate_packaged_artifact() -> CompressedArtifact {
136    build_packaged_artifact()
137}
138
139/// Returns the encoded bytes for the packaged artifact for kernel-free callers.
140///
141/// This returns the committed bytes directly ([`packaged_artifact_bytes()`]) so
142/// kernel-free regeneration commands write the byte-identical committed payload.
143pub fn regenerate_packaged_artifact_bytes() -> &'static [u8] {
144    packaged_artifact_bytes()
145}
146
147fn packaged_body_artifacts_from_snapshot(snapshot: &[SnapshotEntry]) -> Vec<BodyArtifact> {
148    let mut entries_by_body: HashMap<CelestialBody, Vec<&SnapshotEntry>> = HashMap::new();
149
150    for entry in snapshot {
151        entries_by_body
152            .entry(entry.body.clone())
153            .or_default()
154            .push(entry);
155    }
156
157    let mut artifacts = Vec::new();
158    std::thread::scope(|scope| {
159        let mut handles = Vec::new();
160
161        for (body_index, body) in packaged_bodies().iter().cloned().enumerate() {
162            use crate::coverage::{packaged_artifact_body_cadence, PackagedArtifactBodyCadence};
163            if !matches!(
164                packaged_artifact_body_cadence(&body),
165                PackagedArtifactBodyCadence::SelectedAsteroids
166                    | PackagedArtifactBodyCadence::CustomBodies
167            ) {
168                continue; // major bodies are fit from the kernel, never from the snapshot
169            }
170            let Some(mut entries) = entries_by_body.remove(&body) else {
171                continue;
172            };
173
174            handles.push(scope.spawn(move || {
175                entries.sort_by(|left, right| {
176                    left.epoch
177                        .julian_day
178                        .days()
179                        .partial_cmp(&right.epoch.julian_day.days())
180                        .unwrap_or(Ordering::Equal)
181                });
182
183                let reference_backend = JplSnapshotBackend;
184                let segments = body_segments_from_entries(&entries, &reference_backend);
185
186                (body_index, BodyArtifact::new(body, segments))
187            }));
188        }
189
190        for handle in handles {
191            artifacts.push(
192                handle
193                    .join()
194                    .expect("packaged artifact body reconstruction should not panic"),
195            );
196        }
197    });
198
199    artifacts.sort_by_key(|(body_index, _)| *body_index);
200    artifacts
201        .into_iter()
202        .map(|(_, artifact)| artifact)
203        .collect()
204}
205
206pub(crate) fn body_segments_from_entries(
207    entries: &[&SnapshotEntry],
208    reference_backend: &JplSnapshotBackend,
209) -> Vec<Segment> {
210    match entries.len() {
211        0 => Vec::new(),
212        1 => vec![segment_from_single_entry(entries[0])],
213        _ => entries
214            .windows(2)
215            .flat_map(|window| {
216                body_segment_windows_for_interval(window[0], window[1], reference_backend)
217            })
218            .collect(),
219    }
220}
221
222/// Bodies fit in the heliocentric frame and recombined with the geocentric Sun
223/// at lookup. Only the eight true planets; Sun, Moon, Eros, and lunar points
224/// stay geocentric.
225pub(crate) fn body_uses_heliocentric_frame(body: &CelestialBody) -> bool {
226    matches!(
227        body,
228        CelestialBody::Mercury
229            | CelestialBody::Venus
230            | CelestialBody::Mars
231            | CelestialBody::Jupiter
232            | CelestialBody::Saturn
233            | CelestialBody::Uranus
234            | CelestialBody::Neptune
235            | CelestialBody::Pluto
236    )
237}
238
239pub(crate) fn body_segment_span_limit(body: &CelestialBody) -> f64 {
240    match packaged_artifact_body_cadence(body) {
241        PackagedArtifactBodyCadence::Luminaries => 256.0,
242        PackagedArtifactBodyCadence::InnerPlanets => 384.0,
243        PackagedArtifactBodyCadence::OuterPlanets => 768.0,
244        PackagedArtifactBodyCadence::Pluto => 1_536.0,
245        PackagedArtifactBodyCadence::LunarPoints => 256.0,
246        PackagedArtifactBodyCadence::SelectedAsteroids => 256.0,
247        PackagedArtifactBodyCadence::CustomBodies => 512.0,
248    }
249}
250
251const PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO: f64 = 1.1;
252const PACKAGED_ARTIFACT_EXTREME_SPLIT_RATIO: f64 = 4.0;
253const PACKAGED_ARTIFACT_EXTREME_SPLIT_MIN_SPAN_RATIO: f64 = 3.0;
254pub(crate) const PACKAGED_ARTIFACT_LEFT_BIASED_SPLIT_FRACTION: f64 = 0.4;
255pub(crate) const PACKAGED_ARTIFACT_RIGHT_BIASED_SPLIT_FRACTION: f64 = 0.6;
256pub(crate) const PACKAGED_ARTIFACT_LEFT_EXTREME_SPLIT_FRACTION: f64 = 0.25;
257pub(crate) const PACKAGED_ARTIFACT_RIGHT_EXTREME_SPLIT_FRACTION: f64 = 0.75;
258pub(crate) const PACKAGED_ARTIFACT_ONE_FIFTH_SPLIT_FRACTION: f64 = 1.0 / 5.0;
259pub(crate) const PACKAGED_ARTIFACT_FOUR_FIFTHS_SPLIT_FRACTION: f64 = 4.0 / 5.0;
260pub(crate) const PACKAGED_ARTIFACT_ONE_SEVENTH_SPLIT_FRACTION: f64 = 1.0 / 7.0;
261pub(crate) const PACKAGED_ARTIFACT_SIX_SEVENTHS_SPLIT_FRACTION: f64 = 6.0 / 7.0;
262pub(crate) const PACKAGED_ARTIFACT_ONE_NINTH_SPLIT_FRACTION: f64 = 1.0 / 9.0;
263pub(crate) const PACKAGED_ARTIFACT_EIGHT_NINTHS_SPLIT_FRACTION: f64 = 8.0 / 9.0;
264pub(crate) const PACKAGED_ARTIFACT_ONE_EIGHTH_SPLIT_FRACTION: f64 = 1.0 / 8.0;
265pub(crate) const PACKAGED_ARTIFACT_SEVEN_EIGHTHS_SPLIT_FRACTION: f64 = 7.0 / 8.0;
266const PACKAGED_ARTIFACT_SUPER_EXTREME_DENSE_SPLIT_SPAN_RATIO: f64 = 32.0;
267const PACKAGED_ARTIFACT_EXTREME_DENSE_SPLIT_SPAN_RATIO: f64 = 16.0;
268pub(crate) const PACKAGED_ARTIFACT_ONE_THIRD_SPLIT_FRACTION: f64 = 1.0 / 3.0;
269pub(crate) const PACKAGED_ARTIFACT_TWO_THIRD_SPLIT_FRACTION: f64 = 2.0 / 3.0;
270pub(crate) const PACKAGED_ARTIFACT_ONE_SIXTH_SPLIT_FRACTION: f64 = 1.0 / 6.0;
271const PACKAGED_ARTIFACT_VERY_LONG_DENSE_SPLIT_SPAN_RATIO: f64 = 4.0;
272const PACKAGED_ARTIFACT_LONGEST_DENSE_SPLIT_SPAN_RATIO: f64 = 8.0;
273
274#[derive(Clone, Copy)]
275pub(crate) struct PackagedArtifactSplitCurvature<'a> {
276    pub(crate) start_coordinates: &'a EclipticCoordinates,
277    pub(crate) quarter_coordinates: Option<&'a EclipticCoordinates>,
278    pub(crate) one_fifth_coordinates: Option<&'a EclipticCoordinates>,
279    pub(crate) one_sixth_coordinates: Option<&'a EclipticCoordinates>,
280    pub(crate) one_seventh_coordinates: Option<&'a EclipticCoordinates>,
281    pub(crate) six_sevenths_coordinates: Option<&'a EclipticCoordinates>,
282    pub(crate) one_ninth_coordinates: Option<&'a EclipticCoordinates>,
283    pub(crate) eight_ninths_coordinates: Option<&'a EclipticCoordinates>,
284    pub(crate) one_eighth_coordinates: Option<&'a EclipticCoordinates>,
285    pub(crate) seven_eighths_coordinates: Option<&'a EclipticCoordinates>,
286    pub(crate) one_third_coordinates: Option<&'a EclipticCoordinates>,
287    pub(crate) midpoint_coordinates: &'a EclipticCoordinates,
288    pub(crate) two_third_coordinates: Option<&'a EclipticCoordinates>,
289    pub(crate) four_fifth_coordinates: Option<&'a EclipticCoordinates>,
290    pub(crate) five_sixth_coordinates: Option<&'a EclipticCoordinates>,
291    pub(crate) three_quarter_coordinates: Option<&'a EclipticCoordinates>,
292    pub(crate) end_coordinates: &'a EclipticCoordinates,
293}
294
295fn packaged_artifact_coordinate_step_delta(
296    lhs: &EclipticCoordinates,
297    rhs: &EclipticCoordinates,
298) -> f64 {
299    let longitude_delta = Angle::from_degrees(rhs.longitude.degrees() - lhs.longitude.degrees())
300        .normalized_signed()
301        .degrees()
302        .abs();
303    let latitude_delta = (rhs.latitude.degrees() - lhs.latitude.degrees()).abs();
304    let distance_delta = match (lhs.distance_au, rhs.distance_au) {
305        (Some(lhs_distance), Some(rhs_distance)) => (rhs_distance - lhs_distance).abs(),
306        _ => 0.0,
307    };
308
309    longitude_delta.max(latitude_delta).max(distance_delta)
310}
311
312fn packaged_artifact_segment_transition_curvature(
313    start: &EclipticCoordinates,
314    middle: &EclipticCoordinates,
315    end: &EclipticCoordinates,
316) -> f64 {
317    packaged_artifact_coordinate_step_delta(start, middle)
318        .max(packaged_artifact_coordinate_step_delta(middle, end))
319}
320
321pub(crate) fn packaged_artifact_split_fraction_for_interval(
322    body: &CelestialBody,
323    span_days: f64,
324    span_limit: f64,
325    curvature: PackagedArtifactSplitCurvature<'_>,
326) -> f64 {
327    if !packaged_artifact_body_cadence(body).uses_dense_sampling() || span_days <= span_limit * 2.0
328    {
329        return 0.5;
330    }
331
332    let (Some(quarter_coordinates), Some(three_quarter_coordinates)) = (
333        curvature.quarter_coordinates,
334        curvature.three_quarter_coordinates,
335    ) else {
336        return 0.5;
337    };
338
339    let left_curvature = packaged_artifact_segment_transition_curvature(
340        curvature.start_coordinates,
341        quarter_coordinates,
342        curvature.midpoint_coordinates,
343    );
344    let right_curvature = packaged_artifact_segment_transition_curvature(
345        curvature.midpoint_coordinates,
346        three_quarter_coordinates,
347        curvature.end_coordinates,
348    );
349
350    if span_days > span_limit * PACKAGED_ARTIFACT_EXTREME_SPLIT_MIN_SPAN_RATIO {
351        if left_curvature > right_curvature * PACKAGED_ARTIFACT_EXTREME_SPLIT_RATIO {
352            return PACKAGED_ARTIFACT_LEFT_EXTREME_SPLIT_FRACTION;
353        }
354        if right_curvature > left_curvature * PACKAGED_ARTIFACT_EXTREME_SPLIT_RATIO {
355            return PACKAGED_ARTIFACT_RIGHT_EXTREME_SPLIT_FRACTION;
356        }
357        if left_curvature > right_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO {
358            return PACKAGED_ARTIFACT_LEFT_BIASED_SPLIT_FRACTION;
359        }
360        if right_curvature > left_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO {
361            return PACKAGED_ARTIFACT_RIGHT_BIASED_SPLIT_FRACTION;
362        }
363    } else {
364        if left_curvature > right_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO {
365            return PACKAGED_ARTIFACT_LEFT_BIASED_SPLIT_FRACTION;
366        }
367        if right_curvature > left_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO {
368            return PACKAGED_ARTIFACT_RIGHT_BIASED_SPLIT_FRACTION;
369        }
370    }
371
372    if span_days > span_limit * PACKAGED_ARTIFACT_VERY_LONG_DENSE_SPLIT_SPAN_RATIO {
373        if let (Some(one_sixth_coordinates), Some(five_sixth_coordinates)) = (
374            curvature.one_sixth_coordinates,
375            curvature.five_sixth_coordinates,
376        ) {
377            let left_sixth_curvature = packaged_artifact_segment_transition_curvature(
378                curvature.start_coordinates,
379                one_sixth_coordinates,
380                curvature.midpoint_coordinates,
381            );
382            let right_sixth_curvature = packaged_artifact_segment_transition_curvature(
383                curvature.midpoint_coordinates,
384                five_sixth_coordinates,
385                curvature.end_coordinates,
386            );
387
388            if left_sixth_curvature > right_sixth_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO
389            {
390                return PACKAGED_ARTIFACT_ONE_SIXTH_SPLIT_FRACTION;
391            }
392            if right_sixth_curvature > left_sixth_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO
393            {
394                return 5.0 / 6.0;
395            }
396        }
397    }
398
399    let (Some(one_third_coordinates), Some(two_third_coordinates)) = (
400        curvature.one_third_coordinates,
401        curvature.two_third_coordinates,
402    ) else {
403        return 0.5;
404    };
405
406    let left_third_curvature = packaged_artifact_segment_transition_curvature(
407        curvature.start_coordinates,
408        one_third_coordinates,
409        curvature.midpoint_coordinates,
410    );
411    let right_third_curvature = packaged_artifact_segment_transition_curvature(
412        curvature.midpoint_coordinates,
413        two_third_coordinates,
414        curvature.end_coordinates,
415    );
416
417    if left_third_curvature > right_third_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO {
418        return PACKAGED_ARTIFACT_ONE_THIRD_SPLIT_FRACTION;
419    }
420    if right_third_curvature > left_third_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO {
421        return PACKAGED_ARTIFACT_TWO_THIRD_SPLIT_FRACTION;
422    }
423
424    if span_days > span_limit * PACKAGED_ARTIFACT_SUPER_EXTREME_DENSE_SPLIT_SPAN_RATIO {
425        if let (Some(one_ninth_coordinates), Some(eight_ninths_coordinates)) = (
426            curvature.one_ninth_coordinates,
427            curvature.eight_ninths_coordinates,
428        ) {
429            let left_ninth_curvature = packaged_artifact_segment_transition_curvature(
430                curvature.start_coordinates,
431                one_ninth_coordinates,
432                curvature.midpoint_coordinates,
433            );
434            let right_ninth_curvature = packaged_artifact_segment_transition_curvature(
435                curvature.midpoint_coordinates,
436                eight_ninths_coordinates,
437                curvature.end_coordinates,
438            );
439
440            if left_ninth_curvature > right_ninth_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO
441            {
442                return PACKAGED_ARTIFACT_ONE_NINTH_SPLIT_FRACTION;
443            }
444            if right_ninth_curvature > left_ninth_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO
445            {
446                return PACKAGED_ARTIFACT_EIGHT_NINTHS_SPLIT_FRACTION;
447            }
448        }
449
450        if let (Some(one_eighth_coordinates), Some(seven_eighths_coordinates)) = (
451            curvature.one_eighth_coordinates,
452            curvature.seven_eighths_coordinates,
453        ) {
454            let left_eighth_curvature = packaged_artifact_segment_transition_curvature(
455                curvature.start_coordinates,
456                one_eighth_coordinates,
457                curvature.midpoint_coordinates,
458            );
459            let right_eighth_curvature = packaged_artifact_segment_transition_curvature(
460                curvature.midpoint_coordinates,
461                seven_eighths_coordinates,
462                curvature.end_coordinates,
463            );
464
465            if left_eighth_curvature
466                > right_eighth_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO
467            {
468                return PACKAGED_ARTIFACT_ONE_EIGHTH_SPLIT_FRACTION;
469            }
470            if right_eighth_curvature
471                > left_eighth_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO
472            {
473                return PACKAGED_ARTIFACT_SEVEN_EIGHTHS_SPLIT_FRACTION;
474            }
475        }
476    }
477
478    if span_days > span_limit * PACKAGED_ARTIFACT_EXTREME_DENSE_SPLIT_SPAN_RATIO {
479        if let (Some(one_seventh_coordinates), Some(six_sevenths_coordinates)) = (
480            curvature.one_seventh_coordinates,
481            curvature.six_sevenths_coordinates,
482        ) {
483            let left_seventh_curvature = packaged_artifact_segment_transition_curvature(
484                curvature.start_coordinates,
485                one_seventh_coordinates,
486                curvature.midpoint_coordinates,
487            );
488            let right_seventh_curvature = packaged_artifact_segment_transition_curvature(
489                curvature.midpoint_coordinates,
490                six_sevenths_coordinates,
491                curvature.end_coordinates,
492            );
493
494            if left_seventh_curvature
495                > right_seventh_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO
496            {
497                return PACKAGED_ARTIFACT_ONE_SEVENTH_SPLIT_FRACTION;
498            }
499            if right_seventh_curvature
500                > left_seventh_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO
501            {
502                return PACKAGED_ARTIFACT_SIX_SEVENTHS_SPLIT_FRACTION;
503            }
504        }
505    }
506
507    if span_days > span_limit * PACKAGED_ARTIFACT_LONGEST_DENSE_SPLIT_SPAN_RATIO {
508        if let (Some(one_fifth_coordinates), Some(four_fifth_coordinates)) = (
509            curvature.one_fifth_coordinates,
510            curvature.four_fifth_coordinates,
511        ) {
512            let left_fifth_curvature = packaged_artifact_segment_transition_curvature(
513                curvature.start_coordinates,
514                one_fifth_coordinates,
515                curvature.midpoint_coordinates,
516            );
517            let right_fifth_curvature = packaged_artifact_segment_transition_curvature(
518                curvature.midpoint_coordinates,
519                four_fifth_coordinates,
520                curvature.end_coordinates,
521            );
522
523            if left_fifth_curvature > right_fifth_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO
524            {
525                return PACKAGED_ARTIFACT_ONE_FIFTH_SPLIT_FRACTION;
526            }
527            if right_fifth_curvature > left_fifth_curvature * PACKAGED_ARTIFACT_SPLIT_BALANCE_RATIO
528            {
529                return PACKAGED_ARTIFACT_FOUR_FIFTHS_SPLIT_FRACTION;
530            }
531        }
532    }
533
534    0.5
535}
536
537#[derive(Clone, Copy, Debug)]
538pub(crate) struct PackagedArtifactSegmentFitError {
539    pub(crate) longitude_degrees: f64,
540    pub(crate) latitude_degrees: f64,
541    pub(crate) distance_au: f64,
542}
543
544impl PackagedArtifactSegmentFitError {
545    pub(crate) fn max_delta(self) -> f64 {
546        self.longitude_degrees
547            .max(self.latitude_degrees)
548            .max(self.distance_au)
549    }
550}
551
552#[derive(Clone, Copy, Debug)]
553pub(crate) struct PackagedArtifactFitCandidateScore {
554    pub(crate) sample_count: usize,
555    pub(crate) complexity: usize,
556    pub(crate) error: PackagedArtifactSegmentFitError,
557}
558
559impl PackagedArtifactFitCandidateScore {
560    pub(crate) fn max_delta(self) -> f64 {
561        self.error.max_delta()
562    }
563}
564
565pub(crate) fn segment_fit_candidate_is_better(
566    existing: PackagedArtifactFitCandidateScore,
567    candidate: PackagedArtifactFitCandidateScore,
568) -> bool {
569    match candidate.max_delta().total_cmp(&existing.max_delta()) {
570        Ordering::Less => true,
571        Ordering::Greater => false,
572        Ordering::Equal => match candidate.sample_count.cmp(&existing.sample_count) {
573            Ordering::Less => true,
574            Ordering::Greater => false,
575            Ordering::Equal => candidate.complexity < existing.complexity,
576        },
577    }
578}
579
580// Keep candidate-versus-fallback selection accuracy-first: only prefer a candidate
581// when its measured fit is no worse than the fallback reconstruction.
582const PACKAGED_ARTIFACT_SEGMENT_FIT_ACCEPTANCE_RATIO: f64 = 1.0;
583
584fn segment_complexity(segment: &Segment) -> usize {
585    segment
586        .channels
587        .iter()
588        .chain(segment.residual_channels.iter())
589        .map(|channel| channel.coefficients.len())
590        .sum()
591}
592
593pub(crate) fn segment_error_prefers_candidate(
594    candidate_segment: &Segment,
595    candidate_error: Option<PackagedArtifactSegmentFitError>,
596    fallback_segment: &Segment,
597    fallback_error: Option<PackagedArtifactSegmentFitError>,
598) -> bool {
599    candidate_error.is_some()
600        && match (candidate_error, fallback_error) {
601            (Some(candidate_error), Some(fallback_error)) => match candidate_error
602                .max_delta()
603                .total_cmp(&fallback_error.max_delta())
604            {
605                Ordering::Less => true,
606                Ordering::Equal => {
607                    segment_complexity(candidate_segment) <= segment_complexity(fallback_segment)
608                }
609                Ordering::Greater => {
610                    candidate_error.max_delta()
611                        <= fallback_error.max_delta()
612                            * PACKAGED_ARTIFACT_SEGMENT_FIT_ACCEPTANCE_RATIO
613                }
614            },
615            (Some(_), None) => true,
616            (None, _) => false,
617        }
618}
619
620pub(crate) fn packaged_artifact_segment_validation_fractions_for_body(
621    body: &CelestialBody,
622) -> &'static [f64] {
623    if packaged_artifact_body_cadence(body).uses_dense_validation_sampling() {
624        PACKAGED_ARTIFACT_DENSE_VALIDATION_SAMPLE_FRACTIONS
625    } else {
626        PACKAGED_ARTIFACT_MEDIUM_VALIDATION_SAMPLE_FRACTIONS
627    }
628}
629
630fn packaged_artifact_segment_fit_error(
631    body: &CelestialBody,
632    segment: &Segment,
633    reference_backend: &JplSnapshotBackend,
634) -> Option<PackagedArtifactSegmentFitError> {
635    let artifact = CompressedArtifact::new(
636        ArtifactHeader::new(ARTIFACT_LABEL, packaged_artifact_source_text()),
637        vec![BodyArtifact::new(body.clone(), vec![segment.clone()])],
638    );
639    let span_days = segment.end.julian_day.days() - segment.start.julian_day.days();
640    let mut saw_sample = false;
641    let mut longitude_degrees: f64 = 0.0;
642    let mut latitude_degrees: f64 = 0.0;
643    let mut distance_au: f64 = 0.0;
644
645    for fraction in packaged_artifact_segment_validation_fractions_for_body(body) {
646        let sample_jd = segment.start.julian_day.days() + span_days * fraction;
647        let request = EphemerisRequest {
648            body: body.clone(),
649            instant: Instant::new(JulianDay::from_days(sample_jd), TimeScale::Tt),
650            observer: None,
651            frame: CoordinateFrame::Ecliptic,
652            zodiac_mode: ZodiacMode::Tropical,
653            apparent: Apparentness::Mean,
654        };
655
656        let expected = reference_backend.position(&request).ok()?.ecliptic?;
657        let actual = artifact.lookup_ecliptic(body, request.instant).ok()?;
658        longitude_degrees = longitude_degrees.max(
659            Angle::from_degrees(actual.longitude.degrees() - expected.longitude.degrees())
660                .normalized_signed()
661                .degrees()
662                .abs(),
663        );
664        latitude_degrees =
665            latitude_degrees.max((actual.latitude.degrees() - expected.latitude.degrees()).abs());
666        distance_au = distance_au.max((actual.distance_au? - expected.distance_au?).abs());
667        saw_sample = true;
668    }
669
670    saw_sample.then_some(PackagedArtifactSegmentFitError {
671        longitude_degrees,
672        latitude_degrees,
673        distance_au,
674    })
675}
676
677fn packaged_artifact_body_class_span_cap_entries() -> Vec<(&'static str, f64)> {
678    vec![
679        ("luminaries", body_segment_span_limit(&CelestialBody::Sun)),
680        (
681            "inner planets",
682            body_segment_span_limit(&CelestialBody::Mercury),
683        ),
684        (
685            "outer planets",
686            body_segment_span_limit(&CelestialBody::Jupiter),
687        ),
688        ("pluto", body_segment_span_limit(&CelestialBody::Pluto)),
689        (
690            "lunar points",
691            body_segment_span_limit(&CelestialBody::MeanNode),
692        ),
693        (
694            "selected asteroids",
695            body_segment_span_limit(&CelestialBody::Ceres),
696        ),
697        (
698            "custom bodies",
699            body_segment_span_limit(&CelestialBody::Custom(CustomBodyId::new(
700                "catalog",
701                "designation",
702            ))),
703        ),
704    ]
705}
706
707/// Structured summary for the packaged-artifact body-class span caps.
708#[derive(Clone, Debug, PartialEq)]
709pub struct PackagedArtifactBodyClassSpanCapSummary {
710    /// Body-class span cap entries in release-facing order.
711    pub entries: Vec<(&'static str, f64)>,
712}
713
714/// Validation error for a packaged-artifact body-class span cap summary that drifted from the current posture.
715#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
716pub enum PackagedArtifactBodyClassSpanCapSummaryValidationError {
717    /// A summary field is out of sync with the current packaged-artifact posture.
718    FieldOutOfSync { field: &'static str },
719}
720
721impl PackagedArtifactBodyClassSpanCapSummaryValidationError {
722    /// Returns the compact release-facing summary for the validation error.
723    pub fn summary_line(&self) -> String {
724        match self {
725            Self::FieldOutOfSync { field } => format!(
726                "the packaged artifact body-class span cap summary field `{field}` is out of sync with the current posture"
727            ),
728        }
729    }
730}
731
732impl fmt::Display for PackagedArtifactBodyClassSpanCapSummaryValidationError {
733    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
734        f.write_str(&self.summary_line())
735    }
736}
737
738impl std::error::Error for PackagedArtifactBodyClassSpanCapSummaryValidationError {}
739
740impl PackagedArtifactBodyClassSpanCapSummary {
741    /// Returns the body-class span cap summary as a compact human-readable line.
742    pub fn summary_line(&self) -> String {
743        format!("body-class span caps: {}", self.entries_summary_line())
744    }
745
746    fn entries_summary_line(&self) -> String {
747        let entries = self
748            .entries
749            .iter()
750            .map(|(label, days)| format!("{label}={days:.0} days"))
751            .collect::<Vec<_>>();
752
753        join_display(&entries)
754    }
755
756    /// Returns the validated body-class span cap summary as a compact human-readable line.
757    pub fn validated_summary_line(
758        &self,
759    ) -> Result<String, PackagedArtifactBodyClassSpanCapSummaryValidationError> {
760        self.validate()?;
761        Ok(self.summary_line())
762    }
763
764    /// Returns `Ok(())` when the summary still matches the current packaged-artifact posture.
765    pub fn validate(&self) -> Result<(), PackagedArtifactBodyClassSpanCapSummaryValidationError> {
766        if self.entries != packaged_artifact_body_class_span_cap_entries() {
767            return Err(
768                PackagedArtifactBodyClassSpanCapSummaryValidationError::FieldOutOfSync {
769                    field: "entries",
770                },
771            );
772        }
773
774        Ok(())
775    }
776}
777
778impl fmt::Display for PackagedArtifactBodyClassSpanCapSummary {
779    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
780        f.write_str(&self.summary_line())
781    }
782}
783
784/// Returns the current packaged-artifact body-class span caps summary record.
785pub fn packaged_artifact_body_class_span_cap_summary_details(
786) -> PackagedArtifactBodyClassSpanCapSummary {
787    let summary = PackagedArtifactBodyClassSpanCapSummary {
788        entries: packaged_artifact_body_class_span_cap_entries(),
789    };
790    debug_assert!(summary.validate().is_ok());
791    summary
792}
793
794fn packaged_artifact_body_cadence_counts() -> [(&'static str, usize); 7] {
795    let mut counts = [0usize; 7];
796
797    for body in packaged_bodies() {
798        match packaged_artifact_body_cadence(body) {
799            PackagedArtifactBodyCadence::Luminaries => counts[0] += 1,
800            PackagedArtifactBodyCadence::InnerPlanets => counts[1] += 1,
801            PackagedArtifactBodyCadence::OuterPlanets => counts[2] += 1,
802            PackagedArtifactBodyCadence::Pluto => counts[3] += 1,
803            PackagedArtifactBodyCadence::LunarPoints => counts[4] += 1,
804            PackagedArtifactBodyCadence::SelectedAsteroids => counts[5] += 1,
805            PackagedArtifactBodyCadence::CustomBodies => counts[6] += 1,
806        }
807    }
808
809    [
810        ("luminaries", counts[0]),
811        ("inner planets", counts[1]),
812        ("outer planets", counts[2]),
813        ("pluto", counts[3]),
814        ("lunar points", counts[4]),
815        ("selected asteroids", counts[5]),
816        ("custom bodies", counts[6]),
817    ]
818}
819
820/// Structured summary for the packaged-artifact body cadence.
821#[derive(Clone, Debug, PartialEq, Eq)]
822pub struct PackagedArtifactBodyCadenceSummary {
823    /// Body-cadence entries in release-facing order.
824    pub entries: Vec<(&'static str, usize)>,
825}
826
827/// Validation error for a packaged-artifact body cadence summary that drifted from the current posture.
828#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
829pub enum PackagedArtifactBodyCadenceSummaryValidationError {
830    /// A summary field is out of sync with the current packaged-artifact posture.
831    FieldOutOfSync { field: &'static str },
832}
833
834impl PackagedArtifactBodyCadenceSummaryValidationError {
835    /// Returns the compact release-facing summary for the validation error.
836    pub fn summary_line(&self) -> String {
837        match self {
838            Self::FieldOutOfSync { field } => format!(
839                "the packaged artifact body cadence summary field `{field}` is out of sync with the current posture"
840            ),
841        }
842    }
843}
844
845impl fmt::Display for PackagedArtifactBodyCadenceSummaryValidationError {
846    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
847        f.write_str(&self.summary_line())
848    }
849}
850
851impl std::error::Error for PackagedArtifactBodyCadenceSummaryValidationError {}
852
853impl PackagedArtifactBodyCadenceSummary {
854    /// Returns the body cadence summary as a compact human-readable line.
855    pub fn summary_line(&self) -> String {
856        let entries = self
857            .entries
858            .iter()
859            .map(|(label, count)| {
860                format!(
861                    "{label}={count} {}",
862                    if *count == 1 { "body" } else { "bodies" }
863                )
864            })
865            .collect::<Vec<_>>();
866
867        format!("body cadence: {}", join_display(&entries))
868    }
869
870    /// Returns `Ok(())` when the summary still matches the current packaged-artifact posture.
871    pub fn validate(&self) -> Result<(), PackagedArtifactBodyCadenceSummaryValidationError> {
872        if self.entries != packaged_artifact_body_cadence_counts().to_vec() {
873            return Err(
874                PackagedArtifactBodyCadenceSummaryValidationError::FieldOutOfSync {
875                    field: "entries",
876                },
877            );
878        }
879
880        Ok(())
881    }
882
883    /// Returns the summary line after validating the structured posture.
884    pub fn validated_summary_line(
885        &self,
886    ) -> Result<String, PackagedArtifactBodyCadenceSummaryValidationError> {
887        self.validate()?;
888        Ok(self.summary_line())
889    }
890}
891
892impl fmt::Display for PackagedArtifactBodyCadenceSummary {
893    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
894        f.write_str(&self.summary_line())
895    }
896}
897
898/// Returns the current packaged-artifact body cadence summary record.
899pub fn packaged_artifact_body_cadence_summary_details() -> PackagedArtifactBodyCadenceSummary {
900    let summary = PackagedArtifactBodyCadenceSummary {
901        entries: packaged_artifact_body_cadence_counts().to_vec(),
902    };
903    debug_assert!(summary.validate().is_ok());
904    summary
905}
906
907fn body_segment_windows_for_interval(
908    start: &SnapshotEntry,
909    end: &SnapshotEntry,
910    reference_backend: &JplSnapshotBackend,
911) -> Vec<Segment> {
912    let span_days = end.epoch.julian_day.days() - start.epoch.julian_day.days();
913    let span_limit = body_segment_span_limit(&start.body);
914    let start_coordinates = coordinates(start);
915    let end_coordinates = coordinates(end);
916    let start_longitude = start_coordinates.longitude.degrees();
917    let end_longitude =
918        unwrap_longitude_degrees(start_longitude, end_coordinates.longitude.degrees());
919    let start_instant = Instant::new(start.epoch.julian_day, TimeScale::Tt);
920    let end_instant = Instant::new(end.epoch.julian_day, TimeScale::Tt);
921    let sample_fraction = |fraction: f64| -> Option<EclipticCoordinates> {
922        let sample_jd = start.epoch.julian_day.days()
923            + (end.epoch.julian_day.days() - start.epoch.julian_day.days()) * fraction;
924        let request = EphemerisRequest {
925            body: start.body.clone(),
926            instant: Instant::new(JulianDay::from_days(sample_jd), TimeScale::Tt),
927            observer: None,
928            frame: CoordinateFrame::Ecliptic,
929            zodiac_mode: ZodiacMode::Tropical,
930            apparent: Apparentness::Mean,
931        };
932
933        reference_backend
934            .position(&request)
935            .ok()
936            .and_then(|result| result.ecliptic)
937    };
938    let finalize =
939        |segment| segment_with_optional_residual_channels(&start.body, segment, reference_backend);
940    let candidate = segment_from_pair(start, end, reference_backend);
941    let candidate_error = if segment_fits_quantization(&candidate) {
942        packaged_artifact_segment_fit_error(&start.body, &candidate, reference_backend)
943    } else {
944        None
945    };
946
947    if span_days <= 1.0 {
948        let fallback = segment_from_pair_fallback(
949            start_instant,
950            end_instant,
951            start_longitude,
952            end_longitude,
953            &start_coordinates,
954            &end_coordinates,
955            Some(span_days),
956            Some(span_limit),
957            &sample_fraction,
958        );
959        let fallback_error = if segment_fits_quantization(&fallback) {
960            packaged_artifact_segment_fit_error(&start.body, &fallback, reference_backend)
961        } else {
962            None
963        };
964
965        return if segment_error_prefers_candidate(
966            &candidate,
967            candidate_error,
968            &fallback,
969            fallback_error,
970        ) {
971            vec![finalize(candidate)]
972        } else {
973            vec![finalize(fallback)]
974        };
975    }
976
977    if span_days <= span_limit {
978        let fallback = segment_from_pair_fallback(
979            start_instant,
980            end_instant,
981            start_longitude,
982            end_longitude,
983            &start_coordinates,
984            &end_coordinates,
985            Some(span_days),
986            Some(span_limit),
987            &sample_fraction,
988        );
989        let fallback_error = if segment_fits_quantization(&fallback) {
990            packaged_artifact_segment_fit_error(&start.body, &fallback, reference_backend)
991        } else {
992            None
993        };
994
995        if segment_error_prefers_candidate(&candidate, candidate_error, &fallback, fallback_error) {
996            return vec![finalize(candidate)];
997        }
998    }
999
1000    let midpoint_jd = (start.epoch.julian_day.days() + end.epoch.julian_day.days()) / 2.0;
1001    if midpoint_jd <= start.epoch.julian_day.days() || midpoint_jd >= end.epoch.julian_day.days() {
1002        return vec![finalize(segment_from_pair_fallback(
1003            start_instant,
1004            end_instant,
1005            start_longitude,
1006            end_longitude,
1007            &start_coordinates,
1008            &end_coordinates,
1009            Some(span_days),
1010            Some(span_limit),
1011            &sample_fraction,
1012        ))];
1013    }
1014    let Some(midpoint_coordinates) = sample_fraction(0.5) else {
1015        return vec![finalize(segment_from_pair_fallback(
1016            start_instant,
1017            end_instant,
1018            start_longitude,
1019            end_longitude,
1020            &start_coordinates,
1021            &end_coordinates,
1022            Some(span_days),
1023            Some(span_limit),
1024            &sample_fraction,
1025        ))];
1026    };
1027    let use_curvature_bias = packaged_artifact_body_cadence(&start.body).uses_dense_sampling()
1028        && span_days > span_limit * 2.0;
1029    let quarter_coordinates = if use_curvature_bias {
1030        sample_fraction(0.25)
1031    } else {
1032        None
1033    };
1034    let one_fifth_coordinates = if use_curvature_bias && span_days > span_limit * 8.0 {
1035        sample_fraction(1.0 / 5.0)
1036    } else {
1037        None
1038    };
1039    let one_sixth_coordinates = if use_curvature_bias {
1040        sample_fraction(1.0 / 6.0)
1041    } else {
1042        None
1043    };
1044    let one_seventh_coordinates = if use_curvature_bias && span_days > span_limit * 16.0 {
1045        sample_fraction(1.0 / 7.0)
1046    } else {
1047        None
1048    };
1049    let six_sevenths_coordinates = if use_curvature_bias && span_days > span_limit * 16.0 {
1050        sample_fraction(6.0 / 7.0)
1051    } else {
1052        None
1053    };
1054    let three_quarter_coordinates = if use_curvature_bias {
1055        sample_fraction(0.75)
1056    } else {
1057        None
1058    };
1059    let five_sixth_coordinates = if use_curvature_bias {
1060        sample_fraction(5.0 / 6.0)
1061    } else {
1062        None
1063    };
1064    let one_ninth_coordinates = if use_curvature_bias && span_days > span_limit * 32.0 {
1065        sample_fraction(1.0 / 9.0)
1066    } else {
1067        None
1068    };
1069    let eight_ninths_coordinates = if use_curvature_bias && span_days > span_limit * 32.0 {
1070        sample_fraction(8.0 / 9.0)
1071    } else {
1072        None
1073    };
1074    let one_eighth_coordinates = if use_curvature_bias && span_days > span_limit * 128.0 {
1075        sample_fraction(1.0 / 8.0)
1076    } else {
1077        None
1078    };
1079    let seven_eighths_coordinates = if use_curvature_bias && span_days > span_limit * 128.0 {
1080        sample_fraction(7.0 / 8.0)
1081    } else {
1082        None
1083    };
1084    let one_third_coordinates = if use_curvature_bias {
1085        sample_fraction(1.0 / 3.0)
1086    } else {
1087        None
1088    };
1089    let two_third_coordinates = if use_curvature_bias {
1090        sample_fraction(2.0 / 3.0)
1091    } else {
1092        None
1093    };
1094    let four_fifth_coordinates = if use_curvature_bias && span_days > span_limit * 8.0 {
1095        sample_fraction(4.0 / 5.0)
1096    } else {
1097        None
1098    };
1099    let split_fraction = packaged_artifact_split_fraction_for_interval(
1100        &start.body,
1101        span_days,
1102        span_limit,
1103        PackagedArtifactSplitCurvature {
1104            start_coordinates: &start_coordinates,
1105            quarter_coordinates: quarter_coordinates.as_ref(),
1106            one_fifth_coordinates: one_fifth_coordinates.as_ref(),
1107            one_sixth_coordinates: one_sixth_coordinates.as_ref(),
1108            one_seventh_coordinates: one_seventh_coordinates.as_ref(),
1109            six_sevenths_coordinates: six_sevenths_coordinates.as_ref(),
1110            one_ninth_coordinates: one_ninth_coordinates.as_ref(),
1111            eight_ninths_coordinates: eight_ninths_coordinates.as_ref(),
1112            one_eighth_coordinates: one_eighth_coordinates.as_ref(),
1113            seven_eighths_coordinates: seven_eighths_coordinates.as_ref(),
1114            one_third_coordinates: one_third_coordinates.as_ref(),
1115            midpoint_coordinates: &midpoint_coordinates,
1116            two_third_coordinates: two_third_coordinates.as_ref(),
1117            four_fifth_coordinates: four_fifth_coordinates.as_ref(),
1118            five_sixth_coordinates: five_sixth_coordinates.as_ref(),
1119            three_quarter_coordinates: three_quarter_coordinates.as_ref(),
1120            end_coordinates: &end_coordinates,
1121        },
1122    );
1123    let split_jd = start.epoch.julian_day.days() + span_days * split_fraction;
1124    if split_jd <= start.epoch.julian_day.days() || split_jd >= end.epoch.julian_day.days() {
1125        return vec![finalize(segment_from_pair_fallback(
1126            start_instant,
1127            end_instant,
1128            start_longitude,
1129            end_longitude,
1130            &start_coordinates,
1131            &end_coordinates,
1132            Some(span_days),
1133            Some(span_limit),
1134            &sample_fraction,
1135        ))];
1136    }
1137    let split_coordinates = if (split_fraction - 0.5).abs() < f64::EPSILON {
1138        midpoint_coordinates
1139    } else {
1140        let Some(split_coordinates) = sample_fraction(split_fraction) else {
1141            return vec![finalize(segment_from_pair_fallback(
1142                start_instant,
1143                end_instant,
1144                start_longitude,
1145                end_longitude,
1146                &start_coordinates,
1147                &end_coordinates,
1148                Some(span_days),
1149                Some(span_limit),
1150                &sample_fraction,
1151            ))];
1152        };
1153        split_coordinates
1154    };
1155
1156    let split_entry =
1157        snapshot_entry_from_ecliptic_coordinates(start.body.clone(), split_jd, split_coordinates);
1158
1159    let mut segments = body_segment_windows_for_interval(start, &split_entry, reference_backend);
1160    segments.extend(body_segment_windows_for_interval(
1161        &split_entry,
1162        end,
1163        reference_backend,
1164    ));
1165    segments.into_iter().map(finalize).collect()
1166}
1167
1168fn segment_fits_quantization(segment: &Segment) -> bool {
1169    segment
1170        .channels
1171        .iter()
1172        .chain(segment.residual_channels.iter())
1173        .all(|channel| {
1174            channel_coefficients_fit_quantization(channel.scale_exponent, &channel.coefficients)
1175        })
1176}
1177
1178pub(crate) fn snapshot_entry_from_ecliptic_coordinates(
1179    body: CelestialBody,
1180    julian_day: f64,
1181    coordinates: EclipticCoordinates,
1182) -> SnapshotEntry {
1183    let radius_km = coordinates.distance_au.unwrap_or_default() * AU_IN_KM;
1184    let longitude_radians = coordinates.longitude.degrees().to_radians();
1185    let latitude_radians = coordinates.latitude.degrees().to_radians();
1186    let cos_latitude = latitude_radians.cos();
1187    SnapshotEntry {
1188        body,
1189        epoch: Instant::new(JulianDay::from_days(julian_day), TimeScale::Tt),
1190        x_km: radius_km * cos_latitude * longitude_radians.cos(),
1191        y_km: radius_km * cos_latitude * longitude_radians.sin(),
1192        z_km: radius_km * latitude_radians.sin(),
1193        vx_km_s: None,
1194        vy_km_s: None,
1195        vz_km_s: None,
1196    }
1197}
1198
1199fn unwrap_longitude_degrees(reference_degrees: f64, candidate_degrees: f64) -> f64 {
1200    reference_degrees
1201        + Angle::from_degrees(candidate_degrees - reference_degrees)
1202            .normalized_signed()
1203            .degrees()
1204}
1205
1206fn segment_from_single_entry(entry: &SnapshotEntry) -> Segment {
1207    let coordinates = coordinates(entry);
1208    Segment::new(
1209        Instant::new(entry.epoch.julian_day, TimeScale::Tt),
1210        Instant::new(entry.epoch.julian_day, TimeScale::Tt),
1211        vec![
1212            PolynomialChannel::linear(
1213                ChannelKind::Longitude,
1214                9,
1215                coordinates.longitude.degrees(),
1216                coordinates.longitude.degrees(),
1217            ),
1218            PolynomialChannel::linear(
1219                ChannelKind::Latitude,
1220                9,
1221                coordinates.latitude.degrees(),
1222                coordinates.latitude.degrees(),
1223            ),
1224            PolynomialChannel::linear(
1225                ChannelKind::DistanceAu,
1226                10,
1227                coordinates.distance_au.unwrap_or_default(),
1228                coordinates.distance_au.unwrap_or_default(),
1229            ),
1230        ],
1231    )
1232}
1233
1234pub(crate) fn segment_from_pair(
1235    start: &SnapshotEntry,
1236    end: &SnapshotEntry,
1237    reference_backend: &JplSnapshotBackend,
1238) -> Segment {
1239    let span_days = end.epoch.julian_day.days() - start.epoch.julian_day.days();
1240    let span_limit = body_segment_span_limit(&start.body);
1241    let start_coordinates = coordinates(start);
1242    let end_coordinates = coordinates(end);
1243    let start_longitude = start_coordinates.longitude.degrees();
1244    let end_longitude =
1245        unwrap_longitude_degrees(start_longitude, end_coordinates.longitude.degrees());
1246    let start_instant = Instant::new(start.epoch.julian_day, TimeScale::Tt);
1247    let end_instant = Instant::new(end.epoch.julian_day, TimeScale::Tt);
1248    let sample_fraction = |fraction: f64| -> Option<EclipticCoordinates> {
1249        let sample_jd = start.epoch.julian_day.days()
1250            + (end.epoch.julian_day.days() - start.epoch.julian_day.days()) * fraction;
1251        let request = EphemerisRequest {
1252            body: start.body.clone(),
1253            instant: Instant::new(JulianDay::from_days(sample_jd), TimeScale::Tt),
1254            observer: None,
1255            frame: CoordinateFrame::Ecliptic,
1256            zodiac_mode: ZodiacMode::Tropical,
1257            apparent: Apparentness::Mean,
1258        };
1259
1260        reference_backend
1261            .position(&request)
1262            .ok()
1263            .and_then(|result| result.ecliptic)
1264    };
1265
1266    let finalize =
1267        |segment| segment_with_optional_residual_channels(&start.body, segment, reference_backend);
1268
1269    let mut best_candidate: Option<(Segment, PackagedArtifactFitCandidateScore)> = None;
1270    for sample_count in packaged_artifact_fit_sample_counts_for_body(&start.body) {
1271        if let Some((segment, error)) = segment_from_pair_fit_attempt(
1272            start_instant,
1273            end_instant,
1274            &start.body,
1275            &start_coordinates,
1276            &end_coordinates,
1277            &sample_fraction,
1278            reference_backend,
1279            *sample_count,
1280        ) {
1281            let score = PackagedArtifactFitCandidateScore {
1282                sample_count: *sample_count,
1283                complexity: segment_complexity(&segment),
1284                error,
1285            };
1286            let should_replace = best_candidate
1287                .as_ref()
1288                .map(|(_, existing_score)| segment_fit_candidate_is_better(*existing_score, score))
1289                .unwrap_or(true);
1290            if should_replace {
1291                best_candidate = Some((segment, score));
1292            }
1293        }
1294    }
1295
1296    let fallback = segment_from_pair_fallback(
1297        start_instant,
1298        end_instant,
1299        start_longitude,
1300        end_longitude,
1301        &start_coordinates,
1302        &end_coordinates,
1303        Some(span_days),
1304        Some(span_limit),
1305        &sample_fraction,
1306    );
1307    let fallback_error = if segment_fits_quantization(&fallback) {
1308        packaged_artifact_segment_fit_error(&start.body, &fallback, reference_backend)
1309    } else {
1310        None
1311    };
1312
1313    if let Some((candidate, score)) = &best_candidate {
1314        if segment_error_prefers_candidate(candidate, Some(score.error), &fallback, fallback_error)
1315        {
1316            return finalize(candidate.clone());
1317        }
1318    }
1319
1320    finalize(fallback)
1321}
1322
1323#[allow(clippy::too_many_arguments)]
1324fn segment_from_pair_fit_attempt<F>(
1325    start_instant: Instant,
1326    end_instant: Instant,
1327    body: &CelestialBody,
1328    start_coordinates: &EclipticCoordinates,
1329    end_coordinates: &EclipticCoordinates,
1330    sample_fraction: &F,
1331    reference_backend: &JplSnapshotBackend,
1332    sample_count: usize,
1333) -> Option<(Segment, PackagedArtifactSegmentFitError)>
1334where
1335    F: Fn(f64) -> Option<EclipticCoordinates>,
1336{
1337    let fit_sample_fractions = chebyshev_lobatto_fractions(sample_count);
1338    let fit_sample_coordinates = fit_sample_fractions
1339        .iter()
1340        .map(|fraction| sample_fraction(*fraction))
1341        .collect::<Option<Vec<_>>>()?;
1342
1343    let longitude_samples = unwrap_longitude_samples(
1344        &fit_sample_coordinates
1345            .iter()
1346            .map(|coordinates| coordinates.longitude.degrees())
1347            .collect::<Vec<_>>(),
1348    );
1349
1350    let fit_samples = fit_sample_fractions
1351        .iter()
1352        .copied()
1353        .zip(fit_sample_coordinates.iter())
1354        .collect::<Vec<_>>();
1355
1356    let longitude_fit_samples = fit_samples
1357        .iter()
1358        .enumerate()
1359        .map(|(index, (fraction, _))| (*fraction, longitude_samples[index]))
1360        .collect::<Vec<_>>();
1361    let latitude_fit_samples = fit_samples
1362        .iter()
1363        .map(|(fraction, coordinates)| (*fraction, coordinates.latitude.degrees()))
1364        .collect::<Vec<_>>();
1365
1366    let (Some(longitude_channel), Some(latitude_channel)) = (
1367        channel_from_fit_samples_with_control_points(
1368            ChannelKind::Longitude,
1369            9,
1370            &longitude_fit_samples,
1371        ),
1372        channel_from_fit_samples_with_control_points(
1373            ChannelKind::Latitude,
1374            9,
1375            &latitude_fit_samples,
1376        ),
1377    ) else {
1378        return None;
1379    };
1380
1381    let midpoint_distance_au = sample_fraction(0.5).and_then(|coordinates| coordinates.distance_au);
1382    let distance_samples = fit_samples
1383        .iter()
1384        .filter_map(|(fraction, coordinates)| {
1385            coordinates
1386                .distance_au
1387                .map(|distance| (*fraction, distance))
1388        })
1389        .collect::<Vec<_>>();
1390    let segment = Segment::new(
1391        start_instant,
1392        end_instant,
1393        vec![
1394            longitude_channel,
1395            latitude_channel,
1396            distance_channel_from_fit_samples(
1397                &distance_samples,
1398                start_coordinates.distance_au.unwrap_or_default(),
1399                midpoint_distance_au,
1400                end_coordinates.distance_au.unwrap_or_default(),
1401            ),
1402        ],
1403    );
1404    let error = packaged_artifact_segment_fit_error(body, &segment, reference_backend)?;
1405    Some((segment, error))
1406}
1407
1408#[allow(clippy::too_many_arguments)]
1409pub(crate) fn segment_from_pair_fallback(
1410    start_instant: Instant,
1411    end_instant: Instant,
1412    start_longitude: f64,
1413    end_longitude: f64,
1414    start_coordinates: &EclipticCoordinates,
1415    end_coordinates: &EclipticCoordinates,
1416    span_days: Option<f64>,
1417    span_limit: Option<f64>,
1418    sample_fraction: &dyn Fn(f64) -> Option<EclipticCoordinates>,
1419) -> Segment {
1420    let Some(midpoint_coordinates) = sample_fraction(0.5) else {
1421        return Segment::new(
1422            start_instant,
1423            end_instant,
1424            vec![
1425                PolynomialChannel::linear(
1426                    ChannelKind::Longitude,
1427                    9,
1428                    start_longitude,
1429                    end_longitude,
1430                ),
1431                PolynomialChannel::linear(
1432                    ChannelKind::Latitude,
1433                    9,
1434                    start_coordinates.latitude.degrees(),
1435                    end_coordinates.latitude.degrees(),
1436                ),
1437                distance_channel_from_samples(
1438                    start_coordinates.distance_au.unwrap_or_default(),
1439                    None,
1440                    end_coordinates.distance_au.unwrap_or_default(),
1441                ),
1442            ],
1443        );
1444    };
1445    let midpoint_distance_au = midpoint_coordinates.distance_au;
1446    let distance_start = start_coordinates.distance_au.unwrap_or_default();
1447    let distance_end = end_coordinates.distance_au.unwrap_or_default();
1448    let midpoint_longitude =
1449        unwrap_longitude_degrees(start_longitude, midpoint_coordinates.longitude.degrees());
1450
1451    let quarter_coordinates = sample_fraction(0.25);
1452    let three_quarter_coordinates = sample_fraction(0.75);
1453    if let (Some(quarter_coordinates), Some(three_quarter_coordinates)) = (
1454        quarter_coordinates.as_ref(),
1455        three_quarter_coordinates.as_ref(),
1456    ) {
1457        if let (
1458            Some(quarter_distance_au),
1459            Some(midpoint_distance_au),
1460            Some(three_quarter_distance_au),
1461        ) = (
1462            quarter_coordinates.distance_au,
1463            midpoint_distance_au,
1464            three_quarter_coordinates.distance_au,
1465        ) {
1466            let longitude_samples = unwrap_longitude_samples(&[
1467                start_longitude,
1468                quarter_coordinates.longitude.degrees(),
1469                midpoint_longitude,
1470                three_quarter_coordinates.longitude.degrees(),
1471                end_longitude,
1472            ]);
1473
1474            if let (Some(longitude_channel), Some(latitude_channel)) = (
1475                channel_from_dense_fit_samples_with_control_points(
1476                    ChannelKind::Longitude,
1477                    9,
1478                    &[
1479                        (0.0, longitude_samples[0]),
1480                        (0.25, longitude_samples[1]),
1481                        (0.5, longitude_samples[2]),
1482                        (0.75, longitude_samples[3]),
1483                        (1.0, longitude_samples[4]),
1484                    ],
1485                ),
1486                channel_from_dense_fit_samples_with_control_points(
1487                    ChannelKind::Latitude,
1488                    9,
1489                    &[
1490                        (0.0, start_coordinates.latitude.degrees()),
1491                        (0.25, quarter_coordinates.latitude.degrees()),
1492                        (0.5, midpoint_coordinates.latitude.degrees()),
1493                        (0.75, three_quarter_coordinates.latitude.degrees()),
1494                        (1.0, end_coordinates.latitude.degrees()),
1495                    ],
1496                ),
1497            ) {
1498                let distance_channel = distance_channel_from_dense_fit_samples(
1499                    &[
1500                        (0.0, distance_start),
1501                        (0.25, quarter_distance_au),
1502                        (0.5, midpoint_distance_au),
1503                        (0.75, three_quarter_distance_au),
1504                        (1.0, distance_end),
1505                    ],
1506                    distance_start,
1507                    Some(midpoint_distance_au),
1508                    distance_end,
1509                );
1510
1511                return Segment::new(
1512                    start_instant,
1513                    end_instant,
1514                    vec![longitude_channel, latitude_channel, distance_channel],
1515                );
1516            }
1517        }
1518    }
1519
1520    if let (Some(span_days), Some(span_limit)) = (span_days, span_limit) {
1521        if span_days > span_limit * PACKAGED_ARTIFACT_LONGEST_DENSE_SPLIT_SPAN_RATIO {
1522            let one_fifth_coordinates = sample_fraction(1.0 / 5.0);
1523            let two_fifth_coordinates = sample_fraction(2.0 / 5.0);
1524            let three_fifth_coordinates = sample_fraction(3.0 / 5.0);
1525            let four_fifth_coordinates = sample_fraction(4.0 / 5.0);
1526            if let (
1527                Some(one_fifth_coordinates),
1528                Some(two_fifth_coordinates),
1529                Some(three_fifth_coordinates),
1530                Some(four_fifth_coordinates),
1531            ) = (
1532                one_fifth_coordinates.as_ref(),
1533                two_fifth_coordinates.as_ref(),
1534                three_fifth_coordinates.as_ref(),
1535                four_fifth_coordinates.as_ref(),
1536            ) {
1537                let longitude_samples = unwrap_longitude_samples(&[
1538                    start_longitude,
1539                    one_fifth_coordinates.longitude.degrees(),
1540                    two_fifth_coordinates.longitude.degrees(),
1541                    three_fifth_coordinates.longitude.degrees(),
1542                    four_fifth_coordinates.longitude.degrees(),
1543                    end_longitude,
1544                ]);
1545
1546                if let (Some(longitude_channel), Some(latitude_channel)) = (
1547                    channel_from_fit_samples_with_control_points(
1548                        ChannelKind::Longitude,
1549                        9,
1550                        &[
1551                            (0.0, longitude_samples[0]),
1552                            (1.0 / 5.0, longitude_samples[1]),
1553                            (2.0 / 5.0, longitude_samples[2]),
1554                            (3.0 / 5.0, longitude_samples[3]),
1555                            (4.0 / 5.0, longitude_samples[4]),
1556                            (1.0, longitude_samples[5]),
1557                        ],
1558                    ),
1559                    channel_from_fit_samples_with_control_points(
1560                        ChannelKind::Latitude,
1561                        9,
1562                        &[
1563                            (0.0, start_coordinates.latitude.degrees()),
1564                            (1.0 / 5.0, one_fifth_coordinates.latitude.degrees()),
1565                            (2.0 / 5.0, two_fifth_coordinates.latitude.degrees()),
1566                            (3.0 / 5.0, three_fifth_coordinates.latitude.degrees()),
1567                            (4.0 / 5.0, four_fifth_coordinates.latitude.degrees()),
1568                            (1.0, end_coordinates.latitude.degrees()),
1569                        ],
1570                    ),
1571                ) {
1572                    if let (
1573                        Some(one_fifth_distance_au),
1574                        Some(two_fifth_distance_au),
1575                        Some(three_fifth_distance_au),
1576                        Some(four_fifth_distance_au),
1577                    ) = (
1578                        one_fifth_coordinates.distance_au,
1579                        two_fifth_coordinates.distance_au,
1580                        three_fifth_coordinates.distance_au,
1581                        four_fifth_coordinates.distance_au,
1582                    ) {
1583                        let distance_channel = distance_channel_from_fit_samples(
1584                            &[
1585                                (0.0, start_coordinates.distance_au.unwrap_or_default()),
1586                                (1.0 / 5.0, one_fifth_distance_au),
1587                                (2.0 / 5.0, two_fifth_distance_au),
1588                                (3.0 / 5.0, three_fifth_distance_au),
1589                                (4.0 / 5.0, four_fifth_distance_au),
1590                                (1.0, end_coordinates.distance_au.unwrap_or_default()),
1591                            ],
1592                            start_coordinates.distance_au.unwrap_or_default(),
1593                            midpoint_coordinates.distance_au,
1594                            end_coordinates.distance_au.unwrap_or_default(),
1595                        );
1596
1597                        return Segment::new(
1598                            start_instant,
1599                            end_instant,
1600                            vec![longitude_channel, latitude_channel, distance_channel],
1601                        );
1602                    }
1603                }
1604            }
1605        }
1606
1607        if span_days > span_limit * PACKAGED_ARTIFACT_SUPER_EXTREME_DENSE_SPLIT_SPAN_RATIO {
1608            let one_seventh_coordinates = sample_fraction(1.0 / 7.0);
1609            let two_seventh_coordinates = sample_fraction(2.0 / 7.0);
1610            let three_seventh_coordinates = sample_fraction(3.0 / 7.0);
1611            let four_seventh_coordinates = sample_fraction(4.0 / 7.0);
1612            let five_seventh_coordinates = sample_fraction(5.0 / 7.0);
1613            let six_seventh_coordinates = sample_fraction(6.0 / 7.0);
1614            if let (
1615                Some(one_seventh_coordinates),
1616                Some(two_seventh_coordinates),
1617                Some(three_seventh_coordinates),
1618                Some(four_seventh_coordinates),
1619                Some(five_seventh_coordinates),
1620                Some(six_seventh_coordinates),
1621            ) = (
1622                one_seventh_coordinates.as_ref(),
1623                two_seventh_coordinates.as_ref(),
1624                three_seventh_coordinates.as_ref(),
1625                four_seventh_coordinates.as_ref(),
1626                five_seventh_coordinates.as_ref(),
1627                six_seventh_coordinates.as_ref(),
1628            ) {
1629                let longitude_samples = unwrap_longitude_samples(&[
1630                    start_longitude,
1631                    one_seventh_coordinates.longitude.degrees(),
1632                    two_seventh_coordinates.longitude.degrees(),
1633                    three_seventh_coordinates.longitude.degrees(),
1634                    four_seventh_coordinates.longitude.degrees(),
1635                    five_seventh_coordinates.longitude.degrees(),
1636                    six_seventh_coordinates.longitude.degrees(),
1637                    end_longitude,
1638                ]);
1639
1640                if let (Some(longitude_channel), Some(latitude_channel)) = (
1641                    channel_from_dense_fit_samples_with_control_points(
1642                        ChannelKind::Longitude,
1643                        9,
1644                        &[
1645                            (0.0, longitude_samples[0]),
1646                            (1.0 / 7.0, longitude_samples[1]),
1647                            (2.0 / 7.0, longitude_samples[2]),
1648                            (3.0 / 7.0, longitude_samples[3]),
1649                            (4.0 / 7.0, longitude_samples[4]),
1650                            (5.0 / 7.0, longitude_samples[5]),
1651                            (6.0 / 7.0, longitude_samples[6]),
1652                            (1.0, longitude_samples[7]),
1653                        ],
1654                    ),
1655                    channel_from_dense_fit_samples_with_control_points(
1656                        ChannelKind::Latitude,
1657                        9,
1658                        &[
1659                            (0.0, start_coordinates.latitude.degrees()),
1660                            (1.0 / 7.0, one_seventh_coordinates.latitude.degrees()),
1661                            (2.0 / 7.0, two_seventh_coordinates.latitude.degrees()),
1662                            (3.0 / 7.0, three_seventh_coordinates.latitude.degrees()),
1663                            (4.0 / 7.0, four_seventh_coordinates.latitude.degrees()),
1664                            (5.0 / 7.0, five_seventh_coordinates.latitude.degrees()),
1665                            (6.0 / 7.0, six_seventh_coordinates.latitude.degrees()),
1666                            (1.0, end_coordinates.latitude.degrees()),
1667                        ],
1668                    ),
1669                ) {
1670                    if let (
1671                        Some(one_seventh_distance_au),
1672                        Some(two_seventh_distance_au),
1673                        Some(three_seventh_distance_au),
1674                        Some(four_seventh_distance_au),
1675                        Some(five_seventh_distance_au),
1676                        Some(six_seventh_distance_au),
1677                    ) = (
1678                        one_seventh_coordinates.distance_au,
1679                        two_seventh_coordinates.distance_au,
1680                        three_seventh_coordinates.distance_au,
1681                        four_seventh_coordinates.distance_au,
1682                        five_seventh_coordinates.distance_au,
1683                        six_seventh_coordinates.distance_au,
1684                    ) {
1685                        let distance_channel = distance_channel_from_dense_fit_samples(
1686                            &[
1687                                (0.0, start_coordinates.distance_au.unwrap_or_default()),
1688                                (1.0 / 7.0, one_seventh_distance_au),
1689                                (2.0 / 7.0, two_seventh_distance_au),
1690                                (3.0 / 7.0, three_seventh_distance_au),
1691                                (4.0 / 7.0, four_seventh_distance_au),
1692                                (5.0 / 7.0, five_seventh_distance_au),
1693                                (6.0 / 7.0, six_seventh_distance_au),
1694                                (1.0, end_coordinates.distance_au.unwrap_or_default()),
1695                            ],
1696                            start_coordinates.distance_au.unwrap_or_default(),
1697                            midpoint_coordinates.distance_au,
1698                            end_coordinates.distance_au.unwrap_or_default(),
1699                        );
1700
1701                        return Segment::new(
1702                            start_instant,
1703                            end_instant,
1704                            vec![longitude_channel, latitude_channel, distance_channel],
1705                        );
1706                    }
1707                }
1708            }
1709        }
1710    }
1711
1712    let Some(first_third_coordinates) = sample_fraction(1.0 / 3.0) else {
1713        let midpoint_longitude =
1714            unwrap_longitude_degrees(start_longitude, midpoint_coordinates.longitude.degrees());
1715        return Segment::new(
1716            start_instant,
1717            end_instant,
1718            vec![
1719                PolynomialChannel::quadratic(
1720                    ChannelKind::Longitude,
1721                    9,
1722                    start_longitude,
1723                    midpoint_longitude,
1724                    end_longitude,
1725                    0.5,
1726                ),
1727                PolynomialChannel::quadratic(
1728                    ChannelKind::Latitude,
1729                    9,
1730                    start_coordinates.latitude.degrees(),
1731                    midpoint_coordinates.latitude.degrees(),
1732                    end_coordinates.latitude.degrees(),
1733                    0.5,
1734                ),
1735                distance_channel_from_samples(
1736                    distance_start,
1737                    midpoint_coordinates.distance_au,
1738                    distance_end,
1739                ),
1740            ],
1741        );
1742    };
1743
1744    let Some(second_third_coordinates) = sample_fraction(2.0 / 3.0) else {
1745        let midpoint_longitude =
1746            unwrap_longitude_degrees(start_longitude, midpoint_coordinates.longitude.degrees());
1747        return Segment::new(
1748            start_instant,
1749            end_instant,
1750            vec![
1751                PolynomialChannel::quadratic(
1752                    ChannelKind::Longitude,
1753                    9,
1754                    start_longitude,
1755                    midpoint_longitude,
1756                    end_longitude,
1757                    0.5,
1758                ),
1759                PolynomialChannel::quadratic(
1760                    ChannelKind::Latitude,
1761                    9,
1762                    start_coordinates.latitude.degrees(),
1763                    midpoint_coordinates.latitude.degrees(),
1764                    end_coordinates.latitude.degrees(),
1765                    0.5,
1766                ),
1767                distance_channel_from_samples(
1768                    distance_start,
1769                    midpoint_coordinates.distance_au,
1770                    distance_end,
1771                ),
1772            ],
1773        );
1774    };
1775
1776    let longitude_samples = unwrap_longitude_samples(&[
1777        start_longitude,
1778        first_third_coordinates.longitude.degrees(),
1779        second_third_coordinates.longitude.degrees(),
1780        end_longitude,
1781    ]);
1782
1783    if let (Some(longitude_channel), Some(latitude_channel)) = (
1784        polynomial_channel_from_samples(
1785            ChannelKind::Longitude,
1786            9,
1787            &[
1788                (0.0, longitude_samples[0]),
1789                (1.0 / 3.0, longitude_samples[1]),
1790                (2.0 / 3.0, longitude_samples[2]),
1791                (1.0, longitude_samples[3]),
1792            ],
1793        ),
1794        polynomial_channel_from_samples(
1795            ChannelKind::Latitude,
1796            9,
1797            &[
1798                (0.0, start_coordinates.latitude.degrees()),
1799                (1.0 / 3.0, first_third_coordinates.latitude.degrees()),
1800                (2.0 / 3.0, second_third_coordinates.latitude.degrees()),
1801                (1.0, end_coordinates.latitude.degrees()),
1802            ],
1803        ),
1804    ) {
1805        let distance_channel = if let (Some(first_third_distance), Some(second_third_distance)) = (
1806            first_third_coordinates.distance_au,
1807            second_third_coordinates.distance_au,
1808        ) {
1809            distance_channel_from_four_point_control_points(
1810                distance_start,
1811                first_third_distance,
1812                second_third_distance,
1813                distance_end,
1814            )
1815            .unwrap_or_else(|| {
1816                distance_channel_from_samples(
1817                    distance_start,
1818                    midpoint_coordinates.distance_au,
1819                    distance_end,
1820                )
1821            })
1822        } else {
1823            distance_channel_from_samples(
1824                distance_start,
1825                midpoint_coordinates.distance_au,
1826                distance_end,
1827            )
1828        };
1829
1830        return Segment::new(
1831            start_instant,
1832            end_instant,
1833            vec![longitude_channel, latitude_channel, distance_channel],
1834        );
1835    }
1836
1837    let midpoint_longitude =
1838        unwrap_longitude_degrees(start_longitude, midpoint_coordinates.longitude.degrees());
1839
1840    Segment::new(
1841        start_instant,
1842        end_instant,
1843        vec![
1844            PolynomialChannel::quadratic(
1845                ChannelKind::Longitude,
1846                9,
1847                start_longitude,
1848                midpoint_longitude,
1849                end_longitude,
1850                0.5,
1851            ),
1852            PolynomialChannel::quadratic(
1853                ChannelKind::Latitude,
1854                9,
1855                start_coordinates.latitude.degrees(),
1856                midpoint_coordinates.latitude.degrees(),
1857                end_coordinates.latitude.degrees(),
1858                0.5,
1859            ),
1860            distance_channel_from_samples(
1861                start_coordinates.distance_au.unwrap_or_default(),
1862                midpoint_distance_au,
1863                end_coordinates.distance_au.unwrap_or_default(),
1864            ),
1865        ],
1866    )
1867}
1868
1869fn segment_with_optional_residual_channels(
1870    body: &CelestialBody,
1871    segment: Segment,
1872    reference_backend: &JplSnapshotBackend,
1873) -> Segment {
1874    let Some(base_error) = packaged_artifact_segment_fit_error(body, &segment, reference_backend)
1875    else {
1876        return segment;
1877    };
1878
1879    let candidate_for_kind = |segment: &Segment,
1880                              channel_kind: ChannelKind|
1881     -> Option<(Segment, PackagedArtifactSegmentFitError)> {
1882        let candidate = residual_segment(body, segment, reference_backend, channel_kind)?;
1883        let candidate_error =
1884            packaged_artifact_segment_fit_error(body, &candidate, reference_backend)?;
1885        Some((candidate, candidate_error))
1886    };
1887
1888    let (best_segment, _) = best_residual_segment(
1889        segment,
1890        base_error,
1891        &[
1892            ChannelKind::Longitude,
1893            ChannelKind::Latitude,
1894            ChannelKind::DistanceAu,
1895        ],
1896        &candidate_for_kind,
1897    );
1898
1899    best_segment
1900}
1901
1902fn residual_segment_is_better(
1903    candidate_segment: &Segment,
1904    candidate_error: PackagedArtifactSegmentFitError,
1905    existing_segment: &Segment,
1906    existing_error: PackagedArtifactSegmentFitError,
1907) -> bool {
1908    match candidate_error
1909        .max_delta()
1910        .total_cmp(&existing_error.max_delta())
1911    {
1912        Ordering::Less => true,
1913        Ordering::Greater => false,
1914        Ordering::Equal => match candidate_segment
1915            .residual_channels
1916            .len()
1917            .cmp(&existing_segment.residual_channels.len())
1918        {
1919            Ordering::Less => true,
1920            Ordering::Greater => false,
1921            Ordering::Equal => {
1922                let candidate_residual_coefficients = candidate_segment
1923                    .residual_channels
1924                    .iter()
1925                    .map(|channel| channel.coefficients.len())
1926                    .sum::<usize>();
1927                let existing_residual_coefficients = existing_segment
1928                    .residual_channels
1929                    .iter()
1930                    .map(|channel| channel.coefficients.len())
1931                    .sum::<usize>();
1932
1933                candidate_residual_coefficients < existing_residual_coefficients
1934            }
1935        },
1936    }
1937}
1938
1939pub(crate) fn best_residual_segment<F>(
1940    current_segment: Segment,
1941    current_error: PackagedArtifactSegmentFitError,
1942    remaining_kinds: &[ChannelKind],
1943    candidate_for_kind: &F,
1944) -> (Segment, PackagedArtifactSegmentFitError)
1945where
1946    F: Fn(&Segment, ChannelKind) -> Option<(Segment, PackagedArtifactSegmentFitError)>,
1947{
1948    let mut best_segment = current_segment.clone();
1949    let mut best_error = current_error;
1950
1951    for kind in remaining_kinds.iter().copied() {
1952        let Some((candidate_segment, candidate_error)) = candidate_for_kind(&current_segment, kind)
1953        else {
1954            continue;
1955        };
1956
1957        let next_remaining_kinds = remaining_kinds
1958            .iter()
1959            .copied()
1960            .filter(|candidate_kind| *candidate_kind != kind)
1961            .collect::<Vec<_>>();
1962
1963        let (recursive_segment, recursive_error) = best_residual_segment(
1964            candidate_segment,
1965            candidate_error,
1966            &next_remaining_kinds,
1967            candidate_for_kind,
1968        );
1969
1970        if residual_segment_is_better(
1971            &recursive_segment,
1972            recursive_error,
1973            &best_segment,
1974            best_error,
1975        ) {
1976            best_segment = recursive_segment;
1977            best_error = recursive_error;
1978        }
1979    }
1980
1981    (best_segment, best_error)
1982}
1983
1984fn residual_segment(
1985    body: &CelestialBody,
1986    segment: &Segment,
1987    reference_backend: &JplSnapshotBackend,
1988    kind: ChannelKind,
1989) -> Option<Segment> {
1990    if segment
1991        .residual_channels
1992        .iter()
1993        .any(|channel| channel.kind == kind)
1994    {
1995        return None;
1996    }
1997
1998    let channel = segment
1999        .channels
2000        .iter()
2001        .find(|channel| channel.kind == kind)?;
2002    let span_days = segment.end.julian_day.days() - segment.start.julian_day.days();
2003    let residual_samples = packaged_artifact_residual_sample_fractions_for_channel(body, kind)
2004        .iter()
2005        .copied()
2006        .map(|fraction| {
2007            let sample_jd = segment.start.julian_day.days() + span_days * fraction;
2008            let request = EphemerisRequest {
2009                body: body.clone(),
2010                instant: Instant::new(JulianDay::from_days(sample_jd), TimeScale::Tt),
2011                observer: None,
2012                frame: CoordinateFrame::Ecliptic,
2013                zodiac_mode: ZodiacMode::Tropical,
2014                apparent: Apparentness::Mean,
2015            };
2016
2017            let expected = reference_backend.position(&request).ok()?.ecliptic?;
2018            let x = if span_days == 0.0 {
2019                0.0
2020            } else {
2021                (sample_jd - segment.start.julian_day.days()) / span_days
2022            };
2023            let current_value = segment_channel_value(segment, kind, x)?;
2024            let residual = match kind {
2025                ChannelKind::Longitude => {
2026                    Angle::from_degrees(expected.longitude.degrees() - current_value)
2027                        .normalized_signed()
2028                        .degrees()
2029                }
2030                ChannelKind::Latitude => expected.latitude.degrees() - current_value,
2031                ChannelKind::DistanceAu => expected.distance_au? - current_value,
2032                _ => unreachable!("unsupported packaged-artifact channel kind"),
2033            };
2034            Some((fraction, residual))
2035        })
2036        .collect::<Option<Vec<_>>>()?;
2037
2038    let residual_channel =
2039        polynomial_channel_from_samples(kind, channel.scale_exponent, &residual_samples)?;
2040
2041    let mut residual_channels = segment.residual_channels.clone();
2042    residual_channels.push(residual_channel);
2043    residual_channels.sort_by_key(|channel| channel.kind as u8);
2044
2045    let residual_segment = Segment::with_residual_channels(
2046        segment.start,
2047        segment.end,
2048        segment.channels.clone(),
2049        residual_channels,
2050    );
2051
2052    if segment_fits_quantization(&residual_segment) {
2053        Some(residual_segment)
2054    } else {
2055        None
2056    }
2057}
2058
2059pub(crate) const PACKAGED_ARTIFACT_RESIDUAL_SAMPLE_FRACTIONS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0];
2060pub(crate) const PACKAGED_ARTIFACT_DENSE_RESIDUAL_SAMPLE_FRACTIONS: &[f64] =
2061    &[0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0];
2062pub(crate) const PACKAGED_ARTIFACT_MEDIUM_VALIDATION_SAMPLE_FRACTIONS: &[f64] =
2063    &[0.125, 0.25, 0.5, 0.75, 0.875];
2064pub(crate) const PACKAGED_ARTIFACT_DENSE_VALIDATION_SAMPLE_FRACTIONS: &[f64] =
2065    &[0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875];
2066pub(crate) const PACKAGED_ARTIFACT_MEDIUM_FIT_SAMPLE_COUNTS: &[usize] = &[6, 8, 10, 12, 14];
2067pub(crate) const PACKAGED_ARTIFACT_DENSE_FIT_SAMPLE_COUNTS: &[usize] =
2068    &[6, 8, 10, 12, 14, 16, 18, 20];
2069
2070pub(crate) fn packaged_artifact_fit_sample_counts_for_body(
2071    body: &CelestialBody,
2072) -> &'static [usize] {
2073    if packaged_artifact_body_cadence(body).uses_dense_sampling() {
2074        PACKAGED_ARTIFACT_DENSE_FIT_SAMPLE_COUNTS
2075    } else {
2076        PACKAGED_ARTIFACT_MEDIUM_FIT_SAMPLE_COUNTS
2077    }
2078}
2079
2080pub(crate) fn packaged_artifact_residual_sample_fractions_for_channel(
2081    body: &CelestialBody,
2082    kind: ChannelKind,
2083) -> &'static [f64] {
2084    if packaged_artifact_body_cadence(body).uses_dense_residual_sample_lattice(kind) {
2085        PACKAGED_ARTIFACT_DENSE_RESIDUAL_SAMPLE_FRACTIONS
2086    } else {
2087        PACKAGED_ARTIFACT_RESIDUAL_SAMPLE_FRACTIONS
2088    }
2089}
2090
2091pub(crate) fn segment_channel_value(segment: &Segment, kind: ChannelKind, x: f64) -> Option<f64> {
2092    let base = segment
2093        .channels
2094        .iter()
2095        .find(|channel| channel.kind == kind)?;
2096    let residual = segment
2097        .residual_channels
2098        .iter()
2099        .find(|channel| channel.kind == kind)
2100        .map(|channel| evaluate_polynomial_channel(channel, x))
2101        .unwrap_or(0.0);
2102
2103    Some(evaluate_polynomial_channel(base, x) + residual)
2104}
2105
2106pub(crate) fn evaluate_polynomial_channel(channel: &PolynomialChannel, x: f64) -> f64 {
2107    let mut result = 0.0;
2108    let mut power = 1.0;
2109    for coefficient in &channel.coefficients {
2110        result += coefficient * power;
2111        power *= x;
2112    }
2113    result
2114}
2115
2116pub(crate) fn chebyshev_lobatto_fractions(sample_count: usize) -> Vec<f64> {
2117    match sample_count {
2118        0 => Vec::new(),
2119        1 => vec![0.0],
2120        _ => (0..sample_count)
2121            .map(|index| {
2122                let theta = std::f64::consts::PI * index as f64 / (sample_count - 1) as f64;
2123                (1.0 - theta.cos()) / 2.0
2124            })
2125            .collect(),
2126    }
2127}
2128
2129fn unwrap_longitude_samples(samples: &[f64]) -> Vec<f64> {
2130    let mut unwrapped = Vec::with_capacity(samples.len());
2131
2132    for &sample in samples {
2133        if let Some(&previous) = unwrapped.last() {
2134            unwrapped.push(unwrap_longitude_degrees(previous, sample));
2135        } else {
2136            unwrapped.push(sample);
2137        }
2138    }
2139
2140    unwrapped
2141}
2142
2143#[allow(clippy::needless_range_loop)]
2144fn fit_polynomial_coefficients(samples: &[(f64, f64)]) -> Option<Vec<f64>> {
2145    let order = samples.len();
2146    if order == 0 {
2147        return None;
2148    }
2149
2150    let mut matrix = vec![vec![0.0; order + 1]; order];
2151    for (row, (x, y)) in samples.iter().enumerate() {
2152        let mut power = 1.0;
2153        for column in 0..order {
2154            matrix[row][column] = power;
2155            power *= *x;
2156        }
2157        matrix[row][order] = *y;
2158    }
2159
2160    for pivot_index in 0..order {
2161        let mut best_row = pivot_index;
2162        let mut best_value = matrix[pivot_index][pivot_index].abs();
2163        for row in (pivot_index + 1)..order {
2164            let candidate = matrix[row][pivot_index].abs();
2165            if candidate > best_value {
2166                best_value = candidate;
2167                best_row = row;
2168            }
2169        }
2170
2171        if best_value == 0.0 {
2172            return None;
2173        }
2174
2175        if best_row != pivot_index {
2176            matrix.swap(pivot_index, best_row);
2177        }
2178
2179        let pivot = matrix[pivot_index][pivot_index];
2180        for column in pivot_index..=order {
2181            matrix[pivot_index][column] /= pivot;
2182        }
2183
2184        for row in 0..order {
2185            if row == pivot_index {
2186                continue;
2187            }
2188
2189            let factor = matrix[row][pivot_index];
2190            if factor == 0.0 {
2191                continue;
2192            }
2193
2194            for column in pivot_index..=order {
2195                matrix[row][column] -= factor * matrix[pivot_index][column];
2196            }
2197        }
2198    }
2199
2200    Some(matrix.into_iter().map(|row| row[order]).collect())
2201}
2202
2203fn channel_coefficients_fit_quantization(scale_exponent: u8, coefficients: &[f64]) -> bool {
2204    let scale = 10f64.powi(scale_exponent as i32);
2205    coefficients.iter().all(|coefficient| {
2206        let scaled = coefficient * scale;
2207        scaled.is_finite() && scaled.round() >= i64::MIN as f64 && scaled.round() <= i64::MAX as f64
2208    })
2209}
2210
2211pub(crate) fn polynomial_channel_from_samples(
2212    kind: ChannelKind,
2213    scale_exponent: u8,
2214    samples: &[(f64, f64)],
2215) -> Option<PolynomialChannel> {
2216    let coefficients = fit_polynomial_coefficients(samples)?;
2217    if !channel_coefficients_fit_quantization(scale_exponent, &coefficients) {
2218        return None;
2219    }
2220
2221    Some(PolynomialChannel::new(kind, scale_exponent, coefficients))
2222}
2223
2224pub(crate) fn coordinates(entry: &SnapshotEntry) -> EclipticCoordinates {
2225    let radius_km =
2226        (entry.x_km * entry.x_km + entry.y_km * entry.y_km + entry.z_km * entry.z_km).sqrt();
2227    let longitude = entry.y_km.atan2(entry.x_km).to_degrees();
2228    let latitude = (entry.z_km / radius_km)
2229        .clamp(-1.0, 1.0)
2230        .asin()
2231        .to_degrees();
2232    EclipticCoordinates::new(
2233        pleiades_backend::Longitude::from_degrees(longitude),
2234        pleiades_backend::Latitude::from_degrees(latitude),
2235        Some(radius_km / AU_IN_KM),
2236    )
2237}
2238
2239pub(crate) fn artifact_time_range(artifact: &CompressedArtifact) -> TimeRange {
2240    let mut start: Option<Instant> = None;
2241    let mut end: Option<Instant> = None;
2242    for body in &artifact.bodies {
2243        for segment in &body.segments {
2244            start = Some(match start {
2245                Some(current) => {
2246                    if segment.start.julian_day.days() < current.julian_day.days() {
2247                        segment.start
2248                    } else {
2249                        current
2250                    }
2251                }
2252                None => segment.start,
2253            });
2254            end = Some(match end {
2255                Some(current) => {
2256                    if segment.end.julian_day.days() > current.julian_day.days() {
2257                        segment.end
2258                    } else {
2259                        current
2260                    }
2261                }
2262                None => segment.end,
2263            });
2264        }
2265    }
2266    TimeRange::new(start, end)
2267}
2268
2269pub(crate) fn normalize_lookup_instant(instant: Instant) -> Instant {
2270    match instant.scale {
2271        TimeScale::Tt => instant,
2272        TimeScale::Tdb => Instant::new(instant.julian_day, TimeScale::Tt),
2273        _ => instant,
2274    }
2275}
2276
2277pub(crate) fn map_artifact_error(error: pleiades_compression::CompressionError) -> EphemerisError {
2278    let kind = match error.kind {
2279        pleiades_compression::CompressionErrorKind::MissingBody => {
2280            EphemerisErrorKind::UnsupportedBody
2281        }
2282        pleiades_compression::CompressionErrorKind::OutOfRangeInstant => {
2283            EphemerisErrorKind::OutOfRangeInstant
2284        }
2285        pleiades_compression::CompressionErrorKind::UnsupportedTimeScale => {
2286            EphemerisErrorKind::UnsupportedTimeScale
2287        }
2288        pleiades_compression::CompressionErrorKind::MissingChannel => {
2289            EphemerisErrorKind::MissingDataset
2290        }
2291        pleiades_compression::CompressionErrorKind::QuantizationOverflow
2292        | pleiades_compression::CompressionErrorKind::InvalidFormat
2293        | pleiades_compression::CompressionErrorKind::UnsupportedEndianPolicy
2294        | pleiades_compression::CompressionErrorKind::InvalidMagic
2295        | pleiades_compression::CompressionErrorKind::UnsupportedVersion
2296        | pleiades_compression::CompressionErrorKind::ChecksumMismatch
2297        | pleiades_compression::CompressionErrorKind::Truncated
2298        | _ => EphemerisErrorKind::NumericalFailure,
2299    };
2300
2301    EphemerisError::new(kind, error.message)
2302}
2303
2304/// Fits one segment over `[t0_jd, t1_jd]` by sampling `reference` (de440 or a
2305/// test backend) at the body's within-span sample count and least-squares
2306/// fitting Longitude/Latitude/DistanceAu channels over the normalized interval.
2307///
2308/// The x-domain for the polynomial fit matches the decoder: `x = (t - t0) / span`
2309/// in `[0, 1]`, consistent with `CompressedArtifact::lookup_ecliptic` (artifact.rs).
2310///
2311/// Scale exponents match the existing generation pipeline: Longitude=9, Latitude=9,
2312/// DistanceAu=10 (see `regenerate.rs` segment_from_single_entry and threshold.rs).
2313pub(crate) fn fit_segment_within_span(
2314    body: &CelestialBody,
2315    t0_jd: f64,
2316    t1_jd: f64,
2317    reference: &dyn EphemerisBackend,
2318) -> Option<Segment> {
2319    use crate::coverage::{fit_polynomial_lsq, fitting_degree, fitting_within_span_sample_count};
2320
2321    let n = fitting_within_span_sample_count(body).max(fitting_degree(body) + 1);
2322    let span = t1_jd - t0_jd;
2323    if span <= 0.0 {
2324        return None;
2325    }
2326    if n < 2 {
2327        return None;
2328    }
2329
2330    let mut xs = Vec::with_capacity(n);
2331    let mut lon_deg = Vec::with_capacity(n);
2332    let mut lat = Vec::with_capacity(n);
2333    let mut dist = Vec::with_capacity(n);
2334    for i in 0..n {
2335        let frac = i as f64 / (n as f64 - 1.0);
2336        let jd = t0_jd + frac * span;
2337        let inst = Instant::new(JulianDay::from_days(jd), TimeScale::Tdb);
2338        let res = reference
2339            .position(&EphemerisRequest::new(body.clone(), inst))
2340            .ok()?;
2341        let ec = res.ecliptic?;
2342        let ec = if body_uses_heliocentric_frame(body) {
2343            let sun = reference
2344                .position(&EphemerisRequest::new(CelestialBody::Sun, inst))
2345                .ok()?
2346                .ecliptic?;
2347            pleiades_compression::heliocentric_from_geocentric(&ec, &sun)?
2348        } else {
2349            ec
2350        };
2351        xs.push(frac);
2352        lon_deg.push(ec.longitude.degrees());
2353        lat.push(ec.latitude.degrees());
2354        dist.push(ec.distance_au?);
2355    }
2356
2357    // Unwrap longitude to a continuous series before fitting (reuse existing helper).
2358    let lon_unwrapped = unwrap_longitude_samples(&lon_deg);
2359
2360    let degree = fitting_degree(body);
2361    let to_samples =
2362        |ys: &[f64]| -> Vec<(f64, f64)> { xs.iter().copied().zip(ys.iter().copied()).collect() };
2363
2364    let lon_coeffs = fit_polynomial_lsq(&to_samples(&lon_unwrapped), degree)?;
2365    let lat_coeffs = fit_polynomial_lsq(&to_samples(&lat), degree)?;
2366    let dist_coeffs = fit_polynomial_lsq(&to_samples(&dist), degree)?;
2367
2368    // Channels must be ordered by ChannelKind discriminant: Longitude=0, Latitude=1, DistanceAu=2.
2369    // Scale exponents match the existing generation pipeline (Longitude=9, Latitude=9, DistanceAu=10).
2370    let channels = vec![
2371        PolynomialChannel::new(ChannelKind::Longitude, 9, lon_coeffs),
2372        PolynomialChannel::new(ChannelKind::Latitude, 9, lat_coeffs),
2373        PolynomialChannel::new(ChannelKind::DistanceAu, 10, dist_coeffs),
2374    ];
2375
2376    // Validate each channel's coefficients are finite (fail-closed).
2377    for channel in &channels {
2378        channel.validate().ok()?;
2379    }
2380
2381    // Segment boundaries are tagged Tt to match the packaged-lookup convention:
2382    // normalize_lookup_instant re-tags every query to Tt, and Segment::contains
2383    // requires matching scales. The sampling instants above remain Tdb (the
2384    // physical ephemeris query scale); the ~2 ms TT/TDB difference is immaterial
2385    // because the stored artifact only carries the boundary tag, not a converted value.
2386    let seg = Segment::new(
2387        Instant::new(JulianDay::from_days(t0_jd), TimeScale::Tt),
2388        Instant::new(JulianDay::from_days(t1_jd), TimeScale::Tt),
2389        channels,
2390    );
2391    Some(seg)
2392}
2393
2394/// Core artifact builder parameterised by an explicit coverage window.
2395///
2396/// Accepts an explicit `base_window` used for all major bodies (planets, Sun,
2397/// Moon). This separation lets the kernel-free unit test pass a tiny synthetic
2398/// window so the test runs in milliseconds instead of the minutes a full
2399/// 1900–2100 default-window build would take.
2400///
2401/// Major bodies (all cadences except `SelectedAsteroids` / `CustomBodies`) are
2402/// fit densely from `reference` (de440) over `base_window` using
2403/// [`fitting_segment_boundaries`] + [`fit_segment_within_span`].
2404///
2405/// The constrained asteroid (Eros) is kernel-free: its curated 1900–2100 corpus
2406/// data is not present in de440. Instead its segments are re-derived from the
2407/// reference snapshot (curated Horizons 1900–2100 corpus data, the same source
2408/// the committed artifact was originally built from). This approach is
2409/// format/version-independent: it does not decode the committed `.bin` and is
2410/// therefore safe across artifact-version bumps. Two regenerations are
2411/// deterministic: the reference snapshot is static committed source data, so
2412/// the same snapshot fit always yields identical segments.
2413pub(crate) fn build_packaged_artifact_from_reference_over(
2414    reference: &dyn EphemerisBackend,
2415    base_window: (f64, f64),
2416) -> CompressedArtifact {
2417    use crate::coverage::fitting_segment_boundaries;
2418
2419    let mut body_artifacts: Vec<(usize, BodyArtifact)> = Vec::new();
2420
2421    std::thread::scope(|scope| {
2422        let mut handles = Vec::new();
2423
2424        for (body_index, body) in packaged_bodies().iter().cloned().enumerate() {
2425            let cadence = packaged_artifact_body_cadence(&body);
2426
2427            match cadence {
2428                PackagedArtifactBodyCadence::SelectedAsteroids
2429                | PackagedArtifactBodyCadence::CustomBodies => {
2430                    // Constrained asteroid (Eros): re-derive segments from the
2431                    // reference snapshot (curated corpus data), the same source
2432                    // the committed artifact was originally built from. This is
2433                    // format/version-independent — do NOT decode the committed
2434                    // artifact or fit from `reference` (de440).
2435                    let snapshot = reference_snapshot();
2436                    let mut entries: Vec<&SnapshotEntry> =
2437                        snapshot.iter().filter(|e| e.body == body).collect();
2438                    if entries.is_empty() {
2439                        panic!(
2440                            "reference snapshot is missing body {body}; \
2441                             the reference snapshot must contain all constrained-asteroid bodies"
2442                        );
2443                    }
2444                    entries.sort_by(|left, right| {
2445                        left.epoch
2446                            .julian_day
2447                            .days()
2448                            .partial_cmp(&right.epoch.julian_day.days())
2449                            .unwrap_or(Ordering::Equal)
2450                    });
2451                    let segments = body_segments_from_entries(&entries, &JplSnapshotBackend);
2452                    handles
2453                        .push(scope.spawn(move || (body_index, BodyArtifact::new(body, segments))));
2454                }
2455                _ => {
2456                    // Major body: fit densely from the reference (de440) backend.
2457                    let (start_jd, end_jd) = base_window;
2458                    handles.push(scope.spawn(move || {
2459                        let spans = fitting_segment_boundaries(&body, start_jd, end_jd);
2460                        let segments: Vec<Segment> = spans
2461                            .into_iter()
2462                            .map(|(t0, t1)| {
2463                                fit_segment_within_span(&body, t0, t1, reference)
2464                                    .unwrap_or_else(|| {
2465                                        panic!(
2466                                            "fit_segment_within_span failed for body {body} over [{t0}, {t1}]"
2467                                        )
2468                                    })
2469                            })
2470                            .collect();
2471                        let frame = if body_uses_heliocentric_frame(&body) {
2472                            pleiades_compression::StoredFrame::Heliocentric
2473                        } else {
2474                            pleiades_compression::StoredFrame::Geocentric
2475                        };
2476                        (body_index, BodyArtifact::with_frame(body, segments, frame))
2477                    }));
2478                }
2479            }
2480        }
2481
2482        for handle in handles {
2483            body_artifacts.push(
2484                handle
2485                    .join()
2486                    .expect("packaged artifact body assembly should not panic"),
2487            );
2488        }
2489    });
2490
2491    body_artifacts.sort_by_key(|(body_index, _)| *body_index);
2492    let bodies: Vec<BodyArtifact> = body_artifacts
2493        .into_iter()
2494        .map(|(_, artifact)| artifact)
2495        .collect();
2496
2497    let mut artifact = CompressedArtifact::new(
2498        ArtifactHeader::new(ARTIFACT_LABEL, packaged_artifact_source_text()),
2499        bodies,
2500    );
2501    artifact.checksum = artifact
2502        .checksum()
2503        .expect("packaged artifact checksum should be reproducible");
2504    artifact
2505        .validate()
2506        .expect("packaged artifact should validate before encoding");
2507    artifact
2508}
2509
2510/// Regenerates the packaged artifact from a de440 SPK kernel over an explicit
2511/// coverage window. Major bodies are fit densely from the kernel across `window`;
2512/// the constrained asteroid (Eros) is always sourced from its fixed 1900–2100
2513/// corpus data and is unaffected by `window`.
2514pub fn regenerate_packaged_artifact_from_kernel_over(
2515    kernel_path: &str,
2516    window: pleiades_jpl::spk::corpus_spec::CoverageWindow,
2517) -> Result<CompressedArtifact, String> {
2518    let backend = pleiades_jpl::SpkBackend::builder()
2519        .add_kernel(kernel_path)
2520        .map_err(|error| error.message)?
2521        .build();
2522    Ok(build_packaged_artifact_from_reference_over(
2523        &backend,
2524        window.as_tuple(),
2525    ))
2526}
2527
2528/// Regenerates the packaged artifact from a de440 SPK kernel over the shipped
2529/// default window (1900–2100).
2530pub fn regenerate_packaged_artifact_from_kernel(
2531    kernel_path: &str,
2532) -> Result<CompressedArtifact, String> {
2533    regenerate_packaged_artifact_from_kernel_over(
2534        kernel_path,
2535        pleiades_jpl::spk::corpus_spec::CoverageWindow::default(),
2536    )
2537}