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 * ((local_time_h - 14.0) * (2.0 * std::f64::consts::PI / 24.0)).cos();
381 let diurnal = 0.35 + 0.65 * solar.max(0.0);
382 let seasonal = 1.0
383 + 0.08
384 * ((day_of_year - 172.0) * (2.0 * std::f64::consts::PI / DAYS_PER_JULIAN_YEAR)).cos();
385 let equatorial = 1.0 + 0.35 * (-(mu_deg / 22.0).powi(2)).exp();
386
387 let vertical_tecu = (2.5 + 0.135 * az) * diurnal * seasonal * equatorial;
388 let mapping = single_layer_mapping(el_deg);
389 let stec_tecu = vertical_tecu.max(0.0) * mapping;
390 let delay_per_tecu_m = 40.3e16 / (frequency_hz * frequency_hz);
391 stec_tecu * delay_per_tecu_m
392}
393
394pub fn galileo_effective_ionisation_level(
400 coeffs: &GalileoNequickCoeffs,
401 modified_dip_latitude_deg: f64,
402) -> f64 {
403 if coeffs.ai0 == 0.0 && coeffs.ai1 == 0.0 && coeffs.ai2 == 0.0 {
404 return 63.7;
405 }
406 (coeffs.ai0
407 + coeffs.ai1 * modified_dip_latitude_deg
408 + coeffs.ai2 * modified_dip_latitude_deg * modified_dip_latitude_deg)
409 .clamp(0.0, 400.0)
410}
411
412fn galileo_modified_dip_latitude_deg(lat_deg: f64, lon_deg: f64) -> f64 {
413 let lat = lat_deg * DEG_TO_RAD;
414 let lon = lon_deg * DEG_TO_RAD;
415
416 let pole_lat = 80.37 * DEG_TO_RAD;
419 let pole_lon = -72.62 * DEG_TO_RAD;
420 let dip_lat =
421 (lat.sin() * pole_lat.sin() + lat.cos() * pole_lat.cos() * (lon - pole_lon).cos()).asin();
422 let magnetic_dip = (2.0 * dip_lat.tan()).atan();
423 let denom = lat.cos().max(1.0e-12).sqrt();
424 (magnetic_dip.tan() / denom).atan() * RAD_TO_DEG
425}
426
427fn single_layer_mapping(el_deg: f64) -> f64 {
428 let el_rad = el_deg.max(0.1) * DEG_TO_RAD;
429 let earth_radius_m = MEAN_EARTH_RADIUS_M;
430 let shell_radius_m = earth_radius_m + 450_000.0;
431 let arg = earth_radius_m / shell_radius_m * el_rad.cos();
432 1.0 / (1.0 - arg * arg).max(1.0e-12).sqrt()
433}
434
435pub fn ionex_slant_delay(
452 ionex: &Ionex,
453 receiver: Wgs84Geodetic,
454 elevation_rad: f64,
455 azimuth_rad: f64,
456 epoch_j2000_s: i64,
457 frequency_hz: f64,
458) -> Result<f64> {
459 Ok(ionex_slant_delay_with_policy(
460 ionex,
461 receiver,
462 elevation_rad,
463 azimuth_rad,
464 epoch_j2000_s,
465 frequency_hz,
466 IonexCoveragePolicy::Strict,
467 )?
468 .delay_m)
469}
470
471pub fn ionex_slant_delay_with_policy(
473 ionex: &Ionex,
474 receiver: Wgs84Geodetic,
475 elevation_rad: f64,
476 azimuth_rad: f64,
477 epoch_j2000_s: i64,
478 frequency_hz: f64,
479 policy: IonexCoveragePolicy,
480) -> Result<IonexSlantDelayEvaluation> {
481 validate_ionex_slant_inputs(receiver, elevation_rad, azimuth_rad, frequency_hz)?;
482
483 let evaluation = ionex_slant_delay_unchecked_with_policy(
484 ionex,
485 IonexSlantRequest {
486 receiver,
487 elevation_rad,
488 azimuth_rad,
489 epoch_j2000_s,
490 frequency_hz,
491 },
492 ionex_vtec_grid_view(ionex),
493 policy,
494 )?;
495 validate_finite(evaluation.delay_m, "ionosphere_delay_m")?;
496 Ok(evaluation)
497}
498
499#[derive(Debug, Clone, Copy, PartialEq)]
501pub struct IonexSlantRequest {
502 pub receiver: Wgs84Geodetic,
504 pub elevation_rad: f64,
506 pub azimuth_rad: f64,
508 pub epoch_j2000_s: i64,
510 pub frequency_hz: f64,
512}
513
514pub fn ionex_slant_delays(
521 ionex: &Ionex,
522 requests: &[IonexSlantRequest],
523 out: &mut [f64],
524) -> Result<()> {
525 if out.len() != requests.len() {
526 return Err(Error::InvalidInput(format!(
527 "IONEX slant output length {} does not match request length {}",
528 out.len(),
529 requests.len()
530 )));
531 }
532
533 let grid = ionex_vtec_grid_view(ionex);
534 for (request, output) in requests.iter().zip(out.iter_mut()) {
535 validate_ionex_slant_request(*request)?;
536
537 let evaluation = ionex_slant_delay_unchecked_with_policy(
538 ionex,
539 *request,
540 grid,
541 IonexCoveragePolicy::Strict,
542 )?;
543 let delay_m = evaluation.delay_m;
544 validate_finite(delay_m, "ionosphere_delay_m")?;
545 debug_assert!(delay_m.is_finite());
546 *output = delay_m;
547 }
548 Ok(())
549}
550
551pub fn ionex_slant_delay_results(
557 ionex: &Ionex,
558 requests: &[IonexSlantRequest],
559 policy: IonexCoveragePolicy,
560) -> Vec<Result<IonexSlantDelayEvaluation>> {
561 let grid = ionex_vtec_grid_view(ionex);
562 requests
563 .iter()
564 .map(|request| {
565 validate_ionex_slant_request(*request)?;
566 let evaluation =
567 ionex_slant_delay_unchecked_with_policy(ionex, *request, grid, policy)?;
568 validate_finite(evaluation.delay_m, "ionosphere_delay_m")?;
569 Ok(evaluation)
570 })
571 .collect()
572}
573
574impl Ionex {
575 pub fn slant_delays_batch(
583 &self,
584 requests: &[IonexSlantRequest],
585 out: &mut [f64],
586 ) -> Result<()> {
587 ionex_slant_delays(self, requests, out)
588 }
589
590 pub fn slant_delays_batch_vec(&self, requests: &[IonexSlantRequest]) -> Result<Vec<f64>> {
597 let mut out = vec![0.0; requests.len()];
598 self.slant_delays_batch(requests, &mut out)?;
599 Ok(out)
600 }
601
602 pub fn slant_delays_batch_results(
604 &self,
605 requests: &[IonexSlantRequest],
606 policy: IonexCoveragePolicy,
607 ) -> Vec<Result<IonexSlantDelayEvaluation>> {
608 ionex_slant_delay_results(self, requests, policy)
609 }
610}
611
612fn ionex_slant_delay_unchecked_with_policy(
613 ionex: &Ionex,
614 request: IonexSlantRequest,
615 grid: slant::VtecGridView<'_>,
616 policy: IonexCoveragePolicy,
617) -> Result<IonexSlantDelayEvaluation> {
618 let (components, coverage) = slant::slant_delay_components_with_policy(
619 slant::PierceLineOfSight {
620 lat_rad: request.receiver.lat_rad,
621 lon_rad: request.receiver.lon_rad,
622 az_rad: request.azimuth_rad,
623 el_rad: request.elevation_rad,
624 },
625 request.frequency_hz,
626 ionex.base_radius_km(),
627 ionex.shell_height_km(),
628 request.epoch_j2000_s,
629 grid,
630 policy,
631 )
632 .map_err(Error::IonexOutOfCoverage)?;
633 let status = match coverage {
634 Some(error) => IonexSlantDelayStatus::Held(error),
635 None => IonexSlantDelayStatus::Valid,
636 };
637 Ok(IonexSlantDelayEvaluation {
638 delay_m: components.delay_m,
639 status,
640 })
641}
642
643fn ionex_vtec_grid_view(ionex: &Ionex) -> slant::VtecGridView<'_> {
644 slant::VtecGridView {
645 map_epochs: ionex.map_epochs(),
646 maps: ionex.tec_maps(),
647 lat_arr: ionex.lat_nodes_deg(),
648 lon_arr: ionex.lon_nodes_deg(),
649 dlat: ionex.dlat_deg(),
650 dlon: ionex.dlon_deg(),
651 }
652}
653
654fn validate_ionex_slant_request(request: IonexSlantRequest) -> Result<()> {
655 validate_ionex_slant_inputs(
656 request.receiver,
657 request.elevation_rad,
658 request.azimuth_rad,
659 request.frequency_hz,
660 )
661}
662
663fn validate_ionex_slant_inputs(
664 receiver: Wgs84Geodetic,
665 elevation_rad: f64,
666 azimuth_rad: f64,
667 frequency_hz: f64,
668) -> Result<()> {
669 validate_receiver(receiver)?;
670 validate_finite(elevation_rad, "elevation_rad")?;
671 validate_elevation_rad(elevation_rad, "elevation_rad")?;
672 validate_finite(azimuth_rad, "azimuth_rad")?;
673 validate_frequency(frequency_hz)
674}
675
676fn validate_klobuchar_params(params: &KlobucharParams) -> Result<()> {
677 for (index, &value) in params.alpha.iter().enumerate() {
678 validate_finite(value, if index == 0 { "alpha" } else { "alpha[]" })?;
679 }
680 for (index, &value) in params.beta.iter().enumerate() {
681 validate_finite(value, if index == 0 { "beta" } else { "beta[]" })?;
682 }
683 Ok(())
684}
685
686fn validate_galileo_nequick_coeffs(coeffs: &GalileoNequickCoeffs) -> Result<()> {
687 validate_finite(coeffs.ai0, "ai0")?;
688 validate_finite(coeffs.ai1, "ai1")?;
689 validate_finite(coeffs.ai2, "ai2")
690}
691
692fn validate_galileo_eval(eval: GalileoNequickEval) -> Result<()> {
693 validate_lat_deg(eval.lat_deg, "lat_deg")?;
694 validate_lon_deg(eval.lon_deg, "lon_deg")?;
695 validate_el_deg(eval.el_deg, "el_deg")?;
696 validate_second_of_day(eval.t_gal_s, "t_gal_s")?;
697 validate_finite(eval.day_of_year, "day_of_year")?;
698 if !(1.0..367.0).contains(&eval.day_of_year) {
699 return Err(invalid_input("day_of_year", "out of range"));
700 }
701 validate_frequency(eval.frequency_hz)
702}
703
704pub(crate) fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
705 validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
706 validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
707 validate_finite(receiver.height_m, "receiver.height_m")?;
708 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
709 return Err(invalid_input("receiver.lat_rad", "out of range"));
710 }
711 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
712 return Err(invalid_input("receiver.lon_rad", "out of range"));
713 }
714 Ok(())
715}
716
717fn validate_instant(epoch: Instant) -> Result<()> {
718 match epoch.repr {
719 InstantRepr::JulianDate(split) => {
720 validate_finite(split.jd_whole, "epoch.jd_whole")?;
721 validate_finite(split.fraction, "epoch.fraction")?;
722 if !(-1.0..=1.0).contains(&split.fraction) {
723 return Err(invalid_input("epoch.fraction", "out of range"));
724 }
725 }
726 InstantRepr::Nanos(_) => {}
727 }
728 Ok(())
729}
730
731fn validate_lat_deg(value: f64, field: &'static str) -> Result<()> {
732 validate_finite(value, field)?;
733 if !(-90.0..=90.0).contains(&value) {
734 return Err(invalid_input(field, "out of range"));
735 }
736 Ok(())
737}
738
739fn validate_lon_deg(value: f64, field: &'static str) -> Result<()> {
740 validate_finite(value, field)?;
741 if !(-180.0..=180.0).contains(&value) {
742 return Err(invalid_input(field, "out of range"));
743 }
744 Ok(())
745}
746
747pub(crate) fn validate_elevation_rad(value: f64, field: &'static str) -> Result<()> {
748 if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&value) {
749 return Err(invalid_input(field, "out of range"));
750 }
751 Ok(())
752}
753
754fn validate_el_deg(value: f64, field: &'static str) -> Result<()> {
755 validate_finite(value, field)?;
756 if !(0.0..=90.0).contains(&value) {
757 return Err(invalid_input(field, "out of range"));
758 }
759 Ok(())
760}
761
762fn validate_second_of_day(value: f64, field: &'static str) -> Result<()> {
763 validate_finite(value, field)?;
764 if !(0.0..SECONDS_PER_DAY).contains(&value) {
765 return Err(invalid_input(field, "out of range"));
766 }
767 Ok(())
768}
769
770pub(crate) fn validate_frequency(frequency_hz: f64) -> Result<()> {
771 validate_finite(frequency_hz, "frequency_hz")?;
772 if frequency_hz <= 0.0 {
773 return Err(invalid_input("frequency_hz", "not positive"));
774 }
775 Ok(())
776}
777
778fn validate_finite(value: f64, field: &'static str) -> Result<()> {
779 if value.is_finite() {
780 Ok(())
781 } else {
782 Err(invalid_input(field, "not finite"))
783 }
784}
785
786fn invalid_input(field: &'static str, reason: &'static str) -> Error {
787 Error::InvalidInput(format!("{field} {reason}"))
788}
789
790fn gps_second_of_day(epoch: Instant) -> f64 {
805 second_of_day_from_instant(epoch)
806}
807
808fn fractional_day_of_year(epoch: Instant) -> f64 {
810 fractional_day_of_year_from_instant(epoch)
811}