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

103 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:26 -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, UnknownSiteException 

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_MK_FALLBACK_LOCATION = EarthLocation.from_geocentric( 

37 -5464301.77135369, -2493489.8730419, 2151085.16950589, unit=u.m 

38) 

39 

40 

41class MegaPrimeTranslator(FitsTranslator): 

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

43 

44 name = "MegaPrime" 

45 """Name of this translation class""" 

46 

47 supported_instrument = "MegaPrime" 

48 """Supports the MegaPrime instrument.""" 

49 

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

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

52 

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

54 

55 # CFHT Megacam has no rotator, and the instrument angle on sky is set to 

56 # +Y=N, +X=W which we define as a 0 degree rotation. 

57 _const_map = { 

58 "boresight_rotation_angle": Angle(0 * u.deg), 

59 "boresight_rotation_coord": "sky", 

60 "detector_group": None, 

61 } 

62 

63 _trivial_map: dict[str, str | list[str] | tuple[Any, ...]] = { 

64 "physical_filter": "FILTER", 

65 "dark_time": ("DARKTIME", {"unit": u.s}), 

66 "exposure_time": ("EXPTIME", {"unit": u.s}), 

67 "exposure_time_requested": ("EXPREQ", {"unit": u.s}), 

68 "observation_id": "OBSID", 

69 "object": "OBJECT", 

70 "science_program": "RUNID", 

71 "exposure_id": "EXPNUM", 

72 "visit_id": "EXPNUM", 

73 "detector_serial": "CCDNAME", 

74 "relative_humidity": ["RELHUMID", "HUMIDITY"], 

75 "temperature": (["TEMPERAT", "AIRTEMP"], {"unit": u.deg_C}), 

76 "boresight_airmass": ["AIRMASS", "BORE-AIRMASS"], 

77 } 

78 

79 @cache_translation 

80 def to_datetime_begin(self) -> astropy.time.Time: 

81 # Docstring will be inherited. Property defined in properties.py 

82 # We know it is UTC 

83 value = self._from_fits_date_string( 

84 self._header["DATE-OBS"], time_str=self._header["UTC-OBS"], scale="utc" 

85 ) 

86 self._used_these_cards("DATE-OBS", "UTC-OBS") 

87 return value 

88 

89 @cache_translation 

90 def to_datetime_end(self) -> astropy.time.Time: 

91 # Docstring will be inherited. Property defined in properties.py 

92 # Older files are missing UTCEND 

93 if self.is_key_ok("UTCEND"): 

94 # We know it is UTC 

95 value = self._from_fits_date_string( 

96 self._header["DATE-OBS"], time_str=self._header["UTCEND"], scale="utc" 

97 ) 

98 self._used_these_cards("DATE-OBS", "UTCEND") 

99 else: 

100 # Take a guess by adding on the exposure time 

101 value = self.to_datetime_begin() + self.to_exposure_time() 

102 return value 

103 

104 @cache_translation 

105 def to_location(self) -> EarthLocation: 

106 """Calculate the observatory location. 

107 

108 Returns 

109 ------- 

110 location : `astropy.coordinates.EarthLocation` 

111 An object representing the location of the telescope. 

112 """ 

113 # Height is not in some MegaPrime files. Use the value from 

114 # EarthLocation.of_site("CFHT") 

115 # Some data uses OBS-LONG, OBS-LAT, other data uses LONGITUD and 

116 # LATITUDE 

117 for long_key, lat_key in (("LONGITUD", "LATITUDE"), ("OBS-LONG", "OBS-LAT")): 

118 if self.are_keys_ok([long_key, lat_key]): 

119 value = EarthLocation.from_geodetic(self._header[long_key], self._header[lat_key], 4215.0) 

120 self._used_these_cards(long_key, lat_key) 

121 break 

122 else: 

123 try: 

124 value = EarthLocation.of_site("CFHT") 

125 except UnknownSiteException: 

126 value = _MK_FALLBACK_LOCATION 

127 return value 

128 

129 @cache_translation 

130 def to_detector_name(self) -> str: 

131 # Docstring will be inherited. Property defined in properties.py 

132 if self.is_key_ok("EXTNAME"): 

133 name = self._header["EXTNAME"] 

134 # Only valid name has form "ccdNN" 

135 if re.match(r"ccd\d+$", name): 

136 self._used_these_cards("EXTNAME") 

137 return name 

138 

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

140 return "ccd99" 

141 

142 @cache_translation 

143 def to_detector_num(self) -> int: 

144 name = self.to_detector_name() 

145 return int(name[3:]) 

146 

147 @cache_translation 

148 def to_observation_type(self) -> str: 

149 """Calculate the observation type. 

150 

151 Returns 

152 ------- 

153 typ : `str` 

154 Observation type. Normalized to standard set. 

155 """ 

