Coverage for python/astro_metadata_translator/observationInfo.py: 95%
368 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-14 18:12 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-14 18:12 +0000
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
21from collections.abc import Generator, MutableMapping, Sequence
22from contextlib import contextmanager
23from typing import Any, cast, overload
25import astropy.coordinates
26import astropy.time
27import astropy.units
28import numpy as np
29from lsst.resources import ResourcePath
30from pydantic import (
31 BaseModel,
32 ConfigDict,
33 Field,
34 GetJsonSchemaHandler,
35 PrivateAttr,
36 model_serializer,
37)
38from pydantic.json_schema import JsonSchemaValue
39from pydantic_core import CoreSchema
41from .headers import fix_header
42from .properties import (
43 PROPERTIES,
44 AltAzAnnotated,
45 AngleAnnotated,
46 EarthLocationAnnotated,
47 ExposureTimeAnnotated,
48 FocusZAnnotated,
49 PressureAnnotated,
50 PropertyDefinition,
51 SkyCoordAnnotated,
52 TemperatureAnnotated,
53 TimeAnnotated,
54 TimeDeltaAnnotated,
55)
56from .translator import MetadataTranslator
58log = logging.getLogger(__name__)
61def _wire_doc(key: str, wire_form: str) -> str:
62 """Append a wire-format note to a property's semantic doc.
64 Used only for ``Field(description=...)`` in the JSON schema; the
65 underlying ``PROPERTIES[key].doc`` is intentionally left unit-agnostic
66 because it is also surfaced as the docstring of auto-generated
67 translator stub methods, where forcing a wire-format unit would be
68 misleading.
70 Parameters
71 ----------
72 key : `str`
73 Property name in `PROPERTIES`.
74 wire_form : `str`
75 Short noun phrase describing the JSON-serialized form, e.g.
76 ``"a float in meters"``.
78 Returns
79 -------
80 description : `str`
81 ``PROPERTIES[key].doc`` followed by ``"Serialized as <wire_form>."``.
82 """
83 return f"{PROPERTIES[key].doc} Serialized as {wire_form}."
86class ObservationInfo(BaseModel):
87 """Standardized representation of an instrument header for a single
88 exposure observation.
90 Parameters
91 ----------
92 header : `dict`-like
93 Representation of an instrument header accessible as a `dict`.
94 May be updated with header corrections if corrections are found.
95 filename : `str`, optional
96 Name of the file whose header is being translated. For some
97 datasets with missing header information this can sometimes
98 allow for some fixups in translations.
99 translator_class : `MetadataTranslator`-class, optional
100 If not `None`, the class to use to translate the supplied headers
101 into standard form. Otherwise each registered translator class will
102 be asked in turn if it knows how to translate the supplied header.
103 pedantic : `bool`, optional
104 If True the translation must succeed for all properties. If False
105 individual property translations must all be implemented but can fail
106 and a warning will be issued. Only used if a ``header`` is specified.
107 search_path : `~collections.abc.Iterable`, optional
108 Override search paths to use during header fix up. Only used if a
109 ``header`` is specified.
110 required : `set`, optional
111 This parameter can be used to confirm that all properties contained
112 in the set must translate correctly and also be non-None. For the case
113 where ``pedantic`` is `True` this will still check that the resulting
114 value is not `None`. Only used if a ``header`` is specified.
115 subset : `set`, optional
116 If not `None`, controls the translations that will be performed
117 during construction. This can be useful if the caller is only
118 interested in a subset of the properties and knows that some of
119 the others might be slow to compute (for example the airmass if it
120 has to be derived). Only used if a ``header`` is specified.
121 quiet : `bool`, optional
122 If `True`, warning level log messages that would be issued in non
123 pedantic mode are converted to debug messages.
124 **kwargs : `typing.Any`
125 Property name/value pairs for kwargs-based construction mode. This
126 mode creates an `ObservationInfo` directly from supplied properties
127 rather than by translating a header. If ``header`` is provided it is
128 an error to also provide ``kwargs``.
130 Raises
131 ------
132 ValueError
133 Raised if the supplied header was not recognized by any of the
134 registered translators. Also raised if the request property subset
135 is not a subset of the known properties or if a header is given along
136 with kwargs.
137 TypeError
138 Raised if the supplied translator class was not a MetadataTranslator.
139 KeyError
140 Raised if a required property cannot be calculated, or if pedantic
141 mode is enabled and any translations fails.
142 NotImplementedError
143 Raised if the selected translator does not support a required
144 property.
146 Notes
147 -----
148 There is a core set of instrumental properties that are pre-defined.
149 Additional properties may be defined, either through the
150 `makeObservationInfo` factory function by providing the ``extensions``
151 definitions, or through the regular `ObservationInfo` constructor when
152 the extensions have been defined in the `MetadataTranslator` for the
153 instrument of interest (or in the provided ``translator_class``).
155 There are two forms of the constructor. If the ``header`` is given
156 then a translator will be determined and the properties will be populated
157 accordingly. No generic keyword arguments will be expected and the
158 remaining parameters control the behavior of the translator.
160 If the header is not given it is assumed that the keyword arguments
161 are direct specifications of observation properties. In this mode only
162 the ``filename`` and ``translator_class`` parameters will be used. The
163 latter is used to determine any extensions that are being provided,
164 although when using standard serializations the special ``_translator``
165 key will be used instead to specify the name of the registered translator
166 from which to extract extension definitions.
168 Headers will be corrected if correction files are located and this will
169 modify the header provided to the constructor. Modifying the supplied
170 header after construction will modify the internal cached header.
172 Values of the properties are read-only.
173 """
175 model_config = ConfigDict(
176 extra="forbid",
177 validate_assignment=False,
178 populate_by_name=True,
179 serialize_by_alias=True, # Emit the ``_translator`` alias even when nested.
180 ser_json_inf_nan="constants", # Allow for inf and nan to round trip.
181 )
183 translator_name: str | None = Field(
184 default=None,
185 alias="_translator",
186 description="Name of the registered metadata translator class used for these data.",
187 )
189 telescope: str | None = Field(default=None, description=PROPERTIES["telescope"].doc)
190 instrument: str | None = Field(default=None, description=PROPERTIES["instrument"].doc)
191 location: EarthLocationAnnotated | None = Field(
192 default=None,
193 description=_wire_doc("location", "a geocentric (x, y, z) tuple of floats in meters"),
194 )
195 exposure_id: int | None = Field(default=None, description=PROPERTIES["exposure_id"].doc)
196 visit_id: int | None = Field(default=None, description=PROPERTIES["visit_id"].doc)
197 physical_filter: str | None = Field(default=None, description=PROPERTIES["physical_filter"].doc)
198 datetime_begin: TimeAnnotated | None = Field(
199 default=None,
200 description=_wire_doc("datetime_begin", "a two-element TAI Julian Date [jd1, jd2]"),
201 )
202 datetime_end: TimeAnnotated | None = Field(
203 default=None,
204 description=_wire_doc("datetime_end", "a two-element TAI Julian Date [jd1, jd2]"),
205 )
206 exposure_time: ExposureTimeAnnotated | None = Field(
207 default=None, description=PROPERTIES["exposure_time"].doc
208 )
209 exposure_time_requested: ExposureTimeAnnotated | None = Field(
210 default=None, description=PROPERTIES["exposure_time_requested"].doc
211 )
212 dark_time: ExposureTimeAnnotated | None = Field(default=None, description=PROPERTIES["dark_time"].doc)
213 boresight_airmass: float | None = Field(default=None, description=PROPERTIES["boresight_airmass"].doc)
214 boresight_rotation_angle: AngleAnnotated | None = Field(
215 default=None,
216 description=_wire_doc("boresight_rotation_angle", "a float in degrees"),
217 )
218 boresight_rotation_coord: str | None = Field(
219 default=None, description=PROPERTIES["boresight_rotation_coord"].doc
220 )
221 detector_num: int | None = Field(default=None, description=PROPERTIES["detector_num"].doc)
222 detector_name: str | None = Field(default=None, description=PROPERTIES["detector_name"].doc)
223 detector_unique_name: str | None = Field(default=None, description=PROPERTIES["detector_unique_name"].doc)
224 detector_serial: str | None = Field(default=None, description=PROPERTIES["detector_serial"].doc)
225 detector_group: str | None = Field(default=None, description=PROPERTIES["detector_group"].doc)
226 detector_exposure_id: int | None = Field(default=None, description=PROPERTIES["detector_exposure_id"].doc)
227 focus_z: FocusZAnnotated | None = Field(
228 default=None,
229 description=_wire_doc("focus_z", "a float in meters"),
230 )
231 object: str | None = Field(default=None, description=PROPERTIES["object"].doc)
232 temperature: TemperatureAnnotated | None = Field(
233 default=None,
234 description=_wire_doc("temperature", "a float in kelvin"),
235 )
236 pressure: PressureAnnotated | None = Field(
237 default=None,
238 description=_wire_doc("pressure", "a float in hPa"),
239 )
240 relative_humidity: float | None = Field(default=None, description=PROPERTIES["relative_humidity"].doc)
241 tracking_radec: SkyCoordAnnotated | None = Field(
242 default=None,
243 description=_wire_doc("tracking_radec", "an ICRS (RA, Dec) tuple of floats in degrees"),
244 )
245 altaz_begin: AltAzAnnotated | None = Field(
246 default=None,
247 description=_wire_doc("altaz_begin", "an (azimuth, altitude) tuple of floats in degrees"),
248 )
249 altaz_end: AltAzAnnotated | None = Field(
250 default=None,
251 description=_wire_doc("altaz_end", "an (azimuth, altitude) tuple of floats in degrees"),
252 )
253 science_program: str | None = Field(default=None, description=PROPERTIES["science_program"].doc)
254 observation_type: str | None = Field(default=None, description=PROPERTIES["observation_type"].doc)
255 observation_id: str | None = Field(default=None, description=PROPERTIES["observation_id"].doc)
256 observation_reason: str | None = Field(default=None, description=PROPERTIES["observation_reason"].doc)
257 exposure_group: str | None = Field(default=None, description=PROPERTIES["exposure_group"].doc)
258 observing_day: int | None = Field(default=None, description=PROPERTIES["observing_day"].doc)
259 observing_day_offset: TimeDeltaAnnotated | None = Field(
260 default=None,
261 description=_wire_doc("observing_day_offset", "integer seconds"),
262 )
263 observation_counter: int | None = Field(default=None, description=PROPERTIES["observation_counter"].doc)
264 has_simulated_content: bool | None = Field(
265 default=None, description=PROPERTIES["has_simulated_content"].doc
266 )
267 group_counter_start: int | None = Field(default=None, description=PROPERTIES["group_counter_start"].doc)
268 group_counter_end: int | None = Field(default=None, description=PROPERTIES["group_counter_end"].doc)
269 can_see_sky: bool | None = Field(default=None, description=PROPERTIES["can_see_sky"].doc)
271 # Internal runtime state. These are not part of the wire format and so are
272 # kept as PrivateAttr to keep them out of the generated JSON Schema.
273 _filename: str | None = PrivateAttr(default=None)
274 _translator_class_name: str = PrivateAttr(default="<None>")
275 _extensions: dict[str, PropertyDefinition] = PrivateAttr(default_factory=dict)
276 _all_properties: dict[str, PropertyDefinition] = PrivateAttr(default_factory=dict)
277 _header: MutableMapping[str, Any] = PrivateAttr(default_factory=dict)
278 _translator: MetadataTranslator | None = PrivateAttr(default=None)
279 _sealed: bool = PrivateAttr(default=False)
281 @property
282 def filename(self) -> str | None:
283 """Name of the file whose header was translated, if any."""
284 return self._filename
286 @filename.setter
287 def filename(self, value: str | None) -> None:
288 self._filename = value
290 @property
291 def translator_class_name(self) -> str:
292 """Name of the metadata translator class used for these data."""
293 return self._translator_class_name
295 @property
296 def extensions(self) -> dict[str, PropertyDefinition]:
297 """Definitions of the translator-specific extension properties."""
298 return self._extensions
300 @property
301 def all_properties(self) -> dict[str, PropertyDefinition]:
302 """Definitions of all known properties (core plus extensions)."""
303 return self._all_properties
305 @overload
306 def __init__( 306 ↛ exitline 306 didn't return from function '__init__' because
307 self,
308 header: MutableMapping[str, Any],
309 filename: str | ResourcePath | None = None,
310 translator_class: type[MetadataTranslator] | None = None,
311 pedantic: bool = False,
312 search_path: Sequence[str] | None = None,
313 required: set[str] | None = None,
314 subset: set[str] | None = None,
315 quiet: bool = False,
316 ) -> None: ...
318 @overload
319 def __init__( 319 ↛ exitline 319 didn't return from function '__init__' because
320 self,
321 header: None = None,
322 filename: str | ResourcePath | None = None,
323 translator_class: type[MetadataTranslator] | None = None,
324 **kwargs: Any,
325 ) -> None: ...
327 def __init__(
328 self,
329 header: MutableMapping[str, Any] | None = None,
330 filename: str | ResourcePath | None = None,
331 translator_class: type[MetadataTranslator] | None = None,
332 pedantic: bool = False,
333 search_path: Sequence[str] | None = None,
334 required: set[str] | None = None,
335 subset: set[str] | None = None,
336 quiet: bool = False,
337 **kwargs: Any,
338 ) -> None:
339 if filename is not None:
340 filename = str(ResourcePath(filename, forceAbsolute=True))
341 if header is not None:
342 if kwargs:
343 raise ValueError(
344 "kwargs not allowed if constructor given a header to translate. "
345 f"Unrecognized keys: {[k for k in kwargs]}"
346 )
347 self._init_from_header(
348 header,
349 filename=filename,
350 translator_class=translator_class,
351 pedantic=pedantic,
352 search_path=search_path,
353 required=required,
354 subset=subset,
355 quiet=quiet,
356 )
357 return
359 self._init_from_kwargs(filename=filename, translator_class=translator_class, **kwargs)
361 def _init_from_header(
362 self,
363 header: MutableMapping[str, Any],
364 *,
365 filename: str | None,
366 translator_class: type[MetadataTranslator] | None,
367 pedantic: bool,
368 search_path: Sequence[str] | None,
369 required: set[str] | None,
370 subset: set[str] | None,
371 quiet: bool,
372 ) -> None:
373 super().__init__()
374 self._filename = filename
375 self._sealed = False
376 # Initialize the empty object
377 self._header = {}
378 self._translator = None
379 failure_level = logging.DEBUG if quiet else logging.WARNING
381 # Look for translator class before header fixup. fix_header calls
382 # determine_translator immediately on the basis that you need to know
383 # enough of the header to work out the translator before you can fix
384 # it up. There is no gain in asking fix_header to determine the
385 # translator and then trying to work it out again here.
386 if translator_class is None:
387 translator_class = MetadataTranslator.determine_translator(header, filename=filename)
388 elif not issubclass(translator_class, MetadataTranslator):
389 raise TypeError(f"Translator class must be a MetadataTranslator, not {translator_class}")
391 # Fix up the header (if required)
392 fix_header(header, translator_class=translator_class, filename=filename, search_path=search_path)
394 # Store the supplied header for later stripping
395 self._header = header
397 # This configures both self.extensions and self.all_properties.
398 self._declare_extensions(translator_class.extensions)
400 # Create an instance for this header
401 translator = translator_class(header, filename=filename)
403 # Store the translator
404 self._translator = translator
405 self._translator_class_name = translator_class.__name__
406 self.translator_name = translator_class.name
408 # Form file information string in case we need an error message
409 if filename:
410 file_info = f" and file {filename}"
411 else:
412 file_info = ""
414 # Determine the properties of interest
415 full_set = set(self.all_properties)
416 if subset is not None:
417 if not subset:
418 raise ValueError("Cannot request no properties be calculated.")
419 if not subset.issubset(full_set):
420 raise ValueError(
421 f"Requested subset is not a subset of known properties. Got extra: {subset - full_set}"
422 )
423 properties = subset
424 else:
425 properties = full_set
427 if required is None:
428 required = set()
429 else:
430 if not required.issubset(full_set):
431 raise ValueError(f"Requested required properties include unknowns: {required - full_set}")
433 # Loop over each property and request the translated form
434 for property in properties:
435 # prototype code
436 method = f"to_{property}"
438 try:
439 value = getattr(translator, method)()
440 except NotImplementedError as e:
441 raise NotImplementedError(
442 f"No translation exists for property '{property}' using translator {translator.__class__}"
443 ) from e
444 except Exception as e:
445 err_msg = (
446 f"Error calculating property '{property}' using "
447 f"translator {translator.__class__}{file_info}"
448 )
449 if pedantic or property in required:
450 raise KeyError(err_msg) from e
451 else:
452 log.debug("Calculation of property '%s' failed with header: %s", property, header)
453 log.log(failure_level, f"Ignoring {err_msg}: {e}")
454 continue
456 definition = self.all_properties[property]
457 # Some translators can return a compatible form that needs to
458 # be coerced to the correct type (e.g., returning SkyCoord when you
459 # need AltAz). In theory we could patch the translators to return
460 # AltAz but code has historically not been as picky about this
461 # until pydantic turned up.
462 value = self._coerce_from_simple(definition, value, {})
463 if not definition.is_value_conformant(value): 463 ↛ 464line 463 didn't jump to line 464 because the condition on line 463 was never true
464 err_msg = (
465 f"Value calculated for property '{property}' is wrong type "
466 f"({type(value)} != {definition.str_type}) using translator {translator.__class__}"
467 f"{file_info}"
468 )
469 if pedantic or property in required:
470 raise TypeError(err_msg)
471 else:
472 log.debug(
473 "Calculation of property '%s' had unexpected type with header: %s", property, header
474 )
475 log.log(failure_level, f"Ignoring {err_msg}")
477 if value is None and property in required:
478 raise KeyError(f"Calculation of required property {property} resulted in a value of None")
480 object.__setattr__(self, property, value) # allows setting even write-protected extensions
482 self._sealed = True
484 def _init_from_kwargs(
485 self,
486 *,
487 filename: str | None,
488 translator_class: type[MetadataTranslator] | None,
489 **kwargs: Any,
490 ) -> None:
491 supplied_keys = set(kwargs)
492 # Accept both the wire-format alias ``_translator`` and the field name
493 # ``translator_name`` for robustness against callers that pass the
494 # field name directly.
495 translator_name = kwargs.pop("_translator", None) or kwargs.pop("translator_name", None)
496 supplied_extensions = kwargs.pop("_extensions", None)
497 if translator_name is not None and translator_name in MetadataTranslator.translators:
498 translator_class = MetadataTranslator.translators[translator_name]
500 if translator_class is not None and not issubclass(translator_class, MetadataTranslator):
501 raise TypeError(f"Translator class must be a MetadataTranslator, not {translator_class}")
503 if supplied_extensions is not None:
504 if translator_class is not None:
505 raise ValueError("Provide either translator_class or _extensions, not both.")
506 if not isinstance(supplied_extensions, dict):
507 raise TypeError("_extensions must be a dictionary of PropertyDefinition entries.")
508 extensions = supplied_extensions
509 else:
510 extensions = translator_class.extensions if translator_class is not None else {}
512 all_properties = self._get_all_properties(extensions)
513 unrecognized = [key for key in kwargs if key not in all_properties]
514 if unrecognized:
515 # Extension properties (the ``ext_`` prefixed keys) are defined by
516 # the translator. When the translator could not be resolved its
517 # extension definitions are unavailable, so report that as the
518 # underlying cause rather than an opaque unknown property.
519 extension_keys = sorted(key for key in unrecognized if key.startswith("ext_"))
520 if extension_keys and translator_class is None:
521 reason = (
522 f"translator '{translator_name}' is not registered"
523 if translator_name is not None
524 else "no translator was specified"
525 )
526 raise KeyError(
527 f"Unrecognized extension properties {extension_keys}: {reason}, so the "
528 "extension property definitions are unavailable."
529 )
530 text = (
531 f"property ({unrecognized[0]})"
532 if len(unrecognized) == 1
533 else f"properties ({', '.join(unrecognized)})"
534 )
535 raise KeyError(f"Unrecognized {text} provided")
537 processed = {k: v for k, v in kwargs.items() if k in PROPERTIES and v is not None}
538 processed = self._apply_constructor_defaults(processed, supplied_keys)
540 super().__init__(**processed)
541 self._filename = filename
542 self._sealed = False
544 # This configures both self.extensions and self.all_properties.
545 self._declare_extensions(extensions)
547 # Handle extensions.
548 ext_input = {k: v for k, v in kwargs.items() if k.startswith("ext_")}
549 processed_ext = self._validate_property_mapping(ext_input, extensions)
550 for key, value in processed_ext.items():
551 object.__setattr__(self, key, value)
553 if translator_class is not None:
554 self._translator = translator_class({})
555 self._translator_class_name = translator_class.__name__
556 self.translator_name = translator_class.name
557 elif translator_name is not None:
558 # The named translator is not registered in this process. The
559 # translator instance is only needed while constructing from a
560 # header, so it is left as None and the name from the serialization
561 # is recorded directly.
562 self._translator_class_name = translator_name
563 self.translator_name = translator_name
565 self._sealed = True
567 @staticmethod
568 def _apply_constructor_defaults(processed: dict[str, Any], supplied_keys: set[str]) -> dict[str, Any]:
569 """Apply derived/default values for kwargs-style construction.
571 Parameters
572 ----------
573 processed : `dict` [`str`, `typing.Any`]
574 Properties validated from kwargs input.
575 supplied_keys : `set` [`str`]
576 Property names explicitly supplied by the caller.
578 Returns
579 -------
580 updated : `dict` [`str`, `typing.Any`]
581 Updated property mapping with defaults/backfilled values applied.
582 """
583 updated = dict(processed)
584 for key in ("group_counter_start", "group_counter_end"):
585 if (
586 key not in supplied_keys
587 and "observation_counter" in supplied_keys
588 and "observation_counter" in updated
589 ):
590 updated[key] = updated["observation_counter"]
591 if "has_simulated_content" not in supplied_keys:
592 updated["has_simulated_content"] = False
593 return updated
595 @classmethod
596 def from_header(
597 cls,
598 header: MutableMapping[str, Any],
599 *,
600 filename: str | None = None,
601 translator_class: type[MetadataTranslator] | None = None,
602 pedantic: bool = False,
603 search_path: Sequence[str] | None = None,
604 required: set[str] | None = None,
605 subset: set[str] | None = None,
606 quiet: bool = False,
607 ) -> ObservationInfo:
608 """Create an `ObservationInfo` by translating a metadata header.
610 Parameters
611 ----------
612 header : `dict`-like
613 Header mapping to translate.
614 filename : `str`, optional
615 Name of file associated with this header.
616 translator_class : `MetadataTranslator`-class, optional
617 Translator class to use. If `None`, translator will be
618 auto-determined.
619 pedantic : `bool`, optional
620 If `True`, translation failures are fatal.
621 search_path : `~collections.abc.Sequence` [`str`], optional
622 Search paths for header corrections.
623 required : `set` [`str`], optional
624 Properties that must be translated and non-`None`.
625 subset : `set` [`str`], optional
626 Restrict translation to this subset of properties.
627 quiet : `bool`, optional
628 If `True`, warning level log messages that would be issued in non
629 pedantic mode are converted to debug messages.
631 Returns
632 -------
633 obsinfo : `ObservationInfo`
634 Translated observation metadata.
635 """
636 return cls(
637 header=header,
638 filename=filename,
639 translator_class=translator_class,
640 pedantic=pedantic,
641 search_path=search_path,
642 required=required,
643 subset=subset,
644 quiet=quiet,
645 )
647 @staticmethod
648 def _get_all_properties(
649 extensions: dict[str, PropertyDefinition] | None = None,
650 ) -> dict[str, PropertyDefinition]:
651 """Return the definitions of all properties.
653 Parameters
654 ----------
655 extensions : `dict` [`str`: `PropertyDefinition`]
656 List of extension property definitions, indexed by name (with no
657 "ext_" prefix).
659 Returns
660 -------
661 properties : `dict` [`str`: `PropertyDefinition`]
662 Merged list of all property definitions, indexed by name. Extension
663 properties will be listed with an ``ext_`` prefix.
664 """
665 properties = dict(PROPERTIES)
666 if extensions:
667 properties.update({"ext_" + pp: dd for pp, dd in extensions.items()})
668 return properties
670 def _declare_extensions(self, extensions: dict[str, PropertyDefinition] | None) -> None:
671 """Declare and set up extension properties.
673 This should always be called internally as part of the creation of a
674 new `ObservationInfo`.
676 The core set of properties are declared as model fields at import
677 time. Extension properties have to be configured at runtime (because
678 we don't know what they will be until we look at the header and figure
679 out what instrument we're dealing with), so we add them to the model
680 and then use ``__setattr__`` to protect them as read-only. All
681 extension properties are set to `None`.
683 Parameters
684 ----------
685 extensions : `dict` [`str`: `PropertyDefinition`]
686 List of extension property definitions, indexed by name (with no
687 "ext_" prefix).
688 """
689 if extensions:
690 for name in extensions:
691 field_name = "ext_" + name
692 if not hasattr(self, field_name): 692 ↛ 690line 692 didn't jump to line 690 because the condition on line 692 was always true
693 object.__setattr__(self, field_name, None)
694 self._extensions = extensions
695 self._all_properties = self._get_all_properties(self._extensions)
697 def __setattr__(self, name: str, value: Any) -> Any:
698 """Set attribute.
700 This provides read-only protection for all properties once the
701 instance has been sealed.
703 Parameters
704 ----------
705 name : `str`
706 Name of attribute to set.
707 value : `typing.Any`
708 Value to set it to.
709 """
710 if (
711 getattr(self, "_sealed", False)
712 and hasattr(self, "all_properties")
713 and name in self.all_properties
714 ):
715 raise AttributeError(f"Attribute {name} is read-only")
716 return super().__setattr__(name, value)
718 @classmethod
719 def __get_pydantic_json_schema__(
720 cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
721 ) -> JsonSchemaValue:
722 # The model_serializer below is mode="wrap" returning dict[str, Any];
723 # in serialization mode pydantic would otherwise emit a trivial
724 # "object" schema. Hand the inner model-fields schema to the handler
725 # so the field-level WithJsonSchema/PlainSerializer annotations are
726 # honored.
727 inner = core_schema.get("schema", core_schema)
728 schema = handler(inner)
729 schema = handler.resolve_ref_schema(schema)
730 # Allow translator-specific extension properties under the ext_ prefix.
731 schema["patternProperties"] = {
732 "^ext_": {"description": "Extension property value (translator-specific)."},
733 }
734 # Anything other than declared properties and ext_* keys is rejected.
735 schema["additionalProperties"] = False
736 return schema
738 @model_serializer(mode="wrap")
739 def _serialize(self, handler: Any) -> dict[str, Any]:
740 # Field serialization uses the per-field PlainSerializer annotations.
741 # ``serialize_by_alias=True`` in model_config emits the ``_translator``
742 # alias instead of ``translator_name`` (and applies when nested).
743 result: dict[str, Any] = handler(self)
744 # Strip None entries; the wire format omits unset properties.
745 result = {k: v for k, v in result.items() if v is not None}
746 # Append values for any extension properties (dynamic ext_* attrs).
747 # Kept flat for backwards compatibility with the existing wire format.
748 for ext_name, ext_def in self._extensions.items():
749 field_name = f"ext_{ext_name}"
750 value = getattr(self, field_name, None)
751 if value is None: 751 ↛ 752line 751 didn't jump to line 752 because the condition on line 751 was never true
752 continue
753 simplifier = ext_def.to_simple
754 result[field_name] = simplifier(value) if simplifier else value
755 return result
757 @classmethod
758 def _validate_property_mapping(
759 cls,
760 data: MutableMapping[str, Any],
761 extensions: dict[str, PropertyDefinition] | None,
762 ) -> dict[str, Any]:
763 # Validate extension properties.
764 properties = {f"ext_{name}": definition for name, definition in (extensions or {}).items()}
765 processed: dict[str, Any] = {}
767 for key, value in data.items():
768 if key not in properties: 768 ↛ 769line 768 didn't jump to line 769 because the condition on line 768 was never true
769 raise KeyError(f"Unrecognized property '{key}' provided")
770 if value is None:
771 continue
772 processed[key] = cls._coerce_property_value(key, value, properties, processed)
773 return processed
775 @classmethod
776 def _coerce_property_value(
777 cls,
778 key: str,
779 value: Any,
780 properties: dict[str, PropertyDefinition],
781 processed: dict[str, Any],
782 ) -> Any:
783 definition = properties[key]
784 converted = cls._coerce_from_simple(definition, value, processed)
785 if not definition.is_value_conformant(converted):
786 raise TypeError(
787 f"Supplied value {value} for property {key} "
788 f"should be of class {definition.str_type} not {converted.__class__}"
789 )
790 return converted
792 @classmethod
793 def _coerce_from_simple(
794 cls,
795 definition: PropertyDefinition,
796 value: Any,
797 processed: dict[str, Any],
798 ) -> Any:
799 if definition.is_value_conformant(value):
800 return value
801 complexifier = definition.from_simple
802 if complexifier is None: 802 ↛ 805line 802 didn't jump to line 805 because the condition on line 802 was always true
803 # Not the correct type, assumes caller will check.
804 return value
805 return complexifier(value, **processed)
807 @property
808 def cards_used(self) -> frozenset[str]:
809 """Header cards used for the translation.
811 Returns
812 -------
813 used : `frozenset` of `str`
814 Set of card used.
815 """
816 if not self._translator:
817 return frozenset()
818 return self._translator.cards_used()
820 def stripped_header(self) -> MutableMapping[str, Any]:
821 """Return a copy of the supplied header with used keywords removed.
823 Returns
824 -------
825 stripped : `dict`-like
826 Same class as header supplied to constructor, but with the
827 headers used to calculate the generic information removed.
828 """
829 hdr = copy.copy(self._header)
830 used = self.cards_used
831 for c in used:
832 if c in hdr:
833 del hdr[c]
834 return hdr
836 def __str__(self) -> str:
837 # Put more interesting answers at front of list
838 # and then do remainder
839 priority = ("instrument", "telescope", "datetime_begin")
840 properties = sorted(set(self.all_properties) - set(priority))
842 result = ""
843 for p in itertools.chain(priority, properties):
844 value = getattr(self, p)
845 if isinstance(value, astropy.time.Time):
846 value.format = "isot"
847 value = str(value.value)
848 result += f"{p}: {value}\n"
850 return result
852 def __eq__(self, other: Any) -> bool:
853 """Check equality with another object.
855 Compares equal if standard properties are equal.
857 Parameters
858 ----------
859 other : `typing.Any`
860 Thing to compare with.
861 """
862 if not isinstance(other, ObservationInfo):
863 return NotImplemented
865 # Compare simplified forms.
866 # Cannot compare directly because nan will not equate as equal
867 # whereas they should be equal for our purposes
868 self_simple = self.to_simple()
869 other_simple = other.to_simple()
871 # We don't care about the translator internal detail
872 self_simple.pop("_translator", None)
873 other_simple.pop("_translator", None)
875 for k in self_simple.keys() & other_simple.keys():
876 self_value = self_simple[k]
877 other_value = other_simple[k]
878 if self_value != other_value:
879 if isinstance(self_value, float) or (
880 isinstance(self_value, tuple) and isinstance(self_value[0], float)
881 ):
882 close = np.allclose(self_value, other_value, equal_nan=True)
883 if close:
884 continue
885 return False
886 return True
888 def __lt__(self, other: Any) -> bool:
889 if not isinstance(other, ObservationInfo):
890 return NotImplemented
891 if self.datetime_begin is None or other.datetime_begin is None:
892 raise TypeError("Cannot compare ObservationInfo without datetime_begin values")
893 return self.datetime_begin < other.datetime_begin
895 def __gt__(self, other: Any) -> bool:
896 if not isinstance(other, ObservationInfo):
897 return NotImplemented
898 if self.datetime_begin is None or other.datetime_begin is None:
899 raise TypeError("Cannot compare ObservationInfo without datetime_begin values")
900 return self.datetime_begin > other.datetime_begin
902 def __getstate__(self) -> dict[str, Any]:
903 """Get pickleable state.
905 Returns the properties. Deliberately does not preserve the full
906 current state; in particular, does not return the full header or
907 translator.
909 Returns
910 -------
911 state : `dict`
912 Pickled state.
913 """
914 state: dict[str, Any] = {}
915 for p in self.all_properties:
916 state[p] = getattr(self, p)
918 return {"state": state, "extensions": self.extensions}
920 def __setstate__(self, state: dict[Any, Any]) -> None:
921 """Set object state from pickle.
923 Parameters
924 ----------
925 state : `dict`
926 Pickled state.
927 """
928 state_any = cast(Any, state)
929 if isinstance(state_any, dict) and "state" in state_any: 929 ↛ 933line 929 didn't jump to line 933 because the condition on line 929 was always true
930 state = state_any["state"]
931 extensions = state_any.get("extensions", {})
932 else:
933 try:
934 state, extensions = state_any
935 except ValueError:
936 # Backwards compatibility for pickles generated before DM-34175
937 extensions = {}
938 super().__init__()
939 self._sealed = False
940 self._declare_extensions(extensions)
941 for p in self.all_properties:
942 # allows setting even write-protected extensions
943 object.__setattr__(self, p, state[p])
944 self._sealed = True
946 def to_simple(self) -> MutableMapping[str, Any]:
947 """Convert the contents of this object to simple dict form.
949 The keys of the dict are the standard properties but the values
950 can be simplified to support JSON serialization. For example a
951 SkyCoord might be represented as an ICRS RA/Dec tuple rather than
952 a full SkyCoord representation.
954 Any properties with `None` value will be skipped.
956 Can be converted back to an `ObservationInfo` using `from_simple`.
958 Returns
959 -------
960 simple : `dict` of [`str`, `~typing.Any`]
961 Simple dict of all properties.
963 Notes
964 -----
965 Round-tripping of extension properties requires that the
966 `ObservationInfo` was created with the help of a registered
967 `MetadataTranslator` (which contains the extension property
968 definitions).
969 """
970 return self.model_dump(mode="python")
972 def to_json(self) -> str:
973 """Serialize the object to JSON string.
975 Returns
976 -------
977 j : `str`
978 The properties of the ObservationInfo in JSON string form.
980 Notes
981 -----
982 Round-tripping of extension properties requires that the
983 `ObservationInfo` was created with the help of a registered
984 `MetadataTranslator` (which contains the extension property
985 definitions).
986 """
987 return self.model_dump_json()
989 @classmethod
990 def from_simple(cls, simple: MutableMapping[str, Any]) -> ObservationInfo:
991 """Convert the entity returned by `to_simple` back into an
992 `ObservationInfo`.
994 Parameters
995 ----------
996 simple : `dict` [`str`, `~typing.Any`]
997 The dict returned by `to_simple`.
999 Returns
1000 -------
1001 obsinfo : `ObservationInfo`
1002 New object constructed from the dict.
1004 Notes
1005 -----
1006 Round-tripping of extension properties requires that the
1007 `ObservationInfo` was created with the help of a registered
1008 `MetadataTranslator` (which contains the extension property
1009 definitions).
1010 """
1011 return cls.model_validate(simple)
1013 @classmethod
1014 def from_json(cls, json_str: str) -> ObservationInfo:
1015 """Create `ObservationInfo` from JSON string.
1017 Parameters
1018 ----------
1019 json_str : `str`
1020 The JSON representation.
1022 Returns
1023 -------
1024 obsinfo : `ObservationInfo`
1025 Reconstructed object.
1027 Notes
1028 -----
1029 Round-tripping of extension properties requires that the
1030 `ObservationInfo` was created with the help of a registered
1031 `MetadataTranslator` (which contains the extension property
1032 definitions).
1033 """
1034 return cls.model_validate_json(json_str)
1036 @classmethod
1037 def makeObservationInfo( # noqa: N802
1038 cls,
1039 *,
1040 extensions: dict[str, PropertyDefinition] | None = None,
1041 translator_class: type[MetadataTranslator] | None = None,
1042 **kwargs: Any,
1043 ) -> ObservationInfo:
1044 """Construct an `ObservationInfo` from the supplied parameters.
1046 Parameters
1047 ----------
1048 extensions : `dict` [`str`: `PropertyDefinition`], optional
1049 Optional extension definitions, indexed by extension name (without
1050 the ``ext_`` prefix, which will be added by `ObservationInfo`).
1051 translator_class : `MetadataTranslator`-class, optional
1052 Optional translator class defining the extension properties. If
1053 provided, this can be used instead of ``extensions`` and will be
1054 stored in the instance for JSON round-tripping.
1055 **kwargs
1056 Name-value pairs for any properties to be set. In the case of
1057 extension properties, the names should include the ``ext_`` prefix.
1059 Notes
1060 -----
1061 The supplied parameters should use names matching the property.
1062 The type of the supplied value will be checked against the property.
1063 Any properties not supplied will be assigned a value of `None`.
1065 Raises
1066 ------
1067 KeyError
1068 Raised if a supplied parameter key is not a known property.
1069 TypeError
1070 Raised if a supplied value does not match the expected type
1071 of the property.
1072 """
1073 return cls(filename=None, translator_class=translator_class, _extensions=extensions, **kwargs)
1075 @contextmanager
1076 def edit_copy(self) -> Generator[ObservationInfo]: # numpydoc ignore=YD01
1077 """Edit a copy of this struct, via a context manager that marks it
1078 as read-only when it exits.
1079 """
1080 copy = self.model_copy()
1081 copy._sealed = False
1082 yield copy
1083 copy._sealed = True
1086def makeObservationInfo( # noqa: N802
1087 *,
1088 extensions: dict[str, PropertyDefinition] | None = None,
1089 translator_class: type[MetadataTranslator] | None = None,
1090 **kwargs: Any,
1091) -> ObservationInfo:
1092 """Construct an `ObservationInfo` from the supplied parameters.
1094 Parameters
1095 ----------
1096 extensions : `dict` [`str`: `PropertyDefinition`], optional
1097 Optional extension definitions, indexed by extension name (without
1098 the ``ext_`` prefix, which will be added by `ObservationInfo`).
1099 translator_class : `MetadataTranslator`-class, optional
1100 Optional translator class defining the extension properties. If
1101 provided, this can be used instead of ``extensions`` and will be
1102 stored in the instance for JSON round-tripping.
1103 **kwargs
1104 Name-value pairs for any properties to be set. In the case of
1105 extension properties, the names should include the ``ext_`` prefix.
1107 Notes
1108 -----
1109 The supplied parameters should use names matching the property.
1110 The type of the supplied value will be checked against the property.
1111 Any properties not supplied will be assigned a value of `None`.
1113 Raises
1114 ------
1115 KeyError
1116 Raised if a supplied parameter key is not a known property.
1117 TypeError
1118 Raised if a supplied value does not match the expected type
1119 of the property.
1120 """
1121 return ObservationInfo.makeObservationInfo(
1122 extensions=extensions, translator_class=translator_class, **kwargs
1123 )