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
794/// Returns the current packaged-artifact body-class span caps after validating the structured posture.
795pub fn packaged_artifact_body_class_span_cap_summary_for_report() -> String {
796    let summary = packaged_artifact_body_class_span_cap_summary_details();
797    match summary.validated_summary_line() {
798        Ok(line) => line,
799        Err(error) => format!("body-class span caps: unavailable ({error})"),
800    }
801}
802
803/// Returns the current packaged-artifact body-class span-cap entries after validating the structured posture.
804pub fn packaged_artifact_body_class_span_cap_entries_for_report() -> String {
805    let summary = packaged_artifact_body_class_span_cap_summary_details();
806    match summary.validated_summary_line() {
807        Ok(_) => summary.entries_summary_line(),
808        Err(error) => format!("unavailable ({error})"),
809    }
810}
811
812fn packaged_artifact_body_cadence_counts() -> [(&'static str, usize); 7] {
813    let mut counts = [0usize; 7];
814
815    for body in packaged_bodies() {
816        match packaged_artifact_body_cadence(body) {
817            PackagedArtifactBodyCadence::Luminaries => counts[0] += 1,
818            PackagedArtifactBodyCadence::InnerPlanets => counts[1] += 1,
819            PackagedArtifactBodyCadence::OuterPlanets => counts[2] += 1,
820            PackagedArtifactBodyCadence::Pluto => counts[3] += 1,
821            PackagedArtifactBodyCadence::LunarPoints => counts[4] += 1,
822            PackagedArtifactBodyCadence::SelectedAsteroids => counts[5] += 1,
823            PackagedArtifactBodyCadence::CustomBodies => counts[6] += 1,
824        }
825    }
826
827    [
828        ("luminaries", counts[0]),
829        ("inner planets", counts[1]),
830        ("outer planets", counts[2]),
831        ("pluto", counts[3]),
832        ("lunar points", counts[4]),
833        ("selected asteroids", counts[5]),
834        ("custom bodies", counts[6]),
835    ]
836}
837
838/// Structured summary for the packaged-artifact body cadence.
839#[derive(Clone, Debug, PartialEq, Eq)]
840pub struct PackagedArtifactBodyCadenceSummary {
841    /// Body-cadence entries in release-facing order.
842    pub entries: Vec<(&'static str, usize)>,
843}
844
845/// Validation error for a packaged-artifact body cadence summary that drifted from the current posture.
846#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
847pub enum PackagedArtifactBodyCadenceSummaryValidationError {
848    /// A summary field is out of sync with the current packaged-artifact posture.
849    FieldOutOfSync { field: &'static str },
850}
851
852impl PackagedArtifactBodyCadenceSummaryValidationError {
853    /// Returns the compact release-facing summary for the validation error.
854    pub fn summary_line(&self) -> String {
855        match self {
856            Self::FieldOutOfSync { field } => format!(
857                "the packaged artifact body cadence summary field `{field}` is out of sync with the current posture"
858            ),
859        }
860    }
861}
862
863impl fmt::Display for PackagedArtifactBodyCadenceSummaryValidationError {
864    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
865        f.write_str(&self.summary_line())
866    }
867}
868
869impl std::error::Error for PackagedArtifactBodyCadenceSummaryValidationError {}
870
871impl PackagedArtifactBodyCadenceSummary {
872    /// Returns the body cadence summary as a compact human-readable line.
873    pub fn summary_line(&self) -> String {
874        let entries = self
875            .entries
876            .iter()
877            .map(|(label, count)| {
878                format!(
879                    "{label}={count} {}",
880                    if *count == 1 { "body" } else { "bodies" }
881                )
882            })
883            .collect::<Vec<_>>();
884
885        format!("body cadence: {}", join_display(&entries))
886    }
887
888    /// Returns `Ok(())` when the summary still matches the current packaged-artifact posture.
889    pub fn validate(&self) -> Result<(), PackagedArtifactBodyCadenceSummaryValidationError> {
890        if self.entries != packaged_artifact_body_cadence_counts().to_vec() {
891            return Err(
892                PackagedArtifactBodyCadenceSummaryValidationError::FieldOutOfSync {
893                    field: "entries",
894                },
895            );
896        }
897
898        Ok(())
899    }
900
901    /// Returns the summary line after validating the structured posture.
902    pub fn validated_summary_line(
903        &self,
904    ) -> Result<String, PackagedArtifactBodyCadenceSummaryValidationError> {
905        self.validate()?;
906        Ok(self.summary_line())
907    }
908}
909
910impl fmt::Display for PackagedArtifactBodyCadenceSummary {
911    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
912        f.write_str(&self.summary_line())
913    }
914}
915
916/// Returns the current packaged-artifact body cadence summary record.
917pub fn packaged_artifact_body_cadence_summary_details() -> PackagedArtifactBodyCadenceSummary {
918    let summary = PackagedArtifactBodyCadenceSummary {
919        entries: packaged_artifact_body_cadence_counts().to_vec(),
920    };
921    debug_assert!(summary.validate().is_ok());
922    summary
923}
924
925fn render_packaged_artifact_body_cadence_summary(
926    summary: &PackagedArtifactBodyCadenceSummary,
927) -> String {
928    match summary.validated_summary_line() {
929        Ok(line) => line,
930        Err(error) => format!("body cadence: unavailable ({error})"),
931    }
932}
933
934/// Returns the current packaged-artifact body cadence as a compact human-readable line.
935pub fn packaged_artifact_body_cadence_summary_for_report() -> String {
936    render_packaged_artifact_body_cadence_summary(&packaged_artifact_body_cadence_summary_details())
937}
938
939fn body_segment_windows_for_interval(
940    start: &SnapshotEntry,
941    end: &SnapshotEntry,
942    reference_backend: &JplSnapshotBackend,
943) -> Vec<Segment> {
944    let span_days = end.epoch.julian_day.days() - start.epoch.julian_day.days();
945    let span_limit = body_segment_span_limit(&start.body);
946    let start_coordinates = coordinates(start);
947    let end_coordinates = coordinates(end);
948    let start_longitude = start_coordinates.longitude.degrees();
949    let end_longitude =
950        unwrap_longitude_degrees(start_longitude, end_coordinates.longitude.degrees());
951    let start_instant = Instant::new(start.epoch.julian_day, TimeScale::Tt);
952    let end_instant = Instant::new(end.epoch.julian_day, TimeScale::Tt);
953    let sample_fraction = |fraction: f64| -> Option<EclipticCoordinates> {
954        let sample_jd = start.epoch.julian_day.days()
955            + (end.epoch.julian_day.days() - start.epoch.julian_day.days()) * fraction;
956        let request = EphemerisRequest {
957            body: start.body.clone(),
958            instant: Instant::new(JulianDay::from_days(sample_jd), TimeScale::Tt),
959            observer: None,
960            frame: CoordinateFrame::Ecliptic,
961            zodiac_mode: ZodiacMode::Tropical,
962            apparent: Apparentness::Mean,
963        };
964
965        reference_backend
966            .position(&request)
967            .ok()
968            .and_then(|result| result.ecliptic)
969    };
970    let finalize =
971        |segment| segment_with_optional_residual_channels(&start.body, segment, reference_backend);
972    let candidate = segment_from_pair(start, end, reference_backend);
973    let candidate_error = if segment_fits_quantization(&candidate) {
974        packaged_artifact_segment_fit_error(&start.body, &candidate, reference_backend)
975    } else {
976        None
977    };
978
979    if span_days <= 1.0 {
980        let fallback = segment_from_pair_fallback(
981            start_instant,
982            end_instant,
983            start_longitude,
984            end_longitude,
985            &start_coordinates,
986            &end_coordinates,
987            Some(span_days),
988            Some(span_limit),
989            &sample_fraction,
990        );
991        let fallback_error = if segment_fits_quantization(&fallback) {
992            packaged_artifact_segment_fit_error(&start.body, &fallback, reference_backend)
993        } else {
994            None
995        };
996
997        return if segment_error_prefers_candidate(
998            &candidate,
999            candidate_error,
1000            &fallback,
1001            fallback_error,
1002        ) {
1003            vec![finalize(candidate)]
1004        } else {
1005            vec![finalize(fallback)]
1006        };
1007    }
1008
1009    if span_days <= span_limit {
1010        let fallback = segment_from_pair_fallback(
1011            start_instant,
1012            end_instant,
1013            start_longitude,
1014            end_longitude,
1015            &start_coordinates,
1016            &end_coordinates,
1017            Some(span_days),
1018            Some(span_limit),
1019            &sample_fraction,
1020        );
1021        let fallback_error = if segment_fits_quantization(&fallback) {
1022            packaged_artifact_segment_fit_error(&start.body, &fallback, reference_backend)
1023        } else {
1024            None
1025        };
1026
1027        if segment_error_prefers_candidate(&candidate, candidate_error, &fallback, fallback_error) {
1028            return vec![finalize(candidate)];
1029        }
1030    }
1031
1032    let midpoint_jd = (start.epoch.julian_day.days() + end.epoch.julian_day.days()) / 2.0;
1033    if midpoint_jd <= start.epoch.julian_day.days() || midpoint_jd >= end.epoch.julian_day.days() {
1034        return vec![finalize(segment_from_pair_fallback(
1035            start_instant,
1036            end_instant,
1037            start_longitude,
1038            end_longitude,
1039            &start_coordinates,
1040            &end_coordinates,
1041            Some(span_days),
1042            Some(span_limit),
1043            &sample_fraction,
1044        ))];
1045    }
1046    let Some(midpoint_coordinates) = sample_fraction(0.5) else {
1047        return vec![finalize(segment_from_pair_fallback(
1048            start_instant,
1049            end_instant,
1050            start_longitude,
1051            end_longitude,
1052            &start_coordinates,
1053            &end_coordinates,
1054            Some(span_days),
1055            Some(span_limit),
1056            &sample_fraction,
1057        ))];
1058    };
1059    let use_curvature_bias = packaged_artifact_body_cadence(&start.body).uses_dense_sampling()
1060        && span_days > span_limit * 2.0;
1061    let quarter_coordinates = if use_curvature_bias {
1062        sample_fraction(0.25)
1063    } else {
1064        None
1065    };
1066    let one_fifth_coordinates = if use_curvature_bias && span_days > span_limit * 8.0 {
1067        sample_fraction(1.0 / 5.0)
1068    } else {
1069        None
1070    };
1071    let one_sixth_coordinates = if use_curvature_bias {
1072        sample_fraction(1.0 / 6.0)
1073    } else {
1074        None
1075    };
1076    let one_seventh_coordinates = if use_curvature_bias && span_days > span_limit * 16.0 {
1077        sample_fraction(1.0 / 7.0)
1078    } else {
1079        None
1080    };
1081    let six_sevenths_coordinates = if use_curvature_bias && span_days > span_limit * 16.0 {
1082        sample_fraction(6.0 / 7.0)
1083    } else {
1084        None
1085    };
1086    let three_quarter_coordinates = if use_curvature_bias {
1087        sample_fraction(0.75)
1088    } else {
1089        None
1090    };
1091    let five_sixth_coordinates = if use_curvature_bias {
1092        sample_fraction(5.0 / 6.0)
1093    } else {
1094        None
1095    };
1096    let one_ninth_coordinates = if use_curvature_bias && span_days > span_limit * 32.0 {
1097        sample_fraction(1.0 / 9.0)
1098    } else {
1099        None
1100    };
1101    let eight_ninths_coordinates = if use_curvature_bias && span_days > span_limit * 32.0 {
1102        sample_fraction(8.0 / 9.0)
1103    } else {
1104        None
1105    };
1106    let one_eighth_coordinates = if use_curvature_bias && span_days > span_limit * 128.0 {
1107        sample_fraction(1.0 / 8.0)
1108    } else {
1109        None
1110    };
1111    let seven_eighths_coordinates = if use_curvature_bias && span_days > span_limit * 128.0 {
1112        sample_fraction(7.0 / 8.0)
1113    } else {
1114        None
1115    };
1116    let one_third_coordinates = if use_curvature_bias {
1117        sample_fraction(1.0 / 3.0)
1118    } else {
1119        None
1120    };
1121    let two_third_coordinates = if use_curvature_bias {
1122        sample_fraction(2.0 / 3.0)
1123    } else {
1124        None
1125    };
1126    let four_fifth_coordinates = if use_curvature_bias && span_days > span_limit * 8.0 {
1127        sample_fraction(4.0 / 5.0)
1128    } else {
1129        None
1130    };
1131    let split_fraction = packaged_artifact_split_fraction_for_interval(
1132        &start.body,
1133        span_days,
1134        span_limit,
1135        PackagedArtifactSplitCurvature {
1136            start_coordinates: &start_coordinates,
1137            quarter_coordinates: quarter_coordinates.as_ref(),
1138            one_fifth_coordinates: one_fifth_coordinates.as_ref(),
1139            one_sixth_coordinates: one_sixth_coordinates.as_ref(),
1140            one_seventh_coordinates: one_seventh_coordinates.as_ref(),
1141            six_sevenths_coordinates: six_sevenths_coordinates.as_ref(),
1142            one_ninth_coordinates: one_ninth_coordinates.as_ref(),
1143            eight_ninths_coordinates: eight_ninths_coordinates.as_ref(),
1144            one_eighth_coordinates: one_eighth_coordinates.as_ref(),
1145            seven_eighths_coordinates: seven_eighths_coordinates.as_ref(),
1146            one_third_coordinates: one_third_coordinates.as_ref(),
1147            midpoint_coordinates: &midpoint_coordinates,
1148            two_third_coordinates: two_third_coordinates.as_ref(),
1149            four_fifth_coordinates: four_fifth_coordinates.as_ref(),
1150            five_sixth_coordinates: five_sixth_coordinates.as_ref(),
1151            three_quarter_coordinates: three_quarter_coordinates.as_ref(),
1152            end_coordinates: &end_coordinates,
1153        },
1154    );
1155    let split_jd = start.epoch.julian_day.days() + span_days * split_fraction;
1156    if split_jd <= start.epoch.julian_day.days() || split_jd >= end.epoch.julian_day.days() {
1157        return vec![finalize(segment_from_pair_fallback(
1158            start_instant,
1159            end_instant,
1160            start_longitude,
1161            end_longitude,
1162            &start_coordinates,
1163            &end_coordinates,
1164            Some(span_days),
1165            Some(span_limit),
1166            &sample_fraction,
1167        ))];
1168    }
1169    let split_coordinates = if (split_fraction - 0.5).abs() < f64::EPSILON {
1170        midpoint_coordinates
1171    } else {
1172        let Some(split_coordinates) = sample_fraction(split_fraction) else {
1173            return vec![finalize(segment_from_pair_fallback(
1174                start_instant,
1175                end_instant,
1176                start_longitude,
1177                end_longitude,
1178                &start_coordinates,
1179                &end_coordinates,
1180                Some(span_days),
1181                Some(span_limit),
1182                &sample_fraction,
1183            ))];
1184        };
1185        split_coordinates
1186    };
1187
1188    let split_entry =
1189        snapshot_entry_from_ecliptic_coordinates(start.body.clone(), split_jd, split_coordinates);
1190
1191    let mut segments = body_segment_windows_for_interval(start, &split_entry, reference_backend);
1192    segments.extend(body_segment_windows_for_interval(
1193        &split_entry,
1194        end,
1195        reference_backend,
1196    ));
1197    segments.into_iter().map(finalize).collect()
1198}
1199
1200fn segment_fits_quantization(segment: &Segment) -> bool {
1201    segment
1202        .channels
1203        .iter()
1204        .chain(segment.residual_channels.iter())
1205        .all(|channel| {
1206            channel_coefficients_fit_quantization(channel.scale_exponent, &channel.coefficients)
1207        })
1208}
1209
1210pub(crate) fn snapshot_entry_from_ecliptic_coordinates(
1211    body: CelestialBody,
1212    julian_day: f64,
1213    coordinates: EclipticCoordinates,
1214) -> SnapshotEntry {
1215    let radius_km = coordinates.distance_au.unwrap_or_default() * AU_IN_KM;
1216    let longitude_radians = coordinates.longitude.degrees().to_radians();
1217    let latitude_radians = coordinates.latitude.degrees().to_radians();
1218    let cos_latitude = latitude_radians.cos();
1219    SnapshotEntry {
1220        body,
1221        epoch: Instant::new(JulianDay::from_days(julian_day), TimeScale::Tt),
1222        x_km: radius_km * cos_latitude * longitude_radians.cos(),
1223        y_km: radius_km * cos_latitude * longitude_radians.sin(),
1224        z_km: radius_km * latitude_radians.sin(),
1225        vx_km_s: None,
1226        vy_km_s: None,
1227        vz_km_s: None,
1228    }
1229}
1230
1231fn unwrap_longitude_degrees(reference_degrees: f64, candidate_degrees: f64) -> f64 {
1232    reference_degrees
1233        + Angle::from_degrees(candidate_degrees - reference_degrees)
1234            .normalized_signed()
1235            .degrees()
1236}
1237
1238fn segment_from_single_entry(entry: &SnapshotEntry) -> Segment {
1239    let coordinates = coordinates(entry);
1240    Segment::new(
1241        Instant::new(entry.epoch.julian_day, TimeScale::Tt),
1242        Instant::new(entry.epoch.julian_day, TimeScale::Tt),
1243        vec![
1244            PolynomialChannel::linear(
1245                ChannelKind::Longitude,
1246                9,
1247                coordinates.longitude.degrees(),
1248                coordinates.longitude.degrees(),
1249            ),
1250            PolynomialChannel::linear(
1251                ChannelKind::Latitude,
1252                9,
1253                coordinates.latitude.degrees(),
1254                coordinates.latitude.degrees(),
1255            ),
1256            PolynomialChannel::linear(
1257                ChannelKind::DistanceAu,
1258                10,
1259                coordinates.distance_au.unwrap_or_default(),
1260                coordinates.distance_au.unwrap_or_default(),
1261            ),
1262        ],
1263    )
1264}
1265
1266pub(crate) fn segment_from_pair(
1267    start: &SnapshotEntry,
1268    end: &SnapshotEntry,
1269    reference_backend: &JplSnapshotBackend,
1270) -> Segment {
1271    let span_days = end.epoch.julian_day.days() - start.epoch.julian_day.days();
1272    let span_limit = body_segment_span_limit(&start.body);
1273    let start_coordinates = coordinates(start);
1274    let end_coordinates = coordinates(end);
1275    let start_longitude = start_coordinates.longitude.degrees();
1276    let end_longitude =
1277        unwrap_longitude_degrees(start_longitude, end_coordinates.longitude.degrees());
1278    let start_instant = Instant::new(start.epoch.julian_day, TimeScale::Tt);
1279    let end_instant = Instant::new(end.epoch.julian_day, TimeScale::Tt);
1280    let sample_fraction = |fraction: f64| -> Option<EclipticCoordinates> {
1281        let sample_jd = start.epoch.julian_day.days()
1282            + (end.epoch.julian_day.days() - start.epoch.julian_day.days()) * fraction;
1283        let request = EphemerisRequest {
1284            body: start.body.clone(),
1285            instant: Instant::new(JulianDay::from_days(sample_jd), TimeScale::Tt),
1286            observer: None,
1287            frame: CoordinateFrame::Ecliptic,
1288            zodiac_mode: ZodiacMode::Tropical,
1289            apparent: Apparentness::Mean,
1290        };
1291
1292        reference_backend
1293            .position(&request)
1294            .ok()
1295            .and_then(|result| result.ecliptic)
1296    };
1297
1298    let finalize =
1299        |segment| segment_with_optional_residual_channels(&start.body, segment, reference_backend);
1300
1301    let mut best_candidate: Option<(Segment, PackagedArtifactFitCandidateScore)> = None;
1302    for sample_count in packaged_artifact_fit_sample_counts_for_body(&start.body) {
1303        if let Some((segment, error)) = segment_from_pair_fit_attempt(
1304            start_instant,
1305            end_instant,
1306            &start.body,
1307            &start_coordinates,
1308            &end_coordinates,
1309            &sample_fraction,
1310            reference_backend,
1311            *sample_count,
1312        ) {
1313            let score = PackagedArtifactFitCandidateScore {
1314                sample_count: *sample_count,
1315                complexity: segment_complexity(&segment),
1316                error,
1317            };
1318            let should_replace = best_candidate
1319                .as_ref()
1320                .map(|(_, existing_score)| segment_fit_candidate_is_better(*existing_score, score))
1321                .unwrap_or(true);
1322            if should_replace {
1323                best_candidate = Some((segment, score));
1324            }
1325        }
1326    }
1327
1328    let fallback = segment_from_pair_fallback(
1329        start_instant,
1330        end_instant,
1331        start_longitude,
1332        end_longitude,
1333        &start_coordinates,
1334        &end_coordinates,
1335        Some(span_days),
1336        Some(span_limit),
1337        &sample_fraction,
1338    );
1339    let fallback_error = if segment_fits_quantization(&fallback) {
1340        packaged_artifact_segment_fit_error(&start.body, &fallback, reference_backend)
1341    } else {
1342        None
1343    };
1344
1345    if let Some((candidate, score)) = &best_candidate {
1346        if segment_error_prefers_candidate(candidate, Some(score.error), &fallback, fallback_error)
1347        {
1348            return finalize(candidate.clone());
1349        }
1350    }
1351
1352    finalize(fallback)
1353}
1354
1355#[allow(clippy::too_many_arguments)]
1356fn segment_from_pair_fit_attempt<F>(
1357    start_instant: Instant,
1358    end_instant: Instant,
1359    body: &CelestialBody,
1360    start_coordinates: &EclipticCoordinates,
1361    end_coordinates: &EclipticCoordinates,
1362    sample_fraction: &F,
1363    reference_backend: &JplSnapshotBackend,
1364    sample_count: usize,
1365) -> Option<(Segment, PackagedArtifactSegmentFitError)>
1366where
1367    F: Fn(f64) -> Option<EclipticCoordinates>,
1368{
1369    let fit_sample_fractions = chebyshev_lobatto_fractions(sample_count);
1370    let fit_sample_coordinates = fit_sample_fractions
1371        .iter()
1372        .map(|fraction| sample_fraction(*fraction))
1373        .collect::<Option<Vec<_>>>()?;
1374
1375    let longitude_samples = unwrap_longitude_samples(
1376        &fit_sample_coordinates
1377            .iter()
1378            .map(|coordinates| coordinates.longitude.degrees())
1379            .collect::<Vec<_>>(),
1380    );
1381
1382    let fit_samples = fit_sample_fractions
1383        .iter()
1384        .copied()
1385        .zip(fit_sample_coordinates.iter())
1386        .collect::<Vec<_>>();
1387
1388    let longitude_fit_samples = fit_samples
1389        .iter()
1390        .enumerate()
1391        .map(|(index, (fraction, _))| (*fraction, longitude_samples[index]))
1392        .collect::<Vec<_>>();
1393    let latitude_fit_samples = fit_samples
1394        .iter()
1395        .map(|(fraction, coordinates)| (*fraction, coordinates.latitude.degrees()))
1396        .collect::<Vec<_>>();
1397
1398    let (Some(longitude_channel), Some(latitude_channel)) = (
1399        channel_from_fit_samples_with_control_points(
1400            ChannelKind::Longitude,
1401            9,
1402            &longitude_fit_samples,
1403        ),
1404        channel_from_fit_samples_with_control_points(
1405            ChannelKind::Latitude,
1406            9,
1407            &latitude_fit_samples,
1408        ),
1409    ) else {
1410        return None;
1411    };
1412
1413    let midpoint_distance_au = sample_fraction(0.5).and_then(|coordinates| coordinates.distance_au);
1414    let distance_samples = fit_samples
1415        .iter()
1416        .filter_map(|(fraction, coordinates)| {
1417            coordinates
1418                .distance_au
1419                .map(|distance| (*fraction, distance))
1420        })
1421        .collect::<Vec<_>>();
1422    let segment = Segment::new(
1423        start_instant,
1424        end_instant,
1425        vec![
1426            longitude_channel,
1427            latitude_channel,
1428            distance_channel_from_fit_samples(
1429                &distance_samples,
1430                start_coordinates.distance_au.unwrap_or_default(),
1431                midpoint_distance_au,
1432                end_coordinates.distance_au.unwrap_or_default(),
1433            ),
1434        ],
1435    );
1436    let error = packaged_artifact_segment_fit_error(body, &segment, reference_backend)?;
1437    Some((segment, error))
1438}
1439
1440#[allow(clippy::too_many_arguments)]
1441pub(crate) fn segment_from_pair_fallback(
1442    start_instant: Instant,
1443    end_instant: Instant,
1444    start_longitude: f64,
1445    end_longitude: f64,
1446    start_coordinates: &EclipticCoordinates,
1447    end_coordinates: &EclipticCoordinates,
1448    span_days: Option<f64>,
1449    span_limit: Option<f64>,
1450    sample_fraction: &dyn Fn(f64) -> Option<EclipticCoordinates>,
1451) -> Segment {
1452    let Some(midpoint_coordinates) = sample_fraction(0.5) else {
1453        return Segment::new(
1454            start_instant,
1455            end_instant,
1456            vec![
1457                PolynomialChannel::linear(
1458                    ChannelKind::Longitude,
1459                    9,
1460                    start_longitude,
1461                    end_longitude,
1462                ),
1463                PolynomialChannel::linear(
1464                    ChannelKind::Latitude,
1465                    9,
1466                    start_coordinates.latitude.degrees(),
1467                    end_coordinates.latitude.degrees(),
1468                ),
1469                distance_channel_from_samples(
1470                    start_coordinates.distance_au.unwrap_or_default(),
1471                    None,
1472                    end_coordinates.distance_au.unwrap_or_default(),
1473                ),
1474            ],
1475        );
1476    };
1477    let midpoint_distance_au = midpoint_coordinates.distance_au;
1478    let distance_start = start_coordinates.distance_au.unwrap_or_default();
1479    let distance_end = end_coordinates.distance_au.unwrap_or_default();
1480    let midpoint_longitude =
1481        unwrap_longitude_degrees(start_longitude, midpoint_coordinates.longitude.degrees());
1482
1483    let quarter_coordinates = sample_fraction(0.25);
1484    let three_quarter_coordinates = sample_fraction(0.75);
1485    if let (Some(quarter_coordinates), Some(three_quarter_coordinates)) = (
1486        quarter_coordinates.as_ref(),
1487        three_quarter_coordinates.as_ref(),
1488    ) {
1489        if let (
1490            Some(quarter_distance_au),
1491            Some(midpoint_distance_au),
1492            Some(three_quarter_distance_au),
1493        ) = (
1494            quarter_coordinates.distance_au,
1495            midpoint_distance_au,
1496            three_quarter_coordinates.distance_au,
1497        ) {
1498            let longitude_samples = unwrap_longitude_samples(&[
1499                start_longitude,
1500                quarter_coordinates.longitude.degrees(),
1501                midpoint_longitude,
1502                three_quarter_coordinates.longitude.degrees(),
1503                end_longitude,
1504            ]);
1505
1506            if let (Some(longitude_channel), Some(latitude_channel)) = (
1507                channel_from_dense_fit_samples_with_control_points(
1508                    ChannelKind::Longitude,
1509                    9,
1510                    &[
1511                        (0.0, longitude_samples[0]),
1512                        (0.25, longitude_samples[1]),
1513                        (0.5, longitude_samples[2]),
1514                        (0.75, longitude_samples[3]),
1515                        (1.0, longitude_samples[4]),
1516                    ],
1517                ),
1518                channel_from_dense_fit_samples_with_control_points(
1519                    ChannelKind::Latitude,
1520                    9,
1521                    &[
1522                        (0.0, start_coordinates.latitude.degrees()),
1523                        (0.25, quarter_coordinates.latitude.degrees()),
1524                        (0.5, midpoint_coordinates.latitude.degrees()),
1525                        (0.75, three_quarter_coordinates.latitude.degrees()),
1526                        (1.0, end_coordinates.latitude.degrees()),
1527                    ],
1528                ),
1529            ) {
1530                let distance_channel = distance_channel_from_dense_fit_samples(
1531                    &[
1532                        (0.0, distance_start),
1533                        (0.25, quarter_distance_au),
1534                        (0.5, midpoint_distance_au),
1535                        (0.75, three_quarter_distance_au),
1536                        (1.0, distance_end),
1537                    ],
1538                    distance_start,
1539                    Some(midpoint_distance_au),
1540                    distance_end,
1541                );
1542
1543                return Segment::new(
1544                    start_instant,
1545                    end_instant,
1546                    vec![longitude_channel, latitude_channel, distance_channel],
1547                );
1548            }
1549        }
1550    }
1551
1552    if let (Some(span_days), Some(span_limit)) = (span_days, span_limit) {
1553        if span_days > span_limit * PACKAGED_ARTIFACT_LONGEST_DENSE_SPLIT_SPAN_RATIO {
1554            let one_fifth_coordinates = sample_fraction(1.0 / 5.0);
1555            let two_fifth_coordinates = sample_fraction(2.0 / 5.0);
1556            let three_fifth_coordinates = sample_fraction(3.0 / 5.0);
1557            let four_fifth_coordinates = sample_fraction(4.0 / 5.0);
1558            if let (
1559                Some(one_fifth_coordinates),
1560                Some(two_fifth_coordinates),
1561                Some(three_fifth_coordinates),
1562                Some(four_fifth_coordinates),
1563            ) = (
1564                one_fifth_coordinates.as_ref(),
1565                two_fifth_coordinates.as_ref(),
1566                three_fifth_coordinates.as_ref(),
1567                four_fifth_coordinates.as_ref(),
1568            ) {
1569                let longitude_samples = unwrap_longitude_samples(&[
1570                    start_longitude,
1571                    one_fifth_coordinates.longitude.degrees(),
1572                    two_fifth_coordinates.longitude.degrees(),
1573                    three_fifth_coordinates.longitude.degrees(),
1574                    four_fifth_coordinates.longitude.degrees(),
1575                    end_longitude,
1576                ]);
1577
1578                if let (Some(longitude_channel), Some(latitude_channel)) = (
1579                    channel_from_fit_samples_with_control_points(
1580                        ChannelKind::Longitude,
1581                        9,
1582                        &[
1583                            (0.0, longitude_samples[0]),
1584                            (1.0 / 5.0, longitude_samples[1]),
1585                            (2.0 / 5.0, longitude_samples[2]),
1586                            (3.0 / 5.0, longitude_samples[3]),
1587                            (4.0 / 5.0, longitude_samples[4]),
1588                            (1.0, longitude_samples[5]),
1589                        ],
1590                    ),
1591                    channel_from_fit_samples_with_control_points(
1592                        ChannelKind::Latitude,
1593                        9,
1594                        &[
1595                            (0.0, start_coordinates.latitude.degrees()),
1596                            (1.0 / 5.0, one_fifth_coordinates.latitude.degrees()),
1597                            (2.0 / 5.0, two_fifth_coordinates.latitude.degrees()),
1598                            (3.0 / 5.0, three_fifth_coordinates.latitude.degrees()),
1599                            (4.0 / 5.0, four_fifth_coordinates.latitude.degrees()),
1600                            (1.0, end_coordinates.latitude.degrees()),
1601                        ],
1602                    ),
1603                ) {
1604                    if let (
1605                        Some(one_fifth_distance_au),
1606                        Some(two_fifth_distance_au),
1607                        Some(three_fifth_distance_au),
1608                        Some(four_fifth_distance_au),
1609                    ) = (
1610                        one_fifth_coordinates.distance_au,
1611                        two_fifth_coordinates.distance_au,
1612                        three_fifth_coordinates.distance_au,
1613                        four_fifth_coordinates.distance_au,
1614                    ) {
1615                        let distance_channel = distance_channel_from_fit_samples(
1616                            &[
1617                                (0.0, start_coordinates.distance_au.unwrap_or_default()),
1618                                (1.0 / 5.0, one_fifth_distance_au),
1619                                (2.0 / 5.0, two_fifth_distance_au),
1620                                (3.0 / 5.0, three_fifth_distance_au),
1621                                (4.0 / 5.0, four_fifth_distance_au),
1622                                (1.0, end_coordinates.distance_au.unwrap_or_default()),
1623                            ],
1624                            start_coordinates.distance_au.unwrap_or_default(),
1625                            midpoint_coordinates.distance_au,
1626                            end_coordinates.distance_au.unwrap_or_default(),
1627                        );
1628
1629                        return Segment::new(
1630                            start_instant,
1631                            end_instant,
1632                            vec![longitude_channel, latitude_channel, distance_channel],
1633                        );
1634                    }
1635                }
1636            }
1637        }
1638
1639        if span_days > span_limit * PACKAGED_ARTIFACT_SUPER_EXTREME_DENSE_SPLIT_SPAN_RATIO {
1640            let one_seventh_coordinates = sample_fraction(1.0 / 7.0);
1641            let two_seventh_coordinates = sample_fraction(2.0 / 7.0);
1642            let three_seventh_coordinates = sample_fraction(3.0 / 7.0);
1643            let four_seventh_coordinates = sample_fraction(4.0 / 7.0);
1644            let five_seventh_coordinates = sample_fraction(5.0 / 7.0);
1645            let six_seventh_coordinates = sample_fraction(6.0 / 7.0);
1646            if let (
1647                Some(one_seventh_coordinates),
1648                Some(two_seventh_coordinates),
1649                Some(three_seventh_coordinates),
1650                Some(four_seventh_coordinates),
1651                Some(five_seventh_coordinates),
1652                Some(six_seventh_coordinates),
1653            ) = (
1654                one_seventh_coordinates.as_ref(),
1655                two_seventh_coordinates.as_ref(),
1656                three_seventh_coordinates.as_ref(),
1657                four_seventh_coordinates.as_ref(),
1658                five_seventh_coordinates.as_ref(),
1659                six_seventh_coordinates.as_ref(),
1660            ) {
1661                let longitude_samples = unwrap_longitude_samples(&[
1662                    start_longitude,
1663                    one_seventh_coordinates.longitude.degrees(),
1664                    two_seventh_coordinates.longitude.degrees(),
1665                    three_seventh_coordinates.longitude.degrees(),
1666                    four_seventh_coordinates.longitude.degrees(),
1667                    five_seventh_coordinates.longitude.degrees(),
1668                    six_seventh_coordinates.longitude.degrees(),
1669                    end_longitude,
1670                ]);
1671
1672                if let (Some(longitude_channel), Some(latitude_channel)) = (
1673                    channel_from_dense_fit_samples_with_control_points(
1674                        ChannelKind::Longitude,
1675                        9,
1676                        &[
1677                            (0.0, longitude_samples[0]),
1678                            (1.0 / 7.0, longitude_samples[1]),
1679                            (2.0 / 7.0, longitude_samples[2]),
1680                            (3.0 / 7.0, longitude_samples[3]),
1681                            (4.0 / 7.0, longitude_samples[4]),
1682                            (5.0 / 7.0, longitude_samples[5]),
1683                            (6.0 / 7.0, longitude_samples[6]),
1684                            (1.0, longitude_samples[7]),
1685                        ],
1686                    ),
1687                    channel_from_dense_fit_samples_with_control_points(
1688                        ChannelKind::Latitude,
1689                        9,
1690                        &[
1691                            (0.0, start_coordinates.latitude.degrees()),
1692                            (1.0 / 7.0, one_seventh_coordinates.latitude.degrees()),
1693                            (2.0 / 7.0, two_seventh_coordinates.latitude.degrees()),
1694                            (3.0 / 7.0, three_seventh_coordinates.latitude.degrees()),
1695                            (4.0 / 7.0, four_seventh_coordinates.latitude.degrees()),
1696                            (5.0 / 7.0, five_seventh_coordinates.latitude.degrees()),
1697                            (6.0 / 7.0, six_seventh_coordinates.latitude.degrees()),
1698                            (1.0, end_coordinates.latitude.degrees()),
1699                        ],
1700                    ),
1701                ) {
1702                    if let (
1703                        Some(one_seventh_distance_au),
1704                        Some(two_seventh_distance_au),
1705                        Some(three_seventh_distance_au),
1706                        Some(four_seventh_distance_au),
1707                        Some(five_seventh_distance_au),
1708                        Some(six_seventh_distance_au),
1709                    ) = (
1710                        one_seventh_coordinates.distance_au,
1711                        two_seventh_coordinates.distance_au,
1712                        three_seventh_coordinates.distance_au,
1713                        four_seventh_coordinates.distance_au,
1714                        five_seventh_coordinates.distance_au,
1715                        six_seventh_coordinates.distance_au,
1716                    ) {
1717                        let distance_channel = distance_channel_from_dense_fit_samples(
1718                            &[
1719                                (0.0, start_coordinates.distance_au.unwrap_or_default()),
1720                                (1.0 / 7.0, one_seventh_distance_au),
1721                                (2.0 / 7.0, two_seventh_distance_au),
1722                                (3.0 / 7.0, three_seventh_distance_au),
1723                                (4.0 / 7.0, four_seventh_distance_au),
1724                                (5.0 / 7.0, five_seventh_distance_au),
1725                                (6.0 / 7.0, six_seventh_distance_au),
1726                                (1.0, end_coordinates.distance_au.unwrap_or_default()),
1727                            ],
1728                            start_coordinates.distance_au.unwrap_or_default(),
1729                            midpoint_coordinates.distance_au,
1730                            end_coordinates.distance_au.unwrap_or_default(),
1731                        );
1732
1733                        return Segment::new(
1734                            start_instant,
1735                            end_instant,
1736                            vec![longitude_channel, latitude_channel, distance_channel],
1737                        );
1738                    }
1739                }
1740            }
1741        }
1742    }
1743
1744    let Some(first_third_coordinates) = sample_fraction(1.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 Some(second_third_coordinates) = sample_fraction(2.0 / 3.0) else {
1777        let midpoint_longitude =
1778            unwrap_longitude_degrees(start_longitude, midpoint_coordinates.longitude.degrees());
1779        return Segment::new(
1780            start_instant,
1781            end_instant,
1782            vec![
1783                PolynomialChannel::quadratic(
1784                    ChannelKind::Longitude,
1785                    9,
1786                    start_longitude,
1787                    midpoint_longitude,
1788                    end_longitude,
1789                    0.5,
1790                ),
1791                PolynomialChannel::quadratic(
1792                    ChannelKind::Latitude,
1793                    9,
1794                    start_coordinates.latitude.degrees(),
1795                    midpoint_coordinates.latitude.degrees(),
1796                    end_coordinates.latitude.degrees(),
1797                    0.5,
1798                ),
1799                distance_channel_from_samples(
1800                    distance_start,
1801                    midpoint_coordinates.distance_au,
1802                    distance_end,
1803                ),
1804            ],
1805        );
1806    };
1807
1808    let longitude_samples = unwrap_longitude_samples(&[
1809        start_longitude,
1810        first_third_coordinates.longitude.degrees(),
1811        second_third_coordinates.longitude.degrees(),
1812        end_longitude,
1813    ]);
1814
1815    if let (Some(longitude_channel), Some(latitude_channel)) = (
1816        polynomial_channel_from_samples(
1817            ChannelKind::Longitude,
1818            9,
1819            &[
1820                (0.0, longitude_samples[0]),
1821                (1.0 / 3.0, longitude_samples[1]),
1822                (2.0 / 3.0, longitude_samples[2]),
1823                (1.0, longitude_samples[3]),
1824            ],
1825        ),
1826        polynomial_channel_from_samples(
1827            ChannelKind::Latitude,
1828            9,
1829            &[
1830                (0.0, start_coordinates.latitude.degrees()),
1831                (1.0 / 3.0, first_third_coordinates.latitude.degrees()),
1832                (2.0 / 3.0, second_third_coordinates.latitude.degrees()),
1833                (1.0, end_coordinates.latitude.degrees()),
1834            ],
1835        ),
1836    ) {
1837        let distance_channel = if let (Some(first_third_distance), Some(second_third_distance)) = (
1838            first_third_coordinates.distance_au,
1839            second_third_coordinates.distance_au,
1840        ) {
1841            distance_channel_from_four_point_control_points(
1842                distance_start,
1843                first_third_distance,
1844                second_third_distance,
1845                distance_end,
1846            )
1847            .unwrap_or_else(|| {
1848                distance_channel_from_samples(
1849                    distance_start,
1850                    midpoint_coordinates.distance_au,
1851                    distance_end,
1852                )
1853            })
1854        } else {
1855            distance_channel_from_samples(
1856                distance_start,
1857                midpoint_coordinates.distance_au,
1858                distance_end,
1859            )
1860        };
1861
1862        return Segment::new(
1863            start_instant,
1864            end_instant,
1865            vec![longitude_channel, latitude_channel, distance_channel],
1866        );
1867    }
1868
1869    let midpoint_longitude =
1870        unwrap_longitude_degrees(start_longitude, midpoint_coordinates.longitude.degrees());
1871
1872    Segment::new(
1873        start_instant,
1874        end_instant,
1875        vec![
1876            PolynomialChannel::quadratic(
1877                ChannelKind::Longitude,
1878                9,
1879                start_longitude,
1880                midpoint_longitude,
1881                end_longitude,
1882                0.5,
1883            ),
1884            PolynomialChannel::quadratic(
1885                ChannelKind::Latitude,
1886                9,
1887                start_coordinates.latitude.degrees(),
1888                midpoint_coordinates.latitude.degrees(),
1889                end_coordinates.latitude.degrees(),
1890                0.5,
1891            ),
1892            distance_channel_from_samples(
1893                start_coordinates.distance_au.unwrap_or_default(),
1894                midpoint_distance_au,
1895                end_coordinates.distance_au.unwrap_or_default(),
1896            ),
1897        ],
1898    )
1899}
1900
1901fn segment_with_optional_residual_channels(
1902    body: &CelestialBody,
1903    segment: Segment,
1904    reference_backend: &JplSnapshotBackend,
1905) -> Segment {
1906    let Some(base_error) = packaged_artifact_segment_fit_error(body, &segment, reference_backend)
1907    else {
1908        return segment;
1909    };
1910
1911    let candidate_for_kind = |segment: &Segment,
1912                              channel_kind: ChannelKind|
1913     -> Option<(Segment, PackagedArtifactSegmentFitError)> {
1914        let candidate = residual_segment(body, segment, reference_backend, channel_kind)?;
1915        let candidate_error =
1916            packaged_artifact_segment_fit_error(body, &candidate, reference_backend)?;
1917        Some((candidate, candidate_error))
1918    };
1919
1920    let (best_segment, _) = best_residual_segment(
1921        segment,
1922        base_error,
1923        &[
1924            ChannelKind::Longitude,
1925            ChannelKind::Latitude,
1926            ChannelKind::DistanceAu,
1927        ],
1928        &candidate_for_kind,
1929    );
1930
1931    best_segment
1932}
1933
1934fn residual_segment_is_better(
1935    candidate_segment: &Segment,
1936    candidate_error: PackagedArtifactSegmentFitError,
1937    existing_segment: &Segment,
1938    existing_error: PackagedArtifactSegmentFitError,
1939) -> bool {
1940    match candidate_error
1941        .max_delta()
1942        .total_cmp(&existing_error.max_delta())
1943    {
1944        Ordering::Less => true,
1945        Ordering::Greater => false,
1946        Ordering::Equal => match candidate_segment
1947            .residual_channels
1948            .len()
1949            .cmp(&existing_segment.residual_channels.len())
1950        {
1951            Ordering::Less => true,
1952            Ordering::Greater => false,
1953            Ordering::Equal => {
1954                let candidate_residual_coefficients = candidate_segment
1955                    .residual_channels
1956                    .iter()
1957                    .map(|channel| channel.coefficients.len())
1958                    .sum::<usize>();
1959                let existing_residual_coefficients = existing_segment
1960                    .residual_channels
1961                    .iter()
1962                    .map(|channel| channel.coefficients.len())
1963                    .sum::<usize>();
1964
1965                candidate_residual_coefficients < existing_residual_coefficients
1966            }
1967        },
1968    }
1969}
1970
1971pub(crate) fn best_residual_segment<F>(
1972    current_segment: Segment,
1973    current_error: PackagedArtifactSegmentFitError,
1974    remaining_kinds: &[ChannelKind],
1975    candidate_for_kind: &F,
1976) -> (Segment, PackagedArtifactSegmentFitError)
1977where
1978    F: Fn(&Segment, ChannelKind) -> Option<(Segment, PackagedArtifactSegmentFitError)>,
1979{
1980    let mut best_segment = current_segment.clone();
1981    let mut best_error = current_error;
1982
1983    for kind in remaining_kinds.iter().copied() {
1984        let Some((candidate_segment, candidate_error)) = candidate_for_kind(&current_segment, kind)
1985        else {
1986            continue;
1987        };
1988
1989        let next_remaining_kinds = remaining_kinds
1990            .iter()
1991            .copied()
1992            .filter(|candidate_kind| *candidate_kind != kind)
1993            .collect::<Vec<_>>();
1994
1995        let (recursive_segment, recursive_error) = best_residual_segment(
1996            candidate_segment,
1997            candidate_error,
1998            &next_remaining_kinds,
1999            candidate_for_kind,
2000        );
2001
2002        if residual_segment_is_better(
2003            &recursive_segment,
2004            recursive_error,
2005            &best_segment,
2006            best_error,
2007        ) {
2008            best_segment = recursive_segment;
2009            best_error = recursive_error;
2010        }
2011    }
2012
2013    (best_segment, best_error)
2014}
2015
2016fn residual_segment(
2017    body: &CelestialBody,
2018    segment: &Segment,
2019    reference_backend: &JplSnapshotBackend,
2020    kind: ChannelKind,
2021) -> Option<Segment> {
2022    if segment
2023        .residual_channels
2024        .iter()
2025        .any(|channel| channel.kind == kind)
2026    {
2027        return None;
2028    }
2029
2030    let channel = segment
2031        .channels
2032        .iter()
2033        .find(|channel| channel.kind == kind)?;
2034    let span_days = segment.end.julian_day.days() - segment.start.julian_day.days();
2035    let residual_samples = packaged_artifact_residual_sample_fractions_for_channel(body, kind)
2036        .iter()
2037        .copied()
2038        .map(|fraction| {
2039            let sample_jd = segment.start.julian_day.days() + span_days * fraction;
2040            let request = EphemerisRequest {
2041                body: body.clone(),
2042                instant: Instant::new(JulianDay::from_days(sample_jd), TimeScale::Tt),
2043                observer: None,
2044                frame: CoordinateFrame::Ecliptic,
2045                zodiac_mode: ZodiacMode::Tropical,
2046                apparent: Apparentness::Mean,
2047            };
2048
2049            let expected = reference_backend.position(&request).ok()?.ecliptic?;
2050            let x = if span_days == 0.0 {
2051                0.0
2052            } else {
2053                (sample_jd - segment.start.julian_day.days()) / span_days
2054            };
2055            let current_value = segment_channel_value(segment, kind, x)?;
2056            let residual = match kind {
2057                ChannelKind::Longitude => {
2058                    Angle::from_degrees(expected.longitude.degrees() - current_value)
2059                        .normalized_signed()
2060                        .degrees()
2061                }
2062                ChannelKind::Latitude => expected.latitude.degrees() - current_value,
2063                ChannelKind::DistanceAu => expected.distance_au? - current_value,
2064                _ => unreachable!("unsupported packaged-artifact channel kind"),
2065            };
2066            Some((fraction, residual))
2067        })
2068        .collect::<Option<Vec<_>>>()?;
2069
2070    let residual_channel =
2071        polynomial_channel_from_samples(kind, channel.scale_exponent, &residual_samples)?;
2072
2073    let mut residual_channels = segment.residual_channels.clone();
2074    residual_channels.push(residual_channel);
2075    residual_channels.sort_by_key(|channel| channel.kind as u8);
2076
2077    let residual_segment = Segment::with_residual_channels(
2078        segment.start,
2079        segment.end,
2080        segment.channels.clone(),
2081        residual_channels,
2082    );
2083
2084    if segment_fits_quantization(&residual_segment) {
2085        Some(residual_segment)
2086    } else {
2087        None
2088    }
2089}
2090
2091pub(crate) const PACKAGED_ARTIFACT_RESIDUAL_SAMPLE_FRACTIONS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0];
2092pub(crate) const PACKAGED_ARTIFACT_DENSE_RESIDUAL_SAMPLE_FRACTIONS: &[f64] =
2093    &[0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0];
2094pub(crate) const PACKAGED_ARTIFACT_MEDIUM_VALIDATION_SAMPLE_FRACTIONS: &[f64] =
2095    &[0.125, 0.25, 0.5, 0.75, 0.875];
2096pub(crate) const PACKAGED_ARTIFACT_DENSE_VALIDATION_SAMPLE_FRACTIONS: &[f64] =
2097    &[0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875];
2098pub(crate) const PACKAGED_ARTIFACT_MEDIUM_FIT_SAMPLE_COUNTS: &[usize] = &[6, 8, 10, 12, 14];
2099pub(crate) const PACKAGED_ARTIFACT_DENSE_FIT_SAMPLE_COUNTS: &[usize] =
2100    &[6, 8, 10, 12, 14, 16, 18, 20];
2101
2102pub(crate) fn packaged_artifact_fit_sample_counts_for_body(
2103    body: &CelestialBody,
2104) -> &'static [usize] {
2105    if packaged_artifact_body_cadence(body).uses_dense_sampling() {
2106        PACKAGED_ARTIFACT_DENSE_FIT_SAMPLE_COUNTS
2107    } else {
2108        PACKAGED_ARTIFACT_MEDIUM_FIT_SAMPLE_COUNTS
2109    }
2110}
2111
2112pub(crate) fn packaged_artifact_residual_sample_fractions_for_channel(
2113    body: &CelestialBody,
2114    kind: ChannelKind,
2115) -> &'static [f64] {
2116    if packaged_artifact_body_cadence(body).uses_dense_residual_sample_lattice(kind) {
2117        PACKAGED_ARTIFACT_DENSE_RESIDUAL_SAMPLE_FRACTIONS
2118    } else {
2119        PACKAGED_ARTIFACT_RESIDUAL_SAMPLE_FRACTIONS
2120    }
2121}
2122
2123pub(crate) fn segment_channel_value(segment: &Segment, kind: ChannelKind, x: f64) -> Option<f64> {
2124    let base = segment
2125        .channels
2126        .iter()
2127        .find(|channel| channel.kind == kind)?;
2128    let residual = segment
2129        .residual_channels
2130        .iter()
2131        .find(|channel| channel.kind == kind)
2132        .map(|channel| evaluate_polynomial_channel(channel, x))
2133        .unwrap_or(0.0);
2134
2135    Some(evaluate_polynomial_channel(base, x) + residual)
2136}
2137
2138pub(crate) fn evaluate_polynomial_channel(channel: &PolynomialChannel, x: f64) -> f64 {
2139    let mut result = 0.0;
2140    let mut power = 1.0;
2141    for coefficient in &channel.coefficients {
2142        result += coefficient * power;
2143        power *= x;
2144    }
2145    result
2146}
2147
2148pub(crate) fn chebyshev_lobatto_fractions(sample_count: usize) -> Vec<f64> {
2149    match sample_count {
2150        0 => Vec::new(),
2151        1 => vec![0.0],
2152        _ => (0..sample_count)
2153            .map(|index| {
2154                let theta = std::f64::consts::PI * index as f64 / (sample_count - 1) as f64;
2155                (1.0 - theta.cos()) / 2.0
2156            })
2157            .collect(),
2158    }
2159}
2160
2161fn unwrap_longitude_samples(samples: &[f64]) -> Vec<f64> {
2162    let mut unwrapped = Vec::with_capacity(samples.len());
2163
2164    for &sample in samples {
2165        if let Some(&previous) = unwrapped.last() {
2166            unwrapped.push(unwrap_longitude_degrees(previous, sample));
2167        } else {
2168            unwrapped.push(sample);
2169        }
2170    }
2171
2172    unwrapped
2173}
2174
2175#[allow(clippy::needless_range_loop)]
2176fn fit_polynomial_coefficients(samples: &[(f64, f64)]) -> Option<Vec<f64>> {
2177    let order = samples.len();
2178    if order == 0 {
2179        return None;
2180    }
2181
2182    let mut matrix = vec![vec![0.0; order + 1]; order];
2183    for (row, (x, y)) in samples.iter().enumerate() {
2184        let mut power = 1.0;
2185        for column in 0..order {
2186            matrix[row][column] = power;
2187            power *= *x;
2188        }
2189        matrix[row][order] = *y;
2190    }
2191
2192    for pivot_index in 0..order {
2193        let mut best_row = pivot_index;
2194        let mut best_value = matrix[pivot_index][pivot_index].abs();
2195        for row in (pivot_index + 1)..order {
2196            let candidate = matrix[row][pivot_index].abs();
2197            if candidate > best_value {
2198                best_value = candidate;
2199                best_row = row;
2200            }
2201        }
2202
2203        if best_value == 0.0 {
2204            return None;
2205        }
2206
2207        if best_row != pivot_index {
2208            matrix.swap(pivot_index, best_row);
2209        }
2210
2211        let pivot = matrix[pivot_index][pivot_index];
2212        for column in pivot_index..=order {
2213            matrix[pivot_index][column] /= pivot;
2214        }
2215
2216        for row in 0..order {
2217            if row == pivot_index {
2218                continue;
2219            }
2220
2221            let factor = matrix[row][pivot_index];
2222            if factor == 0.0 {
2223                continue;
2224            }
2225
2226            for column in pivot_index..=order {
2227                matrix[row][column] -= factor * matrix[pivot_index][column];
2228            }
2229        }
2230    }
2231
2232    Some(matrix.into_iter().map(|row| row[order]).collect())
2233}
2234
2235fn channel_coefficients_fit_quantization(scale_exponent: u8, coefficients: &[f64]) -> bool {
2236    let scale = 10f64.powi(scale_exponent as i32);
2237    coefficients.iter().all(|coefficient| {
2238        let scaled = coefficient * scale;
2239        scaled.is_finite() && scaled.round() >= i64::MIN as f64 && scaled.round() <= i64::MAX as f64
2240    })
2241}
2242
2243pub(crate) fn polynomial_channel_from_samples(
2244    kind: ChannelKind,
2245    scale_exponent: u8,
2246    samples: &[(f64, f64)],
2247) -> Option<PolynomialChannel> {
2248    let coefficients = fit_polynomial_coefficients(samples)?;
2249    if !channel_coefficients_fit_quantization(scale_exponent, &coefficients) {
2250        return None;
2251    }
2252
2253    Some(PolynomialChannel::new(kind, scale_exponent, coefficients))
2254}
2255
2256pub(crate) fn coordinates(entry: &SnapshotEntry) -> EclipticCoordinates {
2257    let radius_km =
2258        (entry.x_km * entry.x_km + entry.y_km * entry.y_km + entry.z_km * entry.z_km).sqrt();
2259    let longitude = entry.y_km.atan2(entry.x_km).to_degrees();
2260    let latitude = (entry.z_km / radius_km)
2261        .clamp(-1.0, 1.0)
2262        .asin()
2263        .to_degrees();
2264    EclipticCoordinates::new(
2265        pleiades_backend::Longitude::from_degrees(longitude),
2266        pleiades_backend::Latitude::from_degrees(latitude),
2267        Some(radius_km / AU_IN_KM),
2268    )
2269}
2270
2271pub(crate) fn artifact_time_range(artifact: &CompressedArtifact) -> TimeRange {
2272    let mut start: Option<Instant> = None;
2273    let mut end: Option<Instant> = None;
2274    for body in &artifact.bodies {
2275        for segment in &body.segments {
2276            start = Some(match start {
2277                Some(current) => {
2278                    if segment.start.julian_day.days() < current.julian_day.days() {
2279                        segment.start
2280                    } else {
2281                        current
2282                    }
2283                }
2284                None => segment.start,
2285            });
2286            end = Some(match end {
2287                Some(current) => {
2288                    if segment.end.julian_day.days() > current.julian_day.days() {
2289                        segment.end
2290                    } else {
2291                        current
2292                    }
2293                }
2294                None => segment.end,
2295            });
2296        }
2297    }
2298    TimeRange::new(start, end)
2299}
2300
2301pub(crate) fn normalize_lookup_instant(instant: Instant) -> Instant {
2302    match instant.scale {
2303        TimeScale::Tt => instant,
2304        TimeScale::Tdb => Instant::new(instant.julian_day, TimeScale::Tt),
2305        _ => instant,
2306    }
2307}
2308
2309pub(crate) fn map_artifact_error(error: pleiades_compression::CompressionError) -> EphemerisError {
2310    let kind = match error.kind {
2311        pleiades_compression::CompressionErrorKind::MissingBody => {
2312            EphemerisErrorKind::UnsupportedBody
2313        }
2314        pleiades_compression::CompressionErrorKind::OutOfRangeInstant => {
2315            EphemerisErrorKind::OutOfRangeInstant
2316        }
2317        pleiades_compression::CompressionErrorKind::UnsupportedTimeScale => {
2318            EphemerisErrorKind::UnsupportedTimeScale
2319        }
2320        pleiades_compression::CompressionErrorKind::MissingChannel => {
2321            EphemerisErrorKind::MissingDataset
2322        }
2323        pleiades_compression::CompressionErrorKind::QuantizationOverflow
2324        | pleiades_compression::CompressionErrorKind::InvalidFormat
2325        | pleiades_compression::CompressionErrorKind::UnsupportedEndianPolicy
2326        | pleiades_compression::CompressionErrorKind::InvalidMagic
2327        | pleiades_compression::CompressionErrorKind::UnsupportedVersion
2328        | pleiades_compression::CompressionErrorKind::ChecksumMismatch
2329        | pleiades_compression::CompressionErrorKind::Truncated
2330        | _ => EphemerisErrorKind::NumericalFailure,
2331    };
2332
2333    EphemerisError::new(kind, error.message)
2334}
2335
2336/// Fits one segment over `[t0_jd, t1_jd]` by sampling `reference` (de440 or a
2337/// test backend) at the body's within-span sample count and least-squares
2338/// fitting Longitude/Latitude/DistanceAu channels over the normalized interval.
2339///
2340/// The x-domain for the polynomial fit matches the decoder: `x = (t - t0) / span`
2341/// in `[0, 1]`, consistent with `CompressedArtifact::lookup_ecliptic` (artifact.rs).
2342///
2343/// Scale exponents match the existing generation pipeline: Longitude=9, Latitude=9,
2344/// DistanceAu=10 (see `regenerate.rs` segment_from_single_entry and threshold.rs).
2345pub(crate) fn fit_segment_within_span(
2346    body: &CelestialBody,
2347    t0_jd: f64,
2348    t1_jd: f64,
2349    reference: &dyn EphemerisBackend,
2350) -> Option<Segment> {
2351    use crate::coverage::{fit_polynomial_lsq, fitting_degree, fitting_within_span_sample_count};
2352
2353    let n = fitting_within_span_sample_count(body).max(fitting_degree(body) + 1);
2354    let span = t1_jd - t0_jd;
2355    if span <= 0.0 {
2356        return None;
2357    }
2358    if n < 2 {
2359        return None;
2360    }
2361
2362    let mut xs = Vec::with_capacity(n);
2363    let mut lon_deg = Vec::with_capacity(n);
2364    let mut lat = Vec::with_capacity(n);
2365    let mut dist = Vec::with_capacity(n);
2366    for i in 0..n {
2367        let frac = i as f64 / (n as f64 - 1.0);
2368        let jd = t0_jd + frac * span;
2369        let inst = Instant::new(JulianDay::from_days(jd), TimeScale::Tdb);
2370        let res = reference
2371            .position(&EphemerisRequest::new(body.clone(), inst))
2372            .ok()?;
2373        let ec = res.ecliptic?;
2374        let ec = if body_uses_heliocentric_frame(body) {
2375            let sun = reference
2376                .position(&EphemerisRequest::new(CelestialBody::Sun, inst))
2377                .ok()?
2378                .ecliptic?;
2379            pleiades_compression::heliocentric_from_geocentric(&ec, &sun)?
2380        } else {
2381            ec
2382        };
2383        xs.push(frac);
2384        lon_deg.push(ec.longitude.degrees());
2385        lat.push(ec.latitude.degrees());
2386        dist.push(ec.distance_au?);
2387    }
2388
2389    // Unwrap longitude to a continuous series before fitting (reuse existing helper).
2390    let lon_unwrapped = unwrap_longitude_samples(&lon_deg);
2391
2392    let degree = fitting_degree(body);
2393    let to_samples =
2394        |ys: &[f64]| -> Vec<(f64, f64)> { xs.iter().copied().zip(ys.iter().copied()).collect() };
2395
2396    let lon_coeffs = fit_polynomial_lsq(&to_samples(&lon_unwrapped), degree)?;
2397    let lat_coeffs = fit_polynomial_lsq(&to_samples(&lat), degree)?;
2398    let dist_coeffs = fit_polynomial_lsq(&to_samples(&dist), degree)?;
2399
2400    // Channels must be ordered by ChannelKind discriminant: Longitude=0, Latitude=1, DistanceAu=2.
2401    // Scale exponents match the existing generation pipeline (Longitude=9, Latitude=9, DistanceAu=10).
2402    let channels = vec![
2403        PolynomialChannel::new(ChannelKind::Longitude, 9, lon_coeffs),
2404        PolynomialChannel::new(ChannelKind::Latitude, 9, lat_coeffs),
2405        PolynomialChannel::new(ChannelKind::DistanceAu, 10, dist_coeffs),
2406    ];
2407
2408    // Validate each channel's coefficients are finite (fail-closed).
2409    for channel in &channels {
2410        channel.validate().ok()?;
2411    }
2412
2413    // Segment boundaries are tagged Tt to match the packaged-lookup convention:
2414    // normalize_lookup_instant re-tags every query to Tt, and Segment::contains
2415    // requires matching scales. The sampling instants above remain Tdb (the
2416    // physical ephemeris query scale); the ~2 ms TT/TDB difference is immaterial
2417    // because the stored artifact only carries the boundary tag, not a converted value.
2418    let seg = Segment::new(
2419        Instant::new(JulianDay::from_days(t0_jd), TimeScale::Tt),
2420        Instant::new(JulianDay::from_days(t1_jd), TimeScale::Tt),
2421        channels,
2422    );
2423    Some(seg)
2424}
2425
2426/// Core artifact builder parameterised by an explicit coverage window.
2427///
2428/// Accepts an explicit `base_window` used for all major bodies (planets, Sun,
2429/// Moon). This separation lets the kernel-free unit test pass a tiny synthetic
2430/// window so the test runs in milliseconds instead of the minutes a full
2431/// 1900–2100 default-window build would take.
2432///
2433/// Major bodies (all cadences except `SelectedAsteroids` / `CustomBodies`) are
2434/// fit densely from `reference` (de440) over `base_window` using
2435/// [`fitting_segment_boundaries`] + [`fit_segment_within_span`].
2436///
2437/// The constrained asteroid (Eros) is kernel-free: its curated 1900–2100 corpus
2438/// data is not present in de440. Instead its segments are re-derived from the
2439/// reference snapshot (curated Horizons 1900–2100 corpus data, the same source
2440/// the committed artifact was originally built from). This approach is
2441/// format/version-independent: it does not decode the committed `.bin` and is
2442/// therefore safe across artifact-version bumps. Two regenerations are
2443/// deterministic: the reference snapshot is static committed source data, so
2444/// the same snapshot fit always yields identical segments.
2445pub(crate) fn build_packaged_artifact_from_reference_over(
2446    reference: &dyn EphemerisBackend,
2447    base_window: (f64, f64),
2448) -> CompressedArtifact {
2449    use crate::coverage::fitting_segment_boundaries;
2450
2451    let mut body_artifacts: Vec<(usize, BodyArtifact)> = Vec::new();
2452
2453    std::thread::scope(|scope| {
2454        let mut handles = Vec::new();
2455
2456        for (body_index, body) in packaged_bodies().iter().cloned().enumerate() {
2457            let cadence = packaged_artifact_body_cadence(&body);
2458
2459            match cadence {
2460                PackagedArtifactBodyCadence::SelectedAsteroids
2461                | PackagedArtifactBodyCadence::CustomBodies => {
2462                    // Constrained asteroid (Eros): re-derive segments from the
2463                    // reference snapshot (curated corpus data), the same source
2464                    // the committed artifact was originally built from. This is
2465                    // format/version-independent — do NOT decode the committed
2466                    // artifact or fit from `reference` (de440).
2467                    let snapshot = reference_snapshot();
2468                    let mut entries: Vec<&SnapshotEntry> =
2469                        snapshot.iter().filter(|e| e.body == body).collect();
2470                    if entries.is_empty() {
2471                        panic!(
2472                            "reference snapshot is missing body {body}; \
2473                             the reference snapshot must contain all constrained-asteroid bodies"
2474                        );
2475                    }
2476                    entries.sort_by(|left, right| {
2477                        left.epoch
2478                            .julian_day
2479                            .days()
2480                            .partial_cmp(&right.epoch.julian_day.days())
2481                            .unwrap_or(Ordering::Equal)
2482                    });
2483                    let segments = body_segments_from_entries(&entries, &JplSnapshotBackend);
2484                    handles
2485                        .push(scope.spawn(move || (body_index, BodyArtifact::new(body, segments))));
2486                }
2487                _ => {
2488                    // Major body: fit densely from the reference (de440) backend.
2489                    let (start_jd, end_jd) = base_window;
2490                    handles.push(scope.spawn(move || {
2491                        let spans = fitting_segment_boundaries(&body, start_jd, end_jd);
2492                        let segments: Vec<Segment> = spans
2493                            .into_iter()
2494                            .map(|(t0, t1)| {
2495                                fit_segment_within_span(&body, t0, t1, reference)
2496                                    .unwrap_or_else(|| {
2497                                        panic!(
2498                                            "fit_segment_within_span failed for body {body} over [{t0}, {t1}]"
2499                                        )
2500                                    })
2501                            })
2502                            .collect();
2503                        let frame = if body_uses_heliocentric_frame(&body) {
2504                            pleiades_compression::StoredFrame::Heliocentric
2505                        } else {
2506                            pleiades_compression::StoredFrame::Geocentric
2507                        };
2508                        (body_index, BodyArtifact::with_frame(body, segments, frame))
2509                    }));
2510                }
2511            }
2512        }
2513
2514        for handle in handles {
2515            body_artifacts.push(
2516                handle
2517                    .join()
2518                    .expect("packaged artifact body assembly should not panic"),
2519            );
2520        }
2521    });
2522
2523    body_artifacts.sort_by_key(|(body_index, _)| *body_index);
2524    let bodies: Vec<BodyArtifact> = body_artifacts
2525        .into_iter()
2526        .map(|(_, artifact)| artifact)
2527        .collect();
2528
2529    let mut artifact = CompressedArtifact::new(
2530        ArtifactHeader::new(ARTIFACT_LABEL, packaged_artifact_source_text()),
2531        bodies,
2532    );
2533    artifact.checksum = artifact
2534        .checksum()
2535        .expect("packaged artifact checksum should be reproducible");
2536    artifact
2537        .validate()
2538        .expect("packaged artifact should validate before encoding");
2539    artifact
2540}
2541
2542/// Regenerates the packaged artifact from a de440 SPK kernel over an explicit
2543/// coverage window. Major bodies are fit densely from the kernel across `window`;
2544/// the constrained asteroid (Eros) is always sourced from its fixed 1900–2100
2545/// corpus data and is unaffected by `window`.
2546pub fn regenerate_packaged_artifact_from_kernel_over(
2547    kernel_path: &str,
2548    window: pleiades_jpl::spk::corpus_spec::CoverageWindow,
2549) -> Result<CompressedArtifact, String> {
2550    let backend = pleiades_jpl::SpkBackend::builder()
2551        .add_kernel(kernel_path)
2552        .map_err(|error| error.message)?
2553        .build();
2554    Ok(build_packaged_artifact_from_reference_over(
2555        &backend,
2556        window.as_tuple(),
2557    ))
2558}
2559
2560/// Regenerates the packaged artifact from a de440 SPK kernel over the shipped
2561/// default window (1900–2100).
2562pub fn regenerate_packaged_artifact_from_kernel(
2563    kernel_path: &str,
2564) -> Result<CompressedArtifact, String> {
2565    regenerate_packaged_artifact_from_kernel_over(
2566        kernel_path,
2567        pleiades_jpl::spk::corpus_spec::CoverageWindow::default(),
2568    )
2569}