#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(test)]
extern crate alloc;
mod legendre;
#[cfg(feature = "wmm2025")]
mod wmm2025;
use core::fmt;
use kinavis_kernel::environment::{MagneticField, MagneticModel};
use kinavis_kernel::error::{ensure_finite, ensure_range, KernelError, Result};
use kinavis_kernel::geodesy::{Ellipsoid, GeodeticPoint};
use kinavis_kernel::math;
use kinavis_kernel::time::{Civil, Instant, Utc};
use legendre::Legendre;
pub use legendre::MAX_DEGREE;
pub const REFERENCE_RADIUS_METRES: f64 = 6_371_200.0;
pub const VALIDITY_YEARS: f64 = 5.0;
pub const MAX_COEFFICIENTS: usize = MAX_DEGREE * (MAX_DEGREE + 3) / 2;
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct DecimalYear(f64);
impl DecimalYear {
pub fn new(year: f64) -> Result<Self> {
ensure_range("decimal year", year, -10_000.0, 10_000.0)?;
Ok(Self(year))
}
#[must_use]
pub fn from_instant(at: Instant<Utc>) -> Self {
let year = at.civil().year;
let start = Instant::<Utc>::from_civil(Civil::date(year, 1, 1)).unwrap_or(at);
let end =
Instant::<Utc>::from_civil(Civil::date(year.saturating_add(1), 1, 1)).unwrap_or(start);
let elapsed = at.checked_duration_since(start).unwrap_or_default();
let length = end.checked_duration_since(start).unwrap_or_default();
let fraction = if length.is_zero() {
0.0
} else {
elapsed.as_secs_f64() / length.as_secs_f64()
};
Self(f64::from(year) + fraction)
}
#[must_use]
pub const fn value(self) -> f64 {
self.0
}
}
impl fmt::Display for DecimalYear {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let precision = f.precision().unwrap_or(3);
write!(f, "{:.precision$}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Coefficient {
n: u8,
m: u8,
g: f64,
h: f64,
g_dot: f64,
h_dot: f64,
}
impl Coefficient {
#[must_use]
pub const fn new(n: u8, m: u8, g: f64, h: f64, g_dot: f64, h_dot: f64) -> Self {
Self {
n,
m,
g,
h,
g_dot,
h_dot,
}
}
#[must_use]
pub const fn degree(&self) -> u8 {
self.n
}
#[must_use]
pub const fn order(&self) -> u8 {
self.m
}
fn at(&self, years: f64) -> (f64, f64) {
(self.g + years * self.g_dot, self.h + years * self.h_dot)
}
}
#[derive(Debug, Clone, Copy)]
pub struct Wmm {
name: &'static str,
epoch: f64,
coefficients: &'static [Coefficient],
}
impl Wmm {
#[cfg(feature = "wmm2025")]
pub const WMM2025: Self = Self {
name: wmm2025::NAME,
epoch: wmm2025::EPOCH,
coefficients: &wmm2025::COEFFICIENTS,
};
pub fn new(
name: &'static str,
epoch: DecimalYear,
coefficients: &'static [Coefficient],
) -> Result<Self> {
if coefficients.len() < MAX_COEFFICIENTS {
return Err(KernelError::InsufficientData {
found: coefficients.len(),
required: MAX_COEFFICIENTS,
context: "a coefficient table",
});
}
if coefficients.len() > MAX_COEFFICIENTS {
return Err(KernelError::CapacityExceeded {
context: "a coefficient table",
needed: coefficients.len(),
capacity: MAX_COEFFICIENTS,
});
}
let mut expected = (1_u8, 0_u8);
for coefficient in coefficients {
if (coefficient.n, coefficient.m) != expected {
return Err(KernelError::Parse {
what: "coefficient table",
input: kinavis_kernel::Excerpt::new(name),
});
}
ensure_finite("g", coefficient.g)?;
ensure_finite("h", coefficient.h)?;
ensure_finite("g_dot", coefficient.g_dot)?;
ensure_finite("h_dot", coefficient.h_dot)?;
expected = if expected.1 == expected.0 {
(expected.0.saturating_add(1), 0)
} else {
(expected.0, expected.1.saturating_add(1))
};
}
Ok(Self {
name,
epoch: epoch.value(),
coefficients,
})
}
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}
#[must_use]
pub const fn epoch(&self) -> DecimalYear {
DecimalYear(self.epoch)
}
#[must_use]
pub fn expires(&self) -> DecimalYear {
DecimalYear(self.epoch + VALIDITY_YEARS)
}
#[must_use]
pub fn is_valid_at(&self, year: DecimalYear) -> bool {
year.0 >= self.epoch && year.0 <= self.epoch + VALIDITY_YEARS
}
#[allow(clippy::many_single_char_names)]
pub fn field_in(&self, at: GeodeticPoint, year: DecimalYear) -> Result<MagneticField> {
if !self.is_valid_at(year) {
return Err(KernelError::OutsideValidity {
data: "magnetic model",
});
}
let years = year.0 - self.epoch;
let geodetic = at.position();
let phi = geodetic.latitude().radians();
let lambda = geodetic.longitude().radians();
let height = at.height().value().metres();
let wgs84 = Ellipsoid::WGS84;
let e2 = wgs84.first_eccentricity_squared();
let (sin_phi, cos_phi) = (math::sin(phi), math::cos(phi));
let prime_vertical =
wgs84.semi_major_axis().metres() / math::sqrt(1.0 - e2 * sin_phi * sin_phi);
let p = (prime_vertical + height) * cos_phi;
let z = (prime_vertical * (1.0 - e2) + height) * sin_phi;
let radius = math::hypot(p, z);
let phi_prime = math::atan2(z, p);
let tables = Legendre::at(phi_prime);
let ratio = REFERENCE_RADIUS_METRES / radius;
let (mut north, mut east, mut down) = (0.0, 0.0, 0.0);
for coefficient in self.coefficients {
let n = usize::from(coefficient.n).min(MAX_DEGREE);
let m = usize::from(coefficient.m).min(n);
let (g, h) = coefficient.at(years);
let m_lambda = math::count_to_f64(m) * lambda;
let (sin_m, cos_m) = (math::sin(m_lambda), math::cos(m_lambda));
let in_phase = g * cos_m + h * sin_m;
let quadrature = g * sin_m - h * cos_m;
let scale = power(ratio, n + 2);
north -= scale * in_phase * tables.derivative.at(n, m);
east += scale * math::count_to_f64(m) * quadrature * tables.over_cosine.at(n, m);
down -= scale * math::count_to_f64(n + 1) * in_phase * tables.value.at(n, m);
}
let delta = phi_prime - phi;
let (sin_delta, cos_delta) = (math::sin(delta), math::cos(delta));
let x = north * cos_delta - down * sin_delta;
let y = east;
let z = north * sin_delta + down * cos_delta;
MagneticField::from_ned_nanotesla(x, y, z)
}
}
impl MagneticModel for Wmm {
fn field_at(&self, at: GeodeticPoint, when: Instant<Utc>) -> Result<MagneticField> {
self.field_in(at, DecimalYear::from_instant(when))
}
}
fn power(base: f64, exponent: usize) -> f64 {
let mut result = 1.0;
for _ in 0..exponent {
result *= base;
}
result
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp)]
mod tests {
use alloc::format;
use super::*;
#[test]
fn a_decimal_year_counts_the_calendars_own_days() {
let midyear = |year: i32| {
let days = if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
366.0
} else {
365.0
};
let start = Instant::<Utc>::from_civil(Civil::date(year, 1, 1)).unwrap();
start.saturating_add(core::time::Duration::from_secs_f64(days / 2.0 * 86_400.0))
};
assert!((DecimalYear::from_instant(midyear(2025)).value() - 2025.5).abs() < 1e-9);
assert!((DecimalYear::from_instant(midyear(2028)).value() - 2028.5).abs() < 1e-9);
let new_year = Instant::<Utc>::from_civil(Civil::date(2027, 1, 1)).unwrap();
assert_eq!(DecimalYear::from_instant(new_year).value(), 2027.0);
assert_eq!(
format!("{}", DecimalYear::new(2026.25).unwrap()),
"2026.250"
);
assert!(DecimalYear::new(f64::NAN).is_err());
}
#[cfg(feature = "wmm2025")]
#[test]
fn the_embedded_model_knows_its_span() {
let model = Wmm::WMM2025;
assert_eq!(model.name(), "WMM-2025");
assert_eq!(model.epoch().value(), 2025.0);
assert_eq!(model.expires().value(), 2030.0);
assert!(model.is_valid_at(DecimalYear::new(2025.0).unwrap()));
assert!(model.is_valid_at(DecimalYear::new(2030.0).unwrap()));
assert!(!model.is_valid_at(DecimalYear::new(2024.999).unwrap()));
assert!(!model.is_valid_at(DecimalYear::new(2030.001).unwrap()));
}
#[cfg(feature = "wmm2025")]
#[test]
fn the_embedded_table_passes_the_checks_a_supplied_one_must() {
let rebuilt = Wmm::new(
"again",
DecimalYear::new(2025.0).unwrap(),
&wmm2025::COEFFICIENTS,
)
.unwrap();
assert_eq!(rebuilt.name(), "again");
}
#[test]
fn a_supplied_table_is_checked_for_length_and_order() {
static SHORT: [Coefficient; 2] = [
Coefficient::new(1, 0, 1.0, 0.0, 0.0, 0.0),
Coefficient::new(1, 1, 1.0, 1.0, 0.0, 0.0),
];
static DISORDERED: [Coefficient; MAX_COEFFICIENTS] =
[Coefficient::new(12, 12, 0.0, 0.0, 0.0, 0.0); MAX_COEFFICIENTS];
assert!(matches!(
Wmm::new("short", DecimalYear::new(2025.0).unwrap(), &SHORT),
Err(KernelError::InsufficientData { .. })
));
assert!(matches!(
Wmm::new("disordered", DecimalYear::new(2025.0).unwrap(), &DISORDERED),
Err(KernelError::Parse { .. })
));
}
#[cfg(feature = "wmm2025")]
#[test]
fn outside_the_span_the_model_refuses() {
use kinavis_kernel::{Distance, Height, Position};
let point = GeodeticPoint::new(
Position::from_degrees(50.0, 0.0).unwrap(),
Height::above_ellipsoid(Distance::ZERO),
);
assert!(matches!(
Wmm::WMM2025.field_in(point, DecimalYear::new(2031.0).unwrap()),
Err(KernelError::OutsideValidity {
data: "magnetic model"
})
));
assert!(Wmm::WMM2025
.field_in(point, DecimalYear::new(2027.3).unwrap())
.is_ok());
}
}