pub const GPS_L1_HZ: f64 = 1_575.42e6;
pub const GPS_L2_HZ: f64 = 1_227.60e6;
pub const TECU: f64 = 1.0e16;
pub fn vtec_to_delay_m(vtec_tecu: f64, frequency_hz: f64) -> f64 {
40.3 * vtec_tecu * TECU / (frequency_hz * frequency_hz)
}
#[derive(Clone, Debug)]
pub struct TecGrid {
pub lat0_deg: f64,
pub lon0_deg: f64,
pub dlat_deg: f64,
pub dlon_deg: f64,
pub n_lat: usize,
pub n_lon: usize,
pub vtec: Vec<f64>,
}
impl TecGrid {
pub fn node(&self, i: usize, j: usize) -> f64 {
self.vtec[i * self.n_lon + j]
}
pub fn vtec_at(&self, lat_deg: f64, lon_deg: f64) -> f64 {
let (i0, fi) = cell(lat_deg, self.lat0_deg, self.dlat_deg, self.n_lat);
let (j0, fj) = cell(lon_deg, self.lon0_deg, self.dlon_deg, self.n_lon);
let v00 = self.node(i0, j0);
let v01 = self.node(i0, j0 + 1);
let v10 = self.node(i0 + 1, j0);
let v11 = self.node(i0 + 1, j0 + 1);
(1.0 - fi) * (1.0 - fj) * v00
+ (1.0 - fi) * fj * v01
+ fi * (1.0 - fj) * v10
+ fi * fj * v11
}
pub fn delay_at(&self, lat_deg: f64, lon_deg: f64, frequency_hz: f64) -> f64 {
vtec_to_delay_m(self.vtec_at(lat_deg, lon_deg), frequency_hz)
}
}
pub(crate) fn cell(x: f64, x0: f64, dx: f64, n: usize) -> (usize, f64) {
if n < 2 {
return (0, 0.0);
}
let t = (x - x0) / dx;
let i = t.floor();
let i0 = (i as isize).clamp(0, n as isize - 2) as usize;
let f = (t - i0 as f64).clamp(0.0, 1.0);
(i0, f)
}
#[derive(Clone, Debug)]
pub struct IonexMap {
pub epoch_sod: f64,
pub grid: TecGrid,
}
fn ionex_label(line: &str) -> &str {
if line.len() >= 60 {
line[60..].trim()
} else {
""
}
}
fn nums_before_label(line: &str) -> Vec<f64> {
let body = &line[..line.len().min(60)];
body.split_whitespace()
.filter_map(|t| t.parse().ok())
.collect()
}
pub fn parse_ionex(text: &str) -> Option<Vec<IonexMap>> {
let mut lat1 = None;
let mut lat2 = None;
let mut dlat = None;
let mut lon1 = None;
let mut lon2 = None;
let mut dlon = None;
let mut exponent: i32 = 0;
let mut lines = text.lines();
for line in lines.by_ref() {
let nums = nums_before_label(line);
match ionex_label(line) {
"LAT1 / LAT2 / DLAT" => {
lat1 = nums.first().copied();
lat2 = nums.get(1).copied();
dlat = nums.get(2).copied();
}
"LON1 / LON2 / DLON" => {
lon1 = nums.first().copied();
lon2 = nums.get(1).copied();
dlon = nums.get(2).copied();
}
"EXPONENT" => exponent = nums.first().copied().unwrap_or(0.0) as i32,
"END OF HEADER" => break,
_ => {}
}
}
let (lat1, lat2, dlat, lon1, lon2, dlon) = (lat1?, lat2?, dlat?, lon1?, lon2?, dlon?);
if dlat == 0.0 || dlon == 0.0 {
return None;
}
let n_lat = ((lat2 - lat1) / dlat).round() as i64 + 1;
let n_lon = ((lon2 - lon1) / dlon).round() as i64 + 1;
if n_lat < 2 || n_lon < 2 {
return None;
}
let (n_lat, n_lon) = (n_lat as usize, n_lon as usize);
let scale = 10f64.powi(exponent);
let mut maps = Vec::new();
let mut in_map = false;
let mut in_band = false;
let mut epoch_sod = 0.0;
let mut vals: Vec<f64> = Vec::new();
for line in lines {
match ionex_label(line) {
"START OF TEC MAP" => {
in_map = true;
in_band = false;
vals.clear();
}
"EPOCH OF CURRENT MAP" => {
let e = nums_before_label(line);
let h = e.get(3).copied().unwrap_or(0.0);
let m = e.get(4).copied().unwrap_or(0.0);
let s = e.get(5).copied().unwrap_or(0.0);
epoch_sod = h * 3600.0 + m * 60.0 + s;
}
"LAT/LON1/LON2/DLON/H" => in_band = true,
"END OF TEC MAP" => {
if vals.len() != n_lat * n_lon {
return None;
}
let grid = build_grid(lat1, lat2, dlat, lon1, dlon, n_lat, n_lon, &vals);
maps.push(IonexMap { epoch_sod, grid });
in_map = false;
in_band = false;
}
"" if in_map && in_band => {
for t in nums_before_label(line) {
vals.push(t * scale);
}
}
_ => {}
}
}
Some(maps)
}
#[allow(clippy::too_many_arguments)]
fn build_grid(
lat1: f64,
lat2: f64,
dlat: f64,
lon1: f64,
dlon: f64,
n_lat: usize,
n_lon: usize,
vals: &[f64],
) -> TecGrid {
let reverse = dlat < 0.0;
let lat0 = if reverse { lat2 } else { lat1 };
let mut vtec = vec![0.0; n_lat * n_lon];
for (i, row) in vtec.chunks_mut(n_lon).enumerate() {
let src = if reverse { n_lat - 1 - i } else { i };
row.copy_from_slice(&vals[src * n_lon..src * n_lon + n_lon]);
}
TecGrid {
lat0_deg: lat0,
lon0_deg: lon1,
dlat_deg: dlat.abs(),
dlon_deg: dlon.abs(),
n_lat,
n_lon,
vtec,
}
}
pub fn interpolate_tec_in_time(
a: &TecGrid,
b: &TecGrid,
ta: f64,
tb: f64,
t: f64,
) -> Option<TecGrid> {
if a.n_lat != b.n_lat || a.n_lon != b.n_lon || a.vtec.len() != b.vtec.len() {
return None;
}
let w = if (tb - ta).abs() < 1e-12 {
0.0
} else {
((t - ta) / (tb - ta)).clamp(0.0, 1.0)
};
let vtec = a
.vtec
.iter()
.zip(&b.vtec)
.map(|(&x, &y)| x + (y - x) * w)
.collect();
Some(TecGrid { vtec, ..a.clone() })
}
pub const IONO_SHELL_RE_KM: f64 = 6371.0;
pub fn obliquity_factor(zenith_deg: f64, iono_height_km: f64) -> f64 {
let z = zenith_deg.to_radians();
let ratio = IONO_SHELL_RE_KM / (IONO_SHELL_RE_KM + iono_height_km);
let sin_zp = (ratio * z.sin()).clamp(-1.0, 1.0);
1.0 / sin_zp.asin().cos()
}
pub fn slant_tec(vtec_tecu: f64, zenith_deg: f64, iono_height_km: f64) -> f64 {
vtec_tecu * obliquity_factor(zenith_deg, iono_height_km)
}
#[cfg(test)]
mod tests {
use super::*;
fn grid() -> TecGrid {
let mut vtec = Vec::new();
for i in 0..3 {
for j in 0..3 {
vtec.push(10.0 * i as f64 + j as f64);
}
}
TecGrid {
lat0_deg: 0.0,
lon0_deg: 0.0,
dlat_deg: 10.0,
dlon_deg: 10.0,
n_lat: 3,
n_lon: 3,
vtec,
}
}
#[test]
fn delay_is_40_3_tec_over_f_squared() {
let l1 = vtec_to_delay_m(1.0, GPS_L1_HZ);
assert!((l1 - 0.162_37).abs() < 1e-4, "L1 delay = {l1} m");
let l2 = vtec_to_delay_m(1.0, GPS_L2_HZ);
assert!((l2 / l1 - (GPS_L1_HZ / GPS_L2_HZ).powi(2)).abs() < 1e-9);
assert!(l2 > l1, "L2 delay {l2} should exceed L1 {l1}");
assert!((vtec_to_delay_m(10.0, GPS_L1_HZ) - 10.0 * l1).abs() < 1e-12);
}
#[test]
fn interpolation_is_exact_at_nodes() {
let g = grid();
assert!((g.vtec_at(0.0, 0.0) - 0.0).abs() < 1e-12);
assert!((g.vtec_at(10.0, 20.0) - 12.0).abs() < 1e-12); assert!((g.vtec_at(20.0, 10.0) - 21.0).abs() < 1e-12);
}
#[test]
fn bilinear_midpoints_average_the_corners() {
let g = grid();
assert!(
(g.vtec_at(5.0, 5.0) - 5.5).abs() < 1e-12,
"centre = {}",
g.vtec_at(5.0, 5.0)
);
assert!((g.vtec_at(0.0, 5.0) - 0.5).abs() < 1e-12);
}
#[test]
fn queries_outside_the_grid_clamp_to_the_edge() {
let g = grid();
assert!((g.vtec_at(-90.0, -90.0) - 0.0).abs() < 1e-12);
assert!((g.vtec_at(90.0, 90.0) - 22.0).abs() < 1e-12);
assert!(g.delay_at(5.0, 5.0, GPS_L1_HZ) > 0.0);
}
fn rec(data: &str, label: &str) -> String {
format!("{data:<60}{label}")
}
fn sample_ionex() -> String {
[
rec("1.0 IONOSPHERE MAPS GPS", "IONEX VERSION / TYPE"),
rec("10.0 -10.0 -10.0", "LAT1 / LAT2 / DLAT"),
rec("0.0 30.0 10.0", "LON1 / LON2 / DLON"),
rec("-1", "EXPONENT"),
rec("", "END OF HEADER"),
rec("1", "START OF TEC MAP"),
rec("2020 1 1 2 0 0", "EPOCH OF CURRENT MAP"),
rec("10.0 0.0 30.0 10.0 450.0", "LAT/LON1/LON2/DLON/H"),
rec("100 110 120 130", ""),
rec("0.0 0.0 30.0 10.0 450.0", "LAT/LON1/LON2/DLON/H"),
rec("200 210 220 230", ""),
rec("-10.0 0.0 30.0 10.0 450.0", "LAT/LON1/LON2/DLON/H"),
rec("300 310 320 330", ""),
rec("1", "END OF TEC MAP"),
]
.join("\n")
}
#[test]
fn parse_ionex_reads_grid_epoch_and_normalises_latitude() {
let maps = parse_ionex(&sample_ionex()).expect("valid IONEX");
assert_eq!(maps.len(), 1);
assert_eq!(maps[0].epoch_sod, 2.0 * 3600.0);
let g = &maps[0].grid;
assert_eq!((g.n_lat, g.n_lon), (3, 4));
assert_eq!(g.lat0_deg, -10.0);
assert_eq!(g.dlat_deg, 10.0);
assert!((g.vtec_at(10.0, 20.0) - 12.0).abs() < 1e-12); assert!((g.vtec_at(-10.0, 0.0) - 30.0).abs() < 1e-12); assert!((g.vtec_at(0.0, 10.0) - 21.0).abs() < 1e-12); }
#[test]
fn time_interpolation_blends_successive_maps() {
let a = grid(); let mut b = grid();
for v in b.vtec.iter_mut() {
*v += 10.0; }
let mid = interpolate_tec_in_time(&a, &b, 0.0, 7200.0, 3600.0).expect("same shape");
assert!((mid.vtec_at(10.0, 10.0) - (11.0 + 21.0) / 2.0).abs() < 1e-12);
let before = interpolate_tec_in_time(&a, &b, 0.0, 7200.0, -100.0).unwrap();
assert!((before.vtec_at(10.0, 10.0) - 11.0).abs() < 1e-12);
}
#[test]
fn obliquity_maps_vertical_to_slant() {
assert!((obliquity_factor(0.0, 350.0) - 1.0).abs() < 1e-12);
let m60 = obliquity_factor(60.0, 350.0);
assert!(m60 > 1.0, "M(60°) = {m60}");
assert!((slant_tec(10.0, 60.0, 350.0) - 10.0 * m60).abs() < 1e-12);
assert!(obliquity_factor(89.0, 350.0).is_finite());
}
}