Coverage for python/astro_metadata_translator/translators/decam.py: 33%

Shortcuts 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

125 statements  

1# This file is part of astro_metadata_translator. 

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 LICENSE file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

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

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

11 

12"""Metadata translation code for DECam FITS headers""" 

13 

14__all__ = ("DecamTranslator", ) 

15 

16import re 

17import posixpath 

18import logging 

19 

20from astropy.io import fits 

21from astropy.coordinates import EarthLocation, Angle 

22import astropy.units as u 

23 

24from ..translator import cache_translation, CORRECTIONS_RESOURCE_ROOT 

25from .fits import FitsTranslator 

26from .helpers import altaz_from_degree_headers, is_non_science, \ 

27 tracking_from_degree_headers 

28 

29log = logging.getLogger(__name__) 

30 

31 

32class DecamTranslator(FitsTranslator): 

33 """Metadata translator for DECam standard headers. 

34 """ 

35 

36 name = "DECam" 

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

38 

39 supported_instrument = "DECam" 

40 """Supports the DECam instrument.""" 

41 

42 default_resource_root = posixpath.join(CORRECTIONS_RESOURCE_ROOT, "DECam") 

43 """Default resource path root to use to locate header correction files.""" 

44 

45 # DECam has no rotator, and the instrument angle on sky is set to +Y=East, 

46 # +X=South which we define as a 90 degree rotation and an X-flip. 

47 _const_map = {"boresight_rotation_angle": Angle(90*u.deg), 

48 "boresight_rotation_coord": "sky", 

49 } 

50 

51 _trivial_map = {"exposure_time": ("EXPTIME", dict(unit=u.s)), 

52 "dark_time": ("DARKTIME", dict(unit=u.s)), 

53 "boresight_airmass": ("AIRMASS", dict(checker=is_non_science)), 

54 "observation_id": "OBSID", 

55 "object": "OBJECT", 

56 "science_program": "PROPID", 

57 "detector_num": "CCDNUM", 

58 "detector_serial": "DETECTOR", 

59 "detector_unique_name": "DETPOS", 

60 "telescope": ("TELESCOP", dict(default="CTIO 4.0-m telescope")), 

61 "instrument": ("INSTRUME", dict(default="DECam")), 

62 # Ensure that reasonable values are always available 

63 "relative_humidity": ("HUMIDITY", dict(default=40., minimum=0, maximum=100.)), 

64 "temperature": ("OUTTEMP", dict(unit=u.deg_C, default=10., minimum=-10., maximum=40.)), 

65 # Header says torr but seems to be mbar. Use hPa unit 

66 # which is the SI equivalent of mbar. 

67 "pressure": ("PRESSURE", dict(unit=u.hPa, 

68 default=771.611, minimum=700., maximum=850.)), 

69 } 

70 

71 # Unique detector names are currently not used but are read directly from 

72 # header. 

73 # The detector_group could be N or S with detector_name corresponding 

74 # to the number in that group. 

75 detector_names = { 

76 1: 'S29', 2: 'S30', 3: 'S31', 4: 'S25', 5: 'S26', 6: 'S27', 7: 'S28', 8: 'S20', 9: 'S21', 

77 10: 'S22', 11: 'S23', 12: 'S24', 13: 'S14', 14: 'S15', 15: 'S16', 16: 'S17', 17: 'S18', 

78 18: 'S19', 19: 'S8', 20: 'S9', 21: 'S10', 22: 'S11', 23: 'S12', 24: 'S13', 25: 'S1', 26: 'S2', 

79 27: 'S3', 28: 'S4', 29: 'S5', 30: 'S6', 31: 'S7', 32: 'N1', 33: 'N2', 34: 'N3', 35: 'N4', 

80 36: 'N5', 37: 'N6', 38: 'N7', 39: 'N8', 40: 'N9', 41: 'N10', 42: 'N11', 43: 'N12', 44: 'N13', 

81 45: 'N14', 46: 'N15', 47: 'N16', 48: 'N17', 49: 'N18', 50: 'N19', 51: 'N20', 52: 'N21', 

82 53: 'N22', 54: 'N23', 55: 'N24', 56: 'N25', 57: 'N26', 58: 'N27', 59: 'N28', 60: 'N29', 

83 62: 'N31'} 

84 

85 @classmethod 

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

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

88 supplied header. 

89 

90 Checks the INSTRUME and FILTER headers. 

91 

