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

106 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:26 -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__ = ("MetadataAssertHelper", "read_test_file") 

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.io.fits.verify import VerifyWarning 

28from astropy.time import Time 

29 

30from astro_metadata_translator import ObservationInfo 

31 

32# PropertyList is optional 

33try: 

34 import lsst.daf.base as daf_base 

35except ImportError: 

36 daf_base = None 

37 

38 

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

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

41try: 

42 Loader = yaml.FullLoader 

43except AttributeError: 

44 Loader = yaml.Loader 

45 

46 

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

48# we can use if daf_base is not available. 

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

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

51 

52 Parameters 

53 ---------- 

54 loader : `yaml.Loader` 

55 Instance used to load YAML file. 

56 node : `yaml.SequenceNode` 

57 Node to examine. 

58 

59 Yields 

60 ------ 

61 pl : `dict` 

62 Contents of PropertyList as a dict. 

63 """ 

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

65 yield pl 

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

67 for key, dtype, value, _ in state: 

68 if dtype == "Double": 

69 pl[key] = float(value) 

70 elif dtype == "Int": 

71 pl[key] = int(value) 

72 elif dtype == "Bool": 

73 pl[key] = value 

74 else: 

75 pl[key] = value 

76 

77 

78if daf_base is None: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true

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

80 

81 

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

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

84 

85 Parameters 

86 ---------- 

87 filename : `str` 

88 Name of file in the data directory. 

89 dir : `str`, optional 

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

91 specified. 

92 

93 Returns 

94 ------- 

95 header : `dict`-like 

96 Header read from file. 

97 """ 

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

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

100 with open(filename) as fd: 

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

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

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

104 header = json.load(fd) 

105 else: 

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

107 

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

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

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

111 return header 

112 

113 

114class MetadataAssertHelper: 

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

116 translations. 

117 """ 

118 

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

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

121 if TYPE_CHECKING: 

122 

123 def assertAlmostEqual( # noqa: N802 

124 self, 

125 a: float, 

126 b: float, 

127 places: int | None = None, 

128 msg: str | None = None, 

129 delta: float | None = None, 

130 ) -> None: 

131 pass 

132 

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

134 pass 

135 

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

137 pass 

138 

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

140 pass 

141 

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

143 pass 

144 

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

146 pass 

147 

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

149 pass 

150 

151 def assertCoordinatesConsistent( # noqa: N802 

152 self, obsinfo: ObservationInfo, max_sep: float = 1.0, min_sep: float = 0.0, amdelta: float = 0.01 

153 ) -> None: 

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

155 

156 Parameters 

157 ---------- 

158 obsinfo : `ObservationInfo` 

159 Object to check. 

160 max_sep : `float`, optional 

161 Maximum separation between AltAz derived from RA/Dec headers 

162 and that found in the AltAz headers. 

163 min_sep : `float`, optional 

164 Minimum separation between AltAz derived from RA/Dec headers 

165 and that found in the AltAz headers. This is to accommodate 

166 cases where the telecsope boresight headers required adjustment. 

167 amdelta : `float`, optional 

168 Max difference between header airmass and derived airmass. 

169 

170 Raises 

171 ------ 

172 AssertionError 

173 Inconsistencies found. 

174 """ 

175 self.assertIsNotNone(obsinfo.tracking_radec, msg="Checking tracking_radec is defined") 

176 self.assertIsNotNone(obsinfo.altaz_begin, msg="Checking AltAz is defined") 

177 

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

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

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

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

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

183 self.assertAlmostEqual( 

184 obsinfo.altaz_begin.secz.to_value(), 

185 obsinfo.boresight_airmass, 

186 delta=amdelta, 

187 msg="Checking airmass is consistent", 

188 ) 

189 

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

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

192 with warnings.catch_warnings(): 

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

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

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

196 self.assertGreaterEqual( 

197 sep.to_value(unit="arcmin"), 

198 min_sep, 

199 msg="Difference between AltAz and RA/Dec is less than expected from fixed boresight headers.", 

200 ) 

201 

