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