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

100 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-28 02:59 -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.time 

24import astropy.units as u 

25from astropy.coordinates import Angle, EarthLocation 

26from astropy.io import fits 

27 

28from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation 

29from .fits import FitsTranslator 

30from .helpers import altaz_from_degree_headers, tracking_from_degree_headers 

31 

32if TYPE_CHECKING: 

33 import astropy.coordinates 

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 _observing_day_offset = astropy.time.TimeDelta(0, format="sec", scale="tai") 

50 

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 } 

58 

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 } 

73 

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 

83 

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 

98 

99 @cache_translation 

100 def to_location(self) -> EarthLocation: 

101 """Calculate the observatory location. 

102 

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 

120 

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 

130 

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

132 return "ccd99" 

133 

134 @cache_translation 

135 def to_detector_num(self) -> int: 

136 name = self.to_detector_name() 

137 return int(name[3:]) 

138 

139 @cache_translation 

140 def to_observation_type(self) -> str: 

141 """Calculate the observation type. 

142 

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 

153 

154 @cache_translation 

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

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

157 

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

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

160 

161 The method supports multiple versions of header defining tracking 

162 coordinates. 

163 

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) 

172 

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 ) 

179 

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

184 

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) 

192 

193 raise KeyError(f"{self._log_prefix}: Could not find pressure keywords in header") 

194 

195 @cache_translation 

196 def to_observation_counter(self) -> int: 

197 """Return the lifetime exposure number. 

198 

199 Returns 

200 ------- 

201 sequence : `int` 

202 The observation counter. 

203 """ 

204 return self.to_exposure_id() 

205 

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. 

211 

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. 

216 

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. 

226 

227 Yields 

228 ------ 

229 headers : iterator of `dict`-like 

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

231 included. 

232 

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 

252 

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 

258 

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 

263 

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

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

266 yield header 

267 

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. 

271 

272 Parameters 

273 ---------- 

274 observing_date : `astropy.time.Time` 

275 The date of the observation. Unused. 

276 

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