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

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.io import fits
21from astropy.coordinates import EarthLocation, Angle
22import astropy.units as u
24from ..translator import cache_translation, CORRECTIONS_RESOURCE_ROOT
25from .fits import FitsTranslator
26from .helpers import altaz_from_degree_headers, is_non_science, \
27 tracking_from_degree_headers
29log = logging.getLogger(__name__)
32class DecamTranslator(FitsTranslator):
33 """Metadata translator for DECam standard headers.
34 """
36 name = "DECam"
37 """Name of this translation class"""
39 supported_instrument = "DECam"
40 """Supports the DECam instrument."""
42 default_resource_root = posixpath.join(CORRECTIONS_RESOURCE_ROOT, "DECam")
43 """Default resource path root to use to locate header correction files."""
45 # DECam has no rotator, and the instrument angle on sky is set to +Y=East,
46 # +X=South which we define as a 90 degree rotation and an X-flip.
47 _const_map = {"boresight_rotation_angle": Angle(90*u.deg),
48 "boresight_rotation_coord": "sky",
49 }
51 _trivial_map = {"exposure_time": ("EXPTIME", dict(unit=u.s)),
52 "dark_time": ("DARKTIME", dict(unit=u.s)),
53 "boresight_airmass": ("AIRMASS", dict(checker=is_non_science)),
54 "observation_id": "OBSID",
55 "object": "OBJECT",
56 "science_program": "PROPID",
57 "detector_num": "CCDNUM",
58 "detector_serial": "DETECTOR",
59 "detector_unique_name": "DETPOS",
60 "telescope": ("TELESCOP", dict(default="CTIO 4.0-m telescope")),
61 "instrument": ("INSTRUME", dict(default="DECam")),
62 # Ensure that reasonable values are always available
63 "relative_humidity": ("HUMIDITY", dict(default=40., minimum=0, maximum=100.)),
64 "temperature": ("OUTTEMP", dict(unit=u.deg_C, default=10., minimum=-10., maximum=40.)),
65 # Header says torr but seems to be mbar. Use hPa unit
66 # which is the SI equivalent of mbar.
67 "pressure": ("PRESSURE", dict(unit=u.hPa,
68 default=771.611, minimum=700., maximum=850.)),
69 }
71 # Unique detector names are currently not used but are read directly from
72 # header.
73 # The detector_group could be N or S with detector_name corresponding
74 # to the number in that group.
75 detector_names = {
76 1: 'S29', 2: 'S30', 3: 'S31', 4: 'S25', 5: 'S26', 6: 'S27', 7: 'S28', 8: 'S20', 9: 'S21',
77 10: 'S22', 11: 'S23', 12: 'S24', 13: 'S14', 14: 'S15', 15: 'S16', 16: 'S17', 17: 'S18',
78 18: 'S19', 19: 'S8', 20: 'S9', 21: 'S10', 22: 'S11', 23: 'S12', 24: 'S13', 25: 'S1', 26: 'S2',
79 27: 'S3', 28: 'S4', 29: 'S5', 30: 'S6', 31: 'S7', 32: 'N1', 33: 'N2', 34: 'N3', 35: 'N4',
80 36: 'N5', 37: 'N6', 38: 'N7', 39: 'N8', 40: 'N9', 41: 'N10', 42: 'N11', 43: 'N12', 44: 'N13',
81 45: 'N14', 46: 'N15', 47: 'N16', 48: 'N17', 49: 'N18', 50: 'N19', 51: 'N20', 52: 'N21',
82 53: 'N22', 54: 'N23', 55: 'N24', 56: 'N25', 57: 'N26', 58: 'N27', 59: 'N28', 60: 'N29',
83 62: 'N31'}
85 @classmethod
86 def can_translate(cls, header, filename=None):
87 """Indicate whether this translation class can translate the
88 supplied header.
90 Checks the INSTRUME and FILTER headers.
92 Parameters
93 ----------
94 header : `dict`-like
95 Header to convert to standardized form.
96 filename : `str`, optional
97 Name of file being translated.
99 Returns
100 -------
101 can : `bool`
102 `True` if the header is recognized by this class. `False`
103 otherwise.
104 """
105 # Use INSTRUME. Because of defaulting behavior only do this
106 # if we really have an INSTRUME header
107 if "INSTRUME" in header:
108 via_instrume = super().can_translate(header, filename=filename)
109 if via_instrume:
110 return via_instrume
111 if cls.is_keyword_defined(header, "FILTER") and "DECam" in header["FILTER"]:
112 return True
113 return False
115 @cache_translation
116 def to_exposure_id(self):
117 """Calculate exposure ID.
119 Returns
120 -------
121 id : `int`
122 ID of exposure.
123 """
124 value = self._header["EXPNUM"]
125 self._used_these_cards("EXPNUM")
126 return value
128 @cache_translation
129 def to_observation_counter(self):
130 """Return the lifetime exposure number.
132 Returns
133 -------
134 sequence : `int`
135 The observation counter.
136 """
137 return self.to_exposure_id()
139 @cache_translation
140 def to_visit_id(self):
141 # Docstring will be inherited. Property defined in properties.py
142 return self.to_exposure_id()
144 @cache_translation
145 def to_datetime_end(self):
146 # Docstring will be inherited. Property defined in properties.py
147 # Instcals have no DATE-END or DTUTC
148 datetime_end = self._from_fits_date("DTUTC", scale="utc")
149 if datetime_end is None:
150 datetime_end = self.to_datetime_begin() + self.to_exposure_time()
151 return datetime_end
153 def _translate_from_calib_id(self, field):
154 """Fetch the ID from the CALIB_ID header.
156 Calibration products made with constructCalibs have some metadata
157 saved in its FITS header CALIB_ID.
158 """
159 data = self._header["CALIB_ID"]
160 match = re.search(r".*%s=(\S+)" % field, data)
161 self._used_these_cards("CALIB_ID")
162 return match.groups()[0]
164 @cache_translation
165 def to_physical_filter(self):
166 """Calculate physical filter.
168 Return `None` if the keyword FILTER does not exist in the header,
169 which can happen for some valid Community Pipeline products.
171 Returns
172 -------
173 filter : `str`
174 The full filter name.
175 """
176 if self.is_key_ok("FILTER"):
177 value = self._header["FILTER"].strip()
178 self._used_these_cards("FILTER")
179 return value
180 elif self.is_key_ok("CALIB_ID"):
181 return self._translate_from_calib_id("filter")
182 else:
183 return None
185 @cache_translation
186 def to_location(self):
187 """Calculate the observatory location.
189 Returns
190 -------
191 location : `astropy.coordinates.EarthLocation`
192 An object representing the location of the telescope.
193 """
195 if self.is_key_ok("OBS-LONG"):
196 # OBS-LONG has west-positive sign so must be flipped
197 lon = self._header["OBS-LONG"] * -1.0
198 value = EarthLocation.from_geodetic(lon, self._header["OBS-LAT"], self._header["OBS-ELEV"])
199 self._used_these_cards("OBS-LONG", "OBS-LAT", "OBS-ELEV")
200 else:
201 # Look up the value since some files do not have location
202 value = EarthLocation.of_site("ctio")
204 return value
206 @cache_translation
207 def to_observation_type(self):
208 """Calculate the observation type.
210 Returns
211 -------
212 typ : `str`
213 Observation type. Normalized to standard set.
214 """
215 if not self.is_key_ok("OBSTYPE"):
216 return "none"
217 obstype = self._header["OBSTYPE"].strip().lower()
218 self._used_these_cards("OBSTYPE")
219 if obstype == "object":
220 return "science"
221 return obstype
223 @cache_translation
224 def to_tracking_radec(self):
225 # Docstring will be inherited. Property defined in properties.py
226 radecsys = ("RADESYS",)
227 radecpairs = (("TELRA", "TELDEC"),)
228 return tracking_from_degree_headers(self, radecsys, radecpairs, unit=(u.hourangle, u.deg))
230 @cache_translation
231 def to_altaz_begin(self):
232 # Docstring will be inherited. Property defined in properties.py
233 return altaz_from_degree_headers(self, (("ZD", "AZ"),),
234 self.to_datetime_begin(), is_zd=set(["ZD"]))
236 @cache_translation
237 def to_detector_exposure_id(self):
238 # Docstring will be inherited. Property defined in properties.py
239 exposure_id = self.to_exposure_id()
240 if exposure_id is None:
241 return None
242 return int("{:07d}{:02d}".format(exposure_id, self.to_detector_num()))
244 @cache_translation
245 def to_detector_group(self):
246 # Docstring will be inherited. Property defined in properties.py
247 name = self.to_detector_unique_name()
248 return name[0]
250 @cache_translation
251 def to_detector_name(self):
252 # Docstring will be inherited. Property defined in properties.py
253 name = self.to_detector_unique_name()
254 return name[1:]
256 @classmethod
257 def fix_header(cls, header, instrument, obsid, filename=None):
258 """Fix DECam headers.
260 Parameters
261 ----------
262 header : `dict`
263 The header to update. Updates are in place.
264 instrument : `str`
265 The name of the instrument.
266 obsid : `str`
267 Unique observation identifier associated with this header.
268 Will always be provided.
269 filename : `str`, optional
270 Filename associated with this header. May not be set since headers
271 can be fixed independently of any filename being known.
273 Returns
274 -------
275 modified = `bool`
276 Returns `True` if the header was updated.
278 Notes
279 -----
280 Fixes the following issues:
282 * If OBSTYPE contains "zero" or "bias",
283 update the FILTER keyword to "solid plate 0.0 0.0".
285 Corrections are reported as debug level log messages.
286 """
287 modified = False
289 # Calculate the standard label to use for log messages
290 log_label = cls._construct_log_prefix(obsid, filename)
292 obstype = header.get("OBSTYPE", "unknown")
294 if "bias" in obstype.lower() or "zero" in obstype.lower():
295 header["FILTER"] = "solid plate 0.0 0.0"
296 modified = True
297 log.debug("%s: Set FILTER to %s because OBSTYPE is %s",
298 log_label, header["FILTER"], obstype)
300 return modified
302 @classmethod
303 def determine_translatable_headers(cls, filename, primary=None):
304 """Given a file return all the headers usable for metadata translation.
306 DECam files are multi-extension FITS with a primary header and
307 each detector stored in a subsequent extension. DECam uses
308 ``INHERIT=T`` and each detector header will be merged with the
309 primary header.
311 Guide headers are not returned.
313 Parameters
314 ----------
315 filename : `str`
316 Path to a file in a format understood by this translator.
317 primary : `dict`-like, optional
318 The primary header obtained by the caller. This is sometimes
319 already known, for example if a system is trying to bootstrap
320 without already knowing what data is in the file. Will be
321 merged with detector headers if supplied, else will be read
322 from the file.
324 Yields
325 ------
326 headers : iterator of `dict`-like
327 Each detector header in turn. The supplied header will be merged
328 with the contents of each detector header.
330 Notes
331 -----
332 This translator class is specifically tailored to raw DECam data and
333 is not designed to work with general FITS files. The normal paradigm
334 is for the caller to have read the first header and then called
335 `determine_translator()` on the result to work out which translator
336 class to then call to obtain the real headers to be used for
337 translation.
338 """
339 # Circular dependency so must defer import.
340 from ..headers import merge_headers
342 # Since we want to scan many HDUs we use astropy directly to keep
343 # the file open rather than continually opening and closing it
344 # as we go to each HDU.
345 with fits.open(filename) as fits_file:
346 # Astropy does not automatically handle the INHERIT=T in
347 # DECam headers so the primary header must be merged.
348 first_pass = True
350 for hdu in fits_file:
351 if first_pass:
352 if not primary:
353 primary = hdu.header
354 first_pass = False
355 continue
357 header = hdu.header
358 if "CCDNUM" not in header: # Primary does not have CCDNUM
359 continue
360 if header["CCDNUM"] > 62: # ignore guide CCDs
361 continue
362 yield merge_headers([primary, header], mode="overwrite")