Skip to main content

pleiades_data/
regenerate.rs

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