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

161 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:38 +0000

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, UnknownSiteException 

27from astropy.io import fits 

28from lsst.resources import ResourcePath 

29 

30from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation 

31from .fits import FitsTranslator 

32from .helpers import altaz_from_degree_headers, is_non_science, tracking_from_degree_headers 

33 

34if TYPE_CHECKING: 

35 import astropy.coordinates 

36 from lsst.resources import ResourcePathExpression 

37 

38log = logging.getLogger(__name__) 

39 

40_CTIO_FALLBACK_LOCATION = EarthLocation.from_geocentric( 

41 1814303.74553723, -5214365.7436216, -3187340.56598756, unit=u.m 

42) 

43 

44 

45class DecamTranslator(FitsTranslator): 

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

47 

48 name = "DECam" 

49 """Name of this translation class""" 

50 

51 supported_instrument = "DECam" 

52 """Supports the DECam instrument.""" 

53 

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

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

56 

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

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

59 _const_map = { 

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

61 "boresight_rotation_coord": "sky", 

62 } 

63 

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

65 "exposure_time": ("EXPTIME", {"unit": u.s}), 

66 "exposure_time_requested": ("EXPREQ", {"unit": u.s}), 

67 "dark_time": ("DARKTIME", {"unit": u.s}), 

68 "boresight_airmass": ("AIRMASS", {"checker": is_non_science}), 

69 "observation_id": "OBSID", 

70 "object": "OBJECT", 

71 "science_program": "PROPID", 

72 "detector_num": "CCDNUM", 

73 "detector_serial": "DETECTOR", 

74 "detector_unique_name": "DETPOS", 

75 "telescope": ("TELESCOP", {"default": "CTIO 4.0-m telescope"}), 

76 "instrument": ("INSTRUME", {"default": "DECam"}), 

77 # Ensure that reasonable values are always available 

78 "relative_humidity": ("HUMIDITY", {"default": 40.0, "minimum": 0, "maximum": 100.0}), 

79 "temperature": ("OUTTEMP", {"unit": u.deg_C, "default": 10.0, "minimum": -10.0, "maximum": 40.0}), 

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

81 # which is the SI equivalent of mbar. 

82 "pressure": ("PRESSURE", {"unit": u.hPa, "default": 771.611, "minimum": 700.0, "maximum": 850.0}), 

83 } 

84 

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

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

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

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

89 

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

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

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

93 

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

95 # header. 

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

97 # to the number in that group. 

98 detector_names = { 

99 1: "S29", 

100 2: "S30", 

101 3: "S31", 

102 4: "S25", 

103 5: "S26", 

104 6: "S27", 

105 7: "S28", 

106 8: "S20", 

107 9: "S21", 

108 10: "S22", 

109 11: "S23", 

110 12: "S24", 

111 13: "S14", 

112 14: "S15", 

113 15: "S16", 

114 16: "S17", 

115 17: "S18", 

116 18: "S19", 

117 19: "S8", 

118 20: "S9", 

119 21: "S10", 

120 22: "S11", 

121 23: "S12", 

122 24: "S13", 

123 25: "S1", 

124 26: "S2", 

125 27: "S3", 

126 28: "S4", 

127 29: "S5", 

128 30: "S6", 

129 31: "S7", 

130 32: "N1", 

131 33: "N2", 

132 34: "N3", 

133 35: "N4", 

134 36: "N5", 

135 37: "N6", 

136 38: "N7", 

137 39: "N8", 

138 40: "N9", 

139 41: "N10", 

140 42: "N11", 

141 43: "N12", 

142 44: "N13", 

143 45: "N14", 

144 46: "N15", 

145 47: "N16", 

146 48: "N17", 

147 49: "N18", 

148 50: "N19", 

149 51: "N20", 

150 52: "N21", 

151 53: "N22", 

152 54: "N23", 

153 55: "N24", 

154 56: "N25", 

155 57: "N26", 

156 58: "N27", 

157 59: "N28", 

158 60: "N29", 

159 62: "N31", 

160 } 

161 

162 @classmethod 

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

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

165 supplied header. 

166 

167 Checks the INSTRUME and FILTER headers. 

168 

169 Parameters 

