Coverage for python/astro_metadata_translator/tests.py: 18%
107 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-06 12:37 -0800
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-06 12:37 -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.
12from __future__ import annotations
14__all__ = ("read_test_file", "MetadataAssertHelper")
16import os
17import pickle
18import warnings
19from typing import TYPE_CHECKING, Any, Dict, MutableMapping, NoReturn, Optional, Type
21import astropy.units as u
22import astropy.utils.exceptions
23import yaml
24from astropy.io.fits import Header
25from astropy.time import Time
27from astro_metadata_translator import ObservationInfo
29# PropertyList is optional
30try:
31 import lsst.daf.base as daf_base
32except ImportError:
33 daf_base = None
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
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.Node) -> 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
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
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
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.
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
91class MetadataAssertHelper:
92 """Class with helpful asserts that can be used for testing metadata
93 translations.
94 """
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
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
110 def assertIsNotNone(self, a: Any) -> None: # noqa: N802
111 pass
113 def assertEqual(self, a: Any, b: Any, msg: Optional[str] = None) -> None: # noqa: N802
114 pass
116 def assertLess(self, a: Any, b: Any, msg: Optional[str] = None) -> None: # noqa: N802
117 pass
119 def assertLessEqual(self, a: Any, b: Any, msg: Optional[str] = None) -> None: # noqa: N802
120 pass
122 def fail(self, msg: str) -> NoReturn:
123 pass
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.
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.
140 Raises
141 ------
142 AssertionError
143 Inconsistencies found.
144 """
145 self.assertIsNotNone(obsinfo.tracking_radec)
146 self.assertIsNotNone(obsinfo.altaz_begin)
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)
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")
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.
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.
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)
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))
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
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.
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.
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
247 # Check that we can pickle and get back the same properties
248 newinfo = pickle.loads(pickle.dumps(obsinfo))
249 self.assertEqual(obsinfo, newinfo)
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)
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
279 datetime_begin = _format_date_for_testing(obsinfo.datetime_begin)
280 datetime_end = _format_date_for_testing(obsinfo.datetime_end)
282 # Check that dates are defined and end is the same or after beginning
283 self.assertLessEqual(datetime_begin, datetime_end)
285 # Check that exposure time is not outside datetime_end
286 self.assertLessEqual(obsinfo.datetime_begin + obsinfo.exposure_time, obsinfo.datetime_end)
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)