Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 

26from typing import Any, ClassVar 

27import warnings 

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 .utils import Singleton 

41 

42_LOG = logging.getLogger(__name__) 

43 

44 

45class TimeConverter(metaclass=Singleton): 

46 """A singleton that provides methods for mapping TAI times to integer 

47 nanoseconds. 

48 

49 This class allows some time calculations to be deferred until first use, 

50 rather than forcing them to happen at module import time. 

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("'%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 

101 

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 

108 

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

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

111 

112 Parameters 

113 ---------- 

114 time_nsec : `int` 

115 Nanoseconds since epoch. 

116 

117 Returns 

118 ------- 

119 astropy_time : `astropy.time.Time` 

120 Time to be converted. 

121 

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 

133 

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. 

138 

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 

161 

162 # number of nanoseconds in a day 

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

164 

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

169 

170 max_time: astropy.time.Time 

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

172 

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

178 

179 min_nsec: int 

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

181 `epoch` (`int`). 

182 """ 

183 

184 max_nsec: int 

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

186 `max_time` (`int`). 

187 """ 

188 

189 

190class _AstropyTimeToYAML: 

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

192 

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. 

198 

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

207 

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

209 

210 @classmethod 

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

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

213 

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) 

228 

229 @classmethod 

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

231 """Convert YAML node into astropy time 

232 

233 Parameters 

234 ---------- 

235 loader : `yaml.SafeLoader` 

236 Instance of YAML loader class. 

237 node : `yaml.ScalarNode` 

238 YAML node. 

239 

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

247 

248 

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

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

251 

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)