Coverage for python/astro_metadata_translator/translators/decam.py: 33%
140 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-07 01:03 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-07 01:03 -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 typing import TYPE_CHECKING, Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, Union
23import astropy.units as u
24from astropy.coordinates import Angle, EarthLocation
25from astropy.io import fits
27from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation
28from .fits import FitsTranslator
29from .helpers import altaz_from_degree_headers, is_non_science, tracking_from_degree_headers
31if TYPE_CHECKING: 31 ↛ 32line 31 didn't jump to line 32, because the condition on line 31 was never true
32 import astropy.coordinates
33 import astropy.time
35log = logging.getLogger(__name__)
38class DecamTranslator(FitsTranslator):
39 """Metadata translator for DECam standard headers."""
41 name = "DECam"
42 """Name of this translation class"""
44 supported_instrument = "DECam"
45 """Supports the DECam instrument."""
47 default_resource_root = posixpath.join(CORRECTIONS_RESOURCE_ROOT, "DECam")
48 """Default resource path root to use to locate header correction files."""
50 # DECam has no rotator, and the instrument angle on sky is set to +Y=East,
51 # +X=South which we define as a 90 degree rotation and an X-flip.
52 _const_map = {
53 "boresight_rotation_angle": Angle(90 * u.deg),
54 "boresight_rotation_coord": "sky",
55 }
57 _trivial_map: Dict[str, Union[str, List[str], Tuple[Any, ...]]] = {
58 "exposure_time": ("EXPTIME", dict(unit=u.s)),
59 "dark_time": ("DARKTIME", dict(unit=u.s)),
60 "boresight_airmass": ("AIRMASS", dict(checker=is_non_science)),
61 "observation_id": "OBSID",
62 "object": "OBJECT",
63 "science_program": "PROPID",
64 "detector_num": "CCDNUM",
65 "detector_serial": "DETECTOR",
66 "detector_unique_name": "DETPOS",
67 "telescope": ("TELESCOP", dict(default="CTIO 4.0-m telescope")),
68 "instrument": ("INSTRUME", dict(default="DECam")),
69 # Ensure that reasonable values are always available
70 "relative_humidity": ("HUMIDITY", dict(default=40.0, minimum=0, maximum=100.0)),
71 "temperature": ("OUTTEMP", dict(unit=u.deg_C, default=10.0, minimum=-10.0, maximum=40.0)),
72 # Header says torr but seems to be mbar. Use hPa unit
73 # which is the SI equivalent of mbar.
74 "pressure": ("PRESSURE", dict(unit=u.hPa, default=771.611, minimum=700.0, maximum=850.0)),
75 }
77 # Unique detector names are currently not used but are read directly from
78 # header.
79 # The detector_group could be N or S with detector_name corresponding
80 # to the number in that group.
81 detector_names = {
82 1: "S29",
83 2: "S30",
84 3: "S31",
85 4: "S25",
86 5: "S26",
87 6: "S27",
88 7: "S28",
89 8: "S20",
90 9: "S21",
91 10: "S22",
92 11: "S23",
93 12: "S24",
94 13: "S14",
95 14: "S15",
96 15: "S16",
97 16: "S17",
98 17: "S18",
99 18: "S19",
100 19: "S8",
101 20: "S9",
102 21: "S10",
103 22: "S11",
104 23: "S12",
105 24: "S13",
106 25: "S1",
107 26: "S2",
108 27: "S3",
109 28: "S4",
110 29: "S5",
111 30: "S6",
112 31: "S7",
113 32: "N1",
114 33: "N2",
115 34: "N3",
116 35: "N4",
117 36: "N5",
118 37: "N6",
119 38: "N7",
120 39: "N8",
121 40: "N9",
122 41: "N10",
123 42: "N11",
124 43: "N12",
125 44: "N13",
126 45: "N14",
127 46: "N15",
128 47: "N16",
129 48: "N17",
130 49: "N18",
131 50: "N19",
132 51: "N20",
133 52: "N21",
134 53: "N22",
135 54: "N23",
136 55: "N24",
137 56: "N25",
138 57: "N26",
139 58: "N27",
140 59: "N28",
141 60: "N29",
142 62: "N31",
143 }
145 @classmethod
146 def can_translate(cls, header: MutableMapping[str, Any], filename: Optional[str] = None) -> bool:
147 """Indicate whether this translation class can translate the
148 supplied header.
150 Checks the INSTRUME and FILTER headers.
152 Parameters
153 ----------
154 header : `dict`-like
155 Header to convert to standardized form.
156 filename : `str`, optional
157 Name of file being translated.
159 Returns
160 -------
161 can : `bool`
162 `True` if the header is recognized by this class. `False`
163 otherwise.
164 """
165 # Use INSTRUME. Because of defaulting behavior only do this
166 # if we really have an INSTRUME header
167 if "INSTRUME" in header:
168 via_instrume = super().can_translate(header, filename=filename)
169 if via_instrume:
170 return via_instrume
171 if cls.is_keyword_defined(header, "FILTER") and "DECam" in header["FILTER"]:
172 return True
173 return False
175 @cache_translation
176 def to_exposure_id(self) -> int:
177 """Calculate exposure ID.
179 Returns
180 -------
181 id : `int`
182 ID of exposure.
183 """
184 value = self._header["EXPNUM"]
185 self._used_these_cards("EXPNUM")
186 return value
188 @cache_translation
189 def to_observation_counter(self) -> int:
190 """Return the lifetime exposure number.
192 Returns
193 -------
194 sequence : `int`
195 The observation counter.
196 """
197 return self.to_exposure_id()
199 @cache_translation
200 def to_visit_id(self) -> int:
201 # Docstring will be inherited. Property defined in properties.py
202 return self.to_exposure_id()
204 @cache_translation
205 def to_datetime_end(self) -> astropy.time.Time:
206 # Docstring will be inherited. Property defined in properties.py
207 # Instcals have no DATE-END or DTUTC
208 datetime_end = self._from_fits_date("DTUTC", scale="utc")
209 if datetime_end is None:
210 datetime_end = self.to_datetime_begin() + self.to_exposure_time()
211 return datetime_end
213 def _translate_from_calib_id(self, field: str) -> str:
214 """Fetch the ID from the CALIB_ID header.
216 Calibration products made with constructCalibs have some metadata
217 saved in its FITS header CALIB_ID.
218 """
219 data = self._header["CALIB_ID"]
220 match = re.search(r".*%s=(\S+)" % field, data)
221 if not match:
222 raise RuntimeError(f"Header CALIB_ID with value '{data}' has not field '{field}'")
223 self._used_these_cards("CALIB_ID")
224 return match.groups()[0]
226 @cache_translation
227 def to_physical_filter(self) -> Optional[str]:
228 """Calculate physical filter.
230 Return `None` if the keyword FILTER does not exist in the header,
231 which can happen for some valid Community Pipeline products.
233 Returns
234 -------
235 filter : `str`
236 The full filter name.
237 """
238 if self.is_key_ok("FILTER"):
239 value = self._header["FILTER"].strip()
240 self._used_these_cards("FILTER")
241 return value
242 elif self.is_key_ok("CALIB_ID"):
243 return self._translate_from_calib_id("filter")
244 else:
245 return None
247 @cache_translation
248 def to_location(self) -> astropy.coordinates.EarthLocation:
249 """Calculate the observatory location.
251 Returns
252 -------
253 location : `astropy.coordinates.EarthLocation`
254 An object representing the location of the telescope.
255 """
257 if self.is_key_ok("OBS-LONG"):
258 # OBS-LONG has west-positive sign so must be flipped
259 lon = self._header["OBS-LONG"] * -1.0
260 value = EarthLocation.from_geodetic(lon, self._header["OBS-LAT"], self._header["OBS-ELEV"])
261 self._used_these_cards("OBS-LONG", "OBS-LAT", "OBS-ELEV")
262 else:
263 # Look up the value since some files do not have location
264 value = EarthLocation.of_site("ctio")
266 return value
268 @cache_translation
269 def to_observation_type(self) -> str:
270 """Calculate the observation type.
272 Returns
273 -------
274 typ : `str`
275 Observation type. Normalized to standard set.
276 """
277 if not self.is_key_ok("OBSTYPE"):
278 return "none"
279 obstype = self._header["OBSTYPE"].strip().lower()
280 self._used_these_cards("OBSTYPE")
281 if obstype == "object":
282 return "science"
283 return obstype
285 @cache_translation
286 def to_tracking_radec(self) -> astropy.coordinates.SkyCoord:
287 # Docstring will be inherited. Property defined in properties.py
288 radecsys = ("RADESYS",)
289 radecpairs = (("TELRA", "TELDEC"),)
290 return tracking_from_degree_headers(self, radecsys, radecpairs, unit=(u.hourangle, u.deg))
292 @cache_translation
293 def to_altaz_begin(self) -> astropy.coordinates.AltAz:
294 # Docstring will be inherited. Property defined in properties.py
295 return altaz_from_degree_headers(self, (("ZD", "AZ"),), self.to_datetime_begin(), is_zd=set(["ZD"]))
297 @cache_translation
298 def to_detector_exposure_id(self) -> Optional[int]:
299 # Docstring will be inherited. Property defined in properties.py
300 exposure_id = self.to_exposure_id()
301 if exposure_id is None:
302 return None
303 return int("{:07d}{:02d}".format(exposure_id, self.to_detector_num()))
305 @cache_translation
306 def to_detector_group(self) -> str:
307 # Docstring will be inherited. Property defined in properties.py
308 name = self.to_detector_unique_name()
309 return name[0]
311 @cache_translation
312 def to_detector_name(self) -> str:
313 # Docstring will be inherited. Property defined in properties.py
314 name = self.to_detector_unique_name()
315 return name[1:]
317 @cache_translation
318 def to_focus_z(self) -> u.Quantity:
319 # Docstring will be inherited. Property defined in properties.py
320 # ``TELFOCUS`` is a comma-separated string with six focus offsets
321 # (fx, fy, fz, tx, ty, tz) recorded in units of microns.
322 tel_focus_list = self._header["TELFOCUS"].split(",")
323 return float(tel_focus_list[2]) * u.um
325 @classmethod
326 def fix_header(
327 cls, header: MutableMapping[str, Any], instrument: str, obsid: str, filename: Optional[str] = None
328 ) -> bool:
329 """Fix DECam headers.
331 Parameters
332 ----------
333 header : `dict`
334 The header to update. Updates are in place.
335 instrument : `str`
336 The name of the instrument.
337 obsid : `str`
338 Unique observation identifier associated with this header.
339 Will always be provided.
340 filename : `str`, optional
341 Filename associated with this header. May not be set since headers
342 can be fixed independently of any filename being known.
344 Returns
345 -------
346 modified = `bool`
347 Returns `True` if the header was updated.
349 Notes
350 -----
351 Fixes the following issues:
353 * If OBSTYPE contains "zero" or "bias",
354 update the FILTER keyword to "solid plate 0.0 0.0".
356 Corrections are reported as debug level log messages.
357 """
358 modified = False
360 # Calculate the standard label to use for log messages
361 log_label = cls._construct_log_prefix(obsid, filename)
363 obstype = header.get("OBSTYPE", "unknown")
365 if "bias" in obstype.lower() or "zero" in obstype.lower():
366 header["FILTER"] = "solid plate 0.0 0.0"
367 modified = True
368 log.debug("%s: Set FILTER to %s because OBSTYPE is %s", log_label, header["FILTER"], obstype)
370 return modified
372 @classmethod
373 def determine_translatable_headers(
374 cls, filename: str, primary: Optional[MutableMapping[str, Any]] = None
375 ) -> Iterator[MutableMapping[str, Any]]:
376 """Given a file return all the headers usable for metadata translation.
378 DECam files are multi-extension FITS with a primary header and
379 each detector stored in a subsequent extension. DECam uses
380 ``INHERIT=T`` and each detector header will be merged with the
381 primary header.
383 Guide headers are not returned.
385 Parameters
386 ----------
387 filename : `str`
388 Path to a file in a format understood by this translator.
389 primary : `dict`-like, optional
390 The primary header obtained by the caller. This is sometimes
391 already known, for example if a system is trying to bootstrap
392 without already knowing what data is in the file. Will be
393 merged with detector headers if supplied, else will be read
394 from the file.
396 Yields
397 ------
398 headers : iterator of `dict`-like
399 Each detector header in turn. The supplied header will be merged
400 with the contents of each detector header.
402 Notes
403 -----
404 This translator class is specifically tailored to raw DECam data and
405 is not designed to work with general FITS files. The normal paradigm
406 is for the caller to have read the first header and then called
407 `determine_translator()` on the result to work out which translator
408 class to then call to obtain the real headers to be used for
409 translation.
410 """
411 # Circular dependency so must defer import.
412 from ..headers import merge_headers
414 # This is convoluted because we need to turn an Optional variable
415 # to a Dict so that mypy is happy.
416 primary_hdr = primary if primary else {}
418 # Since we want to scan many HDUs we use astropy directly to keep
419 # the file open rather than continually opening and closing it
420 # as we go to each HDU.
421 with fits.open(filename) as fits_file:
422 # Astropy does not automatically handle the INHERIT=T in
423 # DECam headers so the primary header must be merged.
424 first_pass = True
426 for hdu in fits_file:
427 if first_pass:
428 if not primary_hdr:
429 primary_hdr = hdu.header
430 first_pass = False
431 continue
433 header = hdu.header
434 if "CCDNUM" not in header: # Primary does not have CCDNUM
435 continue
436 if header["CCDNUM"] > 62: # ignore guide CCDs
437 continue
438 yield merge_headers([primary_hdr, header], mode="overwrite")