Skip to main content

kinavis_wmm/
lib.rs

1//! World Magnetic Model as a [`MagneticModel`] for the KINAVIS crates.
2//!
3//! WMM is the standard main-field model: a spherical harmonic expansion to
4//! degree 12 with linear secular variation, issued every five years by
5//! NOAA/NCEI and the British Geological Survey. Magnetic variation
6//! (declination) is the angle between its horizontal component and true north.
7//!
8//! The `wmm2025` feature embeds the WMM2025 coefficients (valid 2025.0–2030.0)
9//! and implements the kernel's [`MagneticModel`] port. A date outside the
10//! validity interval returns [`KernelError::OutsideValidity`] instead of
11//! extrapolating: coefficients five years out of date can be a degree off.
12//!
13//! ```rust
14//! use kinavis_kernel::environment::MagneticModel;
15//! use kinavis_kernel::{Civil, Distance, GeodeticPoint, Height, Instant, Position, Utc};
16//! use kinavis_wmm::Wmm;
17//!
18//! // Off Ushant, midsummer 2026.
19//! let point = GeodeticPoint::new(
20//!     Position::from_degrees(48.5, -5.5)?,
21//!     Height::above_mean_sea_level(Distance::ZERO),
22//! );
23//! let when = Instant::<Utc>::from_civil(Civil::date(2026, 6, 21))?;
24//!
25//! let field = Wmm::WMM2025.field_at(point, when)?;
26//! assert_eq!(format!("{:.1}", field.declination()), "0.4°W");
27//! assert!(field.horizontal_intensity_nanotesla() > 20_000.0);
28//!
29//! // Before the epoch the model has nothing to say.
30//! let too_early = Instant::<Utc>::from_civil(Civil::date(2024, 12, 31))?;
31//! assert!(Wmm::WMM2025.field_at(point, too_early).is_err());
32//! # Ok::<(), kinavis_kernel::KernelError>(())
33//! ```
34//!
35//! # Accuracy
36//!
37//! The synthesis reproduces NOAA's hundred published test values to better than
38//! `0.01 nT` per component and `0.01°` in declination and inclination — the
39//! resolution of the published values. The model itself has a global RMS error
40//! of about `0.5°` in declination, larger near the magnetic poles where the
41//! horizontal field is weak. Local anomalies, ship's magnetism and space
42//! weather are not modelled.
43//!
44//! # Height
45//!
46//! The model takes height above the WGS-84 ellipsoid. Heights above MSL or
47//! chart datum differ by the geoid undulation (≤ ~100 m), which changes the
48//! field by less than `3 nT`, two orders below the model error; every
49//! [`Height`] is therefore used as is, whatever its datum.
50//!
51//! # Feature flags
52//!
53//! - `std` *(default)* — standard library maths in the kernel.
54//! - `libm` — for `no_std` targets: `--no-default-features --features libm`.
55//! - `wmm2025` *(default)* — embeds the WMM2025 coefficients as
56//!   [`Wmm::WMM2025`]. Without it, only the synthesis and [`Wmm::new`] for
57//!   caller-supplied coefficients.
58//!
59//! No allocation; builds for bare-metal targets.
60//!
61//! [`Height`]: kinavis_kernel::Height
62
63#![cfg_attr(not(feature = "std"), no_std)]
64
65// The crate does not allocate; tests use `format!`.
66#[cfg(test)]
67extern crate alloc;
68
69mod legendre;
70#[cfg(feature = "wmm2025")]
71mod wmm2025;
72
73use core::fmt;
74
75use kinavis_kernel::environment::{MagneticField, MagneticModel};
76use kinavis_kernel::error::{ensure_finite, ensure_range, KernelError, Result};
77use kinavis_kernel::geodesy::{Ellipsoid, GeodeticPoint};
78use kinavis_kernel::math;
79use kinavis_kernel::time::{Civil, Instant, Utc};
80
81use legendre::Legendre;
82pub use legendre::MAX_DEGREE;
83
84/// Geomagnetic reference radius `a`, in metres.
85///
86/// Not the ellipsoid semi-major axis: the model is defined on a sphere of this
87/// radius.
88pub const REFERENCE_RADIUS_METRES: f64 = 6_371_200.0;
89
90/// Validity span of one model issue, in years.
91pub const VALIDITY_YEARS: f64 = 5.0;
92
93/// Maximum number of coefficients: every `(n, m)` with `1 ≤ m ≤ n` up to
94/// [`MAX_DEGREE`].
95pub const MAX_COEFFICIENTS: usize = MAX_DEGREE * (MAX_DEGREE + 3) / 2;
96
97/// Time in the model's coordinate: decimal year.
98///
99/// `2025.5` is halfway through 2025 by calendar days, as in the coefficient
100/// files and test values. Built from an [`Instant`] in use, or from a number to
101/// check against published values.
102#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
103pub struct DecimalYear(f64);
104
105impl DecimalYear {
106    /// Decimal year from a number.
107    ///
108    /// # Errors
109    ///
110    /// [`KernelError::NotFinite`] for `NaN` or infinity;
111    /// [`KernelError::OutOfRange`] outside `[-10_000, 10_000]`.
112    pub fn new(year: f64) -> Result<Self> {
113        ensure_range("decimal year", year, -10_000.0, 10_000.0)?;
114        Ok(Self(year))
115    }
116
117    /// Decimal year of an instant: calendar year plus the elapsed fraction of
118    /// that year (leap years included).
119    #[must_use]
120    pub fn from_instant(at: Instant<Utc>) -> Self {
121        let year = at.civil().year;
122        // Both dates are valid by construction; the fallbacks are unreachable.
123        let start = Instant::<Utc>::from_civil(Civil::date(year, 1, 1)).unwrap_or(at);
124        let end =
125            Instant::<Utc>::from_civil(Civil::date(year.saturating_add(1), 1, 1)).unwrap_or(start);
126        let elapsed = at.checked_duration_since(start).unwrap_or_default();
127        let length = end.checked_duration_since(start).unwrap_or_default();
128        let fraction = if length.is_zero() {
129            0.0
130        } else {
131            elapsed.as_secs_f64() / length.as_secs_f64()
132        };
133        Self(f64::from(year) + fraction)
134    }
135
136    /// Year as `f64`.
137    #[must_use]
138    pub const fn value(self) -> f64 {
139        self.0
140    }
141}
142
143impl fmt::Display for DecimalYear {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        let precision = f.precision().unwrap_or(3);
146        write!(f, "{:.precision$}", self.0)
147    }
148}
149
150/// Gauss coefficient pair with its secular variation.
151///
152/// Degree `n`, order `m`; `g`, `h` in nT at the epoch and their rates in
153/// nT/year. `h` is zero for `m = 0`.
154#[derive(Debug, Clone, Copy, PartialEq)]
155pub struct Coefficient {
156    n: u8,
157    m: u8,
158    g: f64,
159    h: f64,
160    g_dot: f64,
161    h_dot: f64,
162}
163
164impl Coefficient {
165    /// Coefficient as a row of the coefficient file.
166    ///
167    /// Validated when the [`Wmm`] is built, so a table can be a `const`.
168    #[must_use]
169    pub const fn new(n: u8, m: u8, g: f64, h: f64, g_dot: f64, h_dot: f64) -> Self {
170        Self {
171            n,
172            m,
173            g,
174            h,
175            g_dot,
176            h_dot,
177        }
178    }
179
180    /// Degree.
181    #[must_use]
182    pub const fn degree(&self) -> u8 {
183        self.n
184    }
185
186    /// Order.
187    #[must_use]
188    pub const fn order(&self) -> u8 {
189        self.m
190    }
191
192    /// `g` and `h` at `years` past the epoch.
193    fn at(&self, years: f64) -> (f64, f64) {
194        (self.g + years * self.g_dot, self.h + years * self.h_dot)
195    }
196}
197
198/// One issue of the World Magnetic Model: coefficients, epoch, name.
199///
200/// [`Wmm::WMM2025`] is the embedded issue; [`Wmm::new`] accepts a
201/// caller-supplied coefficient table, e.g. a newer issue.
202#[derive(Debug, Clone, Copy)]
203pub struct Wmm {
204    name: &'static str,
205    epoch: f64,
206    coefficients: &'static [Coefficient],
207}
208
209impl Wmm {
210    /// WMM2025, valid 2025.0–2030.0; coefficient file dated 2024-11-13.
211    #[cfg(feature = "wmm2025")]
212    pub const WMM2025: Self = Self {
213        name: wmm2025::NAME,
214        epoch: wmm2025::EPOCH,
215        coefficients: &wmm2025::COEFFICIENTS,
216    };
217
218    /// Model from a coefficient table.
219    ///
220    /// The table must hold the full expansion, degrees 1 to [`MAX_DEGREE`], in
221    /// coefficient-file order: every `(n, m)` with `0 ≤ m ≤ n`, degree by
222    /// degree.
223    ///
224    /// # Errors
225    ///
226    /// [`KernelError::NotFinite`] for a non-finite coefficient or epoch;
227    /// [`KernelError::OutOfRange`] for an epoch outside the calendar;
228    /// [`KernelError::InsufficientData`] or [`KernelError::CapacityExceeded`]
229    /// unless the table has [`MAX_COEFFICIENTS`] rows; [`KernelError::Parse`]
230    /// for a row out of order.
231    pub fn new(
232        name: &'static str,
233        epoch: DecimalYear,
234        coefficients: &'static [Coefficient],
235    ) -> Result<Self> {
236        if coefficients.len() < MAX_COEFFICIENTS {
237            return Err(KernelError::InsufficientData {
238                found: coefficients.len(),
239                required: MAX_COEFFICIENTS,
240                context: "a coefficient table",
241            });
242        }
243        if coefficients.len() > MAX_COEFFICIENTS {
244            return Err(KernelError::CapacityExceeded {
245                context: "a coefficient table",
246                needed: coefficients.len(),
247                capacity: MAX_COEFFICIENTS,
248            });
249        }
250        let mut expected = (1_u8, 0_u8);
251        for coefficient in coefficients {
252            if (coefficient.n, coefficient.m) != expected {
253                return Err(KernelError::Parse {
254                    what: "coefficient table",
255                    input: kinavis_kernel::Excerpt::new(name),
256                });
257            }
258            ensure_finite("g", coefficient.g)?;
259            ensure_finite("h", coefficient.h)?;
260            ensure_finite("g_dot", coefficient.g_dot)?;
261            ensure_finite("h_dot", coefficient.h_dot)?;
262            // The length check bounds the degree by `MAX_DEGREE`; saturating
263            // arithmetic states that the count cannot wrap.
264            expected = if expected.1 == expected.0 {
265                (expected.0.saturating_add(1), 0)
266            } else {
267                (expected.0, expected.1.saturating_add(1))
268            };
269        }
270        Ok(Self {
271            name,
272            epoch: epoch.value(),
273            coefficients,
274        })
275    }
276
277    /// Model name from the coefficient file, e.g. `WMM-2025`.
278    #[must_use]
279    pub const fn name(&self) -> &'static str {
280        self.name
281    }
282
283    /// Epoch of the coefficients.
284    #[must_use]
285    pub const fn epoch(&self) -> DecimalYear {
286        DecimalYear(self.epoch)
287    }
288
289    /// End of validity (exclusive): epoch + [`VALIDITY_YEARS`].
290    #[must_use]
291    pub fn expires(&self) -> DecimalYear {
292        DecimalYear(self.epoch + VALIDITY_YEARS)
293    }
294
295    /// Whether `year` is within the validity interval.
296    #[must_use]
297    pub fn is_valid_at(&self, year: DecimalYear) -> bool {
298        year.0 >= self.epoch && year.0 <= self.epoch + VALIDITY_YEARS
299    }
300
301    /// Field at `at` in decimal year `year`.
302    ///
303    /// [`MagneticModel::field_at`] converts an instant and calls this.
304    ///
305    /// # Errors
306    ///
307    /// [`KernelError::OutsideValidity`] if `year` is outside `[epoch, epoch +
308    /// 5)`.
309    // Notation follows the WMM technical report.
310    #[allow(clippy::many_single_char_names)]
311    pub fn field_in(&self, at: GeodeticPoint, year: DecimalYear) -> Result<MagneticField> {
312        if !self.is_valid_at(year) {
313            return Err(KernelError::OutsideValidity {
314                data: "magnetic model",
315            });
316        }
317        let years = year.0 - self.epoch;
318
319        // Geodetic to geocentric spherical coordinates on WGS-84.
320        let geodetic = at.position();
321        let phi = geodetic.latitude().radians();
322        let lambda = geodetic.longitude().radians();
323        let height = at.height().value().metres();
324        let wgs84 = Ellipsoid::WGS84;
325        let e2 = wgs84.first_eccentricity_squared();
326        let (sin_phi, cos_phi) = (math::sin(phi), math::cos(phi));
327        let prime_vertical =
328            wgs84.semi_major_axis().metres() / math::sqrt(1.0 - e2 * sin_phi * sin_phi);
329        let p = (prime_vertical + height) * cos_phi;
330        let z = (prime_vertical * (1.0 - e2) + height) * sin_phi;
331        let radius = math::hypot(p, z);
332        let phi_prime = math::atan2(z, p);
333
334        // The expansion, in the geocentric frame.
335        let tables = Legendre::at(phi_prime);
336        let ratio = REFERENCE_RADIUS_METRES / radius;
337        let (mut north, mut east, mut down) = (0.0, 0.0, 0.0);
338        for coefficient in self.coefficients {
339            // `new` validated every degree and order; this bound lets the
340            // compiler prove the indexing cannot wrap.
341            let n = usize::from(coefficient.n).min(MAX_DEGREE);
342            let m = usize::from(coefficient.m).min(n);
343            let (g, h) = coefficient.at(years);
344            let m_lambda = math::count_to_f64(m) * lambda;
345            let (sin_m, cos_m) = (math::sin(m_lambda), math::cos(m_lambda));
346            let in_phase = g * cos_m + h * sin_m;
347            let quadrature = g * sin_m - h * cos_m;
348            let scale = power(ratio, n + 2);
349            north -= scale * in_phase * tables.derivative.at(n, m);
350            east += scale * math::count_to_f64(m) * quadrature * tables.over_cosine.at(n, m);
351            down -= scale * math::count_to_f64(n + 1) * in_phase * tables.value.at(n, m);
352        }
353
354        // Rotate from geocentric to geodetic vertical.
355        let delta = phi_prime - phi;
356        let (sin_delta, cos_delta) = (math::sin(delta), math::cos(delta));
357        let x = north * cos_delta - down * sin_delta;
358        let y = east;
359        let z = north * sin_delta + down * cos_delta;
360        MagneticField::from_ned_nanotesla(x, y, z)
361    }
362}
363
364impl MagneticModel for Wmm {
365    fn field_at(&self, at: GeodeticPoint, when: Instant<Utc>) -> Result<MagneticField> {
366        self.field_in(at, DecimalYear::from_instant(when))
367    }
368}
369
370/// `base` to a small integer power by repeated multiplication: exact enough for
371/// `(a/r)ⁿ⁺²`, `n ≤ 12`, and identical on every target.
372fn power(base: f64, exponent: usize) -> f64 {
373    let mut result = 1.0;
374    for _ in 0..exponent {
375        result *= base;
376    }
377    result
378}
379
380#[cfg(test)]
381#[allow(clippy::unwrap_used, clippy::float_cmp)]
382mod tests {
383    use alloc::format;
384
385    use super::*;
386
387    #[test]
388    fn a_decimal_year_counts_the_calendars_own_days() {
389        let midyear = |year: i32| {
390            // A common year has 365 days; halfway is 182.5 days in.
391            let days = if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
392                366.0
393            } else {
394                365.0
395            };
396            let start = Instant::<Utc>::from_civil(Civil::date(year, 1, 1)).unwrap();
397            start.saturating_add(core::time::Duration::from_secs_f64(days / 2.0 * 86_400.0))
398        };
399        assert!((DecimalYear::from_instant(midyear(2025)).value() - 2025.5).abs() < 1e-9);
400        assert!((DecimalYear::from_instant(midyear(2028)).value() - 2028.5).abs() < 1e-9);
401
402        let new_year = Instant::<Utc>::from_civil(Civil::date(2027, 1, 1)).unwrap();
403        assert_eq!(DecimalYear::from_instant(new_year).value(), 2027.0);
404        assert_eq!(
405            format!("{}", DecimalYear::new(2026.25).unwrap()),
406            "2026.250"
407        );
408        assert!(DecimalYear::new(f64::NAN).is_err());
409    }
410
411    #[cfg(feature = "wmm2025")]
412    #[test]
413    fn the_embedded_model_knows_its_span() {
414        let model = Wmm::WMM2025;
415        assert_eq!(model.name(), "WMM-2025");
416        assert_eq!(model.epoch().value(), 2025.0);
417        assert_eq!(model.expires().value(), 2030.0);
418        assert!(model.is_valid_at(DecimalYear::new(2025.0).unwrap()));
419        assert!(model.is_valid_at(DecimalYear::new(2030.0).unwrap()));
420        assert!(!model.is_valid_at(DecimalYear::new(2024.999).unwrap()));
421        assert!(!model.is_valid_at(DecimalYear::new(2030.001).unwrap()));
422    }
423
424    #[cfg(feature = "wmm2025")]
425    #[test]
426    fn the_embedded_table_passes_the_checks_a_supplied_one_must() {
427        let rebuilt = Wmm::new(
428            "again",
429            DecimalYear::new(2025.0).unwrap(),
430            &wmm2025::COEFFICIENTS,
431        )
432        .unwrap();
433        assert_eq!(rebuilt.name(), "again");
434    }
435
436    #[test]
437    fn a_supplied_table_is_checked_for_length_and_order() {
438        static SHORT: [Coefficient; 2] = [
439            Coefficient::new(1, 0, 1.0, 0.0, 0.0, 0.0),
440            Coefficient::new(1, 1, 1.0, 1.0, 0.0, 0.0),
441        ];
442        static DISORDERED: [Coefficient; MAX_COEFFICIENTS] =
443            [Coefficient::new(12, 12, 0.0, 0.0, 0.0, 0.0); MAX_COEFFICIENTS];
444
445        assert!(matches!(
446            Wmm::new("short", DecimalYear::new(2025.0).unwrap(), &SHORT),
447            Err(KernelError::InsufficientData { .. })
448        ));
449        assert!(matches!(
450            Wmm::new("disordered", DecimalYear::new(2025.0).unwrap(), &DISORDERED),
451            Err(KernelError::Parse { .. })
452        ));
453    }
454
455    #[cfg(feature = "wmm2025")]
456    #[test]
457    fn outside_the_span_the_model_refuses() {
458        use kinavis_kernel::{Distance, Height, Position};
459        let point = GeodeticPoint::new(
460            Position::from_degrees(50.0, 0.0).unwrap(),
461            Height::above_ellipsoid(Distance::ZERO),
462        );
463        assert!(matches!(
464            Wmm::WMM2025.field_in(point, DecimalYear::new(2031.0).unwrap()),
465            Err(KernelError::OutsideValidity {
466                data: "magnetic model"
467            })
468        ));
469        assert!(Wmm::WMM2025
470            .field_in(point, DecimalYear::new(2027.3).unwrap())
471            .is_ok());
472    }
473}