170 ---------- 

171 header : `dict`-like 

172 Header to convert to standardized form. 

173 filename : `str`, optional 

174 Name of file being translated. 

175 

176 Returns 

177 ------- 

178 can : `bool` 

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

180 otherwise. 

181 """ 

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

183 # if we really have an INSTRUME header 

184 if "INSTRUME" in header: 

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

186 if via_instrume: 

187 return via_instrume 

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

189 return True 

190 return False 

191 

192 @cache_translation 

193 def to_exposure_id(self) -> int: 

194 """Calculate exposure ID. 

195 

196 Returns 

197 ------- 

198 id : `int` 

199 ID of exposure. 

200 """ 

201 value = self._header["EXPNUM"] 

202 self._used_these_cards("EXPNUM") 

203 return value 

204 

205 @cache_translation 

206 def to_observation_counter(self) -> int: 

207 """Return the lifetime exposure number. 

208 

209 Returns 

210 ------- 

211 sequence : `int` 

212 The observation counter. 

213 """ 

214 return self.to_exposure_id() 

215 

216 @cache_translation 

217 def to_visit_id(self) -> int: 

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

219 return self.to_exposure_id() 

220 

221 @cache_translation 

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

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

224 # Instcals have no DATE-END or DTUTC 

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

226 if datetime_end is None: 

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

228 return datetime_end 

229 

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

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

232 

233 Calibration products made with constructCalibs have some metadata 

234 saved in its FITS header CALIB_ID. 

235 

236 Parameters 

237 ---------- 

238 field : `str` 

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

240 

241 Returns 

242 ------- 

243 value : `str` 

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

245 """ 

246 data = self._header["CALIB_ID"] 

247 match = re.search(rf".*{field}=(\S+)", data) 

248 if not match: 

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

250 self._used_these_cards("CALIB_ID") 

251 return match.groups()[0] 

252 

253 @cache_translation 

254 def to_physical_filter(self) -> str: 

255 """Calculate physical filter. 

256 

257 Returns 

258 ------- 

259 filter : `str` 

260 The full filter name. 

261 """ 

262 if self.is_key_ok("FILTER"): 

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

264 self._used_these_cards("FILTER") 

265 return value 

266 raise KeyError(f"{self._log_prefix}: Unable to find FILTER keyword in header") 

267 

268 @cache_translation 

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

270 """Calculate the observatory location. 

271 

272 Returns 

273 ------- 

274 location : `astropy.coordinates.EarthLocation` 

275 An object representing the location of the telescope. 

276 """ 

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

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

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

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

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

282 else: 

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

284 try: 

285 value = EarthLocation.of_site("ctio") 

286 except UnknownSiteException: 

287 value = _CTIO_FALLBACK_LOCATION 

288 

289 return value 

290 

291 @cache_translation 

292 def to_observation_type(self) -> str: 

293 """Calculate the observation type. 

294 

295 Returns 

296 ------- 

297 typ : `str` 

298 Observation type. Normalized to standard set. 

299 """ 

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

301 return "none" 

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

303 self._used_these_cards("OBSTYPE") 

304 if obstype == "object": 

305 return "science" 

306 return obstype 

307 

308 @cache_translation 

309 def to_tracking_radec(self) -> astropy.coordinates.SkyCoord | None: 

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

311 radecsys = ("RADESYS",) 

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

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

314 

315 @cache_translation 

316 def to_altaz_begin(self) -> astropy.coordinates.AltAz | None: 

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

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

319 

320 @cache_translation 

321 def to_detector_exposure_id(self) -> int: 

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

323 exposure_id = self.to_exposure_id() 

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

325 

326 @cache_translation 

327 def to_detector_group(self) -> str: 

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

329 name = self.to_detector_unique_name() 

330 return name[0] 

331 

332 @cache_translation 

333 def to_detector_name(self) -> str: 

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

335 name = self.to_detector_unique_name() 

336 return name[1:] 

337 

338 @cache_translation 

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

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

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

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

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

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

345 

346 @classmethod 

347 def fix_header( 

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

349 ) -> bool: 

