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

145 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-28 02:59 -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.time 

25import astropy.units as u 

26from astropy.coordinates import Angle, EarthLocation 

27from astropy.io import fits 

28 

29from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation 

30from .fits import FitsTranslator 

31from .helpers import altaz_from_degree_headers, is_non_science, tracking_from_degree_headers 

32 

33if TYPE_CHECKING: 

34 import astropy.coordinates 

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 # DECam has no formal concept of an observing day. To ensure that 

79 # observations from a single night all have the same observing_day, adopt 

80 # the same offset used by the Vera Rubin Observatory of 12 hours. 

81 _observing_day_offset = astropy.time.TimeDelta(12 * 3600, format="sec", scale="tai") 

82 

83 # List from Frank Valdes (2024-03-21). 

84 _sky_observation_types: tuple[str, ...] = ("science", "object", "standard", "sky flat") 

85 _non_sky_observation_types: tuple[str, ...] = ("zero", "dark", "dome flat") 

86 

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

88 # header. 

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

90 # to the number in that group. 

91 detector_names = { 

92 1: "S29", 

93 2: "S30", 

94 3: "S31", 

95 4: "S25", 

96 5: "S26", 

97 6: "S27", 

98 7: "S28", 

99 8: "S20", 

100 9: "S21", 

101 10: "S22", 

102 11: "S23", 

103 12: "S24", 

104 13: "S14", 

105 14: "S15", 

106 15: "S16", 

107 16: "S17", 

108 17: "S18", 

109 18: "S19", 

110 19: "S8", 

111 20: "S9", 

112 21: "S10", 

113 22: "S11", 

114 23: "S12", 

115 24: "S13", 

116 25: "S1", 

117 26: "S2", 

118 27: "S3", 

119 28: "S4", 

120 29: "S5", 

121 30: "S6", 

122 31: "S7", 

123 32: "N1", 

124 33: "N2", 

125 34: "N3", 

126 35: "N4", 

127 36: "N5", 

128 37: "N6", 

129 38: "N7", 

130 39: "N8", 

131 40: "N9", 

132 41: "N10", 

133 42: "N11", 

134 43: "N12", 

135 44: "N13", 

136 45: "N14", 

137 46: "N15", 

138 47: "N16", 

139 48: "N17", 

140 49: "N18", 

141 50: "N19", 

142 51: "N20", 

143 52: "N21", 

144 53: "N22", 

145 54: "N23", 

146 55: "N24", 

147 56: "N25", 

148 57: "N26", 

149 58: "N27", 

150 59: "N28", 

151 60: "N29", 

152 62: "N31", 

153 } 

154 

155 @classmethod 

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

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

158 supplied header. 

159 

160 Checks the INSTRUME and FILTER headers. 

161 

162 Parameters 

163 ---------- 

164 header : `dict`-like 

165 Header to convert to standardized form. 

166 filename : `str`, optional 

167 Name of file being translated. 

168 

169 Returns 

170 ------- 

171 can : `bool` 

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

173 otherwise. 

174 """ 

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

176 # if we really have an INSTRUME header 

177 if "INSTRUME" in header: 

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

179 if via_instrume: 

180 return via_instrume 

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

182 return True 

183 return False 

184 

185 @cache_translation 

186 def to_exposure_id(self) -> int: 

187 """Calculate exposure ID. 

188 

189 Returns 

190 ------- 

191 id : `int` 

192 ID of exposure. 

193 """ 

194 value = self._header["EXPNUM"] 

195 self._used_these_cards("EXPNUM") 

196 return value 

197 

198 @cache_translation 

199 def to_observation_counter(self) -> int: 

200 """Return the lifetime exposure number. 

201 

202 Returns 

203 ------- 

204 sequence : `int` 

205 The observation counter. 

206 """ 

207 return self.to_exposure_id() 

208 

209 @cache_translation 

210 def to_visit_id(self) -> int: 

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

212 return self.to_exposure_id() 

213 

214 @cache_translation 

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

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

217 # Instcals have no DATE-END or DTUTC 

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

219 if datetime_end is None: 

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

221 return datetime_end 

222 

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

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

225 

226 Calibration products made with constructCalibs have some metadata 

227 saved in its FITS header CALIB_ID. 

228 

229 Parameters 

230 ---------- 

231 field : `str` 

232 Field to extract from the ``CALIB_ID`` header. 

233 

234 Returns 

235 ------- 

236 value : `str` 

237 The value extracted from the calibration header for that field. 

238 """ 

239 data = self._header["CALIB_ID"] 

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

241 if not match: 

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

243 self._used_these_cards("CALIB_ID") 

244 return match.groups()[0] 

245 

246 @cache_translation 

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

