1mod 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#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct Met {
48 pub pressure_hpa: f64,
50 pub temperature_k: f64,
52 pub relative_humidity: f64,
54}
55
56impl Met {
57 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 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#[derive(Debug, Clone, Copy, PartialEq)]
105pub enum TropoModel {
106 Saastamoinen,
108 ZwdAltitudeScaled(ZwdProfile),
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum MappingModel {
115 Niell,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct ZenithDelay {
126 pub dry_m: f64,
128 pub wet_m: f64,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq)]
134pub struct MappingFactors {
135 pub dry: f64,
137 pub wet: f64,
139}
140
141pub 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
193pub 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
239pub 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
382fn 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 (nanos as f64) / 1.0e9 / SECONDS_PER_DAY + J2000_JD
396 }
397 };
398
399 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
410fn 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
429fn 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;