Coverage for python / astro_metadata_translator / translators / sdss.py: 40%

104 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 SDSS FITS headers.""" 

13 

14from __future__ import annotations 

15 

16__all__ = ("SdssTranslator",) 

17 

18import posixpath 

19from collections.abc import Mapping 

20from typing import TYPE_CHECKING, Any 

21 

22import astropy.units as u 

23from astropy.coordinates import AltAz, Angle, EarthLocation, UnknownSiteException 

24 

25from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation 

26from .fits import FitsTranslator 

27from .helpers import tracking_from_degree_headers 

28 

29if TYPE_CHECKING: 

30 import astropy.coordinates 

31 import astropy.time 

32 

33 

34# Hard-code APO location fallback. 

35_APO_LOCATION = EarthLocation.from_geocentric( 

36 -1463969.30185172, -5166673.34223433, 3434985.71204565, unit=u.m 

37) 

38 

39 

40class SdssTranslator(FitsTranslator): 

41 """Metadata translator for SDSS standard headers. 

42 NB: calibration data is not handled as calibration frames were 

43 not available to me at time of writing. 

44 """ 

45 

46 name = "SDSS" 

47 """Name of this translation class""" 

48 

49 supported_instrument = "Imager" 

50 """Supports the SDSS imager instrument.""" 

51 

52 default_resource_root = posixpath.join(CORRECTIONS_RESOURCE_ROOT, "SDSS") 

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

54 

55 # SDSS has has a rotator, but in drift scan mode, the instrument 

56 # angle on sky is set to +X=East, +Y=North which we define as a 

57 # 0 degree rotation. 

58 _const_map = { 

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

60 "boresight_rotation_coord": "sky", 

61 "dark_time": 0.0 * u.s, # Drift scan implies no dark time 

62 "instrument": "Imager on SDSS 2.5m", # We only ever ingest data from the imager 

63 "telescope": "SDSS 2.5m", # Value of TELESCOP in header is ambiguous 

64 "relative_humidity": None, 

65 "temperature": None, 

66 "pressure": None, 

67 "detector_serial": "UNKNOWN", 

68 } 

69 

70 _trivial_map = { 

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

72 "object": "OBJECT", 

73 "physical_filter": "FILTER", 

74 "exposure_id": "RUN", 

75 "visit_id": "RUN", 

76 "science_program": "OBJECT", # This is the closest I can think of to a useful program 

77 "detector_name": "CCDLOC", # This is a numeric incoding of the "slot", i.e. filter+camcol 

78 } 

79 

80 # Need a mapping from unique name to index. The order is arbitrary. 

81 detector_name_id_map = { 

82 "g1": 0, 

83 "z1": 1, 

84 "u1": 2, 

85 "i1": 3, 

86 "r1": 4, 

87 "g2": 5, 

88 "z2": 6, 

89 "u2": 7, 

90 "i2": 8, 

91 "r2": 9, 

92 "g3": 10, 

93 "z3": 11, 

94 "u3": 12, 

95 "i3": 13, 

96 "r3": 14, 

97 "g4": 15, 

98 "z4": 16, 

99 "u4": 17, 

100 "i4": 18, 

101 "r4": 19, 

102 "g5": 20, 

103 "z5": 21, 

104 "u5": 22, 

105 "i5": 23, 

106 "r5": 24, 

107 "g6": 25, 

108 "z6": 26, 

109 "u6": 27, 

110 "i6": 28, 

111 "r6": 29, 

112 } 

113 

114 @classmethod 

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

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

117 supplied header. 

118 

119 Parameters 

120 ---------- 

121 header : `dict`-like 

122 Header to convert to standardized form. 

123 filename : `str`, optional 

124 Name of file being translated. 

125 

126 Returns 

127 ------- 

128 can : `bool` 

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

130 otherwise. 

131 """ 

132 if ( 

133 cls.is_keyword_defined(header, "ORIGIN") 

134 and cls.is_keyword_defined(header, "CCDMODE") 

135 and cls.is_keyword_defined(header, "TELESCOP") 

136 and "2.5m" in header["TELESCOP"] 

137 and "SDSS" in header["ORIGIN"] 

138 and "DRIFT" in header["CCDMODE"] 

139 ): 

140 return True 

141 return False 

142 

143 @cache_translation 

144 def to_detector_unique_name(self) -> str: 

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

146 if self.is_key_ok("CAMCOL"): 

147 return self.to_physical_filter() + str(self._header["CAMCOL"]) 

148 else: 

149 raise ValueError(f"{self._log_prefix}: CAMCOL key is not definded") 

