1mod 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#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Met {
51 pub pressure_hpa: f64,
53 pub temperature_k: f64,
55 pub relative_humidity: f64,
57}
58
59impl Met {
60 pub fn new(pressure_hpa: f64, temperature_k: f64, relative_humidity: f64) -> Result<Self> {
62 validate_met_values(pressure_hpa, temperature_k, relative_humidity)?;
63 Ok(Self {
64 pressure_hpa,
65 temperature_k,
66 relative_humidity,
67 })
68 }
69
70 pub(crate) const fn new_unchecked(
71 pressure_hpa: f64,
72 temperature_k: f64,
73 relative_humidity: f64,
74 ) -> Self {
75 Self {
76 pressure_hpa,
77 temperature_k,
78 relative_humidity,
79 }
80 }
81
82 pub fn standard(height_m: f64, relative_humidity: f64) -> Result<Self> {
89 validate_finite(height_m, "height_m")?;
90 validate_relative_humidity(relative_humidity)?;
91 let met = Self::standard_unchecked(height_m, relative_humidity);
92 validate_met(met)?;
93 Ok(met)
94 }
95
96 pub(crate) fn standard_unchecked(height_m: f64, relative_humidity: f64) -> Self {
97 let s = saastamoinen::standard_atmosphere(height_m, relative_humidity);
98 Self {
99 pressure_hpa: s.pressure_hpa,
100 temperature_k: s.temperature_k,
101 relative_humidity: s.relative_humidity,
102 }
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq)]
108pub enum TropoModel {
109 Saastamoinen,
111 ZwdAltitudeScaled(ZwdProfile),
113}
114
115#[derive(Debug, Clone, Copy, PartialEq)]
120pub enum MappingModel {
121 Niell,
123 Vmf1 {
129 ah: f64,
131 aw: f64,
133 },
134}
135
136#[derive(Debug, Clone, Copy, PartialEq)]
142pub struct ZenithDelay {
143 pub dry_m: f64,
145 pub wet_m: f64,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq)]
151pub struct MappingFactors {
152 pub dry: f64,
154 pub wet: f64,
156}
157
158pub fn tropo_zenith(model: TropoModel, receiver: Wgs84Geodetic, met: Met) -> Result<ZenithDelay> {
165 validate_receiver(receiver)?;
166 validate_met(met)?;
167 validate_tropo_model(model)?;
168
169 let delay = tropo_zenith_unchecked(model, receiver, met);
170 validate_finite(delay.dry_m, "zenith_dry_m")?;
171 validate_finite(delay.wet_m, "zenith_wet_m")?;
172 Ok(delay)
173}
174
175pub(crate) fn tropo_zenith_unchecked(
176 model: TropoModel,
177 receiver: Wgs84Geodetic,
178 met: Met,
179) -> ZenithDelay {
180 match model {
181 TropoModel::Saastamoinen => {
182 let z = saastamoinen::zenith_delays(
183 met.pressure_hpa,
184 met.temperature_k,
185 met.relative_humidity,
186 receiver.lat_rad,
187 receiver.height_m,
188 );
189 ZenithDelay {
190 dry_m: z.zhd_m,
191 wet_m: z.zwd_m,
192 }
193 }
194 TropoModel::ZwdAltitudeScaled(profile) => {
195 let z = saastamoinen::zenith_delays(
196 met.pressure_hpa,
197 met.temperature_k,
198 met.relative_humidity,
199 receiver.lat_rad,
200 receiver.height_m,
201 );
202 ZenithDelay {
203 dry_m: z.zhd_m,
204 wet_m: zwd::zenith_wet_delay_unchecked(profile, receiver.height_m),
205 }
206 }
207 }
208}
209
210pub fn tropo_mapping(
217 model: MappingModel,
218 elevation_rad: f64,
219 receiver: Wgs84Geodetic,
220 epoch: Instant,
221) -> Result<MappingFactors> {
222 validate_mapping_model(model)?;
223 validate_elevation(elevation_rad)?;
224 validate_receiver(receiver)?;
225 validate_instant(epoch)?;
226
227 let mapping = tropo_mapping_unchecked(model, elevation_rad, receiver, epoch);
228 validate_finite(mapping.dry, "mapping_dry")?;
229 validate_finite(mapping.wet, "mapping_wet")?;
230 Ok(mapping)
231}
232
233pub(crate) fn tropo_mapping_unchecked(
234 model: MappingModel,
235 elevation_rad: f64,
236 receiver: Wgs84Geodetic,
237 epoch: Instant,
238) -> MappingFactors {
239 match model {
240 MappingModel::Niell => {
241 let doy = fractional_day_of_year(epoch);
242 let m = saastamoinen::niell_mapping(
243 elevation_rad,
244 receiver.lat_rad,
245 receiver.height_m,
246 doy,
247 );
248 MappingFactors {
249 dry: m.mh,
250 wet: m.mw,
251 }
252 }
253 MappingModel::Vmf1 { ah, aw } => {
254 let mjd = modified_julian_date(epoch);
255 let m = vmf::vmf1_mapping(elevation_rad, receiver.lat_rad, mjd, ah, aw);
256 MappingFactors {
257 dry: m.mh,
258 wet: m.mw,
259 }
260 }
261 }
262}
263
264pub fn tropo_slant(
278 elevation_rad: f64,
279 receiver: Wgs84Geodetic,
280 met: Met,
281 epoch: Instant,
282) -> Result<f64> {
283 validate_elevation(elevation_rad)?;
284 validate_receiver(receiver)?;
285 validate_met(met)?;
286 validate_instant(epoch)?;
287
288 let delay_m = tropo_slant_unchecked(elevation_rad, receiver, met, epoch);
289 validate_finite(delay_m, "tropo_slant_m")?;
290 Ok(delay_m)
291}
292
293pub(crate) fn tropo_slant_unchecked(
294 elevation_rad: f64,
295 receiver: Wgs84Geodetic,
296 met: Met,
297 epoch: Instant,
298) -> f64 {
299 let doy = fractional_day_of_year(epoch);
300 slant_components(
301 elevation_rad,
302 receiver,
303 met.pressure_hpa,
304 met.temperature_k,
305 met.relative_humidity,
306 doy,
307 )
308 .slant_m
309}
310
311pub(crate) fn tropo_slant_with_mapping_unchecked(
321 model: MappingModel,
322 elevation_rad: f64,
323 receiver: Wgs84Geodetic,
324 met: Met,
325 epoch: Instant,
326) -> f64 {
327 if elevation_rad <= 0.0
328 || receiver.height_m < saastamoinen::MET_GATE_LOW_M
329 || receiver.height_m > saastamoinen::MET_GATE_HI_M
330 {
331 return 0.0;
332 }
333 let zenith = tropo_zenith_unchecked(TropoModel::Saastamoinen, receiver, met);
334 let mapping = tropo_mapping_unchecked(model, elevation_rad, receiver, epoch);
335 zenith.dry_m * mapping.dry + zenith.wet_m * mapping.wet
336}
337
338fn validate_tropo_model(model: TropoModel) -> Result<()> {
339 match model {
340 TropoModel::Saastamoinen => Ok(()),
341 TropoModel::ZwdAltitudeScaled(profile) => zwd::validate_profile(profile),
342 }
343}
344
345fn validate_mapping_model(model: MappingModel) -> Result<()> {
346 match model {
347 MappingModel::Niell => Ok(()),
348 MappingModel::Vmf1 { ah, aw } => {
349 validate_finite(ah, "mapping.vmf1.ah")?;
350 if ah <= 0.0 {
351 return Err(invalid_input("mapping.vmf1.ah", "not positive"));
352 }
353 validate_finite(aw, "mapping.vmf1.aw")?;
354 if aw <= 0.0 {
355 return Err(invalid_input("mapping.vmf1.aw", "not positive"));
356 }
357 Ok(())
358 }
359 }
360}
361
362fn validate_met(met: Met) -> Result<()> {
363 validate_met_values(met.pressure_hpa, met.temperature_k, met.relative_humidity)
364}
365
366fn validate_met_values(
367 pressure_hpa: f64,
368 temperature_k: f64,
369 relative_humidity: f64,
370) -> Result<()> {
371 validate_finite(pressure_hpa, "pressure_hpa")?;
372 if pressure_hpa <= 0.0 {
373 return Err(invalid_input("pressure_hpa", "not positive"));
374 }
375 validate_finite(temperature_k, "temperature_k")?;
376 if temperature_k <= 0.0 {
377 return Err(invalid_input("temperature_k", "not positive"));
378 }
379 validate_relative_humidity(relative_humidity)
380}
381
382fn validate_relative_humidity(relative_humidity: f64) -> Result<()> {
383 validate_finite(relative_humidity, "relative_humidity")?;
384 if !(0.0..=1.0).contains(&relative_humidity) {
385 return Err(invalid_input("relative_humidity", "out of range"));
386 }
387 Ok(())
388}
389
390fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
391 validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
392 validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
393 validate_finite(receiver.height_m, "receiver.height_m")?;
394 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
395 return Err(invalid_input("receiver.lat_rad", "out of range"));
396 }
397 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
398 return Err(invalid_input("receiver.lon_rad", "out of range"));
399 }
400 if !(saastamoinen::MET_GATE_LOW_M..=saastamoinen::MET_GATE_HI_M).contains(&receiver.height_m) {
401 return Err(invalid_input("receiver.height_m", "out of range"));
402 }
403 Ok(())
404}
405
406fn validate_elevation(elevation_rad: f64) -> Result<()> {
407 validate_finite(elevation_rad, "elevation_rad")?;
408 if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&elevation_rad) {
409 return Err(invalid_input("elevation_rad", "out of range"));
410 }
411 Ok(())
412}
413
414fn validate_instant(epoch: Instant) -> Result<()> {
415 match epoch.repr {
416 InstantRepr::JulianDate(split) => {
417 validate_finite(split.jd_whole, "epoch.jd_whole")?;
418 validate_finite(split.fraction, "epoch.fraction")?;
419 if !(-1.0..=1.0).contains(&split.fraction) {
420 return Err(invalid_input("epoch.fraction", "out of range"));
421 }
422 let jd = split.jd_whole + split.fraction;
423 validate_finite(jd, "epoch.julian_date")?;
424 if !(MIN_CALENDAR_JULIAN_DATE..=MAX_CALENDAR_JULIAN_DATE).contains(&jd) {
425 return Err(invalid_input("epoch.julian_date", "out of range"));
426 }
427 }
428 InstantRepr::Nanos(_) => {}
429 }
430 Ok(())
431}
432
433fn validate_finite(value: f64, field: &'static str) -> Result<()> {
434 if value.is_finite() {
435 Ok(())
436 } else {
437 Err(invalid_input(field, "not finite"))
438 }
439}
440
441fn invalid_input(field: &'static str, reason: &'static str) -> Error {
442 Error::InvalidInput(format!("{field} {reason}"))
443}
444
445fn fractional_day_of_year(epoch: Instant) -> f64 {
454 fractional_day_of_year_from_instant(epoch)
455}
456
457fn modified_julian_date(epoch: Instant) -> f64 {
463 mjd_from_jd(julian_date_from_instant(epoch))
464}
465
466#[cfg(all(test, sidereon_repo_tests))]
467mod tests;