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