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
689pub fn tai_utc_offset_s(jd_utc: f64) -> f64 {
703 find_leap_seconds(jd_utc)
704}
705
706pub fn gps_utc_offset_s(jd_utc: f64) -> f64 {
720 find_leap_seconds(jd_utc) - GPST_MINUS_TAI_S
721}
722
723fn rubber_tai_minus_utc(jd_utc: f64) -> f64 {
730 let mjd = jd_utc - 2400000.5;
731 let first = &RUBBER_SECONDS[0];
732 if mjd.is_nan() || mjd < first.start_mjd as f64 {
734 return first.base;
735 }
736 let mut selected = first;
737 for entry in RUBBER_SECONDS {
738 if mjd >= entry.start_mjd as f64 {
739 selected = entry;
740 } else {
741 break;
742 }
743 }
744 selected.base + (mjd - selected.ref_mjd) * selected.rate
745}
746
747#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
749pub enum TimeOffsetError {
750 #[error(
754 "time-scale {0} is UTC-based; its offset is epoch-dependent, use timescale_offset_at_s"
755 )]
756 EpochRequired(&'static str),
757 #[error("time-scale {0} has no fixed/constant offset; resolve it through TimeScales")]
760 Unsupported(&'static str),
761 #[error("utc_jd must be finite to resolve leap seconds for scale {0}")]
763 NonFiniteEpoch(&'static str),
764}
765
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
774#[repr(u8)]
775pub enum TimeOffsetErrorCode {
776 EpochRequired = 1,
778 Unsupported = 2,
780 NonFiniteEpoch = 3,
782}
783
784impl TimeOffsetError {
785 #[must_use]
791 pub fn code(&self) -> TimeOffsetErrorCode {
792 match self {
793 Self::EpochRequired(_) => TimeOffsetErrorCode::EpochRequired,
794 Self::Unsupported(_) => TimeOffsetErrorCode::Unsupported,
795 Self::NonFiniteEpoch(_) => TimeOffsetErrorCode::NonFiniteEpoch,
796 }
797 }
798}
799
800fn is_utc_based(scale: TimeScale) -> bool {
802 matches!(scale, TimeScale::Utc | TimeScale::Glonasst)
803}
804
805fn scale_minus_tai_s(scale: TimeScale, utc_jd: f64) -> Result<f64, TimeOffsetError> {
812 let leap = |s: TimeScale| -> Result<f64, TimeOffsetError> {
813 if !utc_jd.is_finite() {
814 return Err(TimeOffsetError::NonFiniteEpoch(s.abbrev()));
815 }
816 Ok(find_leap_seconds(utc_jd))
817 };
818 Ok(match scale {
819 TimeScale::Tai => 0.0,
820 TimeScale::Tt => TT_MINUS_TAI_S,
822 TimeScale::Gpst => -GPST_MINUS_TAI_S,
824 TimeScale::Gst => -GPST_MINUS_TAI_S,
828 TimeScale::Qzsst => -GPST_MINUS_TAI_S,
831 TimeScale::Bdt => -BDT_MINUS_TAI_S,
834 TimeScale::Utc => -leap(scale)?,
836 TimeScale::Glonasst => -leap(scale)? + GLONASST_MINUS_UTC_S,
838 TimeScale::Tdb => return Err(TimeOffsetError::Unsupported("TDB")),
839 })
840}
841
842pub fn timescale_offset_s(from: TimeScale, to: TimeScale) -> Result<f64, TimeOffsetError> {
863 for scale in [from, to] {
864 if is_utc_based(scale) {
865 return Err(TimeOffsetError::EpochRequired(scale.abbrev()));
866 }
867 }
868 timescale_offset_at_s(from, to, f64::NAN)
870}
871
872pub fn timescale_offset_at_s(
884 from: TimeScale,
885 to: TimeScale,
886 utc_jd: f64,
887) -> Result<f64, TimeOffsetError> {
888 Ok(scale_minus_tai_s(to, utc_jd)? - scale_minus_tai_s(from, utc_jd)?)
889}
890
891pub fn leap_second_table() -> LeapSecondTable {
896 LeapSecondTable {
897 source: "IERS Bulletin C (TAI-UTC), bundled in sidereon-core",
898 first_mjd: LEAP_SECONDS.first().map(|e| e.mjd).unwrap_or(0),
899 last_mjd: LEAP_SECONDS.last().map(|e| e.mjd).unwrap_or(0),
900 entries: LEAP_SECONDS.len(),
901 }
902}
903
904fn interpolate_delta_t(jd_tt: f64) -> f64 {
905 use std::sync::LazyLock;
907
908 struct DeltaTRow {
909 jd_tt: f64,
910 delta_t: f64,
911 }
912
913 static TABLE: LazyLock<Vec<DeltaTRow>> = LazyLock::new(|| {
914 UT1_DATA
915 .iter()
916 .map(|entry| {
917 let jd_utc = entry.mjd as f64 + 2400000.5;
918 let leap_seconds = find_leap_seconds(jd_utc);
919 let tt_minus_utc = leap_seconds + TT_MINUS_TAI_S;
920 let delta_t = ((tt_minus_utc - entry.ut1_utc) * ROUND_1E7).round() / ROUND_1E7;
921 DeltaTRow {
922 jd_tt: jd_utc + tt_minus_utc / SECONDS_PER_DAY,
923 delta_t,
924 }
925 })
926 .collect()
927 });
928
929 match TABLE.binary_search_by(|row| row.jd_tt.partial_cmp(&jd_tt).unwrap()) {
931 Ok(i) => TABLE[i].delta_t,
932 Err(0) => TABLE[0].delta_t,
933 Err(i) if i >= TABLE.len() => TABLE.last().unwrap().delta_t,
934 Err(i) => {
935 let p1 = &TABLE[i - 1];
936 let p2 = &TABLE[i];
937 p1.delta_t + (jd_tt - p1.jd_tt) * (p2.delta_t - p1.delta_t) / (p2.jd_tt - p1.jd_tt)
938 }
939 }
940}
941
942pub fn ut1_coverage() -> Ut1Provenance {
949 let first = UT1_DATA.first();
950 let last = UT1_DATA.last();
951 let to_jd_tt = |mjd: i32| -> f64 {
952 let jd_utc = mjd as f64 + 2400000.5;
953 let tt_minus_utc = find_leap_seconds(jd_utc) + TT_MINUS_TAI_S;
954 jd_utc + tt_minus_utc / SECONDS_PER_DAY
955 };
956 Ut1Provenance {
957 source: "IERS Earth Orientation Parameters (UT1-UTC), bundled",
958 first_mjd: first.map(|e| e.mjd).unwrap_or(0),
959 last_mjd: last.map(|e| e.mjd).unwrap_or(0),
960 first_jd_tt: first.map(|e| to_jd_tt(e.mjd)).unwrap_or(0.0),
961 last_jd_tt: last.map(|e| to_jd_tt(e.mjd)).unwrap_or(0.0),
962 entries: UT1_DATA.len(),
963 }
964}
965
966#[cfg(test)]
967mod tests {
968 use super::*;
969
970 #[test]
971 fn julian_day_number_widens_extreme_inputs_before_arithmetic() {
972 let _ = julian_day_number(i32::MIN, i32::MAX, i32::MAX);
973 let _ = julian_day_number(i32::MAX, i32::MIN, i32::MIN);
974 }
975
976 fn utc_jd(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: f64) -> f64 {
980 let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
981 let sod = hour as f64 * 3600.0 + minute as f64 * 60.0 + second;
982 jd1 + sod / SECONDS_PER_DAY
983 }
984
985 #[test]
988 fn tai_minus_utc_pre_1972_matches_published_table() {
989 let cases = [
992 (1961, 1, 1, 1.4228180), (1965, 1, 1, 3.5401300), (1968, 2, 1, 6.1856820), (1971, 1, 1, 8.9461620), ];
998 for (y, m, d, want) in cases {
999 let jd = utc_jd(y, m, d, 0, 0, 0.0);
1000 let got = find_leap_seconds(jd);
1001 assert!(
1002 (got - want).abs() < 1.0e-7,
1003 "TAI-UTC at {y}-{m:02}-{d:02}: got {got}, want {want}"
1004 );
1005 }
1006 }
1007
1008 #[test]
1009 fn tai_minus_utc_pre_1972_is_continuous_within_a_segment() {
1010 let midnight = find_leap_seconds(utc_jd(1969, 6, 1, 0, 0, 0.0));
1013 let noon = find_leap_seconds(utc_jd(1969, 6, 1, 12, 0, 0.0));
1014 assert!(
1015 (noon - midnight - 0.5 * 0.002592).abs() < 1.0e-9,
1016 "rubber-second drift over half a day must equal 0.5*rate"
1017 );
1018 }
1019
1020 #[test]
1021 fn tai_minus_utc_steps_to_ten_at_1972_and_post_1972_unchanged() {
1022 let pre = find_leap_seconds(utc_jd(1971, 12, 31, 0, 0, 0.0));
1024 assert!((pre - 9.8896500).abs() < 1.0e-7, "1971-12-31 TAI-UTC");
1025 assert_eq!(find_leap_seconds(utc_jd(1972, 1, 1, 0, 0, 0.0)), 10.0);
1027 assert_eq!(find_leap_seconds(utc_jd(1980, 1, 1, 0, 0, 0.0)), 19.0);
1029 assert_eq!(find_leap_seconds(utc_jd(2017, 1, 1, 0, 0, 0.0)), 37.0);
1030 }
1031
1032 #[test]
1033 fn tai_utc_and_gps_utc_offsets_match_iers_and_is_gps_200() {
1034 let jd_2017 = utc_jd(2017, 1, 1, 0, 0, 0.0);
1037 assert_eq!(tai_utc_offset_s(jd_2017), 37.0);
1038 assert_eq!(gps_utc_offset_s(jd_2017), 18.0);
1039 assert_eq!(tai_utc_offset_s(jd_2017) - gps_utc_offset_s(jd_2017), 19.0);
1040
1041 for (y, m, d) in [(1980, 1, 1), (2000, 1, 1), (2009, 1, 1), (2017, 1, 1)] {
1044 let jd = utc_jd(y, m, d, 0, 0, 0.0);
1045 assert_eq!(
1046 tai_utc_offset_s(jd).to_bits(),
1047 find_leap_seconds(jd).to_bits()
1048 );
1049 assert_eq!(gps_utc_offset_s(jd), find_leap_seconds(jd) - 19.0);
1050 }
1051
1052 assert_eq!(
1055 gps_utc_offset_s(jd_2017),
1056 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd_2017)
1057 .expect("leap-aware offset")
1058 );
1059 }
1060
1061 #[test]
1062 fn tai_minus_utc_pre_1961_clamps_to_first_segment() {
1063 assert_eq!(find_leap_seconds(utc_jd(1958, 1, 1, 0, 0, 0.0)), 1.4228180);
1065 assert_eq!(find_leap_seconds(f64::NAN), 1.4228180);
1066 }
1067
1068 #[test]
1075 fn offset_gpst_to_bdt_is_minus_14s() {
1076 let want = f64::from_bits(0xc02c_0000_0000_0000);
1078 assert_eq!(
1079 timescale_offset_s(TimeScale::Gpst, TimeScale::Bdt).expect("fixed offset"),
1080 want
1081 );
1082 assert_eq!(want, -14.0);
1083 }
1084
1085 #[test]
1086 fn offset_bdt_to_gpst_is_plus_14s() {
1087 assert_eq!(
1088 timescale_offset_s(TimeScale::Bdt, TimeScale::Gpst).expect("fixed offset"),
1089 14.0
1090 );
1091 }
1092
1093 #[test]
1094 fn offset_gpst_to_gst_is_nominal_zero() {
1095 assert_eq!(
1098 timescale_offset_s(TimeScale::Gpst, TimeScale::Gst).expect("fixed offset"),
1099 0.0
1100 );
1101 }
1102
1103 #[test]
1104 fn offset_gpst_to_qzsst_is_nominal_zero() {
1105 assert_eq!(
1107 timescale_offset_s(TimeScale::Gpst, TimeScale::Qzsst).expect("fixed offset"),
1108 0.0
1109 );
1110 assert_eq!(
1111 timescale_offset_s(TimeScale::Gst, TimeScale::Qzsst).expect("fixed offset"),
1112 0.0
1113 );
1114 }
1115
1116 #[test]
1117 fn offset_tai_to_tt_is_32_184s() {
1118 let want = f64::from_bits(0x4040_178d_4fdf_3b64);
1120 assert_eq!(
1121 timescale_offset_s(TimeScale::Tai, TimeScale::Tt).expect("fixed offset"),
1122 want
1123 );
1124 assert_eq!(want, 32.184);
1125 }
1126
1127 #[test]
1128 fn offset_gpst_to_tt_is_51_184s() {
1129 let want = f64::from_bits(0x4049_978d_4fdf_3b64);
1131 assert_eq!(
1132 timescale_offset_s(TimeScale::Gpst, TimeScale::Tt).expect("fixed offset"),
1133 want
1134 );
1135 assert_eq!(want, 51.184);
1136 }
1137
1138 #[test]
1139 fn offset_gpst_to_tai_is_plus_19s() {
1140 assert_eq!(
1142 timescale_offset_s(TimeScale::Gpst, TimeScale::Tai).expect("fixed offset"),
1143 19.0
1144 );
1145 }
1146
1147 #[test]
1148 fn fixed_offsets_are_antisymmetric_for_atomic_pairs() {
1149 let atomic = [
1150 TimeScale::Tai,
1151 TimeScale::Tt,
1152 TimeScale::Gpst,
1153 TimeScale::Gst,
1154 TimeScale::Qzsst,
1155 TimeScale::Bdt,
1156 ];
1157 for &a in &atomic {
1158 for &b in &atomic {
1159 let ab = timescale_offset_s(a, b).expect("fixed offset");
1160 let ba = timescale_offset_s(b, a).expect("fixed offset");
1161 assert_eq!(ab, -ba, "offset({a:?},{b:?}) must be -offset({b:?},{a:?})");
1162 }
1163 }
1164 }
1165
1166 #[test]
1169 fn fixed_offset_requires_epoch_for_utc_based_scales() {
1170 assert_eq!(
1171 timescale_offset_s(TimeScale::Gpst, TimeScale::Utc),
1172 Err(TimeOffsetError::EpochRequired("UTC"))
1173 );
1174 assert_eq!(
1175 timescale_offset_s(TimeScale::Glonasst, TimeScale::Gpst),
1176 Err(TimeOffsetError::EpochRequired("GLONASST"))
1177 );
1178 }
1179
1180 #[test]
1181 fn tdb_has_no_fixed_offset() {
1182 assert_eq!(
1183 timescale_offset_s(TimeScale::Gpst, TimeScale::Tdb),
1184 Err(TimeOffsetError::Unsupported("TDB"))
1185 );
1186 assert_eq!(
1187 timescale_offset_at_s(TimeScale::Tt, TimeScale::Tdb, 2_451_545.0),
1188 Err(TimeOffsetError::Unsupported("TDB"))
1189 );
1190 }
1191
1192 #[test]
1193 fn leap_aware_offset_rejects_non_finite_epoch() {
1194 assert_eq!(
1195 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Utc, f64::NAN),
1196 Err(TimeOffsetError::NonFiniteEpoch("UTC"))
1197 );
1198 assert_eq!(
1199 timescale_offset_at_s(TimeScale::Glonasst, TimeScale::Gpst, f64::INFINITY),
1200 Err(TimeOffsetError::NonFiniteEpoch("GLONASST"))
1201 );
1202 }
1203
1204 #[test]
1205 fn error_code_maps_each_variant_to_stable_discriminant() {
1206 assert_eq!(
1207 TimeOffsetError::EpochRequired("UTC").code(),
1208 TimeOffsetErrorCode::EpochRequired
1209 );
1210 assert_eq!(
1211 TimeOffsetError::Unsupported("TDB").code(),
1212 TimeOffsetErrorCode::Unsupported
1213 );
1214 assert_eq!(
1215 TimeOffsetError::NonFiniteEpoch("UTC").code(),
1216 TimeOffsetErrorCode::NonFiniteEpoch
1217 );
1218 assert_eq!(TimeOffsetErrorCode::EpochRequired as u8, 1);
1221 assert_eq!(TimeOffsetErrorCode::Unsupported as u8, 2);
1222 assert_eq!(TimeOffsetErrorCode::NonFiniteEpoch as u8, 3);
1223 assert_eq!(
1225 TimeOffsetError::EpochRequired("GLONASST").code() as u8,
1226 TimeOffsetError::EpochRequired("UTC").code() as u8
1227 );
1228 }
1229
1230 #[test]
1231 fn leap_aware_offset_ignores_epoch_for_atomic_pairs() {
1232 assert_eq!(
1234 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Bdt, f64::NAN)
1235 .expect("atomic pair ignores epoch"),
1236 -14.0
1237 );
1238 }
1239
1240 #[test]
1249 fn offset_utc_gpst_matches_rtklib_2017() {
1250 let jd = utc_jd(2017, 1, 1, 0, 0, 0.0);
1251 let want = f64::from_bits(0x4032_0000_0000_0000);
1253 assert_eq!(
1254 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd).expect("leap-aware offset"),
1255 want
1256 );
1257 assert_eq!(want, 18.0);
1258 assert_eq!(
1260 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Utc, jd).expect("leap-aware offset"),
1261 -18.0
1262 );
1263 }
1264
1265 #[test]
1266 fn offset_glonasst_gpst_matches_rtklib_2017() {
1267 let jd = utc_jd(2017, 1, 1, 0, 0, 0.0);
1268 let want = f64::from_bits(0x40c5_0f00_0000_0000);
1270 assert_eq!(
1271 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, jd)
1272 .expect("leap-aware offset"),
1273 want
1274 );
1275 assert_eq!(want, 10782.0);
1276 }
1277
1278 #[test]
1279 fn offset_glonasst_gpst_at_j2000_matches_rtklib() {
1280 let jd = utc_jd(2000, 1, 1, 12, 0, 0.0);
1281 assert_eq!(
1283 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, jd)
1284 .expect("leap-aware offset"),
1285 10787.0
1286 );
1287 }
1288
1289 #[test]
1293 fn glonasst_offset_steps_across_2017_leap_second() {
1294 let before = utc_jd(2016, 12, 31, 23, 59, 59.0);
1295 let after = utc_jd(2017, 1, 1, 0, 0, 0.0);
1296
1297 let off_before = timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, before)
1298 .expect("leap-aware offset");
1299 let off_after = timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, after)
1300 .expect("leap-aware offset");
1301
1302 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);
1305 assert_eq!(off_after, 10782.0);
1306 assert_eq!(off_before - off_after, 1.0);
1309
1310 assert_eq!(
1312 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, before)
1313 .expect("leap-aware offset"),
1314 17.0
1315 );
1316 assert_eq!(
1317 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, after)
1318 .expect("leap-aware offset"),
1319 18.0
1320 );
1321 }
1322
1323 #[test]
1328 fn leap_aware_offset_agrees_with_timescales_path() {
1329 let jd = utc_jd(2020, 6, 15, 0, 0, 0.0);
1331 let gpst_minus_utc =
1332 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd).expect("leap-aware offset");
1333 assert_eq!(gpst_minus_utc, 18.0);
1335 }
1336}