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

ut1

~0.05 s (DUT1)

~0.4 km of LEO satellite ground track

tai

37 s (leap seconds)

~285 km

tt / et

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 TIME column MEASINFO record, mapped to the skyfield.timelib.Timescale constructor 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, with TT and ET as synonyms, and TAI is also spelled IAT. 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.

scale defaults to "utc" because that is what a Measurement Set’s TIME column almost always declares (MEASINFO Ref: UTC). It is a default, not an assumption: an MS may declare TAI or 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. ut1 is the exception — skyfield’s ut1_jd takes no fraction argument, so that one scale is passed the recombined Julian Date and keeps only ~5e-10 days (~40 us) of resolution.

For utc this uses skyfield’s private _utc_jd, which is why pyproject.toml pins skyfield>=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 MEASINFO record. One of TIME_SCALES; case-insensitive. Defaults to "utc".

Returns:

The same times, read on scale.

Return type:

skyfield.timelib.Time

Raises:

ValueError – If scale is not one tabascal can interpret.

tabascal.time.timescale()[source]

The skyfield timescale, built once and reused.

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, and sgp4jax.itrf_to_gcrf has 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 a scale argument threaded through any of them.

utc returns the input unchanged – bit-identical, not merely close, since no arithmetic is done at all. The other scales have utc_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 scale is 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.

utc returns the input unchanged – bit-identical, not merely close, since no arithmetic is done at all. The other scales have utc_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 scale is not one tabascal can interpret.

tabascal.time.utc_offset_days(times_jd, scale: str = 'utc')[source]

Days to add to a Julian Date on scale to 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