Skip to main content

sidereon_core/precise_positioning/
tec.rs

1//! Dual-frequency Total Electron Content estimation helpers.
2//!
3//! This module starts with the absolute, noisy code-derived slant TEC estimate.
4//! It uses the dual-frequency code geometry-free combination `P1 - P2` and the
5//! dispersive ionospheric group-delay relationship
6//! `delay_m = 40.308e16 * TECU * (1 / f1^2 - 1 / f2^2)`, where carrier
7//! frequencies are in hertz and one TECU is `1e16` electrons per square meter.
8//!
9//! ```
10//! use sidereon_core::constants::{C_M_S, F_L1_HZ, F_L2_HZ};
11//! use sidereon_core::precise_positioning::{
12//!     estimate_tec, DualFrequencyObservation, TecConfig, TecEpoch, TecObservation,
13//!     TEC_GROUP_DELAY_COEFFICIENT,
14//! };
15//!
16//! fn observation(_epoch: usize, slant_tec_tecu: f64, phase_bias_tecu: f64) -> DualFrequencyObservation {
17//!     let denominator = TEC_GROUP_DELAY_COEFFICIENT
18//!         * (1.0 / (F_L1_HZ * F_L1_HZ) - 1.0 / (F_L2_HZ * F_L2_HZ));
19//!     let code_geometry_free_m = denominator * slant_tec_tecu;
20//!     let phase_geometry_free_m = -denominator * (slant_tec_tecu + phase_bias_tecu);
21//!     DualFrequencyObservation {
22//!         satellite_id: "G01".to_string(),
23//!         ambiguity_id: "G01".to_string(),
24//!         p1_m: 0.0,
25//!         p2_m: -code_geometry_free_m,
26//!         phi1_cyc: phase_geometry_free_m / (C_M_S / F_L1_HZ),
27//!         phi2_cyc: 0.0,
28//!         f1_hz: F_L1_HZ,
29//!         f2_hz: F_L2_HZ,
30//!         lli1: None,
31//!         lli2: None,
32//!     }
33//! }
34//!
35//! let epochs = (0..2)
36//!     .map(|epoch| TecEpoch {
37//!         time_s: epoch as f64 * 30.0,
38//!         receiver_latitude_rad: 0.0,
39//!         receiver_longitude_rad: 0.0,
40//!         observations: vec![TecObservation {
41//!             observation: observation(epoch, 20.0, 5.0),
42//!             elevation_rad: 60.0_f64.to_radians(),
43//!             azimuth_rad: 90.0_f64.to_radians(),
44//!         }],
45//!     })
46//!     .collect::<Vec<_>>();
47//! let tec = estimate_tec(&epochs, TecConfig::default())?;
48//! assert_eq!(tec.arcs.len(), 1);
49//! assert_eq!(tec.arcs[0].samples.len(), 2);
50//! # Ok::<(), Box<dyn std::error::Error>>(())
51//! ```
52
53use core::f64::consts::{FRAC_PI_2, PI, TAU};
54use std::collections::BTreeMap;
55
56use crate::constants::MEAN_EARTH_RADIUS_M;
57use crate::tolerances::FREQUENCY_DENOMINATOR_EPS_HZ;
58use crate::validate;
59
60use super::cycle_slip::{geometry_free_m as phase_geometry_free_combination_m, CycleSlipError};
61use super::prep::DualFrequencyObservation;
62
63/// Default single-layer ionospheric shell height in meters.
64pub const DEFAULT_IONOSPHERIC_SHELL_HEIGHT_M: f64 = 350_000.0;
65
66/// Electrons per square meter represented by one TECU.
67pub const ELECTRONS_PER_TECU_M2: f64 = 1.0e16;
68
69/// Ionospheric group-delay coefficient for TECU inputs.
70///
71/// Frequencies are in hertz, so multiplying this coefficient by
72/// `(1 / f1^2 - 1 / f2^2)` and a slant TEC value in TECU yields meters.
73pub const TEC_GROUP_DELAY_COEFFICIENT: f64 = 40.308 * ELECTRONS_PER_TECU_M2;
74
75/// Configuration for thin-shell TEC estimation.
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct TecConfig {
78    /// Ionospheric shell height above the spherical Earth, in meters.
79    pub shell_height_m: f64,
80    /// Spherical Earth radius used by the mapping function, in meters.
81    pub earth_radius_m: f64,
82}
83
84impl TecConfig {
85    /// Validate the shell geometry constants.
86    pub fn validate(&self) -> Result<(), TecError> {
87        validate::finite_positive(self.shell_height_m, "shell_height_m")
88            .map_err(|_| TecError::InvalidShellHeight)?;
89        validate::finite_positive(self.earth_radius_m, "earth_radius_m")
90            .map_err(|_| TecError::InvalidEarthRadius)?;
91        Ok(())
92    }
93}
94
95impl Default for TecConfig {
96    fn default() -> Self {
97        Self {
98            shell_height_m: DEFAULT_IONOSPHERIC_SHELL_HEIGHT_M,
99            earth_radius_m: MEAN_EARTH_RADIUS_M,
100        }
101    }
102}
103
104/// One satellite's dual-frequency TEC observation with topocentric geometry.
105#[derive(Debug, Clone, PartialEq)]
106pub struct TecObservation {
107    /// Raw dual-frequency code and carrier-phase observation.
108    pub observation: DualFrequencyObservation,
109    /// Satellite elevation at the receiver, in radians.
110    pub elevation_rad: f64,
111    /// Satellite azimuth clockwise from north, in radians.
112    pub azimuth_rad: f64,
113}
114
115/// One epoch of dual-frequency TEC observations.
116#[derive(Debug, Clone, PartialEq)]
117pub struct TecEpoch {
118    /// Comparable epoch coordinate in seconds.
119    pub time_s: f64,
120    /// Receiver geodetic latitude, in radians.
121    pub receiver_latitude_rad: f64,
122    /// Receiver geodetic longitude, in radians.
123    pub receiver_longitude_rad: f64,
124    /// Satellite observations at this epoch.
125    pub observations: Vec<TecObservation>,
126}
127
128/// TEC estimates for all continuous satellite arcs in a stream.
129#[derive(Debug, Clone, PartialEq)]
130pub struct TecEstimate {
131    /// Per-satellite continuous arcs sorted by satellite and ambiguity id.
132    pub arcs: Vec<TecSatelliteArc>,
133}
134
135/// TEC estimates for one satellite continuous arc.
136#[derive(Debug, Clone, PartialEq)]
137pub struct TecSatelliteArc {
138    /// Satellite identifier copied from the source observations.
139    pub satellite_id: String,
140    /// Ambiguity or continuous-arc identifier copied from the source observations.
141    pub ambiguity_id: String,
142    /// Estimated phase ambiguity bias for this arc, in TECU.
143    pub phase_bias_tecu: f64,
144    /// Per-epoch estimates in input time order.
145    pub samples: Vec<TecEstimateSample>,
146}
147
148/// One leveled TEC estimate at one epoch for one satellite.
149#[derive(Debug, Clone, Copy, PartialEq)]
150pub struct TecEstimateSample {
151    /// Epoch coordinate copied from the input, in seconds.
152    pub time_s: f64,
153    /// Satellite elevation at the receiver, in radians.
154    pub elevation_rad: f64,
155    /// Satellite azimuth clockwise from north, in radians.
156    pub azimuth_rad: f64,
157    /// Code geometry-free combination `P1 - P2`, in meters.
158    pub code_geometry_free_m: f64,
159    /// Carrier phase geometry-free combination `L1 - L2`, in meters.
160    pub phase_geometry_free_m: f64,
161    /// Absolute, noisy code-derived slant TEC, in TECU.
162    pub code_slant_tec_tecu: f64,
163    /// Precise, biased phase-derived slant TEC, in TECU.
164    pub phase_slant_tec_tecu: f64,
165    /// Phase-derived slant TEC after removing the arc bias, in TECU.
166    pub leveled_slant_tec_tecu: f64,
167    /// Thin-shell mapping function used to map slant TEC to vertical TEC.
168    pub mapping_function: f64,
169    /// Leveled vertical TEC, in TECU.
170    pub vertical_tec_tecu: f64,
171    /// Ionospheric pierce point for this sample.
172    pub pierce_point: IonosphericPiercePoint,
173}
174
175/// Code geometry-free slant TEC estimate for one dual-frequency observation.
176#[derive(Debug, Clone, PartialEq)]
177pub struct CodeSlantTecEstimate {
178    /// Satellite identifier copied from the source observation.
179    pub satellite_id: String,
180    /// Ambiguity or continuous-arc identifier copied from the source observation.
181    pub ambiguity_id: String,
182    /// Code geometry-free combination `P1 - P2`, in meters.
183    pub code_geometry_free_m: f64,
184    /// Absolute code-derived slant TEC, in TECU.
185    pub slant_tec_tecu: f64,
186}
187
188/// Phase geometry-free slant TEC estimate for one dual-frequency observation.
189#[derive(Debug, Clone, PartialEq)]
190pub struct PhaseSlantTecEstimate {
191    /// Satellite identifier copied from the source observation.
192    pub satellite_id: String,
193    /// Ambiguity or continuous-arc identifier copied from the source observation.
194    pub ambiguity_id: String,
195    /// Carrier phase geometry-free combination `L1 - L2`, in meters.
196    pub phase_geometry_free_m: f64,
197    /// Precise phase-derived slant TEC, in TECU, including the arc ambiguity bias.
198    pub slant_tec_tecu: f64,
199}
200
201/// One dual-frequency slant TEC sample used by phase-code leveling.
202#[derive(Debug, Clone, Copy, PartialEq)]
203pub struct TecLevelingSample {
204    /// Absolute, noisy code-derived slant TEC, in TECU.
205    pub code_slant_tec_tecu: f64,
206    /// Precise, biased phase-derived slant TEC, in TECU.
207    pub phase_slant_tec_tecu: f64,
208    /// Satellite elevation at the receiver, in radians.
209    pub elevation_rad: f64,
210}
211
212/// One leveled TEC sample emitted for a continuous arc.
213#[derive(Debug, Clone, Copy, PartialEq)]
214pub struct LeveledTecSample {
215    /// Absolute, noisy code-derived slant TEC input, in TECU.
216    pub code_slant_tec_tecu: f64,
217    /// Precise, biased phase-derived slant TEC input, in TECU.
218    pub phase_slant_tec_tecu: f64,
219    /// Phase-derived slant TEC after removing the arc bias, in TECU.
220    pub leveled_slant_tec_tecu: f64,
221    /// Thin-shell mapping function used to map slant TEC to vertical TEC.
222    pub mapping_function: f64,
223    /// Leveled vertical TEC, in TECU.
224    pub vertical_tec_tecu: f64,
225}
226
227/// Result of phase-code leveling across one continuous satellite arc.
228#[derive(Debug, Clone, PartialEq)]
229pub struct TecLevelingResult {
230    /// Estimated phase ambiguity bias, in TECU.
231    pub phase_bias_tecu: f64,
232    /// Leveled samples in input order.
233    pub samples: Vec<LeveledTecSample>,
234}
235
236/// Ionospheric pierce point on the configured thin shell.
237#[derive(Debug, Clone, Copy, PartialEq)]
238pub struct IonosphericPiercePoint {
239    /// Pierce-point geodetic latitude, in radians.
240    pub latitude_rad: f64,
241    /// Pierce-point geodetic longitude normalized to `[-pi, pi)`, in radians.
242    pub longitude_rad: f64,
243    /// Pierce-point geodetic latitude, in degrees.
244    pub latitude_deg: f64,
245    /// Pierce-point geodetic longitude normalized to `[-180, 180)`, in degrees.
246    pub longitude_deg: f64,
247    /// Earth-central angle from receiver to pierce point, in radians.
248    pub earth_central_angle_rad: f64,
249    /// Shell height used for the pierce point, in meters.
250    pub shell_height_m: f64,
251}
252
253/// Error produced while estimating dual-frequency TEC.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum TecError {
256    /// One or more observation scalars were not finite.
257    NonFiniteObservation,
258    /// The configured shell height was not positive and finite.
259    InvalidShellHeight,
260    /// The configured Earth radius was not positive and finite.
261    InvalidEarthRadius,
262    /// One or both carrier frequencies were not positive and finite.
263    InvalidFrequency,
264    /// The two carrier frequencies were too close to form a TEC denominator.
265    EqualFrequencies,
266    /// Receiver geodetic latitude was not finite or not in `[-pi/2, pi/2]`.
267    InvalidReceiverLatitude,
268    /// Receiver geodetic longitude was not finite.
269    InvalidReceiverLongitude,
270    /// Elevation was not finite or not in `[0, pi/2]`.
271    InvalidElevation,
272    /// Azimuth was not finite.
273    InvalidAzimuth,
274    /// A supplied TEC value was not finite.
275    NonFiniteTec,
276    /// The supplied continuous arc had no samples.
277    EmptyArc,
278    /// The input epoch stream had no epochs.
279    NoEpochs,
280    /// The input epoch stream contained no satellite observations.
281    NoObservations,
282    /// An epoch time was not finite.
283    NonFiniteEpochTime,
284    /// Epoch times were not ordered.
285    EpochsNotOrdered,
286    /// A satellite arc did not contain enough samples for leveling.
287    InsufficientArcSamples,
288}
289
290impl core::fmt::Display for TecError {
291    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
292        match self {
293            Self::NonFiniteObservation => write!(f, "TEC observation must be finite"),
294            Self::InvalidShellHeight => write!(f, "TEC shell height must be positive and finite"),
295            Self::InvalidEarthRadius => write!(f, "TEC Earth radius must be positive and finite"),
296            Self::InvalidFrequency => write!(f, "carrier frequency must be positive and finite"),
297            Self::EqualFrequencies => write!(f, "carrier frequencies must be distinct"),
298            Self::InvalidReceiverLatitude => {
299                write!(
300                    f,
301                    "receiver latitude must be finite and within [-pi/2, pi/2]"
302                )
303            }
304            Self::InvalidReceiverLongitude => write!(f, "receiver longitude must be finite"),
305            Self::InvalidElevation => {
306                write!(f, "satellite elevation must be finite and within [0, pi/2]")
307            }
308            Self::InvalidAzimuth => write!(f, "satellite azimuth must be finite"),
309            Self::NonFiniteTec => write!(f, "TEC value must be finite"),
310            Self::EmptyArc => write!(f, "TEC leveling arc must contain at least one sample"),
311            Self::NoEpochs => write!(f, "TEC epoch stream must contain at least one epoch"),
312            Self::NoObservations => {
313                write!(f, "TEC epoch stream must contain at least one observation")
314            }
315            Self::NonFiniteEpochTime => write!(f, "TEC epoch time must be finite"),
316            Self::EpochsNotOrdered => write!(f, "TEC epochs must be time ordered"),
317            Self::InsufficientArcSamples => {
318                write!(f, "TEC satellite arc must contain at least two samples")
319            }
320        }
321    }
322}
323
324impl std::error::Error for TecError {}
325
326/// Compute the code geometry-free combination `P1 - P2`, in meters.
327pub fn code_geometry_free_m(observation: &DualFrequencyObservation) -> Result<f64, TecError> {
328    validate_code_observation(observation)?;
329    let geometry_free_m = observation.p1_m - observation.p2_m;
330    validate::finite(geometry_free_m, "code_geometry_free_m")
331        .map_err(|_| TecError::NonFiniteObservation)?;
332    Ok(geometry_free_m)
333}
334
335/// Convert a code geometry-free delay in meters into slant TEC in TECU.
336pub fn slant_tec_from_code_geometry_free_m(
337    code_geometry_free_m: f64,
338    f1_hz: f64,
339    f2_hz: f64,
340) -> Result<f64, TecError> {
341    validate::finite(code_geometry_free_m, "code_geometry_free_m")
342        .map_err(|_| TecError::NonFiniteObservation)?;
343    if code_geometry_free_m == 0.0 {
344        return Ok(0.0);
345    }
346    let denominator = tec_geometry_free_denominator_m_per_tecu(f1_hz, f2_hz)?;
347    let slant_tec_tecu = code_geometry_free_m / denominator;
348    validate::finite(slant_tec_tecu, "slant_tec_tecu").map_err(|_| TecError::NonFiniteTec)?;
349    Ok(slant_tec_tecu)
350}
351
352/// Compute the carrier phase geometry-free combination `L1 - L2`, in meters.
353pub fn phase_geometry_free_m(observation: &DualFrequencyObservation) -> Result<f64, TecError> {
354    let geometry_free_m =
355        phase_geometry_free_combination_m(observation).map_err(map_cycle_slip_error)?;
356    validate::finite(geometry_free_m, "phase_geometry_free_m")
357        .map_err(|_| TecError::NonFiniteObservation)?;
358    Ok(geometry_free_m)
359}
360
361/// Convert a phase geometry-free delay in meters into biased slant TEC in TECU.
362///
363/// Carrier phase advances through the ionosphere with the opposite sign from
364/// code group delay, so this conversion negates `L1 - L2` before applying the
365/// same TEC denominator as the code geometry-free conversion.
366pub fn slant_tec_from_phase_geometry_free_m(
367    phase_geometry_free_m: f64,
368    f1_hz: f64,
369    f2_hz: f64,
370) -> Result<f64, TecError> {
371    validate::finite(phase_geometry_free_m, "phase_geometry_free_m")
372        .map_err(|_| TecError::NonFiniteObservation)?;
373    if phase_geometry_free_m == 0.0 {
374        return Ok(0.0);
375    }
376    let denominator = tec_geometry_free_denominator_m_per_tecu(f1_hz, f2_hz)?;
377    let slant_tec_tecu = -phase_geometry_free_m / denominator;
378    validate::finite(slant_tec_tecu, "slant_tec_tecu").map_err(|_| TecError::NonFiniteTec)?;
379    Ok(slant_tec_tecu)
380}
381
382/// Estimate absolute code-derived slant TEC for one dual-frequency observation.
383pub fn estimate_code_slant_tec(
384    observation: &DualFrequencyObservation,
385) -> Result<CodeSlantTecEstimate, TecError> {
386    let code_geometry_free_m = code_geometry_free_m(observation)?;
387    let slant_tec_tecu = slant_tec_from_code_geometry_free_m(
388        code_geometry_free_m,
389        observation.f1_hz,
390        observation.f2_hz,
391    )?;
392    Ok(CodeSlantTecEstimate {
393        satellite_id: observation.satellite_id.clone(),
394        ambiguity_id: observation.ambiguity_id.clone(),
395        code_geometry_free_m,
396        slant_tec_tecu,
397    })
398}
399
400/// Estimate biased phase-derived slant TEC for one dual-frequency observation.
401pub fn estimate_phase_slant_tec(
402    observation: &DualFrequencyObservation,
403) -> Result<PhaseSlantTecEstimate, TecError> {
404    let phase_geometry_free_m = phase_geometry_free_m(observation)?;
405    let slant_tec_tecu = slant_tec_from_phase_geometry_free_m(
406        phase_geometry_free_m,
407        observation.f1_hz,
408        observation.f2_hz,
409    )?;
410    Ok(PhaseSlantTecEstimate {
411        satellite_id: observation.satellite_id.clone(),
412        ambiguity_id: observation.ambiguity_id.clone(),
413        phase_geometry_free_m,
414        slant_tec_tecu,
415    })
416}
417
418/// Thin-shell obliquity factor mapping vertical TEC to slant TEC.
419///
420/// The mapping function is
421/// `1 / sqrt(1 - (Re * cos(elevation) / (Re + H))^2)`, where `Re` and `H` come
422/// from [`TecConfig`] and elevation is in radians.
423pub fn thin_shell_mapping_function(elevation_rad: f64, config: TecConfig) -> Result<f64, TecError> {
424    config.validate()?;
425    validate_elevation(elevation_rad)?;
426    let shell_radius_m = config.earth_radius_m + config.shell_height_m;
427    validate::finite_positive(shell_radius_m, "shell_radius_m")
428        .map_err(|_| TecError::InvalidShellHeight)?;
429    let obliquity_arg = config.earth_radius_m * elevation_rad.cos() / shell_radius_m;
430    validate::finite(obliquity_arg, "obliquity_arg").map_err(|_| TecError::InvalidShellHeight)?;
431    let mapping_denominator = 1.0 - obliquity_arg * obliquity_arg;
432    validate::finite_positive(mapping_denominator, "mapping_denominator")
433        .map_err(|_| TecError::InvalidShellHeight)?;
434    let mapping_function = 1.0 / mapping_denominator.sqrt();
435    validate::finite(mapping_function, "mapping_function")
436        .map_err(|_| TecError::InvalidShellHeight)?;
437    Ok(mapping_function)
438}
439
440/// Convert slant TEC to vertical TEC with the configured thin-shell mapping.
441pub fn vertical_tec_from_slant_tec(
442    slant_tec_tecu: f64,
443    elevation_rad: f64,
444    config: TecConfig,
445) -> Result<f64, TecError> {
446    validate_tec(slant_tec_tecu)?;
447    let mapping_function = thin_shell_mapping_function(elevation_rad, config)?;
448    Ok(slant_tec_tecu / mapping_function)
449}
450
451/// Level a continuous arc of code and phase slant TEC samples.
452///
453/// The phase ambiguity bias is the arc mean of `phase_slant_tec_tecu -
454/// code_slant_tec_tecu`. Each output slant TEC is the phase slant TEC minus that
455/// bias, and each vertical TEC is the leveled slant TEC divided by the
456/// thin-shell mapping function at the sample elevation.
457pub fn level_slant_tec_arc(
458    samples: &[TecLevelingSample],
459    config: TecConfig,
460) -> Result<TecLevelingResult, TecError> {
461    config.validate()?;
462    if samples.is_empty() {
463        return Err(TecError::EmptyArc);
464    }
465
466    let mut bias_sum_tecu = 0.0;
467    for sample in samples {
468        validate_leveling_sample(sample)?;
469        bias_sum_tecu += sample.phase_slant_tec_tecu - sample.code_slant_tec_tecu;
470    }
471    let phase_bias_tecu = bias_sum_tecu / samples.len() as f64;
472
473    let leveled_samples = samples
474        .iter()
475        .map(|sample| {
476            let mapping_function = thin_shell_mapping_function(sample.elevation_rad, config)?;
477            let leveled_slant_tec_tecu = sample.phase_slant_tec_tecu - phase_bias_tecu;
478            let vertical_tec_tecu = leveled_slant_tec_tecu / mapping_function;
479            Ok(LeveledTecSample {
480                code_slant_tec_tecu: sample.code_slant_tec_tecu,
481                phase_slant_tec_tecu: sample.phase_slant_tec_tecu,
482                leveled_slant_tec_tecu,
483                mapping_function,
484                vertical_tec_tecu,
485            })
486        })
487        .collect::<Result<Vec<_>, TecError>>()?;
488
489    Ok(TecLevelingResult {
490        phase_bias_tecu,
491        samples: leveled_samples,
492    })
493}
494
495/// Estimate TEC over a time-ordered stream of dual-frequency epochs.
496///
497/// Observations are grouped into continuous arcs by `(satellite_id,
498/// ambiguity_id)`. Each arc must contain at least two samples, then code and
499/// phase slant TEC are leveled, mapped to vertical TEC, and paired with a
500/// thin-shell ionospheric pierce point for every sample.
501pub fn estimate_tec(epochs: &[TecEpoch], config: TecConfig) -> Result<TecEstimate, TecError> {
502    validate_tec_epochs(epochs, config)?;
503
504    let mut arcs = BTreeMap::<(String, String), Vec<TecArcBuildSample>>::new();
505    for epoch in epochs {
506        for observation in &epoch.observations {
507            let code_estimate = estimate_code_slant_tec(&observation.observation)?;
508            let phase_estimate = estimate_phase_slant_tec(&observation.observation)?;
509            arcs.entry((
510                observation.observation.satellite_id.clone(),
511                observation.observation.ambiguity_id.clone(),
512            ))
513            .or_default()
514            .push(TecArcBuildSample {
515                time_s: epoch.time_s,
516                receiver_latitude_rad: epoch.receiver_latitude_rad,
517                receiver_longitude_rad: epoch.receiver_longitude_rad,
518                elevation_rad: observation.elevation_rad,
519                azimuth_rad: observation.azimuth_rad,
520                code_geometry_free_m: code_estimate.code_geometry_free_m,
521                phase_geometry_free_m: phase_estimate.phase_geometry_free_m,
522                code_slant_tec_tecu: code_estimate.slant_tec_tecu,
523                phase_slant_tec_tecu: phase_estimate.slant_tec_tecu,
524            });
525        }
526    }
527
528    if arcs.is_empty() {
529        return Err(TecError::NoObservations);
530    }
531
532    let mut out_arcs = Vec::with_capacity(arcs.len());
533    for ((satellite_id, ambiguity_id), samples) in arcs {
534        if samples.len() < 2 {
535            return Err(TecError::InsufficientArcSamples);
536        }
537        let leveling_samples = samples
538            .iter()
539            .map(|sample| TecLevelingSample {
540                code_slant_tec_tecu: sample.code_slant_tec_tecu,
541                phase_slant_tec_tecu: sample.phase_slant_tec_tecu,
542                elevation_rad: sample.elevation_rad,
543            })
544            .collect::<Vec<_>>();
545        let leveled = level_slant_tec_arc(&leveling_samples, config)?;
546        let output_samples = samples
547            .iter()
548            .zip(leveled.samples.iter())
549            .map(|(sample, leveled)| {
550                let pierce_point = ionospheric_pierce_point(
551                    sample.receiver_latitude_rad,
552                    sample.receiver_longitude_rad,
553                    sample.elevation_rad,
554                    sample.azimuth_rad,
555                    config,
556                )?;
557                Ok(TecEstimateSample {
558                    time_s: sample.time_s,
559                    elevation_rad: sample.elevation_rad,
560                    azimuth_rad: sample.azimuth_rad,
561                    code_geometry_free_m: sample.code_geometry_free_m,
562                    phase_geometry_free_m: sample.phase_geometry_free_m,
563                    code_slant_tec_tecu: sample.code_slant_tec_tecu,
564                    phase_slant_tec_tecu: sample.phase_slant_tec_tecu,
565                    leveled_slant_tec_tecu: leveled.leveled_slant_tec_tecu,
566                    mapping_function: leveled.mapping_function,
567                    vertical_tec_tecu: leveled.vertical_tec_tecu,
568                    pierce_point,
569                })
570            })
571            .collect::<Result<Vec<_>, TecError>>()?;
572        out_arcs.push(TecSatelliteArc {
573            satellite_id,
574            ambiguity_id,
575            phase_bias_tecu: leveled.phase_bias_tecu,
576            samples: output_samples,
577        });
578    }
579
580    Ok(TecEstimate { arcs: out_arcs })
581}
582
583/// Compute the ionospheric pierce point for a receiver and satellite look angle.
584///
585/// Receiver latitude, receiver longitude, satellite elevation, and satellite
586/// azimuth are all radians. Azimuth is clockwise from north. The returned
587/// longitude is normalized to `[-pi, pi)`.
588pub fn ionospheric_pierce_point(
589    receiver_latitude_rad: f64,
590    receiver_longitude_rad: f64,
591    elevation_rad: f64,
592    azimuth_rad: f64,
593    config: TecConfig,
594) -> Result<IonosphericPiercePoint, TecError> {
595    config.validate()?;
596    validate_receiver_latitude(receiver_latitude_rad)?;
597    validate_receiver_longitude(receiver_longitude_rad)?;
598    validate_elevation(elevation_rad)?;
599    validate_azimuth(azimuth_rad)?;
600
601    let shell_radius_m = config.earth_radius_m + config.shell_height_m;
602    let shell_scaled_cosine = config.earth_radius_m / shell_radius_m * elevation_rad.cos();
603    let earth_central_angle_rad = FRAC_PI_2 - elevation_rad - shell_scaled_cosine.asin();
604
605    let receiver_sin = receiver_latitude_rad.sin();
606    let receiver_cos = receiver_latitude_rad.cos();
607    let psi_sin = earth_central_angle_rad.sin();
608    let psi_cos = earth_central_angle_rad.cos();
609    let azimuth_sin = azimuth_rad.sin();
610    let azimuth_cos = azimuth_rad.cos();
611
612    let latitude_rad = (receiver_sin * psi_cos + receiver_cos * psi_sin * azimuth_cos).asin();
613    let longitude_step_rad =
614        (azimuth_sin * psi_sin * receiver_cos).atan2(psi_cos - receiver_sin * latitude_rad.sin());
615    let longitude_rad = normalize_longitude_rad(receiver_longitude_rad + longitude_step_rad);
616
617    Ok(IonosphericPiercePoint {
618        latitude_rad,
619        longitude_rad,
620        latitude_deg: latitude_rad.to_degrees(),
621        longitude_deg: longitude_rad.to_degrees(),
622        earth_central_angle_rad,
623        shell_height_m: config.shell_height_m,
624    })
625}
626
627fn tec_geometry_free_denominator_m_per_tecu(f1_hz: f64, f2_hz: f64) -> Result<f64, TecError> {
628    let f1_hz = validate_frequency(f1_hz)?;
629    let f2_hz = validate_frequency(f2_hz)?;
630    if (f1_hz - f2_hz).abs() < FREQUENCY_DENOMINATOR_EPS_HZ {
631        return Err(TecError::EqualFrequencies);
632    }
633    let denominator = TEC_GROUP_DELAY_COEFFICIENT * (1.0 / (f1_hz * f1_hz) - 1.0 / (f2_hz * f2_hz));
634    validate::finite(denominator, "tec_geometry_free_denominator_m_per_tecu")
635        .map_err(|_| TecError::InvalidFrequency)?;
636    if denominator == 0.0 {
637        return Err(TecError::EqualFrequencies);
638    }
639    Ok(denominator)
640}
641
642fn validate_frequency(frequency_hz: f64) -> Result<f64, TecError> {
643    validate::finite_positive(frequency_hz, "frequency_hz").map_err(|_| TecError::InvalidFrequency)
644}
645
646fn validate_code_observation(observation: &DualFrequencyObservation) -> Result<(), TecError> {
647    if observation.p1_m.is_finite() && observation.p2_m.is_finite() {
648        Ok(())
649    } else {
650        Err(TecError::NonFiniteObservation)
651    }
652}
653
654#[derive(Debug, Clone)]
655struct TecArcBuildSample {
656    time_s: f64,
657    receiver_latitude_rad: f64,
658    receiver_longitude_rad: f64,
659    elevation_rad: f64,
660    azimuth_rad: f64,
661    code_geometry_free_m: f64,
662    phase_geometry_free_m: f64,
663    code_slant_tec_tecu: f64,
664    phase_slant_tec_tecu: f64,
665}
666
667fn validate_tec_epochs(epochs: &[TecEpoch], config: TecConfig) -> Result<(), TecError> {
668    config.validate()?;
669    if epochs.is_empty() {
670        return Err(TecError::NoEpochs);
671    }
672
673    let mut previous_time_s = None;
674    let mut observation_count = 0usize;
675    for epoch in epochs {
676        if !epoch.time_s.is_finite() {
677            return Err(TecError::NonFiniteEpochTime);
678        }
679        if let Some(previous_time_s) = previous_time_s {
680            if epoch.time_s < previous_time_s {
681                return Err(TecError::EpochsNotOrdered);
682            }
683        }
684        previous_time_s = Some(epoch.time_s);
685        validate_receiver_latitude(epoch.receiver_latitude_rad)?;
686        validate_receiver_longitude(epoch.receiver_longitude_rad)?;
687        for observation in &epoch.observations {
688            validate_elevation(observation.elevation_rad)?;
689            validate_azimuth(observation.azimuth_rad)?;
690            observation_count += 1;
691        }
692    }
693
694    if observation_count == 0 {
695        Err(TecError::NoObservations)
696    } else {
697        Ok(())
698    }
699}
700
701fn validate_tec(value: f64) -> Result<(), TecError> {
702    if value.is_finite() {
703        Ok(())
704    } else {
705        Err(TecError::NonFiniteTec)
706    }
707}
708
709fn validate_leveling_sample(sample: &TecLevelingSample) -> Result<(), TecError> {
710    validate_tec(sample.code_slant_tec_tecu)?;
711    validate_tec(sample.phase_slant_tec_tecu)?;
712    validate_elevation(sample.elevation_rad)
713}
714
715fn map_cycle_slip_error(error: CycleSlipError) -> TecError {
716    match error {
717        CycleSlipError::NonFiniteObservation => TecError::NonFiniteObservation,
718        CycleSlipError::InvalidFrequency => TecError::InvalidFrequency,
719        CycleSlipError::EqualFrequencies => TecError::EqualFrequencies,
720        CycleSlipError::InvalidConfig(_)
721        | CycleSlipError::NonFiniteEpochTime
722        | CycleSlipError::EpochsNotOrdered => TecError::NonFiniteObservation,
723    }
724}
725
726fn validate_receiver_latitude(latitude_rad: f64) -> Result<(), TecError> {
727    if latitude_rad.is_finite() && (-FRAC_PI_2..=FRAC_PI_2).contains(&latitude_rad) {
728        Ok(())
729    } else {
730        Err(TecError::InvalidReceiverLatitude)
731    }
732}
733
734fn validate_receiver_longitude(longitude_rad: f64) -> Result<(), TecError> {
735    if longitude_rad.is_finite() {
736        Ok(())
737    } else {
738        Err(TecError::InvalidReceiverLongitude)
739    }
740}
741
742fn validate_elevation(elevation_rad: f64) -> Result<(), TecError> {
743    if elevation_rad.is_finite() && (0.0..=FRAC_PI_2).contains(&elevation_rad) {
744        Ok(())
745    } else {
746        Err(TecError::InvalidElevation)
747    }
748}
749
750fn validate_azimuth(azimuth_rad: f64) -> Result<(), TecError> {
751    if azimuth_rad.is_finite() {
752        Ok(())
753    } else {
754        Err(TecError::InvalidAzimuth)
755    }
756}
757
758fn normalize_longitude_rad(longitude_rad: f64) -> f64 {
759    let mut normalized = (longitude_rad + PI) % TAU;
760    if normalized < 0.0 {
761        normalized += TAU;
762    }
763    normalized - PI
764}
765
766#[cfg(test)]
767mod tests {
768    use crate::constants::{F_L1_HZ, F_L2_HZ};
769
770    use super::*;
771
772    fn deg(value: f64) -> f64 {
773        value.to_radians()
774    }
775
776    fn observation_with_code_geometry_free(code_geometry_free_m: f64) -> DualFrequencyObservation {
777        let (p1_m, p2_m) = if code_geometry_free_m.is_sign_negative() {
778            (0.0, -code_geometry_free_m)
779        } else {
780            (code_geometry_free_m, 0.0)
781        };
782        DualFrequencyObservation {
783            satellite_id: "G01".to_string(),
784            ambiguity_id: "G01".to_string(),
785            p1_m,
786            p2_m,
787            phi1_cyc: 0.0,
788            phi2_cyc: 0.0,
789            f1_hz: F_L1_HZ,
790            f2_hz: F_L2_HZ,
791            lli1: None,
792            lli2: None,
793        }
794    }
795
796    fn observation_from_slant_tec(
797        satellite_id: &str,
798        ambiguity_id: &str,
799        code_slant_tec_tecu: f64,
800        phase_slant_tec_tecu: f64,
801    ) -> DualFrequencyObservation {
802        let denominator = tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
803            .expect("GPS L1/L2 TEC denominator");
804        let code_geometry_free_m = denominator * code_slant_tec_tecu;
805        let phase_geometry_free_m = -denominator * phase_slant_tec_tecu;
806        DualFrequencyObservation {
807            satellite_id: satellite_id.to_string(),
808            ambiguity_id: ambiguity_id.to_string(),
809            p1_m: 0.0,
810            p2_m: -code_geometry_free_m,
811            phi1_cyc: phase_geometry_free_m / (crate::constants::C_M_S / F_L1_HZ),
812            phi2_cyc: 0.0,
813            f1_hz: F_L1_HZ,
814            f2_hz: F_L2_HZ,
815            lli1: None,
816            lli2: None,
817        }
818    }
819
820    fn arc_by_satellite<'a>(estimate: &'a TecEstimate, satellite_id: &str) -> &'a TecSatelliteArc {
821        estimate
822            .arcs
823            .iter()
824            .find(|arc| arc.satellite_id == satellite_id)
825            .expect("satellite arc")
826    }
827
828    fn assert_close(left: f64, right: f64, tolerance: f64) {
829        assert!(
830            (left - right).abs() <= tolerance,
831            "{left} differs from {right} by more than {tolerance}"
832        );
833    }
834
835    #[test]
836    fn code_geometry_free_delay_maps_to_expected_slant_tec() {
837        let expected_slant_tec_tecu = 17.25;
838        let code_geometry_free_m = expected_slant_tec_tecu
839            * tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
840                .expect("GPS L1/L2 TEC denominator");
841        let observation = observation_with_code_geometry_free(code_geometry_free_m);
842
843        let estimate = estimate_code_slant_tec(&observation).expect("code slant TEC");
844
845        assert_close(estimate.code_geometry_free_m, code_geometry_free_m, 1.0e-9);
846        assert_close(estimate.slant_tec_tecu, expected_slant_tec_tecu, 1.0e-12);
847    }
848
849    #[test]
850    fn zero_code_geometry_free_delay_gives_zero_slant_tec() {
851        let observation = observation_with_code_geometry_free(0.0);
852
853        let estimate = estimate_code_slant_tec(&observation).expect("code slant TEC");
854
855        assert_eq!(estimate.code_geometry_free_m.to_bits(), 0.0f64.to_bits());
856        assert_eq!(estimate.slant_tec_tecu.to_bits(), 0.0f64.to_bits());
857    }
858
859    #[test]
860    fn phase_geometry_free_delay_maps_to_biased_slant_tec() {
861        let true_slant_tec_tecu = 21.0;
862        let phase_bias_tecu = 9.5;
863        let denominator = tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
864            .expect("GPS L1/L2 TEC denominator");
865        let phase_geometry_free_m = -(true_slant_tec_tecu + phase_bias_tecu) * denominator;
866
867        let slant_tec_tecu =
868            slant_tec_from_phase_geometry_free_m(phase_geometry_free_m, F_L1_HZ, F_L2_HZ)
869                .expect("phase slant TEC");
870
871        assert_close(
872            slant_tec_tecu,
873            true_slant_tec_tecu + phase_bias_tecu,
874            1.0e-12,
875        );
876    }
877
878    #[test]
879    fn phase_slant_tec_rejects_collapsed_frequency_denominator() {
880        assert_eq!(
881            slant_tec_from_phase_geometry_free_m(1.0, f64::MAX, f64::MAX / 2.0),
882            Err(TecError::EqualFrequencies)
883        );
884    }
885
886    #[test]
887    fn mapping_function_is_one_at_zenith_and_increases_toward_horizon() {
888        let config = TecConfig::default();
889
890        let zenith = thin_shell_mapping_function(FRAC_PI_2, config).expect("zenith mapping");
891        let high = thin_shell_mapping_function(deg(60.0), config).expect("high mapping");
892        let low = thin_shell_mapping_function(deg(30.0), config).expect("low mapping");
893        let horizon = thin_shell_mapping_function(0.0, config).expect("horizon mapping");
894
895        assert_close(zenith, 1.0, 1.0e-15);
896        assert!(high > zenith);
897        assert!(low > high);
898        assert!(horizon > low);
899    }
900
901    #[test]
902    fn mapping_function_rejects_degenerate_shell_geometry() {
903        let config = TecConfig {
904            shell_height_m: f64::MIN_POSITIVE,
905            earth_radius_m: 1.0,
906        };
907
908        assert_eq!(
909            thin_shell_mapping_function(0.0, config),
910            Err(TecError::InvalidShellHeight)
911        );
912    }
913
914    #[test]
915    fn synthetic_leveled_arc_recovers_constant_vertical_tec() {
916        let config = TecConfig::default();
917        let vertical_tec_tecu = 14.0;
918        let phase_bias_tecu = 37.5;
919        let noise_tecu = [0.6, -0.2, -0.4, 0.0];
920        let elevations_rad = [deg(30.0), deg(45.0), deg(60.0), deg(75.0)];
921        let samples = elevations_rad
922            .iter()
923            .zip(noise_tecu)
924            .map(|(&elevation_rad, noise_tecu)| {
925                let mapping_function =
926                    thin_shell_mapping_function(elevation_rad, config).expect("mapping");
927                let true_slant_tec_tecu = vertical_tec_tecu * mapping_function;
928                TecLevelingSample {
929                    code_slant_tec_tecu: true_slant_tec_tecu + noise_tecu,
930                    phase_slant_tec_tecu: true_slant_tec_tecu + phase_bias_tecu,
931                    elevation_rad,
932                }
933            })
934            .collect::<Vec<_>>();
935
936        let result = level_slant_tec_arc(&samples, config).expect("leveled TEC arc");
937
938        assert_close(result.phase_bias_tecu, phase_bias_tecu, 1.0e-12);
939        for sample in result.samples {
940            assert_close(sample.vertical_tec_tecu, vertical_tec_tecu, 1.0e-12);
941        }
942    }
943
944    #[test]
945    fn known_elevation_profile_yields_expected_slant_to_vertical_reduction() {
946        let config = TecConfig::default();
947        let vertical_tec_tecu = 8.25;
948        let elevations_rad = [deg(25.0), deg(55.0), deg(85.0)];
949        let samples = elevations_rad
950            .iter()
951            .map(|&elevation_rad| {
952                let mapping_function =
953                    thin_shell_mapping_function(elevation_rad, config).expect("mapping");
954                let slant_tec_tecu = vertical_tec_tecu * mapping_function;
955                TecLevelingSample {
956                    code_slant_tec_tecu: slant_tec_tecu,
957                    phase_slant_tec_tecu: slant_tec_tecu,
958                    elevation_rad,
959                }
960            })
961            .collect::<Vec<_>>();
962
963        let result = level_slant_tec_arc(&samples, config).expect("leveled TEC arc");
964
965        assert_close(result.phase_bias_tecu, 0.0, 1.0e-12);
966        for (sample, elevation_rad) in result.samples.iter().zip(elevations_rad) {
967            let mapping_function =
968                thin_shell_mapping_function(elevation_rad, config).expect("mapping");
969            assert_close(sample.mapping_function, mapping_function, 1.0e-15);
970            assert_close(sample.vertical_tec_tecu, vertical_tec_tecu, 1.0e-12);
971        }
972    }
973
974    #[test]
975    fn estimate_tec_multi_epoch_stream_returns_vertical_tec_and_pierce_points() {
976        let config = TecConfig::default();
977        let receiver_latitude_rad = 0.0;
978        let receiver_longitude_rad = 0.0;
979        let g01_vertical_tec_tecu = 11.0;
980        let g02_vertical_tec_tecu = 16.0;
981        let g01_phase_bias_tecu = 25.0;
982        let g02_phase_bias_tecu = -13.0;
983        let epochs = [0.0, 30.0, 60.0]
984            .into_iter()
985            .enumerate()
986            .map(|(idx, time_s)| {
987                let g01_elevation_rad = [deg(45.0), deg(55.0), deg(65.0)][idx];
988                let g02_elevation_rad = [deg(40.0), deg(50.0), deg(70.0)][idx];
989                let g01_mapping =
990                    thin_shell_mapping_function(g01_elevation_rad, config).expect("G01 mapping");
991                let g02_mapping =
992                    thin_shell_mapping_function(g02_elevation_rad, config).expect("G02 mapping");
993                let g01_slant_tec_tecu = g01_vertical_tec_tecu * g01_mapping;
994                let g02_slant_tec_tecu = g02_vertical_tec_tecu * g02_mapping;
995                TecEpoch {
996                    time_s,
997                    receiver_latitude_rad,
998                    receiver_longitude_rad,
999                    observations: vec![
1000                        TecObservation {
1001                            observation: observation_from_slant_tec(
1002                                "G01",
1003                                "G01",
1004                                g01_slant_tec_tecu,
1005                                g01_slant_tec_tecu + g01_phase_bias_tecu,
1006                            ),
1007                            elevation_rad: g01_elevation_rad,
1008                            azimuth_rad: deg(90.0),
1009                        },
1010                        TecObservation {
1011                            observation: observation_from_slant_tec(
1012                                "G02",
1013                                "G02",
1014                                g02_slant_tec_tecu,
1015                                g02_slant_tec_tecu + g02_phase_bias_tecu,
1016                            ),
1017                            elevation_rad: g02_elevation_rad,
1018                            azimuth_rad: 0.0,
1019                        },
1020                    ],
1021                }
1022            })
1023            .collect::<Vec<_>>();
1024
1025        let estimate = estimate_tec(&epochs, config).expect("TEC estimate");
1026
1027        assert_eq!(estimate.arcs.len(), 2);
1028        let g01 = arc_by_satellite(&estimate, "G01");
1029        let g02 = arc_by_satellite(&estimate, "G02");
1030        assert_close(g01.phase_bias_tecu, g01_phase_bias_tecu, 1.0e-12);
1031        assert_close(g02.phase_bias_tecu, g02_phase_bias_tecu, 1.0e-12);
1032        for sample in &g01.samples {
1033            assert_close(sample.vertical_tec_tecu, g01_vertical_tec_tecu, 1.0e-12);
1034            assert_close(sample.pierce_point.latitude_rad, 0.0, 1.0e-12);
1035            assert!(sample.pierce_point.longitude_rad > 0.0);
1036        }
1037        for sample in &g02.samples {
1038            assert_close(sample.vertical_tec_tecu, g02_vertical_tec_tecu, 1.0e-12);
1039            assert!(sample.pierce_point.latitude_rad > 0.0);
1040            assert_close(sample.pierce_point.longitude_rad, 0.0, 1.0e-12);
1041        }
1042    }
1043
1044    #[test]
1045    fn estimate_tec_rejects_insufficient_and_invalid_inputs() {
1046        let config = TecConfig::default();
1047        assert_eq!(estimate_tec(&[], config), Err(TecError::NoEpochs));
1048
1049        let single_epoch = vec![TecEpoch {
1050            time_s: 0.0,
1051            receiver_latitude_rad: 0.0,
1052            receiver_longitude_rad: 0.0,
1053            observations: vec![TecObservation {
1054                observation: observation_from_slant_tec("G01", "G01", 10.0, 12.0),
1055                elevation_rad: deg(45.0),
1056                azimuth_rad: 0.0,
1057            }],
1058        }];
1059        assert_eq!(
1060            estimate_tec(&single_epoch, config),
1061            Err(TecError::InsufficientArcSamples)
1062        );
1063
1064        let unordered = vec![
1065            TecEpoch {
1066                time_s: 30.0,
1067                receiver_latitude_rad: 0.0,
1068                receiver_longitude_rad: 0.0,
1069                observations: Vec::new(),
1070            },
1071            TecEpoch {
1072                time_s: 0.0,
1073                receiver_latitude_rad: 0.0,
1074                receiver_longitude_rad: 0.0,
1075                observations: Vec::new(),
1076            },
1077        ];
1078        assert_eq!(
1079            estimate_tec(&unordered, config),
1080            Err(TecError::EpochsNotOrdered)
1081        );
1082
1083        let invalid_elevation = vec![TecEpoch {
1084            time_s: 0.0,
1085            receiver_latitude_rad: 0.0,
1086            receiver_longitude_rad: 0.0,
1087            observations: vec![TecObservation {
1088                observation: observation_from_slant_tec("G01", "G01", 10.0, 12.0),
1089                elevation_rad: -0.1,
1090                azimuth_rad: 0.0,
1091            }],
1092        }];
1093        assert_eq!(
1094            estimate_tec(&invalid_elevation, config),
1095            Err(TecError::InvalidElevation)
1096        );
1097    }
1098
1099    #[test]
1100    fn pierce_point_at_zenith_equals_receiver_horizontal_position() {
1101        let config = TecConfig::default();
1102        let receiver_latitude_rad = deg(34.25);
1103        let receiver_longitude_rad = deg(-118.125);
1104
1105        let pierce_point = ionospheric_pierce_point(
1106            receiver_latitude_rad,
1107            receiver_longitude_rad,
1108            FRAC_PI_2,
1109            deg(127.0),
1110            config,
1111        )
1112        .expect("zenith pierce point");
1113
1114        assert_close(pierce_point.latitude_rad, receiver_latitude_rad, 1.0e-12);
1115        assert_close(pierce_point.longitude_rad, receiver_longitude_rad, 1.0e-12);
1116        assert_close(pierce_point.earth_central_angle_rad, 0.0, 1.0e-12);
1117    }
1118
1119    #[test]
1120    fn pierce_point_moves_toward_satellite_azimuth_as_elevation_decreases() {
1121        let config = TecConfig::default();
1122        let receiver_latitude_rad = 0.0;
1123        let receiver_longitude_rad = 0.0;
1124        let east_azimuth_rad = deg(90.0);
1125
1126        let high = ionospheric_pierce_point(
1127            receiver_latitude_rad,
1128            receiver_longitude_rad,
1129            deg(80.0),
1130            east_azimuth_rad,
1131            config,
1132        )
1133        .expect("high-elevation pierce point");
1134        let low = ionospheric_pierce_point(
1135            receiver_latitude_rad,
1136            receiver_longitude_rad,
1137            deg(30.0),
1138            east_azimuth_rad,
1139            config,
1140        )
1141        .expect("low-elevation pierce point");
1142
1143        assert_close(high.latitude_rad, 0.0, 1.0e-12);
1144        assert_close(low.latitude_rad, 0.0, 1.0e-12);
1145        assert!(high.longitude_rad > 0.0);
1146        assert!(low.longitude_rad > high.longitude_rad);
1147    }
1148}