Skip to main content

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, SECONDS_PER_HOUR, SECONDS_PER_MINUTE};
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 * SECONDS_PER_HOUR + minute as f64 * SECONDS_PER_MINUTE + 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 * SECONDS_PER_HOUR + minute as f64 * SECONDS_PER_MINUTE + 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 / SECONDS_PER_HOUR).floor() as i64;
285    let minute =
286        ((seconds_of_day - hour as f64 * SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).floor() as i64;
287    let second =
288        seconds_of_day - hour as f64 * SECONDS_PER_HOUR - minute as f64 * SECONDS_PER_MINUTE;
289    (year, month, day, hour, minute, second)
290}
291
292/// Advance a split Julian date `(jd_whole, fraction)` by `seconds`, renormalizing
293/// the carry back onto the whole-day boundary.
294///
295/// The seconds are scaled to a day fraction, added to the residual, and the
296/// integer-day carry floored back into `jd_whole`. This is the policy the SGP4
297/// TCA frame-derivative step uses; the arithmetic order is preserved so the
298/// stepped epoch is bit-identical to the open-coded form.
299#[must_use]
300pub fn split_julian_date_add_seconds(jd_whole: f64, fraction: f64, seconds: f64) -> (f64, f64) {
301    let mut whole = jd_whole;
302    let mut fraction = fraction + seconds / SECONDS_PER_DAY;
303    let carry = fraction.floor();
304    whole += carry;
305    fraction -= carry;
306    (whole, fraction)
307}
308
309/// Single-`f64` Julian date carried by an [`Instant`], in the instant's own
310/// scale.
311///
312/// A split-Julian-date instant recombines its two parts; an integer-nanosecond
313/// instant counts nanoseconds from the J2000 Julian-date origin (the
314/// IONEX/SP3/RINEX-clock nanosecond convention). This is the single home for the
315/// instant-to-Julian-date reduction the troposphere and IONEX seasonal terms
316/// each open-coded.
317#[must_use]
318pub fn julian_date_from_instant(epoch: Instant) -> f64 {
319    match epoch.repr {
320        InstantRepr::JulianDate(split) => split.jd_whole + split.fraction,
321        InstantRepr::Nanos(nanos) => (nanos as f64) / 1.0e9 / SECONDS_PER_DAY + J2000_JD,
322    }
323}
324
325/// Fractional day-of-year carried by an [`Instant`]; January 1 00:00 is `1.0`.
326///
327/// The noon Julian-date origin is shifted to midnight, split into an integer day
328/// and the within-day fraction, and the integer day-of-year recovered from the
329/// calendar date. This is the single home for the seasonal day-of-year argument
330/// the Niell troposphere and IONEX diurnal terms each open-coded.
331#[must_use]
332pub fn fractional_day_of_year_from_instant(epoch: Instant) -> f64 {
333    let jd = julian_date_from_instant(epoch);
334    let jd_midnight = jd + 0.5;
335    let day_floor = jd_midnight.floor();
336    let day_fraction = jd_midnight - day_floor;
337
338    let (year, month, day) = civil_from_julian_day_number(day_floor as i64);
339    let doy_integer = day_of_year_int(year as i32, month as i32, day as i32);
340    doy_integer as f64 + day_fraction
341}
342
343/// Second-of-day in `[0, 86400)` carried by an [`Instant`], in its own scale.
344///
345/// A split-Julian-date instant shifts the noon day origin to midnight and keeps
346/// the within-day part; an integer-nanosecond instant reduces by the
347/// seconds-per-day modulus (exact). This is the single home for the diurnal
348/// second-of-day argument the IONEX Klobuchar term open-coded.
349#[must_use]
350pub fn second_of_day_from_instant(epoch: Instant) -> f64 {
351    match epoch.repr {
352        InstantRepr::JulianDate(jd) => {
353            let from_midnight = jd.jd_whole + 0.5 + jd.fraction;
354            let day_fraction = from_midnight - from_midnight.floor();
355            day_fraction * SECONDS_PER_DAY
356        }
357        InstantRepr::Nanos(nanos) => {
358            let ns_per_day: i128 = 86_400 * 1_000_000_000;
359            let mut rem = nanos % ns_per_day;
360            if rem < 0 {
361                rem += ns_per_day;
362            }
363            rem as f64 / 1.0e9
364        }
365    }
366}
367
368/// Gregorian leap-year test (proleptic, `%4`/`%100`/`%400` rule).
369#[must_use]
370pub const fn is_leap_year(year: i64) -> bool {
371    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
372}
373
374/// Days in a civil month; `0` for an out-of-range month index.
375#[must_use]
376pub const fn days_in_month(year: i64, month: i64) -> i64 {
377    match month {
378        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
379        4 | 6 | 9 | 11 => 30,
380        2 if is_leap_year(year) => 29,
381        2 => 28,
382        _ => 0,
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn split_jd_j2000_noon_is_exact() {
392        // 2000-01-01 12:00:00 -> JD 2451545.0 -> whole 2451544.5, fraction 0.5.
393        let (whole, frac) = split_julian_date(2000, 1, 1, 12, 0, 0.0);
394        assert_eq!(whole, 2_451_544.5);
395        assert_eq!(frac, 0.5);
396        assert_eq!(whole + frac, J2000_JD);
397    }
398
399    #[test]
400    fn j2000_seconds_epoch_is_zero() {
401        assert_eq!(j2000_seconds(2000, 1, 1, 12, 0, 0.0), 0.0);
402        // Whole-second epochs land on integers.
403        assert_eq!(j2000_seconds(2000, 1, 2, 12, 0, 0.0), SECONDS_PER_DAY);
404        // Sub-second remainder is carried.
405        assert_eq!(j2000_seconds(2000, 1, 1, 12, 0, 0.25), 0.25);
406    }
407
408    #[test]
409    fn second_of_day_is_clock_arithmetic() {
410        assert_eq!(second_of_day(0, 0, 0.0), 0.0);
411        assert_eq!(second_of_day(1, 2, 3.5), 3723.5);
412    }
413
414    #[test]
415    fn day_of_year_jan1_midnight_is_one() {
416        assert_eq!(day_of_year(2021, 1, 1, 0, 0, 0.0), 1.0);
417        // Noon on Jan 1 is 1.5 day-of-year.
418        assert_eq!(day_of_year(2021, 1, 1, 12, 0, 0.0), 1.5);
419        // March 1 in a leap year is day 61.
420        assert_eq!(day_of_year(2020, 3, 1, 0, 0, 0.0), 61.0);
421        // March 1 in a common year is day 60.
422        assert_eq!(day_of_year(2021, 3, 1, 0, 0, 0.0), 60.0);
423    }
424
425    #[test]
426    fn day_of_year_int_matches_fractional_and_cumulative_table() {
427        // Cumulative-month-day reference (the table the IONEX/tropo copies used).
428        fn cum_table(year: i64, month: i64, day: i64) -> i64 {
429            const CUM: [i64; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
430            let leap = is_leap_year(year);
431            let mut doy = CUM[(month - 1) as usize] + day;
432            if leap && month > 2 {
433                doy += 1;
434            }
435            doy
436        }
437        let cases: [(i64, i64, i64); 7] = [
438            (2000, 1, 1),
439            (2000, 3, 1),
440            (2001, 3, 1),
441            (2020, 12, 31),
442            (1999, 6, 15),
443            (2100, 3, 1),
444            (2400, 2, 29),
445        ];
446        for (y, m, d) in cases {
447            assert_eq!(
448                day_of_year_int(y as i32, m as i32, d as i32),
449                cum_table(y, m, d)
450            );
451            assert_eq!(
452                day_of_year_int(y as i32, m as i32, d as i32) as f64,
453                day_of_year(y as i32, m as i32, d as i32, 0, 0, 0.0)
454            );
455        }
456    }
457
458    #[test]
459    fn civil_from_jdn_inverts_julian_day_number() {
460        let cases: [(i32, i64, i64); 7] = [
461            (2000, 1, 1),
462            (1980, 1, 6),
463            (2006, 1, 1),
464            (1970, 1, 1),
465            (2024, 2, 29),
466            (1, 1, 1),
467            (2099, 12, 31),
468        ];
469        for (y, m, d) in cases {
470            let jdn = julian_day_number(y, m as i32, d as i32);
471            assert_eq!(civil_from_julian_day_number(jdn), (i64::from(y), m, d));
472        }
473        // The +32044 algebraic variant (rinex_clock) agrees bit-for-bit.
474        for jdn in (2_400_000..2_500_000).step_by(37) {
475            let a = jdn + 32_044;
476            let b = (4 * a + 3) / 146_097;
477            let c = a - (146_097 * b) / 4;
478            let dd = (4 * c + 3) / 1461;
479            let e = c - (1461 * dd) / 4;
480            let mm = (5 * e + 2) / 153;
481            let day = e - (153 * mm + 2) / 5 + 1;
482            let month = mm + 3 - 12 * (mm / 10);
483            let year = 100 * b + dd - 4800 + mm / 10;
484            assert_eq!(civil_from_julian_day_number(jdn), (year, month, day));
485        }
486    }
487
488    #[test]
489    fn civil_from_j2000_seconds_inverts_j2000_seconds() {
490        // J2000 epoch itself, a day later, and a pre-J2000 negative count.
491        assert_eq!(civil_from_j2000_seconds(0), (2000, 1, 1, 12, 0, 0));
492        assert_eq!(civil_from_j2000_seconds(86_400), (2000, 1, 2, 12, 0, 0));
493        // 2020-06-25 00:00:00 UTC is 646_315_200 J2000 seconds (IONEX parser pin).
494        assert_eq!(
495            civil_from_j2000_seconds(646_315_200),
496            (2020, 6, 25, 0, 0, 0)
497        );
498        // Round-trip whole-second civil instants through the forward conversion.
499        let cases: [(i32, i64, i64, i64, i64, i64); 4] = [
500            (2000, 1, 1, 12, 0, 0),
501            (1999, 12, 31, 23, 59, 59),
502            (2024, 2, 29, 6, 30, 15),
503            (2030, 7, 4, 0, 0, 1),
504        ];
505        for (y, m, d, h, mi, s) in cases {
506            let secs = j2000_seconds(y, m as i32, d as i32, h as i32, mi as i32, s as f64) as i64;
507            assert_eq!(
508                civil_from_j2000_seconds(secs),
509                (i64::from(y), m, d, h, mi, s)
510            );
511        }
512    }
513
514    #[test]
515    fn mjd_and_leap_and_month_helpers() {
516        assert_eq!(mjd_from_jd(J2000_JD), 51_544.5);
517        assert!(is_leap_year(2000) && is_leap_year(2024) && !is_leap_year(2100));
518        assert!(!is_leap_year(2023));
519        assert_eq!(days_in_month(2024, 2), 29);
520        assert_eq!(days_in_month(2023, 2), 28);
521        assert_eq!(days_in_month(2024, 4), 30);
522        assert_eq!(days_in_month(2024, 13), 0);
523    }
524
525    #[test]
526    fn split_from_j2000_seconds_inverts_field_form() {
527        // J2000 epoch (noon) and a day later land on *.0 boundaries.
528        assert_eq!(split_julian_date_from_j2000_seconds(0), (J2000_JD, 0.0));
529        assert_eq!(
530            split_julian_date_from_j2000_seconds(86_400),
531            (J2000_JD + 1.0, 0.0)
532        );
533        // 2020-06-25 00:00:00 (646_315_200 s) sits half a day before its noon JD.
534        let (whole, frac) = split_julian_date_from_j2000_seconds(646_315_200);
535        assert_eq!(j2000_seconds_from_split(whole, frac), 646_315_200.0);
536    }
537
538    #[test]
539    fn civil_from_split_round_trips_split_julian_date() {
540        let cases: [(i32, i32, i32, i32, i32, f64); 4] = [
541            (2000, 1, 1, 12, 0, 0.0),
542            (2020, 6, 25, 0, 0, 0.0),
543            (2024, 2, 29, 6, 30, 15.0),
544            (1999, 12, 31, 23, 59, 59.0),
545        ];
546        for (y, mo, d, h, mi, s) in cases {
547            let (whole, frac) = split_julian_date(y, mo, d, h, mi, s);
548            let civil = civil_from_split_julian_date(whole, frac);
549            assert_eq!(
550                civil,
551                (
552                    i64::from(y),
553                    i64::from(mo),
554                    i64::from(d),
555                    i64::from(h),
556                    i64::from(mi),
557                    s
558                )
559            );
560        }
561    }
562
563    #[test]
564    fn split_add_seconds_carries_across_midnight() {
565        // Add 6 hours within a day: no carry.
566        let (w, f) = split_julian_date_add_seconds(2_451_544.5, 0.25, 6.0 * SECONDS_PER_HOUR);
567        assert_eq!((w, f), (2_451_544.5, 0.5));
568        // Add 18 hours from 12:00: carry one whole day.
569        let (w, f) = split_julian_date_add_seconds(2_451_544.5, 0.5, 18.0 * SECONDS_PER_HOUR);
570        assert_eq!(w, 2_451_545.5);
571        assert!((f - 0.25).abs() < 1e-12);
572    }
573
574    #[test]
575    fn instant_julian_date_and_doy_and_sod() {
576        use super::super::model::{JulianDateSplit, TimeScale};
577        // 2020-06-25 06:00:00, stored as a split Julian date.
578        let (whole, frac) = split_julian_date(2020, 6, 25, 6, 0, 0.0);
579        let epoch = Instant::from_julian_date(
580            TimeScale::Gpst,
581            JulianDateSplit::new(whole, frac).expect("valid split"),
582        );
583        assert_eq!(julian_date_from_instant(epoch), whole + frac);
584        // 2020 is a leap year; June 25 is day 177, plus 6h = 0.25 day fraction.
585        assert!((fractional_day_of_year_from_instant(epoch) - (177.0 + 0.25)).abs() < 1e-9);
586        assert!((second_of_day_from_instant(epoch) - 6.0 * SECONDS_PER_HOUR).abs() < 1e-6);
587    }
588
589    #[test]
590    fn from_utc_civil_matches_open_coded_path_and_accessors() {
591        use super::super::model::{JulianDateSplit, TimeScale};
592        let cases: [(i32, i32, i32, i32, i32, f64); 4] = [
593            (2000, 1, 1, 12, 0, 0.0),
594            (2020, 6, 25, 6, 0, 0.0),
595            (2024, 2, 29, 6, 30, 15.0),
596            (1999, 12, 31, 23, 59, 59.0),
597        ];
598        for (y, mo, d, h, mi, s) in cases {
599            let instant = Instant::from_utc_civil(y, mo, d, h, mi, s).expect("valid civil instant");
600            // Identical to the open-coded split + from_julian_date path.
601            let (whole, frac) = split_julian_date(y, mo, d, h, mi, s);
602            let expected = Instant::from_julian_date(
603                TimeScale::Utc,
604                JulianDateSplit::new(whole, frac).expect("valid split"),
605            );
606            assert_eq!(instant, expected);
607            assert_eq!(instant.scale, TimeScale::Utc);
608            // Round-trips through the existing instant accessors.
609            assert_eq!(
610                instant.julian_date(),
611                Some(JulianDateSplit {
612                    jd_whole: whole,
613                    fraction: frac
614                })
615            );
616            assert_eq!(julian_date_from_instant(instant), whole + frac);
617            let civil = civil_from_split_julian_date(whole, frac);
618            assert_eq!(
619                civil,
620                (
621                    i64::from(y),
622                    i64::from(mo),
623                    i64::from(d),
624                    i64::from(h),
625                    i64::from(mi),
626                    s
627                )
628            );
629        }
630    }
631
632    #[test]
633    fn from_utc_civil_rejects_out_of_day_clock_field() {
634        // hour 25 pushes the day fraction past the one-day residual window.
635        assert!(Instant::from_utc_civil(2020, 6, 25, 25, 0, 0.0).is_err());
636    }
637
638    #[test]
639    fn j2000_seconds_from_split_matches_field_form() {
640        // A split built from a civil instant scales back to the same seconds the
641        // civil-fields conversion produces.
642        let (whole, frac) = split_julian_date(2020, 6, 25, 0, 0, 0.0);
643        assert_eq!(
644            j2000_seconds_from_split(whole, frac),
645            j2000_seconds(2020, 6, 25, 0, 0, 0.0)
646        );
647    }
648}