Skip to content

Commit

Permalink
Minor refactoring for time-related methods.
Browse files Browse the repository at this point in the history
Move time conversion methods from `ddl` to new module `time_utils`, add
time comparison with precision. Should make it more reusable.
  • Loading branch information
andy-slac committed Apr 9, 2020
1 parent c693751 commit 9bd8c7f
Show file tree
Hide file tree
Showing 6 changed files with 282 additions and 61 deletions.
1 change: 1 addition & 0 deletions python/lsst/daf/butler/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@
from .storedFileInfo import *
from .dimensions import *
from .repoTransfers import *
from . import time_utils
from .timespan import *
41 changes: 3 additions & 38 deletions python/lsst/daf/butler/core/ddl.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,12 @@
from lsst.sphgeom import ConvexPolygon
from .config import Config
from .exceptions import ValidationError
from . import time_utils
from .utils import iterable, stripIfNotNone, NamedValueSet


_LOG = logging.getLogger(__name__)

# These constants can be used by client code
EPOCH = astropy.time.Time("1970-01-01 00:00:00", format="iso", scale="tai")
"""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.
"""


class SchemaValidationError(ValidationError):
"""Exceptions used to indicate problems in Registry schema configuration.
Expand Down Expand Up @@ -158,36 +145,14 @@ def process_bind_param(self, value, dialect):
return None
if not isinstance(value, astropy.time.Time):
raise TypeError(f"Unsupported type: {type(value)}, expected astropy.time.Time")
# sometimes comparison produces warnings if input value is in UTC
# scale, transform it to TAI before doing anyhting
value = value.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",
value, 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)
nsec_per_day = 1_000_000_000 * 24 * 3600
value = int(jd1) * nsec_per_day + int(round((delta.jd2 + extra_jd2)*nsec_per_day))
value = time_utils.astropy_to_nsec(value)
return value

def process_result_value(self, value, dialect):
# value is nanoseconds since epoch, or None
if value is None:
return None
# Again special care needed to preserve precision
nsec_per_day = 1_000_000_000 * 24 * 3600
jd1, jd2 = divmod(value, nsec_per_day)
delta = astropy.time.TimeDelta(float(jd1), float(jd2)/nsec_per_day, format="jd", scale="tai")
value = EPOCH + delta
value = time_utils.nsec_to_astropy(value)
return value


Expand Down
140 changes: 140 additions & 0 deletions python/lsst/daf/butler/core/time_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# This file is part of daf_butler.
#
# Developed for the LSST Data Management System.
# This product includes software developed by the LSST Project
# (http://www.lsst.org).
# See the COPYRIGHT file at the top-level directory of this distribution
# for details of code ownership.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# 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")

import logging

import astropy.time


# These constants can be used by client code
EPOCH = astropy.time.Time("1970-01-01 00:00:00", format="iso", scale="tai")
"""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

_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
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.
"""
# sometimes comparison produces warnings if input value is in UTC
# scale, transform it to TAI before doing anyhting
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


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.
"""
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.
"""
# To compare we need them in common scale, for simplicity just
# bring them both to TAI scale
time1 = time1.tai
time2 = time2.tai
delta = (time2.jd1 - time1.jd1) + (time2.jd2 - time1.jd2)
delta *= _NSEC_PER_DAY
return abs(delta) < precision_nsec
6 changes: 3 additions & 3 deletions python/lsst/daf/butler/core/timespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@
import operator
from typing import Generic, Optional, TypeVar

from . import ddl
from . import ddl, time_utils


TIMESPAN_MIN = ddl.EPOCH
TIMESPAN_MAX = ddl.MAX_TIME
TIMESPAN_MIN = time_utils.EPOCH
TIMESPAN_MAX = time_utils.MAX_TIME

T = TypeVar("T")

Expand Down
30 changes: 10 additions & 20 deletions tests/test_ddl.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@

import unittest

from astropy.time import Time, TimeDelta
from lsst.daf.butler import ddl
from astropy.time import Time
from lsst.daf.butler import ddl, time_utils


class AstropyTimeNsecTaiTestCase(unittest.TestCase):
Expand Down Expand Up @@ -54,7 +54,7 @@ def test_time_before_epoch(self):
self.assertEqual(value, 0)

value = self.decor.process_result_value(value, self.dialect)
self.assertEqual(value, ddl.EPOCH)
self.assertEqual(value, time_utils.EPOCH)

def test_max_time(self):
"""Tests for converting None in bound parameters.
Expand All @@ -63,7 +63,7 @@ def test_max_time(self):
time = Time("2101-01-01T00:00:00", format="isot", scale="tai")
value = self.decor.process_bind_param(time, self.dialect)

value_max = self.decor.process_bind_param(ddl.MAX_TIME, self.dialect)
value_max = self.decor.process_bind_param(time_utils.MAX_TIME, self.dialect)
self.assertEqual(value, value_max)

def test_round_trip(self):
Expand All @@ -72,27 +72,17 @@ def test_round_trip(self):
# do tests at random points between epoch and max. time
times = [
"1970-01-01T12:00:00.123",
"2000-01-01T12:00:00.123456",
"2030-01-01T12:00:00.123456",
"1999-12-31T23:59:59.999999999",
"2000-01-01T12:00:00.000000001",
"2030-01-01T12:00:00.123456789",
"2075-08-17T00:03:45",
"2099-12-31T23:00:50",
]
for time in times:
atime = Time(time, format="isot", scale="tai")
for sec in range(7):
# loop over few seconds to add to each time
for i in range(100):
# loop over additional fractions of seconds
delta = sec + 0.3e-9 * i
in_time = atime + TimeDelta(delta, format="sec")
# do round-trip conversion to nsec and back
value = self.decor.process_bind_param(in_time, self.dialect)
value = self.decor.process_result_value(value, self.dialect)
delta2 = value - in_time
delta2_sec = delta2.to_value("sec")
# absolute precision should be better than half
# nanosecond, but there are rounding errors too
self.assertLess(abs(delta2_sec), 0.51e-9)
value = self.decor.process_bind_param(atime, self.dialect)
value = self.decor.process_result_value(value, self.dialect)
self.assertTrue(time_utils.times_equal(atime, value))


if __name__ == "__main__":
Expand Down

0 comments on commit 9bd8c7f

Please sign in to comment.