sidereon_core/astro/time/civil.rs
1//! Civil-calendar conversions the GNSS bindings consume directly.
2//!
3//! These are the single source for the four civil-time conversions every
4//! language binding previously reimplemented (the Elixir `Sidereon.GNSS.Time`
5//! helpers, and their Python/C/WASM equivalents): split Julian date, continuous
6//! seconds since J2000, second-of-day, and fractional day-of-year. A thin
7//! binding marshals its native datetime into the `(year, month, day, hour,
8//! minute, second)` civil fields and calls these, instead of carrying its own
9//! calendar arithmetic.
10//!
11//! No leap-second shifting is applied: the epoch stays in whatever time scale
12//! the caller supplied it in (typically GPS time for the broadcast correction
13//! models). The full UTC -> TAI/TT/TDB/UT1 leap-aware conversion is the separate
14//! [`super::scales::TimeScales::from_utc`] path.
15//!
16//! The integer day-number step delegates to [`super::scales::julian_day_number`]
17//! (the existing Fliegel-style core primitive); the remaining arithmetic mirrors
18//! the binding reference operation order exactly, so a binding that switches to
19//! these helpers reproduces its previous values bit-for-bit.
20
21use super::model::{Instant, InstantRepr, JulianDateSplit, TimeModelError, TimeScale};
22use super::scales::julian_day_number;
23use crate::astro::constants::time::SECONDS_PER_DAY_I64;
24use crate::constants::{J2000_JD, SECONDS_PER_DAY};
25
26/// Julian Date of the Modified Julian Date origin (`MJD = JD - 2_400_000.5`).
27pub const MJD_JD_OFFSET: f64 = 2_400_000.5;
28
29/// Integer Julian Day Number of the J2000 calendar day (2000-01-01).
30pub const J2000_JULIAN_DAY_NUMBER: i64 = 2_451_545;
31
32/// Seconds from civil midnight of the J2000 day to the J2000 epoch (noon).
33pub const J2000_NOON_OFFSET_S: i64 = 43_200;
34
35/// Split Julian date `(jd_whole, fraction)` for a civil instant.
36///
37/// `jd_whole` is the `*.5` civil-midnight boundary of the day (the integer
38/// `julian_day_number` minus `0.5`) and `fraction` is the within-day part, the
39/// convention the SP3 reader and the RTK epoch axis use. The instant is
40/// `jd_whole + fraction` in the caller's own time scale; no leap second is
41/// applied.
42#[must_use]
43pub fn split_julian_date(
44 year: i32,
45 month: i32,
46 day: i32,
47 hour: i32,
48 minute: i32,
49 second: f64,
50) -> (f64, f64) {
51 let jd_whole = julian_day_number(year, month, day) as f64 - 0.5;
52 let day_seconds = hour as f64 * 3600.0 + minute as f64 * 60.0 + second;
53 let fraction = day_seconds / SECONDS_PER_DAY;
54 (jd_whole, fraction)
55}
56
57impl Instant {
58 /// A UTC [`Instant`] from civil-calendar fields.
59 ///
60 /// Marshals `(year, month, day, hour, minute, second)` through
61 /// [`split_julian_date`] into the split-Julian-date representation, tagged
62 /// [`TimeScale::Utc`]. This is the public entry a thin binding calls to build
63 /// the `epoch` argument for the ionosphere/troposphere dispatchers (for
64 /// example [`crate::atmosphere::ionosphere::ionosphere_delay`] and
65 /// [`crate::atmosphere::ionosphere::klobuchar`]) without reaching into the
66 /// crate-private split internals; it produces the same [`Instant`] as the
67 /// open-coded `split_julian_date` + [`Instant::from_julian_date`] path.
68 ///
69 /// No leap second is applied: the instant carries the civil fields as given,
70 /// the same no-leap contract as [`split_julian_date`]. An out-of-day clock
71 /// field whose residual leaves the one-day fraction window is rejected by
72 /// [`JulianDateSplit::new`].
73 pub fn from_utc_civil(
74 year: i32,
75 month: i32,
76 day: i32,
77 hour: i32,
78 minute: i32,
79 second: f64,
80 ) -> Result<Self, TimeModelError> {
81 let (jd_whole, fraction) = split_julian_date(year, month, day, hour, minute, second);
82 Ok(Self::from_julian_date(
83 TimeScale::Utc,
84 JulianDateSplit::new(jd_whole, fraction)?,
85 ))
86 }
87}
88
89/// Continuous seconds since the J2000 epoch (JD 2451545.0) for a civil instant.
90///
91/// This is the value the SPP solve consumes as
92/// [`crate::positioning::SolveInputs::t_rx_j2000_s`]. The whole-second part is
93/// formed in integer arithmetic (so a whole-second epoch is exact) and the
94/// sub-second remainder is added back, matching the binding reference order. The
95/// epoch stays in the caller's time scale; no leap second is applied.
96#[must_use]
97pub fn j2000_seconds(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: f64) -> f64 {
98 // A non-finite second has no integer whole part; propagate NaN rather than
99 // letting the saturating float-to-int cast below fabricate a finite result.
100 if !second.is_finite() {
101 return f64::NAN;
102 }
103 let whole_second = second.trunc();
104 let fraction = second - whole_second;
105 // J2000 (JD 2451545.0) sits at this day's noon plus an integer day count, so
106 // the day's noon is `(jdn - 2451545)` whole days from J2000; civil midnight
107 // is 43200 s earlier. All integer, then the within-day clock fields are added.
108 // Saturating arithmetic: for any real civil epoch these never saturate (so the
109 // whole-second result stays exact), and an absurd out-of-range second can no
110 // longer overflow-panic.
111 let noon_seconds_from_j2000 = (julian_day_number(year, month, day) - J2000_JD as i64)
112 .saturating_mul(SECONDS_PER_DAY as i64);
113 let day_seconds = (i64::from(hour) * 3600)
114 .saturating_add(i64::from(minute) * 60)
115 .saturating_add(whole_second as i64);
116 (noon_seconds_from_j2000
117 .saturating_sub(43_200)
118 .saturating_add(day_seconds)) as f64
119 + fraction
120}
121
122/// Second-of-day in `[0, 86400)` formed from the clock fields.
123///
124/// This is the value the SPP solve consumes as
125/// [`crate::positioning::SolveInputs::t_rx_second_of_day_s`] (the Klobuchar
126/// diurnal argument). Formed straight from `hour`, `minute`, and `second` so it
127/// is exact, with no split-Julian-date round trip.
128#[must_use]
129pub fn second_of_day(hour: i32, minute: i32, second: f64) -> f64 {
130 hour as f64 * 3600.0 + minute as f64 * 60.0 + second
131}
132
133/// Fractional day-of-year for a civil instant; January 1 00:00 is `1.0`.
134///
135/// This is the value the SPP solve consumes as
136/// [`crate::positioning::SolveInputs::day_of_year`] (the Niell troposphere
137/// seasonal argument). The integer day-of-year is the day-number difference from
138/// January 1 of the same year (exact integer arithmetic, leap-year independent),
139/// plus the within-day fraction.
140#[must_use]
141pub fn day_of_year(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: f64) -> f64 {
142 let integer_day_of_year =
143 (julian_day_number(year, month, day) - julian_day_number(year, 1, 1) + 1) as f64;
144 let sod = second_of_day(hour, minute, second);
145 integer_day_of_year + sod / SECONDS_PER_DAY
146}
147
148/// Integer day-of-year (January 1 is `1`) for a civil date.
149///
150/// The integer companion of [`day_of_year`]: it is the same day-number
151/// difference from January 1 (exact integer arithmetic, leap-year independent),
152/// without the within-day fraction. Replaces the cumulative-month-table copies
153/// in the IONEX and troposphere readers (verified bit-identical to that table).
154#[must_use]
155pub fn day_of_year_int(year: i32, month: i32, day: i32) -> i64 {
156 julian_day_number(year, month, day) - julian_day_number(year, 1, 1) + 1
157}
158
159/// Civil `(year, month, day)` from an integer Julian Day Number.
160///
161/// The Fliegel-Van Flandern inverse, the single home for the calendar-from-JDN
162/// step the SP3, IONEX, RINEX clock/nav, TCA, troposphere, and reduced-orbit
163/// writers each carried a private copy of. The two algebraic forms found in the
164/// tree (the `+68569` and `+32044` variants) are bit-identical across the
165/// non-negative JDN range; this is the `+68569` form.
166///
167/// Domain: JDN `>= 0` (every civil date from -4712 onward, i.e. every epoch any
168/// real ephemeris can occupy, since [`super::scales::julian_day_number`] never
169/// emits a negative JDN). Exact inverse of `julian_day_number` across that
170/// range. Negative JDN is non-physical and not supported: the Fliegel inverse's
171/// truncating integer division would diverge from the floor convention there.
172#[must_use]
173pub fn civil_from_julian_day_number(jdn: i64) -> (i64, i64, i64) {
174 let l = jdn + 68_569;
175 let n = 4 * l / 146_097;
176 let l = l - (146_097 * n + 3) / 4;
177 let i = 4_000 * (l + 1) / 1_461_001;
178 let l = l - 1_461 * i / 4 + 31;
179 let j = 80 * l / 2_447;
180 let day = l - 2_447 * j / 80;
181 let l = j / 11;
182 let month = j + 2 - 12 * l;
183 let year = 100 * (n - 49) + i + l;
184 (year, month, day)
185}
186
187/// Civil `(year, month, day, hour, minute, second)` from continuous integer
188/// seconds since the J2000 epoch.
189///
190/// Exact inverse of [`j2000_seconds`] for a whole-second epoch: the J2000 noon
191/// origin is shifted to civil midnight, the calendar date follows from
192/// [`civil_from_julian_day_number`], and the within-day clock fields are read
193/// from the floored second-of-day. No leap second is applied (the epoch stays in
194/// the caller's own time scale, the same no-leap contract as the forward
195/// conversions).
196#[must_use]
197pub fn civil_from_j2000_seconds(seconds: i64) -> (i64, i64, i64, i64, i64, i64) {
198 let from_midnight = seconds + J2000_NOON_OFFSET_S;
199 let day_index = from_midnight.div_euclid(SECONDS_PER_DAY_I64);
200 let second_of_day = from_midnight.rem_euclid(SECONDS_PER_DAY_I64);
201 let (year, month, day) = civil_from_julian_day_number(day_index + J2000_JULIAN_DAY_NUMBER);
202 let hour = second_of_day / 3_600;
203 let minute = (second_of_day % 3_600) / 60;
204 let second = second_of_day % 60;
205 (year, month, day, hour, minute, second)
206}
207
208/// Modified Julian Date from a Julian Date (`MJD = JD - 2_400_000.5`).
209#[must_use]
210pub fn mjd_from_jd(jd: f64) -> f64 {
211 jd - MJD_JD_OFFSET
212}
213
214/// Continuous seconds since J2000 from a split Julian date `(jd_whole,
215/// fraction)`.
216///
217/// The pure arithmetic core behind [`crate::observables::j2000_seconds_from_split`]
218/// (which keeps the public-facing finiteness validation). Whole-day and
219/// fractional parts are each scaled to seconds and summed, matching the split
220/// the SP3 reader and RTK epoch axis carry.
221#[must_use]
222pub fn j2000_seconds_from_split(jd_whole: f64, fraction: f64) -> f64 {
223 (jd_whole - J2000_JD) * SECONDS_PER_DAY + fraction * SECONDS_PER_DAY
224}
225
226/// Elapsed seconds between two split Julian dates `later - earlier`.
227///
228/// The whole-day and fractional differences are summed first and scaled once
229/// (`(dwhole + dfrac) * 86400`), the policy the RINEX clock interpolation and
230/// reduced-orbit fit duration share. (The J2000-seconds conversion
231/// [`j2000_seconds_from_split`] scales each part separately; that ordering is
232/// kept distinct because the two are not bit-identical in the last place.)
233#[must_use]
234pub fn seconds_between_splits(
235 later_whole: f64,
236 later_fraction: f64,
237 earlier_whole: f64,
238 earlier_fraction: f64,
239) -> f64 {
240 ((later_whole - earlier_whole) + (later_fraction - earlier_fraction)) * SECONDS_PER_DAY
241}
242
243/// Split Julian date `(jd_whole, fraction)` from continuous integer seconds
244/// since the J2000 epoch.
245///
246/// The inverse of the J2000-seconds direction expressed in the split form the
247/// SP3 reader and RTK epoch axis carry: the day count places the integer JD
248/// boundary and the residual within-day seconds become the fraction. `jd_whole`
249/// is a `*.0` boundary here (J2000 noon is JD 2451545.0), matching the IONEX
250/// reader's epoch reconstruction. No leap second is applied.
251#[must_use]
252pub fn split_julian_date_from_j2000_seconds(seconds: i64) -> (f64, f64) {
253 let days = seconds.div_euclid(SECONDS_PER_DAY_I64);
254 let rem_s = seconds.rem_euclid(SECONDS_PER_DAY_I64);
255 (J2000_JD + days as f64, rem_s as f64 / SECONDS_PER_DAY)
256}
257
258/// Civil `(year, month, day, hour, minute, second)` from a split Julian date.
259///
260/// `jd_whole` is the `*.5` civil-midnight boundary and `fraction` is the
261/// within-day part (the [`split_julian_date`] convention). The integer Julian
262/// Day Number is recovered from the boundary, the calendar date from
263/// [`civil_from_julian_day_number`], and the clock fields from the floored
264/// within-day seconds (so the fractional second is carried in `second`). This is
265/// the single home for the floor-based split-to-civil decomposition the SGP4 TCA
266/// path and reduced-orbit fit each open-coded. No leap second is applied.
267#[must_use]
268pub fn civil_from_split_julian_date(
269 jd_whole: f64,
270 fraction: f64,
271) -> (i64, i64, i64, i64, i64, f64) {
272 // Precondition: `jd_whole` is the `*.5` civil-midnight boundary (the
273 // `split_julian_date` convention). Do NOT pass the `*.0` noon boundary
274 // produced by `split_julian_date_from_j2000_seconds` (the IONEX reader's
275 // convention): `(jd_whole + 0.5).round()` would land on the wrong day. The
276 // guard catches that accidental composition in debug/test builds.
277 debug_assert!(
278 (jd_whole.fract().abs() - 0.5).abs() < 1e-6,
279 "civil_from_split_julian_date expects a *.5 civil-midnight jd_whole, not a *.0 noon boundary"
280 );
281 let jdn = (jd_whole + 0.5).round() as i64;
282 let (year, month, day) = civil_from_julian_day_number(jdn);
283 let seconds_of_day = fraction * SECONDS_PER_DAY;
284 let hour = (seconds_of_day / 3600.0).floor() as i64;
285 let minute = ((seconds_of_day - hour as f64 * 3600.0) / 60.0).floor() as i64;
286 let second = seconds_of_day - hour as f64 * 3600.0 - minute as f64 * 60.0;
287 (year, month, day, hour, minute, second)
288}
289
290/// Advance a split Julian date `(jd_whole, fraction)` by `seconds`, renormalizing
291/// the carry back onto the whole-day boundary.
292///
293/// The seconds are scaled to a day fraction, added to the residual, and the
294/// integer-day carry floored back into `jd_whole`. This is the policy the SGP4
295/// TCA frame-derivative step uses; the arithmetic order is preserved so the
296/// stepped epoch is bit-identical to the open-coded form.
297#[must_use]
298pub fn split_julian_date_add_seconds(jd_whole: f64, fraction: f64, seconds: f64) -> (f64, f64) {
299 let mut whole = jd_whole;
300 let mut fraction = fraction + seconds / SECONDS_PER_DAY;
301 let carry = fraction.floor();
302 whole += carry;
303 fraction -= carry;
304 (whole, fraction)
305}
306
307/// Single-`f64` Julian date carried by an [`Instant`], in the instant's own
308/// scale.
309///
310/// A split-Julian-date instant recombines its two parts; an integer-nanosecond
311/// instant counts nanoseconds from the J2000 Julian-date origin (the
312/// IONEX/SP3/RINEX-clock nanosecond convention). This is the single home for the
313/// instant-to-Julian-date reduction the troposphere and IONEX seasonal terms
314/// each open-coded.
315#[must_use]
316pub fn julian_date_from_instant(epoch: Instant) -> f64 {
317 match epoch.repr {
318 InstantRepr::JulianDate(split) => split.jd_whole + split.fraction,
319 InstantRepr::Nanos(nanos) => (nanos as f64) / 1.0e9 / SECONDS_PER_DAY + J2000_JD,
320 }
321}
322
323/// Fractional day-of-year carried by an [`Instant`]; January 1 00:00 is `1.0`.
324///
325/// The noon Julian-date origin is shifted to midnight, split into an integer day
326/// and the within-day fraction, and the integer day-of-year recovered from the
327/// calendar date. This is the single home for the seasonal day-of-year argument
328/// the Niell troposphere and IONEX diurnal terms each open-coded.
329#[must_use]
330pub fn fractional_day_of_year_from_instant(epoch: Instant) -> f64 {
331 let jd = julian_date_from_instant(epoch);
332 let jd_midnight = jd + 0.5;
333 let day_floor = jd_midnight.floor();
334 let day_fraction = jd_midnight - day_floor;
335
336 let (year, month, day) = civil_from_julian_day_number(day_floor as i64);
337 let doy_integer = day_of_year_int(year as i32, month as i32, day as i32);
338 doy_integer as f64 + day_fraction
339}
340
341/// Second-of-day in `[0, 86400)` carried by an [`Instant`], in its own scale.
342///
343/// A split-Julian-date instant shifts the noon day origin to midnight and keeps
344/// the within-day part; an integer-nanosecond instant reduces by the
345/// seconds-per-day modulus (exact). This is the single home for the diurnal
346/// second-of-day argument the IONEX Klobuchar term open-coded.
347#[must_use]
348pub fn second_of_day_from_instant(epoch: Instant) -> f64 {
349 match epoch.repr {
350 InstantRepr::JulianDate(jd) => {
351 let from_midnight = jd.jd_whole + 0.5 + jd.fraction;
352 let day_fraction = from_midnight - from_midnight.floor();
353 day_fraction * SECONDS_PER_DAY
354 }
355 InstantRepr::Nanos(nanos) => {
356 let ns_per_day: i128 = 86_400 * 1_000_000_000;
357 let mut rem = nanos % ns_per_day;
358 if rem < 0 {
359 rem += ns_per_day;
360 }
361 rem as f64 / 1.0e9
362 }
363 }
364}
365
366/// Gregorian leap-year test (proleptic, `%4`/`%100`/`%400` rule).
367#[must_use]
368pub const fn is_leap_year(year: i64) -> bool {
369 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
370}
371
372/// Days in a civil month; `0` for an out-of-range month index.
373#[must_use]
374pub const fn days_in_month(year: i64, month: i64) -> i64 {
375 match month {
376 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
377 4 | 6 | 9 | 11 => 30,
378 2 if is_leap_year(year) => 29,
379 2 => 28,
380 _ => 0,
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn split_jd_j2000_noon_is_exact() {
390 // 2000-01-01 12:00:00 -> JD 2451545.0 -> whole 2451544.5, fraction 0.5.
391 let (whole, frac) = split_julian_date(2000, 1, 1, 12, 0, 0.0);
392 assert_eq!(whole, 2_451_544.5);
393 assert_eq!(frac, 0.5);
394 assert_eq!(whole + frac, J2000_JD);
395 }
396
397 #[test]
398 fn j2000_seconds_epoch_is_zero() {
399 assert_eq!(j2000_seconds(2000, 1, 1, 12, 0, 0.0), 0.0);
400 // Whole-second epochs land on integers.
401 assert_eq!(j2000_seconds(2000, 1, 2, 12, 0, 0.0), 86_400.0);
402 // Sub-second remainder is carried.
403 assert_eq!(j2000_seconds(2000, 1, 1, 12, 0, 0.25), 0.25);
404 }
405
406 #[test]
407 fn second_of_day_is_clock_arithmetic() {
408 assert_eq!(second_of_day(0, 0, 0.0), 0.0);
409 assert_eq!(second_of_day(1, 2, 3.5), 3723.5);
410 }
411
412 #[test]
413 fn day_of_year_jan1_midnight_is_one() {
414 assert_eq!(day_of_year(2021, 1, 1, 0, 0, 0.0), 1.0);
415 // Noon on Jan 1 is 1.5 day-of-year.
416 assert_eq!(day_of_year(2021, 1, 1, 12, 0, 0.0), 1.5);
417 // March 1 in a leap year is day 61.
418 assert_eq!(day_of_year(2020, 3, 1, 0, 0, 0.0), 61.0);
419 // March 1 in a common year is day 60.
420 assert_eq!(day_of_year(2021, 3, 1, 0, 0, 0.0), 60.0);
421 }
422
423 #[test]
424 fn day_of_year_int_matches_fractional_and_cumulative_table() {
425 // Cumulative-month-day reference (the table the IONEX/tropo copies used).
426 fn cum_table(year: i64, month: i64, day: i64) -> i64 {
427 const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
428 let leap = is_leap_year(year);
429 let mut doy = CUM[(month - 1) as usize] + day;
430 if leap && month > 2 {
431 doy += 1;
432 }
433 doy
434 }
435 let cases: [(i64, i64, i64); 7] = [
436 (2000, 1, 1),
437 (2000, 3, 1),
438 (2001, 3, 1),
439 (2020, 12, 31),
440 (1999, 6, 15),
441 (2100, 3, 1),
442 (2400, 2, 29),
443 ];
444 for (y, m, d) in cases {
445 assert_eq!(
446 day_of_year_int(y as i32, m as i32, d as i32),
447 cum_table(y, m, d)
448 );
449 assert_eq!(
450 day_of_year_int(y as i32, m as i32, d as i32) as f64,
451 day_of_year(y as i32, m as i32, d as i32, 0, 0, 0.0)
452 );
453 }
454 }
455
456 #[test]
457 fn civil_from_jdn_inverts_julian_day_number() {
458 let cases: [(i32, i64, i64); 7] = [
459 (2000, 1, 1),
460 (1980, 1, 6),
461 (2006, 1, 1),
462 (1970, 1, 1),
463 (2024, 2, 29),
464 (1, 1, 1),
465 (2099, 12, 31),
466 ];
467 for (y, m, d) in cases {
468 let jdn = julian_day_number(y, m as i32, d as i32);
469 assert_eq!(civil_from_julian_day_number(jdn), (i64::from(y), m, d));
470 }
471 // The +32044 algebraic variant (rinex_clock) agrees bit-for-bit.
472 for jdn in (2_400_000..2_500_000).step_by(37) {
473 let a = jdn + 32_044;
474 let b = (4 * a + 3) / 146_097;
475 let c = a - (146_097 * b) / 4;
476 let dd = (4 * c + 3) / 1461;
477 let e = c - (1461 * dd) / 4;
478 let mm = (5 * e + 2) / 153;
479 let day = e - (153 * mm + 2) / 5 + 1;
480 let month = mm + 3 - 12 * (mm / 10);
481 let year = 100 * b + dd - 4800 + mm / 10;
482 assert_eq!(civil_from_julian_day_number(jdn), (year, month, day));
483 }
484 }
485
486 #[test]
487 fn civil_from_j2000_seconds_inverts_j2000_seconds() {
488 // J2000 epoch itself, a day later, and a pre-J2000 negative count.
489 assert_eq!(civil_from_j2000_seconds(0), (2000, 1, 1, 12, 0, 0));
490 assert_eq!(civil_from_j2000_seconds(86_400), (2000, 1, 2, 12, 0, 0));
491 // 2020-06-25 00:00:00 UTC is 646_315_200 J2000 seconds (IONEX parser pin).
492 assert_eq!(
493 civil_from_j2000_seconds(646_315_200),
494 (2020, 6, 25, 0, 0, 0)
495 );
496 // Round-trip whole-second civil instants through the forward conversion.
497 let cases: [(i32, i64, i64, i64, i64, i64); 4] = [
498 (2000, 1, 1, 12, 0, 0),
499 (1999, 12, 31, 23, 59, 59),
500 (2024, 2, 29, 6, 30, 15),
501 (2030, 7, 4, 0, 0, 1),
502 ];
503 for (y, m, d, h, mi, s) in cases {
504 let secs = j2000_seconds(y, m as i32, d as i32, h as i32, mi as i32, s as f64) as i64;
505 assert_eq!(
506 civil_from_j2000_seconds(secs),
507 (i64::from(y), m, d, h, mi, s)
508 );
509 }
510 }
511
512 #[test]
513 fn mjd_and_leap_and_month_helpers() {
514 assert_eq!(mjd_from_jd(J2000_JD), 51_544.5);
515 assert!(is_leap_year(2000) && is_leap_year(2024) && !is_leap_year(2100));
516 assert!(!is_leap_year(2023));
517 assert_eq!(days_in_month(2024, 2), 29);
518 assert_eq!(days_in_month(2023, 2), 28);
519 assert_eq!(days_in_month(2024, 4), 30);
520 assert_eq!(days_in_month(2024, 13), 0);
521 }
522
523 #[test]
524 fn split_from_j2000_seconds_inverts_field_form() {
525 // J2000 epoch (noon) and a day later land on *.0 boundaries.
526 assert_eq!(split_julian_date_from_j2000_seconds(0), (J2000_JD, 0.0));
527 assert_eq!(
528 split_julian_date_from_j2000_seconds(86_400),
529 (J2000_JD + 1.0, 0.0)
530 );
531 // 2020-06-25 00:00:00 (646_315_200 s) sits half a day before its noon JD.
532 let (whole, frac) = split_julian_date_from_j2000_seconds(646_315_200);
533 assert_eq!(j2000_seconds_from_split(whole, frac), 646_315_200.0);
534 }
535
536 #[test]
537 fn civil_from_split_round_trips_split_julian_date() {
538 let cases: [(i32, i32, i32, i32, i32, f64); 4] = [
539 (2000, 1, 1, 12, 0, 0.0),
540 (2020, 6, 25, 0, 0, 0.0),
541 (2024, 2, 29, 6, 30, 15.0),
542 (1999, 12, 31, 23, 59, 59.0),
543 ];
544 for (y, mo, d, h, mi, s) in cases {
545 let (whole, frac) = split_julian_date(y, mo, d, h, mi, s);
546 let civil = civil_from_split_julian_date(whole, frac);
547 assert_eq!(
548 civil,
549 (
550 i64::from(y),
551 i64::from(mo),
552 i64::from(d),
553 i64::from(h),
554 i64::from(mi),
555 s
556 )
557 );
558 }
559 }
560
561 #[test]
562 fn split_add_seconds_carries_across_midnight() {
563 // Add 6 hours within a day: no carry.
564 let (w, f) = split_julian_date_add_seconds(2_451_544.5, 0.25, 6.0 * 3600.0);
565 assert_eq!((w, f), (2_451_544.5, 0.5));
566 // Add 18 hours from 12:00: carry one whole day.
567 let (w, f) = split_julian_date_add_seconds(2_451_544.5, 0.5, 18.0 * 3600.0);
568 assert_eq!(w, 2_451_545.5);
569 assert!((f - 0.25).abs() < 1e-12);
570 }
571
572 #[test]
573 fn instant_julian_date_and_doy_and_sod() {
574 use super::super::model::{JulianDateSplit, TimeScale};
575 // 2020-06-25 06:00:00, stored as a split Julian date.
576 let (whole, frac) = split_julian_date(2020, 6, 25, 6, 0, 0.0);
577 let epoch = Instant::from_julian_date(
578 TimeScale::Gpst,
579 JulianDateSplit::new(whole, frac).expect("valid split"),
580 );
581 assert_eq!(julian_date_from_instant(epoch), whole + frac);
582 // 2020 is a leap year; June 25 is day 177, plus 6h = 0.25 day fraction.
583 assert!((fractional_day_of_year_from_instant(epoch) - (177.0 + 0.25)).abs() < 1e-9);
584 assert!((second_of_day_from_instant(epoch) - 6.0 * 3600.0).abs() < 1e-6);
585 }
586
587 #[test]
588 fn from_utc_civil_matches_open_coded_path_and_accessors() {
589 use super::super::model::{JulianDateSplit, TimeScale};
590 let cases: [(i32, i32, i32, i32, i32, f64); 4] = [
591 (2000, 1, 1, 12, 0, 0.0),
592 (2020, 6, 25, 6, 0, 0.0),
593 (2024, 2, 29, 6, 30, 15.0),
594 (1999, 12, 31, 23, 59, 59.0),
595 ];
596 for (y, mo, d, h, mi, s) in cases {
597 let instant = Instant::from_utc_civil(y, mo, d, h, mi, s).expect("valid civil instant");
598 // Identical to the open-coded split + from_julian_date path.
599 let (whole, frac) = split_julian_date(y, mo, d, h, mi, s);
600 let expected = Instant::from_julian_date(
601 TimeScale::Utc,
602 JulianDateSplit::new(whole, frac).expect("valid split"),
603 );
604 assert_eq!(instant, expected);
605 assert_eq!(instant.scale, TimeScale::Utc);
606 // Round-trips through the existing instant accessors.
607 assert_eq!(
608 instant.julian_date(),
609 Some(JulianDateSplit {
610 jd_whole: whole,
611 fraction: frac
612 })
613 );
614 assert_eq!(julian_date_from_instant(instant), whole + frac);
615 let civil = civil_from_split_julian_date(whole, frac);
616 assert_eq!(
617 civil,
618 (
619 i64::from(y),
620 i64::from(mo),
621 i64::from(d),
622 i64::from(h),
623 i64::from(mi),
624 s
625 )
626 );
627 }
628 }
629
630 #[test]
631 fn from_utc_civil_rejects_out_of_day_clock_field() {
632 // hour 25 pushes the day fraction past the one-day residual window.
633 assert!(Instant::from_utc_civil(2020, 6, 25, 25, 0, 0.0).is_err());
634 }
635
636 #[test]
637 fn j2000_seconds_from_split_matches_field_form() {
638 // A split built from a civil instant scales back to the same seconds the
639 // civil-fields conversion produces.
640 let (whole, frac) = split_julian_date(2020, 6, 25, 0, 0, 0.0);
641 assert_eq!(
642 j2000_seconds_from_split(whole, frac),
643 j2000_seconds(2020, 6, 25, 0, 0, 0.0)
644 );
645 }
646}