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

136 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-06-14 02:30 -0700

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 

14from __future__ import annotations 

15 

16__all__ = ("DecamTranslator",) 

17 

18import logging 

19import posixpath 

20import re 

21from typing import TYPE_CHECKING, Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, Union 

22 

23import astropy.units as u 

24from astropy.coordinates import Angle, EarthLocation 

25from astropy.io import fits 

26 

27from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation 

28from .fits import FitsTranslator 

29from .helpers import altaz_from_degree_headers, is_non_science, tracking_from_degree_headers 

30 

31if TYPE_CHECKING: 31 ↛ 32line 31 didn't jump to line 32, because the condition on line 31 was never true

32 import astropy.coordinates 

33 import astropy.time 

34 

35log = logging.getLogger(__name__) 

36 

37 

38class DecamTranslator(FitsTranslator): 

39 """Metadata translator for DECam standard headers.""" 

40 

41 name = "DECam" 

42 """Name of this translation class""" 

43 

44 supported_instrument = "DECam" 

45 """Supports the DECam instrument.""" 

46 

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

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

49 

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

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

52 _const_map = { 

53 "boresight_rotation_angle": Angle(90 * u.deg), 

54 "boresight_rotation_coord": "sky", 

55 } 

56 

57 _trivial_map: Dict[str, Union[str, List[str], Tuple[Any, ...]]] = { 

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

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

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

61 "observation_id": "OBSID", 

62 "object": "OBJECT", 

63 "science_program": "PROPID", 

64 "detector_num": "CCDNUM", 

65 "detector_serial": "DETECTOR", 

66 "detector_unique_name": "DETPOS", 

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

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

69 # Ensure that reasonable values are always available 

70 "relative_humidity": ("HUMIDITY", dict(default=40.0, minimum=0, maximum=100.0)), 

71 "temperature": ("OUTTEMP", dict(unit=u.deg_C, default=10.0, minimum=-10.0, maximum=40.0)), 

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

73 # which is the SI equivalent of mbar. 

74 "pressure": ("PRESSURE", dict(unit=u.hPa, default=771.611, minimum=700.0, maximum=850.0)), 

75 } 

76 

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

78 # header. 

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

80 # to the number in that group. 

81 detector_names = { 

82 1: "S29", 

83 2: "S30", 

84 3: "S31", 

85 4: "S25", 

86 5: "S26", 

87 6: "S27", 

88 7: "S28", 

89 8: "S20", 

90 9: "S21", 

91 10: "S22", 

92 11: "S23", 

93 12: "S24", 

94 13: "S14", 

95 14: "S15", 

96 15: "S16", 

97 16: "S17", 

98 17: "S18", 

99 18: "S19", 

100 19: "S8", 

101 20: "S9", 

102 21: "S10", 

103 22: "S11", 

104 23: "S12", 

105 24: "S13", 

106 25: "S1", 

107 26: "S2", 

108 27: "S3", 

109 28: "S4", 

110 29: "S5", 

111 30: "S6", 

112 31: "S7", 

113 32: "N1", 

114 33: "N2", 

115 34: "N3", 

116 35: "N4", 

117 36: "N5", 

118 37: "N6", 

119 38: "N7", 

120 39: "N8", 

121 40: "N9", 

122 41: "N10", 

123 42: "N11", 

124 43: "N12", 

125 44: "N13", 

126 45: "N14", 

127 46: "N15", 

128 47: "N16", 

129 48: "N17", 

130 49: "N18", 

131 50: "N19", 

132 51: "N20", 

133 52: "N21", 

134 53: "N22", 

135 54: "N23", 

136 55: "N24", 

137 56: "N25", 

138 57: "N26", 

139 58: "N27", 

140 59: "N28", 

141 60: "N29", 

142 62: "N31", 

143 } 

144 

145 @classmethod 

146 def can_translate(cls, header: MutableMapping[str, Any], filename: Optional[str] = None) -> bool: 

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

148 supplied header. 

149 

150 Checks the INSTRUME and FILTER headers. 

151 

152 Parameters 

153 ---------- 

154 header : `dict`-like 

155 Header to convert to standardized form. 

156 filename : `str`, optional 

157 Name of file being translated. 

158 

159 Returns 

160 ------- 

161 can : `bool` 

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

163 otherwise. 

164 """ 

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

166 # if we really have an INSTRUME header 

167 if "INSTRUME" in header: 

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

169 if via_instrume: 

170 return via_instrume 

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

172 return True 

173 return False 

174 

175 @cache_translation 

176 def to_exposure_id(self) -> int: 

177 """Calculate exposure ID. 

178 

179 Returns 

180 ------- 

181 id : `int` 

182 ID of exposure. 

183 """ 

184 value = self._header["EXPNUM"] 

185 self._used_these_cards("EXPNUM") 

186 return value 

187 

188 @cache_translation 

189 def to_observation_counter(self) -> int: 

190 """Return the lifetime exposure number. 

191 

192 Returns 

193 ------- 

194 sequence : `int` 

195 The observation counter. 

196 """ 

197 return self.to_exposure_id() 

198 

199 @cache_translation 

200 def to_visit_id(self) -> int: 

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

202 return self.to_exposure_id() 

203 

204 @cache_translation 

205 def to_datetime_end(self) -> astropy.time.Time: 

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

207 # Instcals have no DATE-END or DTUTC 

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

209 if datetime_end is None: 

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

211 return datetime_end 

212 

213 def _translate_from_calib_id(self, field: str) -> str: 

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

215 

216 Calibration products made with constructCalibs have some metadata 

217 saved in its FITS header CALIB_ID. 

218 """ 