202 def assertObservationInfoFromYaml( # noqa: N802 

203 self, 

204 file: str, 

205 dir: str | None = None, 

206 check_wcs: bool = True, 

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

208 check_altaz: bool = False, 

209 **kwargs: Any, 

210 ) -> None: 

211 """Check contents of an ObservationInfo. 

212 

213 Parameters 

214 ---------- 

215 file : `str` 

216 Path to YAML file representing the header. 

217 dir : `str`, optional 

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

219 check_wcs : `bool`, optional 

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

221 wcs_params : `dict`, optional 

222 Parameters to pass to `assertCoordinatesConsistent`. 

223 check_altaz : `bool`, optional 

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

225 **kwargs : `dict` 

226 Keys matching `ObservationInfo` properties with values 

227 to be tested. 

228 

229 Raises 

230 ------ 

231 AssertionError 

232 A value in the ObservationInfo derived from the file is 

233 inconsistent. 

234 """ 

235 header = read_test_file(file, dir=dir) 

236 

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

238 astropy_header = Header() 

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

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

241 for v in values: 

242 # Appending ensures *all* duplicated keys are also preserved. 

243 # astropy.io.fits complains about long keywords rather than 

244 # letting us say "thank you for using the standard to let 

245 # us write this header" so we need to catch the warnings. 

246 with warnings.catch_warnings(): 

247 warnings.simplefilter("ignore", category=VerifyWarning) 

248 astropy_header.append((key, v)) 

249 

250 for hdr in (header, astropy_header): 

251 try: 

252 self.assertObservationInfo( 

253 header, 

254 filename=file, 

255 check_wcs=check_wcs, 

256 wcs_params=wcs_params, 

257 check_altaz=check_altaz, 

258 **kwargs, 

259 ) 

260 except AssertionError as e: 

261 raise AssertionError( 

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

263 ) from e 

264 

265 def assertObservationInfo( # noqa: N802 

266 self, 

267 header: MutableMapping[str, Any], 

268 filename: str | None = None, 

269 check_wcs: bool = True, 

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

271 check_altaz: bool = False, 

272 **kwargs: Any, 

273 ) -> None: 

274 """Check contents of an ObservationInfo. 

275 

276 Parameters 

277 ---------- 

278 header : `dict`-like 

279 Header to be checked. 

280 filename : `str`, optional 

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

282 check_wcs : `bool`, optional 

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

284 are automatically disabled if the translated header does 

285 not appear to be "science". 

286 wcs_params : `dict`, optional 

287 Parameters to pass to `assertCoordinatesConsistent`. 

288 check_altaz : `bool`, optional 

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

290 **kwargs : `dict` 

291 Keys matching `ObservationInfo` properties with values 

292 to be tested. 

293 

294 Raises 

295 ------ 

296 AssertionError 

297 A value in the ObservationInfo derived from the file is 

298 inconsistent. 

299 """ 

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

301 # of all the translations 

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

303 translator = obsinfo.translator_class_name 

304 

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

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

307 self.assertEqual(obsinfo, newinfo) 

308 

309 # Check the properties 

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

311 calculated = getattr(obsinfo, property) 

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

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

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

315 expected = expected.to_value() 

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

317 elif isinstance(calculated, u.Quantity): 

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

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

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

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

322 else: 

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

324 

325 # Date comparison error reports will benefit by specifying ISO 

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

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

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

329 # the past. 

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

331 if date is not None: 

332 date.format = "isot" 

333 date.precision = 9 

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

335 return date 

336 

337 datetime_begin = _format_date_for_testing(obsinfo.datetime_begin) 

338 datetime_end = _format_date_for_testing(obsinfo.datetime_end) 

339 

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

341 self.assertLessEqual(datetime_begin, datetime_end) 

342 

343 # Check that exposure time is not outside datetime_end 

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

345 

346 # Do we expect an AltAz value or not. 

347 if check_altaz: 

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

349 

350 # Check the WCS consistency 

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

352 if wcs_params is None: 

353 wcs_params = {} 

354 self.assertCoordinatesConsistent(obsinfo, **wcs_params)