Time and Time Scales
Conversions between the time representations TABASCAL handles: seconds, days,
Julian Dates, Modified Julian Dates, Python datetime, and skyfield’s
Time.
Time scales
A Julian Date is a number until a scale says what it counts, and the scales in use differ by amounts that matter. At the present epoch the offsets from UTC are:
Scale |
Offset from UTC |
Error if a UTC epoch is read as this scale |
|---|---|---|
|
~0.05 s (DUT1) |
~0.4 km of LEO satellite ground track |
|
37 s (leap seconds) |
~285 km |
|
69.184 s |
~530 km |
Every one of those offsets drifts. TAI − UTC grows with each leap second — it was 32 s at J2000 and has been 37 s since 2017 — and TT − UTC follows it, being that plus a fixed 32.184 s. DUT1 wanders, and is held below 0.9 s (~7 km) by definition, so a UT1 mismatch can cost rather more than its present value suggests. The figures above are the current ones, not properties of the scales.
None of these raise; they simply produce a wrong position. Measurement Sets
record which scale their TIME column uses in its MEASINFO record
(Ref: UTC in the common case), so the scale should be taken from the data
rather than assumed. skyfield_time() accepts it as
scale, defaulting to utc.
Normalising at the boundary
TABASCAL works on UTC Julian Dates everywhere past the reader, so a declared
scale is converted once, where the times are read, rather than carried alongside
them. to_utc_jd() does that conversion and
utc_offset_days() is the offset it applies — the leap
seconds for TAI, those plus 32.184 s for TT, DUT1 for UT1 — computed per sample,
so an observation straddling a leap second is shifted by 36 s on one side of it
and 37 s on the other.
The offset lands on the day fraction, with the whole day carried across
separately. This does not make the result picosecond-accurate: a returned
Julian Date is a single f64 and near 2.5e6 days those are spaced ~40 µs apart,
which is the floor on any JD, converted or not. What the split buys is that the
conversion costs nothing beyond that floor — the offset is a difference of
O(1) fractions and so is exact to picoseconds, and recombining rounds once.
Rebuilding a UTC date from a skyfield accessor would spend the floor a second
time. utc returns its input unchanged — bit-identical, since no arithmetic
is done at all.
Doing this at the boundary is what lets the rest of the package stay
scale-free, including sgp4jax’s ITRF→GCRF path, which has no scale concept
to thread one through. It happens in two places, both reading the same
MEASINFO record and normalising the same way:
read_ms() for the times the fit runs on, and
ms_observation_epoch_jd() for the preflight epoch
the TLE age checks are measured from.
Day numbers that leave the observation
read_ms deliberately keeps a second time coordinate, times_mjd: the
TIME column in days, on the scale the column declares, so it stays
comparable with the column itself and with anything copied from it — a
caltable’s own TIME, for instance.
That makes it the wrong coordinate to hand to anything outside the
measurement set. A light-curve estimate’s time axis is written once and read
back by other runs against other measurement sets, so it names a scale of its
own: UTC. to_utc_mjd() is the conversion onto it, and both
ends of that format go through it — tabascal light-curve when it writes the
axis, and the RFI signal component when it samples an estimate onto the
observation grid. It adds the offset at MJD magnitude rather than routing
through a Julian Date, which keeps the sub-microsecond (~0.6 µs) an MJD near 6e4
resolves to instead of the ~40 µs a JD does; utc again returns its input
bit-identically.
The format states its scale in the file: tabascal light-curve stamps
time_scale: "utc", a file declaring anything else is refused rather than
converted, and one declaring nothing is read as UTC with a warning — files
written before the stamp existed took their times from the TIME column as
declared, so one measured on a non-UTC MS is offset by the leap seconds and
should be regenerated.
- tabascal.time.TIME_SCALES = {'et': 'tt_jd', 'iat': 'tai_jd', 'tai': 'tai_jd', 'tdb': 'tdb_jd', 'tdt': 'tt_jd', 'tt': 'tt_jd', 'ut': 'ut1_jd', 'ut1': 'ut1_jd', 'utc': '_utc_jd'}
Time scales that can be named in a Measurement Set’s
TIMEcolumnMEASINFOrecord, mapped to theskyfield.timelib.Timescaleconstructor that interprets a Julian Date on that scale.casacore names several of these more than once, and the name it writes is not always the one an outsider would reach for: its canonical spelling of Terrestrial Time is
TDT, withTTandETas synonyms, and TAI is also spelledIAT. All spellings are accepted, since the point is to forward whatever the MS declares.
- tabascal.time.datetime_to_jd(dt)[source]
Naive (UTC)
datetime.datetime→ UTC Julian Date.Inverse of
jd_to_datetime(). A timezone-aware datetime is accepted and treated as UTC.
- tabascal.time.gast_deg(times_jd, scale: str = 'utc')[source]
Greenwich Apparent Sidereal Time, in degrees, for Julian Dates.
The apparent (not mean) sidereal angle is returned, i.e. it includes the equation of the equinoxes, so this is GAST and not GMST.
- Parameters:
times_jd (array_like) – Observation times as Julian Dates on
scale.scale (str, optional) – Time scale the Julian Dates are on; see
skyfield_time().
- Returns:
GAST in degrees.
- Return type:
np.ndarray
- tabascal.time.jd_to_datetime(jd)[source]
UTC Julian Date → naive (UTC)
datetime.datetime.Civil-time conversion treating UTC as a uniform day count (no leap-second handling), which is all that is needed for TLE epoch dates and timestamps.
- tabascal.time.skyfield_time(times_jd, scale: str = 'utc')[source]
Julian Dates on a named time scale →
skyfield.timelib.Time.The single entry point for turning observation times into skyfield times, so the decisions below are made once rather than at each call site.
The scale is not cosmetic. A Julian Date is a number until a scale says what it counts. Reading a UTC epoch as UT1 shifts it by DUT1 (up to ~0.9 s), dragging a satellite along its track by the distance it covers in that time; reading it as TAI shifts it by the accumulated leap seconds, currently 37 s. Neither produces an error — only a wrong position.
scaledefaults to"utc"because that is what a Measurement Set’sTIMEcolumn almost always declares (MEASINFO Ref: UTC). It is a default, not an assumption: an MS may declareTAIor another scale, and callers reading one should pass what it says rather than relying on this.The Julian Date is split into whole and fractional parts before being handed to skyfield, to preserve full f64 precision: a JD’s ~2.5e6 day magnitude leaves f64 only ~5e-10 days of resolution on the value as a whole.
ut1is the exception — skyfield’sut1_jdtakes no fraction argument, so that one scale is passed the recombined Julian Date and keeps only ~5e-10 days (~40 us) of resolution.For
utcthis uses skyfield’s private_utc_jd, which is whypyproject.tomlpinsskyfield>=1.49,<2. Keeping it to this one call site means the pin protects a single line.- Parameters:
times_jd (array_like) – Observation times as Julian Dates on
scale.scale (str, optional) – Time scale the Julian Dates are on, as named in an MS
MEASINFOrecord. One ofTIME_SCALES; case-insensitive. Defaults to"utc".
- Returns:
The same times, read on
scale.- Return type:
skyfield.timelib.Time
- Raises:
ValueError – If
scaleis not one tabascal can interpret.
- tabascal.time.to_utc_jd(times_jd, scale: str = 'utc')[source]
Julian Dates on a named scale as the same instants on UTC.
tabascal reads times on whatever scale their source declares and works in UTC everywhere after that:
skyfield_time()defaults to it, the epoch checks compare against it, andsgp4jax.itrf_to_gcrfhas no scale concept to be told anything else. Converting once, where the times are read, puts all of them on the instant the source actually named without ascaleargument threaded through any of them.utcreturns the input unchanged – bit-identical, not merely close, since no arithmetic is done at all. The other scales haveutc_offset_days()added to the day fraction, with the whole day carried across separately.That does not make the answer picosecond-accurate. The return value is a single f64 Julian Date, and near 2.5e6 days those are spaced ~40 us apart: that is the floor on any JD, before or after conversion, and no arrangement of the arithmetic beats it. What the split buys is that the conversion costs nothing beyond that floor – the offset itself is exact to picoseconds, being a difference of O(1) fractions, and recombining rounds once. Rebuilding the UTC date from a skyfield accessor would spend the floor a second time.
- Parameters:
times_jd (array_like) – Julian Dates as the source declares them, on
scale.scale (str, optional) – Time scale the Julian Dates are on; see
skyfield_time(). Defaults to"utc", which is a no-op.
- Returns:
The same instants, as UTC Julian Dates.
- Return type:
np.ndarray
- Raises:
ValueError – If
scaleis not one tabascal can interpret.
- tabascal.time.to_utc_mjd(times_mjd, scale: str = 'utc')[source]
Modified Julian Dates on a named scale as the same instants on UTC.
The MJD counterpart of
to_utc_jd(), for the day numbers that leave an observation and are compared against another source’s – a light-curve estimate’s time axis, say. An MJD is a number until a scale says what it counts, so a day number written on whatever an MS happened to declare cannot be matched against one from anywhere else; UTC is the scale tabascal states for those, and this is what puts a declared column on it.utcreturns the input unchanged – bit-identical, not merely close, since no arithmetic is done at all. The other scales haveutc_offset_days()added.The offset is added at MJD magnitude rather than by routing through
to_utc_jd(): an MJD near 6e4 is spaced sub-microsecond (~0.6 us) apart in f64, where a Julian Date near 2.5e6 is spaced ~40 us apart, so the round trip out to a JD and back would round twice at the coarser magnitude – on a UTC MS, for a conversion that is not even needed. The offset lookup still goes through a Julian Date, which costs nothing: it selects which leap-second era the time falls in, and 40 us only changes that within 40 us of a leap second.- Parameters:
times_mjd (array_like) – Modified Julian Dates as the source declares them, on
scale.scale (str, optional) – Time scale the day numbers are on; see
skyfield_time(). Defaults to"utc", which is a no-op.
- Returns:
The same instants, as UTC Modified Julian Dates.
- Return type:
np.ndarray
- Raises:
ValueError – If
scaleis not one tabascal can interpret.
- tabascal.time.utc_offset_days(times_jd, scale: str = 'utc')[source]
Days to add to a Julian Date on
scaleto name the same instant on UTC.The offset is what separates the scales – the leap seconds for TAI, those plus 32.184 s for TT, DUT1 for UT1 – so it is small, at most a minute or so of days, and it is computed per sample: an observation straddling a leap second is offset by 36 s on one side of it and 37 s on the other.
It is read out of the whole/fraction pair skyfield already holds, rather than by differencing two Julian Dates or by reconstructing a UTC date from an accessor. Both of those would work at the Julian Date’s own ~2.5e6 magnitude, where f64 resolves only ~40 us; the whole days cancel exactly and the fractions are O(1), so the offset itself comes out exact to picoseconds. The Julian Date it is then added to still resolves only to that ~40 us – see
to_utc_jd().- Parameters:
times_jd (array_like) – Julian Dates as the source declares them, on
scale.scale (str, optional) – Time scale the Julian Dates are on; see
skyfield_time().
- Returns:
The offset in days, one per input time. Exactly zero for
utc.- Return type:
np.ndarray