Coverage for python/astro_metadata_translator/translators/megaprime.py: 37%
98 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-29 02:15 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-29 02:15 -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 CFHT MegaPrime FITS headers"""
14from __future__ import annotations
16__all__ = ("MegaPrimeTranslator",)
18import posixpath
19import re
20from typing import TYPE_CHECKING, Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, Union
22import astropy.units as u
23from astropy.coordinates import Angle, EarthLocation
24from astropy.io import fits
26from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation
27from .fits import FitsTranslator
28from .helpers import altaz_from_degree_headers, tracking_from_degree_headers
30if TYPE_CHECKING: 30 ↛ 31line 30 didn't jump to line 31, because the condition on line 30 was never true
31 import astropy.coordinates
32 import astropy.time
33 import astropy.units
36class MegaPrimeTranslator(FitsTranslator):
37 """Metadata translator for CFHT MegaPrime standard headers."""
39 name = "MegaPrime"
40 """Name of this translation class"""
42 supported_instrument = "MegaPrime"
43 """Supports the MegaPrime instrument."""
45 default_resource_root = posixpath.join(CORRECTIONS_RESOURCE_ROOT, "CFHT")
46 """Default resource path root to use to locate header correction files."""
48 # CFHT Megacam has no rotator, and the instrument angle on sky is set to
49 # +Y=N, +X=W which we define as a 0 degree rotation.
50 _const_map = {
51 "boresight_rotation_angle": Angle(0 * u.deg),
52 "boresight_rotation_coord": "sky",
53 "detector_group": None,
54 }
56 _trivial_map: Dict[str, Union[str, List[str], Tuple[Any, ...]]] = {
57 "physical_filter": "FILTER",
58 "dark_time": ("DARKTIME", dict(unit=u.s)),
59 "exposure_time": ("EXPTIME", dict(unit=u.s)),
60 "observation_id": "OBSID",
61 "object": "OBJECT",
62 "science_program": "RUNID",
63 "exposure_id": "EXPNUM",
64 "visit_id": "EXPNUM",
65 "detector_serial": "CCDNAME",
66 "relative_humidity": ["RELHUMID", "HUMIDITY"],
67 "temperature": (["TEMPERAT", "AIRTEMP"], dict(unit=u.deg_C)),
68 "boresight_airmass": ["AIRMASS", "BORE-AIRMASS"],
69 }
71 @cache_translation
72 def to_datetime_begin(self) -> astropy.time.Time:
73 # Docstring will be inherited. Property defined in properties.py
74 # We know it is UTC
75 value = self._from_fits_date_string(
76 self._header["DATE-OBS"], time_str=self._header["UTC-OBS"], scale="utc"
77 )
78 self._used_these_cards("DATE-OBS", "UTC-OBS")
79 return value
81 @cache_translation
82 def to_datetime_end(self) -> astropy.time.Time:
83 # Docstring will be inherited. Property defined in properties.py
84 # Older files are missing UTCEND
85 if self.is_key_ok("UTCEND"):
86 # We know it is UTC
87 value = self._from_fits_date_string(
88 self._header["DATE-OBS"], time_str=self._header["UTCEND"], scale="utc"
89 )
90 self._used_these_cards("DATE-OBS", "UTCEND")
91 else:
92 # Take a guess by adding on the exposure time
93 value = self.to_datetime_begin() + self.to_exposure_time()
94 return value
96 @cache_translation
97 def to_location(self) -> EarthLocation:
98 """Calculate the observatory location.
100 Returns
101 -------
102 location : `astropy.coordinates.EarthLocation`
103 An object representing the location of the telescope.
104 """
105 # Height is not in some MegaPrime files. Use the value from
106 # EarthLocation.of_site("CFHT")
107 # Some data uses OBS-LONG, OBS-LAT, other data uses LONGITUD and
108 # LATITUDE
109 for long_key, lat_key in (("LONGITUD", "LATITUDE"), ("OBS-LONG", "OBS-LAT")):
110 if self.are_keys_ok([long_key, lat_key]):
111 value = EarthLocation.from_geodetic(self._header[long_key], self._header[lat_key], 4215.0)
112 self._used_these_cards(long_key, lat_key)
113 break
114 else:
115 value = EarthLocation.of_site("CFHT")
116 return value
118 @cache_translation
119 def to_detector_name(self) -> str:
120 # Docstring will be inherited. Property defined in properties.py
121 if self.is_key_ok("EXTNAME"):
122 name = self._header["EXTNAME"]
123 # Only valid name has form "ccdNN"
124 if re.match(r"ccd\d+$", name):
125 self._used_these_cards("EXTNAME")
126 return name
128 # Dummy value, intended for PHU (need something to get filename)
129 return "ccd99"
131 @cache_translation
132 def to_detector_num(self) -> int:
133 name = self.to_detector_name()
134 return int(name[3:])
136 @cache_translation
137 def to_observation_type(self) -> str:
138 """Calculate the observation type.
140 Returns
141 -------
142 typ : `str`
143 Observation type. Normalized to standard set.
144 """
145 obstype = self._header["OBSTYPE"].strip().lower()
146 self._used_these_cards("OBSTYPE")
147 if obstype == "object":
148 return "science"
149 return obstype
151 @cache_translation
152 def to_tracking_radec(self) -> astropy.coordinates.SkyCoord:
153 """Calculate the tracking RA/Dec for this observation.
155 Currently will be `None` for geocentric apparent coordinates.
156 Additionally, can be `None` for non-science observations.
158 The method supports multiple versions of header defining tracking
159 coordinates.
161 Returns
162 -------
163 coords : `astropy.coordinates.SkyCoord`
164 The tracking coordinates.
165 """
166 radecsys = ("RADECSYS", "OBJRADEC", "RADESYS")
167 radecpairs = (("RA_DEG", "DEC_DEG"), ("BORE-RA", "BORE-DEC"))
168 return tracking_from_degree_headers(self, radecsys, radecpairs)
170 @cache_translation
171 def to_altaz_begin(self) -> astropy.coordinates.AltAz:
172 # Docstring will be inherited. Property defined in properties.py
173 return altaz_from_degree_headers(
174 self, (("TELALT", "TELAZ"), ("BORE-ALT", "BORE-AZ")), self.to_datetime_begin()
175 )
177 @cache_translation
178 def to_detector_exposure_id(self) -> int:
179 # Docstring will be inherited. Property defined in properties.py
180 return self.to_exposure_id() * 36 + self.to_detector_num()
182 @cache_translation
183 def to_pressure(self) -> astropy.units.Quantity:
184 # Docstring will be inherited. Property defined in properties.py
185 # Can be either AIRPRESS in Pa or PRESSURE in mbar
186 for key, unit in (("PRESSURE", u.hPa), ("AIRPRESS", u.Pa)):
187 if self.is_key_ok(key):
188 return self.quantity_from_card(key, unit)
189 else:
190 raise KeyError(f"{self._log_prefix}: Could not find pressure keywords in header")
192 @cache_translation
193 def to_observation_counter(self) -> int:
194 """Return the lifetime exposure number.
196 Returns
197 -------
198 sequence : `int`
199 The observation counter.
200 """
201 return self.to_exposure_id()
203 @classmethod
204 def determine_translatable_headers(
205 cls, filename: str, primary: Optional[MutableMapping[str, Any]] = None
206 ) -> Iterator[MutableMapping[str, Any]]:
207 """Given a file return all the headers usable for metadata translation.
209 MegaPrime files are multi-extension FITS with a primary header and
210 each detector stored in a subsequent extension. MegaPrime uses
211 ``INHERIT=F`` therefore the primary header will always be ignored
212 if given.
214 Parameters
215 ----------
216 filename : `str`
217 Path to a file in a format understood by this translator.
218 primary : `dict`-like, optional
219 The primary header obtained by the caller. This is sometimes
220 already known, for example if a system is trying to bootstrap
221 without already knowing what data is in the file. Will be
222 ignored.
224 Yields
225 ------
226 headers : iterator of `dict`-like
227 Each detector header in turn. The supplied header will never be
228 included.
230 Notes
231 -----
232 This translator class is specifically tailored to raw MegaPrime data
233 and is not designed to work with general FITS files. The normal
234 paradigm is for the caller to have read the first header and then
235 called `determine_translator()` on the result to work out which
236 translator class to then call to obtain the real headers to be used for
237 translation.
238 """
239 # Since we want to scan many HDUs we use astropy directly to keep
240 # the file open rather than continually opening and closing it
241 # as we go to each HDU.
242 with fits.open(filename) as fits_file:
243 for hdu in fits_file:
244 # Astropy <=4.2 strips the EXTNAME header but some CFHT data
245 # have two EXTNAME headers and the CCD number is in the
246 # second one.
247 if hdu.name == "PRIMARY":
248 continue
250 if hdu.name.startswith("ccd"):
251 # It may only be some data files that are broken so
252 # handle the expected form.
253 yield hdu.header
254 continue
256 # Some test data at least has the EXTNAME as
257 # COMPRESSED_IMAGE but the EXTVER as the detector number.
258 if hdu.name == "COMPRESSED_IMAGE":
259 header = hdu.header
261 # Astropy strips EXTNAME so put it back for the translator
262 header["EXTNAME"] = f"ccd{hdu.ver:02d}"
263 yield header