92 Parameters 

93 ---------- 

94 header : `dict`-like 

95 Header to convert to standardized form. 

96 filename : `str`, optional 

97 Name of file being translated. 

98 

99 Returns 

100 ------- 

101 can : `bool` 

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

103 otherwise. 

104 """ 

105 # Use INSTRUME. Because of defaulting behavior only do this 

106 # if we really have an INSTRUME header 

107 if "INSTRUME" in header: 

108 via_instrume = super().can_translate(header, filename=filename) 

109 if via_instrume: 

110 return via_instrume 

111 if cls.is_keyword_defined(header, "FILTER") and "DECam" in header["FILTER"]: 

112 return True 

113 return False 

114 

115 @cache_translation 

116 def to_exposure_id(self): 

117 """Calculate exposure ID. 

118 

119 Returns 

120 ------- 

121 id : `int` 

122 ID of exposure. 

123 """ 

124 value = self._header["EXPNUM"] 

125 self._used_these_cards("EXPNUM") 

126 return value 

127 

128 @cache_translation 

129 def to_observation_counter(self): 

130 """Return the lifetime exposure number. 

131 

132 Returns 

133 ------- 

134 sequence : `int` 

135 The observation counter. 

136 """ 

137 return self.to_exposure_id() 

138 

139 @cache_translation 

140 def to_visit_id(self): 

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

142 return self.to_exposure_id() 

143 

144 @cache_translation 

145 def to_datetime_end(self): 

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

147 # Instcals have no DATE-END or DTUTC 

148 datetime_end = self._from_fits_date("DTUTC", scale="utc") 

149 if datetime_end is None: 

150 datetime_end = self.to_datetime_begin() + self.to_exposure_time() 

151 return datetime_end 

152 

153 def _translate_from_calib_id(self, field): 

154 """Fetch the ID from the CALIB_ID header. 

155 

156 Calibration products made with constructCalibs have some metadata 

157 saved in its FITS header CALIB_ID. 

158 """ 

159 data = self._header["CALIB_ID"] 

160 match = re.search(r".*%s=(\S+)" % field, data) 

161 self._used_these_cards("CALIB_ID") 

162 return match.groups()[0] 

163 

164 @cache_translation 

165 def to_physical_filter(self): 

166 """Calculate physical filter. 

167 

168 Return `None` if the keyword FILTER does not exist in the header, 

169 which can happen for some valid Community Pipeline products. 

170 

171 Returns 

172 ------- 

173 filter : `str` 

174 The full filter name. 

175 """ 

176 if self.is_key_ok("FILTER"): 

177 value = self._header["FILTER"].strip() 

178 self._used_these_cards("FILTER") 

179 return value 

180 elif self.is_key_ok("CALIB_ID"): 

181 return self._translate_from_calib_id("filter") 

182 else: 

183 return None 

184 

185 @cache_translation 

186 def to_location(self): 

187 """Calculate the observatory location. 

188 

189 Returns 

190 ------- 

191 location : `astropy.coordinates.EarthLocation` 

192 An object representing the location of the telescope. 

193 """ 

194 

195 if self.is_key_ok("OBS-LONG"): 

196 # OBS-LONG has west-positive sign so must be flipped 

197 lon = self._header["OBS-LONG"] * -1.0 

198 value = EarthLocation.from_geodetic(lon, self._header["OBS-LAT"], self._header["OBS-ELEV"]) 

199 self._used_these_cards("OBS-LONG", "OBS-LAT", "OBS-ELEV") 

200 else: 

201 # Look up the value since some files do not have location 

202 value = EarthLocation.of_site("ctio") 

203 

204 return value 

205 

206 @cache_translation 

207 def to_observation_type(self): 

208 """Calculate the observation type. 

209 

210 Returns 

211 ------- 

212 typ : `str` 

213 Observation type. Normalized to standard set. 

