Coverage for python / astro_metadata_translator / translators / suprimecam.py: 34%

117 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:38 +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 SuprimeCam FITS headers.""" 

13 

14from __future__ import annotations 

15 

16__all__ = ("SuprimeCamTranslator",) 

17 

18import logging 

19import posixpath 

20import re 

21from collections.abc import Mapping 

22from typing import TYPE_CHECKING, Any 

23 

24import astropy.units as u 

25from astropy.coordinates import Angle, SkyCoord 

26 

27from ..translator import CORRECTIONS_RESOURCE_ROOT, cache_translation 

28from .helpers import altaz_from_degree_headers 

29from .subaru import SubaruTranslator 

30 

31if TYPE_CHECKING: 

32 import astropy.coordinates 

33 import astropy.time 

34 

35log = logging.getLogger(__name__) 

36 

37 

38class SuprimeCamTranslator(SubaruTranslator): 

39 """Metadata translator for HSC standard headers.""" 

40 

41 name = "SuprimeCam" 

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

43 

44 supported_instrument = "SuprimeCam" 

45 """Supports the SuprimeCam instrument.""" 

46 

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

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

49 

50 _const_map = {"boresight_rotation_coord": "unknown", "detector_group": None} 

51 """Constant mappings""" 

52 

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

54 "observation_id": "EXP-ID", 

55 "object": "OBJECT", 

56 "science_program": "PROP-ID", 

57 "detector_num": "DET-ID", 

58 "detector_serial": "DETECTOR", # DETECTOR is the "call name" 

59 "boresight_airmass": "AIRMASS", 

60 "relative_humidity": "OUT-HUM", 

61 "temperature": ("OUT-TMP", {"unit": u.K}), 

62 "pressure": ("OUT-PRS", {"unit": u.hPa}), 

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

64 "dark_time": ("EXPTIME", {"unit": u.s}), # Assume same as exposure time 

65 } 

66 """One-to-one mappings""" 

67 

68 # Zero point for SuprimeCam dates: 2004-01-01 

69 _DAY0 = 53005 

70 

71 @classmethod 

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

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

74 supplied header. 

75 

76 Parameters 

77 ---------- 

78 header : `dict`-like 

79 Header to convert to standardized form. 

80 filename : `str`, optional 

81 Name of file being translated. 

82 

83 Returns 

84 ------- 

85 can : `bool` 

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

87 otherwise. 

88 """ 

89 if "INSTRUME" in header: 

90 return header["INSTRUME"] == "SuprimeCam" 

91 

92 for k in ("EXP-ID", "FRAMEID"): 

93 if cls.is_keyword_defined(header, k): 

94 if header[k].startswith("SUP"): 

95 return True 

96 return False 

97 

98 def _get_adjusted_mjd(self) -> int: 

99 """Calculate the modified julian date offset from reference day. 

100 

101 Returns 

102 ------- 

103 offset : `int` 

104 Offset day count from reference day. 

105 """ 

106 mjd = self._header["MJD"] 

107 self._used_these_cards("MJD") 

108 return int(mjd) - self._DAY0 

109 

110 @cache_translation 

111 def to_physical_filter(self) -> str: 

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

113 value = self._header["FILTER01"].strip().upper() 

114 self._used_these_cards("FILTER01") 

115 # Map potential "unknown" values to standard form 

116 if value in {"UNRECOGNIZED", "UNRECOGNISED", "NOTSET", "UNKNOWN"}: 

117 value = "unknown" 

118 elif value == "NONE": 

119 value = "empty" 

120 return value 

121 

122 @cache_translation 

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

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

125 # We know it is UTC. Also HSC date are non-standard with DATE-OBS 

126 # only being YYYY-MM-DD. 

127 value = None 

128 if self.is_key_ok("DATE-OBS"): 

129 # This should not happen for raw data but might happen for 

130 # visit images. 

131 value = self._from_fits_date_string( 

132 self._header["DATE-OBS"], time_str=self._header["UT-STR"], scale="utc" 

133 ) 

134 self._used_these_cards("DATE-OBS", "UT-STR") 

135 else: 

136 # Fallback to standard FITS in case AVG is present. 

137 value = super().to_datetime_begin() 

138 

139 if value is None: 

140 raise KeyError("Unable to find any usable date headers to calculate datetime_begin") 

141 

142 return value 

143 

144 @cache_translation 

145 def to_datetime_end(self) -> astropy.time.Time | None: 

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

