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)]
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
209fn validate_compare_inputs(epochs: &[EpochInputs], velocity_half_s: f64) -> Result<()> {
210 validate_finite(velocity_half_s, "velocity_half_s")?;
211 if velocity_half_s <= 0.0 {
212 return Err(invalid_input("velocity_half_s", "not positive"));
213 }
214 for (index, epoch) in epochs.iter().enumerate() {
215 validate_finite(epoch.broadcast_t_j2000_s, "epochs.broadcast_t_j2000_s")?;
216 validate_split(epoch.precise, index, "precise")?;
217 validate_split(epoch.precise_plus, index, "precise_plus")?;
218 validate_split(epoch.precise_minus, index, "precise_minus")?;
219 }
220 Ok(())
221}
222
223fn validate_split(split: JulianDateSplit, _index: usize, field: &'static str) -> Result<()> {
224 validate_finite(split.jd_whole, field)?;
225 validate_finite(split.fraction, field)?;
226 if !(-1.0..=1.0).contains(&split.fraction) {
227 return Err(invalid_input(field, "fraction out of range"));
228 }
229 Ok(())
230}
231
232fn validate_finite(value: f64, field: &'static str) -> Result<()> {
233 if value.is_finite() {
234 Ok(())
235 } else {
236 Err(invalid_input(field, "not finite"))
237 }
238}
239
240fn invalid_input(field: &'static str, reason: &'static str) -> Error {
241 Error::InvalidInput(format!("{field} {reason}"))
242}
243
244fn diff_at(
248 broadcast: &BroadcastEphemeris,
249 precise: &Sp3,
250 scale: TimeScale,
251 sat: GnssSatelliteId,
252 epoch_index: usize,
253 ep: &EpochInputs,
254 velocity_half_s: f64,
255) -> Option<Diff> {
256 let (b_pos, b_clock) = broadcast_state(broadcast, sat, ep.broadcast_t_j2000_s)?;
257 let (p_pos, p_clock) = precise_state(precise, scale, sat, ep.precise)?;
258 let vel = precise_velocity(precise, scale, sat, ep, p_pos, velocity_half_s)?;
259
260 let d = sub3(b_pos, p_pos);
261 let (radial, along, cross) = project_rac(d, p_pos, vel);
262
263 let clock_m = match (b_clock, p_clock) {
264 (Some(bc), Some(pc)) => Some((bc - pc) * C_M_S),
265 _ => None,
266 };
267
268 Some(Diff {
269 epoch_index,
270 orbit_3d: norm3(d),
271 radial,
272 along,
273 cross,
274 clock_m,
275 clock_residual_m: None,
276 })
277}
278
279fn broadcast_state(
281 broadcast: &BroadcastEphemeris,
282 sat: GnssSatelliteId,
283 t_j2000_s: f64,
284) -> Option<([f64; 3], Option<f64>)> {
285 let state = broadcast.observable_state_at_j2000_s(sat, t_j2000_s).ok()?;
286 Some((state.position_ecef_m, state.clock_s))
287}
288
289fn precise_state(
291 precise: &Sp3,
292 scale: TimeScale,
293 sat: GnssSatelliteId,
294 split: JulianDateSplit,
295) -> Option<([f64; 3], Option<f64>)> {
296 let state = precise
297 .position(sat, Instant::from_julian_date(scale, split))
298 .ok()?;
299 Some((state.position.as_array(), state.clock_s))
300}
301
302fn precise_position(
304 precise: &Sp3,
305 scale: TimeScale,
306 sat: GnssSatelliteId,
307 split: JulianDateSplit,
308) -> Option<[f64; 3]> {
309 precise_state(precise, scale, sat, split).map(|(pos, _clock)| pos)
310}
311
312fn precise_velocity(
316 precise: &Sp3,
317 scale: TimeScale,
318 sat: GnssSatelliteId,
319 ep: &EpochInputs,
320 r0: [f64; 3],
321 half_s: f64,
322) -> Option<[f64; 3]> {
323 let rp = precise_position(precise, scale, sat, ep.precise_plus);
324 let rm = precise_position(precise, scale, sat, ep.precise_minus);
325 finite_difference_velocity(rp, rm, r0, half_s)
326}
327
328fn finite_difference_velocity(
333 rp: Option<[f64; 3]>,
334 rm: Option<[f64; 3]>,
335 r0: [f64; 3],
336 half_s: f64,
337) -> Option<[f64; 3]> {
338 match (rp, rm) {
339 (Some(rp), Some(rm)) => Some(scale3(sub3(rp, rm), 1.0 / (2.0 * half_s))),
340 (Some(rp), None) => Some(scale3(sub3(rp, r0), 1.0 / half_s)),
341 (None, Some(rm)) => Some(scale3(sub3(r0, rm), 1.0 / half_s)),
342 (None, None) => None,
343 }
344}
345
346fn project_rac(d: [f64; 3], r: [f64; 3], v: [f64; 3]) -> (f64, f64, f64) {
351 let radial_hat = unit3(r).unwrap_or([0.0, 0.0, 0.0]);
352 let cross_hat = unit3(cross3(r, v)).unwrap_or([0.0, 0.0, 0.0]);
353 let along_hat = cross3(cross_hat, radial_hat);
354 (dot3(d, radial_hat), dot3(d, along_hat), dot3(d, cross_hat))
355}
356
357fn clock_datum_by_epoch(all: &[Diff], n_epochs: usize) -> Vec<Option<f64>> {
361 let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); n_epochs];
362 for diff in all {
363 if let Some(clock) = diff.clock_m {
364 buckets[diff.epoch_index].push(clock);
365 }
366 }
367 buckets.iter().map(|clocks| median(clocks)).collect()
368}
369
370fn enrich(diffs: &[Diff], datum: &[Option<f64>]) -> Vec<Diff> {
373 diffs
374 .iter()
375 .map(|diff| {
376 let clock_residual_m = match (diff.clock_m, datum[diff.epoch_index]) {
377 (Some(clock), Some(d)) => Some(clock - d),
378 _ => None,
379 };
380 Diff {
381 clock_residual_m,
382 ..*diff
383 }
384 })
385 .collect()
386}
387
388fn aggregate(diffs: &[Diff]) -> CompareStats {
390 if diffs.is_empty() {
391 return CompareStats::empty();
392 }
393
394 let orbit: Vec<f64> = diffs.iter().map(|d| d.orbit_3d).collect();
395 let radial: Vec<f64> = diffs.iter().map(|d| d.radial).collect();
396 let along: Vec<f64> = diffs.iter().map(|d| d.along).collect();
397 let cross: Vec<f64> = diffs.iter().map(|d| d.cross).collect();
398 let clocks: Vec<f64> = diffs.iter().filter_map(|d| d.clock_m).collect();
399 let clock_resids: Vec<f64> = diffs.iter().filter_map(|d| d.clock_residual_m).collect();
400
401 CompareStats {
402 count: diffs.len(),
403 orbit_3d_rms_m: Some(rms(&orbit)),
404 orbit_3d_max_m: Some(max_abs(&orbit)),
405 radial_rms_m: Some(rms(&radial)),
406 radial_max_m: Some(max_abs(&radial)),
407 along_rms_m: Some(rms(&along)),
408 along_max_m: Some(max_abs(&along)),
409 cross_rms_m: Some(rms(&cross)),
410 cross_max_m: Some(max_abs(&cross)),
411 clock_rms_m: rms_or_none(&clocks),
412 clock_max_m: max_abs_or_none(&clocks),
413 clock_datum_removed_rms_m: rms_or_none(&clock_resids),
414 clock_datum_removed_max_m: max_abs_or_none(&clock_resids),
415 }
416}
417
418fn rms(values: &[f64]) -> f64 {
420 let sum_sq = values.iter().fold(0.0, |acc, &x| acc + x * x);
421 (sum_sq / values.len() as f64).sqrt()
422}
423
424fn rms_or_none(values: &[f64]) -> Option<f64> {
425 if values.is_empty() {
426 None
427 } else {
428 Some(rms(values))
429 }
430}
431
432fn max_abs(values: &[f64]) -> f64 {
434 values
435 .iter()
436 .map(|v| v.abs())
437 .reduce(f64::max)
438 .expect("max_abs requires a non-empty slice")
439}
440
441fn max_abs_or_none(values: &[f64]) -> Option<f64> {
442 if values.is_empty() {
443 None
444 } else {
445 Some(max_abs(values))
446 }
447}
448
449fn median(values: &[f64]) -> Option<f64> {
451 if values.is_empty() {
452 return None;
453 }
454 let mut sorted = values.to_vec();
455 sorted.sort_by(f64::total_cmp);
456 let n = sorted.len();
457 let mid = n / 2;
458 if n % 2 == 1 {
459 Some(sorted[mid])
460 } else {
461 Some((sorted[mid - 1] + sorted[mid]) / 2.0)
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 fn cell(epoch_index: usize, orbit: f64, r: f64, a: f64, c: f64, clock: Option<f64>) -> Diff {
470 Diff {
471 epoch_index,
472 orbit_3d: orbit,
473 radial: r,
474 along: a,
475 cross: c,
476 clock_m: clock,
477 clock_residual_m: None,
478 }
479 }
480
481 #[test]
482 fn median_odd_and_even_match_reference() {
483 assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
484 assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), Some(2.5));
485 assert_eq!(median(&[]), None);
486 }
487
488 #[test]
489 fn rms_folds_left_from_zero() {
490 let values = [1.0, 2.0, 2.0];
491 let expected = ((1.0_f64 + 4.0 + 4.0) / 3.0).sqrt();
492 assert_eq!(rms(&values).to_bits(), expected.to_bits());
493 assert_eq!(rms_or_none(&[]), None);
494 assert_eq!(max_abs(&[-3.0, 2.0, -1.0]), 3.0);
495 }
496
497 #[test]
500 fn rac_projection_has_frozen_bits() {
501 let r = [7.0e6, 1.0e6, 2.0e6];
502 let v = [-500.0, 7000.0, 1000.0];
503 let d = [3.0, -4.0, 5.0];
504 let (radial, along, cross) = project_rac(d, r, v);
505
506 assert_eq!(radial.to_bits(), 0x400d64d51e0db1c6);
507 assert_eq!(along.to_bits(), 0xc00eed09ea935852);
508 assert_eq!(cross.to_bits(), 0x40129246dff98f29);
509
510 let quad = (radial * radial + along * along + cross * cross).sqrt();
512 assert!((quad - norm3(d)).abs() < 1.0e-9);
513 }
514
515 #[test]
516 fn finite_difference_velocity_has_frozen_bits() {
517 let rp = [1.0e3, 2.0e3, 3.0e3];
518 let rm = [-1.0e3, 1.0e3, 2.5e3];
519 let r0 = [100.0, 1600.0, 2700.0];
520 let half = 450.0;
521
522 let centered = finite_difference_velocity(Some(rp), Some(rm), r0, half).unwrap();
523 assert_eq!(centered[0].to_bits(), 0x4001c71c71c71c72);
524 assert_eq!(centered[1].to_bits(), 0x3ff1c71c71c71c72);
525 assert_eq!(centered[2].to_bits(), 0x3fe1c71c71c71c72);
526
527 let one_sided = finite_difference_velocity(Some(rp), None, r0, half).unwrap();
528 assert_eq!(one_sided[0].to_bits(), 0x4000000000000000);
529 assert_eq!(one_sided[1].to_bits(), 0x3fec71c71c71c71c);
530 assert_eq!(one_sided[2].to_bits(), 0x3fe5555555555555);
531
532 assert_eq!(finite_difference_velocity(None, None, r0, half), None);
533 }
534
535 #[test]
540 fn aggregation_and_datum_have_frozen_bits() {
541 let g01 = [
542 cell(0, 1.0, 0.6, 0.8, 0.0, Some(10.0)),
543 cell(1, 2.0, 1.2, 1.6, 0.0, Some(12.0)),
544 cell(2, 3.0, 1.8, 2.4, 0.0, None),
545 ];
546 let g02 = [
547 cell(0, 4.0, 2.4, 3.2, 0.0, Some(20.0)),
548 cell(1, 5.0, 3.0, 4.0, 0.0, Some(8.0)),
549 ];
550
551 let mut all = Vec::new();
552 all.extend(g01.iter().copied());
553 all.extend(g02.iter().copied());
554 let datum = clock_datum_by_epoch(&all, 3);
555 assert_eq!(datum, vec![Some(15.0), Some(10.0), None]);
557
558 let overall = aggregate(&enrich(&all, &datum));
559 assert_eq!(overall.count, 5);
560 assert_eq!(
561 overall.orbit_3d_rms_m.unwrap().to_bits(),
562 0x400a887293fd6f34
563 );
564 assert_eq!(
565 overall.orbit_3d_max_m.unwrap().to_bits(),
566 0x4014000000000000
567 );
568 assert_eq!(overall.clock_rms_m.unwrap().to_bits(), 0x402a9bb78af6cabc);
569 assert_eq!(
570 overall.clock_datum_removed_rms_m.unwrap().to_bits(),
571 0x400e768d399dc470
572 );
573 assert_eq!(
574 overall.clock_datum_removed_max_m.unwrap().to_bits(),
575 0x4014000000000000
576 );
577
578 let g01_stats = aggregate(&enrich(&g01, &datum));
579 assert_eq!(g01_stats.count, 3);
580 assert_eq!(
581 g01_stats.orbit_3d_rms_m.unwrap().to_bits(),
582 0x4001482f86c40c43
583 );
584 assert_eq!(g01_stats.clock_rms_m.unwrap().to_bits(), 0x402617398f2aaa48);
586 assert_eq!(
587 g01_stats.clock_datum_removed_rms_m.unwrap().to_bits(),
588 0x400e768d399dc470
589 );
590 }
591}