1use crate::astro::constants::time::{BDT_MINUS_TAI_S, GPST_MINUS_TAI_S};
14use crate::astro::constants::time::{
15 DAYS_PER_JULIAN_CENTURY, J2000_JD, SECONDS_PER_DAY, TT_MINUS_TAI_S,
16};
17use crate::astro::data::iers::UT1_DATA;
18use crate::astro::time::civil;
19use crate::astro::time::eop::{
20 check_ut1_coverage, CoverageError, LeapSecondTable, TimeScaleInputErrorKind, Ut1Provenance,
21 Validated, ValidityMode,
22};
23use crate::astro::time::model::TimeScale;
24use crate::validate::{self, FieldError};
25
26const ROUND_1E7: f64 = 10_000_000.0;
27
28pub const GLONASST_MINUS_UTC_S: f64 = 3.0 * 3600.0;
33
34#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct TimeScales {
42 pub jd_whole: f64,
44 pub ut1_fraction: f64,
46 pub tt_fraction: f64,
48 pub tdb_fraction: f64,
50 pub jd_ut1: f64,
52 pub jd_tt: f64,
54 pub jd_tdb: f64,
56}
57
58struct LeapSecondEntry {
59 mjd: i32,
60 tai_utc: f64,
61}
62
63static LEAP_SECONDS: &[LeapSecondEntry] = &[
64 LeapSecondEntry {
65 mjd: 41317,
66 tai_utc: 10.0,
67 },
68 LeapSecondEntry {
69 mjd: 41499,
70 tai_utc: 11.0,
71 },
72 LeapSecondEntry {
73 mjd: 41683,
74 tai_utc: 12.0,
75 },
76 LeapSecondEntry {
77 mjd: 42048,
78 tai_utc: 13.0,
79 },
80 LeapSecondEntry {
81 mjd: 42413,
82 tai_utc: 14.0,
83 },
84 LeapSecondEntry {
85 mjd: 42778,
86 tai_utc: 15.0,
87 },
88 LeapSecondEntry {
89 mjd: 43144,
90 tai_utc: 16.0,
91 },
92 LeapSecondEntry {
93 mjd: 43509,
94 tai_utc: 17.0,
95 },
96 LeapSecondEntry {
97 mjd: 43874,
98 tai_utc: 18.0,
99 },
100 LeapSecondEntry {
101 mjd: 44239,
102 tai_utc: 19.0,
103 },
104 LeapSecondEntry {
105 mjd: 44786,
106 tai_utc: 20.0,
107 },
108 LeapSecondEntry {
109 mjd: 45151,
110 tai_utc: 21.0,
111 },
112 LeapSecondEntry {
113 mjd: 45516,
114 tai_utc: 22.0,
115 },
116 LeapSecondEntry {
117 mjd: 46247,
118 tai_utc: 23.0,
119 },
120 LeapSecondEntry {
121 mjd: 47161,
122 tai_utc: 24.0,
123 },
124 LeapSecondEntry {
125 mjd: 47892,
126 tai_utc: 25.0,
127 },
128 LeapSecondEntry {
129 mjd: 48257,
130 tai_utc: 26.0,
131 },
132 LeapSecondEntry {
133 mjd: 48804,
134 tai_utc: 27.0,
135 },
136 LeapSecondEntry {
137 mjd: 49169,
138 tai_utc: 28.0,
139 },
140 LeapSecondEntry {
141 mjd: 49534,
142 tai_utc: 29.0,
143 },
144 LeapSecondEntry {
145 mjd: 50083,
146 tai_utc: 30.0,
147 },
148 LeapSecondEntry {
149 mjd: 50448,
150 tai_utc: 31.0,
151 },
152 LeapSecondEntry {
153 mjd: 50813,
154 tai_utc: 32.0,
155 },
156 LeapSecondEntry {
157 mjd: 53736,
158 tai_utc: 33.0,
159 },
160 LeapSecondEntry {
161 mjd: 54832,
162 tai_utc: 34.0,
163 },
164 LeapSecondEntry {
165 mjd: 56109,
166 tai_utc: 35.0,
167 },
168 LeapSecondEntry {
169 mjd: 57204,
170 tai_utc: 36.0,
171 },
172 LeapSecondEntry {
173 mjd: 57754,
174 tai_utc: 37.0,
175 },
176];
177
178struct RubberSecondEntry {
182 start_mjd: i32,
184 base: f64,
186 ref_mjd: f64,
188 rate: f64,
190}
191
192static RUBBER_SECONDS: &[RubberSecondEntry] = &[
198 RubberSecondEntry {
199 start_mjd: 37300,
200 base: 1.4228180,
201 ref_mjd: 37300.0,
202 rate: 0.001296,
203 },
204 RubberSecondEntry {
205 start_mjd: 37512,
206 base: 1.3728180,
207 ref_mjd: 37300.0,
208 rate: 0.001296,
209 },
210 RubberSecondEntry {
211 start_mjd: 37665,
212 base: 1.8458580,
213 ref_mjd: 37665.0,
214 rate: 0.0011232,
215 },
216 RubberSecondEntry {
217 start_mjd: 38334,
218 base: 1.9458580,
219 ref_mjd: 37665.0,
220 rate: 0.0011232,
221 },
222 RubberSecondEntry {
223 start_mjd: 38395,
224 base: 3.2401300,
225 ref_mjd: 38761.0,
226 rate: 0.001296,
227 },
228 RubberSecondEntry {
229 start_mjd: 38486,
230 base: 3.3401300,
231 ref_mjd: 38761.0,
232 rate: 0.001296,
233 },
234 RubberSecondEntry {
235 start_mjd: 38639,
236 base: 3.4401300,
237 ref_mjd: 38761.0,
238 rate: 0.001296,
239 },
240 RubberSecondEntry {
241 start_mjd: 38761,
242 base: 3.5401300,
243 ref_mjd: 38761.0,
244 rate: 0.001296,
245 },
246 RubberSecondEntry {
247 start_mjd: 38820,
248 base: 3.6401300,
249 ref_mjd: 38761.0,
250 rate: 0.001296,
251 },
252 RubberSecondEntry {
253 start_mjd: 38942,
254 base: 3.7401300,
255 ref_mjd: 38761.0,
256 rate: 0.001296,
257 },
258 RubberSecondEntry {
259 start_mjd: 39004,
260 base: 3.8401300,
261 ref_mjd: 38761.0,
262 rate: 0.001296,
263 },
264 RubberSecondEntry {
265 start_mjd: 39126,
266 base: 4.3131700,
267 ref_mjd: 39126.0,
268 rate: 0.002592,
269 },
270 RubberSecondEntry {
271 start_mjd: 39887,
272 base: 4.2131700,
273 ref_mjd: 39126.0,
274 rate: 0.002592,
275 },
276];
277
278impl TimeScales {
279 pub fn from_utc(
283 year: i32,
284 month: i32,
285 day: i32,
286 hour: i32,
287 minute: i32,
288 second: f64,
289 ) -> Result<Self, CoverageError> {
290 validate::finite(second, "second").map_err(map_time_scale_field_error)?;
291 validate::civil_datetime_with_second_policy(
292 i64::from(year),
293 i64::from(month),
294 i64::from(day),
295 i64::from(hour),
296 i64::from(minute),
297 second,
298 validate::CivilSecondPolicy::UtcLike,
299 )
300 .map_err(map_time_scale_field_error)?;
301 if second >= 60.0 && !is_positive_leap_second_label(year, month, day, hour, minute) {
302 return Err(CoverageError::InvalidInput {
303 field: "civil datetime",
304 kind: TimeScaleInputErrorKind::InvalidCivilTime,
305 });
306 }
307 Ok(Self::from_utc_unchecked(
308 year, month, day, hour, minute, second,
309 ))
310 }
311
312 fn from_utc_unchecked(
315 year: i32,
316 month: i32,
317 day: i32,
318 hour: i32,
319 minute: i32,
320 second: f64,
321 ) -> Self {
322 let jd_day = julian_day_number(year, month, day);
323 let jd1 = jd_day as f64 - 0.5;
324 let utc_seconds_of_day = hour as f64 * 3600.0 + minute as f64 * 60.0 + second;
325 let leap_lookup_second = if second >= 60.0 { 59.0 } else { second };
326 let jd2 =
327 (leap_lookup_second + minute as f64 * 60.0 + hour as f64 * 3600.0) / SECONDS_PER_DAY;
328 let jd_utc_total = jd1 + jd2;
329
330 let leap_seconds = find_leap_seconds(jd_utc_total);
331 let utc_seconds_at_midnight = jd1 * SECONDS_PER_DAY;
332
333 let utc_whole_seconds = utc_seconds_of_day.trunc();
334 let utc_subsecond = utc_seconds_of_day.fract();
335
336 let tai_seconds = utc_seconds_at_midnight + leap_seconds + utc_whole_seconds;
338 let jd_whole = (tai_seconds / SECONDS_PER_DAY).floor();
339 let tai_fraction =
340 (tai_seconds - jd_whole * SECONDS_PER_DAY + utc_subsecond) / SECONDS_PER_DAY;
341 let tt_offset_days = TT_MINUS_TAI_S / SECONDS_PER_DAY;
342
343 let tt_fraction = tai_fraction + tt_offset_days;
344 let jd_tt = jd_whole + tt_fraction;
345
346 let delta_t = interpolate_delta_t(jd_tt);
347 let ut1_fraction = tt_fraction - delta_t / SECONDS_PER_DAY;
348 let jd_ut1 = jd_whole + ut1_fraction;
349
350 let t = (jd_whole - J2000_JD + tt_fraction) / DAYS_PER_JULIAN_CENTURY;
351 let tdb_minus_tt_seconds = 0.001657 * (628.3076 * t + 6.2401).sin()
352 + 0.000022 * (575.3385 * t + 4.2970).sin()
353 + 0.000014 * (1256.6152 * t + 6.1969).sin()
354 + 0.000005 * (606.9777 * t + 4.0212).sin()
355 + 0.000005 * (52.9691 * t + 0.4444).sin()
356 + 0.000002 * (21.3299 * t + 5.5431).sin()
357 + 0.000010 * t * (628.3076 * t + 4.2490).sin();
358
359 let tdb_fraction = tt_fraction + tdb_minus_tt_seconds / SECONDS_PER_DAY;
360 let jd_tdb = jd_whole + tdb_fraction;
361
362 Self {
363 jd_whole,
364 ut1_fraction,
365 tt_fraction,
366 tdb_fraction,
367 jd_ut1,
368 jd_tt,
369 jd_tdb,
370 }
371 }
372
373 pub fn from_utc_validated(
392 year: i32,
393 month: i32,
394 day: i32,
395 hour: i32,
396 minute: i32,
397 second: f64,
398 mode: ValidityMode,
399 ) -> Result<Validated<Self>, CoverageError> {
400 let scales = Self::from_utc(year, month, day, hour, minute, second)?;
402 let prov = ut1_coverage();
406 let degraded = check_ut1_coverage(&prov, scales.jd_tt, mode)?;
407 Ok(Validated {
408 value: scales,
409 degraded,
410 })
411 }
412
413 pub fn from_scale(
422 scale: TimeScale,
423 year: i32,
424 month: i32,
425 day: i32,
426 hour: i32,
427 minute: i32,
428 second: f64,
429 ) -> Result<Self, CoverageError> {
430 let utc = scale_calendar_to_utc(
431 scale,
432 ScaleCal {
433 year,
434 month,
435 day,
436 hour,
437 minute,
438 second,
439 },
440 );
441 Self::from_utc(
442 utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.second,
443 )
444 }
445}
446
447#[derive(Debug, Clone, Copy, PartialEq)]
449struct ScaleCal {
450 year: i32,
451 month: i32,
452 day: i32,
453 hour: i32,
454 minute: i32,
455 second: f64,
456}
457
458fn scale_calendar_to_utc(scale: TimeScale, cal: ScaleCal) -> ScaleCal {
466 match scale {
467 TimeScale::Utc => cal,
468 TimeScale::Glonasst => normalize_calendar_seconds(cal, cal.second - GLONASST_MINUS_UTC_S),
469 _ => {
470 let tai = normalize_calendar_seconds(cal, cal.second + tai_minus_scale_seconds(scale));
471 tai_calendar_to_utc(tai)
472 }
473 }
474}
475
476fn tai_minus_scale_seconds(scale: TimeScale) -> f64 {
477 match scale {
478 TimeScale::Utc | TimeScale::Glonasst => 0.0,
481 TimeScale::Tai => 0.0,
482 TimeScale::Tt | TimeScale::Tdb => -TT_MINUS_TAI_S,
483 TimeScale::Gpst | TimeScale::Gst | TimeScale::Qzsst => GPST_MINUS_TAI_S,
485 TimeScale::Bdt => BDT_MINUS_TAI_S,
486 }
487}
488
489fn tai_calendar_to_utc(tai: ScaleCal) -> ScaleCal {
490 if let Some(utc) = positive_leap_second_utc_label(tai) {
491 return utc;
492 }
493
494 let mut leap = leap_seconds_at_utc_label(tai);
495 let mut utc = normalize_calendar_seconds(tai, tai.second - leap);
496 for _ in 0..3 {
497 let next_leap = leap_seconds_at_utc_label(utc);
498 if next_leap == leap {
499 return utc;
500 }
501 leap = next_leap;
502 utc = normalize_calendar_seconds(tai, tai.second - leap);
503 }
504 utc
505}
506
507fn positive_leap_second_utc_label(tai: ScaleCal) -> Option<ScaleCal> {
508 let tai_sod = seconds_of_day(tai);
509 let utc_midnight = ScaleCal {
510 year: tai.year,
511 month: tai.month,
512 day: tai.day,
513 hour: 0,
514 minute: 0,
515 second: 0.0,
516 };
517 let previous_second = normalize_calendar_seconds(utc_midnight, -1.0);
518 let old_leap = leap_seconds_at_utc_label(previous_second);
519 let new_leap = leap_seconds_at_utc_label(utc_midnight);
520 if new_leap <= old_leap || !(old_leap..new_leap).contains(&tai_sod) {
521 return None;
522 }
523
524 let mut utc = previous_second;
525 utc.second = 60.0 + (tai_sod - old_leap);
526 Some(utc)
527}
528
529fn leap_seconds_at_utc_label(cal: ScaleCal) -> f64 {
530 let jd1 = julian_day_number(cal.year, cal.month, cal.day) as f64 - 0.5;
531 let lookup_second = if cal.second >= 60.0 { 59.0 } else { cal.second };
532 let jd2 =
533 (cal.hour as f64 * 3600.0 + cal.minute as f64 * 60.0 + lookup_second) / SECONDS_PER_DAY;
534 find_leap_seconds(jd1 + jd2)
535}
536
537fn seconds_of_day(cal: ScaleCal) -> f64 {
538 cal.hour as f64 * 3600.0 + cal.minute as f64 * 60.0 + cal.second
539}
540
541fn normalize_calendar_seconds(mut cal: ScaleCal, second: f64) -> ScaleCal {
542 if !second.is_finite() {
546 cal.second = second;
547 return cal;
548 }
549 cal.second = second;
550 while cal.second < 0.0 {
551 cal.second += 60.0;
552 cal.minute -= 1;
553 }
554 while cal.second >= 60.0 {
555 cal.second -= 60.0;
556 cal.minute += 1;
557 }
558 while cal.minute < 0 {
559 cal.minute += 60;
560 cal.hour -= 1;
561 }
562 while cal.minute > 59 {
563 cal.minute -= 60;
564 cal.hour += 1;
565 }
566 while cal.hour < 0 {
567 cal.hour += 24;
568 cal.day -= 1;
569 }
570 while cal.hour > 23 {
571 cal.hour -= 24;
572 cal.day += 1;
573 }
574 while cal.day < 1 {
575 cal.month -= 1;
576 if cal.month < 1 {
577 cal.month = 12;
578 cal.year -= 1;
579 }
580 cal.day = civil::days_in_month(i64::from(cal.year), i64::from(cal.month)) as i32;
581 }
582 loop {
583 let month_days = civil::days_in_month(i64::from(cal.year), i64::from(cal.month)) as i32;
584 if cal.day <= month_days {
585 break;
586 }
587 cal.day -= month_days;
588 cal.month += 1;
589 if cal.month > 12 {
590 cal.month = 1;
591 cal.year += 1;
592 }
593 }
594 cal
595}
596
597pub(crate) fn is_positive_leap_second_label(
598 year: i32,
599 month: i32,
600 day: i32,
601 hour: i32,
602 minute: i32,
603) -> bool {
604 if hour != 23 || minute != 59 {
605 return false;
606 }
607 let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
608 find_leap_seconds(jd1 + 1.0) > find_leap_seconds(jd1)
609}
610
611impl From<&FieldError> for TimeScaleInputErrorKind {
612 fn from(error: &FieldError) -> Self {
613 match error {
614 FieldError::Missing { .. } => Self::Missing,
615 FieldError::NonFinite { .. } => Self::NonFinite,
616 FieldError::NotPositive { .. } => Self::NotPositive,
617 FieldError::Negative { .. } => Self::Negative,
618 FieldError::OutOfRange { .. } => Self::OutOfRange,
619 FieldError::FloatParse { .. } => Self::FloatParse,
620 FieldError::IntParse { .. } => Self::IntParse,
621 FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
622 FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
623 }
624 }
625}
626
627fn map_time_scale_field_error(error: FieldError) -> CoverageError {
628 CoverageError::InvalidInput {
629 field: error.field(),
630 kind: TimeScaleInputErrorKind::from(&error),
631 }
632}
633
634pub fn julian_day_number(year: i32, month: i32, day: i32) -> i64 {
636 let year = i64::from(year);
637 let month = i64::from(month);
638 let day = i64::from(day);
639 let janfeb = month <= 2;
640 let g = year + 4716 - if janfeb { 1 } else { 0 };
641 let f = (month + 9) % 12;
642 let e = 1461 * g / 4 + day - 1402;
643 let j = e + (153 * f + 2) / 5;
644 j + 38 - ((g + 184) / 100) * 3 / 4
645}
646
647pub fn find_leap_seconds(jd_utc: f64) -> f64 {
673 let mjd = (jd_utc - 2400000.5) as i32;
674 if mjd >= LEAP_SECONDS[0].mjd {
675 let mut ls = 10.0;
677 for entry in LEAP_SECONDS {
678 if mjd >= entry.mjd {
679 ls = entry.tai_utc;
680 } else {
681 break;
682 }
683 }
684 return ls;
685 }
686 rubber_tai_minus_utc(jd_utc)
687}
688
689fn rubber_tai_minus_utc(jd_utc: f64) -> f64 {
696 let mjd = jd_utc - 2400000.5;
697 let first = &RUBBER_SECONDS[0];
698 if mjd.is_nan() || mjd < first.start_mjd as f64 {
700 return first.base;
701 }
702 let mut selected = first;
703 for entry in RUBBER_SECONDS {
704 if mjd >= entry.start_mjd as f64 {
705 selected = entry;
706 } else {
707 break;
708 }
709 }
710 selected.base + (mjd - selected.ref_mjd) * selected.rate
711}
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
715pub enum TimeOffsetError {
716 #[error(
720 "time-scale {0} is UTC-based; its offset is epoch-dependent, use timescale_offset_at_s"
721 )]
722 EpochRequired(&'static str),
723 #[error("time-scale {0} has no fixed/constant offset; resolve it through TimeScales")]
726 Unsupported(&'static str),
727 #[error("utc_jd must be finite to resolve leap seconds for scale {0}")]
729 NonFiniteEpoch(&'static str),
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
740#[repr(u8)]
741pub enum TimeOffsetErrorCode {
742 EpochRequired = 1,
744 Unsupported = 2,
746 NonFiniteEpoch = 3,
748}
749
750impl TimeOffsetError {
751 #[must_use]
757 pub fn code(&self) -> TimeOffsetErrorCode {
758 match self {
759 Self::EpochRequired(_) => TimeOffsetErrorCode::EpochRequired,
760 Self::Unsupported(_) => TimeOffsetErrorCode::Unsupported,
761 Self::NonFiniteEpoch(_) => TimeOffsetErrorCode::NonFiniteEpoch,
762 }
763 }
764}
765
766fn is_utc_based(scale: TimeScale) -> bool {
768 matches!(scale, TimeScale::Utc | TimeScale::Glonasst)
769}
770
771fn scale_minus_tai_s(scale: TimeScale, utc_jd: f64) -> Result<f64, TimeOffsetError> {
778 let leap = |s: TimeScale| -> Result<f64, TimeOffsetError> {
779 if !utc_jd.is_finite() {
780 return Err(TimeOffsetError::NonFiniteEpoch(s.abbrev()));
781 }
782 Ok(find_leap_seconds(utc_jd))
783 };
784 Ok(match scale {
785 TimeScale::Tai => 0.0,
786 TimeScale::Tt => TT_MINUS_TAI_S,
788 TimeScale::Gpst => -GPST_MINUS_TAI_S,
790 TimeScale::Gst => -GPST_MINUS_TAI_S,
794 TimeScale::Qzsst => -GPST_MINUS_TAI_S,
797 TimeScale::Bdt => -BDT_MINUS_TAI_S,
800 TimeScale::Utc => -leap(scale)?,
802 TimeScale::Glonasst => -leap(scale)? + GLONASST_MINUS_UTC_S,
804 TimeScale::Tdb => return Err(TimeOffsetError::Unsupported("TDB")),
805 })
806}
807
808pub fn timescale_offset_s(from: TimeScale, to: TimeScale) -> Result<f64, TimeOffsetError> {
829 for scale in [from, to] {
830 if is_utc_based(scale) {
831 return Err(TimeOffsetError::EpochRequired(scale.abbrev()));
832 }
833 }
834 timescale_offset_at_s(from, to, f64::NAN)
836}
837
838pub fn timescale_offset_at_s(
850 from: TimeScale,
851 to: TimeScale,
852 utc_jd: f64,
853) -> Result<f64, TimeOffsetError> {
854 Ok(scale_minus_tai_s(to, utc_jd)? - scale_minus_tai_s(from, utc_jd)?)
855}
856
857pub fn leap_second_table() -> LeapSecondTable {
862 LeapSecondTable {
863 source: "IERS Bulletin C (TAI-UTC), bundled in sidereon-core",
864 first_mjd: LEAP_SECONDS.first().map(|e| e.mjd).unwrap_or(0),
865 last_mjd: LEAP_SECONDS.last().map(|e| e.mjd).unwrap_or(0),
866 entries: LEAP_SECONDS.len(),
867 }
868}
869
870fn interpolate_delta_t(jd_tt: f64) -> f64 {
871 use std::sync::LazyLock;
873
874 struct DeltaTRow {
875 jd_tt: f64,
876 delta_t: f64,
877 }
878
879 static TABLE: LazyLock<Vec<DeltaTRow>> = LazyLock::new(|| {
880 UT1_DATA
881 .iter()
882 .map(|entry| {
883 let jd_utc = entry.mjd as f64 + 2400000.5;
884 let leap_seconds = find_leap_seconds(jd_utc);
885 let tt_minus_utc = leap_seconds + TT_MINUS_TAI_S;
886 let delta_t = ((tt_minus_utc - entry.ut1_utc) * ROUND_1E7).round() / ROUND_1E7;
887 DeltaTRow {
888 jd_tt: jd_utc + tt_minus_utc / SECONDS_PER_DAY,
889 delta_t,
890 }
891 })
892 .collect()
893 });
894
895 match TABLE.binary_search_by(|row| row.jd_tt.partial_cmp(&jd_tt).unwrap()) {
897 Ok(i) => TABLE[i].delta_t,
898 Err(0) => TABLE[0].delta_t,
899 Err(i) if i >= TABLE.len() => TABLE.last().unwrap().delta_t,
900 Err(i) => {
901 let p1 = &TABLE[i - 1];
902 let p2 = &TABLE[i];
903 p1.delta_t + (jd_tt - p1.jd_tt) * (p2.delta_t - p1.delta_t) / (p2.jd_tt - p1.jd_tt)
904 }
905 }
906}
907
908pub fn ut1_coverage() -> Ut1Provenance {
915 let first = UT1_DATA.first();
916 let last = UT1_DATA.last();
917 let to_jd_tt = |mjd: i32| -> f64 {
918 let jd_utc = mjd as f64 + 2400000.5;
919 let tt_minus_utc = find_leap_seconds(jd_utc) + TT_MINUS_TAI_S;
920 jd_utc + tt_minus_utc / SECONDS_PER_DAY
921 };
922 Ut1Provenance {
923 source: "IERS Earth Orientation Parameters (UT1-UTC), bundled",
924 first_mjd: first.map(|e| e.mjd).unwrap_or(0),
925 last_mjd: last.map(|e| e.mjd).unwrap_or(0),
926 first_jd_tt: first.map(|e| to_jd_tt(e.mjd)).unwrap_or(0.0),
927 last_jd_tt: last.map(|e| to_jd_tt(e.mjd)).unwrap_or(0.0),
928 entries: UT1_DATA.len(),
929 }
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935
936 #[test]
937 fn julian_day_number_widens_extreme_inputs_before_arithmetic() {
938 let _ = julian_day_number(i32::MIN, i32::MAX, i32::MAX);
939 let _ = julian_day_number(i32::MAX, i32::MIN, i32::MIN);
940 }
941
942 fn utc_jd(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: f64) -> f64 {
946 let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
947 let sod = hour as f64 * 3600.0 + minute as f64 * 60.0 + second;
948 jd1 + sod / SECONDS_PER_DAY
949 }
950
951 #[test]
954 fn tai_minus_utc_pre_1972_matches_published_table() {
955 let cases = [
958 (1961, 1, 1, 1.4228180), (1965, 1, 1, 3.5401300), (1968, 2, 1, 6.1856820), (1971, 1, 1, 8.9461620), ];
964 for (y, m, d, want) in cases {
965 let jd = utc_jd(y, m, d, 0, 0, 0.0);
966 let got = find_leap_seconds(jd);
967 assert!(
968 (got - want).abs() < 1.0e-7,
969 "TAI-UTC at {y}-{m:02}-{d:02}: got {got}, want {want}"
970 );
971 }
972 }
973
974 #[test]
975 fn tai_minus_utc_pre_1972_is_continuous_within_a_segment() {
976 let midnight = find_leap_seconds(utc_jd(1969, 6, 1, 0, 0, 0.0));
979 let noon = find_leap_seconds(utc_jd(1969, 6, 1, 12, 0, 0.0));
980 assert!(
981 (noon - midnight - 0.5 * 0.002592).abs() < 1.0e-9,
982 "rubber-second drift over half a day must equal 0.5*rate"
983 );
984 }
985
986 #[test]
987 fn tai_minus_utc_steps_to_ten_at_1972_and_post_1972_unchanged() {
988 let pre = find_leap_seconds(utc_jd(1971, 12, 31, 0, 0, 0.0));
990 assert!((pre - 9.8896500).abs() < 1.0e-7, "1971-12-31 TAI-UTC");
991 assert_eq!(find_leap_seconds(utc_jd(1972, 1, 1, 0, 0, 0.0)), 10.0);
993 assert_eq!(find_leap_seconds(utc_jd(1980, 1, 1, 0, 0, 0.0)), 19.0);
995 assert_eq!(find_leap_seconds(utc_jd(2017, 1, 1, 0, 0, 0.0)), 37.0);
996 }
997
998 #[test]
999 fn tai_minus_utc_pre_1961_clamps_to_first_segment() {
1000 assert_eq!(find_leap_seconds(utc_jd(1958, 1, 1, 0, 0, 0.0)), 1.4228180);
1002 assert_eq!(find_leap_seconds(f64::NAN), 1.4228180);
1003 }
1004
1005 #[test]
1012 fn offset_gpst_to_bdt_is_minus_14s() {
1013 let want = f64::from_bits(0xc02c_0000_0000_0000);
1015 assert_eq!(
1016 timescale_offset_s(TimeScale::Gpst, TimeScale::Bdt).expect("fixed offset"),
1017 want
1018 );
1019 assert_eq!(want, -14.0);
1020 }
1021
1022 #[test]
1023 fn offset_bdt_to_gpst_is_plus_14s() {
1024 assert_eq!(
1025 timescale_offset_s(TimeScale::Bdt, TimeScale::Gpst).expect("fixed offset"),
1026 14.0
1027 );
1028 }
1029
1030 #[test]
1031 fn offset_gpst_to_gst_is_nominal_zero() {
1032 assert_eq!(
1035 timescale_offset_s(TimeScale::Gpst, TimeScale::Gst).expect("fixed offset"),
1036 0.0
1037 );
1038 }
1039
1040 #[test]
1041 fn offset_gpst_to_qzsst_is_nominal_zero() {
1042 assert_eq!(
1044 timescale_offset_s(TimeScale::Gpst, TimeScale::Qzsst).expect("fixed offset"),
1045 0.0
1046 );
1047 assert_eq!(
1048 timescale_offset_s(TimeScale::Gst, TimeScale::Qzsst).expect("fixed offset"),
1049 0.0
1050 );
1051 }
1052
1053 #[test]
1054 fn offset_tai_to_tt_is_32_184s() {
1055 let want = f64::from_bits(0x4040_178d_4fdf_3b64);
1057 assert_eq!(
1058 timescale_offset_s(TimeScale::Tai, TimeScale::Tt).expect("fixed offset"),
1059 want
1060 );
1061 assert_eq!(want, 32.184);
1062 }
1063
1064 #[test]
1065 fn offset_gpst_to_tt_is_51_184s() {
1066 let want = f64::from_bits(0x4049_978d_4fdf_3b64);
1068 assert_eq!(
1069 timescale_offset_s(TimeScale::Gpst, TimeScale::Tt).expect("fixed offset"),
1070 want
1071 );
1072 assert_eq!(want, 51.184);
1073 }
1074
1075 #[test]
1076 fn offset_gpst_to_tai_is_plus_19s() {
1077 assert_eq!(
1079 timescale_offset_s(TimeScale::Gpst, TimeScale::Tai).expect("fixed offset"),
1080 19.0
1081 );
1082 }
1083
1084 #[test]
1085 fn fixed_offsets_are_antisymmetric_for_atomic_pairs() {
1086 let atomic = [
1087 TimeScale::Tai,
1088 TimeScale::Tt,
1089 TimeScale::Gpst,
1090 TimeScale::Gst,
1091 TimeScale::Qzsst,
1092 TimeScale::Bdt,
1093 ];
1094 for &a in &atomic {
1095 for &b in &atomic {
1096 let ab = timescale_offset_s(a, b).expect("fixed offset");
1097 let ba = timescale_offset_s(b, a).expect("fixed offset");
1098 assert_eq!(ab, -ba, "offset({a:?},{b:?}) must be -offset({b:?},{a:?})");
1099 }
1100 }
1101 }
1102
1103 #[test]
1106 fn fixed_offset_requires_epoch_for_utc_based_scales() {
1107 assert_eq!(
1108 timescale_offset_s(TimeScale::Gpst, TimeScale::Utc),
1109 Err(TimeOffsetError::EpochRequired("UTC"))
1110 );
1111 assert_eq!(
1112 timescale_offset_s(TimeScale::Glonasst, TimeScale::Gpst),
1113 Err(TimeOffsetError::EpochRequired("GLONASST"))
1114 );
1115 }
1116
1117 #[test]
1118 fn tdb_has_no_fixed_offset() {
1119 assert_eq!(
1120 timescale_offset_s(TimeScale::Gpst, TimeScale::Tdb),
1121 Err(TimeOffsetError::Unsupported("TDB"))
1122 );
1123 assert_eq!(
1124 timescale_offset_at_s(TimeScale::Tt, TimeScale::Tdb, 2_451_545.0),
1125 Err(TimeOffsetError::Unsupported("TDB"))
1126 );
1127 }
1128
1129 #[test]
1130 fn leap_aware_offset_rejects_non_finite_epoch() {
1131 assert_eq!(
1132 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Utc, f64::NAN),
1133 Err(TimeOffsetError::NonFiniteEpoch("UTC"))
1134 );
1135 assert_eq!(
1136 timescale_offset_at_s(TimeScale::Glonasst, TimeScale::Gpst, f64::INFINITY),
1137 Err(TimeOffsetError::NonFiniteEpoch("GLONASST"))
1138 );
1139 }
1140
1141 #[test]
1142 fn error_code_maps_each_variant_to_stable_discriminant() {
1143 assert_eq!(
1144 TimeOffsetError::EpochRequired("UTC").code(),
1145 TimeOffsetErrorCode::EpochRequired
1146 );
1147 assert_eq!(
1148 TimeOffsetError::Unsupported("TDB").code(),
1149 TimeOffsetErrorCode::Unsupported
1150 );
1151 assert_eq!(
1152 TimeOffsetError::NonFiniteEpoch("UTC").code(),
1153 TimeOffsetErrorCode::NonFiniteEpoch
1154 );
1155 assert_eq!(TimeOffsetErrorCode::EpochRequired as u8, 1);
1158 assert_eq!(TimeOffsetErrorCode::Unsupported as u8, 2);
1159 assert_eq!(TimeOffsetErrorCode::NonFiniteEpoch as u8, 3);
1160 assert_eq!(
1162 TimeOffsetError::EpochRequired("GLONASST").code() as u8,
1163 TimeOffsetError::EpochRequired("UTC").code() as u8
1164 );
1165 }
1166
1167 #[test]
1168 fn leap_aware_offset_ignores_epoch_for_atomic_pairs() {
1169 assert_eq!(
1171 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Bdt, f64::NAN)
1172 .expect("atomic pair ignores epoch"),
1173 -14.0
1174 );
1175 }
1176
1177 #[test]
1186 fn offset_utc_gpst_matches_rtklib_2017() {
1187 let jd = utc_jd(2017, 1, 1, 0, 0, 0.0);
1188 let want = f64::from_bits(0x4032_0000_0000_0000);
1190 assert_eq!(
1191 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd).expect("leap-aware offset"),
1192 want
1193 );
1194 assert_eq!(want, 18.0);
1195 assert_eq!(
1197 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Utc, jd).expect("leap-aware offset"),
1198 -18.0
1199 );
1200 }
1201
1202 #[test]
1203 fn offset_glonasst_gpst_matches_rtklib_2017() {
1204 let jd = utc_jd(2017, 1, 1, 0, 0, 0.0);
1205 let want = f64::from_bits(0x40c5_0f00_0000_0000);
1207 assert_eq!(
1208 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, jd)
1209 .expect("leap-aware offset"),
1210 want
1211 );
1212 assert_eq!(want, 10782.0);
1213 }
1214
1215 #[test]
1216 fn offset_glonasst_gpst_at_j2000_matches_rtklib() {
1217 let jd = utc_jd(2000, 1, 1, 12, 0, 0.0);
1218 assert_eq!(
1220 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, jd)
1221 .expect("leap-aware offset"),
1222 10787.0
1223 );
1224 }
1225
1226 #[test]
1230 fn glonasst_offset_steps_across_2017_leap_second() {
1231 let before = utc_jd(2016, 12, 31, 23, 59, 59.0);
1232 let after = utc_jd(2017, 1, 1, 0, 0, 0.0);
1233
1234 let off_before = timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, before)
1235 .expect("leap-aware offset");
1236 let off_after = timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, after)
1237 .expect("leap-aware offset");
1238
1239 assert_eq!(off_before, f64::from_bits(0x40c5_0f80_0000_0000)); assert_eq!(off_after, f64::from_bits(0x40c5_0f00_0000_0000)); assert_eq!(off_before, 10783.0);
1242 assert_eq!(off_after, 10782.0);
1243 assert_eq!(off_before - off_after, 1.0);
1246
1247 assert_eq!(
1249 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, before)
1250 .expect("leap-aware offset"),
1251 17.0
1252 );
1253 assert_eq!(
1254 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, after)
1255 .expect("leap-aware offset"),
1256 18.0
1257 );
1258 }
1259
1260 #[test]
1265 fn leap_aware_offset_agrees_with_timescales_path() {
1266 let jd = utc_jd(2020, 6, 15, 0, 0, 0.0);
1268 let gpst_minus_utc =
1269 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd).expect("leap-aware offset");
1270 assert_eq!(gpst_minus_utc, 18.0);
1272 }
1273}