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