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