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