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}