Coverage for python/astro_metadata_translator/tests.py: 18%
116 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-12 11:52 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-12 11:52 +0000
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 json
17import os
18import pickle
19import warnings
20from collections.abc import MutableMapping
21from typing import TYPE_CHECKING, Any, NoReturn
23import astropy.units as u
24import astropy.utils.exceptions
25import yaml
26from astropy.io.fits import Header
27from astropy.time import Time
29from astro_metadata_translator import ObservationInfo
31# PropertyList is optional
32try:
33 import lsst.daf.base as daf_base
34except ImportError:
35 daf_base = None
38# For YAML >= 5.1 need a different Loader for the constructor
39Loader: type[yaml.Loader] | type[yaml.FullLoader]
40try:
41 Loader = yaml.FullLoader
42except AttributeError:
43 Loader = yaml.Loader
46# Define a YAML loader for lsst.daf.base.PropertySet serializations that
47# we can use if daf_base is not available.
48def pl_constructor(loader: yaml.Loader, node: yaml.SequenceNode) -> Any:
49 """Construct an OrderedDict from a YAML file containing a PropertyList."""
50 pl: dict[str, Any] = {}
51 yield pl
52 state = loader.construct_sequence(node, deep=True)
53 for key, dtype, value, comment in state:
54 if dtype == "Double":
55 pl[key] = float(value)
56 elif dtype == "Int":
57 pl[key] = int(value)
58 elif dtype == "Bool":
59 pl[key] = value
60 else:
61 pl[key] = value
64if daf_base is None: 64 ↛ 65line 64 didn't jump to line 65, because the condition on line 64 was never true
65 yaml.add_constructor("lsst.daf.base.PropertyList", pl_constructor, Loader=Loader) # type: ignore
68def read_test_file(filename: str, dir: str | None = None) -> MutableMapping[str, Any]:
69 """Read the named test file relative to the location of this helper.
71 Parameters
72 ----------
73 filename : `str`
74 Name of file in the data directory.
75 dir : `str`, optional.
76 Directory from which to read file. Current directory used if none
77 specified.
79 Returns
80 -------
81 header : `dict`-like
82 Header read from file.
83 """
84 if dir is not None and not os.path.isabs(filename):
85 filename = os.path.join(dir, filename)
86 with open(filename) as fd:
87 if filename.endswith(".yaml"):
88 header = yaml.load(fd, Loader=Loader)
89 elif filename.endswith(".json"):
90 header = json.load(fd)
91 else:
92 raise RuntimeError(f"Unrecognized file format: {filename}")
94 # Cannot directly check for Mapping because PropertyList is not one
95 if not hasattr(header, "items"):
96 raise ValueError(f"Contents of YAML file {filename} are not a mapping, they are {type(header)}")
97 return header
100class MetadataAssertHelper:
101 """Class with helpful asserts that can be used for testing metadata
102 translations.
103 """
105 # This class is assumed to be combined with unittest.TestCase but mypy
106 # does not know this. We need to teach mypy about the APIs we are using.
107 if TYPE_CHECKING: 107 ↛ 109line 107 didn't jump to line 109, because the condition on line 107 was never true
109 def assertAlmostEqual( # noqa: N802
110 self,
111 a: float,
112 b: float,
113 places: int | None = None,
114 msg: str | None = None,
115 delta: float | None = None,
116 ) -> None:
117 pass
119 def assertIsNotNone(self, a: Any, msg: str | None = None) -> None: # noqa: N802
120 pass
122 def assertEqual(self, a: Any, b: Any, msg: str | None = None) -> None: # noqa: N802
123 pass
125 def assertLess(self, a: Any, b: Any, msg: str | None = None) -> None: # noqa: N802
126 pass
128 def assertLessEqual(self, a: Any, b: Any, msg: str | None = None) -> None: # noqa: N802
129 pass
131 def fail(self, msg: str) -> NoReturn:
132 pass
134 def assertCoordinatesConsistent( # noqa: N802
135 self, obsinfo: ObservationInfo, max_sep: float = 1.0, amdelta: float = 0.01
136 ) -> None:
137 """Check that SkyCoord, AltAz, and airmass are self consistent.
139 Parameters
140 ----------
141 obsinfo : `ObservationInfo`
142 Object to check.
143 max_sep : `float`, optional
144 Maximum separation between AltAz derived from RA/Dec headers
145 and that found in the AltAz headers.
146 amdelta : `float`, optional
147 Max difference between header airmass and derived airmass.
149 Raises
150 ------
151 AssertionError
152 Inconsistencies found.
153 """
154 self.assertIsNotNone(obsinfo.tracking_radec)
155 self.assertIsNotNone(obsinfo.altaz_begin)
157 # Is airmass from header close to airmass from AltAz headers?
158 # In most cases there is uncertainty over whether the elevation
159 # and airmass in the header are for the start, end, or middle
160 # of the observation. Sometimes the AltAz is from the start
161 # but the airmass is from the middle so accuracy is not certain.
162 self.assertAlmostEqual(obsinfo.altaz_begin.secz.to_value(), obsinfo.boresight_airmass, delta=amdelta)
164 # Is AltAz from headers close to AltAz from RA/Dec headers?
165 # Can trigger warnings from Astropy if date is in future
166 with warnings.catch_warnings():
167 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
168 sep = obsinfo.altaz_begin.separation(obsinfo.tracking_radec.altaz)
169 self.assertLess(sep.to_value(unit="arcmin"), max_sep, msg="AltAz inconsistent with RA/Dec")
171 def assertObservationInfoFromYaml( # noqa: N802
172 self,
173 file: str,
174 dir: str | None = None,
175 check_wcs: bool = True,
176 wcs_params: dict[str, Any] | None = None,
177 check_altaz: bool = False,
178 **kwargs: Any,
179 ) -> None:
180 """Check contents of an ObservationInfo.
182 Parameters
183 ----------
184 file : `str`
185 Path to YAML file representing the header.
186 dir : `str`, optional
187 Optional directory from which to read ``file``.
188 check_wcs : `bool`, optional
189 Check the consistency of the RA/Dec and AltAz values.
190 wcs_params : `dict`, optional
191 Parameters to pass to `assertCoordinatesConsistent`.
192 check_altaz : `bool`, optional
193 Check that an alt/az value has been calculated.
194 kwargs : `dict`
195 Keys matching `ObservationInfo` properties with values
196 to be tested.
198 Raises
199 ------
200 AssertionError
201 A value in the ObservationInfo derived from the file is
202 inconsistent.
203 """
204 header = read_test_file(file, dir=dir)
206 # DM-30093: Astropy Header does not always behave dict-like
207 astropy_header = Header()
208 for key, val in header.items():
209 values = val if isinstance(val, list) else [val]
210 for v in values:
211 # appending ensures *all* duplicated keys are also preserved
212 astropy_header.append((key, v))
214 for hdr in (header, astropy_header):
215 try:
216 self.assertObservationInfo(
217 header,
218 filename=file,
219 check_wcs=check_wcs,
220 wcs_params=wcs_params,
221 check_altaz=check_altaz,
222 **kwargs,
223 )
224 except AssertionError as e:
225 raise AssertionError(
226 f"ObservationInfo derived from {type(hdr)} type is inconsistent: {e}"
227 ) from e
229 def assertObservationInfo( # noqa: N802
230 self,
231 header: MutableMapping[str, Any],
232 filename: str | None = None,
233 check_wcs: bool = True,
234 wcs_params: dict[str, Any] | None = None,
235 check_altaz: bool = False,
236 **kwargs: Any,
237 ) -> None:
238 """Check contents of an ObservationInfo.
240 Parameters
241 ----------
242 header : `dict`-like
243 Header to be checked.
244 filename : `str`, optional
245 Name of the filename associated with this header if known.
246 check_wcs : `bool`, optional
247 Check the consistency of the RA/Dec and AltAz values. Checks
248 are automatically disabled if the translated header does
249 not appear to be "science".
250 wcs_params : `dict`, optional
251 Parameters to pass to `assertCoordinatesConsistent`.
252 check_altaz : `bool`, optional
253 Check that an alt/az value has been calculated.
254 kwargs : `dict`
255 Keys matching `ObservationInfo` properties with values
256 to be tested.
258 Raises
259 ------
260 AssertionError
261 A value in the ObservationInfo derived from the file is
262 inconsistent.
263 """
264 # For testing we force pedantic mode since we are in charge
265 # of all the translations
266 obsinfo = ObservationInfo(header, pedantic=True, filename=filename)
267 translator = obsinfo.translator_class_name
269 # Check that we can pickle and get back the same properties
270 newinfo = pickle.loads(pickle.dumps(obsinfo))
271 self.assertEqual(obsinfo, newinfo)
273 # Check the properties
274 for property, expected in kwargs.items():
275 calculated = getattr(obsinfo, property)
276 msg = f"Comparing property {property} using translator {translator}"
277 if isinstance(expected, u.Quantity) and calculated is not None:
278 calculated = calculated.to_value(unit=expected.unit)
279 expected = expected.to_value()
280 self.assertAlmostEqual(calculated, expected, msg=msg)
281 elif isinstance(calculated, u.Quantity):
282 # Only happens if the test is not a quantity when it should be
283 self.fail(f"Expected {expected!r} but got Quantity '{calculated}': {msg}")
284 elif isinstance(expected, float) and calculated is not None:
285 self.assertAlmostEqual(calculated, expected, msg=msg)
286 else:
287 self.assertEqual(calculated, expected, msg=msg)
289 # Date comparison error reports will benefit by specifying ISO
290 # format. Generate a new Time object at fixed precision
291 # to work around the fact that (as of astropy 3.1) adding 0.0 seconds
292 # to a Time results in a new Time object that is a few picoseconds in
293 # the past.
294 def _format_date_for_testing(date: Time | None) -> Time | None:
295 if date is not None:
296 date.format = "isot"
297 date.precision = 9
298 date = Time(str(date), scale=date.scale, format="isot")
299 return date
301 datetime_begin = _format_date_for_testing(obsinfo.datetime_begin)
302 datetime_end = _format_date_for_testing(obsinfo.datetime_end)
304 # Check that dates are defined and end is the same or after beginning
305 self.assertLessEqual(datetime_begin, datetime_end)
307 # Check that exposure time is not outside datetime_end
308 self.assertLessEqual(obsinfo.datetime_begin + obsinfo.exposure_time, obsinfo.datetime_end)
310 # Do we expect an AltAz value or not.
311 if check_altaz:
312 self.assertIsNotNone(obsinfo.altaz_begin, "altaz_begin is None but should have a value")
314 # Check the WCS consistency
315 if check_wcs and obsinfo.observation_type == "science":
316 if wcs_params is None:
317 wcs_params = {}
318 self.assertCoordinatesConsistent(obsinfo, **wcs_params)