Coverage for python/lsst/daf/butler/time_utils.py: 40%

74 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-16 02:58 -0700

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 

28 

29__all__ = ("TimeConverter",) 

30 

31import logging 

32import warnings 

33from typing import Any, ClassVar 

34 

35import astropy.time 

36import astropy.utils.exceptions 

37import yaml 

38 

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 

45 

46from lsst.utils.classes import Singleton 

47 

48_LOG = logging.getLogger(__name__) 

49 

50 

51class TimeConverter(metaclass=Singleton): 

52 """A singleton for mapping TAI times to integer nanoseconds. 

53 

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 """ 

57 

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) 

66 

67 def astropy_to_nsec(self, astropy_time: astropy.time.Time) -> int: 

68 """Convert astropy time to nanoseconds since epoch. 

69 

70 Input time is converted to TAI scale before conversion to 

71 nanoseconds. 

72 

73 Parameters 

74 ---------- 

75 astropy_time : `astropy.time.Time` 

76 Time to be converted. 

77 

78 Returns 

79 ------- 

80 time_nsec : `int` 

81 Nanoseconds since epoch. 

82 

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 

111 

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 

118 

119 def nsec_to_astropy(self, time_nsec: int) -> astropy.time.Time: 

120 """Convert nanoseconds since epoch to astropy time. 

121 

122 Parameters 

123 ---------- 

124 time_nsec : `int` 

125 Nanoseconds since epoch. 

126 

127 Returns 

128 ------- 

129 astropy_time : `astropy.time.Time` 

130 Time to be converted. 

131 

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 

143 

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. 

148 

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 

171 

172 # number of nanoseconds in a day 

173 _NSEC_PER_DAY: ClassVar[int] = 1_000_000_000 * 24 * 3600 

174 

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 """ 

179 

180 max_time: astropy.time.Time 

181 """Maximum time value that the converter can handle (`astropy.time.Time`). 

182 

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 """ 

188 

189 min_nsec: int 

190 """Minimum value returned by `astropy_to_nsec`, corresponding to 

191 `epoch` (`int`). 

192 """ 

193 

194 max_nsec: int 

195 """Maximum value returned by `astropy_to_nsec`, corresponding to 

196 `max_time` (`int`). 

197 """ 

198 

199 

200class _AstropyTimeToYAML: 

201 """Handle conversion of astropy Time to/from YAML representation. 

202 

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. 

208 

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 """ 

217 

218 yaml_tag = "!butler_time/tai/iso" # YAML tag name for Time class 

219 

220 @classmethod 

221 def to_yaml(cls, dumper: yaml.Dumper, data: astropy.time.Time) -> Any: 

222 """Convert astropy Time object into YAML format. 

223 

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) 

238 

239 @classmethod 

240 def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.ScalarNode) -> astropy.time.Time: 

241 """Convert YAML node into astropy time. 

242 

243 Parameters 

244 ---------- 

245 loader : `yaml.SafeLoader` 

246 Instance of YAML loader class. 

247 node : `yaml.ScalarNode` 

248 YAML node. 

249 

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") 

257 

258 

259# Register Time -> YAML conversion method with Dumper class 

260yaml.Dumper.add_representer(astropy.time.Time, _AstropyTimeToYAML.to_yaml) 

261 

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)