1pub(crate) mod range;
4
5use crate::astro::frames::transforms::itrs_to_geodetic_compute;
6use std::collections::BTreeSet;
7use std::f64::consts::PI;
8
9use crate::astro::angles::normalize_geodetic_lon_rad;
10
11use crate::constants::{C_M_S, F_L1_HZ, KM_TO_M, OMEGA_E_DOT_RAD_S};
12pub use crate::dop::{
13 dop, dop_with_convention, error_ellipse_2x2, error_ellipse_from_geometry, geometry_cofactor,
14 geometry_cofactor_with_convention, horizontal_error_ellipse, line_of_sight_from_az_el_deg,
15 position_covariance_from_geometry_m2, Dop, DopError, EnuConvention, ErrorEllipse2,
16 GeometryCofactor, HorizontalErrorEllipse, LineOfSight, PositionCovariance,
17};
18pub use crate::frame::{ItrfPositionM, ItrfVelocityMS, Wgs84Geodetic};
19use crate::observables::{predict, PredictOptions};
20pub use crate::observables::{
21 transmit_time_satellite_state, ObservableEphemerisSource, ObservableState, ObservablesError,
22 TransmitTimeOptions, TransmitTimeSatelliteState,
23};
24use crate::validate;
25use crate::{GnssSatelliteId, GnssSystem};
26
27pub type Error = DopError;
29
30const DEFAULT_ELEVATION_MASK_DEG: f64 = 5.0;
31const DEG_TO_RAD: f64 = PI / 180.0;
32
33pub fn visible_at_elevation_mask(elevation_deg: f64, mask_deg: f64) -> bool {
35 elevation_deg >= mask_deg
36}
37
38pub fn sagnac_rotate_ecef_m(position_ecef_m: [f64; 3], signal_flight_time_s: f64) -> [f64; 3] {
44 sagnac_rotate_ecef_m_with_rate(position_ecef_m, signal_flight_time_s, OMEGA_E_DOT_RAD_S)
45}
46
47pub fn sagnac_rotate_ecef_m_with_rate(
50 position_ecef_m: [f64; 3],
51 signal_flight_time_s: f64,
52 omega_rad_s: f64,
53) -> [f64; 3] {
54 range::sagnac_rotate_exact(position_ecef_m, signal_flight_time_s, omega_rad_s)
55}
56
57pub fn sagnac_range_first_order_m(satellite_ecef_m: [f64; 3], receiver_ecef_m: [f64; 3]) -> f64 {
63 sagnac_range_first_order_m_with_rate(
64 satellite_ecef_m,
65 receiver_ecef_m,
66 OMEGA_E_DOT_RAD_S,
67 C_M_S,
68 )
69}
70
71pub fn sagnac_range_first_order_m_with_rate(
74 satellite_ecef_m: [f64; 3],
75 receiver_ecef_m: [f64; 3],
76 omega_rad_s: f64,
77 c_m_s: f64,
78) -> f64 {
79 range::sagnac_range_first_order(satellite_ecef_m, receiver_ecef_m, omega_rad_s, c_m_s)
80}
81
82#[derive(Debug, Clone, PartialEq)]
84pub struct VisibilityOptions {
85 pub elevation_mask_deg: f64,
87 pub systems: Option<BTreeSet<GnssSystem>>,
89}
90
91impl Default for VisibilityOptions {
92 fn default() -> Self {
93 Self {
94 elevation_mask_deg: DEFAULT_ELEVATION_MASK_DEG,
95 systems: None,
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
102pub enum DopWeighting {
103 #[default]
105 Unit,
106 Elevation,
108}
109
110#[derive(Debug, Clone, PartialEq)]
112pub struct DopOptions {
113 pub visibility: VisibilityOptions,
115 pub weighting: DopWeighting,
117 pub light_time: bool,
119}
120
121impl Default for DopOptions {
122 fn default() -> Self {
123 Self {
124 visibility: VisibilityOptions::default(),
125 weighting: DopWeighting::Unit,
126 light_time: false,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq)]
133pub struct VisibleSatellite {
134 pub satellite: GnssSatelliteId,
136 pub elevation_deg: f64,
138 pub azimuth_deg: f64,
140}
141
142#[derive(Debug, Clone, PartialEq)]
144pub struct DopAtEpoch {
145 pub dop: Dop,
147 pub satellites: Vec<GnssSatelliteId>,
149}
150
151#[derive(Debug, Clone, PartialEq)]
153pub struct DopSeriesPoint {
154 pub step_index: usize,
156 pub geometry: DopAtEpoch,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct VisibilitySeriesPoint {
163 pub step_index: usize,
165 pub n_visible: usize,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq)]
171pub struct VisibilityPass {
172 pub satellite: GnssSatelliteId,
174 pub rise_step_index: usize,
176 pub set_step_index: usize,
178 pub peak_elevation_deg: f64,
180 pub peak_step_index: usize,
182}
183
184#[derive(Debug, Clone, Copy)]
185struct VisibilitySample {
186 step_index: usize,
187 elevation_deg: f64,
188}
189
190pub fn visible(
192 source: &dyn ObservableEphemerisSource,
193 satellites: &[GnssSatelliteId],
194 receiver_ecef_m: [f64; 3],
195 t_rx_j2000_s: f64,
196 options: &VisibilityOptions,
197) -> Result<Vec<VisibleSatellite>, DopError> {
198 validate_visibility_options(options)?;
199
200 let mut visible = Vec::new();
201 for &sat in satellites {
202 if !system_allowed(sat, options.systems.as_ref()) {
203 continue;
204 }
205
206 let prediction = predict(
207 source,
208 sat,
209 receiver_ecef_m,
210 t_rx_j2000_s,
211 PredictOptions {
212 carrier_hz: F_L1_HZ,
213 light_time: false,
214 sagnac: true,
215 },
216 );
217 let Ok(obs) = prediction else {
218 continue;
219 };
220 if visible_at_elevation_mask(obs.elevation_deg, options.elevation_mask_deg) {
221 visible.push(VisibleSatellite {
222 satellite: sat,
223 elevation_deg: obs.elevation_deg,
224 azimuth_deg: obs.azimuth_deg,
225 });
226 }
227 }
228
229 visible.sort_by(|a, b| b.elevation_deg.total_cmp(&a.elevation_deg));
230 Ok(visible)
231}
232
233pub fn dop_at_epoch(
235 source: &dyn ObservableEphemerisSource,
236 all_satellites: &[GnssSatelliteId],
237 explicit_satellites: Option<&[GnssSatelliteId]>,
238 receiver_ecef_m: [f64; 3],
239 t_rx_j2000_s: f64,
240 options: &DopOptions,
241) -> Result<DopAtEpoch, DopError> {
242 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_geometry_input)?;
243 validate_visibility_options(&options.visibility)?;
244
245 let selected: Vec<GnssSatelliteId> = match explicit_satellites {
246 Some(satellites) => satellites.to_vec(),
247 None => visible(
248 source,
249 all_satellites,
250 receiver_ecef_m,
251 t_rx_j2000_s,
252 &options.visibility,
253 )?
254 .into_iter()
255 .map(|sat| sat.satellite)
256 .collect(),
257 };
258
259 let mut line_of_sight = Vec::new();
260 let mut weights = Vec::new();
261 let mut used = Vec::new();
262 for sat in selected {
263 let prediction = predict(
264 source,
265 sat,
266 receiver_ecef_m,
267 t_rx_j2000_s,
268 PredictOptions {
269 carrier_hz: F_L1_HZ,
270 light_time: options.light_time,
271 sagnac: options.light_time,
272 },
273 );
274 let Ok(obs) = prediction else {
275 continue;
276 };
277 line_of_sight.push(LineOfSight::new(
278 obs.los_unit[0],
279 obs.los_unit[1],
280 obs.los_unit[2],
281 ));
282 weights.push(weight_for(options.weighting, obs.elevation_deg));
283 used.push(sat);
284 }
285
286 let receiver = receiver_geodetic(receiver_ecef_m)?;
287 let dop = dop(&line_of_sight, &weights, receiver)?;
288 Ok(DopAtEpoch {
289 dop,
290 satellites: used,
291 })
292}
293
294pub fn dop_series(
296 source: &dyn ObservableEphemerisSource,
297 all_satellites: &[GnssSatelliteId],
298 explicit_satellites: Option<&[GnssSatelliteId]>,
299 receiver_ecef_m: [f64; 3],
300 window_j2000_s: (f64, f64),
301 step_seconds: u64,
302 options: &DopOptions,
303) -> Result<Vec<DopSeriesPoint>, DopError> {
304 validate::finite_vec3(receiver_ecef_m, "receiver_ecef_m").map_err(map_geometry_input)?;
305 validate_visibility_options(&options.visibility)?;
306
307 let mut out = Vec::new();
308 for (step_index, t_rx_j2000_s) in sample_times(window_j2000_s, step_seconds)? {
309 if let Ok(geometry) = dop_at_epoch(
310 source,
311 all_satellites,
312 explicit_satellites,
313 receiver_ecef_m,
314 t_rx_j2000_s,
315 options,
316 ) {
317 out.push(DopSeriesPoint {
318 step_index,
319 geometry,
320 });
321 }
322 }
323 Ok(out)
324}
325
326pub fn visibility_series(
328 source: &dyn ObservableEphemerisSource,
329 satellites: &[GnssSatelliteId],
330 receiver_ecef_m: [f64; 3],
331 window_j2000_s: (f64, f64),
332 step_seconds: u64,
333 options: &VisibilityOptions,
334) -> Result<Vec<VisibilitySeriesPoint>, DopError> {
335 validate_visibility_options(options)?;
336
337 sample_times(window_j2000_s, step_seconds)?
338 .into_iter()
339 .map(|(step_index, t_rx_j2000_s)| {
340 visible(source, satellites, receiver_ecef_m, t_rx_j2000_s, options).map(|visible| {
341 VisibilitySeriesPoint {
342 step_index,
343 n_visible: visible.len(),
344 }
345 })
346 })
347 .collect::<Result<Vec<_>, _>>()
348}
349
350pub fn passes(
352 source: &dyn ObservableEphemerisSource,
353 satellites: &[GnssSatelliteId],
354 receiver_ecef_m: [f64; 3],
355 window_j2000_s: (f64, f64),
356 step_seconds: u64,
357 options: &VisibilityOptions,
358) -> Result<Vec<VisibilityPass>, DopError> {
359 validate_visibility_options(options)?;
360
361 let samples = sample_times(window_j2000_s, step_seconds)?;
362 let mut out = Vec::new();
363
364 for &sat in satellites {
365 if !system_allowed(sat, options.systems.as_ref()) {
366 continue;
367 }
368
369 let mut current_run: Vec<VisibilitySample> = Vec::new();
370 for &(step_index, t_rx_j2000_s) in &samples {
371 let prediction = predict(
372 source,
373 sat,
374 receiver_ecef_m,
375 t_rx_j2000_s,
376 PredictOptions {
377 carrier_hz: F_L1_HZ,
378 light_time: false,
379 sagnac: true,
380 },
381 );
382 let above = match prediction {
383 Ok(obs)
384 if visible_at_elevation_mask(obs.elevation_deg, options.elevation_mask_deg) =>
385 {
386 Some(VisibilitySample {
387 step_index,
388 elevation_deg: obs.elevation_deg,
389 })
390 }
391 Ok(_) | Err(_) => None,
392 };
393
394 match above {
395 Some(sample) => current_run.push(sample),
396 None if !current_run.is_empty() => {
397 out.push(pass_from_run(sat, ¤t_run));
398 current_run.clear();
399 }
400 None => {}
401 }
402 }
403
404 if !current_run.is_empty() {
405 out.push(pass_from_run(sat, ¤t_run));
406 }
407 }
408
409 out.sort_by_key(|pass| pass.rise_step_index);
410 Ok(out)
411}
412
413fn system_allowed(sat: GnssSatelliteId, systems: Option<&BTreeSet<GnssSystem>>) -> bool {
414 systems.is_none_or(|systems| systems.contains(&sat.system))
415}
416
417fn weight_for(weighting: DopWeighting, elevation_deg: f64) -> f64 {
418 match weighting {
419 DopWeighting::Unit => 1.0,
420 DopWeighting::Elevation => {
421 let s = (elevation_deg * DEG_TO_RAD).sin();
422 s * s
423 }
424 }
425}
426
427fn validate_visibility_options(options: &VisibilityOptions) -> Result<(), DopError> {
428 validate::finite_in_range(
429 options.elevation_mask_deg,
430 -90.0,
431 90.0,
432 "elevation_mask_deg",
433 )
434 .map(|_| ())
435 .map_err(map_geometry_input)
436}
437
438fn receiver_geodetic(receiver_ecef_m: [f64; 3]) -> Result<Wgs84Geodetic, DopError> {
439 let (lat_deg, lon_deg, _height_km) = itrs_to_geodetic_compute(
440 receiver_ecef_m[0] / KM_TO_M,
441 receiver_ecef_m[1] / KM_TO_M,
442 receiver_ecef_m[2] / KM_TO_M,
443 )
444 .map_err(|_| invalid_receiver_geodetic())?;
445 let lon_rad = normalize_geodetic_lon_rad(lon_deg * DEG_TO_RAD);
446 Wgs84Geodetic::new(lat_deg * DEG_TO_RAD, lon_rad, 0.0).map_err(|_| invalid_receiver_geodetic())
447}
448
449fn invalid_receiver_geodetic() -> DopError {
450 DopError::InvalidInput {
451 field: "receiver_ecef_m",
452 reason: "invalid geodetic",
453 }
454}
455
456fn sample_times(
457 window_j2000_s: (f64, f64),
458 step_seconds: u64,
459) -> Result<Vec<(usize, f64)>, DopError> {
460 validate::positive_step(step_seconds as f64, "step_seconds").map_err(map_geometry_input)?;
461
462 let (t0, t1) = window_j2000_s;
463 validate::finite(t0, "window_j2000_s.0").map_err(map_geometry_input)?;
464 validate::finite(t1, "window_j2000_s.1").map_err(map_geometry_input)?;
465 if t0 > t1 {
466 return Ok(Vec::new());
467 }
468
469 let mut out = Vec::new();
470 let step = step_seconds as f64;
471 let mut step_index = 0usize;
472 loop {
473 let t = t0 + step * step_index as f64;
474 if t > t1 {
475 break;
476 }
477 out.push((step_index, t));
478 step_index += 1;
479 }
480 if let Some((_, last_t)) = out.last() {
481 if *last_t < t1 {
482 out.push((step_index, t1));
483 }
484 }
485 Ok(out)
486}
487
488fn map_geometry_input(error: validate::FieldError) -> DopError {
489 DopError::InvalidInput {
490 field: error.field(),
491 reason: error.reason(),
492 }
493}
494
495fn pass_from_run(sat: GnssSatelliteId, run: &[VisibilitySample]) -> VisibilityPass {
496 let rise = run[0];
497 let set = run[run.len() - 1];
498 let mut peak = run[0];
499 for &sample in &run[1..] {
500 if sample.elevation_deg > peak.elevation_deg {
501 peak = sample;
502 }
503 }
504
505 VisibilityPass {
506 satellite: sat,
507 rise_step_index: rise.step_index,
508 set_step_index: set.step_index,
509 peak_elevation_deg: peak.elevation_deg,
510 peak_step_index: peak.step_index,
511 }
512}
513
514#[cfg(test)]
515mod sampling_tests {
516 use super::*;
517 use crate::observables::{ObservableState, ObservablesError};
518
519 const RECEIVER_ECEF_M: [f64; 3] = [6_378_137.0, 0.0, 0.0];
520 const ANTI_MERIDIAN_RECEIVER_ECEF_M: [f64; 3] = [-6_378_137.0, 0.0, 0.0];
521 const RANGE_M: f64 = 20_200_000.0;
522
523 #[test]
524 fn public_sagnac_helpers_match_explicit_formulas() {
525 let sat = [15_600_000.0, -20_400_000.0, 9_800_000.0];
526 let recv = [4_027_894.0, 307_046.0, 4_919_474.0];
527 let tau = 0.072_345;
528 let theta = OMEGA_E_DOT_RAD_S * tau;
529 let c = theta.cos();
530 let s = theta.sin();
531 let rotated = sagnac_rotate_ecef_m(sat, tau);
532 assert_eq!(
533 rotated.map(f64::to_bits),
534 [
535 (c * sat[0] + s * sat[1]).to_bits(),
536 (-s * sat[0] + c * sat[1]).to_bits(),
537 sat[2].to_bits(),
538 ]
539 );
540
541 let dx = sat[0] - recv[0];
542 let dy = sat[1] - recv[1];
543 let dz = sat[2] - recv[2];
544 let euclid = (dx * dx + dy * dy + dz * dz).sqrt();
545 let want = euclid + OMEGA_E_DOT_RAD_S * (sat[0] * recv[1] - sat[1] * recv[0]) / C_M_S;
546 assert_eq!(
547 sagnac_range_first_order_m(sat, recv).to_bits(),
548 want.to_bits()
549 );
550 }
551
552 #[test]
553 fn elevation_mask_predicate_is_inclusive() {
554 assert!(visible_at_elevation_mask(10.0, 10.0));
555 assert!(visible_at_elevation_mask(10.000_001, 10.0));
556 assert!(!visible_at_elevation_mask(9.999_999, 10.0));
557 assert!(!visible_at_elevation_mask(f64::NAN, 10.0));
558 }
559
560 struct FinalOnlySource {
561 visible_from_s: f64,
562 }
563
564 impl ObservableEphemerisSource for FinalOnlySource {
565 fn observable_state_at_j2000_s(
566 &self,
567 sat: GnssSatelliteId,
568 t_j2000_s: f64,
569 ) -> Result<ObservableState, ObservablesError> {
570 let los = if t_j2000_s >= self.visible_from_s {
571 final_los(sat)
572 } else {
573 [-1.0, 0.0, 0.0]
574 };
575 Ok(ObservableState {
576 position_ecef_m: [
577 RECEIVER_ECEF_M[0] + RANGE_M * los[0],
578 RECEIVER_ECEF_M[1] + RANGE_M * los[1],
579 RECEIVER_ECEF_M[2] + RANGE_M * los[2],
580 ],
581 clock_s: Some(0.0),
582 })
583 }
584 }
585
586 struct ReceiverRelativeSource {
587 receiver_ecef_m: [f64; 3],
588 }
589
590 impl ObservableEphemerisSource for ReceiverRelativeSource {
591 fn observable_state_at_j2000_s(
592 &self,
593 sat: GnssSatelliteId,
594 _t_j2000_s: f64,
595 ) -> Result<ObservableState, ObservablesError> {
596 let los = final_los(sat);
597 Ok(ObservableState {
598 position_ecef_m: [
599 self.receiver_ecef_m[0] + RANGE_M * los[0],
600 self.receiver_ecef_m[1] + RANGE_M * los[1],
601 self.receiver_ecef_m[2] + RANGE_M * los[2],
602 ],
603 clock_s: Some(0.0),
604 })
605 }
606 }
607
608 #[test]
609 fn sample_times_includes_partial_end_without_duplicating_exact_end() {
610 assert_eq!(
611 sample_times((0.0, 25.0), 10).expect("partial window"),
612 vec![(0, 0.0), (1, 10.0), (2, 20.0), (3, 25.0)]
613 );
614 assert_eq!(
615 sample_times((0.0, 20.0), 10).expect("exact window"),
616 vec![(0, 0.0), (1, 10.0), (2, 20.0)]
617 );
618 }
619
620 #[test]
621 fn partial_window_end_sample_feeds_all_geometry_series() {
622 let source = FinalOnlySource {
623 visible_from_s: 25.0,
624 };
625 let sats = [sat(1), sat(2), sat(3), sat(4)];
626 let window = (0.0, 25.0);
627
628 let visibility = visibility_series(
629 &source,
630 &sats,
631 RECEIVER_ECEF_M,
632 window,
633 10,
634 &VisibilityOptions::default(),
635 )
636 .expect("visibility series");
637 assert_eq!(
638 visibility
639 .iter()
640 .map(|sample| (sample.step_index, sample.n_visible))
641 .collect::<Vec<_>>(),
642 [(0, 0), (1, 0), (2, 0), (3, 4)]
643 );
644
645 let passes = passes(
646 &source,
647 &sats,
648 RECEIVER_ECEF_M,
649 window,
650 10,
651 &VisibilityOptions::default(),
652 )
653 .expect("passes");
654 assert_eq!(passes.len(), sats.len());
655 for pass in &passes {
656 assert_eq!(pass.rise_step_index, 3);
657 assert_eq!(pass.set_step_index, 3);
658 assert_eq!(pass.peak_step_index, 3);
659 }
660
661 let dop = dop_series(
662 &source,
663 &sats,
664 None,
665 RECEIVER_ECEF_M,
666 window,
667 10,
668 &DopOptions::default(),
669 )
670 .expect("DOP series");
671 assert_eq!(dop.len(), 1);
672 assert_eq!(dop[0].step_index, 3);
673 assert_eq!(dop[0].geometry.satellites.len(), sats.len());
674 }
675
676 #[test]
677 fn dop_rejects_non_finite_receiver_coordinates() {
678 let source = FinalOnlySource {
679 visible_from_s: 0.0,
680 };
681 let sats = [sat(1), sat(2), sat(3), sat(4)];
682 let cases = [
683 [f64::NAN, RECEIVER_ECEF_M[1], RECEIVER_ECEF_M[2]],
684 [RECEIVER_ECEF_M[0], f64::INFINITY, RECEIVER_ECEF_M[2]],
685 [RECEIVER_ECEF_M[0], RECEIVER_ECEF_M[1], f64::NEG_INFINITY],
686 ];
687
688 for receiver in cases {
689 assert_invalid_receiver(dop_at_epoch(
690 &source,
691 &sats,
692 Some(&sats),
693 receiver,
694 0.0,
695 &DopOptions::default(),
696 ));
697 assert_invalid_receiver(dop_series(
698 &source,
699 &sats,
700 Some(&sats),
701 receiver,
702 (0.0, 10.0),
703 10,
704 &DopOptions::default(),
705 ));
706 }
707 }
708
709 #[test]
710 fn dop_handles_antimeridian_receiver_coordinates() {
711 let source = ReceiverRelativeSource {
712 receiver_ecef_m: ANTI_MERIDIAN_RECEIVER_ECEF_M,
713 };
714 let sats = [sat(1), sat(2), sat(3), sat(4)];
715
716 let epoch = dop_at_epoch(
717 &source,
718 &sats,
719 Some(&sats),
720 ANTI_MERIDIAN_RECEIVER_ECEF_M,
721 0.0,
722 &DopOptions::default(),
723 )
724 .expect("antimeridian receiver should produce DOP");
725 assert_eq!(epoch.satellites, sats);
726
727 let series = dop_series(
728 &source,
729 &sats,
730 Some(&sats),
731 ANTI_MERIDIAN_RECEIVER_ECEF_M,
732 (0.0, 0.0),
733 10,
734 &DopOptions::default(),
735 )
736 .expect("antimeridian receiver DOP series");
737 assert_eq!(series.len(), 1);
738 }
739
740 #[test]
741 fn geometry_apis_reject_invalid_elevation_masks() {
742 let source = FinalOnlySource {
743 visible_from_s: 0.0,
744 };
745 let sats = [sat(1), sat(2), sat(3), sat(4)];
746 let invalid_masks = [
747 (f64::NAN, "not finite"),
748 (f64::INFINITY, "not finite"),
749 (-91.0, "out of range"),
750 (91.0, "out of range"),
751 ];
752
753 for (mask, reason) in invalid_masks {
754 let visibility = VisibilityOptions {
755 elevation_mask_deg: mask,
756 systems: None,
757 };
758 let dop_options = DopOptions {
759 visibility: visibility.clone(),
760 weighting: DopWeighting::Unit,
761 light_time: false,
762 };
763
764 assert_invalid_elevation_mask(
765 visible(&source, &sats, RECEIVER_ECEF_M, 0.0, &visibility),
766 reason,
767 );
768 assert_invalid_elevation_mask(
769 visibility_series(
770 &source,
771 &sats,
772 RECEIVER_ECEF_M,
773 (0.0, 10.0),
774 10,
775 &visibility,
776 ),
777 reason,
778 );
779 assert_invalid_elevation_mask(
780 passes(
781 &source,
782 &sats,
783 RECEIVER_ECEF_M,
784 (0.0, 10.0),
785 10,
786 &visibility,
787 ),
788 reason,
789 );
790 assert_invalid_elevation_mask(
791 dop_at_epoch(
792 &source,
793 &sats,
794 Some(&sats),
795 RECEIVER_ECEF_M,
796 0.0,
797 &dop_options,
798 ),
799 reason,
800 );
801 assert_invalid_elevation_mask(
802 dop_series(
803 &source,
804 &sats,
805 Some(&sats),
806 RECEIVER_ECEF_M,
807 (0.0, 10.0),
808 10,
809 &dop_options,
810 ),
811 reason,
812 );
813 }
814 }
815
816 fn sat(prn: u8) -> GnssSatelliteId {
817 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
818 }
819
820 fn final_los(sat: GnssSatelliteId) -> [f64; 3] {
821 let a = std::f64::consts::FRAC_1_SQRT_2;
822 match sat.prn {
823 1 => [1.0, 0.0, 0.0],
824 2 => [a, a, 0.0],
825 3 => [a, -a, 0.0],
826 4 => [a, 0.0, a],
827 _ => [-1.0, 0.0, 0.0],
828 }
829 }
830
831 fn assert_invalid_receiver<T>(result: Result<T, DopError>) {
832 match result {
833 Err(DopError::InvalidInput { field, reason }) => {
834 assert_eq!(field, "receiver_ecef_m");
835 assert_eq!(reason, "not finite");
836 }
837 Err(other) => panic!("expected invalid receiver input, got {other:?}"),
838 Ok(_) => panic!("expected invalid receiver input"),
839 }
840 }
841
842 fn assert_invalid_elevation_mask<T>(result: Result<T, DopError>, expected_reason: &str) {
843 match result {
844 Err(DopError::InvalidInput { field, reason }) => {
845 assert_eq!(field, "elevation_mask_deg");
846 assert_eq!(reason, expected_reason);
847 }
848 Err(other) => panic!("expected invalid elevation mask input, got {other:?}"),
849 Ok(_) => panic!("expected invalid elevation mask input"),
850 }
851 }
852}
853
854#[cfg(all(test, sidereon_repo_tests))]
855mod tests {
856 use super::*;
857 use crate::observables::j2000_seconds_from_split;
858 use crate::sp3::Sp3;
859 use serde_json::Value;
860
861 const APPLICATION_GOLDEN: &str =
862 include_str!("../tests/fixtures/orbis_gnss_application_golden.json");
863 const SPP_TRACE: &str = include_str!("../tests/fixtures/spp_trace_L2_tropo.json");
864
865 fn sp3_fixture() -> Sp3 {
866 let path = concat!(
867 env!("CARGO_MANIFEST_DIR"),
868 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
869 );
870 let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read SP3 fixture {path}: {e}"));
871 Sp3::parse(&bytes).expect("parse SP3 fixture")
872 }
873
874 fn application_case() -> Value {
875 let doc: Value = serde_json::from_str(APPLICATION_GOLDEN).expect("parse golden");
876 doc["sp3_application"].clone()
877 }
878
879 fn parse_hex_float(s: &str) -> f64 {
880 let s = s.strip_prefix("0x").unwrap_or(s);
881 let (mantissa, exp_part) = s.split_once('p').expect("hex float exponent");
882 let exp: i32 = exp_part.parse().expect("hex float exponent integer");
883 let (whole, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
884 let mut value = u64::from_str_radix(whole, 16).expect("hex float whole") as f64;
885 let mut scale = 1.0 / 16.0;
886 for c in frac.chars() {
887 let digit = c.to_digit(16).expect("hex float fraction digit") as f64;
888 value += digit * scale;
889 scale /= 16.0;
890 }
891 value * 2.0_f64.powi(exp)
892 }
893
894 fn hexf(value: &Value) -> f64 {
895 parse_hex_float(value.as_str().expect("hex float string"))
896 }
897
898 fn hex_bits(value: &Value) -> f64 {
899 let raw = value.as_str().expect("hex bits string");
900 let hex = raw.strip_prefix("0x").unwrap_or(raw);
901 f64::from_bits(u64::from_str_radix(hex, 16).expect("hex bits"))
902 }
903
904 fn receiver(case: &Value) -> [f64; 3] {
905 [
906 hexf(&case["receiver_ecef_m"][0]),
907 hexf(&case["receiver_ecef_m"][1]),
908 hexf(&case["receiver_ecef_m"][2]),
909 ]
910 }
911
912 fn trace_receiver() -> [f64; 3] {
913 let doc: Value = serde_json::from_str(SPP_TRACE).expect("parse SPP trace");
914 let truth = &doc["fixture"]["final_solution"]["truth_x"];
915 [
916 hex_bits(&truth[0]),
917 hex_bits(&truth[1]),
918 hex_bits(&truth[2]),
919 ]
920 }
921
922 fn gps_options(mask: f64) -> VisibilityOptions {
923 VisibilityOptions {
924 elevation_mask_deg: mask,
925 systems: Some(BTreeSet::from([GnssSystem::Gps])),
926 }
927 }
928
929 fn sat(system: GnssSystem, prn: u8) -> GnssSatelliteId {
930 GnssSatelliteId::new(system, prn).expect("valid satellite id")
931 }
932
933 fn j2000(jd_whole: f64, jd_fraction: f64) -> f64 {
934 j2000_seconds_from_split(jd_whole, jd_fraction).expect("valid split Julian date")
935 }
936
937 #[test]
938 fn visible_gps_mask10_matches_application_golden_bits() {
939 let sp3 = sp3_fixture();
940 let case = application_case();
941 let rx = receiver(&case);
942 let t = j2000(2_459_024.5, 0.5);
943 let got = visible(&sp3, sp3.satellites(), rx, t, &gps_options(10.0))
944 .expect("valid visibility mask");
945 let expected = case["visible_gps_mask10"].as_array().expect("visible rows");
946
947 assert_eq!(got.len(), expected.len());
948 for (got, want) in got.iter().zip(expected) {
949 assert_eq!(got.satellite.to_string(), want["satellite_id"]);
950 assert_eq!(
951 got.elevation_deg.to_bits(),
952 hexf(&want["elevation_deg"]).to_bits()
953 );
954 assert_eq!(
955 got.azimuth_deg.to_bits(),
956 hexf(&want["azimuth_deg"]).to_bits()
957 );
958 }
959 }
960
961 #[test]
962 fn weighted_dop_matches_application_golden_bits() {
963 let sp3 = sp3_fixture();
964 let case = application_case();
965 let rx = receiver(&case);
966 let t = j2000(2_459_024.5, 0.5);
967 let dop_case = &case["dop_weighted"];
968 let satellites = dop_case["satellites"]
969 .as_array()
970 .expect("satellites")
971 .iter()
972 .map(|value| {
973 let token = value.as_str().expect("satellite token");
974 let prn: u8 = token[1..].parse().expect("satellite PRN");
975 sat(GnssSystem::Gps, prn)
976 })
977 .collect::<Vec<_>>();
978
979 let got = dop_at_epoch(
980 &sp3,
981 sp3.satellites(),
982 Some(&satellites),
983 rx,
984 t,
985 &DopOptions {
986 visibility: gps_options(10.0),
987 weighting: DopWeighting::Elevation,
988 light_time: true,
989 },
990 )
991 .expect("weighted DOP");
992
993 assert_eq!(got.satellites, satellites);
994 assert_eq!(got.dop.gdop.to_bits(), hexf(&dop_case["gdop"]).to_bits());
995 assert_eq!(got.dop.pdop.to_bits(), hexf(&dop_case["pdop"]).to_bits());
996 assert_eq!(got.dop.hdop.to_bits(), hexf(&dop_case["hdop"]).to_bits());
997 assert_eq!(got.dop.vdop.to_bits(), hexf(&dop_case["vdop"]).to_bits());
998 assert_eq!(got.dop.tdop.to_bits(), hexf(&dop_case["tdop"]).to_bits());
999 }
1000
1001 #[test]
1002 fn visibility_series_matches_orbis_sampling_counts() {
1003 let sp3 = sp3_fixture();
1004 let rx = trace_receiver();
1005 let window = (
1006 j2000(2_459_024.5, 0.5),
1007 j2000(2_459_024.5, 0.5) + crate::constants::SECONDS_PER_HOUR,
1008 );
1009
1010 let got = visibility_series(&sp3, sp3.satellites(), rx, window, 300, &gps_options(5.0))
1011 .expect("valid visibility step");
1012 let counts: Vec<usize> = got.iter().map(|sample| sample.n_visible).collect();
1013 assert_eq!(counts, [9, 9, 9, 9, 10, 11, 11, 11, 11, 11, 11, 11, 11]);
1014 assert_eq!(
1015 got.iter()
1016 .map(|sample| sample.step_index)
1017 .collect::<Vec<_>>(),
1018 (0..13).collect::<Vec<_>>()
1019 );
1020 }
1021
1022 #[test]
1023 fn dop_series_matches_orbis_first_sample_bits() {
1024 let sp3 = sp3_fixture();
1025 let rx = trace_receiver();
1026 let window = (
1027 j2000(2_459_024.5, 0.5),
1028 j2000(2_459_024.5, 0.5) + crate::constants::SECONDS_PER_HOUR,
1029 );
1030
1031 let got = dop_series(
1032 &sp3,
1033 sp3.satellites(),
1034 None,
1035 rx,
1036 window,
1037 300,
1038 &DopOptions {
1039 visibility: gps_options(5.0),
1040 weighting: DopWeighting::Unit,
1041 light_time: false,
1042 },
1043 )
1044 .expect("valid DOP step");
1045
1046 assert_eq!(got.len(), 13);
1047 let first = &got[0];
1048 assert_eq!(first.step_index, 0);
1049 assert_eq!(
1050 first
1051 .geometry
1052 .satellites
1053 .iter()
1054 .map(ToString::to_string)
1055 .collect::<Vec<_>>(),
1056 ["G21", "G16", "G26", "G20", "G27", "G18", "G10", "G08", "G07"]
1057 );
1058 assert_eq!(first.geometry.dop.gdop.to_bits(), 0x4000c042642e3cbc);
1059 assert_eq!(first.geometry.dop.pdop.to_bits(), 0x3ffd34cde2c7e400);
1060 assert_eq!(first.geometry.dop.hdop.to_bits(), 0x3ff257e7df379517);
1061 assert_eq!(first.geometry.dop.vdop.to_bits(), 0x3ff6ba2ad4e284af);
1062 assert_eq!(first.geometry.dop.tdop.to_bits(), 0x3ff069acbf06750f);
1063 }
1064
1065 #[test]
1066 fn passes_match_orbis_sampled_rise_set_peak_rows() {
1067 let sp3 = sp3_fixture();
1068 let rx = trace_receiver();
1069 let window = (
1070 j2000(2_459_024.5, 0.0),
1071 j2000(2_459_024.5, 0.9895833333333334),
1072 );
1073
1074 let got = passes(&sp3, sp3.satellites(), rx, window, 900, &gps_options(10.0))
1075 .expect("valid pass step");
1076 assert_eq!(got.len(), 51);
1077 let expected = [
1078 ("G02", 0, 0, 0, 0x4024d260407442fe),
1079 ("G05", 0, 10, 0, 0x40513cd3dd1f7866),
1080 ("G07", 0, 6, 0, 0x4046e04ff1c2a900),
1081 ("G09", 0, 1, 0, 0x402fdced3853f1fb),
1082 ("G13", 0, 19, 8, 0x4054b61de01a5608),
1083 ("G15", 0, 22, 11, 0x4053483acdeec548),
1084 ("G28", 0, 16, 7, 0x404d9cd49009957c),
1085 ("G30", 0, 11, 0, 0x4053eb9157f4b766),
1086 ];
1087 for (got, (satellite, rise, set, peak, elevation_bits)) in got.iter().zip(expected) {
1088 assert_eq!(got.satellite.to_string(), satellite);
1089 assert_eq!(got.rise_step_index, rise);
1090 assert_eq!(got.set_step_index, set);
1091 assert_eq!(got.peak_step_index, peak);
1092 assert_eq!(got.peak_elevation_deg.to_bits(), elevation_bits);
1093 }
1094 }
1095
1096 #[test]
1097 fn sampled_geometry_rejects_zero_step() {
1098 let sp3 = sp3_fixture();
1099 let rx = trace_receiver();
1100 let window = (
1101 j2000(2_459_024.5, 0.5),
1102 j2000(2_459_024.5, 0.5) + crate::constants::SECONDS_PER_HOUR,
1103 );
1104
1105 assert_invalid_geometry_field(
1106 visibility_series(&sp3, sp3.satellites(), rx, window, 0, &gps_options(5.0))
1107 .unwrap_err(),
1108 "step_seconds",
1109 "not positive",
1110 );
1111 assert_invalid_geometry_field(
1112 dop_series(
1113 &sp3,
1114 sp3.satellites(),
1115 None,
1116 rx,
1117 window,
1118 0,
1119 &DopOptions::default(),
1120 )
1121 .unwrap_err(),
1122 "step_seconds",
1123 "not positive",
1124 );
1125 assert_invalid_geometry_field(
1126 passes(&sp3, sp3.satellites(), rx, window, 0, &gps_options(10.0)).unwrap_err(),
1127 "step_seconds",
1128 "not positive",
1129 );
1130 }
1131
1132 #[test]
1133 fn sampled_geometry_rejects_non_finite_window_bounds() {
1134 let sp3 = sp3_fixture();
1135 let rx = trace_receiver();
1136 let t = j2000(2_459_024.5, 0.5);
1137 let cases = [
1138 ((f64::NAN, t + 300.0), "window_j2000_s.0"),
1139 ((f64::NEG_INFINITY, t + 300.0), "window_j2000_s.0"),
1140 ((t, f64::INFINITY), "window_j2000_s.1"),
1141 ];
1142
1143 for (window, field) in cases {
1144 assert_invalid_geometry_field(
1145 visibility_series(&sp3, sp3.satellites(), rx, window, 300, &gps_options(5.0))
1146 .unwrap_err(),
1147 field,
1148 "not finite",
1149 );
1150 assert_invalid_geometry_field(
1151 dop_series(
1152 &sp3,
1153 sp3.satellites(),
1154 None,
1155 rx,
1156 window,
1157 300,
1158 &DopOptions::default(),
1159 )
1160 .unwrap_err(),
1161 field,
1162 "not finite",
1163 );
1164 assert_invalid_geometry_field(
1165 passes(&sp3, sp3.satellites(), rx, window, 300, &gps_options(10.0)).unwrap_err(),
1166 field,
1167 "not finite",
1168 );
1169 }
1170 }
1171
1172 fn assert_invalid_geometry_field(
1173 error: DopError,
1174 expected: &'static str,
1175 expected_reason: &'static str,
1176 ) {
1177 match error {
1178 DopError::InvalidInput { field, reason } => {
1179 assert_eq!(field, expected);
1180 assert_eq!(reason, expected_reason);
1181 }
1182 other => panic!("expected invalid geometry input for {expected}, got {other:?}"),
1183 }
1184 }
1185}