214 """ 

215 if not self.is_key_ok("OBSTYPE"): 

216 return "none" 

217 obstype = self._header["OBSTYPE"].strip().lower() 

218 self._used_these_cards("OBSTYPE") 

219 if obstype == "object": 

220 return "science" 

221 return obstype 

222 

223 @cache_translation 

224 def to_tracking_radec(self): 

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

226 radecsys = ("RADESYS",) 

227 radecpairs = (("TELRA", "TELDEC"),) 

228 return tracking_from_degree_headers(self, radecsys, radecpairs, unit=(u.hourangle, u.deg)) 

229 

230 @cache_translation 

231 def to_altaz_begin(self): 

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

233 return altaz_from_degree_headers(self, (("ZD", "AZ"),), 

234 self.to_datetime_begin(), is_zd=set(["ZD"])) 

235 

236 @cache_translation 

237 def to_detector_exposure_id(self): 

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

239 exposure_id = self.to_exposure_id() 

240 if exposure_id is None: 

241 return None 

242 return int("{:07d}{:02d}".format(exposure_id, self.to_detector_num())) 

243 

244 @cache_translation 

245 def to_detector_group(self): 

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

247 name = self.to_detector_unique_name() 

248 return name[0] 

249 

250 @cache_translation 

251 def to_detector_name(self): 

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

253 name = self.to_detector_unique_name() 

254 return name[1:] 

255 

256 @classmethod 

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

258 """Fix DECam headers. 

259 

260 Parameters 

261 ---------- 

262 header : `dict` 

263 The header to update. Updates are in place. 

264 instrument : `str` 

265 The name of the instrument. 

266 obsid : `str` 

267 Unique observation identifier associated with this header. 

268 Will always be provided. 

269 filename : `str`, optional 

270 Filename associated with this header. May not be set since headers 

271 can be fixed independently of any filename being known. 

272 

273 Returns 

274 ------- 

275 modified = `bool` 

276 Returns `True` if the header was updated. 

277 

278 Notes 

279 ----- 

280 Fixes the following issues: 

281 

282 * If OBSTYPE contains "zero" or "bias", 

283 update the FILTER keyword to "solid plate 0.0 0.0". 

284 

285 Corrections are reported as debug level log messages. 

286 """ 

287 modified = False 

288 

289 # Calculate the standard label to use for log messages 

290 log_label = cls._construct_log_prefix(obsid, filename) 

291 

292 obstype = header.get("OBSTYPE", "unknown") 

293 

294 if "bias" in obstype.lower() or "zero" in obstype.lower(): 

295 header["FILTER"] = "solid plate 0.0 0.0" 

296 modified = True 

297 log.debug("%s: Set FILTER to %s because OBSTYPE is %s", 

298 log_label, header["FILTER"], obstype) 

299 

300 return modified 

301 

302 @classmethod 

303 def determine_translatable_headers(cls, filename, primary=None): 

304 """Given a file return all the headers usable for metadata translation. 

305 

306 DECam files are multi-extension FITS with a primary header and 

307 each detector stored in a subsequent extension. DECam uses 

308 ``INHERIT=T`` and each detector header will be merged with the 

309 primary header. 

310 

311 Guide headers are not returned. 

312 

313 Parameters 

314 ---------- 

315 filename : `str` 

316 Path to a file in a format understood by this translator. 

317 primary : `dict`-like, optional 

318 The primary header obtained by the caller. This is sometimes 

319 already known, for example if a system is trying to bootstrap 

320 without already knowing what data is in the file. Will be 

321 merged with detector headers if supplied, else will be read 

322 from the file. 

323 

324 Yields 

325 ------ 

326 headers : iterator of `dict`-like 

327 Each detector header in turn. The supplied header will be merged 

328 with the contents of each detector header. 

329 

330 Notes 

331 ----- 

332 This translator class is specifically tailored to raw DECam data and 

333 is not designed to work with general FITS files. The normal paradigm 

334 is for the caller to have read the first header and then called 

335 `determine_translator()` on the result to work out which translator 

336 class to then call to obtain the real headers to be used for 

337 translation. 

338 """ 

339 # Circular dependency so must defer import. 

340 from ..headers import merge_headers 

341 

342 # Since we want to scan many HDUs we use astropy directly to keep 

343 # the file open rather than continually opening and closing it 

344 # as we go to each HDU. 

345 with fits.open(filename) as fits_file: 

346 # Astropy does not automatically handle the INHERIT=T in 

347 # DECam headers so the primary header must be merged. 

348 first_pass = True 

349 

350 for hdu in fits_file: 

351 if first_pass: 

352 if not primary: 

353 primary = hdu.header 

354 first_pass = False 

355 continue 

356 

357 header = hdu.header 

358 if "CCDNUM" not in header: # Primary does not have CCDNUM 

359 continue 

360 if header["CCDNUM"] > 62: # ignore guide CCDs 

361 continue 

362 yield merge_headers([primary, header], mode="overwrite")