Coverage for python/astro_metadata_translator/tests.py: 18%

116 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-25 15:15 +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 

12from __future__ import annotations 

13 

14__all__ = ("read_test_file", "MetadataAssertHelper") 

15 

16import json 

17import os 

18import pickle 

19import warnings 

20from collections.abc import MutableMapping 

21from typing import TYPE_CHECKING, Any, NoReturn 

22 

23import astropy.units as u 

24import astropy.utils.exceptions 

25import yaml 

26from astropy.io.fits import Header 

27from astropy.time import Time 

28 

29from astro_metadata_translator import ObservationInfo 

30 

31# PropertyList is optional 

32try: 

33 import lsst.daf.base as daf_base 

34except ImportError: 

35 daf_base = None 

36 

37 

38# For YAML >= 5.1 need a different Loader for the constructor 

39Loader: type[yaml.Loader] | type[yaml.FullLoader] 

40try: 

41 Loader = yaml.FullLoader 

42except AttributeError: 

43 Loader = yaml.Loader 

44 

45 

46# Define a YAML loader for lsst.daf.base.PropertySet serializations that 

47# we can use if daf_base is not available. 

48def pl_constructor(loader: yaml.Loader, node: yaml.SequenceNode) -> Any: 

49 """Construct an OrderedDict from a YAML file containing a PropertyList.""" 

50 pl: dict[str, Any] = {} 

51 yield pl 

52 state = loader.construct_sequence(node, deep=True) 

53 for key, dtype, value, comment in state: 

54 if dtype == "Double": 

55 pl[key] = float(value) 

56 elif dtype == "Int": 

57 pl[key] = int(value) 

58 elif dtype == "Bool": 

59 pl[key] = value 

60 else: 

61 pl[key] = value 

62 

63 

64if daf_base is None: 64 ↛ 65line 64 didn't jump to line 65, because the condition on line 64 was never true

65 yaml.add_constructor("lsst.daf.base.PropertyList", pl_constructor, Loader=Loader) # type: ignore 

66 

67 

68def read_test_file(filename: str, dir: str | None = None) -> MutableMapping[str, Any]: 

69 """Read the named test file relative to the location of this helper. 

70 

71 Parameters 

72 ---------- 

73 filename : `str` 

74 Name of file in the data directory. 

75 dir : `str`, optional. 

76 Directory from which to read file. Current directory used if none 

77 specified. 

78 

79 Returns 

80 ------- 

81 header : `dict`-like 

82 Header read from file. 

83 """ 

84 if dir is not None and not os.path.isabs(filename): 

85 filename = os.path.join(dir, filename) 

86 with open(filename) as fd: 

87 if filename.endswith(".yaml"): 

88 header = yaml.load(fd, Loader=Loader) 

89 elif filename.endswith(".json"): 

90 header = json.load(fd) 

91 else: 

92 raise RuntimeError(f"Unrecognized file format: {filename}") 

93 

94 # Cannot directly check for Mapping because PropertyList is not one 

95 if not hasattr(header, "items"): 

96 raise ValueError(f"Contents of YAML file {filename} are not a mapping, they are {type(header)}") 

97 return header 

98 

99 

100class MetadataAssertHelper: 

101 """Class with helpful asserts that can be used for testing metadata 

102 translations. 

103 """ 

104 

105 # This class is assumed to be combined with unittest.TestCase but mypy 

106 # does not know this. We need to teach mypy about the APIs we are using. 

107 if TYPE_CHECKING: 107 ↛ 109line 107 didn't jump to line 109, because the condition on line 107 was never true

108 

109 def assertAlmostEqual( # noqa: N802 

110 self, 

111 a: float, 

112 b: float, 

113 places: int | None = None, 

114 msg: str | None = None, 

115 delta: float | None = None, 

116 ) -> None: 

117 pass 

118 

119 def assertIsNotNone(self, a: Any, msg: str | None = None) -> None: # noqa: N802 

120 pass 

121 

122 def assertEqual(self, a: Any, b: Any, msg: str | None = None) -> None: # noqa: N802 