219 data = self._header["CALIB_ID"] 

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

221 if not match: 

222 raise RuntimeError(f"Header CALIB_ID with value '{data}' has not field '{field}'") 

223 self._used_these_cards("CALIB_ID") 

224 return match.groups()[0] 

225 

226 @cache_translation 

227 def to_physical_filter(self) -> Optional[str]: 

228 """Calculate physical filter. 

229 

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

231 which can happen for some valid Community Pipeline products. 

232 

233 Returns 

234 ------- 

235 filter : `str` 

236 The full filter name. 

237 """ 

238 if self.is_key_ok("FILTER"): 

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

240 self._used_these_cards("FILTER") 

241 return value 

242 elif self.is_key_ok("CALIB_ID"): 

243 return self._translate_from_calib_id("filter") 

244 else: 

245 return None 

246 

247 @cache_translation 

248 def to_location(self) -> astropy.coordinates.EarthLocation: 

249 """Calculate the observatory location. 

250 

251 Returns 

252 ------- 

253 location : `astropy.coordinates.EarthLocation` 

254 An object representing the location of the telescope. 

255 """ 

256 

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

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

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

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

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

262 else: 

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

264 value = EarthLocation.of_site("ctio") 

265 

266 return value 

267 

268 @cache_translation 

269 def to_observation_type(self) -> str: 

270 """Calculate the observation type. 

271 

272 Returns 

273 ------- 

274 typ : `str` 

275 Observation type. Normalized to standard set. 

276 """ 

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

278 return "none" 

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

280 self._used_these_cards("OBSTYPE") 

281 if obstype == "object": 

282 return "science" 

283 return obstype 

284 

285 @cache_translation 

286 def to_tracking_radec(self) -> astropy.coordinates.SkyCoord: 

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

288 radecsys = ("RADESYS",) 

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

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

291 

292 @cache_translation 

293 def to_altaz_begin(self) -> astropy.coordinates.AltAz: 

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

295 return altaz_from_degree_headers(self, (("ZD", "AZ"),), self.to_datetime_begin(), is_zd=set(["ZD"])) 

296 

297 @cache_translation 

298 def to_detector_exposure_id(self) -> Optional[int]: 

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

300 exposure_id = self.to_exposure_id() 

301 if exposure_id is None: 

302 return None 

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

304 

305 @cache_translation 

306 def to_detector_group(self) -> str: 

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

308 name = self.to_detector_unique_name() 

309 return name[0] 

310 

311 @cache_translation 

312 def to_detector_name(self) -> str: 

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

314 name = self.to_detector_unique_name() 

315 return name[1:] 

316 

317 @classmethod 

318 def fix_header( 

319 cls, header: MutableMapping[str, Any], instrument: str, obsid: str, filename: Optional[str] = None 

320 ) -> bool: 

321 """Fix DECam headers. 

322 

323 Parameters 

324 ---------- 

325 header : `dict` 

326 The header to update. Updates are in place. 

327 instrument : `str` 

328 The name of the instrument. 

329 obsid : `str` 

330 Unique observation identifier associated with this header. 

331 Will always be provided. 

332 filename : `str`, optional 

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

334 can be fixed independently of any filename being known. 

335 

336 Returns 

337 ------- 

338 modified = `bool` 

339 Returns `True` if the header was updated. 

340 

341 Notes 

342 ----- 

343 Fixes the following issues: 

344 

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

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

347 

348 Corrections are reported as debug level log messages. 

349 """ 

350 modified = False 

351 

352 # Calculate the standard label to use for log messages 

353 log_label = cls._construct_log_prefix(obsid, filename) 

354 

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

356 

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

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

359 modified = True 

360 log.debug("%s: Set FILTER to %s because OBSTYPE is %s", log_label, header["FILTER"], obstype) 

361 

362 return modified 

363 

364 @classmethod 

365 def determine_translatable_headers( 

366 cls, filename: str, primary: Optional[MutableMapping[str, Any]] = None 

367 ) -> Iterator[MutableMapping[str, Any]]: 

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

369 

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

371 each detector stored in a subsequent extension. DECam uses 

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

373 primary header. 

374 

375 Guide headers are not returned. 

376 

377 Parameters 

378 ---------- 

379 filename : `str` 

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

381 primary : `dict`-like, optional 

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

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

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

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

386 from the file. 

387 

388 Yields 

389 ------ 

390 headers : iterator of `dict`-like 

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

392 with the contents of each detector header. 

393 

394 Notes 

395 ----- 

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

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

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

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

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

401 translation. 

402 """ 

403 # Circular dependency so must defer import. 

404 from ..headers import merge_headers 

405 

406 # This is convoluted because we need to turn an Optional variable 

407 # to a Dict so that mypy is happy. 

408 primary_hdr = primary if primary else {} 

409 

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

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

412 # as we go to each HDU. 

413 with fits.open(filename) as fits_file: 

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

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

416 first_pass = True 

417 

418 for hdu in fits_file: 

419 if first_pass: 

420 if not primary_hdr: 

421 primary_hdr = hdu.header 

422 first_pass = False 

423 continue 

424 

425 header = hdu.header 

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

427 continue 

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

429 continue 

430 yield merge_headers([primary_hdr, header], mode="overwrite")