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