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