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