Coverage for python/astro_metadata_translator/observationInfo.py: 95%
414 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 09:05 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 09:05 +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 functools
20import itertools
21import logging
22from collections.abc import Generator, Mapping, MutableMapping, Sequence
23from contextlib import contextmanager
24from typing import Any, NamedTuple, cast, overload
26import astropy.coordinates
27import astropy.time
28import astropy.units
29import numpy as np
30from lsst.resources import ResourcePath
31from pydantic import (
32 BaseModel,
33 ConfigDict,
34 Field,
35 GetJsonSchemaHandler,
36 PrivateAttr,
37 model_serializer,
38)
39from pydantic.json_schema import JsonSchemaValue
40from pydantic_core import CoreSchema
42from .headers import fix_header
43from .properties import (
44 PROPERTIES,
45 AltAzAnnotated,
46 AngleAnnotated,
47 EarthLocationAnnotated,
48 ExposureTimeAnnotated,
49 FocusZAnnotated,
50 PressureAnnotated,
51 PropertyDefinition,
52 SkyCoordAnnotated,
53 TemperatureAnnotated,
54 TimeAnnotated,
55 TimeDeltaAnnotated,
56)
57from .serialize.flat_dict import FlatValue, find_flat_keys, find_translator_name, flatten_simple, unflatten
58from .translator import MetadataTranslator
60log = logging.getLogger(__name__)
63def _wire_doc(key: str, wire_form: str) -> str:
64 """Append a wire-format note to a property's semantic doc.
66 Used only for ``Field(description=...)`` in the JSON schema; the
67 underlying ``PROPERTIES[key].doc`` is intentionally left unit-agnostic
68 because it is also surfaced as the docstring of auto-generated
69 translator stub methods, where forcing a wire-format unit would be
70 misleading.
72 Parameters
73 ----------
74 key : `str`
75 Property name in `PROPERTIES`.
76 wire_form : `str`
77 Short noun phrase describing the JSON-serialized form, e.g.
78 ``"a float in meters"``.
80 Returns
81 -------
82 description : `str`
83 ``PROPERTIES[key].doc`` followed by ``"Serialized as <wire_form>."``.
84 """
85 return f"{PROPERTIES[key].doc} Serialized as {wire_form}."
88class ObservationInfo(BaseModel):
89 """Standardized representation of an instrument header for a single
90 exposure observation.
92 Parameters
93 ----------
94 header : `dict`-like
95 Representation of an instrument header accessible as a `dict`.
96 May be updated with header corrections if corrections are found.
97 filename : `str`, optional
98 Name of the file whose header is being translated. For some
99 datasets with missing header information this can sometimes
100 allow for some fixups in translations.
101 translator_class : `MetadataTranslator`-class, optional
102 If not `None`, the class to use to translate the supplied headers
103 into standard form. Otherwise each registered translator class will
104 be asked in turn if it knows how to translate the supplied header.
105 pedantic : `bool`, optional
106 If True the translation must succeed for all properties. If False
107 individual property translations must all be implemented but can fail
108 and a warning will be issued. Only used if a ``header`` is specified.
109 search_path : `~collections.abc.Iterable`, optional
110 Override search paths to use during header fix up. Only used if a
111 ``header`` is specified.
112 required : `set`, optional
113 This parameter can be used to confirm that all properties contained
114 in the set must translate correctly and also be non-None. For the case
115 where ``pedantic`` is `True` this will still check that the resulting
116 value is not `None`. Only used if a ``header`` is specified.
117 subset : `set`, optional
118 If not `None`, controls the translations that will be performed
119 during construction. This can be useful if the caller is only
120 interested in a subset of the properties and knows that some of
121 the others might be slow to compute (for example the airmass if it
122 has to be derived). Only used if a ``header`` is specified.
123 quiet : `bool`, optional
124 If `True`, warning level log messages that would be issued in non
125 pedantic mode are converted to debug messages.
126 **kwargs : `typing.Any`
127 Property name/value pairs for kwargs-based construction mode. This
128 mode creates an `ObservationInfo` directly from supplied properties
129 rather than by translating a header. If ``header`` is provided it is
130 an error to also provide ``kwargs``.
132 Raises
133 ------
134 ValueError
135 Raised if the supplied header was not recognized by any of the
136 registered translators. Also raised if the request property subset
137 is not a subset of the known properties or if a header is given along
138 with kwargs.
139 TypeError
140 Raised if the supplied translator class was not a MetadataTranslator.
141 KeyError
142 Raised if a required property cannot be calculated, or if pedantic
143 mode is enabled and any translations fails.
144 NotImplementedError
145 Raised if the selected translator does not support a required
146 property.
148 Notes
149 -----
150 There is a core set of instrumental properties that are pre-defined.
151 Additional properties may be defined, either through the
152 `makeObservationInfo` factory function by providing the ``extensions``
153 definitions, or through the regular `ObservationInfo` constructor when
154 the extensions have been defined in the `MetadataTranslator` for the
155 instrument of interest (or in the provided ``translator_class``).
157 There are two forms of the constructor. If the ``header`` is given
158 then a translator will be determined and the properties will be populated
159 accordingly. No generic keyword arguments will be expected and the
160 remaining parameters control the behavior of the translator.
162 If the header is not given it is assumed that the keyword arguments
163 are direct specifications of observation properties. In this mode only
164 the ``filename`` and ``translator_class`` parameters will be used. The
165 latter is used to determine any extensions that are being provided,
166 although when using standard serializations the special ``_translator``
167 key will be used instead to specify the name of the registered translator
168 from which to extract extension definitions.
170 Headers will be corrected if correction files are located and this will
171 modify the header provided to the constructor. Modifying the supplied
172 header after construction will modify the internal cached header.
174 Values of the properties are read-only.
175 """
177 model_config = ConfigDict(
178 extra="forbid",
179 validate_assignment=False,
180 populate_by_name=True,
181 serialize_by_alias=True, # Emit the ``_translator`` alias even when nested.
182 ser_json_inf_nan="constants", # Allow for inf and nan to round trip.
183 )
185 translator_name: str | None = Field(
186 default=None,
187 alias="_translator",
188 description="Name of the registered metadata translator class used for these data.",
189 )
191 telescope: str | None = Field(default=None, description=PROPERTIES["telescope"].doc)
192 instrument: str | None = Field(default=None, description=PROPERTIES["instrument"].doc)
193 location: EarthLocationAnnotated | None = Field(
194 default=None,
195 description=_wire_doc("location", "a geocentric (x, y, z) tuple of floats in meters"),
196 )
197 exposure_id: int | None = Field(default=None, description=PROPERTIES["exposure_id"].doc)
198 visit_id: int | None = Field(default=None, description=PROPERTIES["visit_id"].doc)
199 physical_filter: str | None = Field(default=None, description=PROPERTIES["physical_filter"].doc)
200 datetime_begin: TimeAnnotated | None = Field(
201 default=None,
202 description=_wire_doc("datetime_begin", "a two-element TAI Julian Date [jd1, jd2]"),
203 )
204 datetime_end: TimeAnnotated | None = Field(
205 default=None,
206 description=_wire_doc("datetime_end", "a two-element TAI Julian Date [jd1, jd2]"),
207 )
208 exposure_time: ExposureTimeAnnotated | None = Field(
209 default=None, description=PROPERTIES["exposure_time"].doc
210 )
211 exposure_time_requested: ExposureTimeAnnotated | None = Field(
212 default=None, description=PROPERTIES["exposure_time_requested"].doc
213 )
214 dark_time: ExposureTimeAnnotated | None = Field(default=None, description=PROPERTIES["dark_time"].doc)
215 boresight_airmass: float | None = Field(default=None, description=PROPERTIES["boresight_airmass"].doc)
216 boresight_rotation_angle: AngleAnnotated | None = Field(
217 default=None,
218 description=_wire_doc("boresight_rotation_angle", "a float in degrees"),
219 )
220 boresight_rotation_coord: str | None = Field(
221 default=None, description=PROPERTIES["boresight_rotation_coord"].doc
222 )
223 detector_num: int | None = Field(default=None, description=PROPERTIES["detector_num"].doc)
224 detector_name: str | None = Field(default=None, description=PROPERTIES["detector_name"].doc)
225 detector_unique_name: str | None = Field(default=None, description=PROPERTIES["detector_unique_name"].doc)
226 detector_serial: str | None = Field(default=None, description=PROPERTIES["detector_serial"].doc)
227 detector_group: str | None = Field(default=None, description=PROPERTIES["detector_group"].doc)
228 detector_exposure_id: int | None = Field(default=None, description=PROPERTIES["detector_exposure_id"].doc)
229 focus_z: FocusZAnnotated | None = Field(
230 default=None,
231 description=_wire_doc("focus_z", "a float in meters"),
232 )
233 object: str | None = Field(default=None, description=PROPERTIES["object"].doc)
234 temperature: TemperatureAnnotated | None = Field(
235 default=None,
236 description=_wire_doc("temperature", "a float in kelvin"),
237 )
238 pressure: PressureAnnotated | None = Field(
239 default=None,
240 description=_wire_doc("pressure", "a float in hPa"),
241 )
242 relative_humidity: float | None = Field(default=None, description=PROPERTIES["relative_humidity"].doc)
243 tracking_radec: SkyCoordAnnotated | None = Field(
244 default=None,
245 description=_wire_doc("tracking_radec", "an ICRS (RA, Dec) tuple of floats in degrees"),
246 )
247 altaz_begin: AltAzAnnotated | None = Field(
248 default=None,
249 description=_wire_doc("altaz_begin", "an (azimuth, altitude) tuple of floats in degrees"),
250 )
251 altaz_end: AltAzAnnotated | None = Field(
252 default=None,
253 description=_wire_doc("altaz_end", "an (azimuth, altitude) tuple of floats in degrees"),
254 )
255 science_program: str | None = Field(default=None, description=PROPERTIES["science_program"].doc)
256 observation_type: str | None = Field(default=None, description=PROPERTIES["observation_type"].doc)
257 observation_id: str | None = Field(default=None, description=PROPERTIES["observation_id"].doc)
258 observation_reason: str | None = Field(default=None, description=PROPERTIES["observation_reason"].doc)
259 exposure_group: str | None = Field(default=None, description=PROPERTIES["exposure_group"].doc)
260 observing_day: int | None = Field(default=None, description=PROPERTIES["observing_day"].doc)
261 observing_day_offset: TimeDeltaAnnotated | None = Field(
262 default=None,
263 description=_wire_doc("observing_day_offset", "integer seconds"),
264 )
265 observation_counter: int | None = Field(default=None, description=PROPERTIES["observation_counter"].doc)
266 has_simulated_content: bool | None = Field(
267 default=None, description=PROPERTIES["has_simulated_content"].doc
268 )
269 group_counter_start: int | None = Field(default=None, description=PROPERTIES["group_counter_start"].doc)
270 group_counter_end: int | None = Field(default=None, description=PROPERTIES["group_counter_end"].doc)
271 can_see_sky: bool | None = Field(default=None, description=PROPERTIES["can_see_sky"].doc)
273 # Internal runtime state. These are not part of the wire format and so are
274 # kept as PrivateAttr to keep them out of the generated JSON Schema.
275 _filename: str | None = PrivateAttr(default=None)
276 _translator_class_name: str = PrivateAttr(default="<None>")
277 _extensions: dict[str, PropertyDefinition] = PrivateAttr(default_factory=dict)
278 _all_properties: dict[str, PropertyDefinition] = PrivateAttr(default_factory=dict)
279 _header: MutableMapping[str, Any] = PrivateAttr(default_factory=dict)
280 _translator: MetadataTranslator | None = PrivateAttr(default=None)
281 _sealed: bool = PrivateAttr(default=False)
283 @property
284 def filename(self) -> str | None:
285 """Name of the file whose header was translated, if any."""
286 return self._filename
288 @filename.setter
289 def filename(self, value: str | None) -> None:
290 self._filename = value
292 @property
293 def translator_class_name(self) -> str:
294 """Name of the metadata translator class used for these data."""
295 return self._translator_class_name
297 @property
298 def extensions(self) -> dict[str, PropertyDefinition]:
299 """Definitions of the translator-specific extension properties."""
300 return self._extensions
302 @property
303 def all_properties(self) -> dict[str, PropertyDefinition]:
304 """Definitions of all known properties (core plus extensions)."""
305 return self._all_properties
307 @overload
308 def __init__( 308 ↛ exitline 308 didn't return from function '__init__' because
309 self,
310 header: MutableMapping[str, Any],
311 filename: str | ResourcePath | None = None,
312 translator_class: type[MetadataTranslator] | None = None,
313 pedantic: bool = False,
314 search_path: Sequence[str] | None = None,
315 required: set[str] | None = None,
316 subset: set[str] | None = None,
317 quiet: bool = False,
318 ) -> None: ...
320 @overload
321 def __init__( 321 ↛ exitline 321 didn't return from function '__init__' because
322 self,
323 header: None = None,
324 filename: str | ResourcePath | None = None,
325 translator_class: type[MetadataTranslator] | None = None,
326 **kwargs: Any,
327 ) -> None: ...
329 def __init__(
330 self,
331 header: MutableMapping[str, Any] | None = None,
332 filename: str | ResourcePath | None = None,
333 translator_class: type[MetadataTranslator] | None = None,
334 pedantic: bool = False,
335 search_path: Sequence[str] | None = None,
336 required: set[str] | None = None,
337 subset: set[str] | None = None,
338 quiet: bool = False,
339 **kwargs: Any,
340 ) -> None:
341 if filename is not None:
342 filename = str(ResourcePath(filename, forceAbsolute=True))
343 if header is not None:
344 if kwargs:
345 raise ValueError(
346 "kwargs not allowed if constructor given a header to translate. "
347 f"Unrecognized keys: {[k for k in kwargs]}"
348 )
349 self._init_from_header(
350 header,
351 filename=filename,
352 translator_class=translator_class,
353 pedantic=pedantic,
354 search_path=search_path,
355 required=required,
356 subset=subset,
357 quiet=quiet,
358 )
359 return
361 self._init_from_kwargs(filename=filename, translator_class=translator_class, **kwargs)
363 def _init_from_header(
364 self,
365 header: MutableMapping[str, Any],
366 *,
367 filename: str | None,
368 translator_class: type[MetadataTranslator] | None,
369 pedantic: bool,
370 search_path: Sequence[str] | None,
371 required: set[str] | None,
372 subset: set[str] | None,
373 quiet: bool,
374 ) -> None:
375 super().__init__()
376 self._filename = filename
377 self._sealed = False
378 # Initialize the empty object
379 self._header = {}
380 self._translator = None
381 failure_level = logging.DEBUG if quiet else logging.WARNING
383 # Look for translator class before header fixup. fix_header calls
384 # determine_translator immediately on the basis that you need to know
385 # enough of the header to work out the translator before you can fix
386 # it up. There is no gain in asking fix_header to determine the
387 # translator and then trying to work it out again here.
388 if translator_class is None:
389 translator_class = MetadataTranslator.determine_translator(header, filename=filename)
390 elif not issubclass(translator_class, MetadataTranslator):
391 raise TypeError(f"Translator class must be a MetadataTranslator, not {translator_class}")
393 # Fix up the header (if required)
394 fix_header(header, translator_class=translator_class, filename=filename, search_path=search_path)
396 # Store the supplied header for later stripping
397 self._header = header
399 # This configures both self.extensions and self.all_properties.
400 self._declare_extensions(translator_class.extensions)
402 # Create an instance for this header
403 translator = translator_class(header, filename=filename)
405 # Store the translator
406 self._translator = translator
407 self._translator_class_name = translator_class.__name__
408 self.translator_name = translator_class.name
410 # Form file information string in case we need an error message
411 if filename:
412 file_info = f" and file {filename}"
413 else:
414 file_info = ""
416 # Determine the properties of interest
417 full_set = set(self.all_properties)
418 if subset is not None:
419 if not subset:
420 raise ValueError("Cannot request no properties be calculated.")
421 if not subset.issubset(full_set):
422 raise ValueError(
423 f"Requested subset is not a subset of known properties. Got extra: {subset - full_set}"
424 )
425 properties = subset
426 else:
427 properties = full_set
429 if required is None:
430 required = set()
431 else:
432 if not required.issubset(full_set):
433 raise ValueError(f"Requested required properties include unknowns: {required - full_set}")
435 # Loop over each property and request the translated form
436 for property in properties:
437 # prototype code
438 method = f"to_{property}"
440 try:
441 value = getattr(translator, method)()
442 except NotImplementedError as e:
443 raise NotImplementedError(
444 f"No translation exists for property '{property}' using translator {translator.__class__}"
445 ) from e
446 except Exception as e:
447 err_msg = (
448 f"Error calculating property '{property}' using "
449 f"translator {translator.__class__}{file_info}"
450 )
451 if pedantic or property in required:
452 raise KeyError(err_msg) from e
453 else:
454 log.debug("Calculation of property '%s' failed with header: %s", property, header)
455 log.log(failure_level, f"Ignoring {err_msg}: {e}")
456 continue
458 definition = self.all_properties[property]
459 # Some translators can return a compatible form that needs to
460 # be coerced to the correct type (e.g., returning SkyCoord when you
461 # need AltAz). In theory we could patch the translators to return
462 # AltAz but code has historically not been as picky about this
463 # until pydantic turned up.
464 value = self._coerce_from_simple(definition, value, {})
465 if not definition.is_value_conformant(value): 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true
466 err_msg = (
467 f"Value calculated for property '{property}' is wrong type "
468 f"({type(value)} != {definition.str_type}) using translator {translator.__class__}"
469 f"{file_info}"
470 )
471 if pedantic or property in required:
472 raise TypeError(err_msg)
473 else:
474 log.debug(
475 "Calculation of property '%s' had unexpected type with header: %s", property, header
476 )
477 log.log(failure_level, f"Ignoring {err_msg}")
479 if value is None and property in required:
480 raise KeyError(f"Calculation of required property {property} resulted in a value of None")
482 object.__setattr__(self, property, value) # allows setting even write-protected extensions
484 self._sealed = True
486 def _init_from_kwargs(
487 self,
488 *,
489 filename: str | None,
490 translator_class: type[MetadataTranslator] | None,
491 **kwargs: Any,
492 ) -> None:
493 supplied_keys = set(kwargs)
494 # Accept both the wire-format alias ``_translator`` and the field name
495 # ``translator_name`` for robustness against callers that pass the
496 # field name directly.
497 translator_name = kwargs.pop("_translator", None) or kwargs.pop("translator_name", None)
498 supplied_extensions = kwargs.pop("_extensions", None)
499 if translator_name is not None and translator_name in MetadataTranslator.translators:
500 translator_class = MetadataTranslator.translators[translator_name]
502 if translator_class is not None and not issubclass(translator_class, MetadataTranslator):
503 raise TypeError(f"Translator class must be a MetadataTranslator, not {translator_class}")
505 if supplied_extensions is not None:
506 if translator_class is not None:
507 raise ValueError("Provide either translator_class or _extensions, not both.")
508 if not isinstance(supplied_extensions, dict):
509 raise TypeError("_extensions must be a dictionary of PropertyDefinition entries.")
510 extensions = supplied_extensions
511 else:
512 extensions = translator_class.extensions if translator_class is not None else {}
514 all_properties = self._get_all_properties(extensions)
515 unrecognized = [key for key in kwargs if key not in all_properties]
516 if unrecognized:
517 # Extension properties (the ``ext_`` prefixed keys) are defined by
518 # the translator. When the translator could not be resolved its
519 # extension definitions are unavailable, so report that as the
520 # underlying cause rather than an opaque unknown property.
521 extension_keys = sorted(key for key in unrecognized if key.startswith("ext_"))
522 if extension_keys and translator_class is None:
523 reason = (
524 f"translator '{translator_name}' is not registered"
525 if translator_name is not None
526 else "no translator was specified"
527 )
528 raise KeyError(
529 f"Unrecognized extension properties {extension_keys}: {reason}, so the "
530 "extension property definitions are unavailable."
531 )
532 text = (
533 f"property ({unrecognized[0]})"
534 if len(unrecognized) == 1
535 else f"properties ({', '.join(unrecognized)})"
536 )
537 raise KeyError(f"Unrecognized {text} provided")
539 processed = {k: v for k, v in kwargs.items() if k in PROPERTIES and v is not None}
540 processed = self._apply_constructor_defaults(processed, supplied_keys)
542 super().__init__(**processed)
543 self._filename = filename
544 self._sealed = False
546 # This configures both self.extensions and self.all_properties.
547 self._declare_extensions(extensions)
549 # Handle extensions.
550 ext_input = {k: v for k, v in kwargs.items() if k.startswith("ext_")}
551 processed_ext = self._validate_property_mapping(ext_input, extensions)
552 for key, value in processed_ext.items():
553 object.__setattr__(self, key, value)
555 if translator_class is not None:
556 self._translator = translator_class({})
557 self._translator_class_name = translator_class.__name__
558 self.translator_name = translator_class.name
559 elif translator_name is not None:
560 # The named translator is not registered in this process. The
561 # translator instance is only needed while constructing from a
562 # header, so it is left as None and the name from the serialization
563 # is recorded directly.
564 self._translator_class_name = translator_name
565 self.translator_name = translator_name
567 self._sealed = True
569 @staticmethod
570 def _apply_constructor_defaults(processed: dict[str, Any], supplied_keys: set[str]) -> dict[str, Any]:
571 """Apply derived/default values for kwargs-style construction.
573 Parameters
574 ----------
575 processed : `dict` [`str`, `typing.Any`]
576 Properties validated from kwargs input.
577 supplied_keys : `set` [`str`]
578 Property names explicitly supplied by the caller.
580 Returns
581 -------
582 updated : `dict` [`str`, `typing.Any`]
583 Updated property mapping with defaults/backfilled values applied.
584 """
585 updated = dict(processed)
586 for key in ("group_counter_start", "group_counter_end"):
587 if (
588 key not in supplied_keys
589 and "observation_counter" in supplied_keys
590 and "observation_counter" in updated
591 ):
592 updated[key] = updated["observation_counter"]
593 if "has_simulated_content" not in supplied_keys:
594 updated["has_simulated_content"] = False
595 return updated
597 @classmethod
598 def from_header(
599 cls,
600 header: MutableMapping[str, Any],
601 *,
602 filename: str | None = None,
603 translator_class: type[MetadataTranslator] | None = None,
604 pedantic: bool = False,
605 search_path: Sequence[str] | None = None,
606 required: set[str] | None = None,
607 subset: set[str] | None = None,
608 quiet: bool = False,
609 ) -> ObservationInfo:
610 """Create an `ObservationInfo` by translating a metadata header.
612 Parameters
613 ----------
614 header : `dict`-like
615 Header mapping to translate.
616 filename : `str`, optional
617 Name of file associated with this header.
618 translator_class : `MetadataTranslator`-class, optional
619 Translator class to use. If `None`, translator will be
620 auto-determined.
621 pedantic : `bool`, optional
622 If `True`, translation failures are fatal.
623 search_path : `~collections.abc.Sequence` [`str`], optional
624 Search paths for header corrections.
625 required : `set` [`str`], optional
626 Properties that must be translated and non-`None`.
627 subset : `set` [`str`], optional
628 Restrict translation to this subset of properties.
629 quiet : `bool`, optional
630 If `True`, warning level log messages that would be issued in non
631 pedantic mode are converted to debug messages.
633 Returns
634 -------
635 obsinfo : `ObservationInfo`
636 Translated observation metadata.
637 """
638 return cls(
639 header=header,
640 filename=filename,
641 translator_class=translator_class,
642 pedantic=pedantic,
643 search_path=search_path,
644 required=required,
645 subset=subset,
646 quiet=quiet,
647 )
649 @staticmethod
650 def _get_all_properties(
651 extensions: dict[str, PropertyDefinition] | None = None,
652 ) -> dict[str, PropertyDefinition]:
653 """Return the definitions of all properties.
655 Parameters
656 ----------
657 extensions : `dict` [`str`: `PropertyDefinition`]
658 List of extension property definitions, indexed by name (with no
659 "ext_" prefix).
661 Returns
662 -------
663 properties : `dict` [`str`: `PropertyDefinition`]
664 Merged list of all property definitions, indexed by name. Extension
665 properties will be listed with an ``ext_`` prefix.
666 """
667 properties = dict(PROPERTIES)
668 if extensions:
669 properties.update({"ext_" + pp: dd for pp, dd in extensions.items()})
670 return properties
672 def _declare_extensions(self, extensions: dict[str, PropertyDefinition] | None) -> None:
673 """Declare and set up extension properties.
675 This should always be called internally as part of the creation of a
676 new `ObservationInfo`.
678 The core set of properties are declared as model fields at import
679 time. Extension properties have to be configured at runtime (because
680 we don't know what they will be until we look at the header and figure
681 out what instrument we're dealing with), so we add them to the model
682 and then use ``__setattr__`` to protect them as read-only. All
683 extension properties are set to `None`.
685 Parameters
686 ----------
687 extensions : `dict` [`str`: `PropertyDefinition`]
688 List of extension property definitions, indexed by name (with no
689 "ext_" prefix).
690 """
691 if extensions:
692 for name in extensions:
693 field_name = "ext_" + name
694 if not hasattr(self, field_name): 694 ↛ 692line 694 didn't jump to line 692 because the condition on line 694 was always true
695 object.__setattr__(self, field_name, None)
696 self._extensions = extensions
697 self._all_properties = self._get_all_properties(self._extensions)
699 def __setattr__(self, name: str, value: Any) -> Any:
700 """Set attribute.
702 This provides read-only protection for all properties once the
703 instance has been sealed.
705 Parameters
706 ----------
707 name : `str`
708 Name of attribute to set.
709 value : `typing.Any`
710 Value to set it to.
711 """
712 if (
713 getattr(self, "_sealed", False)
714 and hasattr(self, "all_properties")
715 and name in self.all_properties
716 ):
717 raise AttributeError(f"Attribute {name} is read-only")
718 return super().__setattr__(name, value)
720 @classmethod
721 def __get_pydantic_json_schema__(
722 cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
723 ) -> JsonSchemaValue:
724 # The model_serializer below is mode="wrap" returning dict[str, Any];
725 # in serialization mode pydantic would otherwise emit a trivial
726 # "object" schema. Hand the inner model-fields schema to the handler
727 # so the field-level WithJsonSchema/PlainSerializer annotations are
728 # honored.
729 inner = core_schema.get("schema", core_schema)
730 schema = handler(inner)
731 schema = handler.resolve_ref_schema(schema)
732 # Allow translator-specific extension properties under the ext_ prefix.
733 schema["patternProperties"] = {
734 "^ext_": {"description": "Extension property value (translator-specific)."},
735 }
736 # Anything other than declared properties and ext_* keys is rejected.
737 schema["additionalProperties"] = False
738 return schema
740 @model_serializer(mode="wrap")
741 def _serialize(self, handler: Any) -> dict[str, Any]:
742 # Field serialization uses the per-field PlainSerializer annotations.
743 # ``serialize_by_alias=True`` in model_config emits the ``_translator``
744 # alias instead of ``translator_name`` (and applies when nested).
745 result: dict[str, Any] = handler(self)
746 # Strip None entries; the wire format omits unset properties.
747 result = {k: v for k, v in result.items() if v is not None}
748 # Append values for any extension properties (dynamic ext_* attrs).
749 # Kept flat for backwards compatibility with the existing wire format.
750 for ext_name, ext_def in self._extensions.items():
751 field_name = f"ext_{ext_name}"
752 value = getattr(self, field_name, None)
753 if value is None: 753 ↛ 754line 753 didn't jump to line 754 because the condition on line 753 was never true
754 continue
755 simplifier = ext_def.to_simple
756 result[field_name] = simplifier(value) if simplifier else value
757 return result
759 @classmethod
760 def _validate_property_mapping(
761 cls,
762 data: MutableMapping[str, Any],
763 extensions: dict[str, PropertyDefinition] | None,
764 ) -> dict[str, Any]:
765 # Validate extension properties.
766 properties = {f"ext_{name}": definition for name, definition in (extensions or {}).items()}
767 processed: dict[str, Any] = {}
769 for key, value in data.items():
770 if key not in properties: 770 ↛ 771line 770 didn't jump to line 771 because the condition on line 770 was never true
771 raise KeyError(f"Unrecognized property '{key}' provided")
772 if value is None:
773 continue
774 processed[key] = cls._coerce_property_value(key, value, properties, processed)
775 return processed
777 @classmethod
778 def _coerce_property_value(
779 cls,
780 key: str,
781 value: Any,
782 properties: dict[str, PropertyDefinition],
783 processed: dict[str, Any],
784 ) -> Any:
785 definition = properties[key]
786 converted = cls._coerce_from_simple(definition, value, processed)
787 if not definition.is_value_conformant(converted):
788 raise TypeError(
789 f"Supplied value {value} for property {key} "
790 f"should be of class {definition.str_type} not {converted.__class__}"
791 )
792 return converted
794 @classmethod
795 def _coerce_from_simple(
796 cls,
797 definition: PropertyDefinition,
798 value: Any,
799 processed: dict[str, Any],
800 ) -> Any:
801 if definition.is_value_conformant(value):
802 return value
803 complexifier = definition.from_simple
804 if complexifier is None: 804 ↛ 807line 804 didn't jump to line 807 because the condition on line 804 was always true
805 # Not the correct type, assumes caller will check.
806 return value
807 return complexifier(value, **processed)
809 @property
810 def cards_used(self) -> frozenset[str]:
811 """Header cards used for the translation.
813 Returns
814 -------
815 used : `frozenset` of `str`
816 Set of card used.
817 """
818 if not self._translator:
819 return frozenset()
820 return self._translator.cards_used()
822 def stripped_header(self) -> MutableMapping[str, Any]:
823 """Return a copy of the supplied header with used keywords removed.
825 Returns
826 -------
827 stripped : `dict`-like
828 Same class as header supplied to constructor, but with the
829 headers used to calculate the generic information removed.
830 """
831 hdr = copy.copy(self._header)
832 used = self.cards_used
833 for c in used:
834 if c in hdr:
835 del hdr[c]
836 return hdr
838 def __str__(self) -> str:
839 # Put more interesting answers at front of list
840 # and then do remainder
841 priority = ("instrument", "telescope", "datetime_begin")
842 properties = sorted(set(self.all_properties) - set(priority))
844 result = ""
845 for p in itertools.chain(priority, properties):
846 value = getattr(self, p)
847 if isinstance(value, astropy.time.Time):
848 value.format = "isot"
849 value = str(value.value)
850 result += f"{p}: {value}\n"
852 return result
854 def __eq__(self, other: Any) -> bool:
855 """Check equality with another object.
857 Compares equal if standard properties are equal.
859 Parameters
860 ----------
861 other : `typing.Any`
862 Thing to compare with.
863 """
864 if not isinstance(other, ObservationInfo):
865 return NotImplemented
867 # Compare simplified forms.
868 # Cannot compare directly because nan will not equate as equal
869 # whereas they should be equal for our purposes
870 self_simple = self.to_simple()
871 other_simple = other.to_simple()
873 # We don't care about the translator internal detail
874 self_simple.pop("_translator", None)
875 other_simple.pop("_translator", None)
877 for k in self_simple.keys() & other_simple.keys():
878 self_value = self_simple[k]
879 other_value = other_simple[k]
880 if self_value != other_value:
881 if isinstance(self_value, float) or (
882 isinstance(self_value, tuple) and isinstance(self_value[0], float)
883 ):
884 close = np.allclose(self_value, other_value, equal_nan=True)
885 if close:
886 continue
887 return False
888 return True
890 def __lt__(self, other: Any) -> bool:
891 if not isinstance(other, ObservationInfo):
892 return NotImplemented
893 if self.datetime_begin is None or other.datetime_begin is None:
894 raise TypeError("Cannot compare ObservationInfo without datetime_begin values")
895 return self.datetime_begin < other.datetime_begin
897 def __gt__(self, other: Any) -> bool:
898 if not isinstance(other, ObservationInfo):
899 return NotImplemented
900 if self.datetime_begin is None or other.datetime_begin is None:
901 raise TypeError("Cannot compare ObservationInfo without datetime_begin values")
902 return self.datetime_begin > other.datetime_begin
904 def __getstate__(self) -> dict[str, Any]:
905 """Get pickleable state.
907 Returns the properties. Deliberately does not preserve the full
908 current state; in particular, does not return the full header or
909 translator.
911 Returns
912 -------
913 state : `dict`
914 Pickled state.
915 """
916 state: dict[str, Any] = {}
917 for p in self.all_properties:
918 state[p] = getattr(self, p)
920 return {"state": state, "extensions": self.extensions}
922 def __setstate__(self, state: dict[Any, Any]) -> None:
923 """Set object state from pickle.
925 Parameters
926 ----------
927 state : `dict`
928 Pickled state.
929 """
930 state_any = cast(Any, state)
931 if isinstance(state_any, dict) and "state" in state_any: 931 ↛ 935line 931 didn't jump to line 935 because the condition on line 931 was always true
932 state = state_any["state"]
933 extensions = state_any.get("extensions", {})
934 else:
935 try:
936 state, extensions = state_any
937 except ValueError:
938 # Backwards compatibility for pickles generated before DM-34175
939 extensions = {}
940 super().__init__()
941 self._sealed = False
942 self._declare_extensions(extensions)
943 for p in self.all_properties:
944 # allows setting even write-protected extensions
945 object.__setattr__(self, p, state[p])
946 self._sealed = True
948 def to_simple(self) -> MutableMapping[str, Any]:
949 """Convert the contents of this object to simple dict form.
951 The keys of the dict are the standard properties but the values
952 can be simplified to support JSON serialization. For example a
953 SkyCoord might be represented as an ICRS RA/Dec tuple rather than
954 a full SkyCoord representation.
956 Any properties with `None` value will be skipped.
958 Can be converted back to an `ObservationInfo` using `from_simple`.
960 Returns
961 -------
962 simple : `dict` of [`str`, `~typing.Any`]
963 Simple dict of all properties.
965 Notes
966 -----
967 Round-tripping of extension properties requires that the
968 `ObservationInfo` was created with the help of a registered
969 `MetadataTranslator` (which contains the extension property
970 definitions).
971 """
972 return self.model_dump(mode="python")
974 def to_json(self) -> str:
975 """Serialize the object to JSON string.
977 Returns
978 -------
979 j : `str`
980 The properties of the ObservationInfo in JSON string form.
982 Notes
983 -----
984 Round-tripping of extension properties requires that the
985 `ObservationInfo` was created with the help of a registered
986 `MetadataTranslator` (which contains the extension property
987 definitions).
988 """
989 return self.model_dump_json()
991 @classmethod
992 def from_simple(cls, simple: MutableMapping[str, Any]) -> ObservationInfo:
993 """Convert the entity returned by `to_simple` back into an
994 `ObservationInfo`.
996 Parameters
997 ----------
998 simple : `dict` [`str`, `~typing.Any`]
999 The dict returned by `to_simple`.
1001 Returns
1002 -------
1003 obsinfo : `ObservationInfo`
1004 New object constructed from the dict.
1006 Notes
1007 -----
1008 Round-tripping of extension properties requires that the
1009 `ObservationInfo` was created with the help of a registered
1010 `MetadataTranslator` (which contains the extension property
1011 definitions).
1012 """
1013 return cls.model_validate(simple)
1015 @classmethod
1016 def from_json(cls, json_str: str) -> ObservationInfo:
1017 """Create `ObservationInfo` from JSON string.
1019 Parameters
1020 ----------
1021 json_str : `str`
1022 The JSON representation.
1024 Returns
1025 -------
1026 obsinfo : `ObservationInfo`
1027 Reconstructed object.
1029 Notes
1030 -----
1031 Round-tripping of extension properties requires that the
1032 `ObservationInfo` was created with the help of a registered
1033 `MetadataTranslator` (which contains the extension property
1034 definitions).
1035 """
1036 return cls.model_validate_json(json_str)
1038 def to_flat_dict(
1039 self,
1040 *,
1041 prefix: str = "",
1042 sep: str = ".",
1043 use_upper: bool | None = None,
1044 encode_non_finite: bool = False,
1045 ) -> dict[str, FlatValue]:
1046 """Convert the contents of this object to a flat dict of scalars.
1048 Parameters
1049 ----------
1050 prefix : `str`, optional
1051 Prefix placed before every key. Emitted exactly as
1052 given. Use ``FITS_FLAT_DICT_PREFIX`` for FITS headers.
1053 sep : `str`, optional
1054 Separator between the prefix and the property name. Must be
1055 one character that is not a letter, digit, or underscore.
1056 Unused when ``prefix`` is empty. Use ``FITS_FLAT_DICT_SEP``
1057 for FITS headers.
1058 use_upper : `bool` or `None`, optional
1059 If `None` the property names are upper-cased when the first
1060 character of ``prefix`` is upper case and left lower case
1061 otherwise. `True` or `False` force the case.
1062 encode_non_finite : `bool`, optional
1063 If `True`, ``NaN`` and infinite floats are written as the
1064 strings ``NAN``, ``+INFINITY``, and ``-INFINITY``. FITS can
1065 not hold such floats, so set this when the result is going
1066 into a FITS header. By default they are left as floats.
1068 Returns
1069 -------
1070 flat : `dict` [`str`, `str` | `int` | `float` | `bool`]
1071 Flat mapping of all properties that are not `None`.
1073 Raises
1074 ------
1075 ValueError
1076 Raised if ``sep`` is not valid.
1077 TypeError
1078 Raised if a property value can not be represented as a
1079 scalar or a flat sequence of scalars.
1081 Notes
1082 -----
1083 Each key is the property name as used by `to_simple`, optionally
1084 preceded by the prefix and separator. A property whose simple
1085 form is a tuple, such as ``datetime_begin``, is written as one
1086 key per element with an index suffix: ``datetime_begin_0`` and
1087 ``datetime_begin_1``. The ``_translator`` entry is written as
1088 ``translator``. With ``prefix="OBS_INFO"`` and ``sep=" "`` the
1089 keys are suitable for FITS ``HIERARCH`` cards, for example
1090 ``OBS_INFO DATETIME_BEGIN_0``.
1092 Values are passed through unchanged unless ``encode_non_finite``
1093 is set. The strings it writes are the ones cfitsio and afw use
1094 for non-finite floats, and `from_flat_dict` converts them back
1095 to floats. FITS does not preserve trailing spaces in string
1096 values.
1098 The result can be converted back with `from_flat_dict`.
1099 Round-tripping of extension properties requires that the
1100 translator is registered, and an extension name must not end in
1101 an underscore followed by digits if a tuple-valued extension
1102 with the shorter name also exists.
1103 """
1104 return flatten_simple(
1105 self.to_simple(),
1106 prefix=prefix,
1107 sep=sep,
1108 use_upper=use_upper,
1109 encode_non_finite=encode_non_finite,
1110 )
1112 @classmethod
1113 def has_flat_dict(cls, flat: Mapping[str, Any], *, prefix: str | None = None, sep: str = ".") -> bool:
1114 """Report whether a mapping contains keys written by `to_flat_dict`.
1116 Parameters
1117 ----------
1118 flat : `~collections.abc.Mapping` [`str`, `~typing.Any`]
1119 Mapping to inspect, for example a FITS header. Keys are
1120 matched case-insensitively.
1121 prefix : `str` or `None`, optional
1122 Prefix given to `to_flat_dict`. `None` detects the prefix
1123 and separator automatically, in which case ``sep`` is
1124 ignored.
1125 sep : `str`, optional
1126 Separator given to `to_flat_dict`.
1128 Returns
1129 -------
1130 present : `bool`
1131 `True` if at least one key was found.
1133 Raises
1134 ------
1135 ValueError
1136 Raised if automatic detection finds two prefixes that match
1137 equally well.
1139 Notes
1140 -----
1141 Use this before `from_flat_dict` to tell the absence of content
1142 from content that fails validation. Automatic detection requires
1143 an indexed tuple-valued property such as ``datetime_begin_0`` to
1144 be present. Keys that differ only in case count as present even
1145 though `from_flat_dict` will reject them.
1146 """
1147 return bool(
1148 find_flat_keys(
1149 flat, prefix=prefix, sep=sep, anchor_names=_flat_dict_schema().tuple_lengths, unique=False
1150 )
1151 )
1153 @classmethod
1154 def strip_from_flat_dict(
1155 cls, flat: MutableMapping[str, Any], *, prefix: str | None = None, sep: str = "."
1156 ) -> None:
1157 """Remove keys written by `to_flat_dict` from a mapping in place.
1159 Parameters
1160 ----------
1161 flat : `~collections.abc.MutableMapping` [`str`, `~typing.Any`]
1162 Mapping to modify, for example a FITS header. Keys are
1163 matched case-insensitively.
1164 prefix : `str` or `None`, optional
1165 Prefix given to `to_flat_dict`. `None` detects the prefix
1166 and separator automatically, in which case ``sep`` is
1167 ignored.
1168 sep : `str`, optional
1169 Separator given to `to_flat_dict`.
1171 Raises
1172 ------
1173 ValueError
1174 Raised if ``sep`` is invalid or detection is ambiguous.
1176 Notes
1177 -----
1178 Nothing is removed if no keys are found. Other keys in the
1179 mapping are left untouched. Keys that differ only in case are
1180 all removed.
1181 """
1182 found = find_flat_keys(
1183 flat, prefix=prefix, sep=sep, anchor_names=_flat_dict_schema().tuple_lengths, unique=False
1184 )
1185 for key in found:
1186 del flat[key]
1188 @classmethod
1189 def from_flat_dict(
1190 cls, flat: Mapping[str, Any], *, prefix: str | None = None, sep: str = "."
1191 ) -> ObservationInfo:
1192 """Create an `ObservationInfo` from the output of `to_flat_dict`.
1194 Parameters
1195 ----------
1196 flat : `~collections.abc.Mapping` [`str`, `~typing.Any`]
1197 Mapping containing the flat keys, for example a FITS header.
1198 Keys are matched case-insensitively and unrelated keys are
1199 ignored.
1200 prefix : `str` or `None`, optional
1201 Prefix given to `to_flat_dict`. `None` detects the prefix
1202 and separator automatically, in which case ``sep`` is
1203 ignored.
1204 sep : `str`, optional
1205 Separator given to `to_flat_dict`.
1207 Returns
1208 -------
1209 obsinfo : `ObservationInfo`
1210 New object constructed from the mapping.
1212 Raises
1213 ------
1214 ValueError
1215 Raised if no keys are found, if automatic detection is
1216 ambiguous, if two keys differ only in case, if the indexed
1217 values of a property are incomplete, or if a float property
1218 holds a string that is not a number. Also raised, as
1219 `pydantic.ValidationError`, if a value fails validation.
1220 KeyError
1221 Raised if extension properties are present but the named
1222 translator is not registered, so their definitions are
1223 unknown.
1225 Notes
1226 -----
1227 A string found for a float property is converted with `float`.
1228 This restores the ``NAN``, ``+INFINITY``, and ``-INFINITY``
1229 strings written by `to_flat_dict` with ``encode_non_finite``, and
1230 by cfitsio and afw for non-finite floats, as well as the ``NaN``
1231 and ``Infinity`` spellings used in JSON.
1233 Round-tripping of extension properties requires that the
1234 translator named in the mapping is registered, because its
1235 extension definitions are needed to interpret ``ext_`` keys.
1236 """
1237 schema = _flat_dict_schema()
1238 flat_keys = find_flat_keys(flat, prefix=prefix, sep=sep, anchor_names=schema.tuple_lengths)
1239 if not flat_keys:
1240 raise ValueError("No ObservationInfo content found in the given mapping.")
1241 extension_names: tuple[str, ...] = ()
1242 float_names = set(schema.float_names)
1243 translator_name = find_translator_name(flat, flat_keys)
1244 if translator_name is not None:
1245 translator_class = MetadataTranslator.translators.get(translator_name)
1246 if translator_class is not None: 1246 ↛ 1253line 1246 didn't jump to line 1253 because the condition on line 1246 was always true
1247 extension_names = tuple(translator_class.extensions)
1248 float_names.update(
1249 f"ext_{name}"
1250 for name, definition in translator_class.extensions.items()
1251 if definition.py_type is float
1252 )
1253 simple = unflatten(
1254 flat,
1255 flat_keys,
1256 extension_names=extension_names,
1257 expected_lengths=schema.tuple_lengths,
1258 float_names=float_names,
1259 )
1260 return cls.from_simple(simple)
1262 @classmethod
1263 def makeObservationInfo( # noqa: N802
1264 cls,
1265 *,
1266 extensions: dict[str, PropertyDefinition] | None = None,
1267 translator_class: type[MetadataTranslator] | None = None,
1268 **kwargs: Any,
1269 ) -> ObservationInfo:
1270 """Construct an `ObservationInfo` from the supplied parameters.
1272 Parameters
1273 ----------
1274 extensions : `dict` [`str`: `PropertyDefinition`], optional
1275 Optional extension definitions, indexed by extension name (without
1276 the ``ext_`` prefix, which will be added by `ObservationInfo`).
1277 translator_class : `MetadataTranslator`-class, optional
1278 Optional translator class defining the extension properties. If
1279 provided, this can be used instead of ``extensions`` and will be
1280 stored in the instance for JSON round-tripping.
1281 **kwargs
1282 Name-value pairs for any properties to be set. In the case of
1283 extension properties, the names should include the ``ext_`` prefix.
1285 Notes
1286 -----
1287 The supplied parameters should use names matching the property.
1288 The type of the supplied value will be checked against the property.
1289 Any properties not supplied will be assigned a value of `None`.
1291 Raises
1292 ------
1293 KeyError
1294 Raised if a supplied parameter key is not a known property.
1295 TypeError
1296 Raised if a supplied value does not match the expected type
1297 of the property.
1298 """
1299 return cls(filename=None, translator_class=translator_class, _extensions=extensions, **kwargs)
1301 @contextmanager
1302 def edit_copy(self) -> Generator[ObservationInfo]: # numpydoc ignore=YD01
1303 """Edit a copy of this struct, via a context manager that marks it
1304 as read-only when it exits.
1305 """
1306 copy = self.model_copy()
1307 copy._sealed = False
1308 yield copy
1309 copy._sealed = True
1312class _FlatDictSchema(NamedTuple):
1313 """Facts about the simple form of `ObservationInfo` needed to rebuild
1314 it from a flat dict.
1315 """
1317 tuple_lengths: dict[str, int]
1318 """Fixed tuple length of each tuple-valued core property."""
1320 float_names: frozenset[str]
1321 """Core properties whose simple form is a float or a tuple of floats."""
1324@functools.cache
1325def _flat_dict_schema() -> _FlatDictSchema:
1326 """Derive the flat dict facts from the JSON schema of `ObservationInfo`.
1328 The simple form of a tuple-valued property is declared there as a
1329 fixed-size array, and float values as ``number``.
1330 """
1331 lengths: dict[str, int] = {}
1332 floats: set[str] = set()
1333 for name, prop in ObservationInfo.model_json_schema()["properties"].items():
1334 for option in prop.get("anyOf", [prop]):
1335 if option.get("type") == "number":
1336 floats.add(name)
1337 elif (
1338 option.get("type") == "array" and option.get("minItems") == option.get("maxItems") is not None
1339 ):
1340 lengths[name] = option["maxItems"]
1341 if all(item.get("type") == "number" for item in option.get("prefixItems", [])): 1341 ↛ 1334line 1341 didn't jump to line 1334 because the condition on line 1341 was always true
1342 floats.add(name)
1343 return _FlatDictSchema(lengths, frozenset(floats))
1346def makeObservationInfo( # noqa: N802
1347 *,
1348 extensions: dict[str, PropertyDefinition] | None = None,
1349 translator_class: type[MetadataTranslator] | None = None,
1350 **kwargs: Any,
1351) -> ObservationInfo:
1352 """Construct an `ObservationInfo` from the supplied parameters.
1354 Parameters
1355 ----------
1356 extensions : `dict` [`str`: `PropertyDefinition`], optional
1357 Optional extension definitions, indexed by extension name (without
1358 the ``ext_`` prefix, which will be added by `ObservationInfo`).
1359 translator_class : `MetadataTranslator`-class, optional
1360 Optional translator class defining the extension properties. If
1361 provided, this can be used instead of ``extensions`` and will be
1362 stored in the instance for JSON round-tripping.
1363 **kwargs
1364 Name-value pairs for any properties to be set. In the case of
1365 extension properties, the names should include the ``ext_`` prefix.
1367 Notes
1368 -----
1369 The supplied parameters should use names matching the property.
1370 The type of the supplied value will be checked against the property.
1371 Any properties not supplied will be assigned a value of `None`.
1373 Raises
1374 ------
1375 KeyError
1376 Raised if a supplied parameter key is not a known property.
1377 TypeError
1378 Raised if a supplied value does not match the expected type
1379 of the property.
1380 """
1381 return ObservationInfo.makeObservationInfo(
1382 extensions=extensions, translator_class=translator_class, **kwargs
1383 )