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