123 pass 

124 

125 def assertLess(self, a: Any, b: Any, msg: str | None = None) -> None: # noqa: N802 

126 pass 

127 

128 def assertLessEqual(self, a: Any, b: Any, msg: str | None = None) -> None: # noqa: N802 

129 pass 

130 

131 def fail(self, msg: str) -> NoReturn: 

132 pass 

133 

134 def assertCoordinatesConsistent( # noqa: N802 

135 self, obsinfo: ObservationInfo, max_sep: float = 1.0, amdelta: float = 0.01 

136 ) -> None: 

137 """Check that SkyCoord, AltAz, and airmass are self consistent. 

138 

139 Parameters 

140 ---------- 

141 obsinfo : `ObservationInfo` 

142 Object to check. 

143 max_sep : `float`, optional 

144 Maximum separation between AltAz derived from RA/Dec headers 

145 and that found in the AltAz headers. 

146 amdelta : `float`, optional 

147 Max difference between header airmass and derived airmass. 

148 

149 Raises 

150 ------ 

151 AssertionError 

152 Inconsistencies found. 

153 """ 

154 self.assertIsNotNone(obsinfo.tracking_radec) 

155 self.assertIsNotNone(obsinfo.altaz_begin) 

156 

157 # Is airmass from header close to airmass from AltAz headers? 

158 # In most cases there is uncertainty over whether the elevation 

159 # and airmass in the header are for the start, end, or middle 

160 # of the observation. Sometimes the AltAz is from the start 

161 # but the airmass is from the middle so accuracy is not certain. 

162 self.assertAlmostEqual(obsinfo.altaz_begin.secz.to_value(), obsinfo.boresight_airmass, delta=amdelta) 

163 

164 # Is AltAz from headers close to AltAz from RA/Dec headers? 

165 # Can trigger warnings from Astropy if date is in future 

166 with warnings.catch_warnings(): 

167 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning) 

168 sep = obsinfo.altaz_begin.separation(obsinfo.tracking_radec.altaz) 

169 self.assertLess(sep.to_value(unit="arcmin"), max_sep, msg="AltAz inconsistent with RA/Dec") 

170 

171 def assertObservationInfoFromYaml( # noqa: N802 

172 self, 

173 file: str, 

174 dir: str | None = None, 

175 check_wcs: bool = True, 

176 wcs_params: dict[str, Any] | None = None, 

177 check_altaz: bool = False, 

178 **kwargs: Any, 

179 ) -> None: 

180 """Check contents of an ObservationInfo. 

181 

182 Parameters 

183 ---------- 

184 file : `str` 

185 Path to YAML file representing the header. 

186 dir : `str`, optional 

187 Optional directory from which to read ``file``. 

188 check_wcs : `bool`, optional 

189 Check the consistency of the RA/Dec and AltAz values. 

190 wcs_params : `dict`, optional 

191 Parameters to pass to `assertCoordinatesConsistent`. 

192 check_altaz : `bool`, optional 

193 Check that an alt/az value has been calculated. 

194 kwargs : `dict` 

195 Keys matching `ObservationInfo` properties with values 

196 to be tested. 

197 

198 Raises 

199 ------ 

200 AssertionError 

201 A value in the ObservationInfo derived from the file is 

202 inconsistent. 

203 """ 

204 header = read_test_file(file, dir=dir) 

205 

206 # DM-30093: Astropy Header does not always behave dict-like 

207 astropy_header = Header() 

208 for key, val in header.items(): 

209 values = val if isinstance(val, list) else [val] 

210 for v in values: 

211 # appending ensures *all* duplicated keys are also preserved 

212 astropy_header.append((key, v)) 

213 

214 for hdr in (header, astropy_header): 

215 try: 

216 self.assertObservationInfo( 

217 header, 

218 filename=file, 

219 check_wcs=check_wcs, 

220 wcs_params=wcs_params, 

221 check_altaz=check_altaz, 

222 **kwargs, 

223 ) 

224 except AssertionError as e: 

