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