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