225 raise AssertionError( 

226 f"ObservationInfo derived from {type(hdr)} type is inconsistent: {e}" 

227 ) from e 

228 

229 def assertObservationInfo( # noqa: N802 

230 self, 

231 header: MutableMapping[str, Any], 

232 filename: str | None = None, 

233 check_wcs: bool = True, 

234 wcs_params: dict[str, Any] | None = None, 

235 check_altaz: bool = False, 

236 **kwargs: Any, 

237 ) -> None: 

238 """Check contents of an ObservationInfo. 

239 

240 Parameters 

241 ---------- 

242 header : `dict`-like 

243 Header to be checked. 

244 filename : `str`, optional 

245 Name of the filename associated with this header if known. 

246 check_wcs : `bool`, optional 

247 Check the consistency of the RA/Dec and AltAz values. Checks 

248 are automatically disabled if the translated header does 

249 not appear to be "science". 

250 wcs_params : `dict`, optional 

251 Parameters to pass to `assertCoordinatesConsistent`. 

252 check_altaz : `bool`, optional 

253 Check that an alt/az value has been calculated. 

254 kwargs : `dict` 

255 Keys matching `ObservationInfo` properties with values 

256 to be tested. 

257 

258 Raises 

259 ------ 

260 AssertionError 

261 A value in the ObservationInfo derived from the file is 

262 inconsistent. 

263 """ 

264 # For testing we force pedantic mode since we are in charge 

265 # of all the translations 

266 obsinfo = ObservationInfo(header, pedantic=True, filename=filename) 

267 translator = obsinfo.translator_class_name 

268 

269 # Check that we can pickle and get back the same properties 

270 newinfo = pickle.loads(pickle.dumps(obsinfo)) 

271 self.assertEqual(obsinfo, newinfo) 

272 

273 # Check the properties 

274 for property, expected in kwargs.items(): 

275 calculated = getattr(obsinfo, property) 

276 msg = f"Comparing property {property} using translator {translator}" 

277 if isinstance(expected, u.Quantity) and calculated is not None: 

278 calculated = calculated.to_value(unit=expected.unit) 

279 expected = expected.to_value() 

280 self.assertAlmostEqual(calculated, expected, msg=msg) 

281 elif isinstance(calculated, u.Quantity): 

282 # Only happens if the test is not a quantity when it should be 

283 self.fail(f"Expected {expected!r} but got Quantity '{calculated}': {msg}") 

284 elif isinstance(expected, float) and calculated is not None: 

285 self.assertAlmostEqual(calculated, expected, msg=msg) 

286 else: 

287 self.assertEqual(calculated, expected, msg=msg) 

288 

289 # Date comparison error reports will benefit by specifying ISO 

290 # format. Generate a new Time object at fixed precision 

291 # to work around the fact that (as of astropy 3.1) adding 0.0 seconds 

292 # to a Time results in a new Time object that is a few picoseconds in 

293 # the past. 

294 def _format_date_for_testing(date: Time | None) -> Time | None: 

295 if date is not None: 

296 date.format = "isot" 

297 date.precision = 9 

298 date = Time(str(date), scale=date.scale, format="isot") 

299 return date 

300 

301 datetime_begin = _format_date_for_testing(obsinfo.datetime_begin) 

302 datetime_end = _format_date_for_testing(obsinfo.datetime_end) 

303 

304 # Check that dates are defined and end is the same or after beginning 

305 self.assertLessEqual(datetime_begin, datetime_end) 

306 

307 # Check that exposure time is not outside datetime_end 

308 self.assertLessEqual(obsinfo.datetime_begin + obsinfo.exposure_time, obsinfo.datetime_end) 

309 

310 # Do we expect an AltAz value or not. 

311 if check_altaz: 

312 self.assertIsNotNone(obsinfo.altaz_begin, "altaz_begin is None but should have a value") 

313 

314 # Check the WCS consistency 

315 if check_wcs and obsinfo.observation_type == "science": 

316 if wcs_params is None: 

317 wcs_params = {} 

318 self.assertCoordinatesConsistent(obsinfo, **wcs_params)