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 * libm::cos(elevation_rad) / 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 * libm::cos(elevation_rad);
603    let earth_central_angle_rad = FRAC_PI_2 - elevation_rad - libm::asin(shell_scaled_cosine);
604
605    let receiver_sin = libm::sin(receiver_latitude_rad);
606    let receiver_cos = libm::cos(receiver_latitude_rad);
607    let psi_sin = libm::sin(earth_central_angle_rad);
608    let psi_cos = libm::cos(earth_central_angle_rad);
609    let azimuth_sin = libm::sin(azimuth_rad);
610    let azimuth_cos = libm::cos(azimuth_rad);
611
612    let latitude_sine =
613        (receiver_sin * psi_cos + receiver_cos * psi_sin * azimuth_cos).clamp(-1.0, 1.0);
614    let latitude_rad = libm::asin(latitude_sine);
615    let longitude_step_rad = libm::atan2(
616        azimuth_sin * psi_sin * receiver_cos,
617        psi_cos - receiver_sin * libm::sin(latitude_rad),
618    );
619    let longitude_rad = normalize_longitude_rad(receiver_longitude_rad + longitude_step_rad);
620
621    Ok(IonosphericPiercePoint {
622        latitude_rad,
623        longitude_rad,
624        latitude_deg: latitude_rad.to_degrees(),
625        longitude_deg: longitude_rad.to_degrees(),
626        earth_central_angle_rad,
627        shell_height_m: config.shell_height_m,
628    })
629}
630
631fn tec_geometry_free_denominator_m_per_tecu(f1_hz: f64, f2_hz: f64) -> Result<f64, TecError> {
632    let f1_hz = validate_frequency(f1_hz)?;
633    let f2_hz = validate_frequency(f2_hz)?;
634    if (f1_hz - f2_hz).abs() < FREQUENCY_DENOMINATOR_EPS_HZ {
635        return Err(TecError::EqualFrequencies);
636    }
637    let denominator = TEC_GROUP_DELAY_COEFFICIENT * (1.0 / (f1_hz * f1_hz) - 1.0 / (f2_hz * f2_hz));
638    validate::finite(denominator, "tec_geometry_free_denominator_m_per_tecu")
639        .map_err(|_| TecError::InvalidFrequency)?;
640    if denominator == 0.0 {
641        return Err(TecError::EqualFrequencies);
642    }
643    Ok(denominator)
644}
645
646fn validate_frequency(frequency_hz: f64) -> Result<f64, TecError> {
647    validate::finite_positive(frequency_hz, "frequency_hz").map_err(|_| TecError::InvalidFrequency)
648}
649
650fn validate_code_observation(observation: &DualFrequencyObservation) -> Result<(), TecError> {
651    if observation.p1_m.is_finite() && observation.p2_m.is_finite() {
652        Ok(())
653    } else {
654        Err(TecError::NonFiniteObservation)
655    }
656}
657
658#[derive(Debug, Clone)]
659struct TecArcBuildSample {
660    time_s: f64,
661    receiver_latitude_rad: f64,
662    receiver_longitude_rad: f64,
663    elevation_rad: f64,
664    azimuth_rad: f64,
665    code_geometry_free_m: f64,
666    phase_geometry_free_m: f64,
667    code_slant_tec_tecu: f64,
668    phase_slant_tec_tecu: f64,
669}
670
671fn validate_tec_epochs(epochs: &[TecEpoch], config: TecConfig) -> Result<(), TecError> {
672    config.validate()?;
673    if epochs.is_empty() {
674        return Err(TecError::NoEpochs);
675    }
676
677    let mut previous_time_s = None;
678    let mut observation_count = 0usize;
679    for epoch in epochs {
680        if !epoch.time_s.is_finite() {
681            return Err(TecError::NonFiniteEpochTime);
682        }
683        if let Some(previous_time_s) = previous_time_s {
684            if epoch.time_s < previous_time_s {
685                return Err(TecError::EpochsNotOrdered);
686            }
687        }
688        previous_time_s = Some(epoch.time_s);
689        validate_receiver_latitude(epoch.receiver_latitude_rad)?;
690        validate_receiver_longitude(epoch.receiver_longitude_rad)?;
691        for observation in &epoch.observations {
692            validate_elevation(observation.elevation_rad)?;
693            validate_azimuth(observation.azimuth_rad)?;
694            observation_count += 1;
695        }
696    }
697
698    if observation_count == 0 {
699        Err(TecError::NoObservations)
700    } else {
701        Ok(())
702    }
703}
704
705fn validate_tec(value: f64) -> Result<(), TecError> {
706    if value.is_finite() {
707        Ok(())
708    } else {
709        Err(TecError::NonFiniteTec)
710    }
711}
712
713fn validate_leveling_sample(sample: &TecLevelingSample) -> Result<(), TecError> {
714    validate_tec(sample.code_slant_tec_tecu)?;
715    validate_tec(sample.phase_slant_tec_tecu)?;
716    validate_elevation(sample.elevation_rad)
717}
718
719fn map_cycle_slip_error(error: CycleSlipError) -> TecError {
720    match error {
721        CycleSlipError::NonFiniteObservation => TecError::NonFiniteObservation,
722        CycleSlipError::InvalidFrequency => TecError::InvalidFrequency,
723        CycleSlipError::EqualFrequencies => TecError::EqualFrequencies,
724        CycleSlipError::InvalidConfig(_)
725        | CycleSlipError::NonFiniteEpochTime
726        | CycleSlipError::EpochsNotOrdered => TecError::NonFiniteObservation,
727    }
728}
729
730fn validate_receiver_latitude(latitude_rad: f64) -> Result<(), TecError> {
731    if latitude_rad.is_finite() && (-FRAC_PI_2..=FRAC_PI_2).contains(&latitude_rad) {
732        Ok(())
733    } else {
734        Err(TecError::InvalidReceiverLatitude)
735    }
736}
737
738fn validate_receiver_longitude(longitude_rad: f64) -> Result<(), TecError> {
739    if longitude_rad.is_finite() {
740        Ok(())
741    } else {
742        Err(TecError::InvalidReceiverLongitude)
743    }
744}
745
746fn validate_elevation(elevation_rad: f64) -> Result<(), TecError> {
747    if elevation_rad.is_finite() && (0.0..=FRAC_PI_2).contains(&elevation_rad) {
748        Ok(())
749    } else {
750        Err(TecError::InvalidElevation)
751    }
752}
753
754fn validate_azimuth(azimuth_rad: f64) -> Result<(), TecError> {
755    if azimuth_rad.is_finite() {
756        Ok(())
757    } else {
758        Err(TecError::InvalidAzimuth)
759    }
760}
761
762fn normalize_longitude_rad(longitude_rad: f64) -> f64 {
763    let mut normalized = (longitude_rad + PI) % TAU;
764    if normalized < 0.0 {
765        normalized += TAU;
766    }
767    normalized - PI
768}
769
770#[cfg(test)]
771mod tests {
772    use crate::constants::{F_L1_HZ, F_L2_HZ};
773
774    use super::*;
775
776    fn deg(value: f64) -> f64 {
777        value.to_radians()
778    }
779
780    fn observation_with_code_geometry_free(code_geometry_free_m: f64) -> DualFrequencyObservation {
781        let (p1_m, p2_m) = if code_geometry_free_m.is_sign_negative() {
782            (0.0, -code_geometry_free_m)
783        } else {
784            (code_geometry_free_m, 0.0)
785        };
786        DualFrequencyObservation {
787            satellite_id: "G01".to_string(),
788            ambiguity_id: "G01".to_string(),
789            p1_m,
790            p2_m,
791            phi1_cyc: 0.0,
792            phi2_cyc: 0.0,
793            f1_hz: F_L1_HZ,
794            f2_hz: F_L2_HZ,
795            lli1: None,
796            lli2: None,
797        }
798    }
799
800    fn observation_from_slant_tec(
801        satellite_id: &str,
802        ambiguity_id: &str,
803        code_slant_tec_tecu: f64,
804        phase_slant_tec_tecu: f64,
805    ) -> DualFrequencyObservation {
806        let denominator = tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
807            .expect("GPS L1/L2 TEC denominator");
808        let code_geometry_free_m = denominator * code_slant_tec_tecu;
809        let phase_geometry_free_m = -denominator * phase_slant_tec_tecu;
810        DualFrequencyObservation {
811            satellite_id: satellite_id.to_string(),
812            ambiguity_id: ambiguity_id.to_string(),
813            p1_m: 0.0,
814            p2_m: -code_geometry_free_m,
815            phi1_cyc: phase_geometry_free_m / (crate::constants::C_M_S / F_L1_HZ),
816            phi2_cyc: 0.0,
817            f1_hz: F_L1_HZ,
818            f2_hz: F_L2_HZ,
819            lli1: None,
820            lli2: None,
821        }
822    }
823
824    fn arc_by_satellite<'a>(estimate: &'a TecEstimate, satellite_id: &str) -> &'a TecSatelliteArc {
825        estimate
826            .arcs
827            .iter()
828            .find(|arc| arc.satellite_id == satellite_id)
829            .expect("satellite arc")
830    }
831
832    fn assert_close(left: f64, right: f64, tolerance: f64) {
833        assert!(
834            (left - right).abs() <= tolerance,
835            "{left} differs from {right} by more than {tolerance}"
836        );
837    }
838
839    #[test]
840    fn code_geometry_free_delay_maps_to_expected_slant_tec() {
841        let expected_slant_tec_tecu = 17.25;
842        let code_geometry_free_m = expected_slant_tec_tecu
843            * tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
844                .expect("GPS L1/L2 TEC denominator");
845        let observation = observation_with_code_geometry_free(code_geometry_free_m);
846
847        let estimate = estimate_code_slant_tec(&observation).expect("code slant TEC");
848
849        assert_close(estimate.code_geometry_free_m, code_geometry_free_m, 1.0e-9);
850        assert_close(estimate.slant_tec_tecu, expected_slant_tec_tecu, 1.0e-12);
851    }
852
853    #[test]
854    fn zero_code_geometry_free_delay_gives_zero_slant_tec() {
855        let observation = observation_with_code_geometry_free(0.0);
856
857        let estimate = estimate_code_slant_tec(&observation).expect("code slant TEC");
858
859        assert_eq!(estimate.code_geometry_free_m.to_bits(), 0.0f64.to_bits());
860        assert_eq!(estimate.slant_tec_tecu.to_bits(), 0.0f64.to_bits());
861    }
862
863    #[test]
864    fn phase_geometry_free_delay_maps_to_biased_slant_tec() {
865        let true_slant_tec_tecu = 21.0;
866        let phase_bias_tecu = 9.5;
867        let denominator = tec_geometry_free_denominator_m_per_tecu(F_L1_HZ, F_L2_HZ)
868            .expect("GPS L1/L2 TEC denominator");
869        let phase_geometry_free_m = -(true_slant_tec_tecu + phase_bias_tecu) * denominator;
870
871        let slant_tec_tecu =
872            slant_tec_from_phase_geometry_free_m(phase_geometry_free_m, F_L1_HZ, F_L2_HZ)
873                .expect("phase slant TEC");
874
875        assert_close(
876            slant_tec_tecu,
877            true_slant_tec_tecu + phase_bias_tecu,
878            1.0e-12,
879        );
880    }
881
882    #[test]
883    fn phase_slant_tec_rejects_collapsed_frequency_denominator() {
884        assert_eq!(
885            slant_tec_from_phase_geometry_free_m(1.0, f64::MAX, f64::MAX / 2.0),
886            Err(TecError::EqualFrequencies)
887        );
888    }
889
890    #[test]
891    fn mapping_function_is_one_at_zenith_and_increases_toward_horizon() {
892        let config = TecConfig::default();
893
894        let zenith = thin_shell_mapping_function(FRAC_PI_2, config).expect("zenith mapping");
895        let high = thin_shell_mapping_function(deg(60.0), config).expect("high mapping");
896        let low = thin_shell_mapping_function(deg(30.0), config).expect("low mapping");
897        let horizon = thin_shell_mapping_function(0.0, config).expect("horizon mapping");
898
899        assert_close(zenith, 1.0, 1.0e-15);
900        assert!(high > zenith);
901        assert!(low > high);
902        assert!(horizon > low);
903    }
904
905    #[test]
906    fn mapping_function_rejects_degenerate_shell_geometry() {
907        let config = TecConfig {
908            shell_height_m: f64::MIN_POSITIVE,
909            earth_radius_m: 1.0,
910        };
911
912        assert_eq!(
913            thin_shell_mapping_function(0.0, config),
914            Err(TecError::InvalidShellHeight)
915        );
916    }
917
918    #[test]
919    fn synthetic_leveled_arc_recovers_constant_vertical_tec() {
920        let config = TecConfig::default();
921        let vertical_tec_tecu = 14.0;
922        let phase_bias_tecu = 37.5;
923        let noise_tecu = [0.6, -0.2, -0.4, 0.0];
924        let elevations_rad = [deg(30.0), deg(45.0), deg(60.0), deg(75.0)];
925        let samples = elevations_rad
926            .iter()
927            .zip(noise_tecu)
928            .map(|(&elevation_rad, noise_tecu)| {
929                let mapping_function =
930                    thin_shell_mapping_function(elevation_rad, config).expect("mapping");
931                let true_slant_tec_tecu = vertical_tec_tecu * mapping_function;
932                TecLevelingSample {
933                    code_slant_tec_tecu: true_slant_tec_tecu + noise_tecu,
934                    phase_slant_tec_tecu: true_slant_tec_tecu + phase_bias_tecu,
935                    elevation_rad,
936                }
937            })
938            .collect::<Vec<_>>();
939
940        let result = level_slant_tec_arc(&samples, config).expect("leveled TEC arc");
941
942        assert_close(result.phase_bias_tecu, phase_bias_tecu, 1.0e-12);
943        for sample in result.samples {
944            assert_close(sample.vertical_tec_tecu, vertical_tec_tecu, 1.0e-12);
945        }
946    }
947
948    #[test]
949    fn known_elevation_profile_yields_expected_slant_to_vertical_reduction() {
950        let config = TecConfig::default();
951        let vertical_tec_tecu = 8.25;
952        let elevations_rad = [deg(25.0), deg(55.0), deg(85.0)];
953        let samples = elevations_rad
954            .iter()
955            .map(|&elevation_rad| {
956                let mapping_function =
957                    thin_shell_mapping_function(elevation_rad, config).expect("mapping");
958                let slant_tec_tecu = vertical_tec_tecu * mapping_function;
959                TecLevelingSample {
960                    code_slant_tec_tecu: slant_tec_tecu,
961                    phase_slant_tec_tecu: slant_tec_tecu,
962                    elevation_rad,
963                }
964            })
965            .collect::<Vec<_>>();
966
967        let result = level_slant_tec_arc(&samples, config).expect("leveled TEC arc");
968
969        assert_close(result.phase_bias_tecu, 0.0, 1.0e-12);
970        for (sample, elevation_rad) in result.samples.iter().zip(elevations_rad) {
971            let mapping_function =
972                thin_shell_mapping_function(elevation_rad, config).expect("mapping");
973            assert_close(sample.mapping_function, mapping_function, 1.0e-15);
974            assert_close(sample.vertical_tec_tecu, vertical_tec_tecu, 1.0e-12);
975        }
976    }
977
978    #[test]
979    fn estimate_tec_multi_epoch_stream_returns_vertical_tec_and_pierce_points() {
980        let config = TecConfig::default();
981        let receiver_latitude_rad = 0.0;
982        let receiver_longitude_rad = 0.0;
983        let g01_vertical_tec_tecu = 11.0;
984        let g02_vertical_tec_tecu = 16.0;
985        let g01_phase_bias_tecu = 25.0;
986        let g02_phase_bias_tecu = -13.0;
987        let epochs = [0.0, 30.0, 60.0]
988            .into_iter()
989            .enumerate()
990            .map(|(idx, time_s)| {
991                let g01_elevation_rad = [deg(45.0), deg(55.0), deg(65.0)][idx];
992                let g02_elevation_rad = [deg(40.0), deg(50.0), deg(70.0)][idx];
993                let g01_mapping =
994                    thin_shell_mapping_function(g01_elevation_rad, config).expect("G01 mapping");
995                let g02_mapping =
996                    thin_shell_mapping_function(g02_elevation_rad, config).expect("G02 mapping");
997                let g01_slant_tec_tecu = g01_vertical_tec_tecu * g01_mapping;
998                let g02_slant_tec_tecu = g02_vertical_tec_tecu * g02_mapping;
999                TecEpoch {
1000                    time_s,
1001                    receiver_latitude_rad,
1002                    receiver_longitude_rad,
1003                    observations: vec![
1004                        TecObservation {
1005                            observation: observation_from_slant_tec(
1006                                "G01",
1007                                "G01",
1008                                g01_slant_tec_tecu,
1009                                g01_slant_tec_tecu + g01_phase_bias_tecu,
1010                            ),
1011                            elevation_rad: g01_elevation_rad,
1012                            azimuth_rad: deg(90.0),
1013                        },
1014                        TecObservation {
1015                            observation: observation_from_slant_tec(
1016                                "G02",
1017                                "G02",
1018                                g02_slant_tec_tecu,
1019                                g02_slant_tec_tecu + g02_phase_bias_tecu,
1020                            ),
1021                            elevation_rad: g02_elevation_rad,
1022                            azimuth_rad: 0.0,
1023                        },
1024                    ],
1025                }
1026            })
1027            .collect::<Vec<_>>();
1028
1029        let estimate = estimate_tec(&epochs, config).expect("TEC estimate");
1030
1031        assert_eq!(estimate.arcs.len(), 2);
1032        let g01 = arc_by_satellite(&estimate, "G01");
1033        let g02 = arc_by_satellite(&estimate, "G02");
1034        assert_close(g01.phase_bias_tecu, g01_phase_bias_tecu, 1.0e-12);
1035        assert_close(g02.phase_bias_tecu, g02_phase_bias_tecu, 1.0e-12);
1036        for sample in &g01.samples {
1037            assert_close(sample.vertical_tec_tecu, g01_vertical_tec_tecu, 1.0e-12);
1038            assert_close(sample.pierce_point.latitude_rad, 0.0, 1.0e-12);
1039            assert!(sample.pierce_point.longitude_rad > 0.0);
1040        }
1041        for sample in &g02.samples {
1042            assert_close(sample.vertical_tec_tecu, g02_vertical_tec_tecu, 1.0e-12);
1043            assert!(sample.pierce_point.latitude_rad > 0.0);
1044            assert_close(sample.pierce_point.longitude_rad, 0.0, 1.0e-12);
1045        }
1046    }
1047
1048    #[test]
1049    fn estimate_tec_rejects_insufficient_and_invalid_inputs() {
1050        let config = TecConfig::default();
1051        assert_eq!(estimate_tec(&[], config), Err(TecError::NoEpochs));
1052
1053        let single_epoch = vec![TecEpoch {
1054            time_s: 0.0,
1055            receiver_latitude_rad: 0.0,
1056            receiver_longitude_rad: 0.0,
1057            observations: vec![TecObservation {
1058                observation: observation_from_slant_tec("G01", "G01", 10.0, 12.0),
1059                elevation_rad: deg(45.0),
1060                azimuth_rad: 0.0,
1061            }],
1062        }];
1063        assert_eq!(
1064            estimate_tec(&single_epoch, config),
1065            Err(TecError::InsufficientArcSamples)
1066        );
1067
1068        let unordered = vec![
1069            TecEpoch {
1070                time_s: 30.0,
1071                receiver_latitude_rad: 0.0,
1072                receiver_longitude_rad: 0.0,
1073                observations: Vec::new(),
1074            },
1075            TecEpoch {
1076                time_s: 0.0,
1077                receiver_latitude_rad: 0.0,
1078                receiver_longitude_rad: 0.0,
1079                observations: Vec::new(),
1080            },
1081        ];
1082        assert_eq!(
1083            estimate_tec(&unordered, config),
1084            Err(TecError::EpochsNotOrdered)
1085        );
1086
1087        let invalid_elevation = vec![TecEpoch {
1088            time_s: 0.0,
1089            receiver_latitude_rad: 0.0,
1090            receiver_longitude_rad: 0.0,
1091            observations: vec![TecObservation {
1092                observation: observation_from_slant_tec("G01", "G01", 10.0, 12.0),
1093                elevation_rad: -0.1,
1094                azimuth_rad: 0.0,
1095            }],
1096        }];
1097        assert_eq!(
1098            estimate_tec(&invalid_elevation, config),
1099            Err(TecError::InvalidElevation)
1100        );
1101    }
1102
1103    #[test]
1104    fn pierce_point_at_zenith_equals_receiver_horizontal_position() {
1105        let config = TecConfig::default();
1106        let receiver_latitude_rad = deg(34.25);
1107        let receiver_longitude_rad = deg(-118.125);
1108
1109        let pierce_point = ionospheric_pierce_point(
1110            receiver_latitude_rad,
1111            receiver_longitude_rad,
1112            FRAC_PI_2,
1113            deg(127.0),
1114            config,
1115        )
1116        .expect("zenith pierce point");
1117
1118        assert_close(pierce_point.latitude_rad, receiver_latitude_rad, 1.0e-12);
1119        assert_close(pierce_point.longitude_rad, receiver_longitude_rad, 1.0e-12);
1120        assert_close(pierce_point.earth_central_angle_rad, 0.0, 1.0e-12);
1121    }
1122
1123    #[test]
1124    fn pierce_point_near_pole_remains_finite() {
1125        // Independent rounding of the spherical-trigonometry terms puts the
1126        // latitude sine one ulp above 1.0 for this valid near-polar geometry.
1127        let config = TecConfig {
1128            shell_height_m: f64::from_bits(0x0800_003f_f000_0000),
1129            earth_radius_m: f64::from_bits(0x0000_003f_7000_0000),
1130        };
1131
1132        let pierce_point = ionospheric_pierce_point(
1133            f64::from_bits(0x3ff0_0000_0000_0014),
1134            f64::from_bits(0x0000_3f00_f000_0000),
1135            f64::from_bits(0x3ff0_0000_0001_c600),
1136            f64::from_bits(0x0000_0000_0900_0000),
1137            config,
1138        )
1139        .expect("valid near-polar pierce point");
1140
1141        assert!(pierce_point.latitude_rad.is_finite());
1142        assert!(pierce_point.longitude_rad.is_finite());
1143    }
1144
1145    #[test]
1146    fn pierce_point_moves_toward_satellite_azimuth_as_elevation_decreases() {
1147        let config = TecConfig::default();
1148        let receiver_latitude_rad = 0.0;
1149        let receiver_longitude_rad = 0.0;
1150        let east_azimuth_rad = deg(90.0);
1151
1152        let high = ionospheric_pierce_point(
1153            receiver_latitude_rad,
1154            receiver_longitude_rad,
1155            deg(80.0),
1156            east_azimuth_rad,
1157            config,
1158        )
1159        .expect("high-elevation pierce point");
1160        let low = ionospheric_pierce_point(
1161            receiver_latitude_rad,
1162            receiver_longitude_rad,
1163            deg(30.0),
1164            east_azimuth_rad,
1165            config,
1166        )
1167        .expect("low-elevation pierce point");
1168
1169        assert_close(high.latitude_rad, 0.0, 1.0e-12);
1170        assert_close(low.latitude_rad, 0.0, 1.0e-12);
1171        assert!(high.longitude_rad > 0.0);
1172        assert!(low.longitude_rad > high.longitude_rad);
1173    }
1174}