#![allow(clippy::similar_names, clippy::many_single_char_names)]
use libm::{asin, cos, sin, sqrt};
const G10_NT: f64 = -29350.0;
const G11_NT: f64 = -1410.3;
const H11_NT: f64 = 4545.5;
const IGRF_RADIUS: f64 = 6_371_200.0;
const ELEMENTARY_CHARGE: f64 = 1.602_176_634e-19;
const ELECTRON_MASS: f64 = 9.109_383_701_5e-31;
const VACUUM_PERMITTIVITY: f64 = 8.854_187_812_8e-12;
const SECOND_ORDER_GROUP_CONST: f64 = {
let pi3 = core::f64::consts::PI * core::f64::consts::PI * core::f64::consts::PI;
let q3 = ELEMENTARY_CHARGE * ELEMENTARY_CHARGE * ELEMENTARY_CHARGE;
q3 / (8.0 * pi3 * VACUUM_PERMITTIVITY * ELECTRON_MASS * ELECTRON_MASS)
};
const EARTH_MEAN_RADIUS: f64 = 6_371_000.0;
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[must_use]
pub fn geomagnetic_dipole_field(r: [f64; 3]) -> [f64; 3] {
let b0_nt = sqrt(G10_NT * G10_NT + G11_NT * G11_NT + H11_NT * H11_NT);
let g_hat = [G11_NT / b0_nt, H11_NT / b0_nt, G10_NT / b0_nt];
let b0 = b0_nt * 1e-9;
let rn = sqrt(dot(r, r));
let r_hat = [r[0] / rn, r[1] / rn, r[2] / rn];
let g_dot_r = dot(g_hat, r_hat);
let ratio = IGRF_RADIUS / rn;
let scale = b0 * ratio * ratio * ratio;
[
scale * (3.0 * g_dot_r * r_hat[0] - g_hat[0]),
scale * (3.0 * g_dot_r * r_hat[1] - g_hat[1]),
scale * (3.0 * g_dot_r * r_hat[2] - g_hat[2]),
]
}
#[must_use]
pub fn second_order_group_delay(
pierce: [f64; 3],
los_unit: [f64; 3],
stec: f64,
frequency: f64,
) -> f64 {
let b = geomagnetic_dipole_field(pierce);
let b_cos_theta = dot(b, los_unit); SECOND_ORDER_GROUP_CONST * b_cos_theta * stec / (frequency * frequency * frequency)
}
#[must_use]
pub fn second_order_group_delay_igrf(
pierce: [f64; 3],
los_unit: [f64; 3],
stec: f64,
frequency: f64,
year: f64,
) -> f64 {
let b = spacelab_geomagnetism::igrf::field_ecef(pierce, year);
let b_cos_theta = dot(b, los_unit);
SECOND_ORDER_GROUP_CONST * b_cos_theta * stec / (frequency * frequency * frequency)
}
#[must_use]
pub fn second_order_group_delay_geodetic(
latitude: f64,
longitude: f64,
elevation: f64,
azimuth: f64,
pierce_height: f64,
stec: f64,
frequency: f64,
) -> f64 {
let central = core::f64::consts::FRAC_PI_2
- elevation
- asin(EARTH_MEAN_RADIUS / (EARTH_MEAN_RADIUS + pierce_height) * cos(elevation));
let (sin_lat, cos_lat) = (sin(latitude), cos(latitude));
let pierce_lat = asin(sin_lat * cos(central) + cos_lat * sin(central) * cos(azimuth));
let pierce_lon = longitude + asin(sin(central) * sin(azimuth) / cos(pierce_lat));
let radius = EARTH_MEAN_RADIUS + pierce_height;
let (sin_pl, cos_pl) = (sin(pierce_lat), cos(pierce_lat));
let (sin_plon, cos_plon) = (sin(pierce_lon), cos(pierce_lon));
let pierce = [
radius * cos_pl * cos_plon,
radius * cos_pl * sin_plon,
radius * sin_pl,
];
let (sin_lon, cos_lon) = (sin(longitude), cos(longitude));
let (e, n, u) = (
cos(elevation) * sin(azimuth),
cos(elevation) * cos(azimuth),
sin(elevation),
);
let los = [
-sin_lon * e - sin_lat * cos_lon * n + cos_lat * cos_lon * u,
cos_lon * e - sin_lat * sin_lon * n + cos_lat * sin_lon * u,
cos_lat * n + sin_lat * u,
];
second_order_group_delay(pierce, los, stec, frequency)
}
#[cfg(test)]
mod tests {
use super::*;
fn b0_tesla() -> f64 {
sqrt(G10_NT * G10_NT + G11_NT * G11_NT + H11_NT * H11_NT) * 1e-9
}
fn norm(v: [f64; 3]) -> f64 {
sqrt(dot(v, v))
}
#[test]
fn the_second_order_constant_is_the_published_one_and_says_by_how_much_it_is_not() {
const SPEED_OF_LIGHT: f64 = 299_792_458.0;
let published = 7527.0 * SPEED_OF_LIGHT;
let relative = (published - SECOND_ORDER_GROUP_CONST) / SECOND_ORDER_GROUP_CONST;
assert!(
(-5.2e-5..-5.0e-5).contains(&relative),
"derived {SECOND_ORDER_GROUP_CONST:e} against published {published:e} is {relative:e}"
);
let pi2 = core::f64::consts::PI * core::f64::consts::PI;
let first_order = ELEMENTARY_CHARGE * ELEMENTARY_CHARGE
/ (8.0 * pi2 * VACUUM_PERMITTIVITY * ELECTRON_MASS);
assert!(
(first_order - 40.308_193).abs() < 1e-6,
"first-order constant {first_order}"
);
}
#[test]
fn reduced_dipole_moment_matches_the_known_igrf_value() {
assert!((b0_tesla() - 2.9733e-5).abs() < 1e-8, "B0 = {}", b0_tesla());
}
#[test]
fn dipole_magnitude_is_b0_at_the_magnetic_equator_and_2b0_at_the_pole() {
let b0_nt = sqrt(G10_NT * G10_NT + G11_NT * G11_NT + H11_NT * H11_NT);
let g_hat = [G11_NT / b0_nt, H11_NT / b0_nt, G10_NT / b0_nt];
let pole = [
-g_hat[0] * IGRF_RADIUS,
-g_hat[1] * IGRF_RADIUS,
-g_hat[2] * IGRF_RADIUS,
];
assert!((norm(geomagnetic_dipole_field(pole)) - 2.0 * b0_tesla()).abs() < 1e-9);
let z = [0.0, 0.0, 1.0];
let perp = [
g_hat[1] * z[2] - g_hat[2] * z[1],
g_hat[2] * z[0] - g_hat[0] * z[2],
g_hat[0] * z[1] - g_hat[1] * z[0],
];
let pn = norm(perp);
let equator = [
perp[0] / pn * IGRF_RADIUS,
perp[1] / pn * IGRF_RADIUS,
perp[2] / pn * IGRF_RADIUS,
];
let b = geomagnetic_dipole_field(equator);
assert!((norm(b) - b0_tesla()).abs() < 1e-9, "|B|={}", norm(b));
assert!(dot(b, equator).abs() / (norm(b) * norm(equator)) < 1e-9);
}
#[test]
fn second_order_delay_has_the_expected_millimetre_magnitude() {
let stec = 50.0e16; let f_l1 = 1.575_42e9; let b0_nt = sqrt(G10_NT * G10_NT + G11_NT * G11_NT + H11_NT * H11_NT);
let g_hat = [G11_NT / b0_nt, H11_NT / b0_nt, G10_NT / b0_nt];
let pierce = [
-g_hat[0] * IGRF_RADIUS,
-g_hat[1] * IGRF_RADIUS,
-g_hat[2] * IGRF_RADIUS,
];
let b = geomagnetic_dipole_field(pierce);
let los = [b[0] / norm(b), b[1] / norm(b), b[2] / norm(b)]; let delay = second_order_group_delay(pierce, los, stec, f_l1);
let expected = SECOND_ORDER_GROUP_CONST * (2.0 * b0_tesla()) * stec / (f_l1 * f_l1 * f_l1);
assert!((delay - expected).abs() < 1e-12);
assert!((0.005..0.05).contains(&delay.abs()), "delay {delay} m");
}
#[test]
fn geodetic_wrapper_reduces_toward_zero_at_the_magnetic_equator_zenith() {
let f_l1 = 1.575_42e9;
let zenith_equator = second_order_group_delay_geodetic(
0.0,
0.0, core::f64::consts::FRAC_PI_2, 0.0,
350e3,
50.0e16,
f_l1,
);
let low_north = second_order_group_delay_geodetic(
core::f64::consts::FRAC_PI_4,
0.0,
0.174_53, 0.0, 350e3,
50.0e16,
f_l1,
);
assert!(zenith_equator.abs() < low_north.abs());
assert!(low_north.abs() < 0.05);
}
fn enu(lat: f64, lon: f64) -> ([f64; 3], [f64; 3], [f64; 3]) {
let up = [cos(lat) * cos(lon), cos(lat) * sin(lon), sin(lat)];
let east = [-sin(lon), cos(lon), 0.0];
let north = [
up[1] * east[2] - up[2] * east[1],
up[2] * east[0] - up[0] * east[2],
up[0] * east[1] - up[1] * east[0],
];
(east, north, up)
}
fn pierce_by_ray(station: [f64; 3], los: [f64; 3], shell: f64) -> [f64; 3] {
let b = 2.0 * dot(station, los);
let c = dot(station, station) - shell * shell;
let s = 0.5 * (-b + sqrt(b * b - 4.0 * c));
[
station[0] + s * los[0],
station[1] + s * los[1],
station[2] + s * los[2],
]
}
#[test]
fn the_dipole_falls_off_as_the_inverse_cube_and_points_down_at_its_own_pole() {
let b0_nt = sqrt(G10_NT * G10_NT + G11_NT * G11_NT + H11_NT * H11_NT);
let g_hat = [G11_NT / b0_nt, H11_NT / b0_nt, G10_NT / b0_nt];
let pole_lat = asin(-g_hat[2]).to_degrees();
let pole_lon = libm::atan2(-g_hat[1], -g_hat[0]).to_degrees();
assert!(
(pole_lat - 80.789).abs() < 0.01 && (pole_lon + 72.763).abs() < 0.01,
"dipole pole at {pole_lat} N, {pole_lon} E"
);
for r in [[-4.0e6, 3.0e6, 5.0e6], [1.0e7, -2.0e6, 3.0e6]] {
let b = geomagnetic_dipole_field(r);
for lambda in [2.0_f64, 3.7] {
let scaled =
geomagnetic_dipole_field([r[0] * lambda, r[1] * lambda, r[2] * lambda]);
for axis in 0..3 {
let want = b[axis] / lambda.powi(3);
assert!(
(scaled[axis] - want).abs() < 1e-12 * norm(b),
"axis {axis} at λ = {lambda}: {} vs {want}",
scaled[axis]
);
}
}
}
let r = [
-g_hat[0] * 2.0 * IGRF_RADIUS,
-g_hat[1] * 2.0 * IGRF_RADIUS,
-g_hat[2] * 2.0 * IGRF_RADIUS,
];
let b = geomagnetic_dipole_field(r);
for axis in 0..3 {
let want = b0_tesla() / 4.0 * g_hat[axis];
assert!(
(b[axis] - want).abs() < 1e-12 * b0_tesla(),
"axis {axis}: {} vs {want}",
b[axis]
);
}
}
#[test]
fn the_delay_is_linear_in_tec_and_inverse_cubic_in_frequency() {
let pierce = [-4.0e6, 3.0e6, 5.0e6];
let n = norm(pierce);
let los = [pierce[0] / n, pierce[1] / n, pierce[2] / n];
let (stec, f_l1) = (50.0e16, 1.575_42e9);
for label in ["dipole", "igrf"] {
let delay = |s: f64, f: f64| {
if label == "dipole" {
second_order_group_delay(pierce, los, s, f)
} else {
second_order_group_delay_igrf(pierce, los, s, f, 2025.0)
}
};
let base = delay(stec, f_l1);
assert!(base.abs() > 1e-6, "{label}: delay {base} m is not physical");
assert!(
(delay(2.0 * stec, f_l1) - 2.0 * base).abs() < 1e-12 * base.abs(),
"{label}: not linear in TEC"
);
assert!(
(delay(stec, f_l1 / 2.0) - 8.0 * base).abs() < 1e-11 * base.abs(),
"{label}: not inverse-cubic in frequency"
);
}
let dipole = second_order_group_delay(pierce, los, stec, f_l1);
let full = second_order_group_delay_igrf(pierce, los, stec, f_l1, 2025.0);
assert!(
(full - dipole).abs() < 0.25 * dipole.abs(),
"dipole {dipole} vs full {full}"
);
}
#[test]
fn the_geodetic_wrapper_pierces_the_shell_where_the_ray_meets_it() {
let (stec, f_l1) = (50.0e16, 1.575_42e9);
for (lat_deg, lon_deg, el_deg, az_deg, h) in [
(0.0_f64, 0.0_f64, 90.0_f64, 0.0_f64, 350.0e3_f64),
(45.0, 10.0, 10.0, 0.0, 350.0e3),
(-33.0, 151.0, 25.0, 137.0, 450.0e3),
(60.0, -70.0, 5.0, 300.0, 400.0e3),
(-70.0, -170.0, 55.0, 220.0, 400.0e3),
] {
let (lat, lon) = (lat_deg.to_radians(), lon_deg.to_radians());
let (el, az) = (el_deg.to_radians(), az_deg.to_radians());
let (e, n, u) = enu(lat, lon);
let mut los = [0.0; 3];
for axis in 0..3 {
los[axis] =
cos(el) * sin(az) * e[axis] + cos(el) * cos(az) * n[axis] + sin(el) * u[axis];
}
assert!(
(norm(los) - 1.0).abs() < 1e-15,
"the reference line of sight is a unit vector"
);
assert!(
(dot(los, u) - sin(el)).abs() < 1e-15,
"the reference line of sight is at elevation {el_deg}"
);
let station = [
EARTH_MEAN_RADIUS * u[0],
EARTH_MEAN_RADIUS * u[1],
EARTH_MEAN_RADIUS * u[2],
];
let pierce = pierce_by_ray(station, los, EARTH_MEAN_RADIUS + h);
let want = second_order_group_delay(pierce, los, stec, f_l1);
let got = second_order_group_delay_geodetic(lat, lon, el, az, h, stec, f_l1);
assert!(
(got - want).abs() < 1e-9 * want.abs(),
"lat {lat_deg}, el {el_deg}, az {az_deg}: {got} vs {want}"
);
}
}
#[test]
fn igrf_variant_stays_in_band_and_differs_from_the_dipole() {
let pierce = [-4.0e6, 3.0e6, 5.0e6]; let n = norm(pierce);
let los = [pierce[0] / n, pierce[1] / n, pierce[2] / n]; let (stec, f_l1) = (50.0e16, 1.575_42e9);
let dipole = second_order_group_delay(pierce, los, stec, f_l1);
let full = second_order_group_delay_igrf(pierce, los, stec, f_l1, 2025.0);
assert!(full.abs() < 0.05, "full {full}");
assert!(
(full - dipole).abs() > 1e-7,
"dipole {dipole} vs full {full}"
);
}
#[test]
fn over_the_south_atlantic_the_dipole_gets_the_sign_of_the_term_wrong() {
let (stec, f_l1) = (50.0e16, 1.575_42e9);
let (lat, lon) = (0.0, -20.0_f64.to_radians());
let (_, _, up) = enu(lat, lon);
let shell = EARTH_MEAN_RADIUS + 400e3;
let pierce = [up[0] * shell, up[1] * shell, up[2] * shell];
let dipole = second_order_group_delay(pierce, up, stec, f_l1);
let full = second_order_group_delay_igrf(pierce, up, stec, f_l1, 2025.0);
assert!(dipole * full < 0.0, "dipole {dipole} m, full {full} m");
assert!(
(full / dipole + 2.0364).abs() < 1e-3,
"ratio {}",
full / dipole
);
}
}