Skip to main content

hpr_core/magnetic/
mod.rs

1//! The Earth's main magnetic field from the World Magnetic Model, WMM2025: declination (the angle
2//! from true north to magnetic north), inclination, intensity and their yearly change.
3//!
4//! A compass points along the horizontal part of the field, not at true north. Declination `D`
5//! is the angle between the two, positive when magnetic north lies east of true north, so a
6//! magnetic heading converts to a true one by adding `D`.
7//!
8//! Source: A. Chulliat, W. Brown, M. Nair et al., *The US/UK World Magnetic Model for 2025–2030:
9//! Technical Report*, NCEI, NOAA, 2025, <https://doi.org/10.25923/prbc-s316>, section 1.2,
10//! equations 3 to 20. The coefficients are NCEI's `WMM2025.COF`, in the public domain. Details
11//! and tests are in the guide's [magnetic-field page][guide].
12//!
13//! [guide]: https://nrdptel.github.io/hpr-sim/physics/magnetic.html
14//!
15//! The model is valid from 2025.0 to 2030.0 and from 1 km below the WGS 84 ellipsoid to 850 km
16//! above it (report, sections 1.3 and 3); [`MagneticModel::field`] refuses anything outside.
17//!
18//! Two departures from the report's printed text, both checked by NOAA's test values:
19//!
20//! - Equation 15 prints `ġ cos mλ − ḣ sin mλ` for the rate of `Z′`. The potential (equation 4)
21//!   and equation 12 give `+`, and NOAA's 100 test values of `Ż` need `+`.
22//! - Equation 16, the derivative of `P̆ₙᵐ`, and equation 11 divide by `cos φ′`, which is zero at a
23//!   pole. Here every Legendre function is written `P̆ₙᵐ = cₙₘ cosᵐφ′ qₙᵐ(sin φ′)`, with
24//!   `qₙᵐ = dᵐPₙ/dμᵐ` a polynomial, so both divisions cancel exactly and the field is finite at
25//!   the poles (report, section 1.4).
26
27mod coefficients;
28#[cfg(test)]
29mod tests;
30
31use glam::DVec3;
32use serde::{Deserialize, Serialize};
33
34use crate::error::CoreError;
35use crate::geodesy::{Ellipsoid, Geodetic};
36
37/// The geomagnetic reference radius `a`, m (report, equation 4).
38pub const REFERENCE_RADIUS_M: f64 = 6_371_200.0;
39
40/// The highest degree of the WMM's expansion, `N = 12`.
41const DEGREE: usize = 12;
42
43/// A spherical-harmonic model of the main field: Gauss coefficients at an epoch and their linear
44/// secular variation, to degree 12.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct MagneticModel {
47    name: &'static str,
48    epoch_year: f64,
49    valid_until_year: f64,
50    coefficients: &'static [[f64; 4]; 90],
51}
52
53/// WMM2025, the World Magnetic Model for 2025.0 to 2030.0 (NOAA NCEI and the British Geological
54/// Survey, released 2024-12-17).
55pub const WMM2025: MagneticModel = MagneticModel {
56    name: "WMM-2025",
57    epoch_year: 2025.0,
58    valid_until_year: 2030.0,
59    coefficients: &coefficients::WMM2025,
60};
61
62/// The lowest height the WMM is specified for: 1 km below the WGS 84 ellipsoid (report, section
63/// 3, after MIL-PRF-89500B).
64pub const MIN_HEIGHT_M: f64 = -1_000.0;
65
66/// The highest height the WMM is specified for: 850 km above the WGS 84 ellipsoid (report,
67/// section 3, after MIL-PRF-89500B).
68pub const MAX_HEIGHT_M: f64 = 850_000.0;
69
70/// Below this horizontal intensity, 6,000 nT, the report's caution zone around a magnetic pole
71/// begins (report, section 1.8).
72pub const CAUTION_HORIZONTAL_NT: f64 = 6_000.0;
73
74/// Below this horizontal intensity, 2,000 nT, the report's blackout zone begins: declination can
75/// be wrong by up to 180° (report, section 1.8).
76pub const BLACKOUT_HORIZONTAL_NT: f64 = 2_000.0;
77
78/// How far a compass, and so the declination, can be trusted at a place (report, section 1.8).
79///
80/// The report draws the zones on the ellipsoid's surface; at height the horizontal intensity
81/// weakens (to about 0.7 of the surface's at 850 km), so the zones there are wider than the
82/// report's. At a rocket's heights the difference is negligible.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85#[non_exhaustive]
86pub enum CompassZone {
87    /// Horizontal intensity of at least 6,000 nT.
88    Reliable,
89    /// Horizontal intensity from 2,000 to 6,000 nT: approaching a magnetic pole, where
90    /// declination errors exceed 1°.
91    Caution,
92    /// Horizontal intensity below 2,000 nT: near a magnetic pole, where declination errors of up
93    /// to 180° occur.
94    Blackout,
95}
96
97/// The magnetic elements at one place and time, and their rates of change.
98///
99/// Components are in the local geodetic north-east-down frame of the WGS 84 ellipsoid, as the
100/// report gives them (hpr's launch frame is east-north-up: see [`MagneticField::enu_nt`]); angles
101/// are in radians; rates are per year.
102#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
103#[non_exhaustive]
104pub struct MagneticField {
105    /// `X`, the northward component, nT.
106    pub north_nt: f64,
107    /// `Y`, the eastward component, nT.
108    pub east_nt: f64,
109    /// `Z`, the downward component, nT.
110    pub down_nt: f64,
111    /// `H = √(X² + Y²)`, the horizontal intensity, nT.
112    pub horizontal_nt: f64,
113    /// `F = √(H² + Z²)`, the total intensity, nT.
114    pub total_nt: f64,
115    /// `I = atan2(Z, H)`, the inclination or dip, rad, positive down.
116    pub inclination_rad: f64,
117    /// `D = atan2(Y, X)`, the declination, rad, positive east of true north.
118    pub declination_rad: f64,
119    /// The grid variation poleward of 55° (report, equation 1): `D − λ` north of 55° N, `D + λ`
120    /// south of 55° S, in `(−π, π]`; `None` elsewhere.
121    pub grid_variation_rad: Option<f64>,
122    /// `Ẋ`, nT per year.
123    pub north_rate_nt_per_year: f64,
124    /// `Ẏ`, nT per year.
125    pub east_rate_nt_per_year: f64,
126    /// `Ż`, nT per year.
127    pub down_rate_nt_per_year: f64,
128    /// `Ḣ = (X Ẋ + Y Ẏ) / H`, nT per year.
129    pub horizontal_rate_nt_per_year: f64,
130    /// `Ḟ = (X Ẋ + Y Ẏ + Z Ż) / F`, nT per year.
131    pub total_rate_nt_per_year: f64,
132    /// `İ = (H Ż − Z Ḣ) / F²`, rad per year.
133    pub inclination_rate_rad_per_year: f64,
134    /// `Ḋ = (X Ẏ − Y Ẋ) / H²`, rad per year. The grid variation changes at the same rate.
135    pub declination_rate_rad_per_year: f64,
136}
137
138impl MagneticField {
139    /// The field as east, north and up components, nT: `(Y, X, −Z)`, the axes of hpr's launch
140    /// frame (`docs/physics/frames.md`).
141    #[must_use]
142    pub fn enu_nt(&self) -> DVec3 {
143        DVec3::new(self.east_nt, self.north_nt, -self.down_nt)
144    }
145
146    /// The compass zone at this place, from the horizontal intensity (report, section 1.8). A
147    /// horizontal intensity that is not a number counts as the blackout zone.
148    #[must_use]
149    pub fn compass_zone(&self) -> CompassZone {
150        // NaN fails closed, into the blackout zone.
151        if self.horizontal_nt.is_nan() || self.horizontal_nt < BLACKOUT_HORIZONTAL_NT {
152            CompassZone::Blackout
153        } else if self.horizontal_nt < CAUTION_HORIZONTAL_NT {
154            CompassZone::Caution
155        } else {
156            CompassZone::Reliable
157        }
158    }
159
160    /// The model's own estimate of its declination error, one standard deviation, rad (report,
161    /// section 3.4, equation 43):
162    ///
163    /// ```text
164    /// δD = √(0.26² + (5417 / H)²)   degrees, with H in nT
165    /// ```
166    ///
167    /// About 0.29° where the field is strongest and growing without bound toward a magnetic pole.
168    /// It covers the coefficients' and the forecast's errors, the crust's local fields the model
169    /// leaves out, and magnetic storms; a steel rail or car beside a compass adds its own. The
170    /// report fits it on the ellipsoid's surface; at height it uses that height's `H`, which is
171    /// weaker, so the estimate grows a little (negligibly at a rocket's heights).
172    #[must_use]
173    pub fn declination_uncertainty_rad(&self) -> f64 {
174        0.26_f64.hypot(5_417.0 / self.horizontal_nt).to_radians()
175    }
176
177    /// A true bearing from a magnetic one: `true = magnetic + D`, rad, in `[0, 2π)`.
178    #[must_use]
179    pub fn true_from_magnetic_rad(&self, magnetic_bearing_rad: f64) -> f64 {
180        let bearing =
181            (magnetic_bearing_rad + self.declination_rad).rem_euclid(std::f64::consts::TAU);
182        // `rem_euclid` of a tiny negative number rounds up to 2π itself.
183        if bearing >= std::f64::consts::TAU {
184            0.0
185        } else {
186            bearing
187        }
188    }
189}
190
191/// The geocentric field components `(X′, Y′, Z′)` of one set of coefficients, nT or nT per year
192/// (report, equations 10 to 15).
193#[derive(Debug, Clone, Copy, PartialEq)]
194struct Geocentric {
195    north: f64,
196    east: f64,
197    down: f64,
198}
199
200/// What the spherical harmonic sums need at a point, shared by the field and its rate.
201#[derive(Debug)]
202struct Harmonics {
203    /// `P̆ₙᵐ(sin φ′)`, by `[n][m]`.
204    p: [[f64; DEGREE + 1]; DEGREE + 1],
205    /// `dP̆ₙᵐ(sin φ′)/dφ′`, by `[n][m]`.
206    dp: [[f64; DEGREE + 1]; DEGREE + 1],
207    /// `m P̆ₙᵐ(sin φ′) / cos φ′`, by `[n][m]`, finite at the poles.
208    p_over_cos: [[f64; DEGREE + 1]; DEGREE + 1],
209    /// `(a/r)^(n+2)`, by `n`.
210    radial: [f64; DEGREE + 1],
211    /// `cos mλ` and `sin mλ`, by `m`.
212    cos_m: [f64; DEGREE + 1],
213    sin_m: [f64; DEGREE + 1],
214}
215
216impl Harmonics {
217    #[expect(
218        clippy::needless_range_loop,
219        reason = "the recurrences index rows by degree and order, as the equations do"
220    )]
221    fn new(geocentric_latitude_rad: f64, radius_m: f64, longitude_rad: f64) -> Self {
222        let (mu, cos_lat) = geocentric_latitude_rad.sin_cos();
223        // cos φ′ ≥ 0 on [−π/2, π/2]; the clamp only removes a rounding below zero.
224        let s = cos_lat.max(0.0);
225
226        // q[n][m] = dᵐPₙ/dμᵐ, zero for m > n. In n at fixed m it obeys the recurrence of the
227        // associated Legendre functions (DLMF 14.10.3), (n − m) qₙᵐ = (2n − 1) μ qₙ₋₁ᵐ −
228        // (n + m − 1) qₙ₋₂ᵐ, divided through by (1 − μ²)^(m/2); it starts from qₘᵐ = (2m)!/(2ᵐ m!)
229        // = (2m − 1)!!, the m-th derivative of Pₘ's leading term (Rodrigues' formula, DLMF 18.5.5).
230        let mut q = [[0.0_f64; DEGREE + 2]; DEGREE + 1];
231        let mut double_factorial = 1.0;
232        for m in 0..=DEGREE {
233            if m > 0 {
234                double_factorial *= (2 * m - 1) as f64;
235            }
236            q[m][m] = double_factorial;
237            for n in (m + 1)..=DEGREE {
238                let previous = q[n - 1][m];
239                let before = if n >= m + 2 { q[n - 2][m] } else { 0.0 };
240                q[n][m] = ((2 * n - 1) as f64 * mu * previous - (n + m - 1) as f64 * before)
241                    / (n - m) as f64;
242            }
243        }
244
245        let mut p = [[0.0; DEGREE + 1]; DEGREE + 1];
246        let mut dp = [[0.0; DEGREE + 1]; DEGREE + 1];
247        let mut p_over_cos = [[0.0; DEGREE + 1]; DEGREE + 1];
248        for n in 1..=DEGREE {
249            for m in 0..=n {
250                // Schmidt semi-normalization (report, equation 5): √(2 (n − m)! / (n + m)!) for
251                // m > 0, and 1 for m = 0.
252                let norm = if m == 0 {
253                    1.0
254                } else {
255                    let ratio: f64 = ((n - m + 1)..=(n + m)).map(|k| k as f64).product();
256                    (2.0 / ratio).sqrt()
257                };
258                let s_m = s.powi(m as i32);
259                p[n][m] = norm * s_m * q[n][m];
260                // d/dφ′ [cosᵐφ′ qₙᵐ(sin φ′)] = cosᵐ⁺¹φ′ qₙᵐ⁺¹ − m sin φ′ cosᵐ⁻¹φ′ qₙᵐ.
261                let s_m_minus_1 = if m == 0 { 0.0 } else { s.powi(m as i32 - 1) };
262                dp[n][m] = norm * (s_m * s * q[n][m + 1] - m as f64 * mu * s_m_minus_1 * q[n][m]);
263                p_over_cos[n][m] = norm * m as f64 * s_m_minus_1 * q[n][m];
264            }
265        }
266
267        let ratio = REFERENCE_RADIUS_M / radius_m;
268        let mut radial = [0.0; DEGREE + 1];
269        let mut power = ratio * ratio;
270        for value in &mut radial[1..] {
271            power *= ratio;
272            *value = power;
273        }
274
275        let mut cos_m = [0.0; DEGREE + 1];
276        let mut sin_m = [0.0; DEGREE + 1];
277        for m in 0..=DEGREE {
278            let (sin, cos) = (m as f64 * longitude_rad).sin_cos();
279            cos_m[m] = cos;
280            sin_m[m] = sin;
281        }
282
283        Self {
284            p,
285            dp,
286            p_over_cos,
287            radial,
288            cos_m,
289            sin_m,
290        }
291    }
292
293    /// Equations 10 to 12 (and 13 to 15 with the rates as coefficients), for coefficients given
294    /// as `(g, h)` by `[n][m]`.
295    fn sum(
296        &self,
297        g: &[[f64; DEGREE + 1]; DEGREE + 1],
298        h: &[[f64; DEGREE + 1]; DEGREE + 1],
299    ) -> Geocentric {
300        let (mut north, mut east, mut down) = (0.0, 0.0, 0.0);
301        for n in 1..=DEGREE {
302            let (mut sum_north, mut sum_east, mut sum_down) = (0.0, 0.0, 0.0);
303            for m in 0..=n {
304                let cos_term = g[n][m] * self.cos_m[m] + h[n][m] * self.sin_m[m];
305                let sin_term = g[n][m] * self.sin_m[m] - h[n][m] * self.cos_m[m];
306                sum_north += cos_term * self.dp[n][m];
307                sum_east += sin_term * self.p_over_cos[n][m];
308                sum_down += cos_term * self.p[n][m];
309            }
310            north -= self.radial[n] * sum_north;
311            east += self.radial[n] * sum_east;
312            down -= (n + 1) as f64 * self.radial[n] * sum_down;
313        }
314        Geocentric { north, east, down }
315    }
316}
317
318/// The geocentric latitude `φ′` and radius `r` of a geodetic point on WGS 84 (report, equations
319/// 7 and 8).
320fn geocentric(point: Geodetic) -> (f64, f64) {
321    let ellipsoid = Ellipsoid::WGS84;
322    let e2 = ellipsoid.eccentricity_squared();
323    let rc = ellipsoid.prime_vertical_radius_m(point.latitude_rad);
324    let (sin_lat, cos_lat) = point.latitude_rad.sin_cos();
325    let p = (rc + point.height_m) * cos_lat;
326    let z = (rc * (1.0 - e2) + point.height_m) * sin_lat;
327    let r = p.hypot(z);
328    ((z / r).asin(), r)
329}
330
331/// Wraps an angle into `(−π, π]`, to within a unit in the last place of 2π for any finite angle.
332fn wrap_pi(angle_rad: f64) -> f64 {
333    let wrapped = angle_rad.rem_euclid(std::f64::consts::TAU);
334    if wrapped > std::f64::consts::PI {
335        wrapped - std::f64::consts::TAU
336    } else {
337        wrapped
338    }
339}
340
341impl MagneticModel {
342    /// The model's name, as its coefficient file's header gives it.
343    #[must_use]
344    pub fn name(&self) -> &'static str {
345        self.name
346    }
347
348    /// The epoch `t₀` of the main-field coefficients, decimal year.
349    #[must_use]
350    pub fn epoch_year(&self) -> f64 {
351        self.epoch_year
352    }
353
354    /// The end of the model's validity, decimal year.
355    #[must_use]
356    pub fn valid_until_year(&self) -> f64 {
357        self.valid_until_year
358    }
359
360    /// The Gauss coefficients and their rates at decimal year `t` (report, equation 9):
361    /// `g(t) = g(t₀) + (t − t₀) ġ`, and the same for `h`.
362    #[expect(
363        clippy::type_complexity,
364        reason = "four coefficient tables by [n][m], private to this module"
365    )]
366    fn coefficients_at(
367        &self,
368        decimal_year: f64,
369    ) -> (
370        [[f64; DEGREE + 1]; DEGREE + 1],
371        [[f64; DEGREE + 1]; DEGREE + 1],
372        [[f64; DEGREE + 1]; DEGREE + 1],
373        [[f64; DEGREE + 1]; DEGREE + 1],
374    ) {
375        let dt = decimal_year - self.epoch_year;
376        let mut g = [[0.0; DEGREE + 1]; DEGREE + 1];
377        let mut h = [[0.0; DEGREE + 1]; DEGREE + 1];
378        let mut g_dot = [[0.0; DEGREE + 1]; DEGREE + 1];
379        let mut h_dot = [[0.0; DEGREE + 1]; DEGREE + 1];
380        for n in 1..=DEGREE {
381            for m in 0..=n {
382                // Row n(n + 1)/2 − 1 + m holds (n, m): the 90 rows of n = 1 to 12, m = 0 to n, in
383                // order, so the index is below 90 by construction.
384                let [g0, h0, gd, hd] = self.coefficients[n * (n + 1) / 2 - 1 + m];
385                g[n][m] = g0 + dt * gd;
386                h[n][m] = h0 + dt * hd;
387                g_dot[n][m] = gd;
388                h_dot[n][m] = hd;
389            }
390        }
391        (g, h, g_dot, h_dot)
392    }
393
394    /// The magnetic elements at `point` (geodetic, on WGS 84, height above the ellipsoid) at time
395    /// `decimal_year` (report, section 1.2, equations 7 to 20).
396    ///
397    /// The geocentric components are
398    ///
399    /// ```text
400    /// X′ = −Σₙ (a/r)ⁿ⁺² Σₘ (gₙᵐ cos mλ + hₙᵐ sin mλ) dP̆ₙᵐ(sin φ′)/dφ′
401    /// Y′ = (1/cos φ′) Σₙ (a/r)ⁿ⁺² Σₘ m (gₙᵐ sin mλ − hₙᵐ cos mλ) P̆ₙᵐ(sin φ′)
402    /// Z′ = −Σₙ (n + 1)(a/r)ⁿ⁺² Σₘ (gₙᵐ cos mλ + hₙᵐ sin mλ) P̆ₙᵐ(sin φ′)
403    /// ```
404    ///
405    /// turned by `φ′ − φ` into the geodetic frame: `X = X′ cos(φ′ − φ) − Z′ sin(φ′ − φ)`,
406    /// `Y = Y′`, `Z = X′ sin(φ′ − φ) + Z′ cos(φ′ − φ)`. The rates are the same sums over `ġ` and
407    /// `ḣ`.
408    ///
409    /// At a pole the north and east directions follow the given longitude's meridian, as the
410    /// report's section 1.4 sets them.
411    ///
412    /// # Errors
413    ///
414    /// [`CoreError::Domain`] if `point` fails [`Geodetic::validated`], if `decimal_year` is outside
415    /// the model's validity (`[2025.0, 2030.0]` for WMM2025) or not finite, or if the height is
416    /// outside [`MIN_HEIGHT_M`] to [`MAX_HEIGHT_M`].
417    pub fn field(&self, point: Geodetic, decimal_year: f64) -> Result<MagneticField, CoreError> {
418        let point = point.validated()?;
419        if !(self.epoch_year..=self.valid_until_year).contains(&decimal_year) {
420            return Err(CoreError::Domain {
421                what: "decimal year for the magnetic model",
422                value: decimal_year,
423            });
424        }
425        if !(MIN_HEIGHT_M..=MAX_HEIGHT_M).contains(&point.height_m) {
426            return Err(CoreError::Domain {
427                what: "ellipsoidal height for the magnetic model (m)",
428                value: point.height_m,
429            });
430        }
431
432        // Any finite longitude is accepted; reduce it to a turn first (to within a unit in the last
433        // place of 2π), so that `m λ` stays small.
434        let longitude = point.longitude_rad.rem_euclid(std::f64::consts::TAU);
435        let (latitude_prime, radius) = geocentric(point);
436        let harmonics = Harmonics::new(latitude_prime, radius, longitude);
437        let (g, h, g_dot, h_dot) = self.coefficients_at(decimal_year);
438        let field = harmonics.sum(&g, &h);
439        let rate = harmonics.sum(&g_dot, &h_dot);
440
441        let (sin_turn, cos_turn) = (latitude_prime - point.latitude_rad).sin_cos();
442        let rotate = |v: Geocentric| {
443            (
444                v.north * cos_turn - v.down * sin_turn,
445                v.east,
446                v.north * sin_turn + v.down * cos_turn,
447            )
448        };
449        let (x, y, z) = rotate(field);
450        let (x_dot, y_dot, z_dot) = rotate(rate);
451
452        let horizontal = x.hypot(y);
453        let total = horizontal.hypot(z);
454        let declination = y.atan2(x);
455        let horizontal_rate = (x * x_dot + y * y_dot) / horizontal;
456        let latitude_deg = point.latitude_rad.to_degrees();
457        let grid_variation = if latitude_deg > 55.0 {
458            Some(wrap_pi(declination - longitude))
459        } else if latitude_deg < -55.0 {
460            Some(wrap_pi(declination + longitude))
461        } else {
462            None
463        };
464
465        Ok(MagneticField {
466            north_nt: x,
467            east_nt: y,
468            down_nt: z,
469            horizontal_nt: horizontal,
470            total_nt: total,
471            inclination_rad: z.atan2(horizontal),
472            declination_rad: declination,
473            grid_variation_rad: grid_variation,
474            north_rate_nt_per_year: x_dot,
475            east_rate_nt_per_year: y_dot,
476            down_rate_nt_per_year: z_dot,
477            horizontal_rate_nt_per_year: horizontal_rate,
478            total_rate_nt_per_year: (x * x_dot + y * y_dot + z * z_dot) / total,
479            inclination_rate_rad_per_year: (horizontal * z_dot - z * horizontal_rate)
480                / (total * total),
481            declination_rate_rad_per_year: (x * y_dot - y * x_dot) / (horizontal * horizontal),
482        })
483    }
484}
485
486/// A calendar date as a decimal year: `year + (d − 1) / L`, where `d` is the day of the year
487/// (1 for January 1) and `L` is 365 or 366. This is the start of the day. Outside the blackout
488/// zones around the magnetic poles ([`CompassZone`]), the declination changes within a day by a
489/// few thousandths of a degree at most, far below the model's own error.
490///
491/// # Errors
492///
493/// [`CoreError::Domain`] if `month` is not 1 to 12 or `day` is not a day of that month
494/// (Gregorian calendar).
495pub fn decimal_year(year: i32, month: u32, day: u32) -> Result<f64, CoreError> {
496    let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
497    let lengths = [
498        31,
499        if leap { 29 } else { 28 },
500        31,
501        30,
502        31,
503        30,
504        31,
505        31,
506        30,
507        31,
508        30,
509        31,
510    ];
511    let Some(&length) = month
512        .checked_sub(1)
513        .and_then(|index| lengths.get(index as usize))
514    else {
515        return Err(CoreError::Domain {
516            what: "month",
517            value: f64::from(month),
518        });
519    };
520    if day == 0 || day > length {
521        return Err(CoreError::Domain {
522            what: "day of the month",
523            value: f64::from(day),
524        });
525    }
526    let before: u32 = lengths[..(month - 1) as usize].iter().sum();
527    let days_in_year = if leap { 366.0 } else { 365.0 };
528    Ok(f64::from(year) + f64::from(before + day - 1) / days_in_year)
529}