Coverage for python/lsst/obs/lsst/translators/ts8.py: 38%

84 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2023-05-23 04:18 -0700

1# This file is currently part of obs_lsst but is written to allow it 

2# to be migrated to the astro_metadata_translator package at a later date. 

3# 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the LICENSE file in this directory for details of code ownership. 

7# 

8# Use of this source code is governed by a 3-clause BSD-style 

9# license that can be found in the LICENSE file. 

10 

11"""Metadata translation code for LSST TestStand 8 headers""" 

12 

13__all__ = ("LsstTS8Translator", ) 

14 

15import logging 

16import re 

17 

18import astropy.units as u 

19from astropy.time import Time, TimeDelta 

20 

21from astro_metadata_translator import cache_translation 

22 

23from .lsst import LsstBaseTranslator, compute_detector_exposure_id_generic 

24 

25log = logging.getLogger(__name__) 

26 

27 

28class LsstTS8Translator(LsstBaseTranslator): 

29 """Metadata translator for LSST Test Stand 8 data. 

30 """ 

31 

32 name = "LSST-TS8" 

33 """Name of this translation class""" 

34 

35 _const_map = { 

36 # TS8 is not attached to a telescope so many translations are null. 

37 "instrument": "LSST-TS8", 

38 "telescope": None, 

39 "location": None, 

40 "boresight_rotation_coord": None, 

41 "boresight_rotation_angle": None, 

42 "boresight_airmass": None, 

43 "tracking_radec": None, 

44 "altaz_begin": None, 

45 "object": "UNKNOWN", 

46 "relative_humidity": None, 

47 "temperature": None, 

48 "pressure": None, 

49 } 

50 

51 _trivial_map = { 

52 "science_program": "RUNNUM", 

53 "exposure_time": ("EXPTIME", dict(unit=u.s)), 

54 } 

55 

56 cameraPolicyFile = "policy/ts8.yaml" 

57 

58 _ROLLOVER_TIME = TimeDelta(8*60*60, scale="tai", format="sec") 

59 """Time delta for the definition of a Rubin Test Stand start of day.""" 

60 

61 @classmethod 

62 def can_translate(cls, header, filename=None): 

63 """Indicate whether this translation class can translate the 

64 supplied header. 

65 

66 There is no ``INSTRUME`` header in TS8 data. Instead we use 

67 the ``TSTAND`` header. We also look at the file name to see if 

68 it starts with "ts8-". 

69 

70 Older data has no ``TSTAND`` header so we must use a combination 

71 of headers. 

72 

73 Parameters 

74 ---------- 

75 header : `dict`-like 

76 Header to convert to standardized form. 

77 filename : `str`, optional 

78 Name of file being translated. 

79 

80 Returns 

81 ------- 

82 can : `bool` 

83 `True` if the header is recognized by this class. `False` 

84 otherwise. 

85 """ 

86 can = cls.can_translate_with_options(header, {"TSTAND": "TS8"}, filename=filename) 

87 if can: 

88 return True 

89 

90 if "LSST_NUM" in header and "REBNAME" in header and \ 

91 "CONTNUM" in header and \ 

92 header["CONTNUM"] in ("000018910e0c", "000018ee33b7", "000018ee0f35", "000018ee3b40", 

93 "00001891fcc7", "000018edfd65", "0000123b5ba8", "000018911b05", 

94 "00001891fa3e", "000018910d7f", "000018ed9f12", "000018edf4a7", 

95 "000018ee34e6", "000018ef1464", "000018eda120", "000018edf8a2", 

96 "000018ef3819", "000018ed9486", "000018ee02c8", "000018edfb24", 

97 "000018ee34c0", "000018edfb51", "0000123b51d1", "0000123b5862", 

98 "0000123b8ca9", "0000189208fa", "0000189111af", "0000189126e1", 

99 "000018ee0618", "000018ee3b78", "000018ef1534"): 

100 return True 

101 

102 return False 

103 

104 @staticmethod 

105 def compute_exposure_id(dateobs, seqnum=0, controller=None): 

106 """Helper method to calculate the TS8 exposure_id. 

107 

108 Parameters 

109 ---------- 

110 dateobs : `str` 

111 Date of observation in FITS ISO format. 

112 seqnum : `int`, unused 

113 Sequence number. Ignored. 

114 controller : `str`, unused 

115 Controller type. Ignored. 

116 

117 Returns 

118 ------- 

119 exposure_id : `int` 

120 Exposure ID. 

121 """ 

122 # There is worry that seconds are too coarse so use 10th of second 

123 # and read the first 21 characters. 

124 exposure_id = re.sub(r"\D", "", dateobs[:21]) 

125 return int(exposure_id) 

