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

146 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-20 03:54 -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: 33 ↛ 34line 33 didn't jump to line 34, because the condition on line 33 was never true

34 import astropy.coordinates 

35 import astropy.time 

36 

37log = logging.getLogger(__name__) 

38 

39 

40class DecamTranslator(FitsTranslator): 

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

42 

43 name = "DECam" 

44 """Name of this translation class""" 

45 

46 supported_instrument = "DECam" 

47 """Supports the DECam instrument.""" 

48 

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

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

51 

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

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

54 _const_map = { 

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

56 "boresight_rotation_coord": "sky", 

57 } 

58 

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

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

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

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

63 "observation_id": "OBSID", 

64 "object": "OBJECT", 

65 "science_program": "PROPID", 

66 "detector_num": "CCDNUM", 

67 "detector_serial": "DETECTOR", 

68 "detector_unique_name": "DETPOS", 

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

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

71 # Ensure that reasonable values are always available 

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

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

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

75 # which is the SI equivalent of mbar. 

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

77 } 

78 

79 # DECam has no formal concept of an observing day. To ensure that 

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

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

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

83 

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

85 # header. 

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

87 # to the number in that group. 

88 detector_names = { 

89 1: "S29", 

90 2: "S30", 

91 3: "S31", 

92 4: "S25", 

93 5: "S26", 

94 6: "S27", 

95 7: "S28", 

96 8: "S20", 

97 9: "S21", 

98 10: "S22", 

99 11: "S23", 

100 12: "S24", 

101 13: "S14", 

102 14: "S15", 

103 15: "S16", 

104 16: "S17", 

105 17: "S18", 

106 18: "S19", 

107 19: "S8", 

108 20: "S9", 

109 21: "S10", 

110 22: "S11", 

111 23: "S12", 

112 24: "S13", 

113 25: "S1", 

114 26: "S2", 

115 27: "S3", 

116 28: "S4", 

117 29: "S5", 

118 30: "S6", 

119 31: "S7", 

120 32: "N1", 

121 33: "N2", 

122 34: "N3", 

123 35: "N4", 

124 36: "N5", 

125 37: "N6", 

126 38: "N7", 

127 39: "N8", 

128 40: "N9", 

129 41: "N10", 

130 42: "N11", 

131 43: "N12", 

132 44: "N13", 

133 45: "N14", 

134 46: "N15", 

135 47: "N16", 

136 48: "N17", 

137 49: "N18", 

138 50: "N19", 

139 51: "N20", 

140 52: "N21", 

141 53: "N22", 

142 54: "N23", 

143 55: "N24", 

144 56: "N25", 

145 57: "N26", 

146 58: "N27", 

147 59: "N28", 

148 60: "N29", 

149 62: "N31", 

150 } 

151 

152 @classmethod 

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

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

155 supplied header. 

156 

157 Checks the INSTRUME and FILTER headers. 

158 

159 Parameters 

160 ---------- 

161 header : `dict`-like 

162 Header to convert to standardized form. 

163 filename : `str`, optional 

164 Name of file being translated. 

165 

166 Returns 

167 ------- 

168 can : `bool` 

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

170 otherwise. 

171 """ 

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

173 # if we really have an INSTRUME header 

174 if "INSTRUME" in header: 

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

176 if via_instrume: 

177 return via_instrume 

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

179 return True 

180 return False 

181 

182 @cache_translation 

183 def to_exposure_id(self) -> int: 

184 """Calculate exposure ID. 

185 

186 Returns 

187 ------- 

188 id : `int` 

189 ID of exposure. 

190 """ 

191 value = self._header["EXPNUM"] 

192 self._used_these_cards("EXPNUM") 

193 return value 

194 

195 @cache_translation 

196 def to_observation_counter(self) -> int: 

197 """Return the lifetime exposure number. 

198 

199 Returns 

200 ------- 

201 sequence : `int` 

202 The observation counter. 

203 """ 

204 return self.to_exposure_id() 

205 

206 @cache_translation 

207 def to_visit_id(self) -> int: 

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

209 return self.to_exposure_id() 

210 

211 @cache_translation 

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

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

214 # Instcals have no DATE-END or DTUTC 

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

216 if datetime_end is None: 

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

218 return datetime_end 

219 

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

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

222 

223 Calibration products made with constructCalibs have some metadata 

224 saved in its FITS header CALIB_ID. 

225 

226 Parameters 

227 ---------- 

228 field : `str` 

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

230 

231 Returns 

232 ------- 

233 value : `str` 

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

235 """ 

236 data = self._header["CALIB_ID"] 

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

