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