Coverage for python/astro_metadata_translator/tests.py: 18%
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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.
12__all__ = ("read_test_file", "MetadataAssertHelper")
14import astropy.units as u
15import os
16import pickle
17import yaml
18from collections import OrderedDict
19from astropy.time import Time
20import astropy.utils.exceptions
21import warnings
22from astropy.io.fits import Header
24from astro_metadata_translator import ObservationInfo
26# PropertyList is optional
27try:
28 import lsst.daf.base as daf_base
29except ImportError:
30 daf_base = None
33# For YAML >= 5.1 need a different Loader for the constructor
34try:
35 Loader = yaml.FullLoader
36except AttributeError:
37 Loader = yaml.Loader
40# Define a YAML loader for lsst.daf.base.PropertySet serializations that
41# we can use if daf_base is not available.
42def pl_constructor(loader, node):
43 """Construct an OrderedDict from a YAML file containing a PropertyList."""
44 pl = OrderedDict()
45 yield pl
46 state = loader.construct_sequence(node, deep=True)
47 for key, dtype, value, comment in state:
48 if dtype == "Double":
49 pl[key] = float(value)
50 elif dtype == "Int":
51 pl[key] = int(value)
52 elif dtype == "Bool":
53 pl[key] = True if value == "true" else False
54 else:
55 pl[key] = value
58if daf_base is None: 58 ↛ 59line 58 didn't jump to line 59, because the condition on line 58 was never true
59 yaml.add_constructor("lsst.daf.base.PropertyList", pl_constructor, Loader=Loader)
62def read_test_file(filename, dir=None):
63 """Read the named test file relative to the location of this helper
65 Parameters
66 ----------
67 filename : `str`
68 Name of file in the data directory.
69 dir : `str`, optional.
70 Directory from which to read file. Current directory used if none
71 specified.
73 Returns
74 -------
75 header : `dict`-like
76 Header read from file.
77 """
78 if dir is not None and not os.path.isabs(filename):
79 filename = os.path.join(dir, filename)
80 with open(filename) as fd:
81 header = yaml.load(fd, Loader=Loader)
82 # Cannot directly check for Mapping because PropertyList is not one
83 if not hasattr(header, "items"):
84 raise ValueError(f"Contents of YAML file {filename} are not a mapping, they are {type(header)}")
85 return header
88class MetadataAssertHelper:
89 """Class with helpful asserts that can be used for testing metadata
90 translations.
91 """
93 def assertCoordinatesConsistent(self, obsinfo, max_sep=1.0, amdelta=0.01): # noqa: N802
94 """Check that SkyCoord, AltAz, and airmass are self consistent.
96 Parameters
97 ----------
98 obsinfo : `ObservationInfo`
99 Object to check.
100 max_sep : `float`, optional
101 Maximum separation between AltAz derived from RA/Dec headers
102 and that found in the AltAz headers.
103 amdelta : `float`, optional
104 Max difference between header airmass and derived airmass.
106 Raises
107 ------
108 AssertionError
109 Inconsistencies found.
110 """
111 self.assertIsNotNone(obsinfo.tracking_radec)
112 self.assertIsNotNone(obsinfo.altaz_begin)
114 # Is airmass from header close to airmass from AltAz headers?
115 # In most cases there is uncertainty over whether the elevation
116 # and airmass in the header are for the start, end, or middle
117 # of the observation. Sometimes the AltAz is from the start
118 # but the airmass is from the middle so accuracy is not certain.
119 self.assertAlmostEqual(obsinfo.altaz_begin.secz.to_value(), obsinfo.boresight_airmass, delta=amdelta)
121 # Is AltAz from headers close to AltAz from RA/Dec headers?
122 # Can trigger warnings from Astropy if date is in future
123 with warnings.catch_warnings():
124 warnings.simplefilter("ignore", category=astropy.utils.exceptions.AstropyWarning)
125 sep = obsinfo.altaz_begin.separation(obsinfo.tracking_radec.altaz)
126 self.assertLess(sep.to_value(unit="arcmin"), max_sep, msg="AltAz inconsistent with RA/Dec")
128 def assertObservationInfoFromYaml(self, file, dir=None, check_wcs=True, # noqa: N802
129 wcs_params=None, **kwargs):
130 """Check contents of an ObservationInfo.
132 Parameters
133 ----------
134 file : `str`
135 Path to YAML file representing the header.
136 dir : `str`, optional
137 Optional directory from which to read ``file``.
138 check_wcs : `bool`, optional
139 Check the consistency of the RA/Dec and AltAz values.
140 wcs_params : `dict`, optional
141 Parameters to pass to `assertCoordinatesConsistent`.
142 kwargs : `dict`
143 Keys matching `ObservationInfo` properties with values
144 to be tested.
146 Raises
147 ------
148 AssertionError
149 A value in the ObservationInfo derived from the file is
150 inconsistent.
151 """
152 header = read_test_file(file, dir=dir)
154 # DM-30093: Astropy Header does not always behave dict-like
155 astropy_header = Header()
156 for key, val in header.items():
157 values = val if isinstance(val, list) else [val]
158 for v in values:
159 # appending ensures *all* duplicated keys are also preserved
160 astropy_header.append((key, v))
162 for hdr in (header, astropy_header):
163 try:
164 self.assertObservationInfo(header, filename=file, check_wcs=check_wcs,
165 wcs_params=wcs_params, **kwargs)
166 except AssertionError as e:
167 raise AssertionError(f"ObservationInfo derived from {type(hdr)} "
168 "type is inconsistent.") from e
170 def assertObservationInfo(self, header, filename=None, check_wcs=True, # noqa: N802
171 wcs_params=None, **kwargs):
172 """Check contents of an ObservationInfo.
174 Parameters
175 ----------
176 header : `dict`-like
177 Header to be checked.
178 filename : `str`, optional
179 Name of the filename associated with this header if known.
180 check_wcs : `bool`, optional
181 Check the consistency of the RA/Dec and AltAz values. Checks
182 are automatically disabled if the translated header does
183 not appear to be "science".
184 wcs_params : `dict`, optional
185 Parameters to pass to `assertCoordinatesConsistent`.
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 # For testing we force pedantic mode since we are in charge
197 # of all the translations
198 obsinfo = ObservationInfo(header, pedantic=True, filename=filename)
199 translator = obsinfo.translator_class_name
201 # Check that we can pickle and get back the same properties
202 newinfo = pickle.loads(pickle.dumps(obsinfo))
203 self.assertEqual(obsinfo, newinfo)
205 # Check the properties
206 for property, expected in kwargs.items():
207 calculated = getattr(obsinfo, property)
208 msg = f"Comparing property {property} using translator {translator}"
209 if isinstance(expected, u.Quantity) and calculated is not None:
210 calculated = calculated.to_value(unit=expected.unit)
211 expected = expected.to_value()
212 self.assertAlmostEqual(calculated, expected, msg=msg)
213 elif isinstance(calculated, u.Quantity):
214 # Only happens if the test is not a quantity when it should be
215 self.fail(f"Expected {expected!r} but got Quantity '{calculated}': {msg}")
216 elif isinstance(expected, float) and calculated is not None:
217 self.assertAlmostEqual(calculated, expected, msg=msg)
218 else:
219 self.assertEqual(calculated, expected, msg=msg)
221 # Date comparison error reports will benefit by specifying ISO
222 # format. Generate a new Time object at fixed precision
223 # to work around the fact that (as of astropy 3.1) adding 0.0 seconds
224 # to a Time results in a new Time object that is a few picoseconds in
225 # the past.
226 def _format_date_for_testing(date):
227 if date is not None:
228 date.format = "isot"
229 date.precision = 9
230 date = Time(str(date), scale=date.scale, format="isot")
231 return date
233 datetime_begin = _format_date_for_testing(obsinfo.datetime_begin)
234 datetime_end = _format_date_for_testing(obsinfo.datetime_end)
236 # Check that dates are defined and end is the same or after beginning
237 self.assertLessEqual(datetime_begin, datetime_end)
239 # Check that exposure time is not outside datetime_end
240 self.assertLessEqual(obsinfo.datetime_begin + obsinfo.exposure_time,
241 obsinfo.datetime_end)
243 # Check the WCS consistency
244 if check_wcs and obsinfo.observation_type == "science":
245 if wcs_params is None:
246 wcs_params = {}
247 self.assertCoordinatesConsistent(obsinfo, **wcs_params)