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