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

115 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-14 10:44 +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# First observation with new exposure ID is TS_C_20230524_000906. 

28_EXPOSURE_ID_DATE_CHANGE = Time("2023-05-24T23:00:00.0", format="isot", scale="tai") 

29_UNMODIFIED_DATE_OBS_HEADER = "HIERARCH LSST-TS8 DATE-OBS" 

30 

31 

32class LsstTS8Translator(LsstBaseTranslator): 

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

34 """ 

35 

36 name = "LSST-TS8" 

37 """Name of this translation class""" 

38 

39 _const_map = { 

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

41 "instrument": "LSST-TS8", 

42 "telescope": None, 

43 "location": None, 

44 "boresight_rotation_coord": None, 

45 "boresight_rotation_angle": None, 

46 "boresight_airmass": None, 

47 "tracking_radec": None, 

48 "altaz_begin": None, 

49 "object": "UNKNOWN", 

50 "relative_humidity": None, 

51 "temperature": None, 

52 "pressure": None, 

53 } 

54 

55 _trivial_map = { 

56 "science_program": "RUNNUM", 

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

58 } 

59 

60 cameraPolicyFile = "policy/ts8.yaml" 

61 

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

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

64 

65 @classmethod 

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

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

68 supplied header. 

69 

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

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

72 it starts with "ts8-". 

73 

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

75 of headers. 

76 

77 Parameters 

78 ---------- 

79 header : `dict`-like 

80 Header to convert to standardized form. 

81 filename : `str`, optional 

82 Name of file being translated. 

83 

84 Returns 

85 ------- 

86 can : `bool` 

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

88 otherwise. 

89 """ 

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

91 if can: 

92 return True 

93 

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

95 "CONTNUM" in header and \ 

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

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

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

99 "000018ee34e6", "000018ef1464", "000018eda120", "000018edf8a2", 

100 "000018ef3819", "000018ed9486", "000018ee02c8", "000018edfb24", 

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

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

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

104 return True 

105 

106 return False 

107 

108 @classmethod 

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

110 """Fix TS8 headers. 

111 

112 Notes 

113 ----- 

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

115 process. 

116 """ 

117 modified = False 

118 

119 # Calculate the standard label to use for log messages 

120 log_label = cls._construct_log_prefix(obsid, filename) 

121 

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

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

124 if "DATE-END" not in header: 

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

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

127 

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

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

130 # header. 

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

132 

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

134 date_obs = None 

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

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

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

138 break 

139 

140 if date_obs: 

141 # The historical exposure ID calculation requires that we 

142 # have access to the unmodified DATE-OBS value. 

143 header[_UNMODIFIED_DATE_OBS_HEADER] = header["DATE-OBS"] 

144 

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

146 date_obs = date_obs - exptime 

147 header["MJD-OBS"] = float(date_obs.mjd) 

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

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

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

151 

152 modified = True 

153 else: 

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

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

156 

157 return modified 

158 

159 @classmethod 

160 def compute_detector_exposure_id(cls, exposure_id, detector_num): 

161 # Docstring inherited from LsstBaseTranslator. 

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

163 

164 @classmethod 

165 def max_exposure_id(cls): 

166 """The maximum exposure ID expected from this instrument. 

167 

168 The TS8 implementation is non-standard because TS8 data can create 

169 two different forms of exposure_id based on the date but we need 

170 the largest form to be the one returned. 

171 

172 Returns 

173 ------- 

174 max_exposure_id : `int` 

175 The maximum value. 

176 """ 

177 max_date = "2050-12-31T23:59.999" 

178 return int(re.sub(r"\D", "", max_date[:21])) 

179 

180 @cache_translation 

181 def to_detector_name(self): 

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

183 serial = self.to_detector_serial() 

184 detector_info = self.compute_detector_info_from_serial(serial) 

185 return detector_info[1] 

186 

187 def to_detector_group(self): 

188 """Returns the name of the raft. 

189 

190 Extracted from RAFTNAME header. 

191 

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

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

194 

195 Returns 

196 ------- 

197 name : `str` 

198 Name of raft. 

199 """ 

200 raft_name = self._header["RAFTNAME"] 

201 self._used_these_cards("RAFTNAME") 

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

203 if match: 

204 return match.group(0) 

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

206 

207 @cache_translation 

208 def to_detector_serial(self): 

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

210 

211 Returns 

212 ------- 

213 serial : `str` 

214 LSST assigned serial number. 

215 

216 Notes 

217 ----- 

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

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

220 """ 

221 serial = self._header["LSST_NUM"] 

222 self._used_these_cards("LSST_NUM") 

223 

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

225 # removed. 

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

227 return serial 

228 

229 @cache_translation 

230 def to_physical_filter(self): 

231 """Return the filter name. 

232 

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

234 the base class implementation. 

235 

236 Returns 

237 ------- 

238 filter : `str` 

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

240 

241 Notes 

242 ----- 

243 The FILTPOS handling is retained for backwards compatibility. 

244 """ 

245 

246 default = "unknown" 

247 try: 

248 filter_pos = self._header["FILTPOS"] 

249 self._used_these_cards("FILTPOS") 

250 except KeyError: 

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

252 # DM-38882 conventions. 

253 physical_filter = super().to_physical_filter() 

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

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

256 # follow any established convention. 

257 if 'unspecified' in physical_filter: 

258 return default 

259 return physical_filter 

260 

261 try: 

262 return { 

263 2: 'g', 

264 3: 'r', 

265 4: 'i', 

266 5: 'z', 

267 6: 'y', 

268 }[filter_pos] 

269 except KeyError: 

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

271 self._log_prefix, default, filter_pos) 

272 return default 

273 

274 @cache_translation 

275 def to_exposure_id(self): 

276 """Generate a unique exposure ID number 

277 

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

279 controller variant (all TS8 uses "C" controller). Due to existing 

280 ingests, data taken before 2023-05-25 must use the old style 

281 timestamp ID. 

282 

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

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

285 integer. 

286 

287 Returns 

288 ------- 

289 exposure_id : `int` 

290 Unique exposure number. 

291 """ 

292 begin = self.to_datetime_begin() 

293 

294 if begin > _EXPOSURE_ID_DATE_CHANGE: 

295 obsid = self.to_observation_id() 

296 if obsid.startswith("TS_C_"): 

297 return super().to_exposure_id() 

298 

299 iso = self._header.get(_UNMODIFIED_DATE_OBS_HEADER, self._header["DATE-OBS"]) 

300 self._used_these_cards("DATE-OBS") 

301 

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

303 # and read the first 21 characters. 

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

305 return int(exposure_id) 

306 

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

308 to_visit_id = to_exposure_id 

309 

310 @cache_translation 

311 def to_observation_id(self): 

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

313 if self.is_key_ok("OBSID"): 

314 observation_id = self._header["OBSID"] 

315 self._used_these_cards("OBSID") 

316 return observation_id 

317 filename = self._header["FILENAME"] 

318 self._used_these_cards("FILENAME") 

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