Skip to main content

sidereon_core/
geometry.rs

1//! GNSS geometry primitives.
2
3pub(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
16/// Error type returned by DOP calculations.
17pub type Error = DopError;
18
19const DEFAULT_ELEVATION_MASK_DEG: f64 = 5.0;
20const DEG_TO_RAD: f64 = PI / 180.0;
21
22/// Visibility planning options for SP3-derived GNSS geometry.
23#[derive(Debug, Clone, PartialEq)]
24pub struct VisibilityOptions {
25    /// Minimum topocentric elevation, degrees.
26    pub elevation_mask_deg: f64,
27    /// Optional constellation filter. `None` keeps all systems.
28    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/// DOP weighting policy.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42pub enum DopWeighting {
43    /// Unweighted geometric DOP.
44    #[default]
45    Unit,
46    /// Elevation weighting with `sin(elevation)^2`.
47    Elevation,
48}
49
50/// DOP planning options.
51#[derive(Debug, Clone, PartialEq)]
52pub struct DopOptions {
53    /// Visibility scan options used when no explicit satellite list is supplied.
54    pub visibility: VisibilityOptions,
55    /// DOP row weighting policy.
56    pub weighting: DopWeighting,
57    /// Apply light-time and Sagnac corrections when forming the line of sight.
58    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/// One visible satellite row.
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct VisibleSatellite {
74    /// Satellite identifier.
75    pub satellite: GnssSatelliteId,
76    /// Topocentric elevation, degrees.
77    pub elevation_deg: f64,
78    /// Topocentric azimuth, degrees in `[0, 360)`.
79    pub azimuth_deg: f64,
80}
81
82/// DOP result plus the exact satellites that contributed rows.
83#[derive(Debug, Clone, PartialEq)]
84pub struct DopAtEpoch {
85    /// DOP scalars.
86    pub dop: Dop,
87    /// Satellites with successful predicted line-of-sight rows.
88    pub satellites: Vec<GnssSatelliteId>,
89}
90
91/// DOP result for one sampled epoch.
92#[derive(Debug, Clone, PartialEq)]
93pub struct DopSeriesPoint {
94    /// Zero-based sample index from the series start.
95    pub step_index: usize,
96    /// DOP result at this sample.
97    pub geometry: DopAtEpoch,
98}
99
100/// Visible-satellite count for one sampled epoch.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct VisibilitySeriesPoint {
103    /// Zero-based sample index from the series start.
104    pub step_index: usize,
105    /// Number of satellites visible at this sample.
106    pub n_visible: usize,
107}
108
109/// One sampled visibility pass.
110#[derive(Debug, Clone, Copy, PartialEq)]
111pub struct VisibilityPass {
112    /// Satellite identifier.
113    pub satellite: GnssSatelliteId,
114    /// Zero-based sample index of the first above-mask sample.
115    pub rise_step_index: usize,
116    /// Zero-based sample index of the last above-mask sample.
117    pub set_step_index: usize,
118    /// Maximum sampled elevation in the pass, degrees.
119    pub peak_elevation_deg: f64,
120    /// Zero-based sample index of the maximum sampled elevation.
121    pub peak_step_index: usize,
122}
123
124#[derive(Debug, Clone, Copy)]
125struct VisibilitySample {
126    step_index: usize,
127    elevation_deg: f64,
128}
129
130/// List satellites visible from a static receiver at one epoch.
131pub 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
173/// Compute DOP at one epoch from either an explicit satellite set or a visibility scan.
174pub 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
234/// Sample DOP over an inclusive time window, skipping singular or underdetermined samples.
235pub 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
266/// Count visible satellites over an inclusive sampled time window.
267pub 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
290/// Build sampled rise/set/peak visibility passes over an inclusive time window.
291pub 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, &current_run));
336                    current_run.clear();
337                }
338                None => {}
339            }
340        }
341
342        if !current_run.is_empty() {
343            out.push(pass_from_run(sat, &current_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}