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/// Elevation floor, in radians, applied by [`tropo_mapping`] to a transient
43/// at-or-below-horizon input.
44///
45/// The mapping functions contain a `1 / sin(elevation)` term that is singular at
46/// the horizon, so the checked [`tropo_mapping`] entry clamps any elevation at or
47/// below the horizon up to this floor before evaluating, rather than rejecting it
48/// (a least-squares solver routinely probes infeasible states mid-iteration and
49/// must not abort there). The value is `asin(0.01)` (about `0.573` degrees), so
50/// `sin(TROPO_MIN_MAPPING_ELEVATION_RAD) == 0.01` exactly, matching the
51/// `sin(elevation) >= 0.01` floor used on the ZWD slant path ([`zwd`]):
52/// elevation enters the mapping only through `1 / sin(elevation)`, so flooring
53/// the sine to `0.01` is the equivalent contract. Elevations strictly above the
54/// horizon are untouched, so in-domain output is unchanged to the last bit; only
55/// previously-rejected inputs differ.
56// Literal value of `(0.01f64).asin()` (not const-evaluable); a unit test pins
57// `TROPO_MIN_MAPPING_ELEVATION_RAD.sin() == 0.01` to guard this against drift.
58pub const TROPO_MIN_MAPPING_ELEVATION_RAD: f64 = 0.010_000_166_674_167_114;
59
60/// Surface meteorological conditions at the receiver.
61///
62/// These are the inputs to the Saastamoinen zenith delays. Pressure is in
63/// hectopascals (millibars) because that is the unit the troposphere formulas
64/// and meteorological products use; temperature is absolute (kelvin) to avoid a
65/// Celsius zero-point slip; relative humidity is a unit fraction in `[0, 1]`,
66/// not a percentage.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct Met {
69    /// Total atmospheric pressure in hectopascals (= millibars).
70    pub pressure_hpa: f64,
71    /// Ambient temperature in kelvin.
72    pub temperature_k: f64,
73    /// Relative humidity as a unit fraction in `[0, 1]` (0.5 == 50%).
74    pub relative_humidity: f64,
75}
76
77impl Met {
78    /// Construct surface meteorology from explicit values.
79    pub fn new(pressure_hpa: f64, temperature_k: f64, relative_humidity: f64) -> Result<Self> {
80        validate_met_values(pressure_hpa, temperature_k, relative_humidity)?;
81        Ok(Self {
82            pressure_hpa,
83            temperature_k,
84            relative_humidity,
85        })
86    }
87
88    pub(crate) const fn new_unchecked(
89        pressure_hpa: f64,
90        temperature_k: f64,
91        relative_humidity: f64,
92    ) -> Self {
93        Self {
94            pressure_hpa,
95            temperature_k,
96            relative_humidity,
97        }
98    }
99
100    /// Standard-atmosphere pressure and temperature for an ellipsoidal height,
101    /// carrying the supplied relative humidity through unchanged.
102    ///
103    /// A convenience for callers without live meteorology. The height is clamped
104    /// to be non-negative before the standard pressure and temperature lapses
105    /// are applied, so a below-sea-level height yields the sea-level values.
106    pub fn standard(height_m: f64, relative_humidity: f64) -> Result<Self> {
107        validate_finite(height_m, "height_m")?;
108        validate_relative_humidity(relative_humidity)?;
109        let met = Self::standard_unchecked(height_m, relative_humidity);
110        validate_met(met)?;
111        Ok(met)
112    }
113
114    pub(crate) fn standard_unchecked(height_m: f64, relative_humidity: f64) -> Self {
115        let s = saastamoinen::standard_atmosphere(height_m, relative_humidity);
116        Self {
117            pressure_hpa: s.pressure_hpa,
118            temperature_k: s.temperature_k,
119            relative_humidity: s.relative_humidity,
120        }
121    }
122}
123
124/// Tropospheric zenith-delay model selector.
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub enum TropoModel {
127    /// Saastamoinen (1972) zenith hydrostatic + wet delays.
128    Saastamoinen,
129    /// Saastamoinen hydrostatic term plus an exponential ZWD wet profile.
130    ZwdAltitudeScaled(ZwdProfile),
131}
132
133/// Tropospheric mapping-function selector.
134///
135/// Not `Eq`: the [`MappingModel::Vmf1`] variant carries the floating-point `a`
136/// coefficients of the VMF1 data product, so only `PartialEq` is available.
137#[derive(Debug, Clone, Copy, PartialEq)]
138pub enum MappingModel {
139    /// Niell (1996) mapping functions (NMF), driven by a climatological table.
140    Niell,
141    /// Vienna Mapping Function 1 (VMF1, Böhm 2006), site-wise form: the
142    /// continued fraction with the supplied hydrostatic/wet `a` coefficients
143    /// from the VMF1 numerical-weather-model data product at the station and
144    /// epoch, and VMF1's own `b`, `c` coefficients. No height correction (the
145    /// site-wise `a` are already valid at the station). See [`vmf`].
146    Vmf1 {
147        /// Hydrostatic `a` coefficient from the VMF1 data product.
148        ah: f64,
149        /// Wet `a` coefficient from the VMF1 data product.
150        aw: f64,
151    },
152}
153
154/// Zenith tropospheric delay split into its hydrostatic and wet parts.
155///
156/// The two components are returned separately because the Niell mapping applies
157/// a distinct hydrostatic and wet mapping factor; the total slant delay is
158/// `dry_m * mapping.dry + wet_m * mapping.wet`.
159#[derive(Debug, Clone, Copy, PartialEq)]
160pub struct ZenithDelay {
161    /// Zenith hydrostatic (dry) delay in positive meters.
162    pub dry_m: f64,
163    /// Zenith wet delay in positive meters.
164    pub wet_m: f64,
165}
166
167/// Dimensionless mapping factors at a given elevation.
168#[derive(Debug, Clone, Copy, PartialEq)]
169pub struct MappingFactors {
170    /// Hydrostatic mapping factor (includes the height correction).
171    pub dry: f64,
172    /// Wet mapping factor.
173    pub wet: f64,
174}
175
176/// Zenith tropospheric delay (hydrostatic + wet) from supplied meteorology.
177///
178/// The receiver geodetic latitude and ellipsoidal height come from `receiver`;
179/// the surface pressure, temperature, and humidity come from `met`. Both
180/// components are returned as positive meters. The possibly-negative ellipsoidal
181/// height is used with its sign.
182///
183/// A height outside the model's validity range `[-100, 1e4]` meters is clamped
184/// into that range before evaluation rather than rejected, so a transient solver
185/// state never aborts the solve; an in-range height takes the identical code path
186/// and is bit-for-bit unchanged.
187pub fn tropo_zenith(model: TropoModel, receiver: Wgs84Geodetic, met: Met) -> Result<ZenithDelay> {
188    validate_receiver(receiver)?;
189    validate_met(met)?;
190    validate_tropo_model(model)?;
191
192    let receiver = clamp_receiver_height(receiver);
193    let delay = tropo_zenith_unchecked(model, receiver, met);
194    validate_finite(delay.dry_m, "zenith_dry_m")?;
195    validate_finite(delay.wet_m, "zenith_wet_m")?;
196    Ok(delay)
197}
198
199pub(crate) fn tropo_zenith_unchecked(
200    model: TropoModel,
201    receiver: Wgs84Geodetic,
202    met: Met,
203) -> ZenithDelay {
204    match model {
205        TropoModel::Saastamoinen => {
206            let z = saastamoinen::zenith_delays(
207                met.pressure_hpa,
208                met.temperature_k,
209                met.relative_humidity,
210                receiver.lat_rad,
211                receiver.height_m,
212            );
213            ZenithDelay {
214                dry_m: z.zhd_m,
215                wet_m: z.zwd_m,
216            }
217        }
218        TropoModel::ZwdAltitudeScaled(profile) => {
219            let z = saastamoinen::zenith_delays(
220                met.pressure_hpa,
221                met.temperature_k,
222                met.relative_humidity,
223                receiver.lat_rad,
224                receiver.height_m,
225            );
226            ZenithDelay {
227                dry_m: z.zhd_m,
228                wet_m: zwd::zenith_wet_delay_unchecked(profile, receiver.height_m),
229            }
230        }
231    }
232}
233
234/// Niell hydrostatic and wet mapping factors at the given elevation.
235///
236/// The mapping depends on the receiver geodetic latitude and ellipsoidal height
237/// (via `receiver`) and on the fractional day-of-year (derived from `epoch`),
238/// hence both are arguments. The hydrostatic mapping carries the height
239/// correction; the wet mapping does not.
240///
241/// Transient out-of-domain inputs are clamped, not rejected, so a least-squares
242/// solver that probes an infeasible state mid-iteration keeps a finite mapping
243/// and steps back: an elevation at or below the horizon is raised to
244/// [`TROPO_MIN_MAPPING_ELEVATION_RAD`], an elevation past the zenith is lowered
245/// to the zenith, and a height outside `[-100, 1e4]` meters is clamped into
246/// range. An elevation strictly above the horizon (and at or below the zenith)
247/// with an in-range height takes the identical code path and is bit-for-bit
248/// unchanged; only previously-rejected inputs differ.
249pub fn tropo_mapping(
250    model: MappingModel,
251    elevation_rad: f64,
252    receiver: Wgs84Geodetic,
253    epoch: Instant,
254) -> Result<MappingFactors> {
255    validate_mapping_model(model)?;
256    validate_finite(elevation_rad, "elevation_rad")?;
257    validate_receiver(receiver)?;
258    validate_instant(epoch)?;
259
260    let elevation_rad = clamp_mapping_elevation(elevation_rad);
261    let receiver = clamp_receiver_height(receiver);
262    let mapping = tropo_mapping_unchecked(model, elevation_rad, receiver, epoch);
263    validate_finite(mapping.dry, "mapping_dry")?;
264    validate_finite(mapping.wet, "mapping_wet")?;
265    Ok(mapping)
266}
267
268pub(crate) fn tropo_mapping_unchecked(
269    model: MappingModel,
270    elevation_rad: f64,
271    receiver: Wgs84Geodetic,
272    epoch: Instant,
273) -> MappingFactors {
274    match model {
275        MappingModel::Niell => {
276            let doy = fractional_day_of_year(epoch);
277            let m = saastamoinen::niell_mapping(
278                elevation_rad,
279                receiver.lat_rad,
280                receiver.height_m,
281                doy,
282            );
283            MappingFactors {
284                dry: m.mh,
285                wet: m.mw,
286            }
287        }
288        MappingModel::Vmf1 { ah, aw } => {
289            let mjd = modified_julian_date(epoch);
290            let m = vmf::vmf1_mapping(elevation_rad, receiver.lat_rad, mjd, ah, aw);
291            MappingFactors {
292                dry: m.mh,
293                wet: m.mw,
294            }
295        }
296    }
297}
298
299/// Full slant tropospheric delay in positive meters.
300///
301/// Composes the Saastamoinen zenith delays and the Niell mapping into the total
302/// line-of-sight delay `dry_m * mapping.dry + wet_m * mapping.wet`. The delay is
303/// zero for an elevation at or below the horizon and for a height outside the
304/// model's validity range `[-100, 1e4]` meters; inside that range the result is
305/// positive.
306///
307/// Transient out-of-domain inputs are handled, not rejected, matching the RTKLIB
308/// `tropmodel()` oracle: an elevation at or below the horizon and a height
309/// outside the validity range both return `Ok(0.0)`, and an elevation past the
310/// zenith is clamped to the zenith. This lets a least-squares solver probe an
311/// infeasible state mid-iteration, see a zero delay, and step back instead of
312/// aborting. An elevation in `(0, pi/2]` with an in-range height is bit-for-bit
313/// unchanged.
314///
315/// Note on bit-exactness: the fractional day-of-year is derived from `epoch` at
316/// this boundary. Its integer day is exact (from the calendar date); the
317/// within-day fraction carries the float granularity of a split Julian date,
318/// which enters the Niell seasonal term so weakly that it is below the last bit
319/// in practice. The 0-ULP parity contract is on the kernel evaluated at an
320/// exact day-of-year; this wrapper agrees with it to within that bound.
321pub fn tropo_slant(
322    elevation_rad: f64,
323    receiver: Wgs84Geodetic,
324    met: Met,
325    epoch: Instant,
326) -> Result<f64> {
327    validate_finite(elevation_rad, "elevation_rad")?;
328    validate_receiver(receiver)?;
329    validate_met(met)?;
330    validate_instant(epoch)?;
331
332    // Clamp an over-zenith elevation down to the zenith; a sub-horizon elevation
333    // and an out-of-range height fall to the kernel's permissive zero gate
334    // (slant_components), matching RTKLIB tropmodel(). In-domain inputs pass
335    // through unchanged: el.min(FRAC_PI_2) == el for el in (0, pi/2].
336    let elevation_rad = elevation_rad.min(core::f64::consts::FRAC_PI_2);
337    let delay_m = tropo_slant_unchecked(elevation_rad, receiver, met, epoch);
338    validate_finite(delay_m, "tropo_slant_m")?;
339    Ok(delay_m)
340}
341
342pub(crate) fn tropo_slant_unchecked(
343    elevation_rad: f64,
344    receiver: Wgs84Geodetic,
345    met: Met,
346    epoch: Instant,
347) -> f64 {
348    let doy = fractional_day_of_year(epoch);
349    slant_components(
350        elevation_rad,
351        receiver,
352        met.pressure_hpa,
353        met.temperature_k,
354        met.relative_humidity,
355        doy,
356    )
357    .slant_m
358}
359
360/// Slant tropospheric delay (meters) with a selectable mapping function.
361///
362/// Composes the Saastamoinen zenith hydrostatic and wet delays with the chosen
363/// mapping (`MappingModel::Niell` or `MappingModel::Vmf1`):
364/// `zenith.dry * mapping.dry + zenith.wet * mapping.wet`. With `Niell` this is
365/// bit-identical to [`tropo_slant_unchecked`] (same zenith and mapping
366/// primitives, same combination order). The same permissive validity gate
367/// applies: elevation at or below the horizon, or a height outside the Met-path
368/// range, yields zero.
369pub(crate) fn tropo_slant_with_mapping_unchecked(
370    model: MappingModel,
371    elevation_rad: f64,
372    receiver: Wgs84Geodetic,
373    met: Met,
374    epoch: Instant,
375) -> f64 {
376    if elevation_rad <= 0.0
377        || receiver.height_m < saastamoinen::MET_GATE_LOW_M
378        || receiver.height_m > saastamoinen::MET_GATE_HI_M
379    {
380        return 0.0;
381    }
382    let zenith = tropo_zenith_unchecked(TropoModel::Saastamoinen, receiver, met);
383    let mapping = tropo_mapping_unchecked(model, elevation_rad, receiver, epoch);
384    zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet
385}
386
387fn validate_tropo_model(model: TropoModel) -> Result<()> {
388    match model {
389        TropoModel::Saastamoinen => Ok(()),
390        TropoModel::ZwdAltitudeScaled(profile) => zwd::validate_profile(profile),
391    }
392}
393
394fn validate_mapping_model(model: MappingModel) -> Result<()> {
395    match model {
396        MappingModel::Niell => Ok(()),
397        MappingModel::Vmf1 { ah, aw } => {
398            validate_finite(ah, "mapping.vmf1.ah")?;
399            if ah <= 0.0 {
400                return Err(invalid_input("mapping.vmf1.ah", "not positive"));
401            }
402            validate_finite(aw, "mapping.vmf1.aw")?;
403            if aw <= 0.0 {
404                return Err(invalid_input("mapping.vmf1.aw", "not positive"));
405            }
406            Ok(())
407        }
408    }
409}
410
411fn validate_met(met: Met) -> Result<()> {
412    validate_met_values(met.pressure_hpa, met.temperature_k, met.relative_humidity)
413}
414
415fn validate_met_values(
416    pressure_hpa: f64,
417    temperature_k: f64,
418    relative_humidity: f64,
419) -> Result<()> {
420    validate_finite(pressure_hpa, "pressure_hpa")?;
421    if pressure_hpa <= 0.0 {
422        return Err(invalid_input("pressure_hpa", "not positive"));
423    }
424    validate_finite(temperature_k, "temperature_k")?;
425    if temperature_k <= 0.0 {
426        return Err(invalid_input("temperature_k", "not positive"));
427    }
428    validate_relative_humidity(relative_humidity)
429}
430
431fn validate_relative_humidity(relative_humidity: f64) -> Result<()> {
432    validate_finite(relative_humidity, "relative_humidity")?;
433    if !(0.0..=1.0).contains(&relative_humidity) {
434        return Err(invalid_input("relative_humidity", "out of range"));
435    }
436    Ok(())
437}
438
439fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
440    validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
441    validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
442    validate_finite(receiver.height_m, "receiver.height_m")?;
443    if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
444        return Err(invalid_input("receiver.lat_rad", "out of range"));
445    }
446    if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
447        return Err(invalid_input("receiver.lon_rad", "out of range"));
448    }
449    // The height range is not rejected here: an out-of-range height is a
450    // recoverable transient solver state, clamped or zeroed downstream rather
451    // than erroring. Only a non-finite height (caught above) is unusable.
452    Ok(())
453}
454
455/// Clamp a receiver's ellipsoidal height into the model's validity range.
456///
457/// In-range heights are returned unchanged (the clamp is the identity on
458/// `[-100, 1e4]` meters), so in-domain output stays bit-for-bit identical; only a
459/// transient out-of-range height is pulled to the nearest boundary.
460fn clamp_receiver_height(receiver: Wgs84Geodetic) -> Wgs84Geodetic {
461    Wgs84Geodetic {
462        height_m: receiver
463            .height_m
464            .clamp(saastamoinen::MET_GATE_LOW_M, saastamoinen::MET_GATE_HI_M),
465        ..receiver
466    }
467}
468
469/// Clamp an elevation into the range over which [`tropo_mapping`] evaluates.
470///
471/// An elevation at or below the horizon is raised to
472/// [`TROPO_MIN_MAPPING_ELEVATION_RAD`] (the mapping's `1 / sin(el)` term is
473/// singular at the horizon); an elevation past the zenith is lowered to the
474/// zenith. An elevation in `(0, pi/2]` is returned unchanged
475/// (`el.min(FRAC_PI_2) == el`), keeping in-domain mapping factors bit-identical.
476fn clamp_mapping_elevation(elevation_rad: f64) -> f64 {
477    if elevation_rad <= 0.0 {
478        TROPO_MIN_MAPPING_ELEVATION_RAD
479    } else {
480        elevation_rad.min(core::f64::consts::FRAC_PI_2)
481    }
482}
483
484fn validate_instant(epoch: Instant) -> Result<()> {
485    match epoch.repr {
486        InstantRepr::JulianDate(split) => {
487            validate_finite(split.jd_whole, "epoch.jd_whole")?;
488            validate_finite(split.fraction, "epoch.fraction")?;
489            if !(-1.0..=1.0).contains(&split.fraction) {
490                return Err(invalid_input("epoch.fraction", "out of range"));
491            }
492            let jd = split.jd_whole + split.fraction;
493            validate_finite(jd, "epoch.julian_date")?;
494            if !(MIN_CALENDAR_JULIAN_DATE..=MAX_CALENDAR_JULIAN_DATE).contains(&jd) {
495                return Err(invalid_input("epoch.julian_date", "out of range"));
496            }
497        }
498        InstantRepr::Nanos(_) => {}
499    }
500    Ok(())
501}
502
503fn validate_finite(value: f64, field: &'static str) -> Result<()> {
504    if value.is_finite() {
505        Ok(())
506    } else {
507        Err(invalid_input(field, "not finite"))
508    }
509}
510
511fn invalid_input(field: &'static str, reason: &'static str) -> Error {
512    Error::InvalidInput(format!("{field} {reason}"))
513}
514
515/// Fractional day-of-year carried by an instant, Jan 1 00:00:00 = 1.0.
516///
517/// The Niell seasonal term needs the continuous day-of-year (it carries the
518/// fractional time of day). This converts the instant to a calendar date and
519/// returns the day number plus the within-day fraction. The conversion is the
520/// standard civil-date algorithm from the Julian day number; the within-day
521/// fraction comes from the day part shifted from the noon Julian-date origin to
522/// midnight.
523fn fractional_day_of_year(epoch: Instant) -> f64 {
524    fractional_day_of_year_from_instant(epoch)
525}
526
527/// Modified Julian date (`jd - 2400000.5`) carried by an instant.
528///
529/// The VMF1 seasonal `c` expression references the modified Julian date (the
530/// TU Wien `vmf1.f` convention `doy = mjd - 44239 + 1 - 28`), so the VMF mapping
531/// arm derives the MJD here rather than the Niell fractional day-of-year.
532fn modified_julian_date(epoch: Instant) -> f64 {
533    mjd_from_jd(julian_date_from_instant(epoch))
534}
535
536#[cfg(all(test, sidereon_repo_tests))]
537mod tests;