Coverage for python/lsst/daf/butler/core/time_utils.py: 37%
71 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-01 19:55 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-01 19:55 +0000
1# This file is part of daf_butler.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
21from __future__ import annotations
23__all__ = ("TimeConverter",)
25import logging
26from typing import Any, ClassVar
27import warnings
29import astropy.time
30import astropy.utils.exceptions
31import yaml
33# As of astropy 4.2, the erfa interface is shipped independently and
34# ErfaWarning is no longer an AstropyWarning
35try:
36 import erfa
37except ImportError:
38 erfa = None
40from .utils import Singleton
42_LOG = logging.getLogger(__name__)
45class TimeConverter(metaclass=Singleton):
46 """A singleton for mapping TAI times to integer nanoseconds.
48 This class allows some time calculations to be deferred until first use,
49 rather than forcing them to happen at module import time.
50 """
52 def __init__(self) -> None:
53 # EPOCH is used to convert from nanoseconds; its precision is used by
54 # all timestamps returned by nsec_to_astropy, and we set it to 1
55 # microsecond.
56 self.epoch = astropy.time.Time("1970-01-01 00:00:00", format="iso", scale="tai", precision=6)
57 self.max_time = astropy.time.Time("2100-01-01 00:00:00", format="iso", scale="tai")
58 self.min_nsec = 0
59 self.max_nsec = self.astropy_to_nsec(self.max_time)
61 def astropy_to_nsec(self, astropy_time: astropy.time.Time) -> int:
62 """Convert astropy time to nanoseconds since epoch.
64 Input time is converted to TAI scale before conversion to
65 nanoseconds.
67 Parameters
68 ----------
69 astropy_time : `astropy.time.Time`
70 Time to be converted.
72 Returns
73 -------
74 time_nsec : `int`
75 Nanoseconds since epoch.
77 Note
78 ----
79 Only the limited range of input times is supported by this method as it
80 is defined useful in the context of Butler and Registry. If input time
81 is earlier `min_time` then this method returns `min_nsec`. If input
82 time comes after `max_time` then it returns `max_nsec`.
83 """
84 # sometimes comparison produces warnings if input value is in UTC
85 # scale, transform it to TAI before doing anything but also trap
86 # warnings in case we are dealing with simulated data from the future
87 with warnings.catch_warnings():
88 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
89 if erfa is not None:
90 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
91 value = astropy_time.tai
92 # anything before epoch or after max_time is truncated
93 if value < self.epoch:
94 _LOG.warning("'%s' is earlier than epoch time '%s', epoch time will be used instead",
95 astropy_time, self.epoch)
96 value = self.epoch
97 elif value > self.max_time:
98 _LOG.warning("'%s' is later than max. time '%s', max. time time will be used instead",
99 value, self.max_time)
100 value = self.max_time
102 delta = value - self.epoch
103 # Special care needed to preserve nanosecond precision.
104 # Usually jd1 has no fractional part but just in case.
105 jd1, extra_jd2 = divmod(delta.jd1, 1)
106 value = int(jd1) * self._NSEC_PER_DAY + int(round((delta.jd2 + extra_jd2)*self._NSEC_PER_DAY))
107 return value
109 def nsec_to_astropy(self, time_nsec: int) -> astropy.time.Time:
110 """Convert nanoseconds since epoch to astropy time.
112 Parameters
113 ----------
114 time_nsec : `int`
115 Nanoseconds since epoch.
117 Returns
118 -------
119 astropy_time : `astropy.time.Time`
120 Time to be converted.
122 Note
123 ----
124 Usually the input time for this method is the number returned from
125 `astropy_to_nsec` which has a limited range. This method does not check
126 that the number falls in the supported range and can produce output
127 time that is outside of that range.
128 """
129 jd1, jd2 = divmod(time_nsec, self._NSEC_PER_DAY)
130 delta = astropy.time.TimeDelta(float(jd1), float(jd2)/self._NSEC_PER_DAY, format="jd", scale="tai")
131 value = self.epoch + delta
132 return value
134 def times_equal(self, time1: astropy.time.Time,
135 time2: astropy.time.Time,
136 precision_nsec: float = 1.0) -> bool:
137 """Check that times are equal within specified precision.
139 Parameters
140 ----------
141 time1, time2 : `astropy.time.Time`
142 Times to compare.
143 precision_nsec : `float`, optional
144 Precision to use for comparison in nanoseconds, default is one
145 nanosecond which is larger that round-trip error for conversion
146 to/from integer nanoseconds.
147 """
148 # To compare we need them in common scale, for simplicity just
149 # bring them both to TAI scale
150 # Hide any warnings from this conversion since they are not relevant
151 # to the equality
152 with warnings.catch_warnings():
153 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
154 if erfa is not None:
155 warnings.simplefilter("ignore", category=erfa.ErfaWarning)
156 time1 = time1.tai
157 time2 = time2.tai
158 delta = (time2.jd1 - time1.jd1) + (time2.jd2 - time1.jd2)
159 delta *= self._NSEC_PER_DAY
160 return abs(delta) < precision_nsec
162 # number of nanoseconds in a day
163 _NSEC_PER_DAY: ClassVar[int] = 1_000_000_000 * 24 * 3600
165 epoch: astropy.time.Time
166 """Epoch for calculating time delta, this is the minimum time that can be
167 stored in the database.
168 """
170 max_time: astropy.time.Time
171 """Maximum time value that the converter can handle (`astropy.time.Time`).
173 Assuming 64-bit integer field we can actually store higher values but we
174 intentionally limit it to arbitrary but reasonably high value. Note that
175 this value will be stored in registry database for eternity, so it should
176 not be changed without proper consideration.
177 """
179 min_nsec: int
180 """Minimum value returned by `astropy_to_nsec`, corresponding to
181 `epoch` (`int`).
182 """
184 max_nsec: int
185 """Maximum value returned by `astropy_to_nsec`, corresponding to
186 `max_time` (`int`).
187 """
190class _AstropyTimeToYAML:
191 """Handle conversion of astropy Time to/from YAML representation.
193 This class defines methods that convert astropy Time instances to or from
194 YAML representation. On output it converts time to string ISO format in
195 TAI scale with maximum precision defining special YAML tag for it. On
196 input it does inverse transformation. The methods need to be registered
197 with YAML dumper and loader classes.
199 Notes
200 -----
201 Python ``yaml`` module defines special helper base class ``YAMLObject``
202 that provides similar functionality but its use is complicated by the need
203 to convert ``Time`` instances to instances of ``YAMLObject`` sub-class
204 before saving them to YAML. This class avoids this intermediate step but
205 it requires separate regisration step.
206 """
208 yaml_tag = "!butler_time/tai/iso" # YAML tag name for Time class
210 @classmethod
211 def to_yaml(cls, dumper: yaml.Dumper, data: astropy.time.Time) -> Any:
212 """Convert astropy Time object into YAML format.
214 Parameters
215 ----------
216 dumper : `yaml.Dumper`
217 YAML dumper instance.
218 data : `astropy.time.Time`
219 Data to be converted.
220 """
221 if data is not None:
222 # we store time in ISO format but we need full nanosecond
223 # precision so we have to construct intermediate instance to make
224 # sure its precision is set correctly.
225 data = astropy.time.Time(data.tai, precision=9)
226 data = data.to_value("iso")
227 return dumper.represent_scalar(cls.yaml_tag, data)
229 @classmethod
230 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.ScalarNode) -> astropy.time.Time:
231 """Convert YAML node into astropy time.
233 Parameters
234 ----------
235 loader : `yaml.SafeLoader`
236 Instance of YAML loader class.
237 node : `yaml.ScalarNode`
238 YAML node.
240 Returns
241 -------
242 time : `astropy.time.Time`
243 Time instance, can be ``None``.
244 """
245 if node.value is not None:
246 return astropy.time.Time(node.value, format="iso", scale="tai")
249# Register Time -> YAML conversion method with Dumper class
250yaml.Dumper.add_representer(astropy.time.Time, _AstropyTimeToYAML.to_yaml)
252# Register YAML -> Time conversion method with Loader, for our use case we
253# only need SafeLoader.
254yaml.SafeLoader.add_constructor(_AstropyTimeToYAML.yaml_tag, _AstropyTimeToYAML.from_yaml)