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

74 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-06-23 09:44 +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 

22 

23__all__ = ("TimeConverter",) 

24 

25import logging 

26import warnings 

27from typing import Any, ClassVar 

28 

29import astropy.time 

30import astropy.utils.exceptions 

31import yaml 

32 

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 

39 

40from lsst.utils.classes import Singleton 

41 

42_LOG = logging.getLogger(__name__) 

43 

44 

45class TimeConverter(metaclass=Singleton): 

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

47 

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

51 

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) 

60 

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

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

63 

64 Input time is converted to TAI scale before conversion to 

65 nanoseconds. 

66 

67 Parameters 

68 ---------- 

69 astropy_time : `astropy.time.Time` 

70 Time to be converted. 

71 

72 Returns 

73 ------- 

74 time_nsec : `int` 

75 Nanoseconds since epoch. 

76 

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( 

95 "'%s' is earlier than epoch time '%s', epoch time will be used instead", 

96 astropy_time, 

97 self.epoch, 

98 ) 

99 value = self.epoch 

100 elif value > self.max_time: 

101 _LOG.warning( 

102 "'%s' is later than max. time '%s', max. time time will be used instead", value, self.max_time 

103 ) 

104 value = self.max_time 

105 

106 delta = value - self.epoch 

107 # Special care needed to preserve nanosecond precision. 

108 # Usually jd1 has no fractional part but just in case. 

109 jd1, extra_jd2 = divmod(delta.jd1, 1) 

110 value = int(jd1) * self._NSEC_PER_DAY + int(round((delta.jd2 + extra_jd2) * self._NSEC_PER_DAY)) 

111 return value 

112 

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

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

115 

116 Parameters 

117 ---------- 

118 time_nsec : `int` 

119 Nanoseconds since epoch. 

120 

121 Returns 

122 ------- 

123 astropy_time : `astropy.time.Time` 

124 Time to be converted. 

125 

126 Note 

127 ---- 

128 Usually the input time for this method is the number returned from 

129 `astropy_to_nsec` which has a limited range. This method does not check 

130 that the number falls in the supported range and can produce output 

131 time that is outside of that range. 

132 """ 

133 jd1, jd2 = divmod(time_nsec, self._NSEC_PER_DAY) 

134 delta = astropy.time.TimeDelta(float(jd1), float(jd2) / self._NSEC_PER_DAY, format="jd", scale="tai") 

135 value = self.epoch + delta 

136 return value 

137 

138 def times_equal( 

139 self, time1: astropy.time.Time, time2: astropy.time.Time, precision_nsec: float = 1.0 

140 ) -> bool: 

141 """Check that times are equal within specified precision. 

142 

143 Parameters 

144 ---------- 

145 time1, time2 : `astropy.time.Time` 

146 Times to compare. 

147 precision_nsec : `float`, optional 

148 Precision to use for comparison in nanoseconds, default is one 

149 nanosecond which is larger that round-trip error for conversion 

150 to/from integer nanoseconds. 

151 """ 

152 # To compare we need them in common scale, for simplicity just 

153 # bring them both to TAI scale 

154 # Hide any warnings from this conversion since they are not relevant 

155 # to the equality 

156 with warnings.catch_warnings(): 

157 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning) 

158 if erfa is not None: 

159 warnings.simplefilter("ignore", category=erfa.ErfaWarning) 

160 time1 = time1.tai 

161 time2 = time2.tai 

162 delta = (time2.jd1 - time1.jd1) + (time2.jd2 - time1.jd2) 

163 delta *= self._NSEC_PER_DAY 

164 return abs(delta) < precision_nsec 

165 

166 # number of nanoseconds in a day 

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

168 

169 epoch: astropy.time.Time 

170 """Epoch for calculating time delta, this is the minimum time that can be 

171 stored in the database. 

172 """ 

173 

174 max_time: astropy.time.Time 

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

176 

177 Assuming 64-bit integer field we can actually store higher values but we 

178 intentionally limit it to arbitrary but reasonably high value. Note that 

179 this value will be stored in registry database for eternity, so it should 

180 not be changed without proper consideration. 

181 """ 

182 

183 min_nsec: int 

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

185 `epoch` (`int`). 

186 """ 

187 

188 max_nsec: int 

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

190 `max_time` (`int`). 

191 """ 

192 

193 

194class _AstropyTimeToYAML: 

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

196 

197 This class defines methods that convert astropy Time instances to or from 

198 YAML representation. On output it converts time to string ISO format in 

199 TAI scale with maximum precision defining special YAML tag for it. On 

200 input it does inverse transformation. The methods need to be registered 

201 with YAML dumper and loader classes. 

202 

203 Notes 

204 ----- 

205 Python ``yaml`` module defines special helper base class ``YAMLObject`` 

206 that provides similar functionality but its use is complicated by the need 

207 to convert ``Time`` instances to instances of ``YAMLObject`` sub-class 

208 before saving them to YAML. This class avoids this intermediate step but 

209 it requires separate regisration step. 

210 """ 

211 

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

213 

214 @classmethod 

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

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

217 

218 Parameters 

219 ---------- 

220 dumper : `yaml.Dumper` 

221 YAML dumper instance. 

222 data : `astropy.time.Time` 

223 Data to be converted. 

224 """ 

225 if data is not None: 

226 # we store time in ISO format but we need full nanosecond 

227 # precision so we have to construct intermediate instance to make 

228 # sure its precision is set correctly. 

229 data = astropy.time.Time(data.tai, precision=9) 

230 data = data.to_value("iso") 

231 return dumper.represent_scalar(cls.yaml_tag, data) 

232 

233 @classmethod 

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

235 """Convert YAML node into astropy time. 

236 

237 Parameters 

238 ---------- 

239 loader : `yaml.SafeLoader` 

240 Instance of YAML loader class. 

241 node : `yaml.ScalarNode` 

242 YAML node. 

243 

244 Returns 

245 ------- 

246 time : `astropy.time.Time` 

247 Time instance, can be ``None``. 

248 """ 

249 if node.value is not None: 

250 return astropy.time.Time(node.value, format="iso", scale="tai") 

251 

252 

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

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

255 

256# Register YAML -> Time conversion method with Loader, for our use case we 

257# only need SafeLoader. 

258yaml.SafeLoader.add_constructor(_AstropyTimeToYAML.yaml_tag, _AstropyTimeToYAML.from_yaml)