Coverage for python/astro_metadata_translator/translators/megaprime.py: 38%

99 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-04 14:10 -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. 

11 

12"""Metadata translation code for CFHT MegaPrime FITS headers.""" 

13 

14from __future__ import annotations 

15 

16__all__ = ("MegaPrimeTranslator",) 

17 

18import posixpath 

19import re 

20from collections.abc import Iterator, MutableMapping 

21from typing import TYPE_CHECKING, Any 

22 

23import astropy.units as u 

24from astropy.coordinates import Angle, EarthLocation 

25from astropy.io import fits 

26 

27from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation 

28from .fits import FitsTranslator 

29from .helpers import altaz_from_degree_headers, tracking_from_degree_headers 

30 

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 

35 

36 

37class MegaPrimeTranslator(FitsTranslator): 

38 """Metadata translator for CFHT MegaPrime standard headers.""" 

39 

40 name = "MegaPrime" 

41 """Name of this translation class""" 

42 

43 supported_instrument = "MegaPrime" 

44 """Supports the MegaPrime instrument.""" 

45 

46 default_resource_root = posixpath.join(CORRECTIONS_RESOURCE_ROOT, "CFHT") 

47 """Default resource path root to use to locate header correction files.""" 

48 

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 } 

56 

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 } 

71 

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 

81 

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 

96 

97 @cache_translation 

98 def to_location(self) -> EarthLocation: 

99 """Calculate the observatory location. 

100 

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 

118 

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 

128 

129 # Dummy value, intended for PHU (need something to get filename) 

130 return "ccd99" 

131 

132 @cache_translation 

133 def to_detector_num(self) -> int: 

134 name = self.to_detector_name() 

135 return int(name[3:]) 

136 

137 @cache_translation 

138 def to_observation_type(self) -> str: 

139 """Calculate the observation type. 

140 

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 

151 

152 @cache_translation 

153 def to_tracking_radec(self) -> astropy.coordinates.SkyCoord: 

154 """Calculate the tracking RA/Dec for this observation. 

155 

156 Currently will be `None` for geocentric apparent coordinates. 

157 Additionally, can be `None` for non-science observations. 

158 

159 The method supports multiple versions of header defining tracking 

160 coordinates. 

161 

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) 

170 

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 ) 

177 

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() 

182 

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") 

192 

193 @cache_translation 

194 def to_observation_counter(self) -> int: 

195 """Return the lifetime exposure number. 

196 

197 Returns 

198 ------- 

199 sequence : `int` 

200 The observation counter. 

201 """ 

202 return self.to_exposure_id() 

203 

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. 

209 

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. 

214 

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. 

224 

225 Yields 

226 ------ 

227 headers : iterator of `dict`-like 

228 Each detector header in turn. The supplied header will never be 

229 included. 

230 

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 

250 

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 

256 

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 

261 

262 # Astropy strips EXTNAME so put it back for the translator 

263 header["EXTNAME"] = f"ccd{hdu.ver:02d}" 

264 yield header