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