Coverage for python/astro_metadata_translator/observationInfo.py: 17%
271 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 03:54 -0700
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-20 03:54 -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.
12"""Represent standard metadata from instrument headers."""
14from __future__ import annotations
16__all__ = ("ObservationInfo", "makeObservationInfo")
18import copy
19import itertools
20import json
21import logging
22import math
23from collections.abc import Callable, MutableMapping, Sequence
24from typing import TYPE_CHECKING, Any
26import astropy.time
27from astropy.coordinates import AltAz, SkyCoord
29from .headers import fix_header
30from .properties import PROPERTIES, PropertyDefinition
31from .translator import MetadataTranslator
33if TYPE_CHECKING: 33 ↛ 34line 33 didn't jump to line 34, because the condition on line 33 was never true
34 import astropy.coordinates
35 import astropy.units
37log = logging.getLogger(__name__)
40class ObservationInfo:
41 """Standardized representation of an instrument header for a single
42 exposure observation.
44 There is a core set of instrumental properties that are pre-defined.
45 Additional properties may be defined, either through the
46 ``makeObservationInfo`` factory function by providing the ``extensions``
47 definitions, or through the regular ``ObservationInfo`` constructor when
48 the extensions have been defined in the ``MetadataTranslator`` for the
49 instrument of interest (or in the provided ``translator_class``).
51 Parameters
52 ----------
53 header : `dict`-like
54 Representation of an instrument header accessible as a `dict`.
55 May be updated with header corrections if corrections are found.
56 filename : `str`, optional
57 Name of the file whose header is being translated. For some
58 datasets with missing header information this can sometimes
59 allow for some fixups in translations.
60 translator_class : `MetadataTranslator`-class, optional
61 If not `None`, the class to use to translate the supplied headers
62 into standard form. Otherwise each registered translator class will
63 be asked in turn if it knows how to translate the supplied header.
64 pedantic : `bool`, optional
65 If True the translation must succeed for all properties. If False
66 individual property translations must all be implemented but can fail
67 and a warning will be issued.
68 search_path : iterable, optional
69 Override search paths to use during header fix up.
70 required : `set`, optional
71 This parameter can be used to confirm that all properties contained
72 in the set must translate correctly and also be non-None. For the case
73 where ``pedantic`` is `True` this will still check that the resulting
74 value is not `None`.
75 subset : `set`, optional
76 If not `None`, controls the translations that will be performed
77 during construction. This can be useful if the caller is only
78 interested in a subset of the properties and knows that some of
79 the others might be slow to compute (for example the airmass if it
80 has to be derived).
82 Raises
83 ------
84 ValueError
85 Raised if the supplied header was not recognized by any of the
86 registered translators. Also raised if the request property subset
87 is not a subset of the known properties.
88 TypeError
89 Raised if the supplied translator class was not a MetadataTranslator.
90 KeyError
91 Raised if a required property cannot be calculated, or if pedantic
92 mode is enabled and any translations fails.
93 NotImplementedError
94 Raised if the selected translator does not support a required
95 property.
97 Notes
98 -----
99 Headers will be corrected if correction files are located and this will
100 modify the header provided to the constructor.
102 Values of the properties are read-only.
103 """
105 # Static typing requires that we define the standard dynamic properties
106 # statically.
107 if TYPE_CHECKING: 107 ↛ 108line 107 didn't jump to line 108, because the condition on line 107 was never true
108 telescope: int
109 instrument: str
110 location: astropy.coordinates.EarthLocation
111 exposure_id: int
112 visit_id: int
113 physical_filter: str
114 datetime_begin: astropy.time.Time
115 datetime_end: astropy.time.Time
116 exposure_group: str
117 exposure_time: astropy.units.Quantity
118 dark_time: astropy.units.Quantity
119 boresight_airmass: float
120 boresight_rotation_angle: astropy.units.Quantity
121 boresight_rotation_coord: str
122 detector_num: int
123 detector_name: str
124 detector_serial: str
125 detector_group: str
126 detector_exposure_id: int
127 focus_z: astropy.units.Quantity
128 object: str
129 temperature: astropy.units.Quantity
130 pressure: astropy.units.Quantity
131 relative_humidity: float
132 tracking_radec: astropy.coordinates.SkyCoord
133 altaz_begin: astropy.coordinates.AltAz
134 science_program: str
135 observation_counter: int
136 observation_reason: str
137 observation_type: str
138 observation_id: str
139 observing_day: int
140 observing_day_offset: astropy.time.TimeDelta | None
141 group_counter_start: int
142 group_counter_end: int
143 has_simulated_content: bool
145 def __init__(
146 self,
147 header: MutableMapping[str, Any] | None,
148 filename: str | None = None,
149 translator_class: type[MetadataTranslator] | None = None,
150 pedantic: bool = False,
151 search_path: Sequence[str] | None = None,
152 required: set[str] | None = None,
153 subset: set[str] | None = None,
154 ) -> None:
155 # Initialize the empty object
156 self._header: MutableMapping[str, Any] = {}
157 self.filename = filename
158 self._translator = None
159 self.translator_class_name = "<None>"
161 # To allow makeObservationInfo to work, we special case a None
162 # header
163 if header is None:
164 return
166 # Fix up the header (if required)
167 fix_header(header, translator_class=translator_class, filename=filename, search_path=search_path)
169 # Store the supplied header for later stripping
170 self._header = header
172 if translator_class is None:
173 translator_class = MetadataTranslator.determine_translator(header, filename=filename)
174 elif not issubclass(translator_class, MetadataTranslator):
175 raise TypeError(f"Translator class must be a MetadataTranslator, not {translator_class}")
177 self._declare_extensions(translator_class.extensions)
179 # Create an instance for this header
180 translator = translator_class(header, filename=filename)
182 # Store the translator
183 self._translator = translator
184 self.translator_class_name = translator_class.__name__
186 # Form file information string in case we need an error message
187 if filename:
188 file_info = f" and file {filename}"
189 else:
190 file_info = ""
192 # Determine the properties of interest
193 full_set = set(self.all_properties)
194 if subset is not None:
195 if not subset:
196 raise ValueError("Cannot request no properties be calculated.")
197 if not subset.issubset(full_set):
198 raise ValueError(
199 "Requested subset is not a subset of known properties. " f"Got extra: {subset - full_set}"
200 )
201 properties = subset
202 else:
203 properties = full_set
205 if required is None:
206 required = set()
207 else:
208 if not required.issubset(full_set):
209 raise ValueError("Requested required properties include unknowns: " f"{required - full_set}")
211 # Loop over each property and request the translated form
212 for t in properties:
213 # prototype code
214 method = f"to_{t}"
215 property = f"_{t}" if not t.startswith("ext_") else t
217 try:
218 value = getattr(translator, method)()
219 except NotImplementedError as e:
220 raise NotImplementedError(
221 f"No translation exists for property '{t}'" f" using translator {translator.__class__}"
222 ) from e
223 except Exception as e:
224 err_msg = (
225 f"Error calculating property '{t}' using translator {translator.__class__}" f"{file_info}"
226 )
227 if pedantic or t in required:
228 raise KeyError(err_msg) from e
229 else:
230 log.debug("Calculation of property '%s' failed with header: %s", t, header)
231 log.warning(f"Ignoring {err_msg}: {e}")
232 continue
234 definition = self.all_properties[t]
235 if not self._is_property_ok(definition, value):
236 err_msg = (
237 f"Value calculated for property '{t}' is wrong type "
238 f"({type(value)} != {definition.str_type}) using translator {translator.__class__}"
239 f"{file_info}"
240 )
241 if pedantic or t in required:
242 raise TypeError(err_msg)
243 else:
244 log.debug("Calcuation of property '%s' had unexpected type with header: %s", t, header)
245 log.warning(f"Ignoring {err_msg}")
247 if value is None and t in required:
248 raise KeyError(f"Calculation of required property {t} resulted in a value of None")
250 super().__setattr__(property, value) # allows setting even write-protected extensions
252 @staticmethod
253 def _get_all_properties(
254 extensions: dict[str, PropertyDefinition] | None = None
255 ) -> dict[str, PropertyDefinition]:
256 """Return the definitions of all properties.
258 Parameters
259 ----------
260 extensions : `dict` [`str`: `PropertyDefinition`]
261 List of extension property definitions, indexed by name (with no
262 "ext_" prefix).
264 Returns
265 -------
266 properties : `dict` [`str`: `PropertyDefinition`]
267 Merged list of all property definitions, indexed by name. Extension
268 properties will be listed with an ``ext_`` prefix.
269 """
270 properties = dict(PROPERTIES)
271 if extensions:
272 properties.update({"ext_" + pp: dd for pp, dd in extensions.items()})
273 return properties
275 def _declare_extensions(self, extensions: dict[str, PropertyDefinition] | None) -> None:
276 """Declare and set up extension properties.
278 This should always be called internally as part of the creation of a
279 new `ObservationInfo`.
281 The core set of properties each have a python ``property`` that makes
282 them read-only, and serves as a useful place to hang the docstring.
283 However, the core set are set up at compile time, whereas the extension
284 properties have to be configured at run time (because we don't know
285 what they will be until we look at the header and figure out what
286 instrument we're dealing with) when we have an instance rather than a
287 class (and python ``property`` doesn't work on instances; only on
288 classes). We therefore use a separate scheme for the extension
289 properties: we write them directly to their associated instance
290 variable, and we use ``__setattr__`` to protect them as read-only.
291 Unfortunately, with this scheme, we can't give extension properties a
292 docstring; but we're setting them up at runtime, so maybe that's not
293 terribly important.
295 Parameters
296 ----------
297 extensions : `dict` [`str`: `PropertyDefinition`]
298 List of extension property definitions, indexed by name (with no
299 "ext_" prefix).
300 """
301 if not extensions:
302 extensions = {}
303 for name in extensions:
304 super().__setattr__("ext_" + name, None)
305 self.extensions = extensions
306 self.all_properties = self._get_all_properties(extensions)
308 def __setattr__(self, name: str, value: Any) -> Any:
309 """Set attribute.
311 This provides read-only protection for the extension properties. The
312 core set of properties have read-only protection via the use of the
313 python ``property``.
315 Parameters
316 ----------
317 name : `str`
318 Name of attribute to set.
319 value : `typing.Any`
320 Value to set it to.
321 """
322 if hasattr(self, "extensions") and name.startswith("ext_") and name[4:] in self.extensions:
323 raise AttributeError(f"Attribute {name} is read-only")
324 return super().__setattr__(name, value)
326 @classmethod
327 def _is_property_ok(cls, definition: PropertyDefinition, value: Any) -> bool:
328 """Compare the supplied value against the expected type as defined
329 for the corresponding property.
331 Parameters
332 ----------
333 definition : `PropertyDefinition`
334 Property definition.
335 value : `object`
336 Value of the property to validate.
338 Returns
339 -------
340 is_ok : `bool`
341 `True` if the value is of an appropriate type.
343 Notes
344 -----
345 Currently only the type of the property is validated. There is no
346 attempt to check bounds or determine that a Quantity is compatible
347 with the property.
348 """
349 if value is None:
350 return True
352 # For AltAz coordinates, they can either arrive as AltAz or
353 # as SkyCoord(frame=AltAz) so try to find the frame inside
354 # the SkyCoord.
355 if issubclass(definition.py_type, AltAz) and isinstance(value, SkyCoord):
356 value = value.frame
358 if not isinstance(value, definition.py_type):
359 return False
361 return True
363 @property
364 def cards_used(self) -> frozenset[str]:
365 """Header cards used for the translation.
367 Returns
368 -------
369 used : `frozenset` of `str`
370 Set of card used.
371 """
372 if not self._translator:
373 return frozenset()
374 return self._translator.cards_used()
376 def stripped_header(self) -> MutableMapping[str, Any]:
377 """Return a copy of the supplied header with used keywords removed.
379 Returns
380 -------
381 stripped : `dict`-like
382 Same class as header supplied to constructor, but with the
383 headers used to calculate the generic information removed.
384 """
385 hdr = copy.copy(self._header)
386 used = self.cards_used
387 for c in used:
388 del hdr[c]
389 return hdr
391 def __str__(self) -> str:
392 # Put more interesting answers at front of list
393 # and then do remainder
394 priority = ("instrument", "telescope", "datetime_begin")
395 properties = sorted(set(self.all_properties) - set(priority))
397 result = ""
398 for p in itertools.chain(priority, properties):
399 value = getattr(self, p)
400 if isinstance(value, astropy.time.Time):
401 value.format = "isot"
402 value = str(value.value)
403 result += f"{p}: {value}\n"
405 return result
407 def __eq__(self, other: Any) -> bool:
408 """Check equality with another object.
410 Compares equal if standard properties are equal.
412 Parameters
413 ----------
414 other : `typing.Any`
415 Thing to compare with.
416 """
417 if not isinstance(other, ObservationInfo):
418 return NotImplemented
420 # Compare simplified forms.
421 # Cannot compare directly because nan will not equate as equal
422 # whereas they should be equal for our purposes
423 self_simple = self.to_simple()
424 other_simple = other.to_simple()
426 # We don't care about the translator internal detail
427 self_simple.pop("_translator", None)
428 other_simple.pop("_translator", None)
430 for k, self_value in self_simple.items():
431 other_value = other_simple[k]
432 if self_value != other_value:
433 if math.isnan(self_value) and math.isnan(other_value):
434 # If both are nan this is fine
435 continue
436 return False
437 return True
439 def __lt__(self, other: Any) -> bool:
440 if not isinstance(other, ObservationInfo):
441 return NotImplemented
442 return self.datetime_begin < other.datetime_begin
444 def __gt__(self, other: Any) -> bool:
445 if not isinstance(other, ObservationInfo):
446 return NotImplemented
447 return self.datetime_begin > other.datetime_begin
449 def __getstate__(self) -> tuple[Any, ...]:
450 """Get pickleable state.
452 Returns the properties. Deliberately does not preserve the full
453 current state; in particular, does not return the full header or
454 translator.
456 Returns
457 -------
458 state : `tuple`
459 Pickled state.
460 """
461 state = dict()
462 for p in self.all_properties:
463 state[p] = getattr(self, p)
465 return state, self.extensions
467 def __setstate__(self, state: tuple[Any, ...]) -> None:
468 """Set object state from pickle.
470 Parameters
471 ----------
472 state : `tuple`
473 Pickled state.
474 """
475 try:
476 state, extensions = state
477 except ValueError:
478 # Backwards compatibility for pickles generated before DM-34175
479 extensions = {}
480 self._declare_extensions(extensions)
481 for p in self.all_properties:
482 if p.startswith("ext_"):
483 # allows setting even write-protected extensions
484 super().__setattr__(p, state[p]) # type: ignore
485 else:
486 property = f"_{p}"
487 setattr(self, property, state[p]) # type: ignore
489 def to_simple(self) -> MutableMapping[str, Any]:
490 """Convert the contents of this object to simple dict form.
492 The keys of the dict are the standard properties but the values
493 can be simplified to support JSON serialization. For example a
494 SkyCoord might be represented as an ICRS RA/Dec tuple rather than
495 a full SkyCoord representation.
497 Any properties with `None` value will be skipped.
499 Can be converted back to an `ObservationInfo` using `from_simple()`.
501 Returns
502 -------
503 simple : `dict` of [`str`, `~typing.Any`]
504 Simple dict of all properties.
506 Notes
507 -----
508 Round-tripping of extension properties requires that the
509 `ObservationInfo` was created with the help of a registered
510 `MetadataTranslator` (which contains the extension property
511 definitions).
512 """
513 simple = {}
514 if hasattr(self, "_translator") and self._translator and self._translator.name:
515 simple["_translator"] = self._translator.name
517 for p in self.all_properties:
518 property = f"_{p}" if not p.startswith("ext_") else p
519 value = getattr(self, property)
520 if value is None:
521 continue
523 # Access the function to simplify the property
524 simplifier = self.all_properties[p].to_simple
526 if simplifier is None:
527 simple[p] = value
528 continue
530 simple[p] = simplifier(value)
532 return simple
534 def to_json(self) -> str:
535 """Serialize the object to JSON string.
537 Returns
538 -------
539 j : `str`
540 The properties of the ObservationInfo in JSON string form.
542 Notes
543 -----
544 Round-tripping of extension properties requires that the
545 `ObservationInfo` was created with the help of a registered
546 `MetadataTranslator` (which contains the extension property
547 definitions).
548 """
549 return json.dumps(self.to_simple())
551 @classmethod
552 def from_simple(cls, simple: MutableMapping[str, Any]) -> ObservationInfo:
553 """Convert the entity returned by `to_simple` back into an
554 `ObservationInfo`.
556 Parameters
557 ----------
558 simple : `dict` [`str`, `~typing.Any`]
559 The dict returned by `to_simple()`.
561 Returns
562 -------
563 obsinfo : `ObservationInfo`
564 New object constructed from the dict.
566 Notes
567 -----
568 Round-tripping of extension properties requires that the
569 `ObservationInfo` was created with the help of a registered
570 `MetadataTranslator` (which contains the extension property
571 definitions).
572 """
573 extensions = {}
574 translator = simple.pop("_translator", None)
575 if translator:
576 if translator not in MetadataTranslator.translators:
577 raise KeyError(f"Unrecognised translator: {translator}")
578 extensions = MetadataTranslator.translators[translator].extensions
580 properties = cls._get_all_properties(extensions)
582 processed: dict[str, Any] = {}
583 for k, v in simple.items():
584 if v is None:
585 continue
587 # Access the function to convert from simple form
588 complexifier = properties[k].from_simple
590 if complexifier is not None:
591 v = complexifier(v, **processed)
593 processed[k] = v
595 return cls.makeObservationInfo(extensions=extensions, **processed)
597 @classmethod
598 def from_json(cls, json_str: str) -> ObservationInfo:
599 """Create `ObservationInfo` from JSON string.
601 Parameters
602 ----------
603 json_str : `str`
604 The JSON representation.
606 Returns
607 -------
608 obsinfo : `ObservationInfo`
609 Reconstructed object.
611 Notes
612 -----
613 Round-tripping of extension properties requires that the
614 `ObservationInfo` was created with the help of a registered
615 `MetadataTranslator` (which contains the extension property
616 definitions).
617 """
618 simple = json.loads(json_str)
619 return cls.from_simple(simple)
621 @classmethod
622 def makeObservationInfo( # noqa: N802
623 cls, *, extensions: dict[str, PropertyDefinition] | None = None, **kwargs: Any
624 ) -> ObservationInfo:
625 """Construct an `ObservationInfo` from the supplied parameters.
627 Parameters
628 ----------
629 extensions : `dict` [`str`: `PropertyDefinition`], optional
630 Optional extension definitions, indexed by extension name (without
631 the ``ext_`` prefix, which will be added by `ObservationInfo`).
632 **kwargs
633 Name-value pairs for any properties to be set. In the case of
634 extension properties, the names should include the ``ext_`` prefix.
636 Notes
637 -----
638 The supplied parameters should use names matching the property.
639 The type of the supplied value will be checked against the property.
640 Any properties not supplied will be assigned a value of `None`.
642 Raises
643 ------
644 KeyError
645 Raised if a supplied parameter key is not a known property.
646 TypeError
647 Raised if a supplied value does not match the expected type
648 of the property.
649 """
650 obsinfo = cls(None)
651 obsinfo._declare_extensions(extensions)
653 unused = set(kwargs)
655 for p in obsinfo.all_properties:
656 if p in kwargs:
657 property = f"_{p}" if not p.startswith("ext_") else p
658 value = kwargs[p]
659 definition = obsinfo.all_properties[p]
660 if not cls._is_property_ok(definition, value):
661 raise TypeError(
662 f"Supplied value {value} for property {p} "
663 f"should be of class {definition.str_type} not {value.__class__}"
664 )
665 super(cls, obsinfo).__setattr__(property, value) # allows setting write-protected extensions
666 unused.remove(p)
668 # Recent additions to ObservationInfo may not be present in
669 # serializations. In theory they can be derived from other
670 # values in the default case. This might not be the right thing
671 # to do.
672 for k in ("group_counter_start", "group_counter_end"):
673 if k not in kwargs and "observation_counter" in kwargs:
674 super(cls, obsinfo).__setattr__(f"_{k}", obsinfo.observation_counter)
675 if (k := "has_simulated_content") not in kwargs:
676 super(cls, obsinfo).__setattr__(f"_{k}", False)
678 if unused:
679 n = len(unused)
680 raise KeyError(f"Unrecognized propert{'y' if n == 1 else 'ies'} provided: {', '.join(unused)}")
682 return obsinfo
685# Method to add the standard properties
686def _make_property(property: str, doc: str, return_typedoc: str, return_type: type) -> Callable:
687 """Create a getter method with associated docstring.
689 Parameters
690 ----------
691 property : `str`
692 Name of the property getter to be created.
693 doc : `str`
694 Description of this property.
695 return_typedoc : `str`
696 Type string of this property (used in the doc string).
697 return_type : `class`
698 Type of this property.
700 Returns
701 -------
702 p : `function`
703 Getter method for this property.
704 """
706 def getter(self: ObservationInfo) -> Any:
707 return getattr(self, f"_{property}")
709 getter.__doc__ = f"""{doc}
711 Returns
712 -------
713 {property} : `{return_typedoc}`
714 Access the property.
715 """
716 return getter
719# Set up the core set of properties
720# In order to provide read-only protection, each attribute is hidden behind a
721# python "property" wrapper.
722for name, definition in PROPERTIES.items():
723 setattr(ObservationInfo, f"_{name}", None)
724 setattr(
725 ObservationInfo,
726 name,
727 property(_make_property(name, definition.doc, definition.str_type, definition.py_type)),
728 )
731def makeObservationInfo( # noqa: N802
732 *, extensions: dict[str, PropertyDefinition] | None = None, **kwargs: Any
733) -> ObservationInfo:
734 """Construct an `ObservationInfo` from the supplied parameters.
736 Parameters
737 ----------
738 extensions : `dict` [`str`: `PropertyDefinition`], optional
739 Optional extension definitions, indexed by extension name (without
740 the ``ext_`` prefix, which will be added by `ObservationInfo`).
741 **kwargs
742 Name-value pairs for any properties to be set. In the case of
743 extension properties, the names should include the ``ext_`` prefix.
745 Notes
746 -----
747 The supplied parameters should use names matching the property.
748 The type of the supplied value will be checked against the property.
749 Any properties not supplied will be assigned a value of `None`.
751 Raises
752 ------
753 KeyError
754 Raised if a supplied parameter key is not a known property.
755 TypeError
756 Raised if a supplied value does not match the expected type
757 of the property.
758 """
759 return ObservationInfo.makeObservationInfo(extensions=extensions, **kwargs)