use arika::earth::ellipsoid::{WGS84_A, WGS84_E2};
use arika::epoch::Epoch;
mod coeff;
use super::{MagneticFieldInput, MagneticFieldModel};
#[allow(unused_imports)]
use crate::math::F64Ext;
use coeff::*;
pub type IgrfCoeffArray = [f64; N_COEFFS];
#[derive(Clone)]
pub struct GaussCoefficients {
pub g: IgrfCoeffArray,
pub h: IgrfCoeffArray,
pub year: f64,
}
pub struct Igrf {
max_degree: usize,
custom_coeffs: Option<CustomCoeffs>,
}
struct CustomCoeffs {
epoch_a: GaussCoefficients,
epoch_b: GaussCoefficients,
sv_g: IgrfCoeffArray,
sv_h: IgrfCoeffArray,
}
impl Igrf {
pub fn earth() -> Self {
Self {
max_degree: IGRF_MAX_DEGREE,
custom_coeffs: None,
}
}
pub fn with_max_degree(max_degree: usize) -> Self {
assert!(
(1..=IGRF_MAX_DEGREE).contains(&max_degree),
"max_degree must be in 1..={IGRF_MAX_DEGREE}, got {max_degree}"
);
Self {
max_degree,
custom_coeffs: None,
}
}
pub fn from_coefficients(
epoch_a: GaussCoefficients,
epoch_b: GaussCoefficients,
sv: GaussCoefficients,
max_degree: usize,
) -> Self {
assert!(
(1..=IGRF_MAX_DEGREE).contains(&max_degree),
"max_degree must be in 1..={IGRF_MAX_DEGREE}, got {max_degree}"
);
Self {
max_degree,
custom_coeffs: Some(CustomCoeffs {
epoch_a,
epoch_b,
sv_g: sv.g,
sv_h: sv.h,
}),
}
}
}
impl MagneticFieldModel for Igrf {
fn field_ecef(&self, input: &MagneticFieldInput<'_>) -> [f64; 3] {
let lat = input.geodetic.latitude;
let lon = input.geodetic.longitude;
let h = input.geodetic.altitude;
let sin_lat = lat.sin();
let cos_lat = lat.cos();
let n = WGS84_A / (1.0 - WGS84_E2 * sin_lat * sin_lat).sqrt();
let x = (n + h) * cos_lat * lon.cos();
let y = (n + h) * cos_lat * lon.sin();
let z = (n * (1.0 - WGS84_E2) + h) * sin_lat;
let r_km = (x * x + y * y + z * z).sqrt();
if r_km < 1.0 {
return [0.0, 0.0, 0.0];
}
let p = (x * x + y * y).sqrt();
let cos_theta = z / r_km;
let sin_theta = p / r_km;
let phi = y.atan2(x);
let year = decimal_year(input.utc);
let (g, h_coeff) = match &self.custom_coeffs {
Some(c) => interpolate_custom(year, c, self.max_degree),
None => interpolate_builtin(year, self.max_degree),
};
let (b_r, b_theta, b_phi) = evaluate_sh(
&g,
&h_coeff,
r_km,
cos_theta,
sin_theta,
phi,
self.max_degree,
);
let b_r_t = b_r * 1e-9;
let b_theta_t = b_theta * 1e-9;
let b_phi_t = b_phi * 1e-9;
let cos_phi = phi.cos();
let sin_phi = phi.sin();
[
sin_theta * cos_phi * b_r_t + cos_theta * cos_phi * b_theta_t - sin_phi * b_phi_t,
sin_theta * sin_phi * b_r_t + cos_theta * sin_phi * b_theta_t + cos_phi * b_phi_t,
cos_theta * b_r_t - sin_theta * b_theta_t,
]
}
}
fn decimal_year(epoch: &Epoch) -> f64 {
let dt = epoch.to_datetime();
let jan1 = Epoch::from_gregorian(dt.year, 1, 1, 0, 0, 0.0);
let jan1_next = Epoch::from_gregorian(dt.year + 1, 1, 1, 0, 0, 0.0);
let denom = jan1_next.jd() - jan1.jd();
if denom.abs() < 1e-10 {
return dt.year as f64;
}
dt.year as f64 + (epoch.jd() - jan1.jd()) / denom
}
fn interpolate_builtin(year: f64, max_degree: usize) -> (IgrfCoeffArray, IgrfCoeffArray) {
let n = N_COEFFS;
let mut g = [0.0; N_COEFFS];
let mut h = [0.0; N_COEFFS];
let years = &EPOCH_YEARS;
let last_year = years[NUM_EPOCHS - 1];
if year >= last_year {
let dt = year - last_year;
for i in 0..n {
g[i] = G_EPOCHS[NUM_EPOCHS - 1][i] + DG_SV[i] * dt;
h[i] = H_EPOCHS[NUM_EPOCHS - 1][i] + DH_SV[i] * dt;
}
} else if year <= years[0] {
let span = years[1] - years[0];
let dt = year - years[0];
for i in 0..n {
let sv_g = (G_EPOCHS[1][i] - G_EPOCHS[0][i]) / span;
let sv_h = (H_EPOCHS[1][i] - H_EPOCHS[0][i]) / span;
g[i] = G_EPOCHS[0][i] + sv_g * dt;
h[i] = H_EPOCHS[0][i] + sv_h * dt;
}
} else {
let mut idx_a = 0;
for j in 0..(NUM_EPOCHS - 1) {
if year >= years[j] && year < years[j + 1] {
idx_a = j;
break;
}
}
let idx_b = idx_a + 1;
let ya = years[idx_a];
let span = years[idx_b] - ya;
let dt = year - ya;
for i in 0..n {
let sv_g = (G_EPOCHS[idx_b][i] - G_EPOCHS[idx_a][i]) / span;
let sv_h = (H_EPOCHS[idx_b][i] - H_EPOCHS[idx_a][i]) / span;
g[i] = G_EPOCHS[idx_a][i] + sv_g * dt;
h[i] = H_EPOCHS[idx_a][i] + sv_h * dt;
}
}
zero_above_degree(&mut g, &mut h, max_degree);
(g, h)
}
fn zero_above_degree(g: &mut [f64], h: &mut [f64], max_degree: usize) {
let n = g.len();
for nn in (max_degree + 1)..=IGRF_MAX_DEGREE {
for mm in 0..=nn {
let idx = coeff_index(nn, mm);
if idx < n {
g[idx] = 0.0;
h[idx] = 0.0;
}
}
}
}
fn interpolate_custom(
year: f64,
c: &CustomCoeffs,
max_degree: usize,
) -> (IgrfCoeffArray, IgrfCoeffArray) {
let mut g = [0.0; N_COEFFS];
let mut h = [0.0; N_COEFFS];
let ya = c.epoch_a.year;
let yb = c.epoch_b.year;
let span = yb - ya;
if year <= yb && span.abs() > 1e-10 {
let dt = year - ya;
for i in 0..N_COEFFS {
let sv_g = (c.epoch_b.g[i] - c.epoch_a.g[i]) / span;
let sv_h = (c.epoch_b.h[i] - c.epoch_a.h[i]) / span;
g[i] = c.epoch_a.g[i] + sv_g * dt;
h[i] = c.epoch_a.h[i] + sv_h * dt;
}
} else {
let dt = year - yb;
for i in 0..N_COEFFS {
g[i] = c.epoch_b.g[i] + c.sv_g[i] * dt;
h[i] = c.epoch_b.h[i] + c.sv_h[i] * dt;
}
}
zero_above_degree(&mut g, &mut h, max_degree);
(g, h)
}
fn evaluate_sh(
g: &[f64],
h: &[f64],
r_km: f64,
cos_theta: f64,
sin_theta: f64,
phi: f64,
max_degree: usize,
) -> (f64, f64, f64) {
let a = IGRF_REFERENCE_RADIUS;
let ratio = a / r_km;
const ND: usize = IGRF_MAX_DEGREE + 1;
let nd = max_degree + 1;
let mut p = [[0.0; ND]; ND];
let mut dp = [[0.0; ND]; ND];
p[0][0] = 1.0;
dp[0][0] = 0.0;
for n in 1..nd {
let factor = if n == 1 {
1.0
} else {
((2 * n - 1) as f64 / (2 * n) as f64).sqrt()
};
p[n][n] = factor * sin_theta * p[n - 1][n - 1];
dp[n][n] = factor * (sin_theta * dp[n - 1][n - 1] + cos_theta * p[n - 1][n - 1]);
}
for n in 1..nd {
p[n][n - 1] = cos_theta * (2 * n - 1) as f64 * p[n - 1][n - 1];
dp[n][n - 1] =
(2 * n - 1) as f64 * (cos_theta * dp[n - 1][n - 1] - sin_theta * p[n - 1][n - 1]);
}
for n in 2..nd {
for m in 0..=(n.saturating_sub(2)) {
let n_f = n as f64;
let m_f = m as f64;
let k = ((n_f - 1.0) * (n_f - 1.0) - m_f * m_f).sqrt();
let denom = (n_f * n_f - m_f * m_f).sqrt();
p[n][m] = ((2.0 * n_f - 1.0) * cos_theta * p[n - 1][m] - k * p[n - 2][m]) / denom;
dp[n][m] = ((2.0 * n_f - 1.0) * (cos_theta * dp[n - 1][m] - sin_theta * p[n - 1][m])
- k * dp[n - 2][m])
/ denom;
}
}
let mut b_r = 0.0;
let mut b_theta = 0.0;
let mut b_phi = 0.0;
let mut r_power = ratio * ratio;
for n in 1..nd {
r_power *= ratio; let n_plus_1 = (n + 1) as f64;
for m in 0..=n {
let idx = coeff_index(n, m);
let g_nm = g[idx];
let h_nm = h[idx];
let m_f = m as f64;
let cos_m_phi = (m_f * phi).cos();
let sin_m_phi = (m_f * phi).sin();
let gh_cos_sin = g_nm * cos_m_phi + h_nm * sin_m_phi;
b_r += n_plus_1 * r_power * gh_cos_sin * p[n][m];
b_theta -= r_power * gh_cos_sin * dp[n][m];
if m > 0 {
let gh_sin_cos = -g_nm * sin_m_phi + h_nm * cos_m_phi;
if sin_theta.abs() > 1e-10 {
b_phi += r_power * m_f * gh_sin_cos * p[n][m] / sin_theta;
} else if m == 1 {
let n_f = n as f64;
let limit = (n_f * (n_f + 1.0) / 2.0).sqrt();
let sign = if cos_theta < 0.0 && (n + 1) % 2 != 0 {
-1.0
} else {
1.0
};
b_phi += r_power * gh_sin_cos * sign * limit;
}
}
}
}
(b_r, b_theta, b_phi)
}
#[cfg(test)]
mod tests {
use super::*;
use arika::earth::ellipsoid::WGS84_B;
use arika::earth::geodetic::Geodetic;
fn epoch_2025() -> Epoch {
Epoch::from_gregorian(2025, 1, 1, 0, 0, 0.0)
}
fn make_input(geodetic: Geodetic, epoch: &Epoch) -> MagneticFieldInput<'_> {
MagneticFieldInput {
geodetic,
utc: epoch,
}
}
fn b_magnitude(b: &[f64; 3]) -> f64 {
(b[0] * b[0] + b[1] * b[1] + b[2] * b[2]).sqrt()
}
#[test]
fn decimal_year_j2000() {
let dy = decimal_year(&Epoch::j2000());
assert!(
(dy - 2000.0).abs() < 0.01,
"J2000 should be ~2000.0, got {dy}"
);
}
#[test]
fn decimal_year_2025_jan1() {
let dy = decimal_year(&epoch_2025());
assert!(
(dy - 2025.0).abs() < 0.01,
"2025 Jan 1 should be ~2025.0, got {dy}"
);
}
#[test]
fn igrf_field_magnitude_at_equatorial_leo() {
let igrf = Igrf::earth();
let epoch = epoch_2025();
let input = make_input(
Geodetic {
latitude: 0.0,
longitude: 0.0,
altitude: 7000.0 - WGS84_A,
},
&epoch,
);
let b = igrf.field_ecef(&input);
let b_micro_t = b_magnitude(&b) * 1e6;
assert!(
b_micro_t > 15.0 && b_micro_t < 60.0,
"Equatorial LEO field should be ~20-50 uT, got {b_micro_t:.2} uT"
);
}
#[test]
fn igrf_field_magnitude_at_north_pole() {
let igrf = Igrf::earth();
let r = 6771.0; let epoch = epoch_2025();
let input = make_input(
Geodetic {
latitude: std::f64::consts::FRAC_PI_2,
longitude: 0.0,
altitude: r - WGS84_B,
},
&epoch,
);
let b = igrf.field_ecef(&input);
let b_micro_t = b_magnitude(&b) * 1e6;
assert!(
b_micro_t > 40.0 && b_micro_t < 80.0,
"Polar field should be ~50-65 uT, got {b_micro_t:.2} uT"
);
}
#[test]
fn igrf_inverse_cube_at_high_altitude() {
let igrf = Igrf::earth();
let epoch = epoch_2025();
let b1 = b_magnitude(&igrf.field_ecef(&make_input(
Geodetic {
latitude: 0.0,
longitude: 0.0,
altitude: 20000.0 - WGS84_A,
},
&epoch,
)));
let b2 = b_magnitude(&igrf.field_ecef(&make_input(
Geodetic {
latitude: 0.0,
longitude: 0.0,
altitude: 40000.0 - WGS84_A,
},
&epoch,
)));
let ratio = b1 / b2;
assert!(
(ratio - 8.0).abs() < 0.5,
"Expected ~8.0 ratio at high altitude, got {ratio:.2}"
);
}
#[test]
fn igrf_differs_from_dipole_at_leo() {
use super::super::TiltedDipole;
let igrf = Igrf::earth();
let dipole = TiltedDipole::earth();
let epoch = epoch_2025();
let geo = Geodetic {
latitude: -30.0_f64.to_radians(),
longitude: -50.0_f64.to_radians(),
altitude: 400.0,
};
let input = make_input(geo, &epoch);
let b_igrf = b_magnitude(&igrf.field_ecef(&input));
let b_dipole = b_magnitude(&dipole.field_ecef(&input));
let diff_pct = ((b_igrf - b_dipole) / b_dipole).abs() * 100.0;
assert!(
diff_pct > 0.5,
"IGRF and dipole should differ by >0.5% at LEO, got {diff_pct:.1}%"
);
}
#[test]
fn igrf_converges_to_dipole_at_geo() {
use super::super::TiltedDipole;
let igrf = Igrf::earth();
let dipole = TiltedDipole::earth();
let epoch = epoch_2025();
let geo_r = 42164.0; let geo = Geodetic {
latitude: 0.0,
longitude: 0.0,
altitude: geo_r - WGS84_A,
};
let input = make_input(geo, &epoch);
let b_igrf = b_magnitude(&igrf.field_ecef(&input));
let b_dipole = b_magnitude(&dipole.field_ecef(&input));
let diff_pct = ((b_igrf - b_dipole) / b_dipole).abs() * 100.0;
assert!(
diff_pct < 15.0,
"IGRF and dipole should converge at GEO (<15%), got {diff_pct:.1}%"
);
}
#[test]
fn igrf_zero_inside_earth() {
let igrf = Igrf::earth();
let epoch = epoch_2025();
let input = make_input(
Geodetic {
latitude: 0.0,
longitude: 0.0,
altitude: -WGS84_A, },
&epoch,
);
let b = igrf.field_ecef(&input);
assert_eq!(b, [0.0, 0.0, 0.0]);
}
#[test]
fn igrf_field_is_finite() {
let igrf = Igrf::earth();
let epoch = epoch_2025();
let input = make_input(
Geodetic {
latitude: 0.0,
longitude: 0.0,
altitude: 6778.0 - WGS84_A,
},
&epoch,
);
let b = igrf.field_ecef(&input);
assert!(
b[0].is_finite() && b[1].is_finite() && b[2].is_finite(),
"Field must be finite: {b:?}"
);
}
#[test]
fn igrf_secular_variation() {
let igrf = Igrf::earth();
let geo = Geodetic {
latitude: 0.0,
longitude: 0.0,
altitude: 7000.0 - WGS84_A,
};
let e2020 = Epoch::from_gregorian(2020, 1, 1, 0, 0, 0.0);
let e2025 = epoch_2025();
let b2020 = igrf.field_ecef(&make_input(geo, &e2020));
let b2025 = igrf.field_ecef(&make_input(geo, &e2025));
let diff = ((b2020[0] - b2025[0]).powi(2)
+ (b2020[1] - b2025[1]).powi(2)
+ (b2020[2] - b2025[2]).powi(2))
.sqrt();
assert!(
diff > 1e-10,
"Field should change between 2020 and 2025, diff={diff:.3e}"
);
}
#[test]
fn igrf_truncation_degree1_is_dipole_like() {
let igrf1 = Igrf::with_max_degree(1);
let igrf13 = Igrf::earth();
let epoch = epoch_2025();
let geo = Geodetic {
latitude: 0.0,
longitude: 0.0,
altitude: 7000.0 - WGS84_A,
};
let input = make_input(geo, &epoch);
let b1 = b_magnitude(&igrf1.field_ecef(&input));
let b13 = b_magnitude(&igrf13.field_ecef(&input));
let diff_pct = ((b1 - b13) / b13).abs() * 100.0;
assert!(
diff_pct < 25.0,
"Degree-1 truncation should be within 25% of full model, got {diff_pct:.1}%"
);
}
}