Coverage for python / astro_metadata_translator / translators / fits.py: 23%

70 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 08:50 +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 standard FITS headers.""" 

13 

14from __future__ import annotations 

15 

16__all__ = ("FitsTranslator",) 

17 

18from collections.abc import Mapping 

19from typing import Any 

20 

21import astropy.units as u 

22from astropy.coordinates import EarthLocation 

23from astropy.time import Time 

24 

25from ..translator import MetadataTranslator, cache_translation 

26 

27 

28class FitsTranslator(MetadataTranslator): 

29 """Metadata translator for FITS standard headers. 

30 

31 Understands: 

32 

33 - DATE-OBS/MJD-OBS or DATE-BEG/MJD-BEG (-BEG is preferred). 

34 - INSTRUME 

35 - TELESCOP 

36 - OBSGEO-[X,Y,Z] 

37 """ 

38 

39 # Direct translation from header key to standard form 

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

41 "instrument": "INSTRUME", 

42 "telescope": "TELESCOP", 

43 } 

44 

45 @classmethod 

46 def can_translate(cls, header: Mapping[str, Any], filename: str | None = None) -> bool: 

47 """Indicate whether this translation class can translate the 

48 supplied header. 

49 

50 Checks the instrument value and compares with the supported 

51 instruments in the class 

52 

53 Parameters 

54 ---------- 

55 header : `dict`-like 

56 Header to convert to standardized form. 

57 filename : `str`, optional 

58 Name of file being translated. 

59 

60 Returns 

61 ------- 

62 can : `bool` 

63 `True` if the header is recognized by this class. `False` 

64 otherwise. 

65 """ 

66 if cls.supported_instrument is None: 

67 return False 

68 

69 # Protect against being able to always find a standard 

70 # header for instrument 

71 try: 

72 translator = cls(header, filename=filename) 

73 instrument = translator.to_instrument() 

74 except KeyError: 

75 return False 

76 

77 return instrument == cls.supported_instrument 

78 

79 @classmethod 

80 def _from_fits_date_string(cls, date_str: str, scale: str = "utc", time_str: str | None = None) -> Time: 

81 """Parse standard FITS ISO-style date string and return time object. 

82 

83 Parameters 

84 ---------- 

85 date_str : `str` 

86 FITS format date string to convert to standard form. Bypasses 

87 lookup in the header. 

88 scale : `str`, optional 

89 Override the time scale from the TIMESYS header. Defaults to 

90 UTC. 

91 time_str : `str`, optional 

92 If provided, overrides any time component in the ``dateStr``, 

93 retaining the YYYY-MM-DD component and appending this time 

94 string, assumed to be of format HH:MM::SS.ss. 

95 

96 Returns 

97 ------- 

98 date : `astropy.time.Time` 

99 `~astropy.time.Time` representation of the date. 

100 """ 

101 if time_str is not None: 

102 date_str = f"{date_str[:10]}T{time_str}" 

103 

104 return Time(date_str, format="isot", scale=scale) 

105 

106 def _from_fits_date( 

107 self, date_key: str, mjd_key: str | None = None, scale: str | None = None 

108 ) -> Time | None: 

109 """Calculate a date object from the named FITS header. 

110 

111 Uses the TIMESYS header if present to determine the time scale, 

112 defaulting to UTC. Can be overridden since sometimes headers 

113 use TIMESYS for DATE- style headers but also have headers using 

114 different time scales. 

115 

116 Parameters 

117 ---------- 

118 date_key : `str` 

119 The key in the header representing a standard FITS 

120 ISO-style date. Can be `None` to go straight to MJD key. 

121 mjd_key : `str`, optional 

122 The key in the header representing a standard FITS MJD 

123 style date. This key will be tried if ``date_key`` is not 

124 found, is `None`, or can not be parsed. 

125 scale : `str`, optional 

126 Override value to use for the time scale in preference to 

127 TIMESYS or the default. Should be a form understood by 

