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 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.coordinates import EarthLocation, Angle 

21import astropy.units as u 

22 

23from ..translator import cache_translation, CORRECTIONS_RESOURCE_ROOT 

24from .fits import FitsTranslator 

25from .helpers import altaz_from_degree_headers, is_non_science, \ 

26 tracking_from_degree_headers 

27 

28log = logging.getLogger(__name__) 

29 

30 

31class DecamTranslator(FitsTranslator): 

32 """Metadata translator for DECam standard headers. 

33 """ 

34 

35 name = "DECam" 

36 """Name of this translation class""" 

37 

38 supported_instrument = "DECam" 

39 """Supports the DECam instrument.""" 

40 

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

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

43 

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

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

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

47 "boresight_rotation_coord": "sky", 

48 } 

49 

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

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

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

53 "observation_id": "OBSID", 

54 "object": "OBJECT", 

55 "science_program": "PROPID", 

56 "detector_num": "CCDNUM", 

57 "detector_serial": "DETECTOR", 

58 "detector_unique_name": "DETPOS", 

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

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

61 # Ensure that reasonable values are always available 

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

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

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

65 # which is the SI equivalent of mbar. 

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

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

68 } 

69 

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

71 # header. 

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

73 # to the number in that group. 

74 detector_names = { 

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

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

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

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

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

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

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

82 62: 'N31'} 

83 

84 @classmethod 

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

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

87 supplied header. 

88 

89 Checks the INSTRUME and FILTER headers. 

90 

91 Parameters 

92 ---------- 

93 header : `dict`-like 

94 Header to convert to standardized form. 

95 filename : `str`, optional 

96 Name of file being translated. 

97 

98 Returns 

99 ------- 

100 can : `bool` 

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

102 otherwise. 

103 """ 

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

105 # if we really have an INSTRUME header 

106 if "INSTRUME" in header: 

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

108 if via_instrume: 

109 return via_instrume 

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

111 return True 

112 return False 

113 

114 @cache_translation 

115 def to_exposure_id(self): 

116 """Calculate exposure ID. 

117 

118 Returns 

119 ------- 

120 id : `int` 

121 ID of exposure. 

122 """ 

123 value = self._header["EXPNUM"] 

124 self._used_these_cards("EXPNUM") 

125 return value 

126 

127 @cache_translation 

128 def to_observation_counter(self): 

129 """Return the lifetime exposure number. 

130 

131 Returns 

132 ------- 

133 sequence : `int` 

134 The observation counter. 

135 """ 

136 return self.to_exposure_id() 

137 

138 @cache_translation 

139 def to_visit_id(self): 

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

141 return self.to_exposure_id() 

142 

143 @cache_translation 

144 def to_datetime_end(self): 

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

146 # Instcals have no DATE-END or DTUTC 

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

148 if datetime_end is None: 

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

150 return datetime_end 

151 

152 def _translate_from_calib_id(self, field): 

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

154 

155 Calibration products made with constructCalibs have some metadata 

156 saved in its FITS header CALIB_ID. 

157 """ 

158 data = self._header["CALIB_ID"] 

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

160 self._used_these_cards("CALIB_ID") 

161 return match.groups()[0] 

162 

163 @cache_translation 

164 def to_physical_filter(self): 

165 """Calculate physical filter. 

166 

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

168 which can happen for some valid Community Pipeline products. 

169 

170 Returns 

171 ------- 

172 filter : `str` 

173 The full filter name. 

174 """ 

175 if self.is_key_ok("FILTER"): 

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

177 self._used_these_cards("FILTER") 

178 return value 

179 elif self.is_key_ok("CALIB_ID"): 

180 return self._translate_from_calib_id("filter") 

181 else: 

182 return None 

183 

184 @cache_translation 

185 def to_location(self): 

186 """Calculate the observatory location. 

187 

188 Returns 

189 ------- 

190 location : `astropy.coordinates.EarthLocation` 

191 An object representing the location of the telescope. 

192 """ 

193 

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

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

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

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

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

199 else: 

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

201 value = EarthLocation.of_site("ctio") 

202 

203 return value 

204 

205 @cache_translation 

206 def to_observation_type(self): 

207 """Calculate the observation type. 

208 

209 Returns 

210 ------- 

211 typ : `str` 

212 Observation type. Normalized to standard set. 

213 """ 

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

215 return "none" 

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

217 self._used_these_cards("OBSTYPE") 

218 if obstype == "object": 

219 return "science" 

220 return obstype 

221 

222 @cache_translation 

223 def to_tracking_radec(self): 

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

225 radecsys = ("RADESYS",) 

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

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

228 

229 @cache_translation 

230 def to_altaz_begin(self): 

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

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

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

234 

235 @cache_translation 

236 def to_detector_exposure_id(self): 

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

238 exposure_id = self.to_exposure_id() 

239 if exposure_id is None: 

240 return None 

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

242 

243 @cache_translation 

244 def to_detector_group(self): 

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

246 name = self.to_detector_unique_name() 

247 return name[0] 

248 

249 @cache_translation 

250 def to_detector_name(self): 

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

252 name = self.to_detector_unique_name() 

253 return name[1:] 

254 

255 @classmethod 

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

257 """Fix DECam headers. 

258 

259 Parameters 

260 ---------- 

261 header : `dict` 

262 The header to update. Updates are in place. 

263 instrument : `str` 

264 The name of the instrument. 

265 obsid : `str` 

266 Unique observation identifier associated with this header. 

267 Will always be provided. 

268 filename : `str`, optional 

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

270 can be fixed independently of any filename being known. 

271 

272 Returns 

273 ------- 

274 modified = `bool` 

275 Returns `True` if the header was updated. 

276 

277 Notes 

278 ----- 

279 Fixes the following issues: 

280 

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

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

283 

284 Corrections are reported as debug level log messages. 

285 """ 

286 modified = False 

287 

288 # Calculate the standard label to use for log messages 

289 log_label = cls._construct_log_prefix(obsid, filename) 

290 

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

292 

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

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

295 modified = True 

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

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

298 

299 return modified