248 """Calculate physical filter. 

249 

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

251 which can happen for some valid Community Pipeline products. 

252 

253 Returns 

254 ------- 

255 filter : `str` 

256 The full filter name. 

257 """ 

258 if self.is_key_ok("FILTER"): 

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

260 self._used_these_cards("FILTER") 

261 return value 

262 elif self.is_key_ok("CALIB_ID"): 

263 return self._translate_from_calib_id("filter") 

264 else: 

265 return None 

266 

267 @cache_translation 

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

269 """Calculate the observatory location. 

270 

271 Returns 

272 ------- 

273 location : `astropy.coordinates.EarthLocation` 

274 An object representing the location of the telescope. 

275 """ 

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

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

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

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

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

281 else: 

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

283 value = EarthLocation.of_site("ctio") 

284 

285 return value 

286 

287 @cache_translation 

288 def to_observation_type(self) -> str: 

289 """Calculate the observation type. 

290 

291 Returns 

292 ------- 

293 typ : `str` 

294 Observation type. Normalized to standard set. 

295 """ 

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

297 return "none" 

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

299 self._used_these_cards("OBSTYPE") 

300 if obstype == "object": 

301 return "science" 

302 return obstype 

303 

304 @cache_translation 

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

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

307 radecsys = ("RADESYS",) 

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

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

310 

311 @cache_translation 

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

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

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

315 

316 @cache_translation 

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

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

319 exposure_id = self.to_exposure_id() 

320 if exposure_id is None: 

321 return None 

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

323 

324 @cache_translation 

325 def to_detector_group(self) -> str: 

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

327 name = self.to_detector_unique_name() 

328 return name[0] 

329 

330 @cache_translation 

331 def to_detector_name(self) -> str: 

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

333 name = self.to_detector_unique_name() 

334 return name[1:] 

335 

336 @cache_translation 

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

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

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

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

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

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

343 

344 @classmethod 

345 def fix_header( 

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

347 ) -> bool: 

348 """Fix DECam headers. 

349 

350 Parameters 

351 ---------- 

352 header : `dict` 

353 The header to update. Updates are in place. 

354 instrument : `str` 

355 The name of the instrument. 

356 obsid : `str` 

357 Unique observation identifier associated with this header. 

358 Will always be provided. 

359 filename : `str`, optional 

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

361 can be fixed independently of any filename being known. 

362 

363 Returns 

364 ------- 

365 modified = `bool` 

366 Returns `True` if the header was updated. 

367 

368 Notes 

369 ----- 

370 Fixes the following issues: 

371 

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

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

374 

375 Corrections are reported as debug level log messages. 

376 """ 

377 modified = False 

378 

379 # Calculate the standard label to use for log messages 

380 log_label = cls._construct_log_prefix(obsid, filename) 

381 

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

383 

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

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

386 modified = True 

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

388 

389 return modified 

390 

391 @classmethod 

392 def determine_translatable_headers( 

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

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

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

396 

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

398 each detector stored in a subsequent extension. DECam uses 

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

400 primary header. 

401 

402 Guide headers are not returned. 

403 

404 Parameters 

405 ---------- 

406 filename : `str` 

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

408 primary : `dict`-like, optional 

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

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

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

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

413 from the file. 

414 

415 Yields 

416 ------ 

417 headers : iterator of `dict`-like 

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

419 with the contents of each detector header. 

420 

421 Notes 

422 ----- 

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

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

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

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

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

428 translation. 

429 """ 

430 # Circular dependency so must defer import. 

431 from ..headers import merge_headers 

432 

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

434 # to a Dict so that mypy is happy. 

435 primary_hdr = primary if primary else {} 

436 

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

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

439 # as we go to each HDU. 

440 with fits.open(filename) as fits_file: 

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

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

443 first_pass = True 

444 

445 for hdu in fits_file: 

446 if first_pass: 

447 if not primary_hdr: 

448 primary_hdr = hdu.header 

449 first_pass = False 

450 continue 

451 

452 header = hdu.header 

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

454 continue 

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

456 continue 

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

458 

459 @classmethod 

460 def observing_date_to_offset(cls, observing_date: astropy.time.Time) -> astropy.time.TimeDelta | None: 

461 """Return the offset to use when calculating the observing day. 

462 

463 Parameters 

464 ---------- 

465 observing_date : `astropy.time.Time` 

466 The date of the observation. Unused. 

467 

468 Returns 

469 ------- 

470 offset : `astropy.time.TimeDelta` 

471 The offset to apply. The offset is always 12 hours. DECam has 

472 no defined observing day concept in its headers. To ensure that 

473 observations from a single night all have the same observing_day, 

474 adopt the same offset used by the Vera Rubin Observatory of 

475 12 hours. 

476 """ 

477 return cls._observing_day_offset