128 `~astropy.time.Time`. 

129 

130 Returns 

131 ------- 

132 date : `astropy.time.Time` 

133 `~astropy.time.Time` representation of the date. 

134 """ 

135 used = [] 

136 if scale is not None: 

137 pass 

138 elif self.is_key_ok("TIMESYS"): 

139 scale = self._header["TIMESYS"].lower() 

140 used.append("TIMESYS") 

141 else: 

142 scale = "utc" 

143 if date_key is not None and self.is_key_ok(date_key): 

144 date_str = self._header[date_key] 

145 value = self._from_fits_date_string(date_str, scale=scale) 

146 used.append(date_key) 

147 elif self.is_key_ok(mjd_key): 

148 assert mjd_key is not None # for mypy (is_key_ok checks this) 

149 value = Time(self._header[mjd_key], scale=scale, format="mjd") 

150 used.append(mjd_key) 

151 else: 

152 value = None 

153 self._used_these_cards(*used) 

154 return value 

155 

156 @cache_translation 

157 def to_datetime_begin(self) -> Time | None: 

158 """Calculate start time of observation. 

159 

160 Uses FITS standard ``MJD-BEG`` or ``DATE-BEG``, in conjunction 

161 with the ``TIMESYS`` header. Will fallback to using ``MJD-OBS`` 

162 or ``DATE-OBS`` if the ``-BEG`` variants are not found. 

163 

164 Returns 

165 ------- 

166 start_time : `astropy.time.Time` or `None` 

167 Time corresponding to the start of the observation. Returns 

168 `None` if no date can be found. 

169 """ 

170 # Prefer -BEG over -OBS 

171 begin = None 

172 for suffix in ("BEG", "OBS"): 

173 begin = self._from_fits_date(f"DATE-{suffix}", mjd_key=f"MJD-{suffix}") 

174 if begin is not None: 

175 break 

176 if begin is not None: 

177 return begin 

178 

179 # Fallback to AVG if we are missing these, but then also need exposure 

180 # time. 

181 # If this is a VisitInfo the scale is always TAI and not TIMESYS. 

182 # INSTRUMENT was added in 2021. 

183 # BORE-xxx have been there since the beginning. We hope that raw 

184 # date does not use these headers in any instrument. 

185 scale = None 

186 if ("INSTRUMENT" in self._header and "BORE-AIRMASS" in self._header) or ( 

187 "BORE-AIRMASS" in self._header and "BORE-AZ" in self._header and "BORE-RA" in self._header 

188 ): 

189 scale = "tai" 

190 avg = self._from_fits_date("DATE-AVG", mjd_key="MJD-AVG", scale=scale) 

191 if avg is not None: 

192 begin = avg - (self.to_exposure_time() / 2.0) 

193 return begin 

194 

195 @cache_translation 

196 def to_datetime_end(self) -> Time | None: 

197 """Calculate end time of observation. 

198 

199 Uses FITS standard ``MJD-END`` or ``DATE-END``, in conjunction 

200 with the ``TIMESYS`` header. 

201 

202 Returns 

203 ------- 

204 start_time : `astropy.time.Time` 

205 Time corresponding to the end of the observation. 

206 """ 

207 return self._from_fits_date("DATE-END", mjd_key="MJD-END") 

208 

209 @cache_translation 

210 def to_location(self) -> EarthLocation: 

211 """Calculate the observatory location. 

212 

213 Uses FITS standard ``OBSGEO-`` headers. 

214 

215 Returns 

216 ------- 

217 location : `astropy.coordinates.EarthLocation` 

218 An object representing the location of the telescope. 

219 """ 

220 cards = [f"OBSGEO-{c}" for c in ("X", "Y", "Z")] 

221 coords = [self._header[c] for c in cards] 

222 value = EarthLocation.from_geocentric(*coords, unit=u.m) 

223 self._used_these_cards(*cards) 

224 return value