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

98 statements  

« prev     ^ index     » next       coverage.py v6.4.2, created at 2022-07-13 02:31 -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 typing import TYPE_CHECKING, Any, Dict, Iterator, List, MutableMapping, Optional, Tuple, Union 

21 

22import astropy.units as u 

23from astropy.coordinates import Angle, EarthLocation 

24from astropy.io import fits 

25 

26from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation 

27from .fits import FitsTranslator 

28from .helpers import altaz_from_degree_headers, tracking_from_degree_headers 

29 

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 

34 

35 

36class MegaPrimeTranslator(FitsTranslator): 

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

38 

39 name = "MegaPrime" 

40 """Name of this translation class""" 

41 

42 supported_instrument = "MegaPrime" 

43 """Supports the MegaPrime instrument.""" 

44 

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

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

47 

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 } 

55 

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 } 

70 

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 

80 

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 

95 

96 @cache_translation 

97 def to_location(self) -> EarthLocation: 

98 """Calculate the observatory location. 

99 

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 

117 

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 

127 

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

129 return "ccd99" 

130 

131 @cache_translation 

132 def to_detector_num(self) -> int: 

133 name = self.to_detector_name() 

134 return int(name[3:]) 

135 

136 @cache_translation 

137 def to_observation_type(self) -> str: 

138 """Calculate the observation type. 

139 

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 

150 

151 @cache_translation 

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

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

154 

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

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

157 

158 The method supports multiple versions of header defining tracking 

159 coordinates. 

160 

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) 

169 

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 ) 

176 

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

181 

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

191 

192 @cache_translation 

193 def to_observation_counter(self) -> int: 

194 """Return the lifetime exposure number. 

195 

196 Returns 

197 ------- 

198 sequence : `int` 

199 The observation counter. 

200 """ 

201 return self.to_exposure_id() 

202 

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. 

208 

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. 

213 

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. 

223 

224 Yields 

225 ------ 

226 headers : iterator of `dict`-like 

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

228 included. 

229 

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 

249 

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 

255 

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 

260 

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

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

263 yield header