1#![cfg_attr(not(feature = "std"), no_std)]
64
65#[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
84pub const REFERENCE_RADIUS_METRES: f64 = 6_371_200.0;
89
90pub const VALIDITY_YEARS: f64 = 5.0;
92
93pub const MAX_COEFFICIENTS: usize = MAX_DEGREE * (MAX_DEGREE + 3) / 2;
96
97#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
103pub struct DecimalYear(f64);
104
105impl DecimalYear {
106 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 #[must_use]
120 pub fn from_instant(at: Instant<Utc>) -> Self {
121 let year = at.civil().year;
122 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 #[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#[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 #[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 #[must_use]
182 pub const fn degree(&self) -> u8 {
183 self.n
184 }
185
186 #[must_use]
188 pub const fn order(&self) -> u8 {
189 self.m
190 }
191
192 fn at(&self, years: f64) -> (f64, f64) {
194 (self.g + years * self.g_dot, self.h + years * self.h_dot)
195 }
196}
197
198#[derive(Debug, Clone, Copy)]
203pub struct Wmm {
204 name: &'static str,
205 epoch: f64,
206 coefficients: &'static [Coefficient],
207}
208
209impl Wmm {
210 #[cfg(feature = "wmm2025")]
212 pub const WMM2025: Self = Self {
213 name: wmm2025::NAME,
214 epoch: wmm2025::EPOCH,
215 coefficients: &wmm2025::COEFFICIENTS,
216 };
217
218 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 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 #[must_use]
279 pub const fn name(&self) -> &'static str {
280 self.name
281 }
282
283 #[must_use]
285 pub const fn epoch(&self) -> DecimalYear {
286 DecimalYear(self.epoch)
287 }
288
289 #[must_use]
291 pub fn expires(&self) -> DecimalYear {
292 DecimalYear(self.epoch + VALIDITY_YEARS)
293 }
294
295 #[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 #[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 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 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 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 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
370fn 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 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}