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;