Skip to content

Commit

Permalink
Move time-nsec conversion into a singleton class.
Browse files Browse the repository at this point in the history
This defers all operations until first use (instead of
import-time) and adds a bit of encapsulation.
  • Loading branch information
TallJimbo committed Dec 16, 2020
1 parent d218556 commit a26ee5c
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 197 deletions.
4 changes: 2 additions & 2 deletions python/lsst/daf/butler/core/ddl.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,15 @@ def process_bind_param(self, value: Optional[astropy.time.Time], dialect: sqlalc
return None
if not isinstance(value, astropy.time.Time):
raise TypeError(f"Unsupported type: {type(value)}, expected astropy.time.Time")
value = time_utils.astropy_to_nsec(value)
value = time_utils.TimeConverter().astropy_to_nsec(value)
return value

def process_result_value(self, value: Optional[int], dialect: sqlalchemy.engine.Dialect
) -> Optional[astropy.time.Time]:
# value is nanoseconds since epoch, or None
if value is None:
return None
value = time_utils.nsec_to_astropy(value)
value = time_utils.TimeConverter().nsec_to_astropy(value)
return value


Expand Down
263 changes: 141 additions & 122 deletions python/lsst/daf/butler/core/time_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations

__all__ = ("astropy_to_nsec", "nsec_to_astropy", "times_equal")
__all__ = ("TimeConverter",)

import logging
from typing import Any
from typing import Any, ClassVar
import warnings

import astropy.time
Expand All @@ -37,135 +37,154 @@
except ImportError:
erfa = None

# These constants can be used by client code.
# EPOCH is used to construct times as read from database, its precision is
# used by all those timestamps, set it to 1 microsecond.
EPOCH = astropy.time.Time("1970-01-01 00:00:00", format="iso", scale="tai", precision=6)
"""Epoch for calculating time delta, this is the minimum time that can be
stored in the database.
"""

MAX_TIME = astropy.time.Time("2100-01-01 00:00:00", format="iso", scale="tai")
"""Maximum time value that we can store. Assuming 64-bit integer field we
can actually store higher values but we intentionally limit it to arbitrary
but reasonably high value. Note that this value will be stored in registry
database for eternity, so it should not be changed without proper
consideration.
"""

# number of nanosecons in a day
_NSEC_PER_DAY = 1_000_000_000 * 24 * 3600
from .utils import Singleton

_LOG = logging.getLogger(__name__)


def astropy_to_nsec(astropy_time: astropy.time.Time) -> int:
"""Convert astropy time to nanoseconds since epoch.
Input time is converted to TAI scale before conversion to
class TimeConverter(metaclass=Singleton):
"""A singleton that provides methods for mapping TAI times to integer
nanoseconds.
Parameters
----------
astropy_time : `astropy.time.Time`
Time to be converted.
Returns
-------
time_nsec : `int`
Nanoseconds since epoch.
Note
----
Only the limited range of input times is supported by this method as it
is defined useful in the context of Butler and Registry. If input time is
earlier than epoch time then this method returns 0. If input time comes
after the max. time then it returns number corresponding to max. time.
This class allows some time calculations to be deferred until first use,
rather than forcing them to happen at module import time.
"""
def __init__(self) -> None:
# EPOCH is used to convert from nanoseconds; its precision is used by
# all timestamps returned by nsec_to_astropy, and we set it to 1
# microsecond.
self.epoch = astropy.time.Time("1970-01-01 00:00:00", format="iso", scale="tai", precision=6)
self.max_time = astropy.time.Time("2100-01-01 00:00:00", format="iso", scale="tai")
self.min_nsec = 0
self.max_nsec = self.astropy_to_nsec(self.max_time)

def astropy_to_nsec(self, astropy_time: astropy.time.Time) -> int:
"""Convert astropy time to nanoseconds since epoch.
Input time is converted to TAI scale before conversion to
nanoseconds.
Parameters
----------
astropy_time : `astropy.time.Time`
Time to be converted.
Returns
-------
time_nsec : `int`
Nanoseconds since epoch.
Note
----
Only the limited range of input times is supported by this method as it
is defined useful in the context of Butler and Registry. If input time
is earlier `min_time` then this method returns `min_nsec`. If input
time comes after `max_time` then it returns `max_nsec`.
"""
# sometimes comparison produces warnings if input value is in UTC
# scale, transform it to TAI before doing anything but also trap
# warnings in case we are dealing with simulated data from the future
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
if erfa is not None:
warnings.simplefilter("ignore", category=erfa.ErfaWarning)
value = astropy_time.tai
# anything before epoch or after max_time is truncated
if value < self.epoch:
_LOG.warning("'%s' is earlier than epoch time '%s', epoch time will be used instead",
astropy_time, self.epoch)
value = self.epoch
elif value > self.max_time:
_LOG.warning("'%s' is later than max. time '%s', max. time time will be used instead",
value, self.max_time)
value = self.max_time

delta = value - self.epoch
# Special care needed to preserve nanosecond precision.
# Usually jd1 has no fractional part but just in case.
jd1, extra_jd2 = divmod(delta.jd1, 1)
value = int(jd1) * self._NSEC_PER_DAY + int(round((delta.jd2 + extra_jd2)*self._NSEC_PER_DAY))
return value

def nsec_to_astropy(self, time_nsec: int) -> astropy.time.Time:
"""Convert nanoseconds since epoch to astropy time.
Parameters
----------
time_nsec : `int`
Nanoseconds since epoch.
Returns
-------
astropy_time : `astropy.time.Time`
Time to be converted.
Note
----
Usually the input time for this method is the number returned from
`astropy_to_nsec` which has a limited range. This method does not check
that the number falls in the supported range and can produce output
time that is outside of that range.
"""
jd1, jd2 = divmod(time_nsec, self._NSEC_PER_DAY)
delta = astropy.time.TimeDelta(float(jd1), float(jd2)/self._NSEC_PER_DAY, format="jd", scale="tai")
value = self.epoch + delta
return value

def times_equal(self, time1: astropy.time.Time,
time2: astropy.time.Time,
precision_nsec: float = 1.0) -> bool:
"""Check that times are equal within specified precision.
Parameters
----------
time1, time2 : `astropy.time.Time`
Times to compare.
precision_nsec : `float`, optional
Precision to use for comparison in nanoseconds, default is one
nanosecond which is larger that round-trip error for conversion
to/from integer nanoseconds.
"""
# To compare we need them in common scale, for simplicity just
# bring them both to TAI scale
# Hide any warnings from this conversion since they are not relevant
# to the equality
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
if erfa is not None:
warnings.simplefilter("ignore", category=erfa.ErfaWarning)
time1 = time1.tai
time2 = time2.tai
delta = (time2.jd1 - time1.jd1) + (time2.jd2 - time1.jd2)
delta *= self._NSEC_PER_DAY
return abs(delta) < precision_nsec

# number of nanoseconds in a day
_NSEC_PER_DAY: ClassVar[int] = 1_000_000_000 * 24 * 3600

epoch: astropy.time.Time
"""Epoch for calculating time delta, this is the minimum time that can be
stored in the database.
"""

max_time: astropy.time.Time
"""Maximum time value that the converter can handle (`astropy.time.Time`).
Assuming 64-bit integer field we can actually store higher values but we
intentionally limit it to arbitrary but reasonably high value. Note that
this value will be stored in registry database for eternity, so it should
not be changed without proper consideration.
"""
# sometimes comparison produces warnings if input value is in UTC
# scale, transform it to TAI before doing anything but also trap
# warnings in case we are dealing with simulated data from the future
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
if erfa is not None:
warnings.simplefilter("ignore", category=erfa.ErfaWarning)
value = astropy_time.tai
# anything before epoch or after MAX_TIME is truncated
if value < EPOCH:
_LOG.warning("'%s' is earlier than epoch time '%s', epoch time will be used instead",
astropy_time, EPOCH)
value = EPOCH
elif value > MAX_TIME:
_LOG.warning("'%s' is later than max. time '%s', max. time time will be used instead",
value, MAX_TIME)
value = MAX_TIME

delta = value - EPOCH
# Special care needed to preserve nanosecond precision.
# Usually jd1 has no fractional part but just in case.
jd1, extra_jd2 = divmod(delta.jd1, 1)
value = int(jd1) * _NSEC_PER_DAY + int(round((delta.jd2 + extra_jd2)*_NSEC_PER_DAY))
return value


# Two more constants: the integer nanoseconds equivalents to EPOCH and MAX_TIME
MIN_NSEC = 0
MAX_NSEC = astropy_to_nsec(MAX_TIME)


def nsec_to_astropy(time_nsec: int) -> astropy.time.Time:
"""Convert nanoseconds since epoch to astropy time.
Parameters
----------
time_nsec : `int`
Nanoseconds since epoch.
Returns
-------
astropy_time : `astropy.time.Time`
Time to be converted.
Note
----
Usually the input time for this method is the number returned from
`astropy_to_nsec` which has a limited range. This method does not check
that the number falls in the supported range and can produce output
time that is outside of that range.

min_nsec: int
"""Minimum value returned by `astropy_to_nsec`, corresponding to
`epoch` (`int`).
"""
jd1, jd2 = divmod(time_nsec, _NSEC_PER_DAY)
delta = astropy.time.TimeDelta(float(jd1), float(jd2)/_NSEC_PER_DAY, format="jd", scale="tai")
value = EPOCH + delta
return value


def times_equal(time1: astropy.time.Time,
time2: astropy.time.Time,
precision_nsec: float = 1.0) -> bool:
"""Check that times are equal within specified precision.
Parameters
----------
time1, time2 : `astropy.time.Time`
Times to compare.
precision_nsec : `float`, optional
Precision to use for comparison in nanoseconds, default is one
nanosecond which is larger that round-trip error for conversion
to/from integer nanoseconds.

max_nsec: int
"""Maximum value returned by `astropy_to_nsec`, corresponding to
`max_time` (`int`).
"""
# To compare we need them in common scale, for simplicity just
# bring them both to TAI scale
# Hide any warnings from this conversion since they are not relevant
# to the equality
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
if erfa is not None:
warnings.simplefilter("ignore", category=erfa.ErfaWarning)
time1 = time1.tai
time2 = time2.tai
delta = (time2.jd1 - time1.jd1) + (time2.jd2 - time1.jd2)
delta *= _NSEC_PER_DAY
return abs(delta) < precision_nsec


class _AstropyTimeToYAML:
Expand Down

0 comments on commit a26ee5c

Please sign in to comment.