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
46pub(crate) use klobuchar::klobuchar_l1_components;
47pub(crate) use slant::pierce_point;
48
49pub(crate) fn ionex_epoch_from_j2000_seconds(seconds: i64) -> Instant {
50 instant_from_j2000_seconds(TimeScale::Utc, seconds)
51}
52
53pub(crate) fn instant_from_j2000_seconds(scale: TimeScale, seconds: i64) -> Instant {
54 let (jd_whole, fraction) = split_julian_date_from_j2000_seconds(seconds);
55 Instant::from_julian_date(
56 scale,
57 JulianDateSplit::new(jd_whole, fraction).expect("valid split Julian date"),
58 )
59}
60
61pub(crate) fn j2000_seconds_from_instant(epoch: Instant) -> Option<i64> {
62 match epoch.repr {
63 InstantRepr::JulianDate(split) => {
64 let seconds = j2000_seconds_from_split(split.jd_whole, split.fraction);
65 if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
66 Some(seconds.round() as i64)
67 } else {
68 None
69 }
70 }
71 InstantRepr::Nanos(nanos) => {
72 let seconds = (nanos as f64 / 1.0e9).round();
73 if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
74 Some(seconds as i64)
75 } else {
76 None
77 }
78 }
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct KlobucharParams {
90 pub alpha: [f64; 4],
92 pub beta: [f64; 4],
94}
95
96#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct GalileoNequickCoeffs {
103 pub ai0: f64,
105 pub ai1: f64,
107 pub ai2: f64,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq)]
113pub struct GalileoNequickEval {
114 pub lat_deg: f64,
116 pub lon_deg: f64,
118 pub el_deg: f64,
120 pub t_gal_s: f64,
122 pub day_of_year: f64,
124 pub frequency_hz: f64,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq)]
130pub enum IonoModel {
131 Klobuchar(KlobucharParams),
133 GalileoNequickG(GalileoNequickCoeffs),
135}
136
137pub fn ionosphere_delay(
143 receiver: Wgs84Geodetic,
144 elevation_rad: f64,
145 azimuth_rad: f64,
146 epoch: Instant,
147 frequency_hz: f64,
148 model: &IonoModel,
149) -> Result<f64> {
150 validate_receiver(receiver)?;
151 validate_finite(elevation_rad, "elevation_rad")?;
152 validate_elevation_rad(elevation_rad, "elevation_rad")?;
153 validate_finite(azimuth_rad, "azimuth_rad")?;
154 validate_instant(epoch)?;
155 validate_frequency(frequency_hz)?;
156
157 match model {
158 IonoModel::Klobuchar(params) => klobuchar(
159 params,
160 receiver,
161 elevation_rad,
162 azimuth_rad,
163 epoch,
164 frequency_hz,
165 ),
166 IonoModel::GalileoNequickG(coeffs) => galileo_nequick_g_native(
167 coeffs,
168 GalileoNequickEval {
169 lat_deg: receiver.lat_rad * RAD_TO_DEG,
170 lon_deg: receiver.lon_rad * RAD_TO_DEG,
171 el_deg: elevation_rad * RAD_TO_DEG,
172 t_gal_s: gps_second_of_day(epoch),
173 day_of_year: fractional_day_of_year(epoch),
174 frequency_hz,
175 },
176 ),
177 }
178}
179
180pub fn klobuchar(
197 params: &KlobucharParams,
198 receiver: Wgs84Geodetic,
199 elevation_rad: f64,
200 azimuth_rad: f64,
201 epoch: Instant,
202 frequency_hz: f64,
203) -> Result<f64> {
204 validate_receiver(receiver)?;
205 validate_finite(elevation_rad, "elevation_rad")?;
206 validate_elevation_rad(elevation_rad, "elevation_rad")?;
207 validate_finite(azimuth_rad, "azimuth_rad")?;
208 validate_instant(epoch)?;
209
210 klobuchar_native(
211 params,
212 receiver.lat_rad * RAD_TO_DEG,
213 receiver.lon_rad * RAD_TO_DEG,
214 azimuth_rad * RAD_TO_DEG,
215 elevation_rad * RAD_TO_DEG,
216 gps_second_of_day(epoch),
217 frequency_hz,
218 )
219}
220
221pub fn klobuchar_native(
232 params: &KlobucharParams,
233 lat_deg: f64,
234 lon_deg: f64,
235 az_deg: f64,
236 el_deg: f64,
237 t_gps_s: f64,
238 frequency_hz: f64,
239) -> Result<f64> {
240 validate_klobuchar_params(params)?;
241 validate_lat_deg(lat_deg, "lat_deg")?;
242 validate_lon_deg(lon_deg, "lon_deg")?;
243 validate_finite(az_deg, "az_deg")?;
244 validate_el_deg(el_deg, "el_deg")?;
245 validate_second_of_day(t_gps_s, "t_gps_s")?;
246 validate_frequency(frequency_hz)?;
247
248 let delay_m = klobuchar_native_unchecked(
249 params,
250 lat_deg,
251 lon_deg,
252 az_deg,
253 el_deg,
254 t_gps_s,
255 frequency_hz,
256 );
257 validate_finite(delay_m, "ionosphere_delay_m")?;
258 Ok(delay_m)
259}
260
261pub(crate) fn klobuchar_native_unchecked(
262 params: &KlobucharParams,
263 lat_deg: f64,
264 lon_deg: f64,
265 az_deg: f64,
266 el_deg: f64,
267 t_gps_s: f64,
268 frequency_hz: f64,
269) -> f64 {
270 let c = klobuchar_l1_components(
271 lat_deg,
272 lon_deg,
273 az_deg,
274 el_deg,
275 t_gps_s,
276 params.alpha,
277 params.beta,
278 );
279
280 let f_l1_hz = frequencies::frequency_hz(GnssSystem::Gps, CarrierBand::L1)
281 .expect("canonical GPS L1 carrier exists");
282 let ratio = f_l1_hz / frequency_hz;
283 c.delay_l1_m * (ratio * ratio)
284}
285
286pub fn galileo_nequick_g_native(
300 coeffs: &GalileoNequickCoeffs,
301 eval: GalileoNequickEval,
302) -> Result<f64> {
303 validate_galileo_nequick_coeffs(coeffs)?;
304 validate_galileo_eval(eval)?;
305
306 let delay_m = galileo_nequick_g_native_unchecked(coeffs, eval);
307 validate_finite(delay_m, "ionosphere_delay_m")?;
308 Ok(delay_m)
309}
310
311pub(crate) fn galileo_nequick_g_native_unchecked(
312 coeffs: &GalileoNequickCoeffs,
313 eval: GalileoNequickEval,
314) -> f64 {
315 let GalileoNequickEval {
316 lat_deg,
317 lon_deg,
318 el_deg,
319 t_gal_s,
320 day_of_year,
321 frequency_hz,
322 } = eval;
323 let mu_deg = galileo_modified_dip_latitude_deg(lat_deg, lon_deg);
324 let az = galileo_effective_ionisation_level(coeffs, mu_deg);
325
326 let local_time_h = (t_gal_s / SECONDS_PER_HOUR + lon_deg / 15.0).rem_euclid(24.0);
327 let solar = 0.5 + 0.5 * ((local_time_h - 14.0) * (2.0 * std::f64::consts::PI / 24.0)).cos();
328 let diurnal = 0.35 + 0.65 * solar.max(0.0);
329 let seasonal = 1.0
330 + 0.08
331 * ((day_of_year - 172.0) * (2.0 * std::f64::consts::PI / DAYS_PER_JULIAN_YEAR)).cos();
332 let equatorial = 1.0 + 0.35 * (-(mu_deg / 22.0).powi(2)).exp();
333
334 let vertical_tecu = (2.5 + 0.135 * az) * diurnal * seasonal * equatorial;
335 let mapping = single_layer_mapping(el_deg);
336 let stec_tecu = vertical_tecu.max(0.0) * mapping;
337 let delay_per_tecu_m = 40.3e16 / (frequency_hz * frequency_hz);
338 stec_tecu * delay_per_tecu_m
339}
340
341pub fn galileo_effective_ionisation_level(
347 coeffs: &GalileoNequickCoeffs,
348 modified_dip_latitude_deg: f64,
349) -> f64 {
350 if coeffs.ai0 == 0.0 && coeffs.ai1 == 0.0 && coeffs.ai2 == 0.0 {
351 return 63.7;
352 }
353 (coeffs.ai0
354 + coeffs.ai1 * modified_dip_latitude_deg
355 + coeffs.ai2 * modified_dip_latitude_deg * modified_dip_latitude_deg)
356 .clamp(0.0, 400.0)
357}
358
359fn galileo_modified_dip_latitude_deg(lat_deg: f64, lon_deg: f64) -> f64 {
360 let lat = lat_deg * DEG_TO_RAD;
361 let lon = lon_deg * DEG_TO_RAD;
362
363 let pole_lat = 80.37 * DEG_TO_RAD;
366 let pole_lon = -72.62 * DEG_TO_RAD;
367 let dip_lat =
368 (lat.sin() * pole_lat.sin() + lat.cos() * pole_lat.cos() * (lon - pole_lon).cos()).asin();
369 let magnetic_dip = (2.0 * dip_lat.tan()).atan();
370 let denom = lat.cos().max(1.0e-12).sqrt();
371 (magnetic_dip.tan() / denom).atan() * RAD_TO_DEG
372}
373
374fn single_layer_mapping(el_deg: f64) -> f64 {
375 let el_rad = el_deg.max(0.1) * DEG_TO_RAD;
376 let earth_radius_m = MEAN_EARTH_RADIUS_M;
377 let shell_radius_m = earth_radius_m + 450_000.0;
378 let arg = earth_radius_m / shell_radius_m * el_rad.cos();
379 1.0 / (1.0 - arg * arg).max(1.0e-12).sqrt()
380}
381
382pub fn ionex_slant_delay(
398 ionex: &Ionex,
399 receiver: Wgs84Geodetic,
400 elevation_rad: f64,
401 azimuth_rad: f64,
402 epoch_j2000_s: i64,
403 frequency_hz: f64,
404) -> Result<f64> {
405 validate_receiver(receiver)?;
406 validate_finite(elevation_rad, "elevation_rad")?;
407 validate_elevation_rad(elevation_rad, "elevation_rad")?;
408 validate_finite(azimuth_rad, "azimuth_rad")?;
409 validate_frequency(frequency_hz)?;
410
411 let delay_m = ionex_slant_delay_unchecked(
412 ionex,
413 receiver,
414 elevation_rad,
415 azimuth_rad,
416 epoch_j2000_s,
417 frequency_hz,
418 );
419 validate_finite(delay_m, "ionosphere_delay_m")?;
420 Ok(delay_m)
421}
422
423#[derive(Debug, Clone, Copy, PartialEq)]
425pub struct IonexSlantRequest {
426 pub receiver: Wgs84Geodetic,
428 pub elevation_rad: f64,
430 pub azimuth_rad: f64,
432 pub epoch_j2000_s: i64,
434 pub frequency_hz: f64,
436}
437
438pub fn ionex_slant_delays(
445 ionex: &Ionex,
446 requests: &[IonexSlantRequest],
447 out: &mut [f64],
448) -> Result<()> {
449 if out.len() != requests.len() {
450 return Err(Error::InvalidInput(format!(
451 "IONEX slant output length {} does not match request length {}",
452 out.len(),
453 requests.len()
454 )));
455 }
456
457 let grid = ionex_vtec_grid_view(ionex);
458 for (request, output) in requests.iter().zip(out.iter_mut()) {
459 validate_receiver(request.receiver)?;
460 validate_finite(request.elevation_rad, "elevation_rad")?;
461 validate_elevation_rad(request.elevation_rad, "elevation_rad")?;
462 validate_finite(request.azimuth_rad, "azimuth_rad")?;
463 validate_frequency(request.frequency_hz)?;
464
465 let delay_m = ionex_slant_delay_unchecked_with_grid(
466 ionex,
467 request.receiver,
468 request.elevation_rad,
469 request.azimuth_rad,
470 request.epoch_j2000_s,
471 request.frequency_hz,
472 grid,
473 );
474 validate_finite(delay_m, "ionosphere_delay_m")?;
475 debug_assert!(delay_m.is_finite());
476 *output = delay_m;
477 }
478 Ok(())
479}
480
481fn ionex_slant_delay_unchecked(
482 ionex: &Ionex,
483 receiver: Wgs84Geodetic,
484 elevation_rad: f64,
485 azimuth_rad: f64,
486 epoch_j2000_s: i64,
487 frequency_hz: f64,
488) -> f64 {
489 ionex_slant_delay_unchecked_with_grid(
490 ionex,
491 receiver,
492 elevation_rad,
493 azimuth_rad,
494 epoch_j2000_s,
495 frequency_hz,
496 ionex_vtec_grid_view(ionex),
497 )
498}
499
500fn ionex_slant_delay_unchecked_with_grid(
501 ionex: &Ionex,
502 receiver: Wgs84Geodetic,
503 elevation_rad: f64,
504 azimuth_rad: f64,
505 epoch_j2000_s: i64,
506 frequency_hz: f64,
507 grid: slant::VtecGridView<'_>,
508) -> f64 {
509 slant::slant_delay_components(
510 slant::PierceLineOfSight {
511 lat_rad: receiver.lat_rad,
512 lon_rad: receiver.lon_rad,
513 az_rad: azimuth_rad,
514 el_rad: elevation_rad,
515 },
516 frequency_hz,
517 ionex.base_radius_km(),
518 ionex.shell_height_km(),
519 epoch_j2000_s,
520 grid,
521 )
522 .delay_m
523}
524
525fn ionex_vtec_grid_view(ionex: &Ionex) -> slant::VtecGridView<'_> {
526 slant::VtecGridView {
527 map_epochs: ionex.map_epochs(),
528 maps: ionex.tec_maps(),
529 lat_arr: ionex.lat_nodes_deg(),
530 lon_arr: ionex.lon_nodes_deg(),
531 dlat: ionex.dlat_deg(),
532 dlon: ionex.dlon_deg(),
533 }
534}
535
536fn validate_klobuchar_params(params: &KlobucharParams) -> Result<()> {
537 for (index, &value) in params.alpha.iter().enumerate() {
538 validate_finite(value, if index == 0 { "alpha" } else { "alpha[]" })?;
539 }
540 for (index, &value) in params.beta.iter().enumerate() {
541 validate_finite(value, if index == 0 { "beta" } else { "beta[]" })?;
542 }
543 Ok(())
544}
545
546fn validate_galileo_nequick_coeffs(coeffs: &GalileoNequickCoeffs) -> Result<()> {
547 validate_finite(coeffs.ai0, "ai0")?;
548 validate_finite(coeffs.ai1, "ai1")?;
549 validate_finite(coeffs.ai2, "ai2")
550}
551
552fn validate_galileo_eval(eval: GalileoNequickEval) -> Result<()> {
553 validate_lat_deg(eval.lat_deg, "lat_deg")?;
554 validate_lon_deg(eval.lon_deg, "lon_deg")?;
555 validate_el_deg(eval.el_deg, "el_deg")?;
556 validate_second_of_day(eval.t_gal_s, "t_gal_s")?;
557 validate_finite(eval.day_of_year, "day_of_year")?;
558 if !(1.0..367.0).contains(&eval.day_of_year) {
559 return Err(invalid_input("day_of_year", "out of range"));
560 }
561 validate_frequency(eval.frequency_hz)
562}
563
564pub(crate) fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
565 validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
566 validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
567 validate_finite(receiver.height_m, "receiver.height_m")?;
568 if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
569 return Err(invalid_input("receiver.lat_rad", "out of range"));
570 }
571 if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
572 return Err(invalid_input("receiver.lon_rad", "out of range"));
573 }
574 Ok(())
575}
576
577fn validate_instant(epoch: Instant) -> Result<()> {
578 match epoch.repr {
579 InstantRepr::JulianDate(split) => {
580 validate_finite(split.jd_whole, "epoch.jd_whole")?;
581 validate_finite(split.fraction, "epoch.fraction")?;
582 if !(-1.0..=1.0).contains(&split.fraction) {
583 return Err(invalid_input("epoch.fraction", "out of range"));
584 }
585 }
586 InstantRepr::Nanos(_) => {}
587 }
588 Ok(())
589}
590
591fn validate_lat_deg(value: f64, field: &'static str) -> Result<()> {
592 validate_finite(value, field)?;
593 if !(-90.0..=90.0).contains(&value) {
594 return Err(invalid_input(field, "out of range"));
595 }
596 Ok(())
597}
598
599fn validate_lon_deg(value: f64, field: &'static str) -> Result<()> {
600 validate_finite(value, field)?;
601 if !(-180.0..=180.0).contains(&value) {
602 return Err(invalid_input(field, "out of range"));
603 }
604 Ok(())
605}
606
607pub(crate) fn validate_elevation_rad(value: f64, field: &'static str) -> Result<()> {
608 if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&value) {
609 return Err(invalid_input(field, "out of range"));
610 }
611 Ok(())
612}
613
614fn validate_el_deg(value: f64, field: &'static str) -> Result<()> {
615 validate_finite(value, field)?;
616 if !(0.0..=90.0).contains(&value) {
617 return Err(invalid_input(field, "out of range"));
618 }
619 Ok(())
620}
621
622fn validate_second_of_day(value: f64, field: &'static str) -> Result<()> {
623 validate_finite(value, field)?;
624 if !(0.0..SECONDS_PER_DAY).contains(&value) {
625 return Err(invalid_input(field, "out of range"));
626 }
627 Ok(())
628}
629
630pub(crate) fn validate_frequency(frequency_hz: f64) -> Result<()> {
631 validate_finite(frequency_hz, "frequency_hz")?;
632 if frequency_hz <= 0.0 {
633 return Err(invalid_input("frequency_hz", "not positive"));
634 }
635 Ok(())
636}
637
638fn validate_finite(value: f64, field: &'static str) -> Result<()> {
639 if value.is_finite() {
640 Ok(())
641 } else {
642 Err(invalid_input(field, "not finite"))
643 }
644}
645
646fn invalid_input(field: &'static str, reason: &'static str) -> Error {
647 Error::InvalidInput(format!("{field} {reason}"))
648}
649
650fn gps_second_of_day(epoch: Instant) -> f64 {
665 second_of_day_from_instant(epoch)
666}
667
668fn fractional_day_of_year(epoch: Instant) -> f64 {
670 fractional_day_of_year_from_instant(epoch)
671}