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

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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"""
14__all__ = ("DecamTranslator", )
16import re
17import posixpath
18import logging
20from astropy.coordinates import EarthLocation, Angle
21import astropy.units as u
23from ..translator import cache_translation, CORRECTIONS_RESOURCE_ROOT
24from .fits import FitsTranslator
25from .helpers import altaz_from_degree_headers, is_non_science, \
26 tracking_from_degree_headers
28log = logging.getLogger(__name__)
31class DecamTranslator(FitsTranslator):
32 """Metadata translator for DECam standard headers.
33 """
35 name = "DECam"
36 """Name of this translation class"""
38 supported_instrument = "DECam"
39 """Supports the DECam instrument."""
41 default_resource_root = posixpath.join(CORRECTIONS_RESOURCE_ROOT, "DECam")
42 """Default resource path root to use to locate header correction files."""
44 # DECam has no rotator, and the instrument angle on sky is set to +Y=East,
45 # +X=South which we define as a 90 degree rotation and an X-flip.
46 _const_map = {"boresight_rotation_angle": Angle(90*u.deg),
47 "boresight_rotation_coord": "sky",
48 }
50 _trivial_map = {"exposure_time": ("EXPTIME", dict(unit=u.s)),
51 "dark_time": ("DARKTIME", dict(unit=u.s)),
52 "boresight_airmass": ("AIRMASS", dict(checker=is_non_science)),
53 "observation_id": "OBSID",
54 "object": "OBJECT",
55 "science_program": "PROPID",
56 "detector_num": "CCDNUM",
57 "detector_serial": "DETECTOR",
58 "detector_unique_name": "DETPOS",
59 "telescope": ("TELESCOP", dict(default="CTIO 4.0-m telescope")),
60 "instrument": ("INSTRUME", dict(default="DECam")),
61 # Ensure that reasonable values are always available
62 "relative_humidity": ("HUMIDITY", dict(default=40., minimum=0, maximum=100.)),
63 "temperature": ("OUTTEMP", dict(unit=u.deg_C, default=10., minimum=-10., maximum=40.)),
64 # Header says torr but seems to be mbar. Use hPa unit
65 # which is the SI equivalent of mbar.
66 "pressure": ("PRESSURE", dict(unit=u.hPa,
67 default=771.611, minimum=700., maximum=850.)),
68 }
70 # Unique detector names are currently not used but are read directly from
71 # header.
72 # The detector_group could be N or S with detector_name corresponding
73 # to the number in that group.
74 detector_names = {
75 1: 'S29', 2: 'S30', 3: 'S31', 4: 'S25', 5: 'S26', 6: 'S27', 7: 'S28', 8: 'S20', 9: 'S21',
76 10: 'S22', 11: 'S23', 12: 'S24', 13: 'S14', 14: 'S15', 15: 'S16', 16: 'S17', 17: 'S18',
77 18: 'S19', 19: 'S8', 20: 'S9', 21: 'S10', 22: 'S11', 23: 'S12', 24: 'S13', 25: 'S1', 26: 'S2',
78 27: 'S3', 28: 'S4', 29: 'S5', 30: 'S6', 31: 'S7', 32: 'N1', 33: 'N2', 34: 'N3', 35: 'N4',
79 36: 'N5', 37: 'N6', 38: 'N7', 39: 'N8', 40: 'N9', 41: 'N10', 42: 'N11', 43: 'N12', 44: 'N13',
80 45: 'N14', 46: 'N15', 47: 'N16', 48: 'N17', 49: 'N18', 50: 'N19', 51: 'N20', 52: 'N21',
81 53: 'N22', 54: 'N23', 55: 'N24', 56: 'N25', 57: 'N26', 58: 'N27', 59: 'N28', 60: 'N29',
82 62: 'N31'}
84 @classmethod
85 def can_translate(cls, header, filename=None):
86 """Indicate whether this translation class can translate the
87 supplied header.
89 Checks the INSTRUME and FILTER headers.
91 Parameters
92 ----------
93 header : `dict`-like
94 Header to convert to standardized form.
95 filename : `str`, optional
96 Name of file being translated.
98 Returns
99 -------
100 can : `bool`
101 `True` if the header is recognized by this class. `False`
102 otherwise.
103 """
104 # Use INSTRUME. Because of defaulting behavior only do this
105 # if we really have an INSTRUME header
106 if "INSTRUME" in header:
107 via_instrume = super().can_translate(header, filename=filename)
108 if via_instrume:
109 return via_instrume
110 if cls.is_keyword_defined(header, "FILTER") and "DECam" in header["FILTER"]:
111 return True
112 return False
114 @cache_translation
115 def to_exposure_id(self):
116 """Calculate exposure ID.
118 Returns
119 -------
120 id : `int`
121 ID of exposure.
122 """
123 value = self._header["EXPNUM"]
124 self._used_these_cards("EXPNUM")
125 return value
127 @cache_translation
128 def to_observation_counter(self):
129 """Return the lifetime exposure number.
131 Returns
132 -------
133 sequence : `int`
134 The observation counter.
135 """
136 return self.to_exposure_id()
138 @cache_translation
139 def to_visit_id(self):
140 # Docstring will be inherited. Property defined in properties.py
141 return self.to_exposure_id()
143 @cache_translation
144 def to_datetime_end(self):
145 # Docstring will be inherited. Property defined in properties.py
146 # Instcals have no DATE-END or DTUTC
147 datetime_end = self._from_fits_date("DTUTC", scale="utc")
148 if datetime_end is None:
149 datetime_end = self.to_datetime_begin() + self.to_exposure_time()
150 return datetime_end
152 def _translate_from_calib_id(self, field):
153 """Fetch the ID from the CALIB_ID header.
155 Calibration products made with constructCalibs have some metadata
156 saved in its FITS header CALIB_ID.
157 """
158 data = self._header["CALIB_ID"]
159 match = re.search(r".*%s=(\S+)" % field, data)
160 self._used_these_cards("CALIB_ID")
161 return match.groups()[0]
163 @cache_translation
164 def to_physical_filter(self):
165 """Calculate physical filter.
167 Return `None` if the keyword FILTER does not exist in the header,
168 which can happen for some valid Community Pipeline products.
170 Returns
171 -------
172 filter : `str`
173 The full filter name.
174 """
175 if self.is_key_ok("FILTER"):
176 value = self._header["FILTER"].strip()
177 self._used_these_cards("FILTER")
178 return value
179 elif self.is_key_ok("CALIB_ID"):
180 return self._translate_from_calib_id("filter")
181 else:
182 return None
184 @cache_translation
185 def to_location(self):
186 """Calculate the observatory location.
188 Returns
189 -------
190 location : `astropy.coordinates.EarthLocation`
191 An object representing the location of the telescope.
192 """
194 if self.is_key_ok("OBS-LONG"):
195 # OBS-LONG has west-positive sign so must be flipped
196 lon = self._header["OBS-LONG"] * -1.0
197 value = EarthLocation.from_geodetic(lon, self._header["OBS-LAT"], self._header["OBS-ELEV"])
198 self._used_these_cards("OBS-LONG", "OBS-LAT", "OBS-ELEV")
199 else:
200 # Look up the value since some files do not have location
201 value = EarthLocation.of_site("ctio")
203 return value
205 @cache_translation
206 def to_observation_type(self):
207 """Calculate the observation type.
209 Returns
210 -------
211 typ : `str`
212 Observation type. Normalized to standard set.
213 """
214 if not self.is_key_ok("OBSTYPE"):
215 return "none"
216 obstype = self._header["OBSTYPE"].strip().lower()
217 self._used_these_cards("OBSTYPE")
218 if obstype == "object":
219 return "science"
220 return obstype
222 @cache_translation
223 def to_tracking_radec(self):
224 # Docstring will be inherited. Property defined in properties.py
225 radecsys = ("RADESYS",)
226 radecpairs = (("TELRA", "TELDEC"),)
227 return tracking_from_degree_headers(self, radecsys, radecpairs, unit=(u.hourangle, u.deg))
229 @cache_translation
230 def to_altaz_begin(self):
231 # Docstring will be inherited. Property defined in properties.py
232 return altaz_from_degree_headers(self, (("ZD", "AZ"),),
233 self.to_datetime_begin(), is_zd=set(["ZD"]))
235 @cache_translation
236 def to_detector_exposure_id(self):
237 # Docstring will be inherited. Property defined in properties.py
238 exposure_id = self.to_exposure_id()
239 if exposure_id is None:
240 return None
241 return int("{:07d}{:02d}".format(exposure_id, self.to_detector_num()))
243 @cache_translation
244 def to_detector_group(self):
245 # Docstring will be inherited. Property defined in properties.py
246 name = self.to_detector_unique_name()
247 return name[0]
249 @cache_translation
250 def to_detector_name(self):
251 # Docstring will be inherited. Property defined in properties.py
252 name = self.to_detector_unique_name()
253 return name[1:]
255 @classmethod
256 def fix_header(cls, header, obsid, filename=None):
257 """Fix DECam headers.
259 Parameters
260 ----------
261 header : `dict`
262 The header to update. Updates are in place.
263 obsid : `str`
264 Unique observation identifier associated with this header.
265 Will always be provided.
266 filename : `str`, optional
267 Filename associated with this header. May not be set since headers
268 can be fixed independently of any filename being known.
270 Returns
271 -------
272 modified = `bool`
273 Returns `True` if the header was updated.
275 Notes
276 -----
277 Fixes the following issues:
278 * If OBSTYPE contains "zero" or "bias",
279 update the FILTER keyword to "solid plate 0.0 0.0".
281 Corrections are reported as debug level log messages.
282 """
283 modified = False
285 # Calculate the standard label to use for log messages
286 log_label = cls._construct_log_prefix(obsid, filename)
288 obstype = header.get("OBSTYPE", "unknown")
290 if "bias" in obstype.lower() or "zero" in obstype.lower():
291 header["FILTER"] = "solid plate 0.0 0.0"
292 modified = True
293 log.debug("%s: Set FILTER to %s because OBSTYPE is %s",
294 log_label, header["FILTER"], obstype)
296 return modified