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, SECONDS_PER_HOUR, SECONDS_PER_MINUTE,
16 TT_MINUS_TAI_S,
17};
18use crate::astro::data::iers::UT1_DATA;
19use crate::astro::time::civil;
20use crate::astro::time::eop::{
21 check_ut1_coverage, CoverageError, LeapSecondTable, TimeScaleInputErrorKind, Ut1Provenance,
22 Validated, ValidityMode,
23};
24use crate::astro::time::model::TimeScale;
25use crate::validate::{self, FieldError};
26
27const ROUND_1E7: f64 = 10_000_000.0;
28
29pub const GLONASST_MINUS_UTC_S: f64 = 3.0 * SECONDS_PER_HOUR;
34
35#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct TimeScales {
43 pub jd_whole: f64,
45 pub ut1_fraction: f64,
47 pub tt_fraction: f64,
49 pub tdb_fraction: f64,
51 pub jd_ut1: f64,
53 pub jd_tt: f64,
55 pub jd_tdb: f64,
57}
58
59struct LeapSecondEntry {
60 mjd: i32,
61 tai_utc: f64,
62}
63
64static LEAP_SECONDS: &[LeapSecondEntry] = &[
65 LeapSecondEntry {
66 mjd: 41317,
67 tai_utc: 10.0,
68 },
69 LeapSecondEntry {
70 mjd: 41499,
71 tai_utc: 11.0,
72 },
73 LeapSecondEntry {
74 mjd: 41683,
75 tai_utc: 12.0,
76 },
77 LeapSecondEntry {
78 mjd: 42048,
79 tai_utc: 13.0,
80 },
81 LeapSecondEntry {
82 mjd: 42413,
83 tai_utc: 14.0,
84 },
85 LeapSecondEntry {
86 mjd: 42778,
87 tai_utc: 15.0,
88 },
89 LeapSecondEntry {
90 mjd: 43144,
91 tai_utc: 16.0,
92 },
93 LeapSecondEntry {
94 mjd: 43509,
95 tai_utc: 17.0,
96 },
97 LeapSecondEntry {
98 mjd: 43874,
99 tai_utc: 18.0,
100 },
101 LeapSecondEntry {
102 mjd: 44239,
103 tai_utc: 19.0,
104 },
105 LeapSecondEntry {
106 mjd: 44786,
107 tai_utc: 20.0,
108 },
109 LeapSecondEntry {
110 mjd: 45151,
111 tai_utc: 21.0,
112 },
113 LeapSecondEntry {
114 mjd: 45516,
115 tai_utc: 22.0,
116 },
117 LeapSecondEntry {
118 mjd: 46247,
119 tai_utc: 23.0,
120 },
121 LeapSecondEntry {
122 mjd: 47161,
123 tai_utc: 24.0,
124 },
125 LeapSecondEntry {
126 mjd: 47892,
127 tai_utc: 25.0,
128 },
129 LeapSecondEntry {
130 mjd: 48257,
131 tai_utc: 26.0,
132 },
133 LeapSecondEntry {
134 mjd: 48804,
135 tai_utc: 27.0,
136 },
137 LeapSecondEntry {
138 mjd: 49169,
139 tai_utc: 28.0,
140 },
141 LeapSecondEntry {
142 mjd: 49534,
143 tai_utc: 29.0,
144 },
145 LeapSecondEntry {
146 mjd: 50083,
147 tai_utc: 30.0,
148 },
149 LeapSecondEntry {
150 mjd: 50448,
151 tai_utc: 31.0,
152 },
153 LeapSecondEntry {
154 mjd: 50813,
155 tai_utc: 32.0,
156 },
157 LeapSecondEntry {
158 mjd: 53736,
159 tai_utc: 33.0,
160 },
161 LeapSecondEntry {
162 mjd: 54832,
163 tai_utc: 34.0,
164 },
165 LeapSecondEntry {
166 mjd: 56109,
167 tai_utc: 35.0,
168 },
169 LeapSecondEntry {
170 mjd: 57204,
171 tai_utc: 36.0,
172 },
173 LeapSecondEntry {
174 mjd: 57754,
175 tai_utc: 37.0,
176 },
177];
178
179struct RubberSecondEntry {
183 start_mjd: i32,
185 base: f64,
187 ref_mjd: f64,
189 rate: f64,
191}
192
193static RUBBER_SECONDS: &[RubberSecondEntry] = &[
199 RubberSecondEntry {
200 start_mjd: 37300,
201 base: 1.4228180,
202 ref_mjd: 37300.0,
203 rate: 0.001296,
204 },
205 RubberSecondEntry {
206 start_mjd: 37512,
207 base: 1.3728180,
208 ref_mjd: 37300.0,
209 rate: 0.001296,
210 },
211 RubberSecondEntry {
212 start_mjd: 37665,
213 base: 1.8458580,
214 ref_mjd: 37665.0,
215 rate: 0.0011232,
216 },
217 RubberSecondEntry {
218 start_mjd: 38334,
219 base: 1.9458580,
220 ref_mjd: 37665.0,
221 rate: 0.0011232,
222 },
223 RubberSecondEntry {
224 start_mjd: 38395,
225 base: 3.2401300,
226 ref_mjd: 38761.0,
227 rate: 0.001296,
228 },
229 RubberSecondEntry {
230 start_mjd: 38486,
231 base: 3.3401300,
232 ref_mjd: 38761.0,
233 rate: 0.001296,
234 },
235 RubberSecondEntry {
236 start_mjd: 38639,
237 base: 3.4401300,
238 ref_mjd: 38761.0,
239 rate: 0.001296,
240 },
241 RubberSecondEntry {
242 start_mjd: 38761,
243 base: 3.5401300,
244 ref_mjd: 38761.0,
245 rate: 0.001296,
246 },
247 RubberSecondEntry {
248 start_mjd: 38820,
249 base: 3.6401300,
250 ref_mjd: 38761.0,
251 rate: 0.001296,
252 },
253 RubberSecondEntry {
254 start_mjd: 38942,
255 base: 3.7401300,
256 ref_mjd: 38761.0,
257 rate: 0.001296,
258 },
259 RubberSecondEntry {
260 start_mjd: 39004,
261 base: 3.8401300,
262 ref_mjd: 38761.0,
263 rate: 0.001296,
264 },
265 RubberSecondEntry {
266 start_mjd: 39126,
267 base: 4.3131700,
268 ref_mjd: 39126.0,
269 rate: 0.002592,
270 },
271 RubberSecondEntry {
272 start_mjd: 39887,
273 base: 4.2131700,
274 ref_mjd: 39126.0,
275 rate: 0.002592,
276 },
277];
278
279impl TimeScales {
280 pub fn from_utc(
284 year: i32,
285 month: i32,
286 day: i32,
287 hour: i32,
288 minute: i32,
289 second: f64,
290 ) -> Result<Self, CoverageError> {
291 validate::finite(second, "second").map_err(map_time_scale_field_error)?;
292 validate::civil_datetime_with_second_policy(
293 i64::from(year),
294 i64::from(month),
295 i64::from(day),
296 i64::from(hour),
297 i64::from(minute),
298 second,
299 validate::CivilSecondPolicy::UtcLike,
300 )
301 .map_err(map_time_scale_field_error)?;
302 if second >= 60.0 && !is_positive_leap_second_label(year, month, day, hour, minute) {
303 return Err(CoverageError::InvalidInput {
304 field: "civil datetime",
305 kind: TimeScaleInputErrorKind::InvalidCivilTime,
306 });
307 }
308 Ok(Self::from_utc_unchecked(
309 year, month, day, hour, minute, second,
310 ))
311 }
312
313 fn from_utc_unchecked(
316 year: i32,
317 month: i32,
318 day: i32,
319 hour: i32,
320 minute: i32,
321 second: f64,
322 ) -> Self {
323 let jd_day = julian_day_number(year, month, day);
324 let jd1 = jd_day as f64 - 0.5;
325 let utc_seconds_of_day =
326 hour as f64 * SECONDS_PER_HOUR + minute as f64 * SECONDS_PER_MINUTE + second;
327 let leap_lookup_second = if second >= 60.0 { 59.0 } else { second };
328 let jd2 = (leap_lookup_second
329 + minute as f64 * SECONDS_PER_MINUTE
330 + hour as f64 * SECONDS_PER_HOUR)
331 / SECONDS_PER_DAY;
332 let jd_utc_total = jd1 + jd2;
333
334 let leap_seconds = find_leap_seconds(jd_utc_total);
335 let utc_seconds_at_midnight = jd1 * SECONDS_PER_DAY;
336
337 let utc_whole_seconds = utc_seconds_of_day.trunc();
338 let utc_subsecond = utc_seconds_of_day.fract();
339
340 let tai_seconds = utc_seconds_at_midnight + leap_seconds + utc_whole_seconds;
342 let jd_whole = (tai_seconds / SECONDS_PER_DAY).floor();
343 let tai_fraction =
344 (tai_seconds - jd_whole * SECONDS_PER_DAY + utc_subsecond) / SECONDS_PER_DAY;
345 let tt_offset_days = TT_MINUS_TAI_S / SECONDS_PER_DAY;
346
347 let tt_fraction = tai_fraction + tt_offset_days;
348 let jd_tt = jd_whole + tt_fraction;
349
350 let delta_t = interpolate_delta_t(jd_tt);
351 let ut1_fraction = tt_fraction - delta_t / SECONDS_PER_DAY;
352 let jd_ut1 = jd_whole + ut1_fraction;
353
354 let t = (jd_whole - J2000_JD + tt_fraction) / DAYS_PER_JULIAN_CENTURY;
355 let tdb_minus_tt_seconds = 0.001657 * (628.3076 * t + 6.2401).sin()
356 + 0.000022 * (575.3385 * t + 4.2970).sin()
357 + 0.000014 * (1256.6152 * t + 6.1969).sin()
358 + 0.000005 * (606.9777 * t + 4.0212).sin()
359 + 0.000005 * (52.9691 * t + 0.4444).sin()
360 + 0.000002 * (21.3299 * t + 5.5431).sin()
361 + 0.000010 * t * (628.3076 * t + 4.2490).sin();
362
363 let tdb_fraction = tt_fraction + tdb_minus_tt_seconds / SECONDS_PER_DAY;
364 let jd_tdb = jd_whole + tdb_fraction;
365
366 Self {
367 jd_whole,
368 ut1_fraction,
369 tt_fraction,
370 tdb_fraction,
371 jd_ut1,
372 jd_tt,
373 jd_tdb,
374 }
375 }
376
377 pub fn from_utc_validated(
396 year: i32,
397 month: i32,
398 day: i32,
399 hour: i32,
400 minute: i32,
401 second: f64,
402 mode: ValidityMode,
403 ) -> Result<Validated<Self>, CoverageError> {
404 let scales = Self::from_utc(year, month, day, hour, minute, second)?;
406 let prov = ut1_coverage();
410 let degraded = check_ut1_coverage(&prov, scales.jd_tt, mode)?;
411 Ok(Validated {
412 value: scales,
413 degraded,
414 })
415 }
416
417 pub fn from_scale(
426 scale: TimeScale,
427 year: i32,
428 month: i32,
429 day: i32,
430 hour: i32,
431 minute: i32,
432 second: f64,
433 ) -> Result<Self, CoverageError> {
434 let utc = scale_calendar_to_utc(
435 scale,
436 ScaleCal {
437 year,
438 month,
439 day,
440 hour,
441 minute,
442 second,
443 },
444 );
445 Self::from_utc(
446 utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.second,
447 )
448 }
449}
450
451#[derive(Debug, Clone, Copy, PartialEq)]
453struct ScaleCal {
454 year: i32,
455 month: i32,
456 day: i32,
457 hour: i32,
458 minute: i32,
459 second: f64,
460}
461
462fn scale_calendar_to_utc(scale: TimeScale, cal: ScaleCal) -> ScaleCal {
470 match scale {
471 TimeScale::Utc => cal,
472 TimeScale::Glonasst => normalize_calendar_seconds(cal, cal.second - GLONASST_MINUS_UTC_S),
473 _ => {
474 let tai = normalize_calendar_seconds(cal, cal.second + tai_minus_scale_seconds(scale));
475 tai_calendar_to_utc(tai)
476 }
477 }
478}
479
480fn tai_minus_scale_seconds(scale: TimeScale) -> f64 {
481 match scale {
482 TimeScale::Utc | TimeScale::Glonasst => 0.0,
485 TimeScale::Tai => 0.0,
486 TimeScale::Tt | TimeScale::Tdb => -TT_MINUS_TAI_S,
487 TimeScale::Gpst | TimeScale::Gst | TimeScale::Qzsst => GPST_MINUS_TAI_S,
489 TimeScale::Bdt => BDT_MINUS_TAI_S,
490 }
491}
492
493fn tai_calendar_to_utc(tai: ScaleCal) -> ScaleCal {
494 if let Some(utc) = positive_leap_second_utc_label(tai) {
495 return utc;
496 }
497
498 let mut leap = leap_seconds_at_utc_label(tai);
499 let mut utc = normalize_calendar_seconds(tai, tai.second - leap);
500 for _ in 0..3 {
501 let next_leap = leap_seconds_at_utc_label(utc);
502 if next_leap == leap {
503 return utc;
504 }
505 leap = next_leap;
506 utc = normalize_calendar_seconds(tai, tai.second - leap);
507 }
508 utc
509}
510
511fn positive_leap_second_utc_label(tai: ScaleCal) -> Option<ScaleCal> {
512 let tai_sod = seconds_of_day(tai);
513 let utc_midnight = ScaleCal {
514 year: tai.year,
515 month: tai.month,
516 day: tai.day,
517 hour: 0,
518 minute: 0,
519 second: 0.0,
520 };
521 let previous_second = normalize_calendar_seconds(utc_midnight, -1.0);
522 let old_leap = leap_seconds_at_utc_label(previous_second);
523 let new_leap = leap_seconds_at_utc_label(utc_midnight);
524 if new_leap <= old_leap || !(old_leap..new_leap).contains(&tai_sod) {
525 return None;
526 }
527
528 let mut utc = previous_second;
529 utc.second = 60.0 + (tai_sod - old_leap);
530 Some(utc)
531}
532
533fn leap_seconds_at_utc_label(cal: ScaleCal) -> f64 {
534 let jd1 = julian_day_number(cal.year, cal.month, cal.day) as f64 - 0.5;
535 let lookup_second = if cal.second >= 60.0 { 59.0 } else { cal.second };
536 let jd2 = (cal.hour as f64 * SECONDS_PER_HOUR
537 + cal.minute as f64 * SECONDS_PER_MINUTE
538 + lookup_second)
539 / SECONDS_PER_DAY;
540 find_leap_seconds(jd1 + jd2)
541}
542
543fn seconds_of_day(cal: ScaleCal) -> f64 {
544 cal.hour as f64 * SECONDS_PER_HOUR + cal.minute as f64 * SECONDS_PER_MINUTE + cal.second
545}
546
547fn normalize_calendar_seconds(mut cal: ScaleCal, second: f64) -> ScaleCal {
548 if !second.is_finite() {
552 cal.second = second;
553 return cal;
554 }
555 cal.second = second;
556 while cal.second < 0.0 {
557 cal.second += 60.0;
558 cal.minute -= 1;
559 }
560 while cal.second >= 60.0 {
561 cal.second -= 60.0;
562 cal.minute += 1;
563 }
564 while cal.minute < 0 {
565 cal.minute += 60;
566 cal.hour -= 1;
567 }
568 while cal.minute > 59 {
569 cal.minute -= 60;
570 cal.hour += 1;
571 }
572 while cal.hour < 0 {
573 cal.hour += 24;
574 cal.day -= 1;
575 }
576 while cal.hour > 23 {
577 cal.hour -= 24;
578 cal.day += 1;
579 }
580 while cal.day < 1 {
581 cal.month -= 1;
582 if cal.month < 1 {
583 cal.month = 12;
584 cal.year -= 1;
585 }
586 cal.day = civil::days_in_month(i64::from(cal.year), i64::from(cal.month)) as i32;
587 }
588 loop {
589 let month_days = civil::days_in_month(i64::from(cal.year), i64::from(cal.month)) as i32;
590 if cal.day <= month_days {
591 break;
592 }
593 cal.day -= month_days;
594 cal.month += 1;
595 if cal.month > 12 {
596 cal.month = 1;
597 cal.year += 1;
598 }
599 }
600 cal
601}
602
603pub(crate) fn is_positive_leap_second_label(
604 year: i32,
605 month: i32,
606 day: i32,
607 hour: i32,
608 minute: i32,
609) -> bool {
610 if hour != 23 || minute != 59 {
611 return false;
612 }
613 let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
614 find_leap_seconds(jd1 + 1.0) > find_leap_seconds(jd1)
615}
616
617impl From<&FieldError> for TimeScaleInputErrorKind {
618 fn from(error: &FieldError) -> Self {
619 match error {
620 FieldError::Missing { .. } => Self::Missing,
621 FieldError::NonFinite { .. } => Self::NonFinite,
622 FieldError::NotPositive { .. } => Self::NotPositive,
623 FieldError::Negative { .. } => Self::Negative,
624 FieldError::OutOfRange { .. } => Self::OutOfRange,
625 FieldError::FloatParse { .. } => Self::FloatParse,
626 FieldError::IntParse { .. } => Self::IntParse,
627 FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
628 FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
629 }
630 }
631}
632
633fn map_time_scale_field_error(error: FieldError) -> CoverageError {
634 CoverageError::InvalidInput {
635 field: error.field(),
636 kind: TimeScaleInputErrorKind::from(&error),
637 }
638}
639
640pub fn julian_day_number(year: i32, month: i32, day: i32) -> i64 {
642 let year = i64::from(year);
643 let month = i64::from(month);
644 let day = i64::from(day);
645 let janfeb = month <= 2;
646 let g = year + 4716 - if janfeb { 1 } else { 0 };
647 let f = (month + 9) % 12;
648 let e = 1461 * g / 4 + day - 1402;
649 let j = e + (153 * f + 2) / 5;
650 j + 38 - ((g + 184) / 100) * 3 / 4
651}
652
653pub fn find_leap_seconds(jd_utc: f64) -> f64 {
679 let mjd = (jd_utc - 2400000.5) as i32;
680 if mjd >= LEAP_SECONDS[0].mjd {
681 let mut ls = 10.0;
683 for entry in LEAP_SECONDS {
684 if mjd >= entry.mjd {
685 ls = entry.tai_utc;
686 } else {
687 break;
688 }
689 }
690 return ls;
691 }
692 rubber_tai_minus_utc(jd_utc)
693}
694
695pub fn tai_utc_offset_s(jd_utc: f64) -> f64 {
709 find_leap_seconds(jd_utc)
710}
711
712pub fn gps_utc_offset_s(jd_utc: f64) -> f64 {
726 find_leap_seconds(jd_utc) - GPST_MINUS_TAI_S
727}
728
729fn rubber_tai_minus_utc(jd_utc: f64) -> f64 {
736 let mjd = jd_utc - 2400000.5;
737 let first = &RUBBER_SECONDS[0];
738 if mjd.is_nan() || mjd < first.start_mjd as f64 {
740 return first.base;
741 }
742 let mut selected = first;
743 for entry in RUBBER_SECONDS {
744 if mjd >= entry.start_mjd as f64 {
745 selected = entry;
746 } else {
747 break;
748 }
749 }
750 selected.base + (mjd - selected.ref_mjd) * selected.rate
751}
752
753#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
755pub enum TimeOffsetError {
756 #[error(
760 "time-scale {0} is UTC-based; its offset is epoch-dependent, use timescale_offset_at_s"
761 )]
762 EpochRequired(&'static str),
763 #[error("time-scale {0} has no fixed/constant offset; resolve it through TimeScales")]
766 Unsupported(&'static str),
767 #[error("utc_jd must be finite to resolve leap seconds for scale {0}")]
769 NonFiniteEpoch(&'static str),
770}
771
772#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
780#[repr(u8)]
781pub enum TimeOffsetErrorCode {
782 EpochRequired = 1,
784 Unsupported = 2,
786 NonFiniteEpoch = 3,
788}
789
790impl TimeOffsetError {
791 #[must_use]
797 pub fn code(&self) -> TimeOffsetErrorCode {
798 match self {
799 Self::EpochRequired(_) => TimeOffsetErrorCode::EpochRequired,
800 Self::Unsupported(_) => TimeOffsetErrorCode::Unsupported,
801 Self::NonFiniteEpoch(_) => TimeOffsetErrorCode::NonFiniteEpoch,
802 }
803 }
804}
805
806fn is_utc_based(scale: TimeScale) -> bool {
808 matches!(scale, TimeScale::Utc | TimeScale::Glonasst)
809}
810
811fn scale_minus_tai_s(scale: TimeScale, utc_jd: f64) -> Result<f64, TimeOffsetError> {
818 let leap = |s: TimeScale| -> Result<f64, TimeOffsetError> {
819 if !utc_jd.is_finite() {
820 return Err(TimeOffsetError::NonFiniteEpoch(s.abbrev()));
821 }
822 Ok(find_leap_seconds(utc_jd))
823 };
824 Ok(match scale {
825 TimeScale::Tai => 0.0,
826 TimeScale::Tt => TT_MINUS_TAI_S,
828 TimeScale::Gpst => -GPST_MINUS_TAI_S,
830 TimeScale::Gst => -GPST_MINUS_TAI_S,
834 TimeScale::Qzsst => -GPST_MINUS_TAI_S,
837 TimeScale::Bdt => -BDT_MINUS_TAI_S,
840 TimeScale::Utc => -leap(scale)?,
842 TimeScale::Glonasst => -leap(scale)? + GLONASST_MINUS_UTC_S,
844 TimeScale::Tdb => return Err(TimeOffsetError::Unsupported("TDB")),
845 })
846}
847
848pub fn timescale_offset_s(from: TimeScale, to: TimeScale) -> Result<f64, TimeOffsetError> {
869 for scale in [from, to] {
870 if is_utc_based(scale) {
871 return Err(TimeOffsetError::EpochRequired(scale.abbrev()));
872 }
873 }
874 timescale_offset_at_s(from, to, f64::NAN)
876}
877
878pub fn timescale_offset_at_s(
890 from: TimeScale,
891 to: TimeScale,
892 utc_jd: f64,
893) -> Result<f64, TimeOffsetError> {
894 Ok(scale_minus_tai_s(to, utc_jd)? - scale_minus_tai_s(from, utc_jd)?)
895}
896
897pub fn leap_second_table() -> LeapSecondTable {
902 LeapSecondTable {
903 source: "IERS Bulletin C (TAI-UTC), bundled in sidereon-core",
904 first_mjd: LEAP_SECONDS.first().map(|e| e.mjd).unwrap_or(0),
905 last_mjd: LEAP_SECONDS.last().map(|e| e.mjd).unwrap_or(0),
906 entries: LEAP_SECONDS.len(),
907 }
908}
909
910fn interpolate_delta_t(jd_tt: f64) -> f64 {
911 use std::sync::LazyLock;
913
914 struct DeltaTRow {
915 jd_tt: f64,
916 delta_t: f64,
917 }
918
919 static TABLE: LazyLock<Vec<DeltaTRow>> = LazyLock::new(|| {
920 UT1_DATA
921 .iter()
922 .map(|entry| {
923 let jd_utc = entry.mjd as f64 + 2400000.5;
924 let leap_seconds = find_leap_seconds(jd_utc);
925 let tt_minus_utc = leap_seconds + TT_MINUS_TAI_S;
926 let delta_t = ((tt_minus_utc - entry.ut1_utc) * ROUND_1E7).round() / ROUND_1E7;
927 DeltaTRow {
928 jd_tt: jd_utc + tt_minus_utc / SECONDS_PER_DAY,
929 delta_t,
930 }
931 })
932 .collect()
933 });
934
935 match TABLE.binary_search_by(|row| row.jd_tt.partial_cmp(&jd_tt).unwrap()) {
937 Ok(i) => TABLE[i].delta_t,
938 Err(0) => TABLE[0].delta_t,
939 Err(i) if i >= TABLE.len() => TABLE.last().unwrap().delta_t,
940 Err(i) => {
941 let p1 = &TABLE[i - 1];
942 let p2 = &TABLE[i];
943 p1.delta_t + (jd_tt - p1.jd_tt) * (p2.delta_t - p1.delta_t) / (p2.jd_tt - p1.jd_tt)
944 }
945 }
946}
947
948pub fn ut1_coverage() -> Ut1Provenance {
955 let first = UT1_DATA.first();
956 let last = UT1_DATA.last();
957 let to_jd_tt = |mjd: i32| -> f64 {
958 let jd_utc = mjd as f64 + 2400000.5;
959 let tt_minus_utc = find_leap_seconds(jd_utc) + TT_MINUS_TAI_S;
960 jd_utc + tt_minus_utc / SECONDS_PER_DAY
961 };
962 Ut1Provenance {
963 source: "IERS Earth Orientation Parameters (UT1-UTC), bundled",
964 first_mjd: first.map(|e| e.mjd).unwrap_or(0),
965 last_mjd: last.map(|e| e.mjd).unwrap_or(0),
966 first_jd_tt: first.map(|e| to_jd_tt(e.mjd)).unwrap_or(0.0),
967 last_jd_tt: last.map(|e| to_jd_tt(e.mjd)).unwrap_or(0.0),
968 entries: UT1_DATA.len(),
969 }
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975
976 #[test]
977 fn julian_day_number_widens_extreme_inputs_before_arithmetic() {
978 let _ = julian_day_number(i32::MIN, i32::MAX, i32::MAX);
979 let _ = julian_day_number(i32::MAX, i32::MIN, i32::MIN);
980 }
981
982 fn utc_jd(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: f64) -> f64 {
986 let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
987 let sod = hour as f64 * SECONDS_PER_HOUR + minute as f64 * SECONDS_PER_MINUTE + second;
988 jd1 + sod / SECONDS_PER_DAY
989 }
990
991 #[test]
994 fn tai_minus_utc_pre_1972_matches_published_table() {
995 let cases = [
998 (1961, 1, 1, 1.4228180), (1965, 1, 1, 3.5401300), (1968, 2, 1, 6.1856820), (1971, 1, 1, 8.9461620), ];
1004 for (y, m, d, want) in cases {
1005 let jd = utc_jd(y, m, d, 0, 0, 0.0);
1006 let got = find_leap_seconds(jd);
1007 assert!(
1008 (got - want).abs() < 1.0e-7,
1009 "TAI-UTC at {y}-{m:02}-{d:02}: got {got}, want {want}"
1010 );
1011 }
1012 }
1013
1014 #[test]
1015 fn tai_minus_utc_pre_1972_is_continuous_within_a_segment() {
1016 let midnight = find_leap_seconds(utc_jd(1969, 6, 1, 0, 0, 0.0));
1019 let noon = find_leap_seconds(utc_jd(1969, 6, 1, 12, 0, 0.0));
1020 assert!(
1021 (noon - midnight - 0.5 * 0.002592).abs() < 1.0e-9,
1022 "rubber-second drift over half a day must equal 0.5*rate"
1023 );
1024 }
1025
1026 #[test]
1027 fn tai_minus_utc_steps_to_ten_at_1972_and_post_1972_unchanged() {
1028 let pre = find_leap_seconds(utc_jd(1971, 12, 31, 0, 0, 0.0));
1030 assert!((pre - 9.8896500).abs() < 1.0e-7, "1971-12-31 TAI-UTC");
1031 assert_eq!(find_leap_seconds(utc_jd(1972, 1, 1, 0, 0, 0.0)), 10.0);
1033 assert_eq!(find_leap_seconds(utc_jd(1980, 1, 1, 0, 0, 0.0)), 19.0);
1035 assert_eq!(find_leap_seconds(utc_jd(2017, 1, 1, 0, 0, 0.0)), 37.0);
1036 }
1037
1038 #[test]
1039 fn tai_utc_and_gps_utc_offsets_match_iers_and_is_gps_200() {
1040 let jd_2017 = utc_jd(2017, 1, 1, 0, 0, 0.0);
1043 assert_eq!(tai_utc_offset_s(jd_2017), 37.0);
1044 assert_eq!(gps_utc_offset_s(jd_2017), 18.0);
1045 assert_eq!(tai_utc_offset_s(jd_2017) - gps_utc_offset_s(jd_2017), 19.0);
1046
1047 for (y, m, d) in [(1980, 1, 1), (2000, 1, 1), (2009, 1, 1), (2017, 1, 1)] {
1050 let jd = utc_jd(y, m, d, 0, 0, 0.0);
1051 assert_eq!(
1052 tai_utc_offset_s(jd).to_bits(),
1053 find_leap_seconds(jd).to_bits()
1054 );
1055 assert_eq!(gps_utc_offset_s(jd), find_leap_seconds(jd) - 19.0);
1056 }
1057
1058 assert_eq!(
1061 gps_utc_offset_s(jd_2017),
1062 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd_2017)
1063 .expect("leap-aware offset")
1064 );
1065 }
1066
1067 #[test]
1068 fn tai_minus_utc_pre_1961_clamps_to_first_segment() {
1069 assert_eq!(find_leap_seconds(utc_jd(1958, 1, 1, 0, 0, 0.0)), 1.4228180);
1071 assert_eq!(find_leap_seconds(f64::NAN), 1.4228180);
1072 }
1073
1074 #[test]
1081 fn offset_gpst_to_bdt_is_minus_14s() {
1082 let want = f64::from_bits(0xc02c_0000_0000_0000);
1084 assert_eq!(
1085 timescale_offset_s(TimeScale::Gpst, TimeScale::Bdt).expect("fixed offset"),
1086 want
1087 );
1088 assert_eq!(want, -14.0);
1089 }
1090
1091 #[test]
1092 fn offset_bdt_to_gpst_is_plus_14s() {
1093 assert_eq!(
1094 timescale_offset_s(TimeScale::Bdt, TimeScale::Gpst).expect("fixed offset"),
1095 14.0
1096 );
1097 }
1098
1099 #[test]
1100 fn offset_gpst_to_gst_is_nominal_zero() {
1101 assert_eq!(
1104 timescale_offset_s(TimeScale::Gpst, TimeScale::Gst).expect("fixed offset"),
1105 0.0
1106 );
1107 }
1108
1109 #[test]
1110 fn offset_gpst_to_qzsst_is_nominal_zero() {
1111 assert_eq!(
1113 timescale_offset_s(TimeScale::Gpst, TimeScale::Qzsst).expect("fixed offset"),
1114 0.0
1115 );
1116 assert_eq!(
1117 timescale_offset_s(TimeScale::Gst, TimeScale::Qzsst).expect("fixed offset"),
1118 0.0
1119 );
1120 }
1121
1122 #[test]
1123 fn offset_tai_to_tt_is_32_184s() {
1124 let want = f64::from_bits(0x4040_178d_4fdf_3b64);
1126 assert_eq!(
1127 timescale_offset_s(TimeScale::Tai, TimeScale::Tt).expect("fixed offset"),
1128 want
1129 );
1130 assert_eq!(want, 32.184);
1131 }
1132
1133 #[test]
1134 fn offset_gpst_to_tt_is_51_184s() {
1135 let want = f64::from_bits(0x4049_978d_4fdf_3b64);
1137 assert_eq!(
1138 timescale_offset_s(TimeScale::Gpst, TimeScale::Tt).expect("fixed offset"),
1139 want
1140 );
1141 assert_eq!(want, 51.184);
1142 }
1143
1144 #[test]
1145 fn offset_gpst_to_tai_is_plus_19s() {
1146 assert_eq!(
1148 timescale_offset_s(TimeScale::Gpst, TimeScale::Tai).expect("fixed offset"),
1149 19.0
1150 );
1151 }
1152
1153 #[test]
1154 fn fixed_offsets_are_antisymmetric_for_atomic_pairs() {
1155 let atomic = [
1156 TimeScale::Tai,
1157 TimeScale::Tt,
1158 TimeScale::Gpst,
1159 TimeScale::Gst,
1160 TimeScale::Qzsst,
1161 TimeScale::Bdt,
1162 ];
1163 for &a in &atomic {
1164 for &b in &atomic {
1165 let ab = timescale_offset_s(a, b).expect("fixed offset");
1166 let ba = timescale_offset_s(b, a).expect("fixed offset");
1167 assert_eq!(ab, -ba, "offset({a:?},{b:?}) must be -offset({b:?},{a:?})");
1168 }
1169 }
1170 }
1171
1172 #[test]
1175 fn fixed_offset_requires_epoch_for_utc_based_scales() {
1176 assert_eq!(
1177 timescale_offset_s(TimeScale::Gpst, TimeScale::Utc),
1178 Err(TimeOffsetError::EpochRequired("UTC"))
1179 );
1180 assert_eq!(
1181 timescale_offset_s(TimeScale::Glonasst, TimeScale::Gpst),
1182 Err(TimeOffsetError::EpochRequired("GLONASST"))
1183 );
1184 }
1185
1186 #[test]
1187 fn tdb_has_no_fixed_offset() {
1188 assert_eq!(
1189 timescale_offset_s(TimeScale::Gpst, TimeScale::Tdb),
1190 Err(TimeOffsetError::Unsupported("TDB"))
1191 );
1192 assert_eq!(
1193 timescale_offset_at_s(TimeScale::Tt, TimeScale::Tdb, 2_451_545.0),
1194 Err(TimeOffsetError::Unsupported("TDB"))
1195 );
1196 }
1197
1198 #[test]
1199 fn leap_aware_offset_rejects_non_finite_epoch() {
1200 assert_eq!(
1201 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Utc, f64::NAN),
1202 Err(TimeOffsetError::NonFiniteEpoch("UTC"))
1203 );
1204 assert_eq!(
1205 timescale_offset_at_s(TimeScale::Glonasst, TimeScale::Gpst, f64::INFINITY),
1206 Err(TimeOffsetError::NonFiniteEpoch("GLONASST"))
1207 );
1208 }
1209
1210 #[test]
1211 fn error_code_maps_each_variant_to_stable_discriminant() {
1212 assert_eq!(
1213 TimeOffsetError::EpochRequired("UTC").code(),
1214 TimeOffsetErrorCode::EpochRequired
1215 );
1216 assert_eq!(
1217 TimeOffsetError::Unsupported("TDB").code(),
1218 TimeOffsetErrorCode::Unsupported
1219 );
1220 assert_eq!(
1221 TimeOffsetError::NonFiniteEpoch("UTC").code(),
1222 TimeOffsetErrorCode::NonFiniteEpoch
1223 );
1224 assert_eq!(TimeOffsetErrorCode::EpochRequired as u8, 1);
1227 assert_eq!(TimeOffsetErrorCode::Unsupported as u8, 2);
1228 assert_eq!(TimeOffsetErrorCode::NonFiniteEpoch as u8, 3);
1229 assert_eq!(
1231 TimeOffsetError::EpochRequired("GLONASST").code() as u8,
1232 TimeOffsetError::EpochRequired("UTC").code() as u8
1233 );
1234 }
1235
1236 #[test]
1237 fn leap_aware_offset_ignores_epoch_for_atomic_pairs() {
1238 assert_eq!(
1240 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Bdt, f64::NAN)
1241 .expect("atomic pair ignores epoch"),
1242 -14.0
1243 );
1244 }
1245
1246 #[test]
1255 fn offset_utc_gpst_matches_rtklib_2017() {
1256 let jd = utc_jd(2017, 1, 1, 0, 0, 0.0);
1257 let want = f64::from_bits(0x4032_0000_0000_0000);
1259 assert_eq!(
1260 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd).expect("leap-aware offset"),
1261 want
1262 );
1263 assert_eq!(want, 18.0);
1264 assert_eq!(
1266 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Utc, jd).expect("leap-aware offset"),
1267 -18.0
1268 );
1269 }
1270
1271 #[test]
1272 fn offset_glonasst_gpst_matches_rtklib_2017() {
1273 let jd = utc_jd(2017, 1, 1, 0, 0, 0.0);
1274 let want = f64::from_bits(0x40c5_0f00_0000_0000);
1276 assert_eq!(
1277 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, jd)
1278 .expect("leap-aware offset"),
1279 want
1280 );
1281 assert_eq!(want, 10782.0);
1282 }
1283
1284 #[test]
1285 fn offset_glonasst_gpst_at_j2000_matches_rtklib() {
1286 let jd = utc_jd(2000, 1, 1, 12, 0, 0.0);
1287 assert_eq!(
1289 timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, jd)
1290 .expect("leap-aware offset"),
1291 10787.0
1292 );
1293 }
1294
1295 #[test]
1299 fn glonasst_offset_steps_across_2017_leap_second() {
1300 let before = utc_jd(2016, 12, 31, 23, 59, 59.0);
1301 let after = utc_jd(2017, 1, 1, 0, 0, 0.0);
1302
1303 let off_before = timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, before)
1304 .expect("leap-aware offset");
1305 let off_after = timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, after)
1306 .expect("leap-aware offset");
1307
1308 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);
1311 assert_eq!(off_after, 10782.0);
1312 assert_eq!(off_before - off_after, 1.0);
1315
1316 assert_eq!(
1318 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, before)
1319 .expect("leap-aware offset"),
1320 17.0
1321 );
1322 assert_eq!(
1323 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, after)
1324 .expect("leap-aware offset"),
1325 18.0
1326 );
1327 }
1328
1329 #[test]
1334 fn leap_aware_offset_agrees_with_timescales_path() {
1335 let jd = utc_jd(2020, 6, 15, 0, 0, 0.0);
1337 let gpst_minus_utc =
1338 timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd).expect("leap-aware offset");
1339 assert_eq!(gpst_minus_utc, 18.0);
1341 }
1342}