Time Systems¶
Precise timekeeping is fundamental to astrodynamics: a satellite in low-Earth orbit moves at ~7.5 km/s, so a 1 ms timing error is ~7.5 m of position error. This page explains how satkit handles time — the scales it supports, which one each part of the library uses, how an instant is stored, and how leap seconds, Earth orientation data and TDB enter the conversions — with runnable examples alongside. The API reference lists every constructor and method and how Python datetime values are interpreted.
satkit has its own time type, satkit.time (Rust: satkit::Instant), rather than using datetime or datetime64, because the library needs what those lack: an explicit time scale, leap seconds that exist, exact integer arithmetic, and UT1 and TDB. Every function that takes a time accepts one, in Python and Rust alike.
The Time Scales¶
A time scale is a rule for labelling instants. The scales differ in what "one second" means (an atomic SI second, or a fraction of one rotation of the Earth), where they are defined (on the geoid or at the solar-system barycenter), and whether they follow the Earth's rotation. The definitions follow Chapter 10 of the IERS Conventions (Petit & Luzum 2010); UTC and leap seconds are defined by ITU-R TF.460-6.
satkit.timescale (Rust: satkit::TimeScale) has six members:
| Scale | Relation used by satkit | What it is for |
|---|---|---|
| TAI, International Atomic Time | reference scale | The average of several hundred atomic clocks, in SI seconds on the geoid. It never jumps, so satkit stores instants and measures elapsed time in it. |
| UTC, Coordinated Universal Time | $\text{UTC} = \text{TAI} - \Delta AT$ | Civil time: TAI minus $\Delta AT$, a whole number of leap seconds since 1972 and a drifting fractional offset from 1961 to 1971 (Leap Seconds), which keeps it within 0.9 s of UT1. Almost every external time — a calendar date, an ISO string, a Unix timestamp, a TLE epoch — is UTC. |
| TT, Terrestrial Time | $\text{TT} = \text{TAI} + 32.184$ s | The time argument of geocentric theories (precession–nutation, the lunar and planetary series). J2000 is 2000-01-01 12:00:00 TT. |
| GPS, GPS system time | $\text{GPS} = \text{TAI} - 19$ s | Equal to UTC at the GPS epoch (1980-01-06) and has not followed a leap second since. GPS receivers and SP3 orbit files label epochs in it. |
| UT1, Universal Time | $\text{UT1} = \text{UTC} + \Delta\text{UT1}$ | The Earth's rotation angle expressed as time; $\Delta\text{UT1}$ is measured, not computed (UT1 and EOP). |
| TDB, Barycentric Dynamical Time | $\text{TDB} \approx \text{TT} + 1.657\ \text{ms}\,\sin g$ | The time argument of the solar-system ephemerides (TDB and TT). |
There is also an Invalid member (converting to it returns NaN). TCG, TCB
and other GNSS system times are not represented.
One instant expressed in each scale:
import satkit as sk
t = sk.time(2024, 6, 15, 12, 0, 0)
print(f"UTC: MJD = {t.to_mjd(sk.timescale.UTC):.10f}")
print(f"TAI: MJD = {t.to_mjd(sk.timescale.TAI):.10f}")
print(f"TT: MJD = {t.to_mjd(sk.timescale.TT):.10f}")
print(f"TDB: MJD = {t.to_mjd(sk.timescale.TDB):.10f}")
print(f"UT1: MJD = {t.to_mjd(sk.timescale.UT1):.10f}")
print(f"GPS: MJD = {t.to_mjd(sk.timescale.GPS):.10f}")
# The offsets become clear when expressed in seconds
utc_mjd = t.to_mjd(sk.timescale.UTC)
print("\nOffsets from UTC (seconds):")
print(f" TAI - UTC = {(t.to_mjd(sk.timescale.TAI) - utc_mjd) * 86400:.3f} s (leap seconds)")
print(f" TT - UTC = {(t.to_mjd(sk.timescale.TT) - utc_mjd) * 86400:.3f} s (TAI + 32.184s)")
print(f" GPS - UTC = {(t.to_mjd(sk.timescale.GPS) - utc_mjd) * 86400:.3f} s (TAI - 19s)")
print(f" UT1 - UTC = {(t.to_mjd(sk.timescale.UT1) - utc_mjd) * 86400:.6f} s (Earth rotation)")
print(f" TDB - UTC = {(t.to_mjd(sk.timescale.TDB) - utc_mjd) * 86400:.6f} s (≈TT + periodic)")
UTC: MJD = 60476.5000000000 TAI: MJD = 60476.5004282407 TT: MJD = 60476.5008007407 TDB: MJD = 60476.5008007470 UT1: MJD = 60476.4999998105 GPS: MJD = 60476.5002083333 Offsets from UTC (seconds): TAI - UTC = 37.000 s (leap seconds) TT - UTC = 69.184 s (TAI + 32.184s) GPS - UTC = 18.000 s (TAI - 19s) UT1 - UTC = -0.016376 s (Earth rotation) TDB - UTC = 69.184540 s (≈TT + periodic)
Creating Times¶
satkit.time objects can be created from calendar dates, Julian dates, Unix timestamps, ISO strings, GPS week/second, or Python datetime objects. Numbers and strings without an offset are UTC; for how a datetime is interpreted (a naive one is local time) see the API reference.
import satkit as sk
from datetime import datetime, timezone
# From calendar date
t1 = sk.time(2024, 6, 15, 12, 30, 0)
print("Calendar: ", t1)
# From RFC 3339 / ISO 8601 string
t2 = sk.time.from_rfc3339("2024-06-15T12:30:00Z")
print("RFC 3339: ", t2)
# From Julian Date
t3 = sk.time.from_jd(2460477.0208333335, sk.timescale.UTC)
print("Julian Date: ", t3)
# From Modified Julian Date
t4 = sk.time.from_mjd(60476.520833333336, sk.timescale.UTC)
print("MJD: ", t4)
# From Unix timestamp
t5 = sk.time.from_unixtime(1718454600.0)
print("Unix: ", t5)
# From a time-zone-aware Python datetime
t6 = sk.time.from_datetime(datetime(2024, 6, 15, 12, 30, 0, tzinfo=timezone.utc))
print("datetime: ", t6)
# Current time
t7 = sk.time.now()
print("Now: ", t7)
Calendar: 2024-06-15T12:30:00.000000Z RFC 3339: 2024-06-15T12:30:00.000000Z Julian Date: 2024-06-15T12:30:00.000013Z MJD: 2024-06-15T12:30:00.000000Z Unix: 2024-06-15T12:30:00.000000Z datetime: 2024-06-15T12:30:00.000000Z Now: 2026-10-10T12:35:16.876883Z
Which Scale satkit Uses Where¶
The scale is always chosen inside the library; you pass a satkit.time and
each algorithm reads it in the scale it needs.
| Component | Scale |
|---|---|
Internal instant count, time − time, time ± duration |
TAI (continuous SI microseconds) |
| Numerical propagator independent variable; SGP4 time since epoch | elapsed SI seconds between instants (TAI) |
| Calendar components, strings, Unix time (input and output) | UTC, unless scale= is given |
Python datetime input |
its own time zone, or local time if naive (API reference) |
| TLE and OMM epochs; SGP4 initialization | UTC |
| EOP lookup ($\Delta$UT1, polar motion, $dX$/$dY$) | UTC (daily rows at 0 h UTC) |
| Space-weather lookup for the density models | UTC calendar day |
| Earth rotation angle (ITRF ↔ GCRF), GMST (TEME ↔ ITRF) | UT1 |
| IAU 2006/2000A precession–nutation, TIO locator $s'$, equation of the equinoxes | TT |
JPL DE ephemerides (satkit.jplephem) |
TDB |
Low-precision Sun and Moon (satkit.sun, satkit.moon) |
TDB |
Low-precision planets (satkit.planets) |
TT |
Because the frame transforms read UT1 and TT from the same instant, a time
given in the wrong scale is not a small error: labelling a GPS-time epoch as
UTC shifts it by 18 s, which is 18 s of Earth rotation (about 1.3 mrad, or
8 km at the equator) in any ITRF ↔ GCRF rotation. Build such times with
scale=satkit.timescale.GPS or satkit.time.from_gps_week_and_second().
Calendar fields can be read in another scale with the scale= keyword, and from_mjd / from_jd take the scale of the number you pass:
import satkit as sk
# The same calendar fields, read as TT instead of UTC
t_tt = sk.time(2024, 6, 15, 12, 0, 0, scale=sk.timescale.TT)
print(f"12:00 TT is {t_tt} (printed in UTC)")
print(f"Difference: {(sk.time(2024, 6, 15, 12, 0, 0) - t_tt).seconds} s\n")
# Create a time specified in TAI
# TAI is ahead of UTC by 37 leap seconds (as of 2017)
# So TAI 00:00:37 is the same instant as UTC 00:00:00
t_utc = sk.time(2024, 1, 1, 0, 0, 0)
mjd_tai = t_utc.to_mjd(sk.timescale.TAI)
t_from_tai = sk.time.from_mjd(mjd_tai, sk.timescale.TAI)
print(f"UTC midnight: {t_utc}")
print(f"From TAI MJD: {t_from_tai}")
print(f"Same instant? {abs((t_utc - t_from_tai).seconds) < 1e-6}")
# Convert a known time to GPS MJD and back
t = sk.time(2024, 6, 15, 12, 0, 0)
mjd_gps = t.to_mjd(sk.timescale.GPS)
t_back = sk.time.from_mjd(mjd_gps, sk.timescale.GPS)
print(f"Original: {t}")
print(f"Via GPS MJD round-trip: {t_back}")
print(f"Match? {abs((t - t_back).seconds) < 1e-6}")
12:00 TT is 2024-06-15T11:58:50.816000Z (printed in UTC) Difference: 69.184 s UTC midnight: 2024-01-01T00:00:00.000000Z From TAI MJD: 2024-01-01T00:00:00.000000Z Same instant? True Original: 2024-06-15T12:00:00.000000Z Via GPS MJD round-trip: 2024-06-15T12:00:00.000000Z Match? True
How an Instant Is Stored¶
An Instant is a single signed 64-bit integer, raw: the number of SI
microseconds since 1970-01-01 00:00:00 TAI, counted continuously through
every leap second and pre-1972 UTC step. In terms of the Unix time $t_\text{unix}$ (which ignores leap
seconds),
$$ \text{raw} = 10^6\,\big(t_\text{unix} + \Delta AT(t)\big) \qquad\Longleftrightarrow\qquad \text{MJD}_\text{TAI} = 40587 + \frac{\text{raw}}{86\,400 \times 10^6}. $$
That is, raw is a TAI count zeroed at 1970-01-01 00:00:00 TAI. The Unix
epoch, 1970-01-01 00:00:00 UTC, is 8.000082 s later (time.UNIX_EPOCH,
raw = 8,000,082), because $\Delta AT$ was 8.000082 s then
(Leap Seconds). Every other scale is computed
from it on demand:
- UTC: subtract $\Delta AT$: the leap-second table from 1972, the 1961–1971 drift-and-step model before it, zero before 1961.
- TT, GPS: add the fixed offsets above; exact in integer microseconds.
- UT1: add the interpolated $\Delta\text{UT1}$ to UTC.
- TDB: add the periodic series to TT.
Consequences of the integer representation:
- Resolution is 1 µs over a range of about ±292,000 years. Equality, ordering and hashing are exact, and the difference of two instants is an exact integer number of microseconds that includes any leap seconds in between.
satkit.durationis also an integer microsecond count. Floating-point input — calendar seconds, Unix time, MJD/JD, GPS seconds of week anddurationfields — is rounded to the nearest microsecond; a string with more than six fractional digits is truncated to six.- Floating-point dates lose precision.
to_mjd()andto_jd()returnf64days. Near the present an MJD is resolved to about 0.6 µs, so MJD round trips are exact at the microsecond level, but a Julian Date (a number near 2.46 million) is resolved only to about 40 µs, and a JD round trip can be off by ~20 µs. Compute intervals by subtractingtimeobjects, not their MJDs or JDs. - Adding a float adds SI days.
time + 1.0adds onedurationof exactly 86,400 s. To step by UTC calendar days — which differ from 86,400 s on a day with a leap second — useadd_utc_days().
satkit.duration arithmetic:
import satkit as sk
t = sk.time(2024, 3, 20, 12, 0, 0)
# Create durations with mixed units
d = sk.duration(days=1, hours=6, minutes=30)
print(f"Duration: {d.days:.4f} days = {d.hours:.2f} hours = {d.seconds:.1f} seconds")
# Time arithmetic
t2 = t + d
print(f"\n{t} + {d.hours:.1f}h = {t2}")
# Difference between two times
new_year = sk.time(2025, 1, 1)
countdown = new_year - t
print(f"\nDays from {t} to New Year 2025: {countdown.days:.1f}")
# Generate a time array (common pattern for propagation)
times = [t + sk.duration(minutes=i * 10) for i in range(7)]
for ti in times:
print(f" {ti}")
Duration: 1.2708 days = 30.50 hours = 109800.0 seconds 2024-03-20T12:00:00.000000Z + 30.5h = 2024-03-21T18:30:00.000000Z Days from 2024-03-20T12:00:00.000000Z to New Year 2025: 286.5 2024-03-20T12:00:00.000000Z 2024-03-20T12:10:00.000000Z 2024-03-20T12:20:00.000000Z 2024-03-20T12:30:00.000000Z 2024-03-20T12:40:00.000000Z 2024-03-20T12:50:00.000000Z 2024-03-20T13:00:00.000000Z
Leap Seconds¶
The $\Delta AT$ table is compiled into the library: 28 entries, from
10 s at 1972-01-01 to 37 s at 2017-01-01, transcribed from
IERS Bulletin C, plus the 1961–1971 model of
USNO tai-utc.dat described below. No data file is read for it and
nothing is downloaded, so a leap second announced in the future requires a
new satkit release. After the last entry $\Delta AT$ is held at 37 s, which
remains correct until IERS announces another leap second.
During a leap second the internal count advances normally; one second of
raw values has no counterpart in a uniform-day representation of UTC.
satkit handles that second as follows:
- Calendar output (
str(),to_gregorian(),to_rfc3339()) shows it as23:59:60.xxxxxx, and calendar and RFC 3339 input accept that label on a day that has a leap second (satkit.time(2016, 12, 31, 23, 59, 60.5)); on any other day:60is an error. to_mjd()/to_jd()in UTC, andto_unixtime(), map it onto a repeat of23:59:59.xxxxxx, the POSIX convention: every UTC day is 86,400 s long in these units, so the UTC MJD is not monotonic across a leap second. The TAI, TT and GPS MJDs are.to_datetime()goes through Unix time, so Python'sdatetime, which has no second 60, also receives23:59:59.xxxxxx.- Durations and
timedifferences count the leap second: adding 2 s to 2016-12-31 23:59:59 UTC gives 2017-01-01 00:00:00 UTC, andtime(2017, 1, 1) - time(2016, 12, 31)is 86,401 s.add_utc_days(1.0)steps one calendar day instead. - The same applies to the positive pre-1972 steps.
Before 1972 UTC was not a leap-second scale. From 1961-01-01 it ran on
"rubber seconds": $\Delta AT = A + (\text{MJD}_\text{UTC} - \text{MJD}_0)\,r$,
drifting by 1.1–2.6 ms per day, with steps of +0.1 s (seven times, 1963-11-01
to 1965-09-01) and −0.05 s / −0.1 s (1961-08-01, 1968-02-01). satkit uses
the coefficients of USNO tai-utc.dat,
which are also those of ERFA's dat, so pre-1972 UTC labels convert to TAI,
TT, TDB and GPS to within 1 µs of ERFA. $\Delta AT$ was 1.422818 s at
1961-01-01, 8.000082 s at 1970-01-01 and 9.892242 s at the end of 1971, when
it stepped by +0.107758 s to 10 s. Positive steps are inserted intervals
labelled 23:59:60.x, like leap seconds (1971-12-31T23:59:60.0 to
23:59:60.107757). At a negative step the last 0.05 s / 0.1 s of labels on
the preceding day never occurred; they are accepted and, as in ERFA, land on
the first 0.05 s / 0.1 s of the next day. A pre-1972 UTC second is
$1 + (1.3\text{–}3.0)\times10^{-8}$ SI seconds, so about one TAI microsecond
in $3\times10^{7}$ shares its label with its neighbour. Labels on the step days
follow ERFA's dtf2d / utctai; ERFA's d2dtf treats only steps over 0.5 s
as leap seconds, so its printed labels on those days can differ by up to the step.
Before 1961 satkit takes $\Delta AT = 0$, so UTC labels there are
TAI-aligned. This deliberately differs from ERFA, which also models 1960, and
it puts a 1.422818 s inserted interval (1960-12-31T23:59:60.0 to
23:59:61.422817) at the start of 1961.
Stepping through the 2016-12-31 leap second, entering it by its label, and adding 86,400 s versus one UTC calendar day:
import satkit as sk
# Stepping through the 2016-12-31 leap second
t0 = sk.time(2016, 12, 31, 23, 59, 59)
for s in range(3):
print(t0 + sk.duration(seconds=s))
# The leap second can be entered by its label
print("\nParsed:", sk.time(2016, 12, 31, 23, 59, 60.5), sk.time.from_rfc3339("2016-12-31T23:59:60.5Z"))
# That day is 86401 s long
print("Length of 2016-12-31:", (sk.time(2017, 1, 1) - sk.time(2016, 12, 31)).seconds, "s")
# Adding 1 day (86400 s) vs. one UTC calendar day
noon = sk.time(2016, 12, 31, 12, 0, 0)
print("\nnoon + 1 day (86400 s):", noon + sk.duration(days=1))
print("noon.add_utc_days(1): ", noon.add_utc_days(1.0))
2016-12-31T23:59:59.000000Z 2016-12-31T23:59:60.000000Z 2017-01-01T00:00:00.000000Z Parsed: 2016-12-31T23:59:60.500000Z 2016-12-31T23:59:60.500000Z Length of 2016-12-31: 86401.0 s noon + 1 day (86400 s): 2017-01-01T11:59:59.000000Z noon.add_utc_days(1): 2017-01-01T12:00:00.000000Z
UT1 and Earth Orientation Parameters¶
$\Delta\text{UT1}$ comes from the Earth orientation parameter (EOP) table,
loaded from the IERS finals2000A.all file
(IERS Rapid Service), which starts
on 1973-01-02. The table has one row per day at 0 h UTC and
is looked up by UTC MJD; values between rows are linearly interpolated.
$\Delta\text{UT1}$ jumps wherever $\Delta AT$ does, by 1 s at a leap second,
so the rows are interpolated as $\text{UT1} - \text{TAI}$, using
$\Delta AT$ at each row and at the query; UT1 is continuous, monotonic and
invertible across every step inside the table. The inverse conversion
(from_mjd(..., scale=UT1)) uses the same continuous quantity and
round-trips to the microsecond.
What happens outside the table depends on where the epoch falls, as reported
by satkit.frametransform.eop_status(t):
| Status | Meaning | $\Delta\text{UT1}$ used |
|---|---|---|
"observed" |
inside the table, on or before the last measured row | interpolated |
"predicted" |
inside the table, in the IERS prediction (about a year for finals2000A.all) |
interpolated prediction |
"extrapolated" |
after the last row | last row held constant; one-time warning on stderr |
"before_table" |
before the first row (1973-01-02) | zero, so UT1 = UTC and repeats UTC's steps, stepping back at 00:00 after each leap second or positive pre-1972 step (as ERFA does with $\Delta\text{UT1} = 0$); one-time warning |
"not_loaded" |
no EOP table available | zero (UT1 = UTC, as above); one-time warning |
This table covers $\Delta\text{UT1}$ only; what each status means for frame
transforms and for the propagator (which refuses to run with "not_loaded")
is in EOP coverage.
A zero $\Delta\text{UT1}$ means UT1 = UTC, an error of up to 0.9 s in UT1,
i.e. up to ~420 m of Earth rotation at the equator. Held-constant values drift
by roughly 10 ms over a few months. Refresh the table with
satkit.utils.update_datafiles(); check the covered span with
satkit.frametransform.eop_coverage(); silence the warnings with
satkit.frametransform.disable_eop_time_warning(); or set
propsettings.require_eop_coverage = True to make the propagator raise
rather than extrapolate past the table or use zero before it.
The loaded table's span, and where some epochs fall in it:
import satkit as sk
first, last_observed, last = sk.frametransform.eop_coverage()
print(f"EOP table: {first} to {last} (measured up to {last_observed})")
for t in (sk.time(1990, 1, 1), sk.time.now(), sk.time(2040, 1, 1)):
print(f"{t}: {sk.frametransform.eop_status(t)}")
EOP table: 1973-01-02T00:00:00.000000Z to 2027-10-04T00:00:00.000000Z (measured up to 2026-10-08T00:00:00.000000Z) 1990-01-01T00:00:00.000000Z: observed 2026-10-10T12:35:16.979772Z: predicted 2040-01-01T00:00:00.000000Z: extrapolated
Offsets Between Scales Over Time¶
Each scale's offset from UTC every few days from 2000 to 2024:
import satkit as sk
import numpy as np
import matplotlib.pyplot as plt
import scienceplots # noqa: F401
plt.style.use(["science", "no-latex", "../satkit.mplstyle"])
%config InlineBackend.figure_formats = ['svg']
# Show the difference between time scales over a year
start = sk.time(2000, 1, 1)
times = [start + sk.duration(days=d) for d in np.linspace(0, 365*24, 2000)]
# Compute offsets from UTC in seconds
tai_offset = [(t.to_mjd(sk.timescale.TAI) - t.to_mjd(sk.timescale.UTC)) * 86400 for t in times]
tt_offset = [(t.to_mjd(sk.timescale.TT) - t.to_mjd(sk.timescale.UTC)) * 86400 for t in times]
gps_offset = [(t.to_mjd(sk.timescale.GPS) - t.to_mjd(sk.timescale.UTC)) * 86400 for t in times]
ut1_offset = [(t.to_mjd(sk.timescale.UT1) - t.to_mjd(sk.timescale.UTC)) * 86400 for t in times]
tdb_tt = [(t.to_mjd(sk.timescale.TDB) - t.to_mjd(sk.timescale.TT)) * 86400 for t in times]
dates = [t.to_datetime() for t in times]
fig, axes = plt.subplots(2, 1, figsize=(10, 7))
ax = axes[0]
ax.plot(dates, tai_offset, label="TAI $-$ UTC")
ax.plot(dates, tt_offset, label="TT $-$ UTC")
ax.plot(dates, gps_offset, label="GPS $-$ UTC")
ax.set_ylabel("Offset from UTC [s]")
ax.set_title("Time Scale Offsets from UTC (2000--2024)")
ax.legend()
ax = axes[1]
ax.plot(dates, [u * 1000 for u in ut1_offset], label="UT1 $-$ UTC", color="C3")
ax.set_ylabel("Offset [ms]")
ax.set_xlabel("Year")
ax.set_title("UT1 $-$ UTC (Earth Rotation Irregularities)")
ax.legend()
for ax in axes:
ax.xaxis.set_major_formatter(plt.matplotlib.dates.DateFormatter("%Y"))
fig.autofmt_xdate()
plt.tight_layout()
plt.show()
The top panel shows the fixed offsets: TAI − UTC steps by 1 s at each leap second, TT and GPS follow it at fixed distances. The bottom panel shows ΔUT1 = UT1 − UTC from the IERS Earth-orientation table, which drifts with the Earth's rotation and jumps by +1 s at each leap second.
TDB and TT¶
TDB and TT tick at the same average rate but differ periodically, because a
clock on the moving, gravitating Earth runs at a varying rate relative to one
at the barycenter. satkit keeps only the dominant annual term
(Vallado 2013, Eq. 3-50;
Petit & Luzum 2010, §10.1):
$$ \text{TDB} - \text{TT} \approx 0.001657\ \text{s}\ \sin\!\big(628.3076\,T + 6.2401\big), \qquad T = \frac{\text{JD}_\text{TT} - 2451545.0}{36525}, $$
with the argument in radians (628.3076 rad per century is one revolution per
year; the phase is the Earth's mean anomaly at J2000). The terms left out are
individually small; against the full series (ERFA dtdb) the one-term
formula is within 54 µs over 1900–2100 (20 µs RMS). The inverse
(from_mjd(..., scale=TDB)) evaluates the same term at the TDB date, which is
equivalent to well under a microsecond.
TDB is read by the JPL DE ephemerides (whose native argument is TDB, strictly $T_\text{eph}$) and by the low-precision Sun and Moon models.
# TDB - TT: the annual periodic term described above
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(dates, [t * 1000 for t in tdb_tt], "k-", linewidth=0.8)
ax.set_xlabel("Year")
ax.set_ylabel("TDB $-$ TT [ms]")
ax.set_title("TDB $-$ TT: Relativistic Correction from Earth's Orbital Eccentricity")
ax.xaxis.set_major_formatter(plt.matplotlib.dates.DateFormatter("%Y"))
fig.autofmt_xdate()
plt.tight_layout()
plt.show()
GPS Week and Second¶
GPS receivers report time as a week number and second of week, which from_gps_week_and_second reads directly:
import satkit as sk
# GPS epoch: week 0, second 0 = January 6, 1980 00:00:00 UTC
gps_epoch = sk.time.from_gps_week_and_second(0, 0)
print(f"GPS epoch: {gps_epoch}")
# A GPS receiver reports week 2321, second-of-week 302400
# (Wednesday at noon, since 302400 = 3.5 days * 86400 s/day)
t = sk.time.from_gps_week_and_second(2321, 302400)
print(f"Week 2321, SOW 302400: {t}")
# GPS-UTC offset: GPS time is ahead of UTC by (TAI-UTC) - 19 seconds
# At GPS epoch, TAI-UTC was 19s, so GPS = UTC. Since then, 18 more
# leap seconds have been inserted, so GPS is now 18s ahead of UTC.
t_modern = sk.time(2024, 6, 15, 12, 0, 0)
gps_mjd = t_modern.to_mjd(sk.timescale.GPS)
utc_mjd = t_modern.to_mjd(sk.timescale.UTC)
print(f"\nGPS - UTC offset in 2024: {(gps_mjd - utc_mjd) * 86400:.0f} seconds")
# Round-trip: time -> GPS week/second -> time
# Compute GPS week and second-of-week from a known time
gps_days = gps_mjd - gps_epoch.to_mjd(sk.timescale.GPS)
week = int(gps_days // 7)
sow = (gps_days - week * 7) * 86400
print(f"\n{t_modern} in GPS: week {week}, SOW {sow:.1f}")
t_back = sk.time.from_gps_week_and_second(week, sow)
print(f"Round-trip: {t_back}")
print(f"Match: {abs((t_modern - t_back).seconds) < 1e-3}")
GPS epoch: 1980-01-06T00:00:00.000000Z Week 2321, SOW 302400: 2024-07-03T11:59:42.000000Z GPS - UTC offset in 2024: 18 seconds 2024-06-15T12:00:00.000000Z in GPS: week 2318, SOW 561618.0 Round-trip: 2024-06-15T12:00:00.000000Z Match: True
Rust Usage¶
The Rust equivalents are methods on satkit::Instant, with the scale passed explicitly:
use satkit::{Duration, Instant, TimeScale};
// Calendar input is UTC
let t = Instant::from_datetime(2024, 6, 15, 12, 0, 0.0)?;
let tt_minus_utc =
(t.as_mjd_with_scale(TimeScale::TT) - t.as_mjd_with_scale(TimeScale::UTC)) * 86400.0;
println!("TT - UTC = {tt_minus_utc:.3} s"); // 69.184 s
// The same calendar components interpreted in TT
let t_tt = Instant::from_datetime_with_scale(2024, 6, 15, 12, 0, 0.0, TimeScale::TT)?;
let dt: Duration = t - t_tt; // exact integer microseconds
assert_eq!(dt.as_microseconds(), 69_184_000);
// Crossing a leap second
let t0 = Instant::from_datetime(2016, 12, 31, 23, 59, 59.0)?;
println!("{}", t0 + Duration::from_seconds(1.0)); // 2016-12-31T23:59:60.000000Z
Time and Time Scales in the Rust section has a complete program, with parsing, formatting and chrono interoperability.
See Also¶
- Theory: TLEs, SGP4 & OMMs for UTC element-set epochs; Force Model for how stale EOP data affects propagation.
- Data: Data Coverage for the span of the EOP table.
- API:
satkit.time,satkit.timescale,satkit.duration;satkit.frametransform(eop_status,eop_coverage,disable_eop_time_warning). - References: Petit & Luzum 2010 (Ch. 10), ITU-R TF.460-6, IERS Bulletin C, Vallado 2013 (Eq. 3-50), IERS
finals2000A.all.