1mod grid;
14mod klobuchar;
15mod nequick_g;
16mod nequick_g_data;
17mod samples;
18mod slant;
19mod tec_grid;
20mod write;
21
22#[cfg(all(test, sidereon_repo_tests))]
23mod tests;
24
25use crate::astro::constants::time::{DAYS_PER_JULIAN_YEAR, SECONDS_PER_DAY, SECONDS_PER_HOUR};
26use crate::astro::time::civil::{
27 fractional_day_of_year_from_instant, j2000_seconds_from_split, second_of_day_from_instant,
28 split_julian_date_from_j2000_seconds,
29};
30use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
31
32use crate::constants::{DEG_TO_RAD, MEAN_EARTH_RADIUS_M, RAD_TO_DEG};
33use crate::error::{Error, Result};
34use crate::frame::Wgs84Geodetic;
35use crate::frequencies::{self, CarrierBand};
36use crate::GnssSystem;
37
38pub use grid::Ionex;
39pub use nequick_g::{nequick_g_delay_m, nequick_g_stec_tecu, NequickGRayEval};
40pub use samples::{TecGridSamples, TecSample, TecSamplesError};
41pub use tec_grid::{
42 iono_delay_xyz as regular_tec_grid_delay_xyz, tec_xyz as regular_tec_xyz, TecGrid,
43 TecGridEpoch, TecGridEvalOptions, TecGridShellGeometry,
44};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum IonexCoveragePolicy {
49 #[default]
51 Strict,
52 Hold,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum IonexCoverageError {
59 EpochBeforeFirstMap,
61 EpochAfterLastMap,
63 LatitudeOutOfRange,
65 LongitudeOutOfRange,
67}
68
69impl core::fmt::Display for IonexCoverageError {
70 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
71 let message = match self {
72 Self::EpochBeforeFirstMap => "epoch precedes first map",
73 Self::EpochAfterLastMap => "epoch follows last map",
74 Self::LatitudeOutOfRange => "latitude outside grid",
75 Self::LongitudeOutOfRange => "longitude outside grid",
76 };
77 f.write_str(message)
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum IonexSlantDelayStatus {
84 Valid,
86 Held(IonexCoverageError),
88}
89
90#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct IonexSlantDelayEvaluation {
93 pub delay_m: f64,
95 pub status: IonexSlantDelayStatus,
97}
98
99pub(crate) use klobuchar::klobuchar_l1_components;
100pub(crate) use slant::pierce_point;
101
102pub(crate) fn ionex_epoch_from_j2000_seconds(seconds: i64) -> Instant {
103 instant_from_j2000_seconds(TimeScale::Utc, seconds)
104}
105
106pub(crate) fn instant_from_j2000_seconds(scale: TimeScale, seconds: i64) -> Instant {
107 let (jd_whole, fraction) = split_julian_date_from_j2000_seconds(seconds);
108 Instant::from_julian_date(
109 scale,
110 JulianDateSplit::new(jd_whole, fraction).expect("valid split Julian date"),
111 )
112}
113
114pub(crate) fn j2000_seconds_from_instant(epoch: Instant) -> Option<i64> {
115 match epoch.repr {
116 InstantRepr::JulianDate(split) => {
117 let seconds = j2000_seconds_from_split(split.jd_whole, split.fraction);
118 if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
119 Some(seconds.round() as i64)
120 } else {
121 None
122 }
123 }
124 InstantRepr::Nanos(nanos) => {
125 let seconds = (nanos as f64 / 1.0e9).round();
126 if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
127 Some(seconds as i64)
128 } else {
129 None
130 }
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq)]
142pub struct KlobucharParams {
143 pub alpha: [f64; 4],
145 pub beta: [f64; 4],
147}
148
149#[derive(Debug, Clone, Copy, PartialEq)]
155pub struct GalileoNequickCoeffs {
156 pub ai0: f64,
158 pub ai1: f64,
160 pub ai2: f64,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq)]
166pub struct GalileoNequickEval {
167 pub lat_deg: f64,
169 pub lon_deg: f64,
171 pub el_deg: f64,
173 pub t_gal_s: f64,
175 pub day_of_year: f64,
177 pub frequency_hz: f64,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq)]
183pub enum IonoModel {
184 Klobuchar(KlobucharParams),
186 GalileoNequickG(GalileoNequickCoeffs),
188}
189
190pub fn ionosphere_delay(
196 receiver: Wgs84Geodetic,
197 elevation_rad: f64,
198 azimuth_rad: f64,
199 epoch: Instant,
200 frequency_hz: f64,
201 model: &IonoModel,
202) -> Result<f64> {
203 validate_receiver(receiver)?;
204 validate_finite(elevation_rad, "elevation_rad")?;
205 validate_elevation_rad(elevation_rad, "elevation_rad")?;
206 validate_finite(azimuth_rad, "azimuth_rad")?;
207 validate_instant(epoch)?;
208 validate_frequency(frequency_hz)?;
209
210 match model {
211 IonoModel::Klobuchar(params) => klobuchar(
212 params,
213 receiver,
214 elevation_rad,
215 azimuth_rad,
216 epoch,
217 frequency_hz,
218 ),
219 IonoModel::GalileoNequickG(coeffs) => galileo_nequick_g_native(
220 coeffs,
221 GalileoNequickEval {
222 lat_deg: receiver.lat_rad * RAD_TO_DEG,
223 lon_deg: receiver.lon_rad * RAD_TO_DEG,
224 el_deg: elevation_rad * RAD_TO_DEG,
225 t_gal_s: gps_second_of_day(epoch),
226 day_of_year: fractional_day_of_year(epoch),
227 frequency_hz,
228 },
229 ),
230 }
231}
232
233pub fn klobuchar(
250 params: &KlobucharParams,
251 receiver: Wgs84Geodetic,
252 elevation_rad: f64,
253 azimuth_rad: f64,
254 epoch: Instant,
255 frequency_hz: f64,
256) -> Result<f64> {
257 validate_receiver(receiver)?;
258 validate_finite(elevation_rad, "elevation_rad")?;
259 validate_elevation_rad(elevation_rad, "elevation_rad")?;
260 validate_finite(azimuth_rad, "azimuth_rad")?;
261 validate_instant(epoch)?;
262
263 klobuchar_native(
264 params,
265 receiver.lat_rad * RAD_TO_DEG,
266 receiver.lon_rad * RAD_TO_DEG,
267 azimuth_rad * RAD_TO_DEG,
268 elevation_rad * RAD_TO_DEG,
269 gps_second_of_day(epoch),
270 frequency_hz,
271 )
272}
273
274pub fn klobuchar_native(
285 params: &KlobucharParams,
286 lat_deg: f64,
287 lon_deg: f64,
288 az_deg: f64,
289 el_deg: f64,
290 t_gps_s: f64,
291 frequency_hz: f64,
292) -> Result<f64> {
293 validate_klobuchar_params(params)?;
294 validate_lat_deg(lat_deg, "lat_deg")?;
295 validate_lon_deg(lon_deg, "lon_deg")?;
296 validate_finite(az_deg, "az_deg")?;
297 validate_el_deg(el_deg, "el_deg")?;
298 validate_second_of_day(t_gps_s, "t_gps_s")?;
299 validate_frequency(frequency_hz)?;
300
301 let delay_m = klobuchar_native_unchecked(
302 params,
303 lat_deg,
304 lon_deg,
305 az_deg,
306 el_deg,
307 t_gps_s,
308 frequency_hz,
309 );
310 validate_finite(delay_m, "ionosphere_delay_m")?;
311 Ok(delay_m)
312}
313
314pub(crate) fn klobuchar_native_unchecked(
315 params: &KlobucharParams,
316 lat_deg: f64,
317 lon_deg: f64,
318 az_deg: f64,
319 el_deg: f64,
320 t_gps_s: f64,
321 frequency_hz: f64,
322) -> f64 {
323 let c = klobuchar_l1_components(
324 lat_deg,
325 lon_deg,
326 az_deg,
327 el_deg,
328 t_gps_s,
329 params.alpha,
330 params.beta,
331 );
332
333 let f_l1_hz = frequencies::frequency_hz(GnssSystem::Gps, CarrierBand::L1)
334 .expect("canonical GPS L1 carrier exists");
335 let ratio = f_l1_hz / frequency_hz;
336 c.delay_l1_m * (ratio * ratio)
337}
338
339pub fn galileo_nequick_g_native(
353 coeffs: &GalileoNequickCoeffs,
354 eval: GalileoNequickEval,
355) -> Result<f64> {
356 validate_galileo_nequick_coeffs(coeffs)?;
357 validate_galileo_eval(eval)?;
358
359 let delay_m = galileo_nequick_g_native_unchecked(coeffs, eval);
360 validate_finite(delay_m, "ionosphere_delay_m")?;
361 Ok(delay_m)
362}
363
364pub(crate) fn galileo_nequick_g_native_unchecked(
365 coeffs: &GalileoNequickCoeffs,
366 eval: GalileoNequickEval,
367) -> f64 {
368 let GalileoNequickEval {
369 lat_deg,
370 lon_deg,
371 el_deg,
372 t_gal_s,
373 day_of_year,
374 frequency_hz,
375 } = eval;
376 let mu_deg = galileo_modified_dip_latitude_deg(lat_deg, lon_deg);
377 let az = galileo_effective_ionisation_level(coeffs, mu_deg);
378
379 let local_time_h = (t_gal_s / SECONDS_PER_HOUR + lon_deg / 15.0).rem_euclid(24.0);
380 let solar = 0.5 + 0.5 * libm::cos((local_time_h - 14.0) * (2.0 * std::f64::consts::PI / 24.0));
381 let diurnal = 0.35 + 0.65 * solar.max(0.0);
382 let seasonal = 1.0
383 + 0.08
384 * libm::cos(
385 (day_of_year - 172.0) * (2.0 * std::f64::consts::PI / DAYS_PER_JULIAN_YEAR),
386 );
387 let mu_ratio = mu_deg / 22.0;
388 let equatorial = 1.0 + 0.35 * libm::exp(-(mu_ratio * mu_ratio));
389
390 let vertical_tecu = (2.5 + 0.135 * az) * diurnal * seasonal * equatorial;
391 let mapping = single_layer_mapping(el_deg);
392 let stec_tecu = vertical_tecu.max(0.0) * mapping;
393 let delay_per_tecu_m = 40.3e16 / (frequency_hz * frequency_hz);
394 stec_tecu * delay_per_tecu_m
395}
396
397pub fn galileo_effective_ionisation_level(
403 coeffs: &GalileoNequickCoeffs,
404 modified_dip_latitude_deg: f64,
405) -> f64 {
406 if coeffs.ai0 == 0.0 && coeffs.ai1 == 0.0 && coeffs.ai2 == 0.0 {
407 return 63.7;
408 }
409 (coeffs.ai0
410 + coeffs.ai1 * modified_dip_latitude_deg
411 + coeffs.ai2 * modified_dip_latitude_deg * modified_dip_latitude_deg)
412 .clamp(0.0, 400.0)
413}
414
415fn galileo_modified_dip_latitude_deg(lat_deg: f64, lon_deg: f64) -> f64 {
416 let lat = lat_deg * DEG_TO_RAD;
417 let lon = lon_deg * DEG_TO_RAD;
418
419 let pole_lat = 80.37 * DEG_TO_RAD;
422 let pole_lon = -72.62 * DEG_TO_RAD;
423 let dip_lat = libm::asin(
424 libm::sin(lat) * libm::sin(pole_lat)
425 + libm::cos(lat) * libm::cos(pole_lat) * libm::cos(lon - pole_lon),
426 );
427 let magnetic_dip = libm::atan(2.0 * libm::tan(dip_lat));
428 let denom = libm::cos(lat).max(1.0e-12).sqrt();
429 libm::atan(libm::tan(magnetic_dip) / denom) * RAD_TO_DEG
430}
431
432fn single_layer_mapping(el_deg: f64) -> f64 {
433 let el_rad = el_deg.max(0.1) * DEG_TO_RAD;
434 let earth_radius_m = MEAN_EARTH_RADIUS_M;
435 let shell_radius_m = earth_radius_m + 450_000.0;
436 let arg = earth_radius_m / shell_radius_m * libm::cos(el_rad);
437 1.0 / (1.0 - arg * arg).max(1.0e-12).sqrt()
438}
439
440pub fn ionex_slant_delay(
457 ionex: &Ionex,
458 receiver: Wgs84Geodetic,
459 elevation_rad: f64,
460 azimuth_rad: f64,
461 epoch_j2000_s: i64,
462 frequency_hz: f64,
463) -> Result<f64> {
464 Ok(ionex_slant_delay_with_policy(
465 ionex,
466 receiver,
467 elevation_rad,
468 azimuth_rad,
469 epoch_j2000_s,
470 frequency_hz,
471 IonexCoveragePolicy::Strict,
472 )?
473 .delay_m)
474}
475
476pub fn ionex_slant_delay_with_policy(
478 ionex: &Ionex,
479 receiver: Wgs84Geodetic,
480 elevation_rad: f64,
481 azimuth_rad: f64,
482 epoch_j2000_s: i64,
483 frequency_hz: f64,
484 policy: IonexCoveragePolicy,
485) -> Result<IonexSlantDelayEvaluation> {
486 validate_ionex_slant_inputs(receiver, elevation_rad, azimuth_rad, frequency_hz)?;
487
488 let evaluation = ionex_slant_delay_unchecked_with_policy(
489 ionex,
490 IonexSlantRequest {
491 receiver,
492 elevation_rad,
493 azimuth_rad,
494 epoch_j2000_s,
495 frequency_hz,
496 },
497 ionex_vtec_grid_view(ionex),
498 policy,
499 )?;
500 validate_finite(evaluation.delay_m, "ionosphere_delay_m")?;
501 Ok(evaluation)
502}
503
504#[derive(Debug, Clone, Copy, PartialEq)]
506pub struct IonexSlantRequest {
507 pub receiver: Wgs84Geodetic,
509 pub elevation_rad: f64,
511 pub azimuth_rad: f64,
513 pub epoch_j2000_s: i64,
515 pub frequency_hz: f64,
517}
518
519pub fn ionex_slant_delays(
526 ionex: &Ionex,
527 requests: &[IonexSlantRequest],
528 out: &mut [f64],
529) -> Result<()> {
530 if out.len() != requests.len() {
531 return Err(Error::InvalidInput(format!(
532 "IONEX slant output length {} does not match request length {}",
533 out.len(),
534 requests.len()
535 )));
536 }
537
538 let grid = ionex_vtec_grid_view(ionex);
539 for (request, output) in requests.iter().zip(out.iter_mut()) {
540 validate_ionex_slant_request(*request)?;
541
542 let evaluation = ionex_slant_delay_unchecked_with_policy(
543 ionex,
544 *request,
545 grid,
546 IonexCoveragePolicy::Strict,
547 )?;
548 let delay_m = evaluation.delay_m;
549 validate_finite(delay_m, "ionosphere_delay_m")?;
550 debug_assert!(delay_m.is_finite());
551 *output = delay_m;
552 }
553 Ok(())
554}
555
556pub fn ionex_slant_delay_results(
562 ionex: &Ionex,
563 requests: &[IonexSlantRequest],
564 policy: IonexCoveragePolicy,
565) -> Vec<Result<IonexSlantDelayEvaluation>> {
566 let grid = ionex_vtec_grid_view(ionex);
567 requests
568 .iter()
569 .map(|request| {
570 validate_ionex_slant_request(*request)?;
571 let evaluation =
572 ionex_slant_delay_unchecked_with_policy(ionex, *request, grid, policy)?;
573 validate_finite(evaluation.delay_m, "ionosphere_delay_m")?;
574 Ok(evaluation)
575 })
576 .collect()
577}
578
579impl Ionex {
580 pub fn slant_delays_batch(
588 &self,
589 requests: &[IonexSlantRequest],
590 out: &mut [f64],
591 ) -> Result<()> {
592 ionex_slant_delays(self, requests, out)
593 }
594
595 pub fn slant_delays_batch_vec(&self, requests: &[IonexSlantRequest]) -> Result<Vec<f64>> {
602 let mut out = vec![0.0; requests.len()];
603 self.slant_delays_batch(requests, &mut out)?;
604 Ok(out)
605 }
606
607 pub fn slant_delays_batch_results(
609 &self,
610 requests: &[IonexSlantRequest],
611 policy: IonexCoveragePolicy,
612 ) -> Vec<Result<IonexSlantDelayEvaluation>> {
613 ionex_slant_delay_results(self, requests, policy)
614 }
615}
616
617fn ionex_slant_delay_unchecked_with_policy(
618 ionex: &Ionex,
619 request: IonexSlantRequest,
620 grid: slant::VtecGridView<'_>,
621 policy: IonexCoveragePolicy,
622) -> Result<IonexSlantDelayEvaluation> {
623 let (components, coverage) = slant::slant_delay_components_with_policy(
624 slant::PierceLineOfSight {
625 lat_rad: request.receiver.lat_rad,
626 lon_rad: request.receiver.lon_rad,
627 az_rad: request.azimuth_rad,
628 el_rad: request.elevation_rad,
629 },
630 request.frequency_hz,
631 ionex.base_radius_km(),
632 ionex.shell_height_km(),
633 request.epoch_j2000_s,
634 grid,
635 policy,
636 )
637 .map_err(Error::IonexOutOfCoverage)?;
638 let status = match coverage {
639 Some(error) => IonexSlantDelayStatus::Held(error),
640 None => IonexSlantDelayStatus::Valid,
641 };
642 Ok(IonexSlantDelayEvaluation {
643 delay_m: components.delay_m,
644 status,
645 })
646}
647
648fn ionex_vtec_grid_view(ionex: &Ionex) -> slant::VtecGridView<'_> {
649 slant::VtecGridView {
650 map_epochs: ionex.map_epochs(),
651 maps: ionex.tec_maps(),
652 lat_arr: ionex.lat_nodes_deg(),
653 lon_arr: ionex.lon_nodes_deg(),
654 dlat: ionex.dlat_deg(),
655 dlon: ionex.dlon_deg(),
656 }
657}
658
659fn validate_ionex_slant_request(request: IonexSlantRequest) -> Result<()> {
660 validate_ionex_slant_inputs(
661 request.receiver,
662 request.elevation_rad,
663 request.azimuth_rad,
664 request.frequency_hz,
665 )
666}
667
668fn validate_ionex_slant_inputs(
669 receiver: Wgs84Geodetic,
670 elevation_rad: f64,
671 azimuth_rad: f64,
672 frequency_hz: f64,
673) -> Result<()> {
674 validate_receiver(receiver)?;
675 validate_finite(elevation_rad, "elevation_rad")?;
676 validate_elevation_rad(elevation_rad, "elevation_rad")?;
677 validate_finite(azimuth_rad, "azimuth_rad")?;
678 validate_frequency(frequency_hz)
679}
680
681fn validate_klobuchar_params(params: &KlobucharParams) -> Result<()> {
682 for (index, &value) in params.alpha.iter().enumerate() {
683 validate_finite(value, if index == 0 { "alpha" } else { "alpha[]" })?;
684 }
685 for (index, &value) in params.beta.iter().enumerate() {
686 validate_finite(value, if index == 0 { "beta" } else { "beta[]" })?;
687 }
688 Ok(())
689}
690
691fn validate_galileo_nequick_coeffs(coeffs: &GalileoNequickCoeffs) -> Result<()> {
692 validate_finite(coeffs.ai0, "ai0")?;
693 validate_finite(coeffs.ai1, "ai1")?;
694 validate_finite(coeffs.ai2, "ai2")
695}
696
697fn validate_galileo_eval(eval: GalileoNequickEval) -> Result<()> {
698 validate_lat_deg(eval.lat_deg, "lat_deg")?;
699 validate_lon_deg(eval.lon_deg, "lon_deg")?;
700 validate_el_deg(eval.el_deg, "el_deg")?;
701 validate_second_of_day(eval.t_gal_s, "t_gal_s")?;
702 validate_finite(eval.day_of_year, "day_of_year")?;
703 if !(1.0..367.0).contains(&eval.day_of_year) {
704 return Err(invalid_input("day_of_year", "out of range"));
705 }
706 validate_frequency(eval.frequency_hz)
707}
708
709pub(crate) fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
710 validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
711 validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
712 validate_finite(receiver.height_m, "receiver.height_m")?;
713 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
714 return Err(invalid_input("receiver.lat_rad", "out of range"));
715 }
716 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
717 return Err(invalid_input("receiver.lon_rad", "out of range"));
718 }
719 Ok(())
720}
721
722fn validate_instant(epoch: Instant) -> Result<()> {
723 match epoch.repr {
724 InstantRepr::JulianDate(split) => {
725 validate_finite(split.jd_whole, "epoch.jd_whole")?;
726 validate_finite(split.fraction, "epoch.fraction")?;
727 if !(-1.0..=1.0).contains(&split.fraction) {
728 return Err(invalid_input("epoch.fraction", "out of range"));
729 }
730 }
731 InstantRepr::Nanos(_) => {}
732 }
733 Ok(())
734}
735
736fn validate_lat_deg(value: f64, field: &'static str) -> Result<()> {
737 validate_finite(value, field)?;
738 if !(-90.0..=90.0).contains(&value) {
739 return Err(invalid_input(field, "out of range"));
740 }
741 Ok(())
742}
743
744fn validate_lon_deg(value: f64, field: &'static str) -> Result<()> {
745 validate_finite(value, field)?;
746 if !(-180.0..=180.0).contains(&value) {
747 return Err(invalid_input(field, "out of range"));
748 }
749 Ok(())
750}
751
752pub(crate) fn validate_elevation_rad(value: f64, field: &'static str) -> Result<()> {
753 if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&value) {
754 return Err(invalid_input(field, "out of range"));
755 }
756 Ok(())
757}
758
759fn validate_el_deg(value: f64, field: &'static str) -> Result<()> {
760 validate_finite(value, field)?;
761 if !(0.0..=90.0).contains(&value) {
762 return Err(invalid_input(field, "out of range"));
763 }
764 Ok(())
765}
766
767fn validate_second_of_day(value: f64, field: &'static str) -> Result<()> {
768 validate_finite(value, field)?;
769 if !(0.0..SECONDS_PER_DAY).contains(&value) {
770 return Err(invalid_input(field, "out of range"));
771 }
772 Ok(())
773}
774
775pub(crate) fn validate_frequency(frequency_hz: f64) -> Result<()> {
776 validate_finite(frequency_hz, "frequency_hz")?;
777 if frequency_hz <= 0.0 {
778 return Err(invalid_input("frequency_hz", "not positive"));
779 }
780 Ok(())
781}
782
783fn validate_finite(value: f64, field: &'static str) -> Result<()> {
784 if value.is_finite() {
785 Ok(())
786 } else {
787 Err(invalid_input(field, "not finite"))
788 }
789}
790
791fn invalid_input(field: &'static str, reason: &'static str) -> Error {
792 Error::InvalidInput(format!("{field} {reason}"))
793}
794
795fn gps_second_of_day(epoch: Instant) -> f64 {
810 second_of_day_from_instant(epoch)
811}
812
813fn fractional_day_of_year(epoch: Instant) -> f64 {
815 fractional_day_of_year_from_instant(epoch)
816}