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;
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
209const SECONDS_PER_DAY: f64 = 86_400.0;
212
213#[derive(Debug, Clone, Copy, PartialEq)]
228pub struct CompareWindow {
229 pub broadcast_window_j2000_s: (f64, f64),
232 pub precise_start: JulianDateSplit,
235 pub step_s: f64,
237 pub velocity_half_s: f64,
240}
241
242pub fn compare_window_epochs(window: &CompareWindow) -> Result<Vec<EpochInputs>> {
251 validate_window(window)?;
252
253 let (t0, _t1) = window.broadcast_window_j2000_s;
254 let times = sample_times(window.broadcast_window_j2000_s, window.step_s);
255 let mut epochs = Vec::with_capacity(times.len());
256 for t in times {
257 let dt = t - t0;
258 epochs.push(EpochInputs {
259 broadcast_t_j2000_s: t,
260 precise: advance_split(window.precise_start, dt),
261 precise_plus: advance_split(window.precise_start, dt + window.velocity_half_s),
262 precise_minus: advance_split(window.precise_start, dt - window.velocity_half_s),
263 });
264 }
265 Ok(epochs)
266}
267
268pub fn compare_window(
276 broadcast: &BroadcastEphemeris,
277 precise: &Sp3,
278 satellites: &[GnssSatelliteId],
279 window: &CompareWindow,
280) -> Result<CompareReport> {
281 let epochs = compare_window_epochs(window)?;
282 compare(
283 broadcast,
284 precise,
285 satellites,
286 &epochs,
287 window.velocity_half_s,
288 )
289}
290
291fn advance_split(start: JulianDateSplit, delta_s: f64) -> JulianDateSplit {
294 let total_fraction = start.fraction + delta_s / SECONDS_PER_DAY;
295 let whole_days = total_fraction.trunc();
296 JulianDateSplit {
297 jd_whole: start.jd_whole + whole_days,
298 fraction: total_fraction - whole_days,
299 }
300}
301
302fn sample_times(window_j2000_s: (f64, f64), step_s: f64) -> Vec<f64> {
306 let (t0, t1) = window_j2000_s;
307 if t0 > t1 {
308 return Vec::new();
309 }
310 let mut out = Vec::new();
311 let mut step_index = 0usize;
312 loop {
313 let t = t0 + step_s * step_index as f64;
314 if t > t1 {
315 break;
316 }
317 out.push(t);
318 step_index += 1;
319 }
320 if let Some(&last) = out.last() {
321 if last < t1 {
322 out.push(t1);
323 }
324 }
325 out
326}
327
328fn validate_window(window: &CompareWindow) -> Result<()> {
329 let (t0, t1) = window.broadcast_window_j2000_s;
330 validate_finite(t0, "window.broadcast_window_j2000_s.0")?;
331 validate_finite(t1, "window.broadcast_window_j2000_s.1")?;
332 validate_finite(window.step_s, "window.step_s")?;
333 if window.step_s <= 0.0 {
334 return Err(invalid_input("window.step_s", "not positive"));
335 }
336 validate_finite(window.velocity_half_s, "window.velocity_half_s")?;
337 if window.velocity_half_s <= 0.0 {
338 return Err(invalid_input("window.velocity_half_s", "not positive"));
339 }
340 validate_finite(
341 window.precise_start.jd_whole,
342 "window.precise_start.jd_whole",
343 )?;
344 validate_finite(
345 window.precise_start.fraction,
346 "window.precise_start.fraction",
347 )?;
348 Ok(())
349}
350
351fn validate_compare_inputs(epochs: &[EpochInputs], velocity_half_s: f64) -> Result<()> {
352 validate_finite(velocity_half_s, "velocity_half_s")?;
353 if velocity_half_s <= 0.0 {
354 return Err(invalid_input("velocity_half_s", "not positive"));
355 }
356 for (index, epoch) in epochs.iter().enumerate() {
357 validate_finite(epoch.broadcast_t_j2000_s, "epochs.broadcast_t_j2000_s")?;
358 validate_split(epoch.precise, index, "precise")?;
359 validate_split(epoch.precise_plus, index, "precise_plus")?;
360 validate_split(epoch.precise_minus, index, "precise_minus")?;
361 }
362 Ok(())
363}
364
365fn validate_split(split: JulianDateSplit, _index: usize, field: &'static str) -> Result<()> {
366 validate_finite(split.jd_whole, field)?;
367 validate_finite(split.fraction, field)?;
368 if !(-1.0..=1.0).contains(&split.fraction) {
369 return Err(invalid_input(field, "fraction out of range"));
370 }
371 Ok(())
372}
373
374fn validate_finite(value: f64, field: &'static str) -> Result<()> {
375 if value.is_finite() {
376 Ok(())
377 } else {
378 Err(invalid_input(field, "not finite"))
379 }
380}
381
382fn invalid_input(field: &'static str, reason: &'static str) -> Error {
383 Error::InvalidInput(format!("{field} {reason}"))
384}
385
386fn diff_at(
390 broadcast: &BroadcastEphemeris,
391 precise: &Sp3,
392 scale: TimeScale,
393 sat: GnssSatelliteId,
394 epoch_index: usize,
395 ep: &EpochInputs,
396 velocity_half_s: f64,
397) -> Option<Diff> {
398 let (b_pos, b_clock) = broadcast_state(broadcast, sat, ep.broadcast_t_j2000_s)?;
399 let (p_pos, p_clock) = precise_state(precise, scale, sat, ep.precise)?;
400 let vel = precise_velocity(precise, scale, sat, ep, p_pos, velocity_half_s)?;
401
402 let d = sub3(b_pos, p_pos);
403 let (radial, along, cross) = project_rac(d, p_pos, vel);
404
405 let clock_m = match (b_clock, p_clock) {
406 (Some(bc), Some(pc)) => Some((bc - pc) * C_M_S),
407 _ => None,
408 };
409
410 Some(Diff {
411 epoch_index,
412 orbit_3d: norm3(d),
413 radial,
414 along,
415 cross,
416 clock_m,
417 clock_residual_m: None,
418 })
419}
420
421fn broadcast_state(
423 broadcast: &BroadcastEphemeris,
424 sat: GnssSatelliteId,
425 t_j2000_s: f64,
426) -> Option<([f64; 3], Option<f64>)> {
427 let state = broadcast.observable_state_at_j2000_s(sat, t_j2000_s).ok()?;
428 Some((state.position_ecef_m, state.clock_s))
429}
430
431fn precise_state(
433 precise: &Sp3,
434 scale: TimeScale,
435 sat: GnssSatelliteId,
436 split: JulianDateSplit,
437) -> Option<([f64; 3], Option<f64>)> {
438 let state = precise
439 .position(sat, Instant::from_julian_date(scale, split))
440 .ok()?;
441 Some((state.position.as_array(), state.clock_s))
442}
443
444fn precise_position(
446 precise: &Sp3,
447 scale: TimeScale,
448 sat: GnssSatelliteId,
449 split: JulianDateSplit,
450) -> Option<[f64; 3]> {
451 precise_state(precise, scale, sat, split).map(|(pos, _clock)| pos)
452}
453
454fn precise_velocity(
458 precise: &Sp3,
459 scale: TimeScale,
460 sat: GnssSatelliteId,
461 ep: &EpochInputs,
462 r0: [f64; 3],
463 half_s: f64,
464) -> Option<[f64; 3]> {
465 let rp = precise_position(precise, scale, sat, ep.precise_plus);
466 let rm = precise_position(precise, scale, sat, ep.precise_minus);
467 finite_difference_velocity(rp, rm, r0, half_s)
468}
469
470fn finite_difference_velocity(
475 rp: Option<[f64; 3]>,
476 rm: Option<[f64; 3]>,
477 r0: [f64; 3],
478 half_s: f64,
479) -> Option<[f64; 3]> {
480 match (rp, rm) {
481 (Some(rp), Some(rm)) => Some(scale3(sub3(rp, rm), 1.0 / (2.0 * half_s))),
482 (Some(rp), None) => Some(scale3(sub3(rp, r0), 1.0 / half_s)),
483 (None, Some(rm)) => Some(scale3(sub3(r0, rm), 1.0 / half_s)),
484 (None, None) => None,
485 }
486}
487
488fn project_rac(d: [f64; 3], r: [f64; 3], v: [f64; 3]) -> (f64, f64, f64) {
493 let radial_hat = unit3(r).unwrap_or([0.0, 0.0, 0.0]);
494 let cross_hat = unit3(cross3(r, v)).unwrap_or([0.0, 0.0, 0.0]);
495 let along_hat = cross3(cross_hat, radial_hat);
496 (dot3(d, radial_hat), dot3(d, along_hat), dot3(d, cross_hat))
497}
498
499fn clock_datum_by_epoch(all: &[Diff], n_epochs: usize) -> Vec<Option<f64>> {
503 let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); n_epochs];
504 for diff in all {
505 if let Some(clock) = diff.clock_m {
506 buckets[diff.epoch_index].push(clock);
507 }
508 }
509 buckets.iter().map(|clocks| median(clocks)).collect()
510}
511
512fn enrich(diffs: &[Diff], datum: &[Option<f64>]) -> Vec<Diff> {
515 diffs
516 .iter()
517 .map(|diff| {
518 let clock_residual_m = match (diff.clock_m, datum[diff.epoch_index]) {
519 (Some(clock), Some(d)) => Some(clock - d),
520 _ => None,
521 };
522 Diff {
523 clock_residual_m,
524 ..*diff
525 }
526 })
527 .collect()
528}
529
530fn aggregate(diffs: &[Diff]) -> CompareStats {
532 if diffs.is_empty() {
533 return CompareStats::empty();
534 }
535
536 let orbit: Vec<f64> = diffs.iter().map(|d| d.orbit_3d).collect();
537 let radial: Vec<f64> = diffs.iter().map(|d| d.radial).collect();
538 let along: Vec<f64> = diffs.iter().map(|d| d.along).collect();
539 let cross: Vec<f64> = diffs.iter().map(|d| d.cross).collect();
540 let clocks: Vec<f64> = diffs.iter().filter_map(|d| d.clock_m).collect();
541 let clock_resids: Vec<f64> = diffs.iter().filter_map(|d| d.clock_residual_m).collect();
542
543 CompareStats {
544 count: diffs.len(),
545 orbit_3d_rms_m: Some(rms(&orbit)),
546 orbit_3d_max_m: Some(max_abs(&orbit)),
547 radial_rms_m: Some(rms(&radial)),
548 radial_max_m: Some(max_abs(&radial)),
549 along_rms_m: Some(rms(&along)),
550 along_max_m: Some(max_abs(&along)),
551 cross_rms_m: Some(rms(&cross)),
552 cross_max_m: Some(max_abs(&cross)),
553 clock_rms_m: rms_or_none(&clocks),
554 clock_max_m: max_abs_or_none(&clocks),
555 clock_datum_removed_rms_m: rms_or_none(&clock_resids),
556 clock_datum_removed_max_m: max_abs_or_none(&clock_resids),
557 }
558}
559
560fn rms(values: &[f64]) -> f64 {
562 let sum_sq = values.iter().fold(0.0, |acc, &x| acc + x * x);
563 (sum_sq / values.len() as f64).sqrt()
564}
565
566fn rms_or_none(values: &[f64]) -> Option<f64> {
567 if values.is_empty() {
568 None
569 } else {
570 Some(rms(values))
571 }
572}
573
574fn max_abs(values: &[f64]) -> f64 {
576 values
577 .iter()
578 .map(|v| v.abs())
579 .reduce(f64::max)
580 .expect("max_abs requires a non-empty slice")
581}
582
583fn max_abs_or_none(values: &[f64]) -> Option<f64> {
584 if values.is_empty() {
585 None
586 } else {
587 Some(max_abs(values))
588 }
589}
590
591fn median(values: &[f64]) -> Option<f64> {
593 if values.is_empty() {
594 return None;
595 }
596 let mut sorted = values.to_vec();
597 sorted.sort_by(f64::total_cmp);
598 let n = sorted.len();
599 let mid = n / 2;
600 if n % 2 == 1 {
601 Some(sorted[mid])
602 } else {
603 Some((sorted[mid - 1] + sorted[mid]) / 2.0)
604 }
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 fn cell(epoch_index: usize, orbit: f64, r: f64, a: f64, c: f64, clock: Option<f64>) -> Diff {
612 Diff {
613 epoch_index,
614 orbit_3d: orbit,
615 radial: r,
616 along: a,
617 cross: c,
618 clock_m: clock,
619 clock_residual_m: None,
620 }
621 }
622
623 #[test]
624 fn window_grid_matches_independent_construction() {
625 let window = CompareWindow {
626 broadcast_window_j2000_s: (1_000.0, 1_000.0 + 3.0 * 900.0),
627 precise_start: JulianDateSplit::new(2_451_545.0, 0.25).expect("valid split"),
628 step_s: 900.0,
629 velocity_half_s: 450.0,
630 };
631
632 let grid = compare_window_epochs(&window).expect("window grid");
633
634 let (t0, _t1) = window.broadcast_window_j2000_s;
638 let advance = |seconds: f64| {
639 let total = window.precise_start.fraction + seconds / 86_400.0;
640 let days = total.trunc();
641 JulianDateSplit {
642 jd_whole: window.precise_start.jd_whole + days,
643 fraction: total - days,
644 }
645 };
646 let mut expected = Vec::new();
647 for index in 0..4 {
648 let dt = 900.0 * index as f64;
649 expected.push(EpochInputs {
650 broadcast_t_j2000_s: t0 + dt,
651 precise: advance(dt),
652 precise_plus: advance(dt + 450.0),
653 precise_minus: advance(dt - 450.0),
654 });
655 }
656
657 assert_eq!(grid, expected);
658 }
659
660 #[test]
661 fn window_grid_snaps_final_sample_to_window_end() {
662 let window = CompareWindow {
665 broadcast_window_j2000_s: (0.0, 1_000.0),
666 precise_start: JulianDateSplit::new(2_451_545.0, 0.0).expect("valid split"),
667 step_s: 400.0,
668 velocity_half_s: 200.0,
669 };
670 let grid = compare_window_epochs(&window).expect("window grid");
671 let times: Vec<f64> = grid.iter().map(|e| e.broadcast_t_j2000_s).collect();
672 assert_eq!(times, vec![0.0, 400.0, 800.0, 1_000.0]);
673 }
674
675 #[test]
676 fn window_grid_is_empty_when_start_after_end() {
677 let window = CompareWindow {
678 broadcast_window_j2000_s: (2_000.0, 1_000.0),
679 precise_start: JulianDateSplit::new(2_451_545.0, 0.0).expect("valid split"),
680 step_s: 100.0,
681 velocity_half_s: 50.0,
682 };
683 assert!(compare_window_epochs(&window)
684 .expect("empty grid")
685 .is_empty());
686 }
687
688 #[test]
689 fn window_rejects_non_positive_step() {
690 let window = CompareWindow {
691 broadcast_window_j2000_s: (0.0, 1_000.0),
692 precise_start: JulianDateSplit::new(2_451_545.0, 0.0).expect("valid split"),
693 step_s: 0.0,
694 velocity_half_s: 50.0,
695 };
696 assert!(matches!(
697 compare_window_epochs(&window),
698 Err(Error::InvalidInput(_))
699 ));
700 }
701
702 #[test]
703 fn median_odd_and_even_match_reference() {
704 assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
705 assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), Some(2.5));
706 assert_eq!(median(&[]), None);
707 }
708
709 #[test]
710 fn rms_folds_left_from_zero() {
711 let values = [1.0, 2.0, 2.0];
712 let expected = ((1.0_f64 + 4.0 + 4.0) / 3.0).sqrt();
713 assert_eq!(rms(&values).to_bits(), expected.to_bits());
714 assert_eq!(rms_or_none(&[]), None);
715 assert_eq!(max_abs(&[-3.0, 2.0, -1.0]), 3.0);
716 }
717
718 #[test]
721 fn rac_projection_has_frozen_bits() {
722 let r = [7.0e6, 1.0e6, 2.0e6];
723 let v = [-500.0, 7000.0, 1000.0];
724 let d = [3.0, -4.0, 5.0];
725 let (radial, along, cross) = project_rac(d, r, v);
726
727 assert_eq!(radial.to_bits(), 0x400d64d51e0db1c6);
728 assert_eq!(along.to_bits(), 0xc00eed09ea935852);
729 assert_eq!(cross.to_bits(), 0x40129246dff98f29);
730
731 let quad = (radial * radial + along * along + cross * cross).sqrt();
733 assert!((quad - norm3(d)).abs() < 1.0e-9);
734 }
735
736 #[test]
737 fn finite_difference_velocity_has_frozen_bits() {
738 let rp = [1.0e3, 2.0e3, 3.0e3];
739 let rm = [-1.0e3, 1.0e3, 2.5e3];
740 let r0 = [100.0, 1600.0, 2700.0];
741 let half = 450.0;
742
743 let centered = finite_difference_velocity(Some(rp), Some(rm), r0, half).unwrap();
744 assert_eq!(centered[0].to_bits(), 0x4001c71c71c71c72);
745 assert_eq!(centered[1].to_bits(), 0x3ff1c71c71c71c72);
746 assert_eq!(centered[2].to_bits(), 0x3fe1c71c71c71c72);
747
748 let one_sided = finite_difference_velocity(Some(rp), None, r0, half).unwrap();
749 assert_eq!(one_sided[0].to_bits(), 0x4000000000000000);
750 assert_eq!(one_sided[1].to_bits(), 0x3fec71c71c71c71c);
751 assert_eq!(one_sided[2].to_bits(), 0x3fe5555555555555);
752
753 assert_eq!(finite_difference_velocity(None, None, r0, half), None);
754 }
755
756 #[test]
761 fn aggregation_and_datum_have_frozen_bits() {
762 let g01 = [
763 cell(0, 1.0, 0.6, 0.8, 0.0, Some(10.0)),
764 cell(1, 2.0, 1.2, 1.6, 0.0, Some(12.0)),
765 cell(2, 3.0, 1.8, 2.4, 0.0, None),
766 ];
767 let g02 = [
768 cell(0, 4.0, 2.4, 3.2, 0.0, Some(20.0)),
769 cell(1, 5.0, 3.0, 4.0, 0.0, Some(8.0)),
770 ];
771
772 let mut all = Vec::new();
773 all.extend(g01.iter().copied());
774 all.extend(g02.iter().copied());
775 let datum = clock_datum_by_epoch(&all, 3);
776 assert_eq!(datum, vec![Some(15.0), Some(10.0), None]);
778
779 let overall = aggregate(&enrich(&all, &datum));
780 assert_eq!(overall.count, 5);
781 assert_eq!(
782 overall.orbit_3d_rms_m.unwrap().to_bits(),
783 0x400a887293fd6f34
784 );
785 assert_eq!(
786 overall.orbit_3d_max_m.unwrap().to_bits(),
787 0x4014000000000000
788 );
789 assert_eq!(overall.clock_rms_m.unwrap().to_bits(), 0x402a9bb78af6cabc);
790 assert_eq!(
791 overall.clock_datum_removed_rms_m.unwrap().to_bits(),
792 0x400e768d399dc470
793 );
794 assert_eq!(
795 overall.clock_datum_removed_max_m.unwrap().to_bits(),
796 0x4014000000000000
797 );
798
799 let g01_stats = aggregate(&enrich(&g01, &datum));
800 assert_eq!(g01_stats.count, 3);
801 assert_eq!(
802 g01_stats.orbit_3d_rms_m.unwrap().to_bits(),
803 0x4001482f86c40c43
804 );
805 assert_eq!(g01_stats.clock_rms_m.unwrap().to_bits(), 0x402617398f2aaa48);
807 assert_eq!(
808 g01_stats.clock_datum_removed_rms_m.unwrap().to_bits(),
809 0x400e768d399dc470
810 );
811 }
812}