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

110 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-03-31 02:46 -0700

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 typing import TYPE_CHECKING, Any, Dict, MutableMapping, NoReturn, Optional, Type 

20 

21import astropy.units as u 

22import astropy.utils.exceptions 

23import yaml 

24from astropy.io.fits import Header 

25from astropy.time import Time 

26 

27from astro_metadata_translator import ObservationInfo 

28 

29# PropertyList is optional 

30try: 

31 import lsst.daf.base as daf_base 

32except ImportError: 

33 daf_base = None 

34 

35 

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

37Loader: Type[yaml.Loader] | Type[yaml.FullLoader] 

38try: 

39 Loader = yaml.FullLoader 

40except AttributeError: 

41 Loader = yaml.Loader 

42 

43 

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

45# we can use if daf_base is not available. 

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

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

48 pl: Dict[str, Any] = {} 

49 yield pl 

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

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

52 if dtype == "Double": 

53 pl[key] = float(value) 

54 elif dtype == "Int": 

55 pl[key] = int(value) 

56 elif dtype == "Bool": 

57 pl[key] = value 

58 else: 

59 pl[key] = value 

60 

61 

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

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

64 

65 

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

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

68 

69 Parameters 

70 ---------- 

71 filename : `str` 

72 Name of file in the data directory. 

73 dir : `str`, optional. 

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

75 specified. 

76 

77 Returns 

78 ------- 

79 header : `dict`-like 

80 Header read from file. 

81 """ 

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

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

84 with open(filename) as fd: 

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

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

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

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

89 return header 

90 

91 

92class MetadataAssertHelper: 

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

94 translations. 

95 """ 

96 

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

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

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

100 

101 def assertAlmostEqual( # noqa: N802 

102 self, 

103 a: float, 

104 b: float, 

105 places: Optional[int] = None, 

106 msg: Optional[str] = None, 

107 delta: Optional[float] = None, 

108 ) -> None: 

109 pass 

110 

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

112 pass 

113 

114 def assertEqual(self, a: Any, b: Any, msg: Optional[str] = None) -> None: # noqa: N802 

115 pass 

116 

117 def assertLess(self, a: Any, b: Any, msg: Optional[str] = None) -> None: # noqa: N802 

118 pass 

119 

120 def assertLessEqual(self, a: Any, b: Any, msg: Optional[str] = None) -> None: # noqa: N802 

121 pass 

122 

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

124 pass 

125 

126 def assertCoordinatesConsistent( # noqa: N802 

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

128 ) -> None: 

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

130 

131 Parameters 

132 ---------- 

133 obsinfo : `ObservationInfo` 

134 Object to check. 

135 max_sep : `float`, optional 

136 Maximum separation between AltAz derived from RA/Dec headers 

137 and that found in the AltAz headers. 

138 amdelta : `float`, optional 

139 Max difference between header airmass and derived airmass. 

140 

141 Raises 

142 ------ 

143 AssertionError 

144 Inconsistencies found. 

145 """ 

146 self.assertIsNotNone(obsinfo.tracking_radec) 

147 self.assertIsNotNone(obsinfo.altaz_begin) 

148 

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

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

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

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

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

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

155 

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

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

158 with warnings.catch_warnings(): 

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

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

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

162 

163 def assertObservationInfoFromYaml( # noqa: N802 

164 self, 

165 file: str, 

166 dir: Optional[str] = None, 

167 check_wcs: bool = True, 

168 wcs_params: Optional[Dict[str, Any]] = None, 

169 check_altaz: bool = False, 

170 **kwargs: Any, 

171 ) -> None: 

172 """Check contents of an ObservationInfo. 

173 

174 Parameters 

175 ---------- 

176 file : `str` 

177 Path to YAML file representing the header. 

178 dir : `str`, optional 

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

180 check_wcs : `bool`, optional 

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

182 wcs_params : `dict`, optional 

183 Parameters to pass to `assertCoordinatesConsistent`. 

184 check_altaz : `bool`, optional 

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

186 kwargs : `dict` 

187 Keys matching `ObservationInfo` properties with values 

188 to be tested. 

189 

190 Raises 

191 ------ 

192 AssertionError 

193 A value in the ObservationInfo derived from the file is 

194 inconsistent. 

195 """ 

196 header = read_test_file(file, dir=dir) 

197 

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

199 astropy_header = Header() 

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

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

202 for v in values: 

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

204 astropy_header.append((key, v)) 

205 

206 for hdr in (header, astropy_header): 

207 try: 

208 self.assertObservationInfo( 

209 header, 

210 filename=file, 

211 check_wcs=check_wcs, 

212 wcs_params=wcs_params, 

213 check_altaz=check_altaz, 

214 **kwargs, 

215 ) 

216 except AssertionError as e: 

217 raise AssertionError( 

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

219 ) from e 

220 

221 def assertObservationInfo( # noqa: N802 

222 self, 

223 header: MutableMapping[str, Any], 

224 filename: Optional[str] = None, 

225 check_wcs: bool = True, 

226 wcs_params: Optional[Dict[str, Any]] = None, 

227 check_altaz: bool = False, 

228 **kwargs: Any, 

229 ) -> None: 

230 """Check contents of an ObservationInfo. 

231 

232 Parameters 

233 ---------- 

234 header : `dict`-like 

235 Header to be checked. 

236 filename : `str`, optional 

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

238 check_wcs : `bool`, optional 

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

240 are automatically disabled if the translated header does 

241 not appear to be "science". 

242 wcs_params : `dict`, optional 

243 Parameters to pass to `assertCoordinatesConsistent`. 

244 check_altaz : `bool`, optional 

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

246 kwargs : `dict` 

247 Keys matching `ObservationInfo` properties with values 

248 to be tested. 

249 

250 Raises 

251 ------ 

252 AssertionError 

253 A value in the ObservationInfo derived from the file is 

254 inconsistent. 

255 """ 

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

257 # of all the translations 

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

259 translator = obsinfo.translator_class_name 

260 

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

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

263 self.assertEqual(obsinfo, newinfo) 

264 

265 # Check the properties 

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

267 calculated = getattr(obsinfo, property) 

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

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

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

271 expected = expected.to_value() 

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

273 elif isinstance(calculated, u.Quantity): 

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

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

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

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

278 else: 

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

280 

281 # Date comparison error reports will benefit by specifying ISO 

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

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

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

285 # the past. 

286 def _format_date_for_testing(date: Optional[Time]) -> Optional[Time]: 

287 if date is not None: 

288 date.format = "isot" 

289 date.precision = 9 

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

291 return date 

292 

293 datetime_begin = _format_date_for_testing(obsinfo.datetime_begin) 

294 datetime_end = _format_date_for_testing(obsinfo.datetime_end) 

295 

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

297 self.assertLessEqual(datetime_begin, datetime_end) 

298 

299 # Check that exposure time is not outside datetime_end 

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

301 

302 # Do we expect an AltAz value or not. 

303 if check_altaz: 

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

305 

306 # Check the WCS consistency 

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

308 if wcs_params is None: 

309 wcs_params = {} 

310 self.assertCoordinatesConsistent(obsinfo, **wcs_params)