Coverage for python/astro_metadata_translator/translators/megaprime.py: 50%
100 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 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.time
24import astropy.units as u
25from astropy.coordinates import Angle, EarthLocation
26from astropy.io import fits
28from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation
29from .fits import FitsTranslator
30from .helpers import altaz_from_degree_headers, tracking_from_degree_headers
32if TYPE_CHECKING:
33 import astropy.coordinates
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 _observing_day_offset = astropy.time.TimeDelta(0, format="sec", scale="tai")
51 # CFHT Megacam has no rotator, and the instrument angle on sky is set to
52 # +Y=N, +X=W which we define as a 0 degree rotation.
53 _const_map = {
54 "boresight_rotation_angle": Angle(0 * u.deg),
55 "boresight_rotation_coord": "sky",
56 "detector_group": None,
57 }
59 _trivial_map: dict[str, str | list[str] | tuple[Any, ...]] = {
60 "physical_filter": "FILTER",
61 "dark_time": ("DARKTIME", dict(unit=u.s)),
62 "exposure_time": ("EXPTIME", dict(unit=u.s)),
63 "observation_id": "OBSID",
64 "object": "OBJECT",
65 "science_program": "RUNID",
66 "exposure_id": "EXPNUM",
67 "visit_id": "EXPNUM",
68 "detector_serial": "CCDNAME",
69 "relative_humidity": ["RELHUMID", "HUMIDITY"],
70 "temperature": (["TEMPERAT", "AIRTEMP"], dict(unit=u.deg_C)),
71 "boresight_airmass": ["AIRMASS", "BORE-AIRMASS"],
72 }
74 @cache_translation
75 def to_datetime_begin(self) -> astropy.time.Time:
76 # Docstring will be inherited. Property defined in properties.py
77 # We know it is UTC
78 value = self._from_fits_date_string(
79 self._header["DATE-OBS"], time_str=self._header["UTC-OBS"], scale="utc"
80 )
81 self._used_these_cards("DATE-OBS", "UTC-OBS")
82 return value
84 @cache_translation
85 def to_datetime_end(self) -> astropy.time.Time:
86 # Docstring will be inherited. Property defined in properties.py
87 # Older files are missing UTCEND
88 if self.is_key_ok("UTCEND"):
89 # We know it is UTC
90 value = self._from_fits_date_string(
91 self._header["DATE-OBS"], time_str=self._header["UTCEND"], scale="utc"
92 )
93 self._used_these_cards("DATE-OBS", "UTCEND")
94 else:
95 # Take a guess by adding on the exposure time
96 value = self.to_datetime_begin() + self.to_exposure_time()
97 return value
99 @cache_translation
100 def to_location(self) -> EarthLocation:
101 """Calculate the observatory location.
103 Returns
104 -------
105 location : `astropy.coordinates.EarthLocation`
106 An object representing the location of the telescope.
107 """
108 # Height is not in some MegaPrime files. Use the value from
109 # EarthLocation.of_site("CFHT")
110 # Some data uses OBS-LONG, OBS-LAT, other data uses LONGITUD and
111 # LATITUDE
112 for long_key, lat_key in (("LONGITUD", "LATITUDE"), ("OBS-LONG", "OBS-LAT")):
113 if self.are_keys_ok([long_key, lat_key]):
114 value = EarthLocation.from_geodetic(self._header[long_key], self._header[lat_key], 4215.0)
115 self._used_these_cards(long_key, lat_key)
116 break
117 else:
118 value = EarthLocation.of_site("CFHT")
119 return value
121 @cache_translation
122 def to_detector_name(self) -> str:
123 # Docstring will be inherited. Property defined in properties.py
124 if self.is_key_ok("EXTNAME"):
125 name = self._header["EXTNAME"]
126 # Only valid name has form "ccdNN"
127 if re.match(r"ccd\d+$", name):
128 self._used_these_cards("EXTNAME")
129 return name
131 # Dummy value, intended for PHU (need something to get filename)
132 return "ccd99"
134 @cache_translation
135 def to_detector_num(self) -> int:
136 name = self.to_detector_name()
137 return int(name[3:])
139 @cache_translation
140 def to_observation_type(self) -> str:
141 """Calculate the observation type.
143 Returns
144 -------
145 typ : `str`
146 Observation type. Normalized to standard set.
147 """
148 obstype = self._header["OBSTYPE"].strip().lower()
149 self._used_these_cards("OBSTYPE")
150 if obstype == "object":
151 return "science"
152 return obstype
154 @cache_translation
155 def to_tracking_radec(self) -> astropy.coordinates.SkyCoord:
156 """Calculate the tracking RA/Dec for this observation.
158 Currently will be `None` for geocentric apparent coordinates.
159 Additionally, can be `None` for non-science observations.
161 The method supports multiple versions of header defining tracking
162 coordinates.
164 Returns
165 -------
166 coords : `astropy.coordinates.SkyCoord`
167 The tracking coordinates.
168 """
169 radecsys = ("RADECSYS", "OBJRADEC", "RADESYS")
170 radecpairs = (("RA_DEG", "DEC_DEG"), ("BORE-RA", "BORE-DEC"))
171 return tracking_from_degree_headers(self, radecsys, radecpairs)
173 @cache_translation
174 def to_altaz_begin(self) -> astropy.coordinates.AltAz:
175 # Docstring will be inherited. Property defined in properties.py
176 return altaz_from_degree_headers(
177 self, (("TELALT", "TELAZ"), ("BORE-ALT", "BORE-AZ")), self.to_datetime_begin()
178 )
180 @cache_translation
181 def to_detector_exposure_id(self) -> int:
182 # Docstring will be inherited. Property defined in properties.py
183 return self.to_exposure_id() * 36 + self.to_detector_num()
185 @cache_translation
186 def to_pressure(self) -> astropy.units.Quantity:
187 # Docstring will be inherited. Property defined in properties.py
188 # Can be either AIRPRESS in Pa or PRESSURE in mbar
189 for key, unit in (("PRESSURE", u.hPa), ("AIRPRESS", u.Pa)):
190 if self.is_key_ok(key):
191 return self.quantity_from_card(key, unit)
193 raise KeyError(f"{self._log_prefix}: Could not find pressure keywords in header")
195 @cache_translation
196 def to_observation_counter(self) -> int:
197 """Return the lifetime exposure number.
199 Returns
200 -------
201 sequence : `int`
202 The observation counter.
203 """
204 return self.to_exposure_id()
206 @classmethod
207 def determine_translatable_headers(
208 cls, filename: str, primary: MutableMapping[str, Any] | None = None
209 ) -> Iterator[MutableMapping[str, Any]]:
210 """Given a file return all the headers usable for metadata translation.
212 MegaPrime files are multi-extension FITS with a primary header and
213 each detector stored in a subsequent extension. MegaPrime uses
214 ``INHERIT=F`` therefore the primary header will always be ignored
215 if given.
217 Parameters
218 ----------
219 filename : `str`
220 Path to a file in a format understood by this translator.
221 primary : `dict`-like, optional
222 The primary header obtained by the caller. This is sometimes
223 already known, for example if a system is trying to bootstrap
224 without already knowing what data is in the file. Will be
225 ignored.
227 Yields
228 ------
229 headers : iterator of `dict`-like
230 Each detector header in turn. The supplied header will never be
231 included.
233 Notes
234 -----
235 This translator class is specifically tailored to raw MegaPrime data
236 and is not designed to work with general FITS files. The normal
237 paradigm is for the caller to have read the first header and then
238 called `determine_translator()` on the result to work out which
239 translator class to then call to obtain the real headers to be used for
240 translation.
241 """
242 # Since we want to scan many HDUs we use astropy directly to keep
243 # the file open rather than continually opening and closing it
244 # as we go to each HDU.
245 with fits.open(filename) as fits_file:
246 for hdu in fits_file:
247 # Astropy <=4.2 strips the EXTNAME header but some CFHT data
248 # have two EXTNAME headers and the CCD number is in the
249 # second one.
250 if hdu.name == "PRIMARY":
251 continue
253 if hdu.name.startswith("ccd"):
254 # It may only be some data files that are broken so
255 # handle the expected form.
256 yield hdu.header
257 continue
259 # Some test data at least has the EXTNAME as
260 # COMPRESSED_IMAGE but the EXTVER as the detector number.
261 if hdu.name == "COMPRESSED_IMAGE":
262 header = hdu.header
264 # Astropy strips EXTNAME so put it back for the translator
265 header["EXTNAME"] = f"ccd{hdu.ver:02d}"
266 yield header
268 @classmethod
269 def observing_date_to_offset(cls, observing_date: astropy.time.Time) -> astropy.time.TimeDelta | None:
270 """Return the offset to use when calculating the observing day.
272 Parameters
273 ----------
274 observing_date : `astropy.time.Time`
275 The date of the observation. Unused.
277 Returns
278 -------
279 offset : `astropy.time.TimeDelta`
280 The offset to apply. The offset is always 0 seconds. In Hawaii
281 UTC rollover is at 2pm local time.
282 """
283 return cls._observing_day_offset