126 

127 @classmethod 

128 def compute_detector_exposure_id(cls, exposure_id, detector_num): 

129 # Docstring inherited from LsstBaseTranslator. 

130 return compute_detector_exposure_id_generic(exposure_id, detector_num, max_num=cls.DETECTOR_MAX) 

131 

132 @cache_translation 

133 def to_datetime_begin(self): 

134 # Docstring will be inherited. Property defined in properties.py 

135 self._used_these_cards("MJD-OBS") 

136 return Time(self._header["MJD-OBS"], scale="utc", format="mjd") 

137 

138 @cache_translation 

139 def to_detector_name(self): 

140 # Docstring will be inherited. Property defined in properties.py 

141 serial = self.to_detector_serial() 

142 detector_info = self.compute_detector_info_from_serial(serial) 

143 return detector_info[1] 

144 

145 def to_detector_group(self): 

146 """Returns the name of the raft. 

147 

148 Extracted from RAFTNAME header. 

149 

150 Raftname should be of the form: 'LCA-11021_RTM-011-Dev' and 

151 the resulting name will have the form "RTM-NNN". 

152 

153 Returns 

154 ------- 

155 name : `str` 

156 Name of raft. 

157 """ 

158 raft_name = self._header["RAFTNAME"] 

159 self._used_these_cards("RAFTNAME") 

160 match = re.search(r"(RTM-\d\d\d)", raft_name) 

161 if match: 

162 return match.group(0) 

163 raise ValueError(f"{self._log_prefix}: RAFTNAME has unexpected form of '{raft_name}'") 

164 

165 @cache_translation 

166 def to_detector_serial(self): 

167 """Returns the serial number of the detector. 

168 

169 Returns 

170 ------- 

171 serial : `str` 

172 LSST assigned serial number. 

173 

174 Notes 

175 ----- 

176 This is the LSST assigned serial number (``LSST_NUM``), and not 

177 the manufacturer's serial number (``CCD_SERN``). 

178 """ 

179 serial = self._header["LSST_NUM"] 

180 self._used_these_cards("LSST_NUM") 

181 

182 # this seems to be appended more or less at random and should be 

183 # removed. 

184 serial = re.sub("-Dev$", "", serial) 

185 return serial 

186 

187 @cache_translation 

188 def to_physical_filter(self): 

189 """Return the filter name. 

190 

191 Uses the FILTPOS header for older TS8 data. Newer data can use 

192 the base class implementation. 

193 

194 Returns 

195 ------- 

196 filter : `str` 

197 The filter name. Returns "NONE" if no filter can be determined. 

198 

199 Notes 

200 ----- 

201 The FILTPOS handling is retained for backwards compatibility. 

202 """ 

203 

204 default = "unknown" 

205 try: 

206 filter_pos = self._header["FILTPOS"] 

207 self._used_these_cards("FILTPOS") 

208 except KeyError: 

209 # TS8 data from 2023-05-09 and later should be following 

210 # DM-38882 conventions. 

211 physical_filter = super().to_physical_filter() 

212 # Some TS8 taken prior to 2023-05-09 have the string 

213 # 'unspecified' as the FILTER keyword and don't really 

214 # follow any established convention. 

215 if 'unspecified' in physical_filter: 

216 return default 

217 return physical_filter 

218 

219 try: 

220 return { 

221 2: 'g', 

222 3: 'r', 

223 4: 'i', 

224 5: 'z', 

225 6: 'y', 

226 }[filter_pos] 

227 except KeyError: 

228 log.warning("%s: Unknown filter position (assuming %s): %d", 

229 self._log_prefix, default, filter_pos) 

230 return default 

231 

232 def to_exposure_id(self): 

233 """Generate a unique exposure ID number 

234 

235 Note that SEQNUM is not unique for a given day in TS8 data 

236 so instead we convert the ISO date of observation directly to an 

237 integer. 

238 

239 Returns 

240 ------- 

241 exposure_id : `int` 

242 Unique exposure number. 

243 """ 

244 iso = self._header["DATE-OBS"] 

245 self._used_these_cards("DATE-OBS") 

246 

247 return self.compute_exposure_id(iso) 

248 

249 # For now assume that visit IDs and exposure IDs are identical 

250 to_visit_id = to_exposure_id 

251 

252 @cache_translation 

253 def to_observation_id(self): 

254 # Docstring will be inherited. Property defined in properties.py 

255 if self.is_key_ok("OBSID"): 

256 observation_id = self._header["OBSID"] 

257 self._used_these_cards("OBSID") 

258 return observation_id 

259 filename = self._header["FILENAME"] 

260 self._used_these_cards("FILENAME") 

261 return filename[:filename.rfind(".")]