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

106 statements  

« prev     ^ index     » next       coverage.py v7.2.6, created at 2023-05-24 09:52 +0000

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 @classmethod 

105 def fix_header(cls, header, instrument, obsid, filename=None): 

106 """Fix TS8 headers. 

107 

108 Notes 

109 ----- 

110 See `~astro_metadata_translator.fix_header` for details of the general 

111 process. 

112 """ 

113 modified = False 

114 

115 # Calculate the standard label to use for log messages 

116 log_label = cls._construct_log_prefix(obsid, filename) 

117 

118 if header.get("DATE-OBS", "OBS") == header.get("DATE-TRG", "TRG"): 

119 log.warning("%s: DATE-OBS detected referring to end of observation.", log_label) 

120 if "DATE-END" not in header: 

121 header["DATE-END"] = header["DATE-OBS"] 

122 header["MJD-END"] = header["MJD-OBS"] 

123 

124 # Time system used to be UTC and at some point became TAI. 

125 # Need to include the transition date and update the TIMESYS 

126 # header. 

127 timesys = header.get("TIMESYS", "utc").lower() 

128 

129 # Need to subtract exposure time from DATE-OBS. 

130 date_obs = None 

131 for (key, format) in (("MJD-OBS", "mjd"), ("DATE-OBS", "isot")): 

132 if date_val := header.get(key): 

133 date_obs = Time(date_val, format=format, scale=timesys) 

134 break 

135 

136 if date_obs: 

137 exptime = TimeDelta(header["EXPTIME"]*u.s, scale="tai") 

138 date_obs = date_obs - exptime 

139 header["MJD-OBS"] = date_obs.mjd 

140 header["DATE-OBS"] = date_obs.isot 

141 header["DATE-BEG"] = header["DATE-OBS"] 

142 header["MJD-BEG"] = header["MJD-OBS"] 

143 

144 modified = True 

145 else: 

146 # This should never happen because DATE-OBS is already present. 

147 log.warning("%s: Unexpectedly failed to extract date from DATE-OBS/MJD-OBS", log_label) 

148 

149 return modified 

150 

151 @classmethod 

152 def compute_detector_exposure_id(cls, exposure_id, detector_num): 

153 # Docstring inherited from LsstBaseTranslator. 

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

155 

156 @cache_translation 

157 def to_detector_name(self): 

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

159 serial = self.to_detector_serial() 

160 detector_info = self.compute_detector_info_from_serial(serial) 

161 return detector_info[1] 

162 

163 def to_detector_group(self): 

164 """Returns the name of the raft. 

165 

166 Extracted from RAFTNAME header. 

167 

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

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

170 

171 Returns 

172 ------- 

173 name : `str` 

174 Name of raft. 

175 """ 

176 raft_name = self._header["RAFTNAME"] 

177 self._used_these_cards("RAFTNAME") 

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

179 if match: 

180 return match.group(0) 

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

182 

183 @cache_translation 

184 def to_detector_serial(self): 

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

186 

187 Returns 

188 ------- 

189 serial : `str` 

190 LSST assigned serial number. 

191 

192 Notes 

193 ----- 

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

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

196 """ 

197 serial = self._header["LSST_NUM"] 

198 self._used_these_cards("LSST_NUM") 

199 

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

201 # removed. 

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

203 return serial 

204 

205 @cache_translation 

206 def to_physical_filter(self): 

207 """Return the filter name. 

208 

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

210 the base class implementation. 

211 

212 Returns 

213 ------- 

214 filter : `str` 

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

216 

217 Notes 

218 ----- 

219 The FILTPOS handling is retained for backwards compatibility. 

220 """ 

221 

222 default = "unknown" 

223 try: 

224 filter_pos = self._header["FILTPOS"] 

225 self._used_these_cards("FILTPOS") 

226 except KeyError: 

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

228 # DM-38882 conventions. 

229 physical_filter = super().to_physical_filter() 

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

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

232 # follow any established convention. 

233 if 'unspecified' in physical_filter: 

234 return default 

235 return physical_filter 

236 

237 try: 

238 return { 

239 2: 'g', 

240 3: 'r', 

241 4: 'i', 

242 5: 'z', 

243 6: 'y', 

244 }[filter_pos] 

245 except KeyError: 

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

247 self._log_prefix, default, filter_pos) 

248 return default 

249 

250 @cache_translation 

251 def to_exposure_id(self): 

252 """Generate a unique exposure ID number 

253 

254 Modern TS8 data conforms to standard LSSTCam OBSID, using the "C" 

255 controller variant (all TS8 uses "C" controller). 

256 

257 For older data SEQNUM is not unique for a given day in TS8 data 

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

259 integer. 

260 

261 Returns 

262 ------- 

263 exposure_id : `int` 

264 Unique exposure number. 

265 """ 

266 obsid = self.to_observation_id() 

267 if obsid.startswith("TS_C_"): 

268 return super().to_exposure_id() 

269 

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

271 self._used_these_cards("DATE-OBS") 

272 

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

274 # and read the first 21 characters. 

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

276 return int(exposure_id) 

277 

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

279 to_visit_id = to_exposure_id 

280 

281 @cache_translation 

282 def to_observation_id(self): 

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

284 if self.is_key_ok("OBSID"): 

285 observation_id = self._header["OBSID"] 

286 self._used_these_cards("OBSID") 

287 return observation_id 

288 filename = self._header["FILENAME"] 

289 self._used_these_cards("FILENAME") 

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