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