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