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;