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

107 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-09 02:48 -0800

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 

37try: 

38 Loader: Optional[Type] = yaml.FullLoader 

39except AttributeError: 

40 Loader = yaml.Loader 

41 

42 

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

44# we can use if daf_base is not available. 

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

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

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

48 yield pl 

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

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

51 if dtype == "Double": 

52 pl[key] = float(value) 

53 elif dtype == "Int": 

54 pl[key] = int(value) 

55 elif dtype == "Bool": 

56 pl[key] = value 

57 else: 

58 pl[key] = value 

59 

60 

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

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

63 

64 

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

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

67 

68 Parameters 

69 ---------- 

70 filename : `str` 

71 Name of file in the data directory. 

72 dir : `str`, optional. 

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

74 specified. 

75 

76 Returns 

77 ------- 

78 header : `dict`-like 

79 Header read from file. 

80 """ 

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

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

83 with open(filename) as fd: 

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

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

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

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

88 return header 

89 

90 

91class MetadataAssertHelper: 

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

93 translations. 

94 """ 

95 

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

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

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

99 

100 def assertAlmostEqual( # noqa: N802 

101 self, 

102 a: float, 

103 b: float, 

104 places: Optional[int] = None, 

105 msg: Optional[str] = None, 

106 delta: Optional[float] = None, 

107 ) -> None: 

108 pass 

109 

110 def assertIsNotNone(self, a: Any) -> None: # noqa: N802 

111 pass 

112 

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

114 pass 

115 

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

117 pass 

118 

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

120 pass 

121 

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

123 pass 

124 

125 def assertCoordinatesConsistent( # noqa: N802 

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

127 ) -> None: 

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

129 

130 Parameters 

131 ---------- 

132 obsinfo : `ObservationInfo` 

133 Object to check. 

134 max_sep : `float`, optional 

135 Maximum separation between AltAz derived from RA/Dec headers 

136 and that found in the AltAz headers. 

137 amdelta : `float`, optional 

138 Max difference between header airmass and derived airmass. 

139 

140 Raises 

141 ------ 

142 AssertionError 

143 Inconsistencies found. 

144 """ 

145 self.assertIsNotNone(obsinfo.tracking_radec) 

146 self.assertIsNotNone(obsinfo.altaz_begin) 

147 

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

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

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

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

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

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

154 

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

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

157 with warnings.catch_warnings(): 

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

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

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

161 

162 def assertObservationInfoFromYaml( # noqa: N802 

163 self, 

164 file: str, 

165 dir: Optional[str] = None, 

166 check_wcs: bool = True, 

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

168 **kwargs: Any, 

169 ) -> None: 

170 """Check contents of an ObservationInfo. 

171 

172 Parameters 

173 ---------- 

174 file : `str` 

175 Path to YAML file representing the header. 

176 dir : `str`, optional 

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

178 check_wcs : `bool`, optional 

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

180 wcs_params : `dict`, optional 

181 Parameters to pass to `assertCoordinatesConsistent`. 

182 kwargs : `dict` 

183 Keys matching `ObservationInfo` properties with values 

184 to be tested. 

185 

186 Raises 

187 ------ 

188 AssertionError 

189 A value in the ObservationInfo derived from the file is 

190 inconsistent. 

191 """ 

192 header = read_test_file(file, dir=dir) 

193 

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

195 astropy_header = Header() 

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

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

198 for v in values: 

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

200 astropy_header.append((key, v)) 

201 

202 for hdr in (header, astropy_header): 

203 try: 

204 self.assertObservationInfo( 

205 header, filename=file, check_wcs=check_wcs, wcs_params=wcs_params, **kwargs 

206 ) 

207 except AssertionError as e: 

208 raise AssertionError(f"ObservationInfo derived from {type(hdr)} type is inconsistent.") from e 

209 

210 def assertObservationInfo( # noqa: N802 

211 self, 

212 header: MutableMapping[str, Any], 

213 filename: Optional[str] = None, 

214 check_wcs: bool = True, 

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

216 **kwargs: Any, 

217 ) -> None: 

218 """Check contents of an ObservationInfo. 

219 

220 Parameters 

221 ---------- 

222 header : `dict`-like 

223 Header to be checked. 

224 filename : `str`, optional 

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

226 check_wcs : `bool`, optional 

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

228 are automatically disabled if the translated header does 

229 not appear to be "science". 

230 wcs_params : `dict`, optional 

231 Parameters to pass to `assertCoordinatesConsistent`. 

232 kwargs : `dict` 

233 Keys matching `ObservationInfo` properties with values 

234 to be tested. 

235 

236 Raises 

237 ------ 

238 AssertionError 

239 A value in the ObservationInfo derived from the file is 

240 inconsistent. 

241 """ 

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

243 # of all the translations 

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

245 translator = obsinfo.translator_class_name 

246 

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

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

249 self.assertEqual(obsinfo, newinfo) 

250 

251 # Check the properties 

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

253 calculated = getattr(obsinfo, property) 

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

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

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

257 expected = expected.to_value() 

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

259 elif isinstance(calculated, u.Quantity): 

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

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

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

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

264 else: 

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

266 

267 # Date comparison error reports will benefit by specifying ISO 

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

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

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

271 # the past. 

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

273 if date is not None: 

274 date.format = "isot" 

275 date.precision = 9 

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

277 return date 

278 

279 datetime_begin = _format_date_for_testing(obsinfo.datetime_begin) 

280 datetime_end = _format_date_for_testing(obsinfo.datetime_end) 

281 

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

283 self.assertLessEqual(datetime_begin, datetime_end) 

284 

285 # Check that exposure time is not outside datetime_end 

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

287 

288 # Check the WCS consistency 

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

290 if wcs_params is None: 

291 wcs_params = {} 

292 self.assertCoordinatesConsistent(obsinfo, **wcs_params)