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

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: 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 

36 

37 

38class MegaPrimeTranslator(FitsTranslator): 

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

40 

41 name = "MegaPrime" 

42 """Name of this translation class""" 

43 

44 supported_instrument = "MegaPrime" 

45 """Supports the MegaPrime instrument.""" 

46 

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

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

49 

50 _observing_day_offset = astropy.time.TimeDelta(0, format="sec", scale="tai") 

51 

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 } 

59 

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 } 

74 

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 

84 

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 

99 

100 @cache_translation 

101 def to_location(self) -> EarthLocation: 

102 """Calculate the observatory location. 

103 

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 

121 

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 

131 

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

133 return "ccd99" 

134 

135 @cache_translation 

136 def to_detector_num(self) -> int: 

137 name = self.to_detector_name() 

138 return int(name[3:]) 

139 

140 @cache_translation 

141 def to_observation_type(self) -> str: 

142 """Calculate the observation type. 

143 

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 

154 

155 @cache_translation 

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

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

158 

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

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

161 

162 The method supports multiple versions of header defining tracking 

163 coordinates. 

164 

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) 

173 

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 ) 

180 

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

185 

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

195 

196 @cache_translation 

197 def to_observation_counter(self) -> int: 

198 """Return the lifetime exposure number. 

199 

200 Returns 

201 ------- 

202 sequence : `int` 

203 The observation counter. 

204 """ 

205 return self.to_exposure_id() 

206 

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. 

212 

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. 

217 

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. 

227 

228 Yields 

229 ------ 

230 headers : iterator of `dict`-like 

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

232 included. 

233 

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 

253 

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 

259 

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 

264 

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

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

267 yield header 

268 

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. 

272 

273 Parameters 

274 ---------- 

275 observing_date : `astropy.time.Time` 

276 The date of the observation. Unused. 

277 

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