1mod grid;
14mod klobuchar;
15mod slant;
16mod tec_grid;
17
18#[cfg(all(test, sidereon_repo_tests))]
19mod tests;
20
21use crate::astro::constants::time::{J2000_JD, SECONDS_PER_DAY, SECONDS_PER_DAY_I64};
22use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
23
24use crate::constants::{DEG_TO_RAD, MEAN_EARTH_RADIUS_M, RAD_TO_DEG};
25use crate::error::{Error, Result};
26use crate::frame::Wgs84Geodetic;
27use crate::frequencies::{self, CarrierBand};
28use crate::GnssSystem;
29
30pub use grid::Ionex;
31pub use tec_grid::{
32 iono_delay_xyz as regular_tec_grid_delay_xyz, tec_xyz as regular_tec_xyz, TecGrid,
33 TecGridEpoch, TecGridEvalOptions, TecGridShellGeometry,
34};
35
36pub(crate) use klobuchar::klobuchar_l1_components;
37
38pub(crate) fn ionex_epoch_from_j2000_seconds(seconds: i64) -> Instant {
39 instant_from_j2000_seconds(TimeScale::Utc, seconds)
40}
41
42pub(crate) fn instant_from_j2000_seconds(scale: TimeScale, seconds: i64) -> Instant {
43 let days = seconds.div_euclid(SECONDS_PER_DAY_I64);
44 let rem_s = seconds.rem_euclid(SECONDS_PER_DAY_I64);
45 Instant::from_julian_date(
46 scale,
47 JulianDateSplit::new(J2000_JD + days as f64, rem_s as f64 / SECONDS_PER_DAY)
48 .expect("valid split Julian date"),
49 )
50}
51
52pub(crate) fn j2000_seconds_from_instant(epoch: Instant) -> Option<i64> {
53 match epoch.repr {
54 InstantRepr::JulianDate(split) => {
55 let seconds =
56 (split.jd_whole - J2000_JD) * SECONDS_PER_DAY + split.fraction * SECONDS_PER_DAY;
57 if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
58 Some(seconds.round() as i64)
59 } else {
60 None
61 }
62 }
63 InstantRepr::Nanos(nanos) => {
64 let seconds = (nanos as f64 / 1.0e9).round();
65 if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
66 Some(seconds as i64)
67 } else {
68 None
69 }
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq)]
81pub struct KlobucharParams {
82 pub alpha: [f64; 4],
84 pub beta: [f64; 4],
86}
87
88#[derive(Debug, Clone, Copy, PartialEq)]
94pub struct GalileoNequickCoeffs {
95 pub ai0: f64,
97 pub ai1: f64,
99 pub ai2: f64,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq)]
105pub struct GalileoNequickEval {
106 pub lat_deg: f64,
108 pub lon_deg: f64,
110 pub el_deg: f64,
112 pub t_gal_s: f64,
114 pub day_of_year: f64,
116 pub frequency_hz: f64,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq)]
122pub enum IonoModel {
123 Klobuchar(KlobucharParams),
125 GalileoNequickG(GalileoNequickCoeffs),
127}
128
129pub fn ionosphere_delay(
135 receiver: Wgs84Geodetic,
136 elevation_rad: f64,
137 azimuth_rad: f64,
138 epoch: Instant,
139 frequency_hz: f64,
140 model: &IonoModel,
141) -> Result<f64> {
142 validate_receiver(receiver)?;
143 validate_finite(elevation_rad, "elevation_rad")?;
144 validate_elevation_rad(elevation_rad, "elevation_rad")?;
145 validate_finite(azimuth_rad, "azimuth_rad")?;
146 validate_instant(epoch)?;
147 validate_frequency(frequency_hz)?;
148
149 match model {
150 IonoModel::Klobuchar(params) => klobuchar(
151 params,
152 receiver,
153 elevation_rad,
154 azimuth_rad,
155 epoch,
156 frequency_hz,
157 ),
158 IonoModel::GalileoNequickG(coeffs) => galileo_nequick_g_native(
159 coeffs,
160 GalileoNequickEval {
161 lat_deg: receiver.lat_rad * RAD_TO_DEG,
162 lon_deg: receiver.lon_rad * RAD_TO_DEG,
163 el_deg: elevation_rad * RAD_TO_DEG,
164 t_gal_s: gps_second_of_day(epoch),
165 day_of_year: fractional_day_of_year(epoch),
166 frequency_hz,
167 },
168 ),
169 }
170}
171
172pub fn klobuchar(
189 params: &KlobucharParams,
190 receiver: Wgs84Geodetic,
191 elevation_rad: f64,
192 azimuth_rad: f64,
193 epoch: Instant,
194 frequency_hz: f64,
195) -> Result<f64> {
196 validate_receiver(receiver)?;
197 validate_finite(elevation_rad, "elevation_rad")?;
198 validate_elevation_rad(elevation_rad, "elevation_rad")?;
199 validate_finite(azimuth_rad, "azimuth_rad")?;
200 validate_instant(epoch)?;
201
202 klobuchar_native(
203 params,
204 receiver.lat_rad * RAD_TO_DEG,
205 receiver.lon_rad * RAD_TO_DEG,
206 azimuth_rad * RAD_TO_DEG,
207 elevation_rad * RAD_TO_DEG,
208 gps_second_of_day(epoch),
209 frequency_hz,
210 )
211}
212
213pub fn klobuchar_native(
224 params: &KlobucharParams,
225 lat_deg: f64,
226 lon_deg: f64,
227 az_deg: f64,
228 el_deg: f64,
229 t_gps_s: f64,
230 frequency_hz: f64,
231) -> Result<f64> {
232 validate_klobuchar_params(params)?;
233 validate_lat_deg(lat_deg, "lat_deg")?;
234 validate_lon_deg(lon_deg, "lon_deg")?;
235 validate_finite(az_deg, "az_deg")?;
236 validate_el_deg(el_deg, "el_deg")?;
237 validate_second_of_day(t_gps_s, "t_gps_s")?;
238 validate_frequency(frequency_hz)?;
239
240 let delay_m = klobuchar_native_unchecked(
241 params,
242 lat_deg,
243 lon_deg,
244 az_deg,
245 el_deg,
246 t_gps_s,
247 frequency_hz,
248 );
249 validate_finite(delay_m, "ionosphere_delay_m")?;
250 Ok(delay_m)
251}
252
253pub(crate) fn klobuchar_native_unchecked(
254 params: &KlobucharParams,
255 lat_deg: f64,
256 lon_deg: f64,
257 az_deg: f64,
258 el_deg: f64,
259 t_gps_s: f64,
260 frequency_hz: f64,
261) -> f64 {
262 let c = klobuchar_l1_components(
263 lat_deg,
264 lon_deg,
265 az_deg,
266 el_deg,
267 t_gps_s,
268 params.alpha,
269 params.beta,
270 );
271
272 let f_l1_hz = frequencies::frequency_hz(GnssSystem::Gps, CarrierBand::L1)
273 .expect("canonical GPS L1 carrier exists");
274 let ratio = f_l1_hz / frequency_hz;
275 c.delay_l1_m * (ratio * ratio)
276}
277
278pub fn galileo_nequick_g_native(
292 coeffs: &GalileoNequickCoeffs,
293 eval: GalileoNequickEval,
294) -> Result<f64> {
295 validate_galileo_nequick_coeffs(coeffs)?;
296 validate_galileo_eval(eval)?;
297
298 let delay_m = galileo_nequick_g_native_unchecked(coeffs, eval);
299 validate_finite(delay_m, "ionosphere_delay_m")?;
300 Ok(delay_m)
301}
302
303pub(crate) fn galileo_nequick_g_native_unchecked(
304 coeffs: &GalileoNequickCoeffs,
305 eval: GalileoNequickEval,
306) -> f64 {
307 let GalileoNequickEval {
308 lat_deg,
309 lon_deg,
310 el_deg,
311 t_gal_s,
312 day_of_year,
313 frequency_hz,
314 } = eval;
315 let mu_deg = galileo_modified_dip_latitude_deg(lat_deg, lon_deg);
316 let az = galileo_effective_ionisation_level(coeffs, mu_deg);
317
318 let local_time_h = (t_gal_s / 3600.0 + lon_deg / 15.0).rem_euclid(24.0);
319 let solar = 0.5 + 0.5 * ((local_time_h - 14.0) * (2.0 * std::f64::consts::PI / 24.0)).cos();
320 let diurnal = 0.35 + 0.65 * solar.max(0.0);
321 let seasonal =
322 1.0 + 0.08 * ((day_of_year - 172.0) * (2.0 * std::f64::consts::PI / 365.25)).cos();
323 let equatorial = 1.0 + 0.35 * (-(mu_deg / 22.0).powi(2)).exp();
324
325 let vertical_tecu = (2.5 + 0.135 * az) * diurnal * seasonal * equatorial;
326 let mapping = single_layer_mapping(el_deg);
327 let stec_tecu = vertical_tecu.max(0.0) * mapping;
328 let delay_per_tecu_m = 40.3e16 / (frequency_hz * frequency_hz);
329 stec_tecu * delay_per_tecu_m
330}
331
332pub fn galileo_effective_ionisation_level(
338 coeffs: &GalileoNequickCoeffs,
339 modified_dip_latitude_deg: f64,
340) -> f64 {
341 if coeffs.ai0 == 0.0 && coeffs.ai1 == 0.0 && coeffs.ai2 == 0.0 {
342 return 63.7;
343 }
344 (coeffs.ai0
345 + coeffs.ai1 * modified_dip_latitude_deg
346 + coeffs.ai2 * modified_dip_latitude_deg * modified_dip_latitude_deg)
347 .clamp(0.0, 400.0)
348}
349
350fn galileo_modified_dip_latitude_deg(lat_deg: f64, lon_deg: f64) -> f64 {
351 let lat = lat_deg * DEG_TO_RAD;
352 let lon = lon_deg * DEG_TO_RAD;
353
354 let pole_lat = 80.37 * DEG_TO_RAD;
357 let pole_lon = -72.62 * DEG_TO_RAD;
358 let dip_lat =
359 (lat.sin() * pole_lat.sin() + lat.cos() * pole_lat.cos() * (lon - pole_lon).cos()).asin();
360 let magnetic_dip = (2.0 * dip_lat.tan()).atan();
361 let denom = lat.cos().max(1.0e-12).sqrt();
362 (magnetic_dip.tan() / denom).atan() * RAD_TO_DEG
363}
364
365fn single_layer_mapping(el_deg: f64) -> f64 {
366 let el_rad = el_deg.max(0.1) * DEG_TO_RAD;
367 let earth_radius_m = MEAN_EARTH_RADIUS_M;
368 let shell_radius_m = earth_radius_m + 450_000.0;
369 let arg = earth_radius_m / shell_radius_m * el_rad.cos();
370 1.0 / (1.0 - arg * arg).max(1.0e-12).sqrt()
371}
372
373pub fn ionex_slant_delay(
389 ionex: &Ionex,
390 receiver: Wgs84Geodetic,
391 elevation_rad: f64,
392 azimuth_rad: f64,
393 epoch_j2000_s: i64,
394 frequency_hz: f64,
395) -> Result<f64> {
396 validate_receiver(receiver)?;
397 validate_finite(elevation_rad, "elevation_rad")?;
398 validate_elevation_rad(elevation_rad, "elevation_rad")?;
399 validate_finite(azimuth_rad, "azimuth_rad")?;
400 validate_frequency(frequency_hz)?;
401
402 let delay_m = ionex_slant_delay_unchecked(
403 ionex,
404 receiver,
405 elevation_rad,
406 azimuth_rad,
407 epoch_j2000_s,
408 frequency_hz,
409 );
410 validate_finite(delay_m, "ionosphere_delay_m")?;
411 Ok(delay_m)
412}
413
414fn ionex_slant_delay_unchecked(
415 ionex: &Ionex,
416 receiver: Wgs84Geodetic,
417 elevation_rad: f64,
418 azimuth_rad: f64,
419 epoch_j2000_s: i64,
420 frequency_hz: f64,
421) -> f64 {
422 slant::slant_delay_components(
423 slant::PierceLineOfSight {
424 lat_rad: receiver.lat_rad,
425 lon_rad: receiver.lon_rad,
426 az_rad: azimuth_rad,
427 el_rad: elevation_rad,
428 },
429 frequency_hz,
430 ionex.base_radius_km(),
431 ionex.shell_height_km(),
432 epoch_j2000_s,
433 slant::VtecGridView {
434 map_epochs: ionex.map_epochs(),
435 maps: ionex.tec_maps(),
436 lat_arr: ionex.lat_nodes_deg(),
437 lon_arr: ionex.lon_nodes_deg(),
438 dlat: ionex.dlat_deg(),
439 dlon: ionex.dlon_deg(),
440 },
441 )
442 .delay_m
443}
444
445fn validate_klobuchar_params(params: &KlobucharParams) -> Result<()> {
446 for (index, &value) in params.alpha.iter().enumerate() {
447 validate_finite(value, if index == 0 { "alpha" } else { "alpha[]" })?;
448 }
449 for (index, &value) in params.beta.iter().enumerate() {
450 validate_finite(value, if index == 0 { "beta" } else { "beta[]" })?;
451 }
452 Ok(())
453}
454
455fn validate_galileo_nequick_coeffs(coeffs: &GalileoNequickCoeffs) -> Result<()> {
456 validate_finite(coeffs.ai0, "ai0")?;
457 validate_finite(coeffs.ai1, "ai1")?;
458 validate_finite(coeffs.ai2, "ai2")
459}
460
461fn validate_galileo_eval(eval: GalileoNequickEval) -> Result<()> {
462 validate_lat_deg(eval.lat_deg, "lat_deg")?;
463 validate_lon_deg(eval.lon_deg, "lon_deg")?;
464 validate_el_deg(eval.el_deg, "el_deg")?;
465 validate_second_of_day(eval.t_gal_s, "t_gal_s")?;
466 validate_finite(eval.day_of_year, "day_of_year")?;
467 if !(1.0..367.0).contains(&eval.day_of_year) {
468 return Err(invalid_input("day_of_year", "out of range"));
469 }
470 validate_frequency(eval.frequency_hz)
471}
472
473fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
474 validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
475 validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
476 validate_finite(receiver.height_m, "receiver.height_m")?;
477 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
478 return Err(invalid_input("receiver.lat_rad", "out of range"));
479 }
480 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
481 return Err(invalid_input("receiver.lon_rad", "out of range"));
482 }
483 Ok(())
484}
485
486fn validate_instant(epoch: Instant) -> Result<()> {
487 match epoch.repr {
488 InstantRepr::JulianDate(split) => {
489 validate_finite(split.jd_whole, "epoch.jd_whole")?;
490 validate_finite(split.fraction, "epoch.fraction")?;
491 if !(-1.0..=1.0).contains(&split.fraction) {
492 return Err(invalid_input("epoch.fraction", "out of range"));
493 }
494 }
495 InstantRepr::Nanos(_) => {}
496 }
497 Ok(())
498}
499
500fn validate_lat_deg(value: f64, field: &'static str) -> Result<()> {
501 validate_finite(value, field)?;
502 if !(-90.0..=90.0).contains(&value) {
503 return Err(invalid_input(field, "out of range"));
504 }
505 Ok(())
506}
507
508fn validate_lon_deg(value: f64, field: &'static str) -> Result<()> {
509 validate_finite(value, field)?;
510 if !(-180.0..=180.0).contains(&value) {
511 return Err(invalid_input(field, "out of range"));
512 }
513 Ok(())
514}
515
516fn validate_elevation_rad(value: f64, field: &'static str) -> Result<()> {
517 if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&value) {
518 return Err(invalid_input(field, "out of range"));
519 }
520 Ok(())
521}
522
523fn validate_el_deg(value: f64, field: &'static str) -> Result<()> {
524 validate_finite(value, field)?;
525 if !(0.0..=90.0).contains(&value) {
526 return Err(invalid_input(field, "out of range"));
527 }
528 Ok(())
529}
530
531fn validate_second_of_day(value: f64, field: &'static str) -> Result<()> {
532 validate_finite(value, field)?;
533 if !(0.0..86_400.0).contains(&value) {
534 return Err(invalid_input(field, "out of range"));
535 }
536 Ok(())
537}
538
539fn validate_frequency(frequency_hz: f64) -> Result<()> {
540 validate_finite(frequency_hz, "frequency_hz")?;
541 if frequency_hz <= 0.0 {
542 return Err(invalid_input("frequency_hz", "not positive"));
543 }
544 Ok(())
545}
546
547fn validate_finite(value: f64, field: &'static str) -> Result<()> {
548 if value.is_finite() {
549 Ok(())
550 } else {
551 Err(invalid_input(field, "not finite"))
552 }
553}
554
555fn invalid_input(field: &'static str, reason: &'static str) -> Error {
556 Error::InvalidInput(format!("{field} {reason}"))
557}
558
559fn gps_second_of_day(epoch: Instant) -> f64 {
574 match epoch.repr {
575 InstantRepr::JulianDate(jd) => {
576 let from_midnight = jd.jd_whole + 0.5 + jd.fraction;
579 let day_fraction = from_midnight - from_midnight.floor();
580 day_fraction * SECONDS_PER_DAY
581 }
582 InstantRepr::Nanos(nanos) => {
583 let ns_per_day: i128 = 86_400 * 1_000_000_000;
584 let mut rem = nanos % ns_per_day;
585 if rem < 0 {
586 rem += ns_per_day;
587 }
588 rem as f64 / 1.0e9
589 }
590 }
591}
592
593fn fractional_day_of_year(epoch: Instant) -> f64 {
595 let jd = match epoch.repr {
596 InstantRepr::JulianDate(split) => split.jd_whole + split.fraction,
597 InstantRepr::Nanos(nanos) => (nanos as f64) / 1.0e9 / SECONDS_PER_DAY + J2000_JD,
598 };
599
600 let jd_midnight = jd + 0.5;
601 let day_floor = jd_midnight.floor();
602 let day_fraction = jd_midnight - day_floor;
603
604 let (year, month, day) = civil_from_jd_floor(day_floor);
605 let doy_integer = day_of_year(year, month, day);
606 doy_integer as f64 + day_fraction
607}
608
609fn civil_from_jd_floor(day_floor: f64) -> (i64, i64, i64) {
611 let jdn = day_floor as i64;
612 let l = jdn + 68_569;
613 let n = (4 * l) / 146_097;
614 let l = l - (146_097 * n + 3) / 4;
615 let i = (4_000 * (l + 1)) / 1_461_001;
616 let l = l - (1_461 * i) / 4 + 31;
617 let j = (80 * l) / 2_447;
618 let day = l - (2_447 * j) / 80;
619 let l = j / 11;
620 let month = j + 2 - 12 * l;
621 let year = 100 * (n - 49) + i + l;
622 (year, month, day)
623}
624
625fn day_of_year(year: i64, month: i64, day: i64) -> i64 {
627 const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
628 let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
629 let mut doy = CUM[(month - 1) as usize] + day;
630 if leap && month > 2 {
631 doy += 1;
632 }
633 doy
634}