147 # We know it is UTC. 

148 

149 # For validation. 

150 exposure_time = self.to_exposure_time() 

151 datetime_begin = self.to_datetime_begin() 

152 

153 # This should be there for raws but maybe not for visits. 

154 value = None 

155 if self.is_key_ok("DATE-OBS"): 

156 value = self._from_fits_date_string( 

157 self._header["DATE-OBS"], time_str=self._header["UT-END"], scale="utc" 

158 ) 

159 self._used_these_cards("DATE-OBS", "UT-END") 

160 elif datetime_begin is not None and exposure_time is not None: 

161 value = datetime_begin + exposure_time 

162 

163 # Sometimes the end time is less than the begin time plus the 

164 # exposure time so we have to check for that. 

165 exposure_end = datetime_begin + exposure_time 

166 if value < exposure_end: 

167 value = exposure_end 

168 

169 return value 

170 

171 @cache_translation 

172 def to_exposure_id(self) -> int: 

173 """Calculate unique exposure integer for this observation. 

174 

175 Returns 

176 ------- 

177 visit : `int` 

178 Integer uniquely identifying this exposure. 

179 """ 

180 exp_id = self._header["EXP-ID"].strip() 

181 m = re.search(r"^SUP[A-Z](\d{7})0$", exp_id) 

182 if not m: 

183 raise RuntimeError(f"{self._log_prefix}: Unable to interpret EXP-ID: {exp_id}") 

184 exposure = int(m.group(1)) 

185 if int(exposure) == 0: 

186 # Don't believe it 

187 frame_id = self._header["FRAMEID"].strip() 

188 m = re.search(r"^SUP[A-Z](\d{7})\d{1}$", frame_id) 

189 if not m: 

190 raise RuntimeError(f"{self._log_prefix}: Unable to interpret FRAMEID: {frame_id}") 

191 exposure = int(m.group(1)) 

192 self._used_these_cards("EXP-ID", "FRAMEID") 

193 return exposure 

194 

195 @cache_translation 

196 def to_visit_id(self) -> int: 

197 """Calculate the unique integer ID for this visit. 

198 

199 Assumed to be identical to the exposure ID in this implementation. 

200 

201 Returns 

202 ------- 

203 exp : `int` 

204 Unique visit identifier. 

205 """ 

206 return self.to_exposure_id() 

207 

208 @cache_translation 

209 def to_observation_type(self) -> str: 

210 """Calculate the observation type. 

211 

212 Returns 

213 ------- 

214 typ : `str` 

215 Observation type. Normalized to standard set. 

216 """ 

217 obstype = self._header["DATA-TYP"].strip().lower() 

218 self._used_these_cards("DATA-TYP") 

219 if obstype == "object": 

220 return "science" 

221 return obstype 

222 

223 @cache_translation 

224 def to_tracking_radec(self) -> SkyCoord: 

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

226 radec = SkyCoord( 

227 self._header["RA2000"], 

228 self._header["DEC2000"], 

229 frame="icrs", 

230 unit=(u.hourangle, u.deg), 

231 obstime=self.to_datetime_begin(), 

232 location=self.to_location(), 

233 ) 

234 self._used_these_cards("RA2000", "DEC2000") 

235 return radec 

236 

237 @cache_translation 

238 def to_altaz_begin(self) -> astropy.coordinates.AltAz | None: 

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

240 return altaz_from_degree_headers(self, (("ALTITUDE", "AZIMUTH"),), self.to_datetime_begin()) 

241 

242 @cache_translation 

243 def to_boresight_rotation_angle(self) -> Angle: 

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

245 angle = Angle(self.quantity_from_card("INR-STR", u.deg)) 

246 angle.wrap_at("360d", inplace=True) 

247 return angle 

248 

249 @cache_translation 

250 def to_detector_exposure_id(self) -> int: 

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

252 return self.to_exposure_id() * 10 + self.to_detector_num() 

253 

254 @cache_translation 

255 def to_detector_name(self) -> str: 

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

257 # See https://subarutelescope.org/Observing/Instruments/SCam/ccd.html 

258 num = self.to_detector_num() 

259 

260 names = ( 

261 "nausicaa", 

262 "kiki", 

263 "fio", 

264 "sophie", 

265 "sheeta", 

266 "satsuki", 

267 "chihiro", 

268 "clarisse", 

269 "ponyo", 

270 "san", 

271 ) 

272 

273 return names[num]