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