350 """Fix DECam headers. 

351 

352 Parameters 

353 ---------- 

354 header : `dict` 

355 The header to update. Updates are in place. 

356 instrument : `str` 

357 The name of the instrument. 

358 obsid : `str` 

359 Unique observation identifier associated with this header. 

360 Will always be provided. 

361 filename : `str`, optional 

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

363 can be fixed independently of any filename being known. 

364 

365 Returns 

366 ------- 

367 modified : `bool` 

368 Returns `True` if the header was updated. 

369 

370 Notes 

371 ----- 

372 Fixes the following issues: 

373 

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

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

376 

377 Corrections are reported as debug level log messages. 

378 """ 

379 modified = False 

380 

381 # Calculate the standard label to use for log messages 

382 log_label = cls._construct_log_prefix(obsid, filename) 

383 

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

385 

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

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

388 modified = True 

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

390 

391 # The following provides corrections to the boresight header info for 

392 # the raw data files obtained from http://astroarchive.noirlab.edu/, 

393 # program number 2013A-0351, of the Trifid and Lagoon Nebulae region. 

394 if "Trifid" in header.get("OBJECT", "unknown") and obstype == "object": 

395 # Create a translator since we need the date 

396 translator = cls(header) 

397 date_obs = translator.to_datetime_begin() 

398 date_min = astropy.time.Time("2013-02-10", format="isot", scale="utc") 

399 date_max = astropy.time.Time("2013-02-14", format="isot", scale="utc") 

400 if date_obs > date_min and date_obs < date_max: 

401 # Median differences (in deg) between the raw header 

402 # RA/DEC values & the boresight values obtianed from 

403 # https://astroarchive.noirlab.edu for these data. 

404 delta_ra = -0.027417 

405 delta_dec = -0.002833 

406 ra_hour = Angle(header["RA"], unit="hour") 

407 dec_deg = Angle(header["DEC"], unit="degree") 

408 header["TELRA"] = (ra_hour + delta_ra * u.degree).hour 

409 header["TELDEC"] = (dec_deg + delta_dec * u.degree).degree 

410 modified = True 

411 log.debug( 

412 "Found Trifid in OBJECT header. Changing TELRA and TELDEC headers to " 

413 "RA + %.5f deg and DEC + %.5f deg, respectively.", 

414 delta_ra, 

415 delta_dec, 

416 ) 

417 

418 return modified 

419 

420 @classmethod 

421 def determine_translatable_headers( 

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

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

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

425 

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

427 each detector stored in a subsequent extension. DECam uses 

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

429 primary header. 

430 

431 Guide headers are not returned. 

432 

433 Parameters 

434 ---------- 

435 filename : `str` or `lsst.resources.ResourcePathExpression` 

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

437 primary : `dict`-like, optional 

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

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

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

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

442 from the file. 

443 

444 Yields 

445 ------ 

446 headers : iterator of `dict`-like 

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

448 with the contents of each detector header. 

449 

450 Notes 

451 ----- 

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

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

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

455 `~astro_metadata_translator.MetadataTranslator.determine_translator` on 

456 the result to work out which translator class to then call to obtain 

457 the real headers to be used for translation. 

458 """ 

459 # Circular dependency so must defer import. 

460 from ..headers import merge_headers 

461 

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

463 # to a Dict so that mypy is happy. 

464 primary_hdr = primary if primary else {} 

465 

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

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

468 # as we go to each HDU. 

469 uri = ResourcePath(filename, forceDirectory=False) 

470 fs, fspath = uri.to_fsspec() 

471 with fs.open(fspath) as f, fits.open(f) as fits_file: 

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

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

474 first_pass = True 

475 

476 for hdu in fits_file: 

477 if first_pass: 

478 if not primary_hdr: 

479 primary_hdr = hdu.header 

480 first_pass = False 

481 continue 

482 

483 header = hdu.header 

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

485 continue 

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

487 continue 

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

489 

490 @classmethod 

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

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

493 

494 Parameters 

495 ---------- 

496 observing_date : `astropy.time.Time` 

497 The date of the observation. Unused. 

498 

499 Returns 

500 ------- 

501 offset : `astropy.time.TimeDelta` 

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

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

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

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

506 12 hours. 

507 """ 

508 return cls._observing_day_offset