Skip to main content

sidereon_core/tropo/
mod.rs

1//! Tropospheric delay model.
2//!
3//! Computes the neutral-atmosphere (tropospheric) delay on a GNSS signal as a
4//! Saastamoinen (1972) zenith hydrostatic and wet delay, driven by supplied
5//! surface meteorology, mapped to the line of sight by the Niell (1996)
6//! continued-fraction mapping functions (NMF). The zenith primitives and the
7//! mapping primitives are exposed separately so a caller can apply the distinct
8//! hydrostatic and wet mappings, and a convenience entry composes the full
9//! slant delay.
10//!
11//! The tropospheric delay is non-dispersive: it has the same sign and magnitude
12//! for code and carrier phase, and it is a positive additive range error. The
13//! returned delays are positive meters that increase the measured pseudorange;
14//! `delay_m > 0` means the signal arrived later and the pseudorange is too long
15//! by `delay_m`.
16//!
17//! Angles are radians internally (`_rad`); height is the WGS84 ellipsoidal
18//! height in meters carried by [`Wgs84Geodetic`]. Pressure is hectopascals,
19//! temperature is kelvin, and relative humidity is a unit fraction in `[0, 1]`.
20
21mod saastamoinen;
22mod vmf;
23mod zwd;
24
25use crate::astro::time::civil::{
26    fractional_day_of_year_from_instant, julian_date_from_instant, mjd_from_jd,
27};
28use crate::astro::time::model::{Instant, InstantRepr};
29
30use crate::error::{Error, Result};
31use crate::frame::Wgs84Geodetic;
32
33pub(crate) use saastamoinen::slant_components;
34pub use zwd::{
35    tropo_delay_xyz as tropo_zwd_delay_xyz, zenith_wet_delay as zwd_zenith_wet_delay,
36    AltitudeClamp, ZwdEpoch, ZwdProfile, ZwdSlantOptions,
37};
38
39const MIN_CALENDAR_JULIAN_DATE: f64 = 1_721_425.0;
40const MAX_CALENDAR_JULIAN_DATE: f64 = 5_373_485.0;
41
42/// Surface meteorological conditions at the receiver.
43///
44/// These are the inputs to the Saastamoinen zenith delays. Pressure is in
45/// hectopascals (millibars) because that is the unit the troposphere formulas
46/// and meteorological products use; temperature is absolute (kelvin) to avoid a
47/// Celsius zero-point slip; relative humidity is a unit fraction in `[0, 1]`,
48/// not a percentage.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Met {
51    /// Total atmospheric pressure in hectopascals (= millibars).
52    pub pressure_hpa: f64,
53    /// Ambient temperature in kelvin.
54    pub temperature_k: f64,
55    /// Relative humidity as a unit fraction in `[0, 1]` (0.5 == 50%).
56    pub relative_humidity: f64,
57}
58
59impl Met {
60    /// Construct surface meteorology from explicit values.
61    pub fn new(pressure_hpa: f64, temperature_k: f64, relative_humidity: f64) -> Result<Self> {
62        validate_met_values(pressure_hpa, temperature_k, relative_humidity)?;
63        Ok(Self {
64            pressure_hpa,
65            temperature_k,
66            relative_humidity,
67        })
68    }
69
70    pub(crate) const fn new_unchecked(
71        pressure_hpa: f64,
72        temperature_k: f64,
73        relative_humidity: f64,
74    ) -> Self {
75        Self {
76            pressure_hpa,
77            temperature_k,
78            relative_humidity,
79        }
80    }
81
82    /// Standard-atmosphere pressure and temperature for an ellipsoidal height,
83    /// carrying the supplied relative humidity through unchanged.
84    ///
85    /// A convenience for callers without live meteorology. The height is clamped
86    /// to be non-negative before the standard pressure and temperature lapses
87    /// are applied, so a below-sea-level height yields the sea-level values.
88    pub fn standard(height_m: f64, relative_humidity: f64) -> Result<Self> {
89        validate_finite(height_m, "height_m")?;
90        validate_relative_humidity(relative_humidity)?;
91        let met = Self::standard_unchecked(height_m, relative_humidity);
92        validate_met(met)?;
93        Ok(met)
94    }
95
96    pub(crate) fn standard_unchecked(height_m: f64, relative_humidity: f64) -> Self {
97        let s = saastamoinen::standard_atmosphere(height_m, relative_humidity);
98        Self {
99            pressure_hpa: s.pressure_hpa,
100            temperature_k: s.temperature_k,
101            relative_humidity: s.relative_humidity,
102        }
103    }
104}
105
106/// Tropospheric zenith-delay model selector.
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub enum TropoModel {
109    /// Saastamoinen (1972) zenith hydrostatic + wet delays.
110    Saastamoinen,
111    /// Saastamoinen hydrostatic term plus an exponential ZWD wet profile.
112    ZwdAltitudeScaled(ZwdProfile),
113}
114
115/// Tropospheric mapping-function selector.
116///
117/// Not `Eq`: the [`MappingModel::Vmf1`] variant carries the floating-point `a`
118/// coefficients of the VMF1 data product, so only `PartialEq` is available.
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub enum MappingModel {
121    /// Niell (1996) mapping functions (NMF), driven by a climatological table.
122    Niell,
123    /// Vienna Mapping Function 1 (VMF1, Böhm 2006), site-wise form: the
124    /// continued fraction with the supplied hydrostatic/wet `a` coefficients
125    /// from the VMF1 numerical-weather-model data product at the station and
126    /// epoch, and VMF1's own `b`, `c` coefficients. No height correction (the
127    /// site-wise `a` are already valid at the station). See [`vmf`].
128    Vmf1 {
129        /// Hydrostatic `a` coefficient from the VMF1 data product.
130        ah: f64,
131        /// Wet `a` coefficient from the VMF1 data product.
132        aw: f64,
133    },
134}
135
136/// Zenith tropospheric delay split into its hydrostatic and wet parts.
137///
138/// The two components are returned separately because the Niell mapping applies
139/// a distinct hydrostatic and wet mapping factor; the total slant delay is
140/// `dry_m * mapping.dry + wet_m * mapping.wet`.
141#[derive(Debug, Clone, Copy, PartialEq)]
142pub struct ZenithDelay {
143    /// Zenith hydrostatic (dry) delay in positive meters.
144    pub dry_m: f64,
145    /// Zenith wet delay in positive meters.
146    pub wet_m: f64,
147}
148
149/// Dimensionless mapping factors at a given elevation.
150#[derive(Debug, Clone, Copy, PartialEq)]
151pub struct MappingFactors {
152    /// Hydrostatic mapping factor (includes the height correction).
153    pub dry: f64,
154    /// Wet mapping factor.
155    pub wet: f64,
156}
157
158/// Zenith tropospheric delay (hydrostatic + wet) from supplied meteorology.
159///
160/// The receiver geodetic latitude and ellipsoidal height come from `receiver`;
161/// the surface pressure, temperature, and humidity come from `met`. Both
162/// components are returned as positive meters. The possibly-negative ellipsoidal
163/// height is used with its sign.
164pub fn tropo_zenith(model: TropoModel, receiver: Wgs84Geodetic, met: Met) -> Result<ZenithDelay> {
165    validate_receiver(receiver)?;
166    validate_met(met)?;
167    validate_tropo_model(model)?;
168
169    let delay = tropo_zenith_unchecked(model, receiver, met);
170    validate_finite(delay.dry_m, "zenith_dry_m")?;
171    validate_finite(delay.wet_m, "zenith_wet_m")?;
172    Ok(delay)
173}
174
175pub(crate) fn tropo_zenith_unchecked(
176    model: TropoModel,
177    receiver: Wgs84Geodetic,
178    met: Met,
179) -> ZenithDelay {
180    match model {
181        TropoModel::Saastamoinen => {
182            let z = saastamoinen::zenith_delays(
183                met.pressure_hpa,
184                met.temperature_k,
185                met.relative_humidity,
186                receiver.lat_rad,
187                receiver.height_m,
188            );
189            ZenithDelay {
190                dry_m: z.zhd_m,
191                wet_m: z.zwd_m,
192            }
193        }
194        TropoModel::ZwdAltitudeScaled(profile) => {
195            let z = saastamoinen::zenith_delays(
196                met.pressure_hpa,
197                met.temperature_k,
198                met.relative_humidity,
199                receiver.lat_rad,
200                receiver.height_m,
201            );
202            ZenithDelay {
203                dry_m: z.zhd_m,
204                wet_m: zwd::zenith_wet_delay_unchecked(profile, receiver.height_m),
205            }
206        }
207    }
208}
209
210/// Niell hydrostatic and wet mapping factors at the given elevation.
211///
212/// The mapping depends on the receiver geodetic latitude and ellipsoidal height
213/// (via `receiver`) and on the fractional day-of-year (derived from `epoch`),
214/// hence both are arguments. The hydrostatic mapping carries the height
215/// correction; the wet mapping does not.
216pub fn tropo_mapping(
217    model: MappingModel,
218    elevation_rad: f64,
219    receiver: Wgs84Geodetic,
220    epoch: Instant,
221) -> Result<MappingFactors> {
222    validate_mapping_model(model)?;
223    validate_elevation(elevation_rad)?;
224    validate_receiver(receiver)?;
225    validate_instant(epoch)?;
226
227    let mapping = tropo_mapping_unchecked(model, elevation_rad, receiver, epoch);
228    validate_finite(mapping.dry, "mapping_dry")?;
229    validate_finite(mapping.wet, "mapping_wet")?;
230    Ok(mapping)
231}
232
233pub(crate) fn tropo_mapping_unchecked(
234    model: MappingModel,
235    elevation_rad: f64,
236    receiver: Wgs84Geodetic,
237    epoch: Instant,
238) -> MappingFactors {
239    match model {
240        MappingModel::Niell => {
241            let doy = fractional_day_of_year(epoch);
242            let m = saastamoinen::niell_mapping(
243                elevation_rad,
244                receiver.lat_rad,
245                receiver.height_m,
246                doy,
247            );
248            MappingFactors {
249                dry: m.mh,
250                wet: m.mw,
251            }
252        }
253        MappingModel::Vmf1 { ah, aw } => {
254            let mjd = modified_julian_date(epoch);
255            let m = vmf::vmf1_mapping(elevation_rad, receiver.lat_rad, mjd, ah, aw);
256            MappingFactors {
257                dry: m.mh,
258                wet: m.mw,
259            }
260        }
261    }
262}
263
264/// Full slant tropospheric delay in positive meters.
265///
266/// Composes the Saastamoinen zenith delays and the Niell mapping into the total
267/// line-of-sight delay `dry_m * mapping.dry + wet_m * mapping.wet`. The delay is
268/// zero for an elevation at or below the horizon and for a height outside the
269/// model's validity range; inside that range the result is positive.
270///
271/// Note on bit-exactness: the fractional day-of-year is derived from `epoch` at
272/// this boundary. Its integer day is exact (from the calendar date); the
273/// within-day fraction carries the float granularity of a split Julian date,
274/// which enters the Niell seasonal term so weakly that it is below the last bit
275/// in practice. The 0-ULP parity contract is on the kernel evaluated at an
276/// exact day-of-year; this wrapper agrees with it to within that bound.
277pub fn tropo_slant(
278    elevation_rad: f64,
279    receiver: Wgs84Geodetic,
280    met: Met,
281    epoch: Instant,
282) -> Result<f64> {
283    validate_elevation(elevation_rad)?;
284    validate_receiver(receiver)?;
285    validate_met(met)?;
286    validate_instant(epoch)?;
287
288    let delay_m = tropo_slant_unchecked(elevation_rad, receiver, met, epoch);
289    validate_finite(delay_m, "tropo_slant_m")?;
290    Ok(delay_m)
291}
292
293pub(crate) fn tropo_slant_unchecked(
294    elevation_rad: f64,
295    receiver: Wgs84Geodetic,
296    met: Met,
297    epoch: Instant,
298) -> f64 {
299    let doy = fractional_day_of_year(epoch);
300    slant_components(
301        elevation_rad,
302        receiver,
303        met.pressure_hpa,
304        met.temperature_k,
305        met.relative_humidity,
306        doy,
307    )
308    .slant_m
309}
310
311/// Slant tropospheric delay (meters) with a selectable mapping function.
312///
313/// Composes the Saastamoinen zenith hydrostatic and wet delays with the chosen
314/// mapping (`MappingModel::Niell` or `MappingModel::Vmf1`):
315/// `zenith.dry * mapping.dry + zenith.wet * mapping.wet`. With `Niell` this is
316/// bit-identical to [`tropo_slant_unchecked`] (same zenith and mapping
317/// primitives, same combination order). The same permissive validity gate
318/// applies: elevation at or below the horizon, or a height outside the Met-path
319/// range, yields zero.
320pub(crate) fn tropo_slant_with_mapping_unchecked(
321    model: MappingModel,
322    elevation_rad: f64,
323    receiver: Wgs84Geodetic,
324    met: Met,
325    epoch: Instant,
326) -> f64 {
327    if elevation_rad <= 0.0
328        || receiver.height_m < saastamoinen::MET_GATE_LOW_M
329        || receiver.height_m > saastamoinen::MET_GATE_HI_M
330    {
331        return 0.0;
332    }
333    let zenith = tropo_zenith_unchecked(TropoModel::Saastamoinen, receiver, met);
334    let mapping = tropo_mapping_unchecked(model, elevation_rad, receiver, epoch);
335    zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet
336}
337
338fn validate_tropo_model(model: TropoModel) -> Result<()> {
339    match model {
340        TropoModel::Saastamoinen => Ok(()),
341        TropoModel::ZwdAltitudeScaled(profile) => zwd::validate_profile(profile),
342    }
343}
344
345fn validate_mapping_model(model: MappingModel) -> Result<()> {
346    match model {
347        MappingModel::Niell => Ok(()),
348        MappingModel::Vmf1 { ah, aw } => {
349            validate_finite(ah, "mapping.vmf1.ah")?;
350            if ah <= 0.0 {
351                return Err(invalid_input("mapping.vmf1.ah", "not positive"));
352            }
353            validate_finite(aw, "mapping.vmf1.aw")?;
354            if aw <= 0.0 {
355                return Err(invalid_input("mapping.vmf1.aw", "not positive"));
356            }
357            Ok(())
358        }
359    }
360}
361
362fn validate_met(met: Met) -> Result<()> {
363    validate_met_values(met.pressure_hpa, met.temperature_k, met.relative_humidity)
364}
365
366fn validate_met_values(
367    pressure_hpa: f64,
368    temperature_k: f64,
369    relative_humidity: f64,
370) -> Result<()> {
371    validate_finite(pressure_hpa, "pressure_hpa")?;
372    if pressure_hpa <= 0.0 {
373        return Err(invalid_input("pressure_hpa", "not positive"));
374    }
375    validate_finite(temperature_k, "temperature_k")?;
376    if temperature_k <= 0.0 {
377        return Err(invalid_input("temperature_k", "not positive"));
378    }
379    validate_relative_humidity(relative_humidity)
380}
381
382fn validate_relative_humidity(relative_humidity: f64) -> Result<()> {
383    validate_finite(relative_humidity, "relative_humidity")?;
384    if !(0.0..=1.0).contains(&relative_humidity) {
385        return Err(invalid_input("relative_humidity", "out of range"));
386    }
387    Ok(())
388}
389
390fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
391    validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
392    validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
393    validate_finite(receiver.height_m, "receiver.height_m")?;
394    if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
395        return Err(invalid_input("receiver.lat_rad", "out of range"));
396    }
397    if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
398        return Err(invalid_input("receiver.lon_rad", "out of range"));
399    }
400    if !(saastamoinen::MET_GATE_LOW_M..=saastamoinen::MET_GATE_HI_M).contains(&receiver.height_m) {
401        return Err(invalid_input("receiver.height_m", "out of range"));
402    }
403    Ok(())
404}
405
406fn validate_elevation(elevation_rad: f64) -> Result<()> {
407    validate_finite(elevation_rad, "elevation_rad")?;
408    if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&elevation_rad) {
409        return Err(invalid_input("elevation_rad", "out of range"));
410    }
411    Ok(())
412}
413
414fn validate_instant(epoch: Instant) -> Result<()> {
415    match epoch.repr {
416        InstantRepr::JulianDate(split) => {
417            validate_finite(split.jd_whole, "epoch.jd_whole")?;
418            validate_finite(split.fraction, "epoch.fraction")?;
419            if !(-1.0..=1.0).contains(&split.fraction) {
420                return Err(invalid_input("epoch.fraction", "out of range"));
421            }
422            let jd = split.jd_whole + split.fraction;
423            validate_finite(jd, "epoch.julian_date")?;
424            if !(MIN_CALENDAR_JULIAN_DATE..=MAX_CALENDAR_JULIAN_DATE).contains(&jd) {
425                return Err(invalid_input("epoch.julian_date", "out of range"));
426            }
427        }
428        InstantRepr::Nanos(_) => {}
429    }
430    Ok(())
431}
432
433fn validate_finite(value: f64, field: &'static str) -> Result<()> {
434    if value.is_finite() {
435        Ok(())
436    } else {
437        Err(invalid_input(field, "not finite"))
438    }
439}
440
441fn invalid_input(field: &'static str, reason: &'static str) -> Error {
442    Error::InvalidInput(format!("{field} {reason}"))
443}
444
445/// Fractional day-of-year carried by an instant, Jan 1 00:00:00 = 1.0.
446///
447/// The Niell seasonal term needs the continuous day-of-year (it carries the
448/// fractional time of day). This converts the instant to a calendar date and
449/// returns the day number plus the within-day fraction. The conversion is the
450/// standard civil-date algorithm from the Julian day number; the within-day
451/// fraction comes from the day part shifted from the noon Julian-date origin to
452/// midnight.
453fn fractional_day_of_year(epoch: Instant) -> f64 {
454    fractional_day_of_year_from_instant(epoch)
455}
456
457/// Modified Julian date (`jd - 2400000.5`) carried by an instant.
458///
459/// The VMF1 seasonal `c` expression references the modified Julian date (the
460/// TU Wien `vmf1.f` convention `doy = mjd - 44239 + 1 - 28`), so the VMF mapping
461/// arm derives the MJD here rather than the Niell fractional day-of-year.
462fn modified_julian_date(epoch: Instant) -> f64 {
463    mjd_from_jd(julian_date_from_instant(epoch))
464}
465
466#[cfg(all(test, sidereon_repo_tests))]
467mod tests;