156 obstype = self._header["OBSTYPE"].strip().lower() 

157 self._used_these_cards("OBSTYPE") 

158 if obstype == "object": 

159 return "science" 

160 return obstype 

161 

162 @cache_translation 

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

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

165 

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

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

168 

169 The method supports multiple versions of header defining tracking 

170 coordinates. 

171 

172 Returns 

173 ------- 

174 coords : `astropy.coordinates.SkyCoord` 

175 The tracking coordinates. 

176 """ 

177 radecsys = ("RADECSYS", "OBJRADEC", "RADESYS") 

178 radecpairs = (("RA_DEG", "DEC_DEG"), ("BORE-RA", "BORE-DEC")) 

179 return tracking_from_degree_headers(self, radecsys, radecpairs) 

180 

181 @cache_translation 

182 def to_altaz_begin(self) -> astropy.coordinates.AltAz: 

183 # Docstring will be inherited. Property defined in properties.py 

184 return altaz_from_degree_headers( 

185 self, (("TELALT", "TELAZ"), ("BORE-ALT", "BORE-AZ")), self.to_datetime_begin() 

186 ) 

187 

188 @cache_translation 

189 def to_detector_exposure_id(self) -> int: 

190 # Docstring will be inherited. Property defined in properties.py 

191 return self.to_exposure_id() * 36 + self.to_detector_num() 

192 

193 @cache_translation 

194 def to_pressure(self) -> astropy.units.Quantity: 

195 # Docstring will be inherited. Property defined in properties.py 

196 # Can be either AIRPRESS in Pa or PRESSURE in mbar 

197 for key, unit in (("PRESSURE", u.hPa), ("AIRPRESS", u.Pa)): 

198 if self.is_key_ok(key): 

199 return self.quantity_from_card(key, unit) 

200 

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

202 

203 @cache_translation 

204 def to_observation_counter(self) -> int: 

205 """Return the lifetime exposure number. 

206 

207 Returns 

208 ------- 

209 sequence : `int` 

210 The observation counter. 

211 """ 

212 return self.to_exposure_id() 

213 

214 @classmethod 

215 def determine_translatable_headers( 

216 cls, filename: str, primary: MutableMapping[str, Any] | None = None 

217 ) -> Iterator[MutableMapping[str, Any]]: 

218 """Given a file return all the headers usable for metadata translation. 

219 

220 MegaPrime files are multi-extension FITS with a primary header and 

221 each detector stored in a subsequent extension. MegaPrime uses 

222 ``INHERIT=F`` therefore the primary header will always be ignored 

223 if given. 

224 

225 Parameters 

226 ---------- 

227 filename : `str` 

228 Path to a file in a format understood by this translator. 

229 primary : `dict`-like, optional 

230 The primary header obtained by the caller. This is sometimes 

231 already known, for example if a system is trying to bootstrap 

232 without already knowing what data is in the file. Will be 

233 ignored. 

234 

235 Yields 

236 ------ 

237 headers : iterator of `dict`-like 

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

239 included. 

240 

241 Notes 

242 ----- 

243 This translator class is specifically tailored to raw MegaPrime data 

244 and is not designed to work with general FITS files. The normal 

245 paradigm is for the caller to have read the first header and then 

246 called `determine_translator()` on the result to work out which 

247 translator class to then call to obtain the real headers to be used for 

248 translation. 

249 """ 

250 # Since we want to scan many HDUs we use astropy directly to keep 

251 # the file open rather than continually opening and closing it 

252 # as we go to each HDU. 

253 with fits.open(filename) as fits_file: 

254 for hdu in fits_file: 

255 # Astropy <=4.2 strips the EXTNAME header but some CFHT data 

256 # have two EXTNAME headers and the CCD number is in the 

257 # second one. 

258 if hdu.name == "PRIMARY": 

259 continue 

260 

261 if hdu.name.startswith("ccd"): 

262 # It may only be some data files that are broken so 

263 # handle the expected form. 

264 yield hdu.header 

265 continue 

266 

267 # Some test data at least has the EXTNAME as 

268 # COMPRESSED_IMAGE but the EXTVER as the detector number. 

269 if hdu.name == "COMPRESSED_IMAGE": 

270 header = hdu.header 

271 

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

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

274 yield header 

275 

276 @classmethod 

277 def observing_date_to_offset(cls, observing_date: astropy.time.Time) -> astropy.time.TimeDelta | None: 

278 """Return the offset to use when calculating the observing day. 

279 

280 Parameters 

281 ---------- 

282 observing_date : `astropy.time.Time` 

283 The date of the observation. Unused. 

284 

285 Returns 

286 ------- 

287 offset : `astropy.time.TimeDelta` 

288 The offset to apply. The offset is always 0 seconds. In Hawaii 

289 UTC rollover is at 2pm local time. 

290 """ 

291 return cls._observing_day_offset