Coverage for python/astro_metadata_translator/observationInfo.py: 17%
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"""Represent standard metadata from instrument headers"""
14__all__ = ("ObservationInfo", "makeObservationInfo")
16import itertools
17import logging
18import copy
19import json
20import math
22import astropy.time
23from astropy.coordinates import SkyCoord, AltAz
25from .translator import MetadataTranslator
26from .properties import PROPERTIES
27from .headers import fix_header
29log = logging.getLogger(__name__)
32class ObservationInfo:
33 """Standardized representation of an instrument header for a single
34 exposure observation.
36 Parameters
37 ----------
38 header : `dict`-like
39 Representation of an instrument header accessible as a `dict`.
40 May be updated with header corrections if corrections are found.
41 filename : `str`, optional
42 Name of the file whose header is being translated. For some
43 datasets with missing header information this can sometimes
44 allow for some fixups in translations.
45 translator_class : `MetadataTranslator`-class, optional
46 If not `None`, the class to use to translate the supplied headers
47 into standard form. Otherwise each registered translator class will
48 be asked in turn if it knows how to translate the supplied header.
49 pedantic : `bool`, optional
50 If True the translation must succeed for all properties. If False
51 individual property translations must all be implemented but can fail
52 and a warning will be issued.
53 search_path : iterable, optional
54 Override search paths to use during header fix up.
55 required : `set`, optional
56 This parameter can be used to confirm that all properties contained
57 in the set must translate correctly and also be non-None. For the case
58 where ``pedantic`` is `True` this will still check that the resulting
59 value is not `None`.
60 subset : `set`, optional
61 If not `None`, controls the translations that will be performed
62 during construction. This can be useful if the caller is only
63 interested in a subset of the properties and knows that some of
64 the others might be slow to compute (for example the airmass if it
65 has to be derived).
67 Raises
68 ------
69 ValueError
70 Raised if the supplied header was not recognized by any of the
71 registered translators. Also raised if the request property subset
72 is not a subset of the known properties.
73 TypeError
74 Raised if the supplied translator class was not a MetadataTranslator.
75 KeyError
76 Raised if a required property cannot be calculated, or if pedantic
77 mode is enabled and any translations fails.
78 NotImplementedError
79 Raised if the selected translator does not support a required
80 property.
82 Notes
83 -----
84 Headers will be corrected if correction files are located and this will
85 modify the header provided to the constructor.
86 """
88 _PROPERTIES = PROPERTIES
89 """All the properties supported by this class with associated
90 documentation."""
92 def __init__(self, header, filename=None, translator_class=None, pedantic=False,
93 search_path=None, required=None, subset=None):
95 # Initialize the empty object
96 self._header = {}
97 self.filename = filename
98 self._translator = None
99 self.translator_class_name = "<None>"
101 # To allow makeObservationInfo to work, we special case a None
102 # header
103 if header is None:
104 return
106 # Fix up the header (if required)
107 fix_header(header, translator_class=translator_class, filename=filename,
108 search_path=search_path)
110 # Store the supplied header for later stripping
111 self._header = header
113 if translator_class is None:
114 translator_class = MetadataTranslator.determine_translator(header, filename=filename)
115 elif not issubclass(translator_class, MetadataTranslator):
116 raise TypeError(f"Translator class must be a MetadataTranslator, not {translator_class}")
118 # Create an instance for this header
119 translator = translator_class(header, filename=filename)
121 # Store the translator
122 self._translator = translator
123 self.translator_class_name = translator_class.__name__
125 # Form file information string in case we need an error message
126 if filename:
127 file_info = f" and file {filename}"
128 else:
129 file_info = ""
131 # Determine the properties of interest
132 all_properties = set(self._PROPERTIES)
133 if subset is not None:
134 if not subset:
135 raise ValueError("Cannot request no properties be calculated.")
136 if not subset.issubset(all_properties):
137 raise ValueError("Requested subset is not a subset of known properties. "
138 f"Got extra: {subset - all_properties}")
139 properties = subset
140 else:
141 properties = all_properties
143 if required is None:
144 required = set()
145 else:
146 if not required.issubset(all_properties):
147 raise ValueError("Requested required properties include unknowns: "
148 f"{required - all_properties}")
150 # Loop over each property and request the translated form
151 for t in properties:
152 # prototype code
153 method = f"to_{t}"
154 property = f"_{t}"
156 try:
157 value = getattr(translator, method)()
158 except NotImplementedError as e:
159 raise NotImplementedError(f"No translation exists for property '{t}'"
160 f" using translator {translator.__class__}") from e
161 except KeyError as e:
162 err_msg = f"Error calculating property '{t}' using translator {translator.__class__}" \
163 f"{file_info}"
164 if pedantic or t in required:
165 raise KeyError(err_msg) from e
166 else:
167 log.debug("Calculation of property '%s' failed with header: %s", t, header)
168 log.warning(f"Ignoring {err_msg}: {e}")
169 continue
171 if not self._is_property_ok(t, value):
172 err_msg = f"Value calculated for property '{t}' is wrong type " \
173 f"({type(value)} != {self._PROPERTIES[t][1]}) using translator {translator.__class__}" \
174 f"{file_info}"
175 if pedantic or t in required:
176 raise TypeError(err_msg)
177 else:
178 log.debug("Calcuation of property '%s' had unexpected type with header: %s", t, header)
179 log.warning(f"Ignoring {err_msg}")
181 if value is None and t in required:
182 raise KeyError(f"Calculation of required property {t} resulted in a value of None")
184 setattr(self, property, value)
186 @classmethod
187 def _is_property_ok(cls, property, value):
188 """Compare the supplied value against the expected type as defined
189 for the corresponding property.
191 Parameters
192 ----------
193 property : `str`
194 Name of property.
195 value : `object`
196 Value of the property to validate.
198 Returns
199 -------
200 is_ok : `bool`
201 `True` if the value is of an appropriate type.
203 Notes
204 -----
205 Currently only the type of the property is validated. There is no
206 attempt to check bounds or determine that a Quantity is compatible
207 with the property.
208 """
209 if value is None:
210 return True
212 property_type = cls._PROPERTIES[property][2]
214 # For AltAz coordinates, they can either arrive as AltAz or
215 # as SkyCoord(frame=AltAz) so try to find the frame inside
216 # the SkyCoord.
217 if issubclass(property_type, AltAz) and isinstance(value, SkyCoord):
218 value = value.frame
220 if not isinstance(value, property_type):
221 return False
223 return True
225 @property
226 def cards_used(self):
227 """Header cards used for the translation.
229 Returns
230 -------
231 used : `frozenset` of `str`
232 Set of card used.
233 """
234 if not self._translator:
235 return frozenset()
236 return self._translator.cards_used()
238 def stripped_header(self):
239 """Return a copy of the supplied header with used keywords removed.
241 Returns
242 -------
243 stripped : `dict`-like
244 Same class as header supplied to constructor, but with the
245 headers used to calculate the generic information removed.
246 """
247 hdr = copy.copy(self._header)
248 used = self.cards_used
249 for c in used:
250 del hdr[c]
251 return hdr
253 def __str__(self):
254 # Put more interesting answers at front of list
255 # and then do remainder
256 priority = ("instrument", "telescope", "datetime_begin")
257 properties = sorted(set(self._PROPERTIES.keys()) - set(priority))
259 result = ""
260 for p in itertools.chain(priority, properties):
261 value = getattr(self, p)
262 if isinstance(value, astropy.time.Time):
263 value.format = "isot"
264 value = str(value.value)
265 result += f"{p}: {value}\n"
267 return result
269 def __eq__(self, other):
270 """Compares equal if standard properties are equal
271 """
272 if not isinstance(other, ObservationInfo):
273 return NotImplemented
275 # Compare simplified forms.
276 # Cannot compare directly because nan will not equate as equal
277 # whereas they should be equal for our purposes
278 self_simple = self.to_simple()
279 other_simple = other.to_simple()
281 for k, self_value in self_simple.items():
282 other_value = other_simple[k]
283 if self_value != other_value:
284 if math.isnan(self_value) and math.isnan(other_value):
285 # If both are nan this is fine
286 continue
287 return False
288 return True
290 def __lt__(self, other):
291 return self.datetime_begin < other.datetime_begin
293 def __gt__(self, other):
294 return self.datetime_begin > other.datetime_begin
296 def __getstate__(self):
297 """Get pickleable state
299 Returns the properties, the name of the translator, and the
300 cards that were used. Does not return the full header.
302 Returns
303 -------
304 state : `dict`
305 Dict containing items that can be persisted.
306 """
307 state = dict()
308 for p in self._PROPERTIES:
309 property = f"_{p}"
310 state[p] = getattr(self, property)
312 return state
314 def __setstate__(self, state):
315 for p in self._PROPERTIES:
316 property = f"_{p}"
317 setattr(self, property, state[p])
319 def to_simple(self):
320 """Convert the contents of this object to simple dict form.
322 The keys of the dict are the standard properties but the values
323 can be simplified to support JSON serialization. For example a
324 SkyCoord might be represented as an ICRS RA/Dec tuple rather than
325 a full SkyCoord representation.
327 Any properties with `None` value will be skipped.
329 Can be converted back to an `ObservationInfo` using `from_simple()`.
331 Returns
332 -------
333 simple : `dict` of [`str`, `Any`]
334 Simple dict of all properties.
335 """
336 simple = {}
338 for p in self._PROPERTIES:
339 property = f"_{p}"
340 value = getattr(self, property)
341 if value is None:
342 continue
344 # Access the function to simplify the property
345 simplifier = self._PROPERTIES[p][3]
347 if simplifier is None:
348 simple[p] = value
349 continue
351 simple[p] = simplifier(value)
353 return simple
355 def to_json(self):
356 """Serialize the object to JSON string.
358 Returns
359 -------
360 j : `str`
361 The properties of the ObservationInfo in JSON string form.
362 """
363 return json.dumps(self.to_simple())
365 @classmethod
366 def from_simple(cls, simple):
367 """Convert the entity returned by `to_simple` back into an
368 `ObservationInfo`.
370 Parameters
371 ----------
372 simple : `dict` [`str`, `Any`]
373 The dict returned by `to_simple()`
375 Returns
376 -------
377 obsinfo : `ObservationInfo`
378 New object constructed from the dict.
379 """
380 processed = {}
381 for k, v in simple.items():
383 if v is None:
384 continue
386 # Access the function to convert from simple form
387 complexifier = cls._PROPERTIES[k][4]
389 if complexifier is not None:
390 v = complexifier(v, **processed)
392 processed[k] = v
394 return cls.makeObservationInfo(**processed)
396 @classmethod
397 def from_json(cls, json_str):
398 """Create `ObservationInfo` from JSON string.
400 Parameters
401 ----------
402 json_str : `str`
403 The JSON representation.
405 Returns
406 -------
407 obsinfo : `ObservationInfo`
408 Reconstructed object.
409 """
410 simple = json.loads(json_str)
411 return cls.from_simple(simple)
413 @classmethod
414 def makeObservationInfo(cls, **kwargs): # noqa: N802
415 """Construct an `ObservationInfo` from the supplied parameters.
417 Notes
418 -----
419 The supplied parameters should use names matching the property.
420 The type of the supplied value will be checked against the property.
421 Any properties not supplied will be assigned a value of `None`.
423 Raises
424 ------
425 KeyError
426 Raised if a supplied parameter key is not a known property.
427 TypeError
428 Raised if a supplied value does not match the expected type
429 of the property.
430 """
432 obsinfo = cls(None)
434 unused = set(kwargs)
436 for p in cls._PROPERTIES:
437 if p in kwargs:
438 property = f"_{p}"
439 value = kwargs[p]
440 if not cls._is_property_ok(p, value):
441 raise TypeError(f"Supplied value {value} for property {p} "
442 f"should be of class {cls._PROPERTIES[p][1]} not {value.__class__}")
443 setattr(obsinfo, property, value)
444 unused.remove(p)
446 if unused:
447 n = len(unused)
448 raise KeyError(f"Unrecognized propert{'y' if n == 1 else 'ies'} provided: {', '.join(unused)}")
450 return obsinfo
453# Method to add the standard properties
454def _make_property(property, doc, return_typedoc, return_type):
455 """Create a getter method with associated docstring.
457 Parameters
458 ----------
459 property : `str`
460 Name of the property getter to be created.
461 doc : `str`
462 Description of this property.
463 return_typedoc : `str`
464 Type string of this property (used in the doc string).
465 return_type : `class`
466 Type of this property.
468 Returns
469 -------
470 p : `function`
471 Getter method for this property.
472 """
473 def getter(self):
474 return getattr(self, f"_{property}")
476 getter.__doc__ = f"""{doc}
478 Returns
479 -------
480 {property} : `{return_typedoc}`
481 Access the property.
482 """
483 return getter
486# Initialize the internal properties (underscored) and add the associated
487# getter methods.
488for name, description in ObservationInfo._PROPERTIES.items():
489 setattr(ObservationInfo, f"_{name}", None)
490 setattr(ObservationInfo, name, property(_make_property(name, *description[:3])))
493def makeObservationInfo(**kwargs): # noqa: N802
494 """Construct an `ObservationInfo` from the supplied parameters.
496 Notes
497 -----
498 The supplied parameters should use names matching the property.
499 The type of the supplied value will be checked against the property.
500 Any properties not supplied will be assigned a value of `None`.
502 Raises
503 ------
504 KeyError
505 Raised if a supplied parameter key is not a known property.
506 TypeError
507 Raised if a supplied value does not match the expected type
508 of the property.
509 """
510 return ObservationInfo.makeObservationInfo(**kwargs)