1mod grid;
14mod klobuchar;
15mod nequick_g;
16mod nequick_g_data;
17mod slant;
18mod tec_grid;
19mod write;
20
21#[cfg(all(test, sidereon_repo_tests))]
22mod tests;
23
24use crate::astro::constants::time::SECONDS_PER_DAY;
25use crate::astro::time::civil::{
26 fractional_day_of_year_from_instant, j2000_seconds_from_split, second_of_day_from_instant,
27 split_julian_date_from_j2000_seconds,
28};
29use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
30
31use crate::constants::{DEG_TO_RAD, MEAN_EARTH_RADIUS_M, RAD_TO_DEG};
32use crate::error::{Error, Result};
33use crate::frame::Wgs84Geodetic;
34use crate::frequencies::{self, CarrierBand};
35use crate::GnssSystem;
36
37pub use grid::Ionex;
38pub use nequick_g::{nequick_g_delay_m, nequick_g_stec_tecu, NequickGRayEval};
39pub use tec_grid::{
40 iono_delay_xyz as regular_tec_grid_delay_xyz, tec_xyz as regular_tec_xyz, TecGrid,
41 TecGridEpoch, TecGridEvalOptions, TecGridShellGeometry,
42};
43
44pub(crate) use klobuchar::klobuchar_l1_components;
45
46pub(crate) fn ionex_epoch_from_j2000_seconds(seconds: i64) -> Instant {
47 instant_from_j2000_seconds(TimeScale::Utc, seconds)
48}
49
50pub(crate) fn instant_from_j2000_seconds(scale: TimeScale, seconds: i64) -> Instant {
51 let (jd_whole, fraction) = split_julian_date_from_j2000_seconds(seconds);
52 Instant::from_julian_date(
53 scale,
54 JulianDateSplit::new(jd_whole, fraction).expect("valid split Julian date"),
55 )
56}
57
58pub(crate) fn j2000_seconds_from_instant(epoch: Instant) -> Option<i64> {
59 match epoch.repr {
60 InstantRepr::JulianDate(split) => {
61 let seconds = j2000_seconds_from_split(split.jd_whole, split.fraction);
62 if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
63 Some(seconds.round() as i64)
64 } else {
65 None
66 }
67 }
68 InstantRepr::Nanos(nanos) => {
69 let seconds = (nanos as f64 / 1.0e9).round();
70 if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
71 Some(seconds as i64)
72 } else {
73 None
74 }
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct KlobucharParams {
87 pub alpha: [f64; 4],
89 pub beta: [f64; 4],
91}
92
93#[derive(Debug, Clone, Copy, PartialEq)]
99pub struct GalileoNequickCoeffs {
100 pub ai0: f64,
102 pub ai1: f64,
104 pub ai2: f64,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq)]
110pub struct GalileoNequickEval {
111 pub lat_deg: f64,
113 pub lon_deg: f64,
115 pub el_deg: f64,
117 pub t_gal_s: f64,
119 pub day_of_year: f64,
121 pub frequency_hz: f64,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq)]
127pub enum IonoModel {
128 Klobuchar(KlobucharParams),
130 GalileoNequickG(GalileoNequickCoeffs),
132}
133
134pub fn ionosphere_delay(
140 receiver: Wgs84Geodetic,
141 elevation_rad: f64,
142 azimuth_rad: f64,
143 epoch: Instant,
144 frequency_hz: f64,
145 model: &IonoModel,
146) -> Result<f64> {
147 validate_receiver(receiver)?;
148 validate_finite(elevation_rad, "elevation_rad")?;
149 validate_elevation_rad(elevation_rad, "elevation_rad")?;
150 validate_finite(azimuth_rad, "azimuth_rad")?;
151 validate_instant(epoch)?;
152 validate_frequency(frequency_hz)?;
153
154 match model {
155 IonoModel::Klobuchar(params) => klobuchar(
156 params,
157 receiver,
158 elevation_rad,
159 azimuth_rad,
160 epoch,
161 frequency_hz,
162 ),
163 IonoModel::GalileoNequickG(coeffs) => galileo_nequick_g_native(
164 coeffs,
165 GalileoNequickEval {
166 lat_deg: receiver.lat_rad * RAD_TO_DEG,
167 lon_deg: receiver.lon_rad * RAD_TO_DEG,
168 el_deg: elevation_rad * RAD_TO_DEG,
169 t_gal_s: gps_second_of_day(epoch),
170 day_of_year: fractional_day_of_year(epoch),
171 frequency_hz,
172 },
173 ),
174 }
175}
176
177pub fn klobuchar(
194 params: &KlobucharParams,
195 receiver: Wgs84Geodetic,
196 elevation_rad: f64,
197 azimuth_rad: f64,
198 epoch: Instant,
199 frequency_hz: f64,
200) -> Result<f64> {
201 validate_receiver(receiver)?;
202 validate_finite(elevation_rad, "elevation_rad")?;
203 validate_elevation_rad(elevation_rad, "elevation_rad")?;
204 validate_finite(azimuth_rad, "azimuth_rad")?;
205 validate_instant(epoch)?;
206
207 klobuchar_native(
208 params,
209 receiver.lat_rad * RAD_TO_DEG,
210 receiver.lon_rad * RAD_TO_DEG,
211 azimuth_rad * RAD_TO_DEG,
212 elevation_rad * RAD_TO_DEG,
213 gps_second_of_day(epoch),
214 frequency_hz,
215 )
216}
217
218pub fn klobuchar_native(
229 params: &KlobucharParams,
230 lat_deg: f64,
231 lon_deg: f64,
232 az_deg: f64,
233 el_deg: f64,
234 t_gps_s: f64,
235 frequency_hz: f64,
236) -> Result<f64> {
237 validate_klobuchar_params(params)?;
238 validate_lat_deg(lat_deg, "lat_deg")?;
239 validate_lon_deg(lon_deg, "lon_deg")?;
240 validate_finite(az_deg, "az_deg")?;
241 validate_el_deg(el_deg, "el_deg")?;
242 validate_second_of_day(t_gps_s, "t_gps_s")?;
243 validate_frequency(frequency_hz)?;
244
245 let delay_m = klobuchar_native_unchecked(
246 params,
247 lat_deg,
248 lon_deg,
249 az_deg,
250 el_deg,
251 t_gps_s,
252 frequency_hz,
253 );
254 validate_finite(delay_m, "ionosphere_delay_m")?;
255 Ok(delay_m)
256}
257
258pub(crate) fn klobuchar_native_unchecked(
259 params: &KlobucharParams,
260 lat_deg: f64,
261 lon_deg: f64,
262 az_deg: f64,
263 el_deg: f64,
264 t_gps_s: f64,
265 frequency_hz: f64,
266) -> f64 {
267 let c = klobuchar_l1_components(
268 lat_deg,
269 lon_deg,
270 az_deg,
271 el_deg,
272 t_gps_s,
273 params.alpha,
274 params.beta,
275 );
276
277 let f_l1_hz = frequencies::frequency_hz(GnssSystem::Gps, CarrierBand::L1)
278 .expect("canonical GPS L1 carrier exists");
279 let ratio = f_l1_hz / frequency_hz;
280 c.delay_l1_m * (ratio * ratio)
281}
282
283pub fn galileo_nequick_g_native(
297 coeffs: &GalileoNequickCoeffs,
298 eval: GalileoNequickEval,
299) -> Result<f64> {
300 validate_galileo_nequick_coeffs(coeffs)?;
301 validate_galileo_eval(eval)?;
302
303 let delay_m = galileo_nequick_g_native_unchecked(coeffs, eval);
304 validate_finite(delay_m, "ionosphere_delay_m")?;
305 Ok(delay_m)
306}
307
308pub(crate) fn galileo_nequick_g_native_unchecked(
309 coeffs: &GalileoNequickCoeffs,
310 eval: GalileoNequickEval,
311) -> f64 {
312 let GalileoNequickEval {
313 lat_deg,
314 lon_deg,
315 el_deg,
316 t_gal_s,
317 day_of_year,
318 frequency_hz,
319 } = eval;
320 let mu_deg = galileo_modified_dip_latitude_deg(lat_deg, lon_deg);
321 let az = galileo_effective_ionisation_level(coeffs, mu_deg);
322
323 let local_time_h = (t_gal_s / 3600.0 + lon_deg / 15.0).rem_euclid(24.0);
324 let solar = 0.5 + 0.5 * ((local_time_h - 14.0) * (2.0 * std::f64::consts::PI / 24.0)).cos();
325 let diurnal = 0.35 + 0.65 * solar.max(0.0);
326 let seasonal =
327 1.0 + 0.08 * ((day_of_year - 172.0) * (2.0 * std::f64::consts::PI / 365.25)).cos();
328 let equatorial = 1.0 + 0.35 * (-(mu_deg / 22.0).powi(2)).exp();
329
330 let vertical_tecu = (2.5 + 0.135 * az) * diurnal * seasonal * equatorial;
331 let mapping = single_layer_mapping(el_deg);
332 let stec_tecu = vertical_tecu.max(0.0) * mapping;
333 let delay_per_tecu_m = 40.3e16 / (frequency_hz * frequency_hz);
334 stec_tecu * delay_per_tecu_m
335}
336
337pub fn galileo_effective_ionisation_level(
343 coeffs: &GalileoNequickCoeffs,
344 modified_dip_latitude_deg: f64,
345) -> f64 {
346 if coeffs.ai0 == 0.0 && coeffs.ai1 == 0.0 && coeffs.ai2 == 0.0 {
347 return 63.7;
348 }
349 (coeffs.ai0
350 + coeffs.ai1 * modified_dip_latitude_deg
351 + coeffs.ai2 * modified_dip_latitude_deg * modified_dip_latitude_deg)
352 .clamp(0.0, 400.0)
353}
354
355fn galileo_modified_dip_latitude_deg(lat_deg: f64, lon_deg: f64) -> f64 {
356 let lat = lat_deg * DEG_TO_RAD;
357 let lon = lon_deg * DEG_TO_RAD;
358
359 let pole_lat = 80.37 * DEG_TO_RAD;
362 let pole_lon = -72.62 * DEG_TO_RAD;
363 let dip_lat =
364 (lat.sin() * pole_lat.sin() + lat.cos() * pole_lat.cos() * (lon - pole_lon).cos()).asin();
365 let magnetic_dip = (2.0 * dip_lat.tan()).atan();
366 let denom = lat.cos().max(1.0e-12).sqrt();
367 (magnetic_dip.tan() / denom).atan() * RAD_TO_DEG
368}
369
370fn single_layer_mapping(el_deg: f64) -> f64 {
371 let el_rad = el_deg.max(0.1) * DEG_TO_RAD;
372 let earth_radius_m = MEAN_EARTH_RADIUS_M;
373 let shell_radius_m = earth_radius_m + 450_000.0;
374 let arg = earth_radius_m / shell_radius_m * el_rad.cos();
375 1.0 / (1.0 - arg * arg).max(1.0e-12).sqrt()
376}
377
378pub fn ionex_slant_delay(
394 ionex: &Ionex,
395 receiver: Wgs84Geodetic,
396 elevation_rad: f64,
397 azimuth_rad: f64,
398 epoch_j2000_s: i64,
399 frequency_hz: f64,
400) -> Result<f64> {
401 validate_receiver(receiver)?;
402 validate_finite(elevation_rad, "elevation_rad")?;
403 validate_elevation_rad(elevation_rad, "elevation_rad")?;
404 validate_finite(azimuth_rad, "azimuth_rad")?;
405 validate_frequency(frequency_hz)?;
406
407 let delay_m = ionex_slant_delay_unchecked(
408 ionex,
409 receiver,
410 elevation_rad,
411 azimuth_rad,
412 epoch_j2000_s,
413 frequency_hz,
414 );
415 validate_finite(delay_m, "ionosphere_delay_m")?;
416 Ok(delay_m)
417}
418
419fn ionex_slant_delay_unchecked(
420 ionex: &Ionex,
421 receiver: Wgs84Geodetic,
422 elevation_rad: f64,
423 azimuth_rad: f64,
424 epoch_j2000_s: i64,
425 frequency_hz: f64,
426) -> f64 {
427 slant::slant_delay_components(
428 slant::PierceLineOfSight {
429 lat_rad: receiver.lat_rad,
430 lon_rad: receiver.lon_rad,
431 az_rad: azimuth_rad,
432 el_rad: elevation_rad,
433 },
434 frequency_hz,
435 ionex.base_radius_km(),
436 ionex.shell_height_km(),
437 epoch_j2000_s,
438 slant::VtecGridView {
439 map_epochs: ionex.map_epochs(),
440 maps: ionex.tec_maps(),
441 lat_arr: ionex.lat_nodes_deg(),
442 lon_arr: ionex.lon_nodes_deg(),
443 dlat: ionex.dlat_deg(),
444 dlon: ionex.dlon_deg(),
445 },
446 )
447 .delay_m
448}
449
450fn validate_klobuchar_params(params: &KlobucharParams) -> Result<()> {
451 for (index, &value) in params.alpha.iter().enumerate() {
452 validate_finite(value, if index == 0 { "alpha" } else { "alpha[]" })?;
453 }
454 for (index, &value) in params.beta.iter().enumerate() {
455 validate_finite(value, if index == 0 { "beta" } else { "beta[]" })?;
456 }
457 Ok(())
458}
459
460fn validate_galileo_nequick_coeffs(coeffs: &GalileoNequickCoeffs) -> Result<()> {
461 validate_finite(coeffs.ai0, "ai0")?;
462 validate_finite(coeffs.ai1, "ai1")?;
463 validate_finite(coeffs.ai2, "ai2")
464}
465
466fn validate_galileo_eval(eval: GalileoNequickEval) -> Result<()> {
467 validate_lat_deg(eval.lat_deg, "lat_deg")?;
468 validate_lon_deg(eval.lon_deg, "lon_deg")?;
469 validate_el_deg(eval.el_deg, "el_deg")?;
470 validate_second_of_day(eval.t_gal_s, "t_gal_s")?;
471 validate_finite(eval.day_of_year, "day_of_year")?;
472 if !(1.0..367.0).contains(&eval.day_of_year) {
473 return Err(invalid_input("day_of_year", "out of range"));
474 }
475 validate_frequency(eval.frequency_hz)
476}
477
478fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
479 validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
480 validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
481 validate_finite(receiver.height_m, "receiver.height_m")?;
482 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
483 return Err(invalid_input("receiver.lat_rad", "out of range"));
484 }
485 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
486 return Err(invalid_input("receiver.lon_rad", "out of range"));
487 }
488 Ok(())
489}
490
491fn validate_instant(epoch: Instant) -> Result<()> {
492 match epoch.repr {
493 InstantRepr::JulianDate(split) => {
494 validate_finite(split.jd_whole, "epoch.jd_whole")?;
495 validate_finite(split.fraction, "epoch.fraction")?;
496 if !(-1.0..=1.0).contains(&split.fraction) {
497 return Err(invalid_input("epoch.fraction", "out of range"));
498 }
499 }
500 InstantRepr::Nanos(_) => {}
501 }
502 Ok(())
503}
504
505fn validate_lat_deg(value: f64, field: &'static str) -> Result<()> {
506 validate_finite(value, field)?;
507 if !(-90.0..=90.0).contains(&value) {
508 return Err(invalid_input(field, "out of range"));
509 }
510 Ok(())
511}
512
513fn validate_lon_deg(value: f64, field: &'static str) -> Result<()> {
514 validate_finite(value, field)?;
515 if !(-180.0..=180.0).contains(&value) {
516 return Err(invalid_input(field, "out of range"));
517 }
518 Ok(())
519}
520
521fn validate_elevation_rad(value: f64, field: &'static str) -> Result<()> {
522 if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&value) {
523 return Err(invalid_input(field, "out of range"));
524 }
525 Ok(())
526}
527
528fn validate_el_deg(value: f64, field: &'static str) -> Result<()> {
529 validate_finite(value, field)?;
530 if !(0.0..=90.0).contains(&value) {
531 return Err(invalid_input(field, "out of range"));
532 }
533 Ok(())
534}
535
536fn validate_second_of_day(value: f64, field: &'static str) -> Result<()> {
537 validate_finite(value, field)?;
538 if !(0.0..SECONDS_PER_DAY).contains(&value) {
539 return Err(invalid_input(field, "out of range"));
540 }
541 Ok(())
542}
543
544fn validate_frequency(frequency_hz: f64) -> Result<()> {
545 validate_finite(frequency_hz, "frequency_hz")?;
546 if frequency_hz <= 0.0 {
547 return Err(invalid_input("frequency_hz", "not positive"));
548 }
549 Ok(())
550}
551
552fn validate_finite(value: f64, field: &'static str) -> Result<()> {
553 if value.is_finite() {
554 Ok(())
555 } else {
556 Err(invalid_input(field, "not finite"))
557 }
558}
559
560fn invalid_input(field: &'static str, reason: &'static str) -> Error {
561 Error::InvalidInput(format!("{field} {reason}"))
562}
563
564fn gps_second_of_day(epoch: Instant) -> f64 {
579 second_of_day_from_instant(epoch)
580}
581
582fn fractional_day_of_year(epoch: Instant) -> f64 {
584 fractional_day_of_year_from_instant(epoch)
585}