150 

151 @cache_translation 

152 def to_detector_num(self) -> int: 

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

154 return self.detector_name_id_map[self.to_detector_unique_name()] 

155 

156 @cache_translation 

157 def to_observation_id(self) -> str: 

158 """Calculate the observation ID. 

159 

160 Returns 

161 ------- 

162 observation_id : `str` 

163 A string uniquely describing the observation. 

164 This incorporates the run, camcol, filter and frame. 

165 """ 

166 return " ".join([str(self._header[el]) for el in ["RUN", "CAMCOL", "FILTER", "FRAME"]]) 

167 

168 @cache_translation 

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

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

171 # We know it is UTC 

172 value = self._from_fits_date_string( 

173 self._header["DATE-OBS"], time_str=self._header["TAIHMS"], scale="tai" 

174 ) 

175 self._used_these_cards("DATE-OBS", "TAIHMS") 

176 return value 

177 

178 @cache_translation 

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

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

181 return self.to_datetime_begin() + self.to_exposure_time() 

182 

183 @cache_translation 

184 def to_location(self) -> EarthLocation: 

185 """Calculate the observatory location. 

186 

187 Returns 

188 ------- 

189 location : `astropy.coordinates.EarthLocation` 

190 An object representing the location of the telescope. 

191 """ 

192 # Look up the value since files do not have location. 

193 # This might require a network look up if the cached database 

194 # is not accessible. If it fails fall back to hard-coded location. 

195 try: 

196 value = EarthLocation.of_site("apo") 

197 except UnknownSiteException: 

198 value = _APO_LOCATION 

199 

200 return value 

201 

202 @cache_translation 

203 def to_observation_type(self) -> str: 

204 """Calculate the observation type. 

205 

206 Returns 

207 ------- 

208 typ : `str` 

209 Observation type. Normalized to standard set. 

210 """ 

211 obstype_key = "FLAVOR" 

212 if not self.is_key_ok(obstype_key): 

213 return "none" 

214 obstype = self._header[obstype_key].strip().lower() 

215 self._used_these_cards(obstype_key) 

216 return obstype 

217 

218 @cache_translation 

219 def to_tracking_radec(self) -> astropy.coordinates.SkyCoord | None: 

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

221 radecsys = ("RADECSYS",) 

222 radecpairs = (("RA", "DEC"),) 

223 return tracking_from_degree_headers(self, radecsys, radecpairs, unit=u.deg) 

224 

225 @cache_translation 

226 def to_altaz_begin(self) -> AltAz | None: 

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

228 try: 

229 az = self._header["AZ"] 

230 alt = self._header["ALT"] 

231 # It appears SDSS defines azimuth as increasing 

232 # from South through East. This translates to 

233 # North through East 

234 az = (-az + 180.0) % 360.0 

235 altaz = AltAz( 

236 az * u.deg, alt * u.deg, obstime=self.to_datetime_begin(), location=self.to_location() 

237 ) 

238 self._used_these_cards("AZ", "ALT") 

239 return altaz 

240 except Exception as e: 

241 if self.to_observation_type() != "science": 

242 return None # Allow Alt/Az not to be set for calibrations 

243 raise (e) 

244 

245 @cache_translation 

246 def to_boresight_airmass(self) -> float | None: 

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

248 altaz = self.to_altaz_begin() 

249 if altaz is not None: 

250 return altaz.secz.value # This is an estimate 

251 return None 

252 

253 @cache_translation 

254 def to_detector_exposure_id(self) -> int | None: 

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

256 run = self.to_exposure_id() 

257 filt = self.to_physical_filter() 

258 try: 

259 frame_field_map = {"r": 0, "i": 2, "u": 4, "z": 6, "g": 8} 

260 camcol = self._header["CAMCOL"] 

261 field = self._header["FRAME"] - frame_field_map[filt] 

262 self._used_these_cards("CAMCOL", "FRAME") 

263 except Exception as e: 

264 if self.to_observation_type() != "science": 

265 return None 

266 raise (e) 

267 filter_id_map = {"u": 0, "g": 1, "r": 2, "i": 3, "z": 4} 

268 return ((int(run) * 10 + filter_id_map[filt]) * 10 + int(camcol)) * 10000 + int(field) 

269 

270 @cache_translation 

271 def to_detector_group(self) -> str: 

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

273 if self.is_key_ok("CAMCOL"): 

274 return str(self._header["CAMCOL"]) 

275 else: 

276 raise ValueError(f"{self._log_prefix}: CAMCOL key is not definded")