Coverage for python/astro_metadata_translator/tests.py: 18%
110 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-13 02:37 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2023-04-13 02:37 -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.
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
37Loader: Type[yaml.Loader] | Type[yaml.FullLoader]
38try:
39 Loader = yaml.FullLoader
40except AttributeError:
41 Loader = yaml.Loader
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
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
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
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.
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
92class MetadataAssertHelper:
93 """Class with helpful asserts that can be used for testing metadata
94 translations.
95 """
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
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
111 def assertIsNotNone(self, a: Any, msg: str | None = None) -> None: # noqa: N802
112 pass
114 def assertEqual(self, a: Any, b: Any, msg: Optional[str] = None) -> None: # noqa: N802
115 pass
117 def assertLess(self, a: Any, b: Any, msg: Optional[str] = None) -> None: # noqa: N802
118 pass
120 def assertLessEqual(self, a: Any, b: Any, msg: Optional[str] = None) -> None: # noqa: N802
121 pass
123 def fail(self, msg: str) -> NoReturn:
124 pass
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.
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.
141 Raises
142 ------
143 AssertionError
144 Inconsistencies found.
145 """
146 self.assertIsNotNone(obsinfo.tracking_radec)
147 self.assertIsNotNone(obsinfo.altaz_begin)
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)
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")
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.
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.
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)
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))
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
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.
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.
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
261 # Check that we can pickle and get back the same properties
262 newinfo = pickle.loads(pickle.dumps(obsinfo))
263 self.assertEqual(obsinfo, newinfo)
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)
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
293 datetime_begin = _format_date_for_testing(obsinfo.datetime_begin)
294 datetime_end = _format_date_for_testing(obsinfo.datetime_end)
296 # Check that dates are defined and end is the same or after beginning
297 self.assertLessEqual(datetime_begin, datetime_end)
299 # Check that exposure time is not outside datetime_end
300 self.assertLessEqual(obsinfo.datetime_begin + obsinfo.exposure_time, obsinfo.datetime_end)
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")
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)