Coverage for python / astro_metadata_translator / observationInfo.py: 24%
332 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-27 01:23 -0800
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-27 01:23 -0800
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 logging
21import math
22from collections.abc import MutableMapping, Sequence
23from typing import TYPE_CHECKING, Any, cast, overload
25import astropy.time
26from lsst.resources import ResourcePath
27from pydantic import (
28 BaseModel,
29 ConfigDict,
30 Field,
31 PrivateAttr,
32 ValidationInfo,
33 field_validator,
34 model_serializer,
35)
37from .headers import fix_header
38from .properties import (
39 PROPERTIES,
40 PropertyDefinition,
41)
42from .translator import MetadataTranslator
44if TYPE_CHECKING:
45 import astropy.coordinates
46 import astropy.units
48log = logging.getLogger(__name__)
49_CORE_FROM_SIMPLE_FIELDS = tuple(name for name, definition in PROPERTIES.items() if definition.from_simple)
52class ObservationInfo(BaseModel):
53 """Standardized representation of an instrument header for a single
54 exposure observation.
56 Parameters
57 ----------
58 header : `dict`-like
59 Representation of an instrument header accessible as a `dict`.
60 May be updated with header corrections if corrections are found.
61 filename : `str`, optional
62 Name of the file whose header is being translated. For some
63 datasets with missing header information this can sometimes
64 allow for some fixups in translations.
65 translator_class : `MetadataTranslator`-class, optional
66 If not `None`, the class to use to translate the supplied headers
67 into standard form. Otherwise each registered translator class will
68 be asked in turn if it knows how to translate the supplied header.
69 pedantic : `bool`, optional
70 If True the translation must succeed for all properties. If False
71 individual property translations must all be implemented but can fail
72 and a warning will be issued. Only used if a ``header`` is specified.
73 search_path : `~collections.abc.Iterable`, optional
74 Override search paths to use during header fix up. Only used if a
75 ``header`` is specified.
76 required : `set`, optional
77 This parameter can be used to confirm that all properties contained
78 in the set must translate correctly and also be non-None. For the case
79 where ``pedantic`` is `True` this will still check that the resulting
80 value is not `None`. Only used if a ``header`` is specified.
81 subset : `set`, optional
82 If not `None`, controls the translations that will be performed
83 during construction. This can be useful if the caller is only
84 interested in a subset of the properties and knows that some of
85 the others might be slow to compute (for example the airmass if it
86 has to be derived). Only used if a ``header`` is specified.
87 **kwargs : `typing.Any`
88 Property name/value pairs for kwargs-based construction mode. This
89 mode creates an `ObservationInfo` directly from supplied properties
90 rather than by translating a header. If ``header`` is provided it is
91 an error to also provide ``kwargs``.
93 Raises
94 ------
95 ValueError
96 Raised if the supplied header was not recognized by any of the
97 registered translators. Also raised if the request property subset
98 is not a subset of the known properties or if a header is given along
99 with kwargs.
100 TypeError
101 Raised if the supplied translator class was not a MetadataTranslator.
102 KeyError
103 Raised if a required property cannot be calculated, or if pedantic
104 mode is enabled and any translations fails.
105 NotImplementedError
106 Raised if the selected translator does not support a required
107 property.
109 Notes
110 -----
111 There is a core set of instrumental properties that are pre-defined.
112 Additional properties may be defined, either through the
113 `makeObservationInfo` factory function by providing the ``extensions``
114 definitions, or through the regular `ObservationInfo` constructor when
115 the extensions have been defined in the `MetadataTranslator` for the
116 instrument of interest (or in the provided ``translator_class``).
118 There are two forms of the constructor. If the ``header`` is given
119 then a translator will be determined and the properties will be populated
120 accordingly. No generic keyword arguments will be expected and the
121 remaining parameters control the behavior of the translator.
123 If the header is not given it is assumed that the keyword arguments
124 are direct specifications of observation properties. In this mode only
125 the ``filename`` and ``translator_class`` parameters will be used. The
126 latter is used to determine any extensions that are being provided,
127 although when using standard serializations the special ``_translator``
128 key will be used instead to specify the name of the registered translator
129 from which to extract extension definitions.
131 Headers will be corrected if correction files are located and this will
132 modify the header provided to the constructor. Modifying the supplied
133 header after construction will modify the internal cached header.
135 Values of the properties are read-only.
136 """
138 model_config = ConfigDict(
139 extra="forbid",
140 arbitrary_types_allowed=True,
141 validate_assignment=False,
142 ser_json_inf_nan="constants", # Allow for inf and nan to round trip.
143 )
145 filename: str | None = Field(default=None, exclude=True)
146 translator_class_name: str = Field(default="<None>", exclude=True)
147 extensions: dict[str, PropertyDefinition] = Field(default_factory=dict, exclude=True)
148 all_properties: dict[str, PropertyDefinition] = Field(default_factory=dict, exclude=True)
149 telescope: str | None = Field(default=None, description=PROPERTIES["telescope"].doc)
150 instrument: str | None = Field(default=None, description=PROPERTIES["instrument"].doc)
151 location: astropy.coordinates.EarthLocation | None = Field(
152 default=None, description=PROPERTIES["location"].doc
153 )
154 exposure_id: int | None = Field(default=None, description=PROPERTIES["exposure_id"].doc)
155 visit_id: int | None = Field(default=None, description=PROPERTIES["visit_id"].doc)
156 physical_filter: str | None = Field(default=None, description=PROPERTIES["physical_filter"].doc)
157 datetime_begin: astropy.time.Time | None = Field(
158 default=None, description=PROPERTIES["datetime_begin"].doc
159 )
160 datetime_end: astropy.time.Time | None = Field(default=None, description=PROPERTIES["datetime_end"].doc)
161 exposure_time: astropy.units.Quantity | None = Field(
162 default=None, description=PROPERTIES["exposure_time"].doc
163 )
164 exposure_time_requested: astropy.units.Quantity | None = Field(
165 default=None, description=PROPERTIES["exposure_time_requested"].doc
166 )
167 dark_time: astropy.units.Quantity | None = Field(default=None, description=PROPERTIES["dark_time"].doc)
168 boresight_airmass: float | None = Field(default=None, description=PROPERTIES["boresight_airmass"].doc)
169 boresight_rotation_angle: astropy.coordinates.Angle | None = Field(
170 default=None, description=PROPERTIES["boresight_rotation_angle"].doc
171 )
172 boresight_rotation_coord: str | None = Field(
173 default=None, description=PROPERTIES["boresight_rotation_coord"].doc
174 )
175 detector_num: int | None = Field(default=None, description=PROPERTIES["detector_num"].doc)
176 detector_name: str | None = Field(default=None, description=PROPERTIES["detector_name"].doc)
177 detector_unique_name: str | None = Field(default=None, description=PROPERTIES["detector_unique_name"].doc)
178 detector_serial: str | None = Field(default=None, description=PROPERTIES["detector_serial"].doc)
179 detector_group: str | None = Field(default=None, description=PROPERTIES["detector_group"].doc)
180 detector_exposure_id: int | None = Field(default=None, description=PROPERTIES["detector_exposure_id"].doc)
181 focus_z: astropy.units.Quantity | None = Field(default=None, description=PROPERTIES["focus_z"].doc)
182 object: str | None = Field(default=None, description=PROPERTIES["object"].doc)
183 temperature: astropy.units.Quantity | None = Field(
184 default=None, description=PROPERTIES["temperature"].doc
185 )
186 pressure: astropy.units.Quantity | None = Field(default=None, description=PROPERTIES["pressure"].doc)
187 relative_humidity: float | None = Field(default=None, description=PROPERTIES["relative_humidity"].doc)
188 tracking_radec: astropy.coordinates.SkyCoord | None = Field(
189 default=None, description=PROPERTIES["tracking_radec"].doc
190 )
191 altaz_begin: astropy.coordinates.AltAz | None = Field(
192 default=None, description=PROPERTIES["altaz_begin"].doc
193 )
194 altaz_end: astropy.coordinates.AltAz | None = Field(default=None, description=PROPERTIES["altaz_end"].doc)
195 science_program: str | None = Field(default=None, description=PROPERTIES["science_program"].doc)
196 observation_type: str | None = Field(default=None, description=PROPERTIES["observation_type"].doc)
197 observation_id: str | None = Field(default=None, description=PROPERTIES["observation_id"].doc)
198 observation_reason: str | None = Field(default=None, description=PROPERTIES["observation_reason"].doc)
199 exposure_group: str | None = Field(default=None, description=PROPERTIES["exposure_group"].doc)
200 observing_day: int | None = Field(default=None, description=PROPERTIES["observing_day"].doc)
201 observing_day_offset: astropy.time.TimeDelta | None = Field(
202 default=None, description=PROPERTIES["observing_day_offset"].doc
203 )
204 observation_counter: int | None = Field(default=None, description=PROPERTIES["observation_counter"].doc)
205 has_simulated_content: bool | None = Field(
206 default=None, description=PROPERTIES["has_simulated_content"].doc
207 )
208 group_counter_start: int | None = Field(default=None, description=PROPERTIES["group_counter_start"].doc)
209 group_counter_end: int | None = Field(default=None, description=PROPERTIES["group_counter_end"].doc)
210 can_see_sky: bool | None = Field(default=None, description=PROPERTIES["can_see_sky"].doc)
212 _header: MutableMapping[str, Any] = PrivateAttr(default_factory=dict)
213 _translator: MetadataTranslator | None = PrivateAttr(default=None)
214 _sealed: bool = PrivateAttr(default=False)
216 @field_validator(*_CORE_FROM_SIMPLE_FIELDS, mode="before")
217 @classmethod
218 def _before_core_from_simple(cls, value: Any, info: ValidationInfo) -> Any:
219 assert info.field_name is not None
220 definition = PROPERTIES[info.field_name]
221 context = info.data if isinstance(info.data, dict) else {}
222 return cls._coerce_from_simple(definition, value, context)
224 @overload
225 def __init__( 225 ↛ exitline 225 didn't return from function '__init__' because
226 self,
227 header: MutableMapping[str, Any],
228 filename: str | ResourcePath | None = None,
229 translator_class: type[MetadataTranslator] | None = None,
230 pedantic: bool = False,
231 search_path: Sequence[str] | None = None,
232 required: set[str] | None = None,
233 subset: set[str] | None = None,
234 ) -> None: ...
236 @overload
237 def __init__( 237 ↛ exitline 237 didn't return from function '__init__' because
238 self,
239 header: None = None,
240 filename: str | ResourcePath | None = None,
241 translator_class: type[MetadataTranslator] | None = None,
242 **kwargs: Any,
243 ) -> None: ...
245 def __init__(
246 self,
247 header: MutableMapping[str, Any] | None = None,
248 filename: str | ResourcePath | None = None,
249 translator_class: type[MetadataTranslator] | None = None,
250 pedantic: bool = False,
251 search_path: Sequence[str] | None = None,
252 required: set[str] | None = None,
253 subset: set[str] | None = None,
254 **kwargs: Any,
255 ) -> None:
256 if filename is not None:
257 filename = str(ResourcePath(filename, forceAbsolute=True))
258 if header is not None:
259 if kwargs:
260 raise ValueError(
261 "kwargs not allowed if constructor given a header to translate. "
262 f"Unrecognized keys: {[k for k in kwargs]}"
263 )
264 self._init_from_header(
265 header,
266 filename=filename,
267 translator_class=translator_class,
268 pedantic=pedantic,
269 search_path=search_path,
270 required=required,
271 subset=subset,
272 )
273 return
275 self._init_from_kwargs(filename=filename, translator_class=translator_class, **kwargs)
277 def _init_from_header(
278 self,
279 header: MutableMapping[str, Any],
280 *,
281 filename: str | None,
282 translator_class: type[MetadataTranslator] | None,
283 pedantic: bool,
284 search_path: Sequence[str] | None,
285 required: set[str] | None,
286 subset: set[str] | None,
287 ) -> None:
288 super().__init__(filename=filename)
289 self._sealed = False
290 # Initialize the empty object
291 self._header = {}
292 self._translator = None
294 # Look for translator class before header fixup. fix_header calls
295 # determine_translator immediately on the basis that you need to know
296 # enough of the header to work out the translator before you can fix
297 # it up. There is no gain in asking fix_header to determine the
298 # translator and then trying to work it out again here.
299 if translator_class is None:
300 translator_class = MetadataTranslator.determine_translator(header, filename=filename)
301 elif not issubclass(translator_class, MetadataTranslator):
302 raise TypeError(f"Translator class must be a MetadataTranslator, not {translator_class}")
304 # Fix up the header (if required)
305 fix_header(header, translator_class=translator_class, filename=filename, search_path=search_path)
307 # Store the supplied header for later stripping
308 self._header = header
310 # This configures both self.extensions and self.all_properties.
311 self._declare_extensions(translator_class.extensions)
313 # Create an instance for this header
314 translator = translator_class(header, filename=filename)
316 # Store the translator
317 self._translator = translator
318 self.translator_class_name = translator_class.__name__
320 # Form file information string in case we need an error message
321 if filename:
322 file_info = f" and file {filename}"
323 else:
324 file_info = ""
326 # Determine the properties of interest
327 full_set = set(self.all_properties)
328 if subset is not None:
329 if not subset:
330 raise ValueError("Cannot request no properties be calculated.")
331 if not subset.issubset(full_set):
332 raise ValueError(
333 f"Requested subset is not a subset of known properties. Got extra: {subset - full_set}"
334 )
335 properties = subset
336 else:
337 properties = full_set
339 if required is None:
340 required = set()
341 else:
342 if not required.issubset(full_set):
343 raise ValueError(f"Requested required properties include unknowns: {required - full_set}")
345 # Loop over each property and request the translated form
346 for property in properties:
347 # prototype code
348 method = f"to_{property}"
350 try:
351 value = getattr(translator, method)()
352 except NotImplementedError as e:
353 raise NotImplementedError(
354 f"No translation exists for property '{property}' using translator {translator.__class__}"
355 ) from e
356 except Exception as e:
357 err_msg = (
358 f"Error calculating property '{property}' using "
359 f"translator {translator.__class__}{file_info}"
360 )
361 if pedantic or property in required:
362 raise KeyError(err_msg) from e
363 else:
364 log.debug("Calculation of property '%s' failed with header: %s", property, header)
365 log.warning(f"Ignoring {err_msg}: {e}")
366 continue
368 definition = self.all_properties[property]
369 # Some translators can return a compatible form that needs to
370 # be coerced to the correct type (e.g., returning SkyCoord when you
371 # need AltAz). In theory we could patch the translators to return
372 # AltAz but code has historically not been as picky about this
373 # until pydantic turned up.
374 value = self._coerce_from_simple(definition, value, {})
375 if not definition.is_value_conformant(value):
376 err_msg = (
377 f"Value calculated for property '{property}' is wrong type "
378 f"({type(value)} != {definition.str_type}) using translator {translator.__class__}"
379 f"{file_info}"
380 )
381 if pedantic or property in required:
382 raise TypeError(err_msg)
383 else:
384 log.debug(
385 "Calculation of property '%s' had unexpected type with header: %s", property, header
386 )
387 log.warning(f"Ignoring {err_msg}")
389 if value is None and property in required:
390 raise KeyError(f"Calculation of required property {property} resulted in a value of None")
392 object.__setattr__(self, property, value) # allows setting even write-protected extensions
394 self._sealed = True
396 def _init_from_kwargs(
397 self,
398 *,
399 filename: str | None,
400 translator_class: type[MetadataTranslator] | None,
401 **kwargs: Any,
402 ) -> None:
403 supplied_keys = set(kwargs)
404 translator_name = kwargs.pop("_translator", None)
405 supplied_extensions = kwargs.pop("_extensions", None)
406 if translator_name is not None:
407 if translator_name not in MetadataTranslator.translators:
408 raise KeyError(f"Unrecognized translator: {translator_name}")
409 translator_class = MetadataTranslator.translators[translator_name]
411 if translator_class is not None and not issubclass(translator_class, MetadataTranslator):
412 raise TypeError(f"Translator class must be a MetadataTranslator, not {translator_class}")
414 if supplied_extensions is not None:
415 if translator_class is not None:
416 raise ValueError("Provide either translator_class or _extensions, not both.")
417 if not isinstance(supplied_extensions, dict):
418 raise TypeError("_extensions must be a dictionary of PropertyDefinition entries.")
419 extensions = supplied_extensions
420 else:
421 extensions = translator_class.extensions if translator_class is not None else {}
423 all_properties = self._get_all_properties(extensions)
424 for key in kwargs:
425 if key not in all_properties:
426 raise KeyError(f"Unrecognized property '{key}' provided")
428 processed = {k: v for k, v in kwargs.items() if k in PROPERTIES and v is not None}
429 processed = self._apply_constructor_defaults(processed, supplied_keys)
431 super().__init__(filename=filename, **processed)
432 self._sealed = False
434 # This configures both self.extensions and self.all_properties.
435 self._declare_extensions(extensions)
437 # Handle extensions.
438 ext_input = {k: v for k, v in kwargs.items() if k.startswith("ext_")}
439 processed_ext = self._validate_property_mapping(ext_input, extensions)
440 for key, value in processed_ext.items():
441 object.__setattr__(self, key, value)
443 if translator_class is not None:
444 self._translator = translator_class({})
445 self.translator_class_name = translator_class.__name__
447 self._sealed = True
449 @staticmethod
450 def _apply_constructor_defaults(processed: dict[str, Any], supplied_keys: set[str]) -> dict[str, Any]:
451 """Apply derived/default values for kwargs-style construction.
453 Parameters
454 ----------
455 processed : `dict` [`str`, `typing.Any`]
456 Properties validated from kwargs input.
457 supplied_keys : `set` [`str`]
458 Property names explicitly supplied by the caller.
460 Returns
461 -------
462 updated : `dict` [`str`, `typing.Any`]
463 Updated property mapping with defaults/backfilled values applied.
464 """
465 updated = dict(processed)
466 for key in ("group_counter_start", "group_counter_end"):
467 if (
468 key not in supplied_keys
469 and "observation_counter" in supplied_keys
470 and "observation_counter" in updated
471 ):
472 updated[key] = updated["observation_counter"]
473 if "has_simulated_content" not in supplied_keys:
474 updated["has_simulated_content"] = False
475 return updated
477 @classmethod
478 def from_header(
479 cls,
480 header: MutableMapping[str, Any],
481 *,
482 filename: str | None = None,
483 translator_class: type[MetadataTranslator] | None = None,
484 pedantic: bool = False,
485 search_path: Sequence[str] | None = None,
486 required: set[str] | None = None,
487 subset: set[str] | None = None,
488 ) -> ObservationInfo:
489 """Create an `ObservationInfo` by translating a metadata header.
491 Parameters
492 ----------
493 header : `dict`-like
494 Header mapping to translate.
495 filename : `str`, optional
496 Name of file associated with this header.
497 translator_class : `MetadataTranslator`-class, optional
498 Translator class to use. If `None`, translator will be
499 auto-determined.
500 pedantic : `bool`, optional
501 If `True`, translation failures are fatal.
502 search_path : `~collections.abc.Sequence` [`str`], optional
503 Search paths for header corrections.
504 required : `set` [`str`], optional
505 Properties that must be translated and non-`None`.
506 subset : `set` [`str`], optional
507 Restrict translation to this subset of properties.
509 Returns
510 -------
511 obsinfo : `ObservationInfo`
512 Translated observation metadata.
513 """
514 return cls(
515 header=header,
516 filename=filename,
517 translator_class=translator_class,
518 pedantic=pedantic,
519 search_path=search_path,
520 required=required,
521 subset=subset,
522 )
524 @staticmethod
525 def _get_all_properties(
526 extensions: dict[str, PropertyDefinition] | None = None,
527 ) -> dict[str, PropertyDefinition]:
528 """Return the definitions of all properties.
530 Parameters
531 ----------
532 extensions : `dict` [`str`: `PropertyDefinition`]
533 List of extension property definitions, indexed by name (with no
534 "ext_" prefix).
536 Returns
537 -------
538 properties : `dict` [`str`: `PropertyDefinition`]
539 Merged list of all property definitions, indexed by name. Extension
540 properties will be listed with an ``ext_`` prefix.
541 """
542 properties = dict(PROPERTIES)
543 if extensions:
544 properties.update({"ext_" + pp: dd for pp, dd in extensions.items()})
545 return properties
547 def _declare_extensions(self, extensions: dict[str, PropertyDefinition] | None) -> None:
548 """Declare and set up extension properties.
550 This should always be called internally as part of the creation of a
551 new `ObservationInfo`.
553 The core set of properties are declared as model fields at import
554 time. Extension properties have to be configured at runtime (because
555 we don't know what they will be until we look at the header and figure
556 out what instrument we're dealing with), so we add them to the model
557 and then use ``__setattr__`` to protect them as read-only. All
558 extension properties are set to `None`.
560 Parameters
561 ----------
562 extensions : `dict` [`str`: `PropertyDefinition`]
563 List of extension property definitions, indexed by name (with no
564 "ext_" prefix).
565 """
566 if extensions:
567 for name in extensions:
568 field_name = "ext_" + name
569 if not hasattr(self, field_name):
570 object.__setattr__(self, field_name, None)
571 self.extensions = extensions
572 self.all_properties = self._get_all_properties(self.extensions)
574 def __setattr__(self, name: str, value: Any) -> Any:
575 """Set attribute.
577 This provides read-only protection for all properties once the
578 instance has been sealed.
580 Parameters
581 ----------
582 name : `str`
583 Name of attribute to set.
584 value : `typing.Any`
585 Value to set it to.
586 """
587 if (
588 getattr(self, "_sealed", False)
589 and hasattr(self, "all_properties")
590 and name in self.all_properties
591 ):
592 raise AttributeError(f"Attribute {name} is read-only")
593 return super().__setattr__(name, value)
595 @model_serializer(mode="plain")
596 def _serialize(self) -> dict[str, Any]:
597 simple: dict[str, Any] = {}
598 if self._translator and self._translator.name:
599 simple["_translator"] = self._translator.name
601 for p, definition in self.all_properties.items():
602 value = getattr(self, p)
603 if value is None:
604 continue
605 simplifier = definition.to_simple
606 if simplifier is None:
607 simple[p] = value
608 else:
609 simple[p] = simplifier(value)
611 return simple
613 @classmethod
614 def _validate_property_mapping(
615 cls,
616 data: MutableMapping[str, Any],
617 extensions: dict[str, PropertyDefinition] | None,
618 ) -> dict[str, Any]:
619 # Validate extension properties.
620 properties = {f"ext_{name}": definition for name, definition in (extensions or {}).items()}
621 processed: dict[str, Any] = {}
623 for key, value in data.items():
624 if key not in properties:
625 raise KeyError(f"Unrecognized property '{key}' provided")
626 if value is None:
627 continue
628 processed[key] = cls._coerce_property_value(key, value, properties, processed)
629 return processed
631 @classmethod
632 def _coerce_property_value(
633 cls,
634 key: str,
635 value: Any,
636 properties: dict[str, PropertyDefinition],
637 processed: dict[str, Any],
638 ) -> Any:
639 definition = properties[key]
640 converted = cls._coerce_from_simple(definition, value, processed)
641 if not definition.is_value_conformant(converted):
642 raise TypeError(
643 f"Supplied value {value} for property {key} "
644 f"should be of class {definition.str_type} not {converted.__class__}"
645 )
646 return converted
648 @classmethod
649 def _coerce_from_simple(
650 cls,
651 definition: PropertyDefinition,
652 value: Any,
653 processed: dict[str, Any],
654 ) -> Any:
655 if definition.is_value_conformant(value):
656 return value
657 complexifier = definition.from_simple
658 if complexifier is None:
659 # Not the correct type, assumes caller will check.
660 return value
661 return complexifier(value, **processed)
663 @property
664 def cards_used(self) -> frozenset[str]:
665 """Header cards used for the translation.
667 Returns
668 -------
669 used : `frozenset` of `str`
670 Set of card used.
671 """
672 if not self._translator:
673 return frozenset()
674 return self._translator.cards_used()
676 def stripped_header(self) -> MutableMapping[str, Any]:
677 """Return a copy of the supplied header with used keywords removed.
679 Returns
680 -------
681 stripped : `dict`-like
682 Same class as header supplied to constructor, but with the
683 headers used to calculate the generic information removed.
684 """
685 hdr = copy.copy(self._header)
686 used = self.cards_used
687 for c in used:
688 if c in hdr:
689 del hdr[c]
690 return hdr
692 def __str__(self) -> str:
693 # Put more interesting answers at front of list
694 # and then do remainder
695 priority = ("instrument", "telescope", "datetime_begin")
696 properties = sorted(set(self.all_properties) - set(priority))
698 result = ""
699 for p in itertools.chain(priority, properties):
700 value = getattr(self, p)
701 if isinstance(value, astropy.time.Time):
702 value.format = "isot"
703 value = str(value.value)
704 result += f"{p}: {value}\n"
706 return result
708 def __eq__(self, other: Any) -> bool:
709 """Check equality with another object.
711 Compares equal if standard properties are equal.
713 Parameters
714 ----------
715 other : `typing.Any`
716 Thing to compare with.
717 """
718 if not isinstance(other, ObservationInfo):
719 return NotImplemented
721 # Compare simplified forms.
722 # Cannot compare directly because nan will not equate as equal
723 # whereas they should be equal for our purposes
724 self_simple = self.to_simple()
725 other_simple = other.to_simple()
727 # We don't care about the translator internal detail
728 self_simple.pop("_translator", None)
729 other_simple.pop("_translator", None)
731 for k in self_simple.keys() & other_simple.keys():
732 self_value = self_simple[k]
733 other_value = other_simple[k]
734 if self_value != other_value:
735 try:
736 both_nan = math.isnan(self_value) and math.isnan(other_value)
737 except TypeError:
738 both_nan = False
739 if both_nan:
740 # If both are nan this is fine
741 continue
742 return False
743 return True
745 def __lt__(self, other: Any) -> bool:
746 if not isinstance(other, ObservationInfo):
747 return NotImplemented
748 if self.datetime_begin is None or other.datetime_begin is None:
749 raise TypeError("Cannot compare ObservationInfo without datetime_begin values")
750 return self.datetime_begin < other.datetime_begin
752 def __gt__(self, other: Any) -> bool:
753 if not isinstance(other, ObservationInfo):
754 return NotImplemented
755 if self.datetime_begin is None or other.datetime_begin is None:
756 raise TypeError("Cannot compare ObservationInfo without datetime_begin values")
757 return self.datetime_begin > other.datetime_begin
759 def __getstate__(self) -> dict[str, Any]:
760 """Get pickleable state.
762 Returns the properties. Deliberately does not preserve the full
763 current state; in particular, does not return the full header or
764 translator.
766 Returns
767 -------
768 state : `dict`
769 Pickled state.
770 """
771 state: dict[str, Any] = {}
772 for p in self.all_properties:
773 state[p] = getattr(self, p)
775 return {"state": state, "extensions": self.extensions}
777 def __setstate__(self, state: dict[Any, Any]) -> None:
778 """Set object state from pickle.
780 Parameters
781 ----------
782 state : `dict`
783 Pickled state.
784 """
785 state_any = cast(Any, state)
786 if isinstance(state_any, dict) and "state" in state_any:
787 state = state_any["state"]
788 extensions = state_any.get("extensions", {})
789 else:
790 try:
791 state, extensions = state_any
792 except ValueError:
793 # Backwards compatibility for pickles generated before DM-34175
794 extensions = {}
795 super().__init__()
796 self._sealed = False
797 self._declare_extensions(extensions)
798 for p in self.all_properties:
799 # allows setting even write-protected extensions
800 object.__setattr__(self, p, state[p])
801 self._sealed = True
803 def to_simple(self) -> MutableMapping[str, Any]:
804 """Convert the contents of this object to simple dict form.
806 The keys of the dict are the standard properties but the values
807 can be simplified to support JSON serialization. For example a
808 SkyCoord might be represented as an ICRS RA/Dec tuple rather than
809 a full SkyCoord representation.
811 Any properties with `None` value will be skipped.
813 Can be converted back to an `ObservationInfo` using `from_simple`.
815 Returns
816 -------
817 simple : `dict` of [`str`, `~typing.Any`]
818 Simple dict of all properties.
820 Notes
821 -----
822 Round-tripping of extension properties requires that the
823 `ObservationInfo` was created with the help of a registered
824 `MetadataTranslator` (which contains the extension property
825 definitions).
826 """
827 return self.model_dump(mode="python")
829 def to_json(self) -> str:
830 """Serialize the object to JSON string.
832 Returns
833 -------
834 j : `str`
835 The properties of the ObservationInfo in JSON string form.
837 Notes
838 -----
839 Round-tripping of extension properties requires that the
840 `ObservationInfo` was created with the help of a registered
841 `MetadataTranslator` (which contains the extension property
842 definitions).
843 """
844 return self.model_dump_json()
846 @classmethod
847 def from_simple(cls, simple: MutableMapping[str, Any]) -> ObservationInfo:
848 """Convert the entity returned by `to_simple` back into an
849 `ObservationInfo`.
851 Parameters
852 ----------
853 simple : `dict` [`str`, `~typing.Any`]
854 The dict returned by `to_simple`.
856 Returns
857 -------
858 obsinfo : `ObservationInfo`
859 New object constructed from the dict.
861 Notes
862 -----
863 Round-tripping of extension properties requires that the
864 `ObservationInfo` was created with the help of a registered
865 `MetadataTranslator` (which contains the extension property
866 definitions).
867 """
868 return cls.model_validate(simple)
870 @classmethod
871 def from_json(cls, json_str: str) -> ObservationInfo:
872 """Create `ObservationInfo` from JSON string.
874 Parameters
875 ----------
876 json_str : `str`
877 The JSON representation.
879 Returns
880 -------
881 obsinfo : `ObservationInfo`
882 Reconstructed object.
884 Notes
885 -----
886 Round-tripping of extension properties requires that the
887 `ObservationInfo` was created with the help of a registered
888 `MetadataTranslator` (which contains the extension property
889 definitions).
890 """
891 return cls.model_validate_json(json_str)
893 @classmethod
894 def makeObservationInfo( # noqa: N802
895 cls,
896 *,
897 extensions: dict[str, PropertyDefinition] | None = None,
898 translator_class: type[MetadataTranslator] | None = None,
899 **kwargs: Any,
900 ) -> ObservationInfo:
901 """Construct an `ObservationInfo` from the supplied parameters.
903 Parameters
904 ----------
905 extensions : `dict` [`str`: `PropertyDefinition`], optional
906 Optional extension definitions, indexed by extension name (without
907 the ``ext_`` prefix, which will be added by `ObservationInfo`).
908 translator_class : `MetadataTranslator`-class, optional
909 Optional translator class defining the extension properties. If
910 provided, this can be used instead of ``extensions`` and will be
911 stored in the instance for JSON round-tripping.
912 **kwargs
913 Name-value pairs for any properties to be set. In the case of
914 extension properties, the names should include the ``ext_`` prefix.
916 Notes
917 -----
918 The supplied parameters should use names matching the property.
919 The type of the supplied value will be checked against the property.
920 Any properties not supplied will be assigned a value of `None`.
922 Raises
923 ------
924 KeyError
925 Raised if a supplied parameter key is not a known property.
926 TypeError
927 Raised if a supplied value does not match the expected type
928 of the property.
929 """
930 return cls(filename=None, translator_class=translator_class, _extensions=extensions, **kwargs)
933def makeObservationInfo( # noqa: N802
934 *,
935 extensions: dict[str, PropertyDefinition] | None = None,
936 translator_class: type[MetadataTranslator] | None = None,
937 **kwargs: Any,
938) -> ObservationInfo:
939 """Construct an `ObservationInfo` from the supplied parameters.
941 Parameters
942 ----------
943 extensions : `dict` [`str`: `PropertyDefinition`], optional
944 Optional extension definitions, indexed by extension name (without
945 the ``ext_`` prefix, which will be added by `ObservationInfo`).
946 translator_class : `MetadataTranslator`-class, optional
947 Optional translator class defining the extension properties. If
948 provided, this can be used instead of ``extensions`` and will be
949 stored in the instance for JSON round-tripping.
950 **kwargs
951 Name-value pairs for any properties to be set. In the case of
952 extension properties, the names should include the ``ext_`` prefix.
954 Notes
955 -----
956 The supplied parameters should use names matching the property.
957 The type of the supplied value will be checked against the property.
958 Any properties not supplied will be assigned a value of `None`.
960 Raises
961 ------
962 KeyError
963 Raised if a supplied parameter key is not a known property.
964 TypeError
965 Raised if a supplied value does not match the expected type
966 of the property.
967 """
968 return ObservationInfo.makeObservationInfo(
969 extensions=extensions, translator_class=translator_class, **kwargs
970 )