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

141 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-27 02:38 -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 collections.abc import Iterator, Mapping, MutableMapping 

22from typing import TYPE_CHECKING, Any 

23 

24import astropy.units as u 

25from astropy.coordinates import Angle, EarthLocation 

26from astropy.io import fits 

27 

28from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation 

29from .fits import FitsTranslator 

30from .helpers import altaz_from_degree_headers, is_non_science, tracking_from_degree_headers 

31 

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

33 import astropy.coordinates 

34 import astropy.time 

35 

36log = logging.getLogger(__name__) 

37 

38 

39class DecamTranslator(FitsTranslator): 

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

41 

42 name = "DECam" 

43 """Name of this translation class""" 

44 

45 supported_instrument = "DECam" 

46 """Supports the DECam instrument.""" 

47 

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

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

50 

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

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

53 _const_map = { 

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

55 "boresight_rotation_coord": "sky", 

56 } 

57 

58 _trivial_map: dict[str, str | list[str] | tuple[Any, ...]] = { 

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

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

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

62 "observation_id": "OBSID", 

63 "object": "OBJECT", 

64 "science_program": "PROPID", 

65 "detector_num": "CCDNUM", 

66 "detector_serial": "DETECTOR", 

67 "detector_unique_name": "DETPOS", 

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

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

70 # Ensure that reasonable values are always available 

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

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

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

74 # which is the SI equivalent of mbar. 

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

76 } 

77 

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

79 # header. 

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

81 # to the number in that group. 

82 detector_names = { 

83 1: "S29", 

84 2: "S30", 

85 3: "S31", 

86 4: "S25", 

87 5: "S26", 

88 6: "S27", 

89 7: "S28", 

90 8: "S20", 

91 9: "S21", 

92 10: "S22", 

93 11: "S23", 

94 12: "S24", 

95 13: "S14", 

96 14: "S15", 

97 15: "S16", 

98 16: "S17", 

99 17: "S18", 

100 18: "S19", 

101 19: "S8", 

102 20: "S9", 

103 21: "S10", 

104 22: "S11", 

105 23: "S12", 

106 24: "S13", 

107 25: "S1", 

108 26: "S2", 

109 27: "S3", 

110 28: "S4", 

111 29: "S5", 

112 30: "S6", 

113 31: "S7", 

114 32: "N1", 

115 33: "N2", 

116 34: "N3", 

117 35: "N4", 

118 36: "N5", 

119 37: "N6", 

120 38: "N7", 

121 39: "N8", 

122 40: "N9", 

123 41: "N10", 

124 42: "N11", 

125 43: "N12", 

126 44: "N13", 

127 45: "N14", 

128 46: "N15", 

129 47: "N16", 

130 48: "N17", 

131 49: "N18", 

132 50: "N19", 

133 51: "N20", 

134 52: "N21", 

135 53: "N22", 

136 54: "N23", 

137 55: "N24", 

138 56: "N25", 

139 57: "N26", 

140 58: "N27", 

141 59: "N28", 

142 60: "N29", 

143 62: "N31", 

144 } 

145 

146 @classmethod 

147 def can_translate(cls, header: Mapping[str, Any], filename: str | None = None) -> bool: 

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

149 supplied header. 

150 

151 Checks the INSTRUME and FILTER headers. 

152 

153 Parameters 

154 ---------- 

155 header : `dict`-like 

156 Header to convert to standardized form. 

157 filename : `str`, optional 

158 Name of file being translated. 

159 

160 Returns 

161 ------- 

162 can : `bool` 

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

164 otherwise. 

165 """ 

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

167 # if we really have an INSTRUME header 

168 if "INSTRUME" in header: 

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

170 if via_instrume: 

171 return via_instrume 

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

173 return True 

174 return False 

175 

176 @cache_translation 

177 def to_exposure_id(self) -> int: 

178 """Calculate exposure ID. 

179 

180 Returns 

181 ------- 

182 id : `int` 

183 ID of exposure. 

184 """ 

185 value = self._header["EXPNUM"] 

186 self._used_these_cards("EXPNUM") 

187 return value 

188 

189 @cache_translation 

190 def to_observation_counter(self) -> int: 

191 """Return the lifetime exposure number. 

192 

193 Returns 

194 ------- 

195 sequence : `int` 

196 The observation counter. 

197 """ 

198 return self.to_exposure_id() 

199 

200 @cache_translation 

201 def to_visit_id(self) -> int: 

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

203 return self.to_exposure_id() 

204 

205 @cache_translation 

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

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

208 # Instcals have no DATE-END or DTUTC 

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

210 if datetime_end is None: 

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

212 return datetime_end 

213 

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

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

216 

217 Calibration products made with constructCalibs have some metadata 

218 saved in its FITS header CALIB_ID. 

219 """ 

220 data = self._header["CALIB_ID"] 

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

222 if not match: 

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

224 self._used_these_cards("CALIB_ID") 

