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

111 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-08-05 01:30 +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 os 

17import pickle 

18import warnings 

19from collections.abc import MutableMapping 

20from typing import TYPE_CHECKING, Any, NoReturn 

21 

22import astropy.units as u 

23import astropy.utils.exceptions 

24import yaml 

25from astropy.io.fits import Header 

26from astropy.time import Time 

27 

28from astro_metadata_translator import ObservationInfo 

29 

30# PropertyList is optional 

31try: 

32 import lsst.daf.base as daf_base 

33except ImportError: 

34 daf_base = None 

35 

36 

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

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

39try: 

40 Loader = yaml.FullLoader 

41except AttributeError: 

42 Loader = yaml.Loader 

43 

44 

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

46# we can use if daf_base is not available. 

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

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

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

50 yield pl 

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

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

53 if dtype == "Double": 

54 pl[key] = float(value) 

55 elif dtype == "Int": 

56 pl[key] = int(value) 

57 elif dtype == "Bool": 

58 pl[key] = value 

59 else: 

60 pl[key] = value 

61 

62 

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

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

65 

66 

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

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

69 

70 Parameters 

71 ---------- 

72 filename : `str` 

73 Name of file in the data directory. 

74 dir : `str`, optional. 

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

76 specified. 

77 

78 Returns 

79 ------- 

80 header : `dict`-like 

81 Header read from file. 

82 """ 

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

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

85 with open(filename) as fd: 

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

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

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

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

90 return header 

91 

92 

93class MetadataAssertHelper: 

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

95 translations. 

96 """ 

97 

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

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

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

101 

102 def assertAlmostEqual( # noqa: N802 

103 self, 

104 a: float, 

105 b: float, 

106 places: int | None = None, 

107 msg: str | None = None, 

108 delta: float | None = None, 

109 ) -> None: 

110 pass 

111 

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

113 pass 

114 

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

116 pass 

117 

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

119 pass 

120 

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

122 pass 

123 

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

125 pass 

126 

127 def assertCoordinatesConsistent( # noqa: N802 

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

129 ) -> None: 

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

131 

132 Parameters 

133 ---------- 

134 obsinfo : `ObservationInfo` 

135 Object to check. 

136 max_sep : `float`, optional 

137 Maximum separation between AltAz derived from RA/Dec headers 

138 and that found in the AltAz headers. 

139 amdelta : `float`, optional 

140 Max difference between header airmass and derived airmass. 

141 

142 Raises 

143 ------ 

144 AssertionError 

145 Inconsistencies found. 

146 """ 

147 self.assertIsNotNone(obsinfo.tracking_radec) 

148 self.assertIsNotNone(obsinfo.altaz_begin) 

149 

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

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

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

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

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

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

156 

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

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

159 with warnings.catch_warnings(): 

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

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

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

163 

164 def assertObservationInfoFromYaml( # noqa: N802 

165 self, 

166 file: str, 

167 dir: str | None = None, 

168 check_wcs: bool = True, 

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

170 check_altaz: bool = False, 

171 **kwargs: Any, 

172 ) -> None: 

173 """Check contents of an ObservationInfo. 

174 

175 Parameters 

176 ---------- 

177 file : `str` 

178 Path to YAML file representing the header. 

179 dir : `str`, optional 

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

181 check_wcs : `bool`, optional 

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

183 wcs_params : `dict`, optional 

184 Parameters to pass to `assertCoordinatesConsistent`. 

185 check_altaz : `bool`, optional 

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

187 kwargs : `dict` 

188 Keys matching `ObservationInfo` properties with values 

189 to be tested. 

190 

191 Raises 

192 ------ 

193 AssertionError 

194 A value in the ObservationInfo derived from the file is 

195 inconsistent. 

196 """ 

197 header = read_test_file(file, dir=dir) 

198 

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

200 astropy_header = Header() 

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

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

203 for v in values: 

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

205 astropy_header.append((key, v)) 

206 

207 for hdr in (header, astropy_header): 

208 try: 

209 self.assertObservationInfo( 

210 header, 

211 filename=file, 

212 check_wcs=check_wcs, 

213 wcs_params=wcs_params, 

214 check_altaz=check_altaz, 

215 **kwargs, 

216 ) 

217 except AssertionError as e: 

218 raise AssertionError( 

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

220 ) from e 

221 

222 def assertObservationInfo( # noqa: N802 

223 self, 

224 header: MutableMapping[str, Any], 

225 filename: str | None = None, 

226 check_wcs: bool = True, 

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

228 check_altaz: bool = False, 

229 **kwargs: Any, 

230 ) -> None: 

231 """Check contents of an ObservationInfo. 

232 

233 Parameters 

234 ---------- 

235 header : `dict`-like 

236 Header to be checked. 

237 filename : `str`, optional 

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

239 check_wcs : `bool`, optional 

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

241 are automatically disabled if the translated header does 

242 not appear to be "science". 

243 wcs_params : `dict`, optional 

244 Parameters to pass to `assertCoordinatesConsistent`. 

245 check_altaz : `bool`, optional 

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

247 kwargs : `dict` 

248 Keys matching `ObservationInfo` properties with values 

249 to be tested. 

250 

251 Raises 

252 ------ 

253 AssertionError 

254 A value in the ObservationInfo derived from the file is 

255 inconsistent. 

256 """ 

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

258 # of all the translations 

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

260 translator = obsinfo.translator_class_name 

261 

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

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

264 self.assertEqual(obsinfo, newinfo) 

265 

266 # Check the properties 

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

268 calculated = getattr(obsinfo, property) 

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

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

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

272 expected = expected.to_value() 

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

274 elif isinstance(calculated, u.Quantity): 

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

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

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

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

279 else: 

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

281 

282 # Date comparison error reports will benefit by specifying ISO 

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

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

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

286 # the past. 

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

288 if date is not None: 

289 date.format = "isot" 

290 date.precision = 9 

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

292 return date 

293 

294 datetime_begin = _format_date_for_testing(obsinfo.datetime_begin) 

295 datetime_end = _format_date_for_testing(obsinfo.datetime_end) 

296 

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

298 self.assertLessEqual(datetime_begin, datetime_end) 

299 

300 # Check that exposure time is not outside datetime_end 

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

302 

303 # Do we expect an AltAz value or not. 

304 if check_altaz: 

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

306 

307 # Check the WCS consistency 

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

309 if wcs_params is None: 

310 wcs_params = {} 

311 self.assertCoordinatesConsistent(obsinfo, **wcs_params)