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

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

93 statements  

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__all__ = ("read_test_file", "MetadataAssertHelper") 

13 

14import astropy.units as u 

15import os 

16import pickle 

17import yaml 

18from collections import OrderedDict 

19from astropy.time import Time 

20import astropy.utils.exceptions 

21import warnings 

22from astropy.io.fits import Header 

23 

24from astro_metadata_translator import ObservationInfo 

25 

26# PropertyList is optional 

27try: 

28 import lsst.daf.base as daf_base 

29except ImportError: 

30 daf_base = None 

31 

32 

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

34try: 

35 Loader = yaml.FullLoader 

36except AttributeError: 

37 Loader = yaml.Loader 

38 

39 

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

41# we can use if daf_base is not available. 

42def pl_constructor(loader, node): 

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

44 pl = OrderedDict() 

45 yield pl 

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

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

48 if dtype == "Double": 

49 pl[key] = float(value) 

50 elif dtype == "Int": 

51 pl[key] = int(value) 

52 elif dtype == "Bool": 

53 pl[key] = True if value == "true" else False 

54 else: 

55 pl[key] = value 

56 

57 

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

59 yaml.add_constructor("lsst.daf.base.PropertyList", pl_constructor, Loader=Loader) 

60 

61 

62def read_test_file(filename, dir=None): 

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

64 

65 Parameters 

66 ---------- 

67 filename : `str` 

68 Name of file in the data directory. 

69 dir : `str`, optional. 

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

71 specified. 

72 

73 Returns 

74 ------- 

75 header : `dict`-like 

76 Header read from file. 

77 """ 

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

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

80 with open(filename) as fd: 

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

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

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

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

85 return header 

86 

87 

88class MetadataAssertHelper: 

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

90 translations. 

91 """ 

92 

93 def assertCoordinatesConsistent(self, obsinfo, max_sep=1.0, amdelta=0.01): # noqa: N802 

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

95 

96 Parameters 

97 ---------- 

98 obsinfo : `ObservationInfo` 

99 Object to check. 

100 max_sep : `float`, optional 

101 Maximum separation between AltAz derived from RA/Dec headers 

102 and that found in the AltAz headers. 

103 amdelta : `float`, optional 

104 Max difference between header airmass and derived airmass. 

105 

106 Raises 

107 ------ 

108 AssertionError 

109 Inconsistencies found. 

110 """ 

111 self.assertIsNotNone(obsinfo.tracking_radec) 

112 self.assertIsNotNone(obsinfo.altaz_begin) 

113 

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

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

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

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

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

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

120 

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

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

123 with warnings.catch_warnings(): 

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

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

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

127 

128 def assertObservationInfoFromYaml(self, file, dir=None, check_wcs=True, # noqa: N802 

129 wcs_params=None, **kwargs): 

130 """Check contents of an ObservationInfo. 

131 

132 Parameters 

133 ---------- 

134 file : `str` 

135 Path to YAML file representing the header. 

136 dir : `str`, optional 

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

138 check_wcs : `bool`, optional 

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

140 wcs_params : `dict`, optional 

141 Parameters to pass to `assertCoordinatesConsistent`. 

142 kwargs : `dict` 

143 Keys matching `ObservationInfo` properties with values 

144 to be tested. 

145 

146 Raises 

147 ------ 

148 AssertionError 

149 A value in the ObservationInfo derived from the file is 

150 inconsistent. 

151 """ 

152 header = read_test_file(file, dir=dir) 

153 

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

155 astropy_header = Header() 

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

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

158 for v in values: 

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

160 astropy_header.append((key, v)) 

161 

162 for hdr in (header, astropy_header): 

163 try: 

164 self.assertObservationInfo(header, filename=file, check_wcs=check_wcs, 

165 wcs_params=wcs_params, **kwargs) 

166 except AssertionError as e: 

167 raise AssertionError(f"ObservationInfo derived from {type(hdr)} " 

168 "type is inconsistent.") from e 

169 

170 def assertObservationInfo(self, header, filename=None, check_wcs=True, # noqa: N802 

171 wcs_params=None, **kwargs): 

172 """Check contents of an ObservationInfo. 

173 

174 Parameters 

175 ---------- 

176 header : `dict`-like 

177 Header to be checked. 

178 filename : `str`, optional 

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

180 check_wcs : `bool`, optional 

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

182 are automatically disabled if the translated header does 

183 not appear to be "science". 

184 wcs_params : `dict`, optional 

185 Parameters to pass to `assertCoordinatesConsistent`. 

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 # For testing we force pedantic mode since we are in charge 

197 # of all the translations 

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

199 translator = obsinfo.translator_class_name 

200 

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

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

203 self.assertEqual(obsinfo, newinfo) 

204 

205 # Check the properties 

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

207 calculated = getattr(obsinfo, property) 

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

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

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

211 expected = expected.to_value() 

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

213 elif isinstance(calculated, u.Quantity): 

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

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

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

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

218 else: 

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

220 

221 # Date comparison error reports will benefit by specifying ISO 

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

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

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

225 # the past. 

226 def _format_date_for_testing(date): 

227 if date is not None: 

228 date.format = "isot" 

229 date.precision = 9 

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

231 return date 

232 

233 datetime_begin = _format_date_for_testing(obsinfo.datetime_begin) 

234 datetime_end = _format_date_for_testing(obsinfo.datetime_end) 

235 

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

237 self.assertLessEqual(datetime_begin, datetime_end) 

238 

239 # Check that exposure time is not outside datetime_end 

240 self.assertLessEqual(obsinfo.datetime_begin + obsinfo.exposure_time, 

241 obsinfo.datetime_end) 

242 

243 # Check the WCS consistency 

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

245 if wcs_params is None: 

246 wcs_params = {} 

247 self.assertCoordinatesConsistent(obsinfo, **wcs_params)