use crate::ChronoError;
use hifitime::Epoch;
#[derive(Debug, Clone, serde::Serialize)]
pub struct LeapReading {
pub scale: &'static str,
pub citation: &'static str,
pub utc_rfc3339: String,
pub assumptions: Vec<String>,
}
const SECS_1900_TO_1970: i64 = 2_208_988_800;
const TAI64_BASE: u64 = 1 << 62;
fn utc_rfc3339(epoch: Epoch) -> String {
let (y, mo, d, h, mi, s, ns) = epoch.to_gregorian_utc();
if ns == 0 {
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
} else {
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{ns:09}Z")
}
}
#[must_use]
pub fn from_gps_seconds(seconds: f64) -> LeapReading {
LeapReading {
scale: "GPS",
citation: "IS-GPS-200 (GPS system time; epoch 1980-01-06)",
utc_rfc3339: utc_rfc3339(Epoch::from_gpst_seconds(seconds)),
assumptions: vec![
"consistent with GPS system time — a reading, not a determination".to_string(),
"GPS↔UTC via the leap-second table; GPS itself has no leap seconds".to_string(),
],
}
}
#[must_use]
pub fn from_ptp(seconds: u64, nanos: u32) -> LeapReading {
let tai_since_1900 = SECS_1900_TO_1970 as f64 + seconds as f64 + f64::from(nanos) / 1e9;
LeapReading {
scale: "PTP (IEEE 1588)",
citation: "IEEE 1588 PTP (default profile: epoch 1970-01-01, TAI scale)",
utc_rfc3339: utc_rfc3339(Epoch::from_tai_seconds(tai_since_1900)),
assumptions: vec![
"consistent with IEEE 1588 PTP time — a reading, not a determination".to_string(),
"assumes the DEFAULT profile (epoch 1970 on the TAI scale, ptpTimescale=true); a \
profile with a different epoch or timescale would differ"
.to_string(),
],
}
}
#[must_use]
pub fn gps_rollover_eras(week10: u32, tow: f64, anchor: Option<i64>) -> Vec<LeapReading> {
const SECS_PER_WEEK: f64 = 604_800.0;
const GPS_EPOCH_UNIX: f64 = 315_964_800.0; const ERAS: u32 = 4; let base = week10 % 1024;
let reading = |era: u32| -> LeapReading {
let full = base + era * 1024;
let mut r = from_gps_seconds(f64::from(full) * SECS_PER_WEEK + tow);
r.assumptions.push(format!(
"10-bit GPS week aliases mod 1024 — era {era} (full week {full}); a legacy \
receiver's rollover means the true era depends on the case date"
));
r
};
match anchor {
Some(a) => {
let best = (0..ERAS)
.min_by_key(|&era| {
let approx =
GPS_EPOCH_UNIX + f64::from(base + era * 1024) * SECS_PER_WEEK + tow;
(approx - a as f64).abs() as i64
})
.unwrap_or(0);
vec![reading(best)]
}
None => (0..ERAS).map(reading).collect(),
}
}
#[must_use]
pub fn within_leap_smear_window(unix_seconds: i64) -> bool {
let offset_at = |s: i64| Epoch::from_unix_seconds(s as f64).leap_seconds(true);
match (
offset_at(unix_seconds - 43_200),
offset_at(unix_seconds + 43_200),
) {
(Some(before), Some(after)) => (before - after).abs() > 0.5,
_ => false,
}
}
#[must_use]
pub fn leap_seconds_on_utc_day(unix_midnight: i64) -> i8 {
let offset_at = |s: i64| Epoch::from_unix_seconds(s as f64).leap_seconds(true);
match (offset_at(unix_midnight), offset_at(unix_midnight + 86_400)) {
(Some(start), Some(end)) => (end - start).round() as i8,
_ => 0,
}
}
#[must_use]
pub fn gps_week(unix_seconds: i64) -> i64 {
let gpst = Epoch::from_unix_seconds(unix_seconds as f64).to_gpst_seconds();
(gpst / 604_800.0).floor() as i64
}
pub fn from_tai64(label: u64) -> Result<LeapReading, ChronoError> {
let s = label
.checked_sub(TAI64_BASE)
.ok_or(ChronoError::OutOfRange {
what: "TAI64 label below 2^62 (pre-1970 era not supported)",
value: i128::from(label),
})?;
let tai_since_1970 = i64::try_from(s).map_err(|_| ChronoError::OutOfRange {
what: "TAI64 seconds offset too large",
value: i128::from(s),
})?;
let secs_since_1900 =
tai_since_1970
.checked_add(SECS_1900_TO_1970)
.ok_or(ChronoError::OutOfRange {
what: "TAI seconds overflow",
value: i128::from(tai_since_1970),
})?;
Ok(LeapReading {
scale: "TAI64",
citation: "D. J. Bernstein libtai TAI64 (label = 2^62 + TAI seconds since 1970)",
utc_rfc3339: utc_rfc3339(Epoch::from_tai_seconds(secs_since_1900 as f64)),
assumptions: vec![
"consistent with a TAI64 label — a reading, not a determination".to_string(),
"TAI→UTC applies the leap-second table (TAI−UTC = 37 s since 2017)".to_string(),
],
})
}
pub fn from_ntp_seconds(seconds: u64) -> Result<LeapReading, ChronoError> {
let unix = i64::try_from(seconds)
.map(|s| s - SECS_1900_TO_1970)
.map_err(|_| ChronoError::OutOfRange {
what: "NTP seconds too large",
value: i128::from(seconds),
})?;
let ts = jiff::Timestamp::from_second(unix).map_err(|e| ChronoError::Render(e.to_string()))?;
Ok(LeapReading {
scale: "NTP",
citation: "RFC 5905 (NTPv4; prime epoch 1900-01-01)",
utc_rfc3339: ts.to_string(),
assumptions: vec![
"consistent with an NTP timestamp — a reading, not a determination".to_string(),
"NTP follows UTC (no leap-second counting); conversion is additive".to_string(),
"era 0 assumed — the 32-bit seconds field wraps in 2036; RFC 5905's \
128-bit date format carries an explicit era to disambiguate"
.to_string(),
],
})
}
#[must_use]
pub fn decode(id: &str, value: i64) -> Option<Result<LeapReading, ChronoError>> {
match id {
"gps" => Some(Ok(from_gps_seconds(value as f64))),
"tai64" => Some(
u64::try_from(value)
.map_err(|_| ChronoError::OutOfRange {
what: "TAI64 label (negative)",
value: i128::from(value),
})
.and_then(from_tai64),
),
"ntp" => Some(
u64::try_from(value)
.map_err(|_| ChronoError::OutOfRange {
what: "NTP seconds (negative)",
value: i128::from(value),
})
.and_then(from_ntp_seconds),
),
_ => None,
}
}