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