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