225 return match.groups()[0] 

226 

227 @cache_translation 

228 def to_physical_filter(self) -> str | None: 

229 """Calculate physical filter. 

230 

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

232 which can happen for some valid Community Pipeline products. 

233 

234 Returns 

235 ------- 

236 filter : `str` 

237 The full filter name. 

238 """ 

239 if self.is_key_ok("FILTER"): 

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

241 self._used_these_cards("FILTER") 

242 return value 

243 elif self.is_key_ok("CALIB_ID"): 

244 return self._translate_from_calib_id("filter") 

245 else: 

246 return None 

247 

248 @cache_translation 

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

250 """Calculate the observatory location. 

251 

252 Returns 

253 ------- 

254 location : `astropy.coordinates.EarthLocation` 

255 An object representing the location of the telescope. 

256 """ 

257 

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

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

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

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

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

263 else: 

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

265 value = EarthLocation.of_site("ctio") 

266 

267 return value 

268 

269 @cache_translation 

270 def to_observation_type(self) -> str: 

271 """Calculate the observation type. 

272 

273 Returns 

274 ------- 

275 typ : `str` 

276 Observation type. Normalized to standard set. 

277 """ 

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

279 return "none" 

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

281 self._used_these_cards("OBSTYPE") 

282 if obstype == "object": 

283 return "science" 

284 return obstype 

285 

286 @cache_translation 

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

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

289 radecsys = ("RADESYS",) 

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

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

292 

293 @cache_translation 

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

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

296 return altaz_from_degree_headers(self, (("ZD", "AZ"),), self.to_datetime_begin(), is_zd={"ZD"}) 

297 

298 @cache_translation 

299 def to_detector_exposure_id(self) -> int | None: 

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

301 exposure_id = self.to_exposure_id() 

302 if exposure_id is None: 

303 return None 

304 return int(f"{exposure_id:07d}{self.to_detector_num():02d}") 

305 

306 @cache_translation 

307 def to_detector_group(self) -> str: 

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

309 name = self.to_detector_unique_name() 

310 return name[0] 

311 

312 @cache_translation 

313 def to_detector_name(self) -> str: 

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

315 name = self.to_detector_unique_name() 

316 return name[1:] 

317 

318 @cache_translation 

319 def to_focus_z(self) -> u.Quantity: 

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

321 # ``TELFOCUS`` is a comma-separated string with six focus offsets 

322 # (fx, fy, fz, tx, ty, tz) recorded in units of microns. 

323 tel_focus_list = self._header["TELFOCUS"].split(",") 

324 return float(tel_focus_list[2]) * u.um 

325 

326 @classmethod 

327 def fix_header( 

328 cls, header: MutableMapping[str, Any], instrument: str, obsid: str, filename: str | None = None 

329 ) -> bool: 

330 """Fix DECam headers. 

331 

332 Parameters 

333 ---------- 

334 header : `dict` 

335 The header to update. Updates are in place. 

336 instrument : `str` 

337 The name of the instrument. 

338 obsid : `str` 

339 Unique observation identifier associated with this header. 

340 Will always be provided. 

341 filename : `str`, optional 

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

343 can be fixed independently of any filename being known. 

344 

345 Returns 

346 ------- 

347 modified = `bool` 

348 Returns `True` if the header was updated. 

349 

350 Notes 

351 ----- 

352 Fixes the following issues: 

353 

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

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

356 

357 Corrections are reported as debug level log messages. 

358 """ 

359 modified = False 

360 

361 # Calculate the standard label to use for log messages 

362 log_label = cls._construct_log_prefix(obsid, filename) 

363 

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

365 

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

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

368 modified = True 

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

370 

371 return modified 

372 

373 @classmethod 

374 def determine_translatable_headers( 

375 cls, filename: str, primary: MutableMapping[str, Any] | None = None 

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

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

378 

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

380 each detector stored in a subsequent extension. DECam uses 

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

382 primary header. 

383 

384 Guide headers are not returned. 

385 

386 Parameters 

387 ---------- 

388 filename : `str` 

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

390 primary : `dict`-like, optional 

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

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

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

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

395 from the file. 

396 

397 Yields 

398 ------ 

399 headers : iterator of `dict`-like 

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

401 with the contents of each detector header. 

402 

403 Notes 

404 ----- 

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

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

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

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

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

410 translation. 

411 """ 

412 # Circular dependency so must defer import. 

413 from ..headers import merge_headers 

414 

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

416 # to a Dict so that mypy is happy. 

417 primary_hdr = primary if primary else {} 

418 

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

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

421 # as we go to each HDU. 

422 with fits.open(filename) as fits_file: 

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

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

425 first_pass = True 

426 

427 for hdu in fits_file: 

428 if first_pass: 

429 if not primary_hdr: 

430 primary_hdr = hdu.header 

431 first_pass = False 

432 continue 

433 

434 header = hdu.header 

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

436 continue 

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

438 continue 

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