Time Systems¶
Precise timekeeping is fundamental to astrodynamics. A satellite in low-Earth orbit moves at ~7.5 km/s, so a 1-millisecond timing error translates to ~7.5 meters of position error. Different time scales exist because no single definition of "time" serves all purposes — atomic clocks, Earth rotation, and solar system dynamics each demand their own.
Why yet another time type?¶
Python already has datetime.datetime, NumPy has np.datetime64, astropy has astropy.time.Time, and the standard library has time.time(). Adding satkit.time on top of all of these is a deliberate choice, not a case of not-invented-here:
- Time scale is a first-class concept. A
datetimehas no idea whether it represents UTC, TAI, TT, or "wall clock with unknown offset." Subtracting twodatetimevalues across a leap second silently gives the wrong answer.satkit.timecarries no such ambiguity — conversions between UTC, TAI, TT, TDB, UT1, and GPS are explicit, and leap seconds are applied from the bundled IERS tables rather than assumed away. - Microsecond-precision integer representation.
satkit.timestores time internally as microseconds since the Unix epoch in a 64-bit signed integer. This gives microsecond resolution over a ±292,000-year range — the same resolution asdatetime.datetime, but with an unambiguous epoch and monotonic integer semantics that make arithmetic, comparison, and hashing trivial and lossless. Round-trips through TAI↔UTC↔TT↔TDB↔UT1↔GPS and arithmetic withsatkit.durationare exact to the microsecond. - Leap seconds handled correctly.
datetimepretends leap seconds do not exist; during a real leap instant like2016-12-31T23:59:60Zit either refuses to parse or silently collapses it onto 23:59:59.satkit.timerepresents the leap instant faithfully and the TAI↔UTC conversion is exact on both sides of every leap. - UT1 and TDB without extra dependencies. UT1 requires IERS Earth-orientation data; TDB requires the relativistic correction from Earth's orbital motion.
satkitalready bundles IERS EOP tables and JPL ephemerides for its frame transforms and propagator, so exposing these scales as first-class types costs nothing extra and avoids forcing users to stitch together astropy + an IERS downloader + a relativity model just to log a propagation epoch. - One type across the Rust core and Python bindings. The propagator, frame transforms, SGP4 wrapper, ephemeris queries, and ground-contact search all speak
satkit.time. Converting todatetimeat every API boundary would be both slower and lossy.
Where none of that matters, satkit.time interoperates directly with datetime.datetime — you can construct one from the other and back, and every public API that takes a time will also accept a plain datetime.
Supported time scales¶
satkit supports seamless conversion between six time scales:
| Scale | Description | Use |
|---|---|---|
| UTC | Coordinated Universal Time | Civil time, input/output default |
| TAI | International Atomic Time | Monotonic, no leap seconds |
| TT | Terrestrial Time | Ephemeris calculations on Earth |
| TDB | Barycentric Dynamical Time | Solar system ephemerides (JPL DE440) |
| UT1 | Universal Time 1 | Tied to Earth rotation angle |
| GPS | GPS Time | GPS epoch (Jan 6, 1980), no leap seconds |
Creating Times¶
satkit.time objects can be created from calendar dates, Julian dates, Unix timestamps, ISO strings, GPS week/second, or Python datetime objects. All interpret input as UTC by default.
import satkit as sk
from datetime import datetime
# 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 Python datetime
t6 = sk.time.from_datetime(datetime(2024, 6, 15, 12, 30, 0))
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-04-06T00:00:02.567627Z
Time Scale Conversions¶
The relationships between time scales are:
- TAI = UTC + leap seconds (37s as of 2017)
- TT = TAI + 32.184s (fixed offset, by definition)
- GPS = TAI - 19s (fixed offset; GPS epoch predates some leap seconds)
- TDB ≈ TT + periodic terms (±1.7 ms, due to Earth's orbital eccentricity)
- UT1 = UTC + (UT1-UTC) where (UT1-UTC) is measured and tabulated by IERS, always < 0.9s
Use as_jd(scale) or as_mjd(scale) to convert to any scale:
import satkit as sk
t = sk.time(2024, 6, 15, 12, 0, 0)
print(f"UTC: MJD = {t.as_mjd(sk.timescale.UTC):.10f}")
print(f"TAI: MJD = {t.as_mjd(sk.timescale.TAI):.10f}")
print(f"TT: MJD = {t.as_mjd(sk.timescale.TT):.10f}")
print(f"TDB: MJD = {t.as_mjd(sk.timescale.TDB):.10f}")
print(f"UT1: MJD = {t.as_mjd(sk.timescale.UT1):.10f}")
print(f"GPS: MJD = {t.as_mjd(sk.timescale.GPS):.10f}")
# The offsets become clear when expressed in seconds
utc_mjd = t.as_mjd(sk.timescale.UTC)
print("\nOffsets from UTC (seconds):")
print(f" TAI - UTC = {(t.as_mjd(sk.timescale.TAI) - utc_mjd) * 86400:.3f} s (leap seconds)")
print(f" TT - UTC = {(t.as_mjd(sk.timescale.TT) - utc_mjd) * 86400:.3f} s (TAI + 32.184s)")
print(f" GPS - UTC = {(t.as_mjd(sk.timescale.GPS) - utc_mjd) * 86400:.3f} s (TAI - 19s)")
print(f" UT1 - UTC = {(t.as_mjd(sk.timescale.UT1) - utc_mjd) * 86400:.6f} s (Earth rotation)")
print(f" TDB - UTC = {(t.as_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.5008007473 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.016374 s (Earth rotation) TDB - UTC = 69.184569 s (≈TT + periodic)
Constructing Times in Non-UTC Scales¶
To create a time from a non-UTC scale, use from_mjd or from_jd with the appropriate timescale:
import satkit as sk
# 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.as_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}")
# GPS epoch: January 6, 1980 00:00:00 GPS
gps_epoch = sk.time.from_gps_week_and_second(0, 0)
print(f"\nGPS epoch (UTC): {gps_epoch}")
# Convert a known time to GPS MJD and back
t = sk.time(2024, 6, 15, 12, 0, 0)
mjd_gps = t.as_mjd(sk.timescale.GPS)
t_back = sk.time.from_mjd(mjd_gps, sk.timescale.GPS)
print(f"\nOriginal: {t}")
print(f"Via GPS MJD round-trip: {t_back}")
print(f"Match? {abs((t - t_back).seconds) < 1e-6}")
UTC midnight: 2024-01-01T00:00:00.000000Z From TAI MJD: 2024-01-01T00:00:00.000000Z Same instant? True GPS epoch (UTC): 1980-01-06T00:00:00.000000Z Original: 2024-06-15T12:00:00.000000Z Via GPS MJD round-trip: 2024-06-15T12:00:00.000000Z Match? True
Duration Arithmetic¶
satkit.duration represents time intervals with microsecond precision. Durations can be added to or subtracted from time objects, and two times can be subtracted to get a duration.
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
Why Time Scales Matter¶
Using the wrong time scale introduces systematic errors. This example shows the position error when propagating a GPS satellite for 24 hours using UTC vs. GPS time for the internal calculations. The difference between time scales at epoch translates directly into along-track position error.
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.as_mjd(sk.timescale.TAI) - t.as_mjd(sk.timescale.UTC)) * 86400 for t in times]
tt_offset = [(t.as_mjd(sk.timescale.TT) - t.as_mjd(sk.timescale.UTC)) * 86400 for t in times]
gps_offset = [(t.as_mjd(sk.timescale.GPS) - t.as_mjd(sk.timescale.UTC)) * 86400 for t in times]
ut1_offset = [(t.as_mjd(sk.timescale.UT1) - t.as_mjd(sk.timescale.UTC)) * 86400 for t in times]
tdb_tt = [(t.as_mjd(sk.timescale.TDB) - t.as_mjd(sk.timescale.TT)) * 86400 for t in times]
dates = [t.as_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 leads UTC by the cumulative leap second count (which jumps by 1s each time a leap second is inserted), TT is TAI + 32.184s, and GPS is TAI - 19s.
The bottom panel shows UT1-UTC, which reflects irregularities in Earth's rotation rate. This is measured by VLBI and published by the IERS; it is kept within ±0.9s of UTC by inserting leap seconds.
# TDB - TT: the periodic relativistic correction
# This oscillation (~1.7 ms amplitude) is caused by Earth's orbital eccentricity:
# clocks on Earth run slightly faster at aphelion (further from Sun, weaker gravity)
# and slower at perihelion (closer to Sun, stronger gravity)
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 Time¶
GPS time is a continuous time scale that started aligned with UTC on January 6, 1980. Unlike UTC, GPS time does not insert leap seconds, so it has gradually drifted ahead of UTC (by 18 seconds as of 2017). The from_gps_week_and_second function creates a time from the GPS week number and second-of-week — the native time format used by GPS receivers.
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.as_mjd(sk.timescale.GPS)
utc_mjd = t_modern.as_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.as_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-16T11:59:59.999999Z Match: True