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