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