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
« 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.
12"""Metadata translation code for DECam FITS headers."""
14from __future__ import annotations
16__all__ = ("DecamTranslator",)
18import logging
19import posixpath
20import re
21from collections.abc import Iterator, Mapping, MutableMapping
22from typing import TYPE_CHECKING, Any
24import astropy.time
25import astropy.units as u
26from astropy.coordinates import Angle, EarthLocation
27from astropy.io import fits
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
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
37log = logging.getLogger(__name__)
40class DecamTranslator(FitsTranslator):
41 """Metadata translator for DECam standard headers."""
43 name = "DECam"
44 """Name of this translation class"""
46 supported_instrument = "DECam"
47 """Supports the DECam instrument."""
49 default_resource_root = posixpath.join(CORRECTIONS_RESOURCE_ROOT, "DECam")
50 """Default resource path root to use to locate header correction files."""
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 }
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 }
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")
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 }
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.
157 Checks the INSTRUME and FILTER headers.
159 Parameters
160 ----------
161 header : `dict`-like
162 Header to convert to standardized form.
163 filename : `str`, optional
164 Name of file being translated.
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
182 @cache_translation
183 def to_exposure_id(self) -> int:
184 """Calculate exposure ID.
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
195 @cache_translation
196 def to_observation_counter(self) -> int:
197 """Return the lifetime exposure number.
199 Returns
200 -------
201 sequence : `int`
202 The observation counter.
203 """
204 return self.to_exposure_id()
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()
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
220 def _translate_from_calib_id(self, field: str) -> str:
221 """Fetch the ID from the CALIB_ID header.
223 Calibration products made with constructCalibs have some metadata
224 saved in its FITS header CALIB_ID.
226 Parameters
227 ----------
228 field : `str`
229 Field to extract from the ``CALIB_ID`` header.
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]
243 @cache_translation
244 def to_physical_filter(self) -> str | None:
245 """Calculate physical filter.
247 Return `None` if the keyword FILTER does not exist in the header,
248 which can happen for some valid Community Pipeline products.
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
264 @cache_translation
265 def to_location(self) -> astropy.coordinates.EarthLocation:
266 """Calculate the observatory location.
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")
282 return value
284 @cache_translation
285 def to_observation_type(self) -> str:
286 """Calculate the observation type.
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
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))
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"})
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}")
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]
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:]
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
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.
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.
360 Returns
361 -------
362 modified = `bool`
363 Returns `True` if the header was updated.
365 Notes
366 -----
367 Fixes the following issues:
369 * If OBSTYPE contains "zero" or "bias",
370 update the FILTER keyword to "solid plate 0.0 0.0".
372 Corrections are reported as debug level log messages.
373 """
374 modified = False
376 # Calculate the standard label to use for log messages
377 log_label = cls._construct_log_prefix(obsid, filename)
379 obstype = header.get("OBSTYPE", "unknown")
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)
386 return modified
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.
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.
399 Guide headers are not returned.
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.
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.
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
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 {}
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
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
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")
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.
460 Parameters
461 ----------
462 observing_date : `astropy.time.Time`
463 The date of the observation. Unused.
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