mod grid;
mod klobuchar;
mod slant;
mod tec_grid;
#[cfg(all(test, sidereon_repo_tests))]
mod tests;
use crate::astro::constants::time::{J2000_JD, SECONDS_PER_DAY, SECONDS_PER_DAY_I64};
use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
use crate::constants::{DEG_TO_RAD, MEAN_EARTH_RADIUS_M, RAD_TO_DEG};
use crate::error::{Error, Result};
use crate::frame::Wgs84Geodetic;
use crate::frequencies::{self, CarrierBand};
use crate::GnssSystem;
pub use grid::Ionex;
pub use tec_grid::{
iono_delay_xyz as regular_tec_grid_delay_xyz, tec_xyz as regular_tec_xyz, TecGrid,
TecGridEpoch, TecGridEvalOptions, TecGridShellGeometry,
};
pub(crate) use klobuchar::klobuchar_l1_components;
pub(crate) fn ionex_epoch_from_j2000_seconds(seconds: i64) -> Instant {
instant_from_j2000_seconds(TimeScale::Utc, seconds)
}
pub(crate) fn instant_from_j2000_seconds(scale: TimeScale, seconds: i64) -> Instant {
let days = seconds.div_euclid(SECONDS_PER_DAY_I64);
let rem_s = seconds.rem_euclid(SECONDS_PER_DAY_I64);
Instant::from_julian_date(
scale,
JulianDateSplit::new(J2000_JD + days as f64, rem_s as f64 / SECONDS_PER_DAY)
.expect("valid split Julian date"),
)
}
pub(crate) fn j2000_seconds_from_instant(epoch: Instant) -> Option<i64> {
match epoch.repr {
InstantRepr::JulianDate(split) => {
let seconds =
(split.jd_whole - J2000_JD) * SECONDS_PER_DAY + split.fraction * SECONDS_PER_DAY;
if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
Some(seconds.round() as i64)
} else {
None
}
}
InstantRepr::Nanos(nanos) => {
let seconds = (nanos as f64 / 1.0e9).round();
if seconds.is_finite() && seconds >= i64::MIN as f64 && seconds <= i64::MAX as f64 {
Some(seconds as i64)
} else {
None
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KlobucharParams {
pub alpha: [f64; 4],
pub beta: [f64; 4],
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GalileoNequickCoeffs {
pub ai0: f64,
pub ai1: f64,
pub ai2: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GalileoNequickEval {
pub lat_deg: f64,
pub lon_deg: f64,
pub el_deg: f64,
pub t_gal_s: f64,
pub day_of_year: f64,
pub frequency_hz: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum IonoModel {
Klobuchar(KlobucharParams),
GalileoNequickG(GalileoNequickCoeffs),
}
pub fn ionosphere_delay(
receiver: Wgs84Geodetic,
elevation_rad: f64,
azimuth_rad: f64,
epoch: Instant,
frequency_hz: f64,
model: &IonoModel,
) -> Result<f64> {
validate_receiver(receiver)?;
validate_finite(elevation_rad, "elevation_rad")?;
validate_elevation_rad(elevation_rad, "elevation_rad")?;
validate_finite(azimuth_rad, "azimuth_rad")?;
validate_instant(epoch)?;
validate_frequency(frequency_hz)?;
match model {
IonoModel::Klobuchar(params) => klobuchar(
params,
receiver,
elevation_rad,
azimuth_rad,
epoch,
frequency_hz,
),
IonoModel::GalileoNequickG(coeffs) => galileo_nequick_g_native(
coeffs,
GalileoNequickEval {
lat_deg: receiver.lat_rad * RAD_TO_DEG,
lon_deg: receiver.lon_rad * RAD_TO_DEG,
el_deg: elevation_rad * RAD_TO_DEG,
t_gal_s: gps_second_of_day(epoch),
day_of_year: fractional_day_of_year(epoch),
frequency_hz,
},
),
}
}
pub fn klobuchar(
params: &KlobucharParams,
receiver: Wgs84Geodetic,
elevation_rad: f64,
azimuth_rad: f64,
epoch: Instant,
frequency_hz: f64,
) -> Result<f64> {
validate_receiver(receiver)?;
validate_finite(elevation_rad, "elevation_rad")?;
validate_elevation_rad(elevation_rad, "elevation_rad")?;
validate_finite(azimuth_rad, "azimuth_rad")?;
validate_instant(epoch)?;
klobuchar_native(
params,
receiver.lat_rad * RAD_TO_DEG,
receiver.lon_rad * RAD_TO_DEG,
azimuth_rad * RAD_TO_DEG,
elevation_rad * RAD_TO_DEG,
gps_second_of_day(epoch),
frequency_hz,
)
}
pub fn klobuchar_native(
params: &KlobucharParams,
lat_deg: f64,
lon_deg: f64,
az_deg: f64,
el_deg: f64,
t_gps_s: f64,
frequency_hz: f64,
) -> Result<f64> {
validate_klobuchar_params(params)?;
validate_lat_deg(lat_deg, "lat_deg")?;
validate_lon_deg(lon_deg, "lon_deg")?;
validate_finite(az_deg, "az_deg")?;
validate_el_deg(el_deg, "el_deg")?;
validate_second_of_day(t_gps_s, "t_gps_s")?;
validate_frequency(frequency_hz)?;
let delay_m = klobuchar_native_unchecked(
params,
lat_deg,
lon_deg,
az_deg,
el_deg,
t_gps_s,
frequency_hz,
);
validate_finite(delay_m, "ionosphere_delay_m")?;
Ok(delay_m)
}
pub(crate) fn klobuchar_native_unchecked(
params: &KlobucharParams,
lat_deg: f64,
lon_deg: f64,
az_deg: f64,
el_deg: f64,
t_gps_s: f64,
frequency_hz: f64,
) -> f64 {
let c = klobuchar_l1_components(
lat_deg,
lon_deg,
az_deg,
el_deg,
t_gps_s,
params.alpha,
params.beta,
);
let f_l1_hz = frequencies::frequency_hz(GnssSystem::Gps, CarrierBand::L1)
.expect("canonical GPS L1 carrier exists");
let ratio = f_l1_hz / frequency_hz;
c.delay_l1_m * (ratio * ratio)
}
pub fn galileo_nequick_g_native(
coeffs: &GalileoNequickCoeffs,
eval: GalileoNequickEval,
) -> Result<f64> {
validate_galileo_nequick_coeffs(coeffs)?;
validate_galileo_eval(eval)?;
let delay_m = galileo_nequick_g_native_unchecked(coeffs, eval);
validate_finite(delay_m, "ionosphere_delay_m")?;
Ok(delay_m)
}
pub(crate) fn galileo_nequick_g_native_unchecked(
coeffs: &GalileoNequickCoeffs,
eval: GalileoNequickEval,
) -> f64 {
let GalileoNequickEval {
lat_deg,
lon_deg,
el_deg,
t_gal_s,
day_of_year,
frequency_hz,
} = eval;
let mu_deg = galileo_modified_dip_latitude_deg(lat_deg, lon_deg);
let az = galileo_effective_ionisation_level(coeffs, mu_deg);
let local_time_h = (t_gal_s / 3600.0 + lon_deg / 15.0).rem_euclid(24.0);
let solar = 0.5 + 0.5 * ((local_time_h - 14.0) * (2.0 * std::f64::consts::PI / 24.0)).cos();
let diurnal = 0.35 + 0.65 * solar.max(0.0);
let seasonal =
1.0 + 0.08 * ((day_of_year - 172.0) * (2.0 * std::f64::consts::PI / 365.25)).cos();
let equatorial = 1.0 + 0.35 * (-(mu_deg / 22.0).powi(2)).exp();
let vertical_tecu = (2.5 + 0.135 * az) * diurnal * seasonal * equatorial;
let mapping = single_layer_mapping(el_deg);
let stec_tecu = vertical_tecu.max(0.0) * mapping;
let delay_per_tecu_m = 40.3e16 / (frequency_hz * frequency_hz);
stec_tecu * delay_per_tecu_m
}
pub fn galileo_effective_ionisation_level(
coeffs: &GalileoNequickCoeffs,
modified_dip_latitude_deg: f64,
) -> f64 {
if coeffs.ai0 == 0.0 && coeffs.ai1 == 0.0 && coeffs.ai2 == 0.0 {
return 63.7;
}
(coeffs.ai0
+ coeffs.ai1 * modified_dip_latitude_deg
+ coeffs.ai2 * modified_dip_latitude_deg * modified_dip_latitude_deg)
.clamp(0.0, 400.0)
}
fn galileo_modified_dip_latitude_deg(lat_deg: f64, lon_deg: f64) -> f64 {
let lat = lat_deg * DEG_TO_RAD;
let lon = lon_deg * DEG_TO_RAD;
let pole_lat = 80.37 * DEG_TO_RAD;
let pole_lon = -72.62 * DEG_TO_RAD;
let dip_lat =
(lat.sin() * pole_lat.sin() + lat.cos() * pole_lat.cos() * (lon - pole_lon).cos()).asin();
let magnetic_dip = (2.0 * dip_lat.tan()).atan();
let denom = lat.cos().max(1.0e-12).sqrt();
(magnetic_dip.tan() / denom).atan() * RAD_TO_DEG
}
fn single_layer_mapping(el_deg: f64) -> f64 {
let el_rad = el_deg.max(0.1) * DEG_TO_RAD;
let earth_radius_m = MEAN_EARTH_RADIUS_M;
let shell_radius_m = earth_radius_m + 450_000.0;
let arg = earth_radius_m / shell_radius_m * el_rad.cos();
1.0 / (1.0 - arg * arg).max(1.0e-12).sqrt()
}
pub fn ionex_slant_delay(
ionex: &Ionex,
receiver: Wgs84Geodetic,
elevation_rad: f64,
azimuth_rad: f64,
epoch_j2000_s: i64,
frequency_hz: f64,
) -> Result<f64> {
validate_receiver(receiver)?;
validate_finite(elevation_rad, "elevation_rad")?;
validate_elevation_rad(elevation_rad, "elevation_rad")?;
validate_finite(azimuth_rad, "azimuth_rad")?;
validate_frequency(frequency_hz)?;
let delay_m = ionex_slant_delay_unchecked(
ionex,
receiver,
elevation_rad,
azimuth_rad,
epoch_j2000_s,
frequency_hz,
);
validate_finite(delay_m, "ionosphere_delay_m")?;
Ok(delay_m)
}
fn ionex_slant_delay_unchecked(
ionex: &Ionex,
receiver: Wgs84Geodetic,
elevation_rad: f64,
azimuth_rad: f64,
epoch_j2000_s: i64,
frequency_hz: f64,
) -> f64 {
slant::slant_delay_components(
slant::PierceLineOfSight {
lat_rad: receiver.lat_rad,
lon_rad: receiver.lon_rad,
az_rad: azimuth_rad,
el_rad: elevation_rad,
},
frequency_hz,
ionex.base_radius_km(),
ionex.shell_height_km(),
epoch_j2000_s,
slant::VtecGridView {
map_epochs: ionex.map_epochs(),
maps: ionex.tec_maps(),
lat_arr: ionex.lat_nodes_deg(),
lon_arr: ionex.lon_nodes_deg(),
dlat: ionex.dlat_deg(),
dlon: ionex.dlon_deg(),
},
)
.delay_m
}
fn validate_klobuchar_params(params: &KlobucharParams) -> Result<()> {
for (index, &value) in params.alpha.iter().enumerate() {
validate_finite(value, if index == 0 { "alpha" } else { "alpha[]" })?;
}
for (index, &value) in params.beta.iter().enumerate() {
validate_finite(value, if index == 0 { "beta" } else { "beta[]" })?;
}
Ok(())
}
fn validate_galileo_nequick_coeffs(coeffs: &GalileoNequickCoeffs) -> Result<()> {
validate_finite(coeffs.ai0, "ai0")?;
validate_finite(coeffs.ai1, "ai1")?;
validate_finite(coeffs.ai2, "ai2")
}
fn validate_galileo_eval(eval: GalileoNequickEval) -> Result<()> {
validate_lat_deg(eval.lat_deg, "lat_deg")?;
validate_lon_deg(eval.lon_deg, "lon_deg")?;
validate_el_deg(eval.el_deg, "el_deg")?;
validate_second_of_day(eval.t_gal_s, "t_gal_s")?;
validate_finite(eval.day_of_year, "day_of_year")?;
if !(1.0..367.0).contains(&eval.day_of_year) {
return Err(invalid_input("day_of_year", "out of range"));
}
validate_frequency(eval.frequency_hz)
}
fn validate_receiver(receiver: Wgs84Geodetic) -> Result<()> {
validate_finite(receiver.lat_rad, "receiver.lat_rad")?;
validate_finite(receiver.lon_rad, "receiver.lon_rad")?;
validate_finite(receiver.height_m, "receiver.height_m")?;
if !(-core::f64::consts::FRAC_PI_2..=core::f64::consts::FRAC_PI_2).contains(&receiver.lat_rad) {
return Err(invalid_input("receiver.lat_rad", "out of range"));
}
if !(-core::f64::consts::PI..=core::f64::consts::PI).contains(&receiver.lon_rad) {
return Err(invalid_input("receiver.lon_rad", "out of range"));
}
Ok(())
}
fn validate_instant(epoch: Instant) -> Result<()> {
match epoch.repr {
InstantRepr::JulianDate(split) => {
validate_finite(split.jd_whole, "epoch.jd_whole")?;
validate_finite(split.fraction, "epoch.fraction")?;
if !(-1.0..=1.0).contains(&split.fraction) {
return Err(invalid_input("epoch.fraction", "out of range"));
}
}
InstantRepr::Nanos(_) => {}
}
Ok(())
}
fn validate_lat_deg(value: f64, field: &'static str) -> Result<()> {
validate_finite(value, field)?;
if !(-90.0..=90.0).contains(&value) {
return Err(invalid_input(field, "out of range"));
}
Ok(())
}
fn validate_lon_deg(value: f64, field: &'static str) -> Result<()> {
validate_finite(value, field)?;
if !(-180.0..=180.0).contains(&value) {
return Err(invalid_input(field, "out of range"));
}
Ok(())
}
fn validate_elevation_rad(value: f64, field: &'static str) -> Result<()> {
if !(0.0..=core::f64::consts::FRAC_PI_2).contains(&value) {
return Err(invalid_input(field, "out of range"));
}
Ok(())
}
fn validate_el_deg(value: f64, field: &'static str) -> Result<()> {
validate_finite(value, field)?;
if !(0.0..=90.0).contains(&value) {
return Err(invalid_input(field, "out of range"));
}
Ok(())
}
fn validate_second_of_day(value: f64, field: &'static str) -> Result<()> {
validate_finite(value, field)?;
if !(0.0..86_400.0).contains(&value) {
return Err(invalid_input(field, "out of range"));
}
Ok(())
}
fn validate_frequency(frequency_hz: f64) -> Result<()> {
validate_finite(frequency_hz, "frequency_hz")?;
if frequency_hz <= 0.0 {
return Err(invalid_input("frequency_hz", "not positive"));
}
Ok(())
}
fn validate_finite(value: f64, field: &'static str) -> Result<()> {
if value.is_finite() {
Ok(())
} else {
Err(invalid_input(field, "not finite"))
}
}
fn invalid_input(field: &'static str, reason: &'static str) -> Error {
Error::InvalidInput(format!("{field} {reason}"))
}
fn gps_second_of_day(epoch: Instant) -> f64 {
match epoch.repr {
InstantRepr::JulianDate(jd) => {
let from_midnight = jd.jd_whole + 0.5 + jd.fraction;
let day_fraction = from_midnight - from_midnight.floor();
day_fraction * SECONDS_PER_DAY
}
InstantRepr::Nanos(nanos) => {
let ns_per_day: i128 = 86_400 * 1_000_000_000;
let mut rem = nanos % ns_per_day;
if rem < 0 {
rem += ns_per_day;
}
rem as f64 / 1.0e9
}
}
}
fn fractional_day_of_year(epoch: Instant) -> f64 {
let jd = match epoch.repr {
InstantRepr::JulianDate(split) => split.jd_whole + split.fraction,
InstantRepr::Nanos(nanos) => (nanos as f64) / 1.0e9 / SECONDS_PER_DAY + J2000_JD,
};
let jd_midnight = jd + 0.5;
let day_floor = jd_midnight.floor();
let day_fraction = jd_midnight - day_floor;
let (year, month, day) = civil_from_jd_floor(day_floor);
let doy_integer = day_of_year(year, month, day);
doy_integer as f64 + day_fraction
}
fn civil_from_jd_floor(day_floor: f64) -> (i64, i64, i64) {
let jdn = day_floor as i64;
let l = jdn + 68_569;
let n = (4 * l) / 146_097;
let l = l - (146_097 * n + 3) / 4;
let i = (4_000 * (l + 1)) / 1_461_001;
let l = l - (1_461 * i) / 4 + 31;
let j = (80 * l) / 2_447;
let day = l - (2_447 * j) / 80;
let l = j / 11;
let month = j + 2 - 12 * l;
let year = 100 * (n - 49) + i + l;
(year, month, day)
}
fn day_of_year(year: i64, month: i64, day: i64) -> i64 {
const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
let mut doy = CUM[(month - 1) as usize] + day;
if leap && month > 2 {
doy += 1;
}
doy
}