238 if not match: 

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

240 self._used_these_cards("CALIB_ID") 

241 return match.groups()[0] 

242 

243 @cache_translation 

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

245 """Calculate physical filter. 

246 

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

248 which can happen for some valid Community Pipeline products. 

249 

250 Returns 

251 ------- 

252 filter : `str` 

253 The full filter name. 

254 """ 

255 if self.is_key_ok("FILTER"): 

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

257 self._used_these_cards("FILTER") 

258 return value 

259 elif self.is_key_ok("CALIB_ID"): 

260 return self._translate_from_calib_id("filter") 

261 else: 

262 return None 

263 

264 @cache_translation 

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

266 """Calculate the observatory location. 

267 

268 Returns 

269 ------- 

270 location : `astropy.coordinates.EarthLocation` 

271 An object representing the location of the telescope. 

272 """ 

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

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

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

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

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

278 else: 

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

280 value = EarthLocation.of_site("ctio") 

281 

282 return value 

283 

284 @cache_translation 

285 def to_observation_type(self) -> str: 

286 """Calculate the observation type. 

287 

288 Returns 

289 ------- 

290 typ : `str` 

291 Observation type. Normalized to standard set. 

292 """ 

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

294 return "none" 

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

296 self._used_these_cards("OBSTYPE") 

297 if obstype == "object": 

298 return "science" 

299 return obstype 

300 

301 @cache_translation 

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

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

304 radecsys = ("RADESYS",) 

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

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

307 

308 @cache_translation 

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

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

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

312 

313 @cache_translation 

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

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

316 exposure_id = self.to_exposure_id() 

317 if exposure_id is None: 

318 return None 

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

320 

321 @cache_translation 

322 def to_detector_group(self) -> str: 

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

324 name = self.to_detector_unique_name() 

325 return name[0] 

326 

327 @cache_translation 

328 def to_detector_name(self) -> str: 

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

330 name = self.to_detector_unique_name() 

331 return name[1:] 

332 

333 @cache_translation 

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

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

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

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

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

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

340 

341 @classmethod 

342 def fix_header( 

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

344 ) -> bool: 

345 """Fix DECam headers. 

346 

347 Parameters 

348 ---------- 

349 header : `dict` 

350 The header to update. Updates are in place. 

351 instrument : `str` 

352 The name of the instrument. 

353 obsid : `str` 

354 Unique observation identifier associated with this header. 

355 Will always be provided. 

356 filename : `str`, optional 

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

358 can be fixed independently of any filename being known. 

359 

360 Returns 

361 ------- 

362 modified = `bool` 

363 Returns `True` if the header was updated. 

364 

365 Notes 

366 ----- 

367 Fixes the following issues: 

368 

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

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

371 

372 Corrections are reported as debug level log messages. 

373 """ 

374 modified = False 

375 

376 # Calculate the standard label to use for log messages 

377 log_label = cls._construct_log_prefix(obsid, filename) 

378 

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

380 

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

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

383 modified = True 

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

385 

386 return modified 

387 

388 @classmethod 

389 def determine_translatable_headers( 

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

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

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

393 

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

395 each detector stored in a subsequent extension. DECam uses 

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

397 primary header. 

398 

399 Guide headers are not returned. 

400 

401 Parameters 

402 ---------- 

403 filename : `str` 

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

405 primary : `dict`-like, optional 

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

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

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

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

410 from the file. 

411 

412 Yields 

413 ------ 

414 headers : iterator of `dict`-like 

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

416 with the contents of each detector header. 

417 

418 Notes 

419 ----- 

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

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

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

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

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

425 translation. 

426 """ 

427 # Circular dependency so must defer import. 

428 from ..headers import merge_headers 

429 

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

431 # to a Dict so that mypy is happy. 

432 primary_hdr = primary if primary else {} 

433 

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

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

436 # as we go to each HDU. 

437 with fits.open(filename) as fits_file: 

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

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

440 first_pass = True 

441 

442 for hdu in fits_file: 

443 if first_pass: 

444 if not primary_hdr: 

445 primary_hdr = hdu.header 

446 first_pass = False 

447 continue 

448 

449 header = hdu.header 

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

451 continue 

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

453 continue 

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

455 

456 @classmethod 

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

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

459 

460 Parameters 

461 ---------- 

462 observing_date : `astropy.time.Time` 

463 The date of the observation. Unused. 

464 

465 Returns 

466 ------- 

467 offset : `astropy.time.TimeDelta` 

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

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

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

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

472 12 hours. 

473 """ 

474 return cls._observing_day_offset