1use crate::astro::math::vec3::{cross3, dot3, norm3, scale3, sub3, unit3};
22use crate::astro::time::model::{Instant, JulianDateSplit, TimeScale};
23use crate::constants::{C_M_S, SECONDS_PER_DAY};
24use crate::ephemeris::{BroadcastEphemeris, Sp3};
25use crate::error::{Error, Result};
26use crate::observables::ObservableEphemerisSource;
27use crate::GnssSatelliteId;
28
29#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct EpochInputs {
37 pub broadcast_t_j2000_s: f64,
39 pub precise: JulianDateSplit,
41 pub precise_plus: JulianDateSplit,
43 pub precise_minus: JulianDateSplit,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct CompareStats {
59 pub count: usize,
61 pub orbit_3d_rms_m: Option<f64>,
63 pub orbit_3d_max_m: Option<f64>,
65 pub radial_rms_m: Option<f64>,
67 pub radial_max_m: Option<f64>,
69 pub along_rms_m: Option<f64>,
71 pub along_max_m: Option<f64>,
73 pub cross_rms_m: Option<f64>,
75 pub cross_max_m: Option<f64>,
77 pub clock_rms_m: Option<f64>,
79 pub clock_max_m: Option<f64>,
81 pub clock_datum_removed_rms_m: Option<f64>,
83 pub clock_datum_removed_max_m: Option<f64>,
85}
86
87impl CompareStats {
88 fn empty() -> Self {
90 Self {
91 count: 0,
92 orbit_3d_rms_m: None,
93 orbit_3d_max_m: None,
94 radial_rms_m: None,
95 radial_max_m: None,
96 along_rms_m: None,
97 along_max_m: None,
98 cross_rms_m: None,
99 cross_max_m: None,
100 clock_rms_m: None,
101 clock_max_m: None,
102 clock_datum_removed_rms_m: None,
103 clock_datum_removed_max_m: None,
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq)]
115pub struct CompareReport {
116 pub per_satellite: Vec<(GnssSatelliteId, CompareStats)>,
118 pub overall: CompareStats,
120 pub missing: Vec<(GnssSatelliteId, usize)>,
122}
123
124#[derive(Debug, Clone, Copy)]
127struct Diff {
128 epoch_index: usize,
129 orbit_3d: f64,
130 radial: f64,
131 along: f64,
132 cross: f64,
133 clock_m: Option<f64>,
134 clock_residual_m: Option<f64>,
135}
136
137struct SatelliteDiffs {
139 satellite: GnssSatelliteId,
140 diffs: Vec<Diff>,
141 missing: usize,
142}
143
144pub fn compare(
150 broadcast: &BroadcastEphemeris,
151 precise: &Sp3,
152 satellites: &[GnssSatelliteId],
153 epochs: &[EpochInputs],
154 velocity_half_s: f64,
155) -> Result<CompareReport> {
156 validate_compare_inputs(epochs, velocity_half_s)?;
157
158 let scale = precise.header.time_scale;
159
160 let mut per_sat: Vec<SatelliteDiffs> = Vec::with_capacity(satellites.len());
162 for &sat in satellites {
163 let mut diffs = Vec::new();
164 let mut missing = 0usize;
165 for (idx, ep) in epochs.iter().enumerate() {
166 match diff_at(broadcast, precise, scale, sat, idx, ep, velocity_half_s) {
167 Some(diff) => diffs.push(diff),
168 None => missing += 1,
169 }
170 }
171 per_sat.push(SatelliteDiffs {
172 satellite: sat,
173 diffs,
174 missing,
175 });
176 }
177
178 let mut all: Vec<Diff> = Vec::new();
180 for sat in &per_sat {
181 all.extend(sat.diffs.iter().copied());
182 }
183
184 let datum = clock_datum_by_epoch(&all, epochs.len());
188
189 let overall = aggregate(&enrich(&all, &datum));
190 let per_satellite = per_sat
191 .iter()
192 .map(|sat| (sat.satellite, aggregate(&enrich(&sat.diffs, &datum))))
193 .collect();
194
195 let mut missing: Vec<(GnssSatelliteId, usize)> = per_sat
196 .iter()
197 .filter(|sat| sat.missing > 0)
198 .map(|sat| (sat.satellite, sat.missing))
199 .collect();
200 missing.sort_by(|a, b| a.0.to_string().cmp(&b.0.to_string()).then(a.1.cmp(&b.1)));
201
202 Ok(CompareReport {
203 per_satellite,
204 overall,
205 missing,
206 })
207}
208
209#[derive(Debug, Clone, Copy, PartialEq)]
224pub struct CompareWindow {
225 pub broadcast_window_j2000_s: (f64, f64),
228 pub precise_start: JulianDateSplit,
231 pub step_s: f64,
233 pub velocity_half_s: f64,
236}
237
238pub fn compare_window_epochs(window: &CompareWindow) -> Result<Vec<EpochInputs>> {
247 validate_window(window)?;
248
249 let (t0, _t1) = window.broadcast_window_j2000_s;
250 let times = sample_times(window.broadcast_window_j2000_s, window.step_s);
251 let mut epochs = Vec::with_capacity(times.len());
252 for t in times {
253 let dt = t - t0;
254 epochs.push(EpochInputs {
255 broadcast_t_j2000_s: t,
256 precise: advance_split(window.precise_start, dt),
257 precise_plus: advance_split(window.precise_start, dt + window.velocity_half_s),
258 precise_minus: advance_split(window.precise_start, dt - window.velocity_half_s),
259 });
260 }
261 Ok(epochs)
262}
263
264pub fn compare_window(
272 broadcast: &BroadcastEphemeris,
273 precise: &Sp3,
274 satellites: &[GnssSatelliteId],
275 window: &CompareWindow,
276) -> Result<CompareReport> {
277 let epochs = compare_window_epochs(window)?;
278 compare(
279 broadcast,
280 precise,
281 satellites,
282 &epochs,
283 window.velocity_half_s,
284 )
285}
286
287fn advance_split(start: JulianDateSplit, delta_s: f64) -> JulianDateSplit {
290 let total_fraction = start.fraction + delta_s / SECONDS_PER_DAY;
291 let whole_days = total_fraction.trunc();
292 JulianDateSplit {
293 jd_whole: start.jd_whole + whole_days,
294 fraction: total_fraction - whole_days,
295 }
296}
297
298fn sample_times(window_j2000_s: (f64, f64), step_s: f64) -> Vec<f64> {
302 let (t0, t1) = window_j2000_s;
303 if t0 > t1 {
304 return Vec::new();
305 }
306 let mut out = Vec::new();
307 let mut step_index = 0usize;
308 loop {
309 let t = t0 + step_s * step_index as f64;
310 if t > t1 {
311 break;
312 }
313 out.push(t);
314 step_index += 1;
315 }
316 if let Some(&last) = out.last() {
317 if last < t1 {
318 out.push(t1);
319 }
320 }
321 out
322}
323
324fn validate_window(window: &CompareWindow) -> Result<()> {
325 let (t0, t1) = window.broadcast_window_j2000_s;
326 validate_finite(t0, "window.broadcast_window_j2000_s.0")?;
327 validate_finite(t1, "window.broadcast_window_j2000_s.1")?;
328 validate_finite(window.step_s, "window.step_s")?;
329 if window.step_s <= 0.0 {
330 return Err(invalid_input("window.step_s", "not positive"));
331 }
332 validate_finite(window.velocity_half_s, "window.velocity_half_s")?;
333 if window.velocity_half_s <= 0.0 {
334 return Err(invalid_input("window.velocity_half_s", "not positive"));
335 }
336 validate_finite(
337 window.precise_start.jd_whole,
338 "window.precise_start.jd_whole",
339 )?;
340 validate_finite(
341 window.precise_start.fraction,
342 "window.precise_start.fraction",
343 )?;
344 Ok(())
345}
346
347fn validate_compare_inputs(epochs: &[EpochInputs], velocity_half_s: f64) -> Result<()> {
348 validate_finite(velocity_half_s, "velocity_half_s")?;
349 if velocity_half_s <= 0.0 {
350 return Err(invalid_input("velocity_half_s", "not positive"));
351 }
352 for (index, epoch) in epochs.iter().enumerate() {
353 validate_finite(epoch.broadcast_t_j2000_s, "epochs.broadcast_t_j2000_s")?;
354 validate_split(epoch.precise, index, "precise")?;
355 validate_split(epoch.precise_plus, index, "precise_plus")?;
356 validate_split(epoch.precise_minus, index, "precise_minus")?;
357 }
358 Ok(())
359}
360
361fn validate_split(split: JulianDateSplit, _index: usize, field: &'static str) -> Result<()> {
362 validate_finite(split.jd_whole, field)?;
363 validate_finite(split.fraction, field)?;
364 if !(-1.0..=1.0).contains(&split.fraction) {
365 return Err(invalid_input(field, "fraction out of range"));
366 }
367 Ok(())
368}
369
370fn validate_finite(value: f64, field: &'static str) -> Result<()> {
371 if value.is_finite() {
372 Ok(())
373 } else {
374 Err(invalid_input(field, "not finite"))
375 }
376}
377
378fn invalid_input(field: &'static str, reason: &'static str) -> Error {
379 Error::InvalidInput(format!("{field} {reason}"))
380}
381
382fn diff_at(
386 broadcast: &BroadcastEphemeris,
387 precise: &Sp3,
388 scale: TimeScale,
389 sat: GnssSatelliteId,
390 epoch_index: usize,
391 ep: &EpochInputs,
392 velocity_half_s: f64,
393) -> Option<Diff> {
394 let (b_pos, b_clock) = broadcast_state(broadcast, sat, ep.broadcast_t_j2000_s)?;
395 let (p_pos, p_clock) = precise_state(precise, scale, sat, ep.precise)?;
396 let vel = precise_velocity(precise, scale, sat, ep, p_pos, velocity_half_s)?;
397
398 let d = sub3(b_pos, p_pos);
399 let (radial, along, cross) = project_rac(d, p_pos, vel);
400
401 let clock_m = match (b_clock, p_clock) {
402 (Some(bc), Some(pc)) => Some((bc - pc) * C_M_S),
403 _ => None,
404 };
405
406 Some(Diff {
407 epoch_index,
408 orbit_3d: norm3(d),
409 radial,
410 along,
411 cross,
412 clock_m,
413 clock_residual_m: None,
414 })
415}
416
417fn broadcast_state(
419 broadcast: &BroadcastEphemeris,
420 sat: GnssSatelliteId,
421 t_j2000_s: f64,
422) -> Option<([f64; 3], Option<f64>)> {
423 let state = broadcast.observable_state_at_j2000_s(sat, t_j2000_s).ok()?;
424 Some((state.position_ecef_m, state.clock_s))
425}
426
427fn precise_state(
429 precise: &Sp3,
430 scale: TimeScale,
431 sat: GnssSatelliteId,
432 split: JulianDateSplit,
433) -> Option<([f64; 3], Option<f64>)> {
434 let state = precise
435 .position(sat, Instant::from_julian_date(scale, split))
436 .ok()?;
437 Some((state.position.as_array(), state.clock_s))
438}
439
440fn precise_position(
442 precise: &Sp3,
443 scale: TimeScale,
444 sat: GnssSatelliteId,
445 split: JulianDateSplit,
446) -> Option<[f64; 3]> {
447 precise_state(precise, scale, sat, split).map(|(pos, _clock)| pos)
448}
449
450fn precise_velocity(
454 precise: &Sp3,
455 scale: TimeScale,
456 sat: GnssSatelliteId,
457 ep: &EpochInputs,
458 r0: [f64; 3],
459 half_s: f64,
460) -> Option<[f64; 3]> {
461 let rp = precise_position(precise, scale, sat, ep.precise_plus);
462 let rm = precise_position(precise, scale, sat, ep.precise_minus);
463 finite_difference_velocity(rp, rm, r0, half_s)
464}
465
466fn finite_difference_velocity(
471 rp: Option<[f64; 3]>,
472 rm: Option<[f64; 3]>,
473 r0: [f64; 3],
474 half_s: f64,
475) -> Option<[f64; 3]> {
476 match (rp, rm) {
477 (Some(rp), Some(rm)) => Some(scale3(sub3(rp, rm), 1.0 / (2.0 * half_s))),
478 (Some(rp), None) => Some(scale3(sub3(rp, r0), 1.0 / half_s)),
479 (None, Some(rm)) => Some(scale3(sub3(r0, rm), 1.0 / half_s)),
480 (None, None) => None,
481 }
482}
483
484fn project_rac(d: [f64; 3], r: [f64; 3], v: [f64; 3]) -> (f64, f64, f64) {
489 let radial_hat = unit3(r).unwrap_or([0.0, 0.0, 0.0]);
490 let cross_hat = unit3(cross3(r, v)).unwrap_or([0.0, 0.0, 0.0]);
491 let along_hat = cross3(cross_hat, radial_hat);
492 (dot3(d, radial_hat), dot3(d, along_hat), dot3(d, cross_hat))
493}
494
495fn clock_datum_by_epoch(all: &[Diff], n_epochs: usize) -> Vec<Option<f64>> {
499 let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); n_epochs];
500 for diff in all {
501 if let Some(clock) = diff.clock_m {
502 buckets[diff.epoch_index].push(clock);
503 }
504 }
505 buckets.iter().map(|clocks| median(clocks)).collect()
506}
507
508fn enrich(diffs: &[Diff], datum: &[Option<f64>]) -> Vec<Diff> {
511 diffs
512 .iter()
513 .map(|diff| {
514 let clock_residual_m = match (diff.clock_m, datum[diff.epoch_index]) {
515 (Some(clock), Some(d)) => Some(clock - d),
516 _ => None,
517 };
518 Diff {
519 clock_residual_m,
520 ..*diff
521 }
522 })
523 .collect()
524}
525
526fn aggregate(diffs: &[Diff]) -> CompareStats {
528 if diffs.is_empty() {
529 return CompareStats::empty();
530 }
531
532 let orbit: Vec<f64> = diffs.iter().map(|d| d.orbit_3d).collect();
533 let radial: Vec<f64> = diffs.iter().map(|d| d.radial).collect();
534 let along: Vec<f64> = diffs.iter().map(|d| d.along).collect();
535 let cross: Vec<f64> = diffs.iter().map(|d| d.cross).collect();
536 let clocks: Vec<f64> = diffs.iter().filter_map(|d| d.clock_m).collect();
537 let clock_resids: Vec<f64> = diffs.iter().filter_map(|d| d.clock_residual_m).collect();
538
539 CompareStats {
540 count: diffs.len(),
541 orbit_3d_rms_m: Some(rms(&orbit)),
542 orbit_3d_max_m: Some(max_abs(&orbit)),
543 radial_rms_m: Some(rms(&radial)),
544 radial_max_m: Some(max_abs(&radial)),
545 along_rms_m: Some(rms(&along)),
546 along_max_m: Some(max_abs(&along)),
547 cross_rms_m: Some(rms(&cross)),
548 cross_max_m: Some(max_abs(&cross)),
549 clock_rms_m: rms_or_none(&clocks),
550 clock_max_m: max_abs_or_none(&clocks),
551 clock_datum_removed_rms_m: rms_or_none(&clock_resids),
552 clock_datum_removed_max_m: max_abs_or_none(&clock_resids),
553 }
554}
555
556fn rms(values: &[f64]) -> f64 {
558 let sum_sq = values.iter().fold(0.0, |acc, &x| acc + x * x);
559 (sum_sq / values.len() as f64).sqrt()
560}
561
562fn rms_or_none(values: &[f64]) -> Option<f64> {
563 if values.is_empty() {
564 None
565 } else {
566 Some(rms(values))
567 }
568}
569
570fn max_abs(values: &[f64]) -> f64 {
572 values
573 .iter()
574 .map(|v| v.abs())
575 .reduce(f64::max)
576 .expect("max_abs requires a non-empty slice")
577}
578
579fn max_abs_or_none(values: &[f64]) -> Option<f64> {
580 if values.is_empty() {
581 None
582 } else {
583 Some(max_abs(values))
584 }
585}
586
587fn median(values: &[f64]) -> Option<f64> {
589 if values.is_empty() {
590 return None;
591 }
592 let mut sorted = values.to_vec();
593 sorted.sort_by(f64::total_cmp);
594 let n = sorted.len();
595 let mid = n / 2;
596 if n % 2 == 1 {
597 Some(sorted[mid])
598 } else {
599 Some((sorted[mid - 1] + sorted[mid]) / 2.0)
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 fn cell(epoch_index: usize, orbit: f64, r: f64, a: f64, c: f64, clock: Option<f64>) -> Diff {
608 Diff {
609 epoch_index,
610 orbit_3d: orbit,
611 radial: r,
612 along: a,
613 cross: c,
614 clock_m: clock,
615 clock_residual_m: None,
616 }
617 }
618
619 #[test]
620 fn window_grid_matches_independent_construction() {
621 let window = CompareWindow {
622 broadcast_window_j2000_s: (1_000.0, 1_000.0 + 3.0 * 900.0),
623 precise_start: JulianDateSplit::new(2_451_545.0, 0.25).expect("valid split"),
624 step_s: 900.0,
625 velocity_half_s: 450.0,
626 };
627
628 let grid = compare_window_epochs(&window).expect("window grid");
629
630 let (t0, _t1) = window.broadcast_window_j2000_s;
634 let advance = |seconds: f64| {
635 let total = window.precise_start.fraction + seconds / SECONDS_PER_DAY;
636 let days = total.trunc();
637 JulianDateSplit {
638 jd_whole: window.precise_start.jd_whole + days,
639 fraction: total - days,
640 }
641 };
642 let mut expected = Vec::new();
643 for index in 0..4 {
644 let dt = 900.0 * index as f64;
645 expected.push(EpochInputs {
646 broadcast_t_j2000_s: t0 + dt,
647 precise: advance(dt),
648 precise_plus: advance(dt + 450.0),
649 precise_minus: advance(dt - 450.0),
650 });
651 }
652
653 assert_eq!(grid, expected);
654 }
655
656 #[test]
657 fn window_grid_snaps_final_sample_to_window_end() {
658 let window = CompareWindow {
661 broadcast_window_j2000_s: (0.0, 1_000.0),
662 precise_start: JulianDateSplit::new(2_451_545.0, 0.0).expect("valid split"),
663 step_s: 400.0,
664 velocity_half_s: 200.0,
665 };
666 let grid = compare_window_epochs(&window).expect("window grid");
667 let times: Vec<f64> = grid.iter().map(|e| e.broadcast_t_j2000_s).collect();
668 assert_eq!(times, vec![0.0, 400.0, 800.0, 1_000.0]);
669 }
670
671 #[test]
672 fn window_grid_is_empty_when_start_after_end() {
673 let window = CompareWindow {
674 broadcast_window_j2000_s: (2_000.0, 1_000.0),
675 precise_start: JulianDateSplit::new(2_451_545.0, 0.0).expect("valid split"),
676 step_s: 100.0,
677 velocity_half_s: 50.0,
678 };
679 assert!(compare_window_epochs(&window)
680 .expect("empty grid")
681 .is_empty());
682 }
683
684 #[test]
685 fn window_rejects_non_positive_step() {
686 let window = CompareWindow {
687 broadcast_window_j2000_s: (0.0, 1_000.0),
688 precise_start: JulianDateSplit::new(2_451_545.0, 0.0).expect("valid split"),
689 step_s: 0.0,
690 velocity_half_s: 50.0,
691 };
692 assert!(matches!(
693 compare_window_epochs(&window),
694 Err(Error::InvalidInput(_))
695 ));
696 }
697
698 #[test]
699 fn median_odd_and_even_match_reference() {
700 assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
701 assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), Some(2.5));
702 assert_eq!(median(&[]), None);
703 }
704
705 #[test]
706 fn rms_folds_left_from_zero() {
707 let values = [1.0, 2.0, 2.0];
708 let expected = ((1.0_f64 + 4.0 + 4.0) / 3.0).sqrt();
709 assert_eq!(rms(&values).to_bits(), expected.to_bits());
710 assert_eq!(rms_or_none(&[]), None);
711 assert_eq!(max_abs(&[-3.0, 2.0, -1.0]), 3.0);
712 }
713
714 #[test]
717 fn rac_projection_has_frozen_bits() {
718 let r = [7.0e6, 1.0e6, 2.0e6];
719 let v = [-500.0, 7000.0, 1000.0];
720 let d = [3.0, -4.0, 5.0];
721 let (radial, along, cross) = project_rac(d, r, v);
722
723 assert_eq!(radial.to_bits(), 0x400d64d51e0db1c6);
724 assert_eq!(along.to_bits(), 0xc00eed09ea935852);
725 assert_eq!(cross.to_bits(), 0x40129246dff98f29);
726
727 let quad = (radial * radial + along * along + cross * cross).sqrt();
729 assert!((quad - norm3(d)).abs() < 1.0e-9);
730 }
731
732 #[test]
733 fn finite_difference_velocity_has_frozen_bits() {
734 let rp = [1.0e3, 2.0e3, 3.0e3];
735 let rm = [-1.0e3, 1.0e3, 2.5e3];
736 let r0 = [100.0, 1600.0, 2700.0];
737 let half = 450.0;
738
739 let centered = finite_difference_velocity(Some(rp), Some(rm), r0, half).unwrap();
740 assert_eq!(centered[0].to_bits(), 0x4001c71c71c71c72);
741 assert_eq!(centered[1].to_bits(), 0x3ff1c71c71c71c72);
742 assert_eq!(centered[2].to_bits(), 0x3fe1c71c71c71c72);
743
744 let one_sided = finite_difference_velocity(Some(rp), None, r0, half).unwrap();
745 assert_eq!(one_sided[0].to_bits(), 0x4000000000000000);
746 assert_eq!(one_sided[1].to_bits(), 0x3fec71c71c71c71c);
747 assert_eq!(one_sided[2].to_bits(), 0x3fe5555555555555);
748
749 assert_eq!(finite_difference_velocity(None, None, r0, half), None);
750 }
751
752 #[test]
757 fn aggregation_and_datum_have_frozen_bits() {
758 let g01 = [
759 cell(0, 1.0, 0.6, 0.8, 0.0, Some(10.0)),
760 cell(1, 2.0, 1.2, 1.6, 0.0, Some(12.0)),
761 cell(2, 3.0, 1.8, 2.4, 0.0, None),
762 ];
763 let g02 = [
764 cell(0, 4.0, 2.4, 3.2, 0.0, Some(20.0)),
765 cell(1, 5.0, 3.0, 4.0, 0.0, Some(8.0)),
766 ];
767
768 let mut all = Vec::new();
769 all.extend(g01.iter().copied());
770 all.extend(g02.iter().copied());
771 let datum = clock_datum_by_epoch(&all, 3);
772 assert_eq!(datum, vec![Some(15.0), Some(10.0), None]);
774
775 let overall = aggregate(&enrich(&all, &datum));
776 assert_eq!(overall.count, 5);
777 assert_eq!(
778 overall.orbit_3d_rms_m.unwrap().to_bits(),
779 0x400a887293fd6f34
780 );
781 assert_eq!(
782 overall.orbit_3d_max_m.unwrap().to_bits(),
783 0x4014000000000000
784 );
785 assert_eq!(overall.clock_rms_m.unwrap().to_bits(), 0x402a9bb78af6cabc);
786 assert_eq!(
787 overall.clock_datum_removed_rms_m.unwrap().to_bits(),
788 0x400e768d399dc470
789 );
790 assert_eq!(
791 overall.clock_datum_removed_max_m.unwrap().to_bits(),
792 0x4014000000000000
793 );
794
795 let g01_stats = aggregate(&enrich(&g01, &datum));
796 assert_eq!(g01_stats.count, 3);
797 assert_eq!(
798 g01_stats.orbit_3d_rms_m.unwrap().to_bits(),
799 0x4001482f86c40c43
800 );
801 assert_eq!(g01_stats.clock_rms_m.unwrap().to_bits(), 0x402617398f2aaa48);
803 assert_eq!(
804 g01_stats.clock_datum_removed_rms_m.unwrap().to_bits(),
805 0x400e768d399dc470
806 );
807 }
808}