Skip to content
Advanced

Time scales

A timestamp is a point: a moment against an epoch. Which epoch matters less than which time scale the epoch sits on, because scales count seconds differently:

  • TAI counts every SI second. GPS time is TAI − 19 s, and PTP counts TAI from 1970.
  • POSIX time (time_t, CLOCK_REALTIME, NTP) counts UTC with every day 86 400 s. A leap second isn't counted, so POSIX time and TAI drift apart by one second at each leap second: 10 s in 1972, 37 s since 2017.
  • A device's monotonic clock counts from its boot. Each device and each boot has its own zero, known only at run time.

Each scale is a Reference, and its epochs are origins on it. Points convert exactly within one scale. Between scales they convert only by an explicit step at run time.

Reference Origins Aliases
TaiScale TaiEpoch1958 (1958-01-01 TAI), GpsEpoch (1980-01-06 00:00:19 TAI), PtpEpoch (1970-01-01 TAI) TaiSecs .. TaiNanos, GpsSecs .. GpsNanos, PtpNanos
PosixScale UnixEpoch (1970-01-01 UTC), NtpEpoch (1900-01-01 UTC) UnixSecs .. UnixNanos, NtpSecs
DeviceMonotonic DeviceBoot DeviceMillis, DeviceMicros, DeviceNanos

All of them are in exact_units::origins (feature time). The T of each alias defaults to u64.

Within a scale: exact at compile time

GPS time to TAI or PTP time, or Unix to NTP time, is an offset of whole seconds. to works it out at compile time:

use exact_units::origins::{GpsSecs, NtpSecs, PtpEpoch, PtpNanos, UnixEpoch, UnixSecs, NtpEpoch};
use exact_units::prefix::{Nano, One};

// the GPS epoch is 315 964 819 s after PTP's: 3657 days and the 19 s GPS stays behind TAI
assert_eq!(
    GpsSecs::from_raw(0).to::<PtpEpoch, Nano, u64>(),
    Some(PtpNanos::from_raw(315_964_819_000_000_000))
);
// NTP's epoch is 2 208 988 800 s before Unix's
assert_eq!(UnixSecs::from_raw(0).to::<NtpEpoch, One, u64>(), Some(NtpSecs::from_raw(2_208_988_800)));

Points of two scales don't convert this way. POSIX time to GPS time needs the leap seconds, so it doesn't compile:

use exact_units::origins::{GpsEpoch, UnixSecs};
use exact_units::prefix::One;
let _ = UnixSecs::<u64>::from_raw(1_700_000_000).to::<GpsEpoch, One, u64>();

Between POSIX time and TAI: the leap-second table

The feature leap_seconds adds exact_units::leap. It has a LeapTable trait, one table, BULLETIN_C_72, and Point::across, which converts a moment between PosixScale and TaiScale through a table at run time. The table is named after its source, IERS Bulletin C 72 (2026-07-06), and it expires on 2027-06-28, the earliest date a new leap second could come. across gives the exact moment, or it refuses with a reason:

  • Stale: the moment is at or past the table's expiry.
  • InLeap: the moment of TAI falls inside a leap second. POSIX time has no number for 23:59:60.
  • BeforeTable: the moment is before 1972-01-01, when UTC was not yet whole seconds from TAI.
  • NotExact: the result isn't a whole number of the target's steps, or it doesn't fit the target's storage.

A GPS receiver gives its week number modulo 1024 and the time of week. Together they make a GpsMillis. That converts at compile time to TAI, and then through the table to POSIX time:

use exact_units::leap::{AcrossError, BULLETIN_C_72};
use exact_units::origins::{GpsMillis, TaiEpoch1958, TaiNanos, UnixEpoch, UnixNanos};
use exact_units::prefix::{Milli, Nano, One};
use exact_units::units::{Second, Week};
use exact_units::{Fixed, Millis};

// week 370 of the third era (it began 2019-04-07), halfway through the week
let (week, tow) = (370_u16 & 0x3ff, Millis::<u32>::new(302_400_000));
let weeks = Fixed::<Week, One, u64>::new(2 * 1024 + u64::from(week));
let gps = GpsMillis::<u64>::from_raw(0)
    + weeks.convert::<Second, Milli, u64>().unwrap()
    + tow.to().unwrap();

let tai: TaiNanos = gps.to::<TaiEpoch1958, Nano, u64>().unwrap();
let unix: UnixNanos = tai.across::<UnixEpoch, Nano, u64, _>(&BULLETIN_C_72).unwrap();
// GPS time is 18 s ahead of UTC in 2026
let gps_s = 2418 * 604_800 + 302_400;
assert_eq!(unix.raw(), (315_964_800 + gps_s - 18) * 1_000_000_000);

// the last leap second, 2016-12-31 23:59:60 UTC, has no POSIX time
let new_year = TaiNanos::from_raw((1_483_228_800 + 37 + 378_691_200) * 1_000_000_000_u64);
let leap = TaiNanos::from_raw(new_year.raw() - 500_000_000);
assert_eq!(leap.across::<UnixEpoch, Nano, u64, _>(&BULLETIN_C_72), Err(AcrossError::InLeap));

The table sits behind its feature on purpose. A firmware that doesn't enable leap_seconds has neither the table nor across: they are not compiled, so nothing of them reaches its flash. A host that keeps a newer table (from a later Bulletin C, or IANA's leap-seconds.list) implements LeapTable for it and passes that instead.

A device's clock: against a sync point

A device stamps its samples on its own monotonic clock. Where its boot lies on a host's scale is a run-time value: a clock sync measures it. against takes that value and gives the host's point:

use exact_units::origins::{DeviceMicros, TaiEpoch1958};
use exact_units::{Micros, Point};

let sample = DeviceMicros::from_raw(2_500_000);
// the sync says the device booted at this moment of TAI
let boot = Point::<TaiEpoch1958, Micros<u64>>::from_raw(2_000_000_000_000_000);
assert_eq!(sample.against(boot), Some(Point::from_raw(2_000_000_002_500_000)));

A host that talks to several devices declares a reference for each one, as DeviceMonotonic is declared. Then their points can't mix by mistake.

A reference as data

Reference::DEF is a ReferenceDef: the reference's id, its name, and how its zero is known (ReferenceHow):

  • Exact: the unit's own zero.
  • RunTime: a zero known only at run time, like the ambient pressure.
  • TimeScale: a time scale, with how it counts seconds (TimeCounting::EverySecond or PosixDays) and whether its zero is agreed or a device's own (TimeZero).

OriginDef::reference carries it, so a client that knows an origin only from the API model can tell whether two points convert, and how.