Coverage for python / astro_metadata_translator / properties.py: 50%
112 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 08:50 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 08:50 +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"""Properties calculated by this package.
14Defines all properties in one place so that both `ObservationInfo` and
15`MetadataTranslator` can use them. In particular, the translator
16base class can use knowledge of these properties to predefine translation
17stubs with documentation attached, and `ObservationInfo` can automatically
18define the getter methods.
19"""
21from __future__ import annotations
23__all__ = (
24 "PROPERTIES",
25 "PropertyDefinition",
26)
28from collections.abc import Callable
29from typing import Any, Protocol, SupportsFloat
31import astropy.coordinates
32import astropy.time
33import astropy.units
34import numpy as np
36# Helper functions to convert complex types to simple form suitable
37# for JSON serialization
38# All take the complex type and return simple python form using str, float,
39# int, dict, or list.
40# All assume the supplied parameter is not None.
43class _ToValueProtocol(Protocol):
44 """Protocol for Quantity-like class that has to_value method."""
46 def to_value(self, unit: astropy.units.UnitBase | None = None) -> SupportsFloat | np.ndarray:
47 """Return converted value that might be ndarray or a single number.
49 Parameters
50 ----------
51 unit : `astropy.units.UnitBase` or `None`, optional
52 Optional unit to use when converting the values to floats.
53 """
54 ...
57def _quantity_to_float(q: _ToValueProtocol, unit: astropy.units.UnitBase | None = None) -> float:
58 """Convert a quantity to a float, in a type safe manner, returning
59 a single float.
61 Parameters
62 ----------
63 q : `_ToValueProtocol`
64 The Astropy object to extract the float value from. Must support a
65 ``to_value()`` method.
66 unit : `astropy.units.UnitBase` or `None`, optional
67 Optional unit to use when converting the values to floats.
69 Returns
70 -------
71 value : `float`
72 Single float corresponding to the quantity-like input.
73 """
74 # Quantity.to_value is typed to return np.ndarray or a scalar-like value
75 # that supports float conversion.
76 # We only went a single float and it is an error to return multiples.
77 values = q.to_value(unit=unit)
78 if isinstance(values, np.ndarray):
79 raise ValueError(
80 f"Converting quantity to a float failed because unexpectedly got more than one float: {values}"
81 )
82 return float(values)
85def earthlocation_to_simple(location: astropy.coordinates.EarthLocation) -> tuple[float, ...]:
86 """Convert EarthLocation to tuple.
88 Parameters
89 ----------
90 location : `astropy.coordinates.EarthLocation`
91 The location to simplify.
93 Returns
94 -------
95 geocentric : `tuple` of (`float`, `float`, `float`)
96 The geocentric location as three floats in meters.
97 """
98 geocentric = location.to_geocentric()
99 return tuple(_quantity_to_float(c, astropy.units.m) for c in geocentric)
102def simple_to_earthlocation(simple: tuple[float, ...], **kwargs: Any) -> astropy.coordinates.EarthLocation:
103 """Convert simple form back to EarthLocation.
105 Parameters
106 ----------
107 simple : `tuple` [`float`, ...]
108 The geocentric location as three floats in meters.
109 **kwargs : `typing.Any`
110 Keyword arguments. Currently not used.
112 Returns
113 -------
114 loc : `astropy.coordinates.EarthLocation`
115 The location on the Earth.
116 """
117 return astropy.coordinates.EarthLocation.from_geocentric(*simple, unit=astropy.units.m)
120def datetime_to_simple(datetime: astropy.time.Time) -> tuple[float, float]:
121 """Convert Time to tuple.
123 Parameters
124 ----------
125 datetime : `astropy.time.Time`
126 The time to simplify.
128 Returns
129 -------
130 mjds : `tuple` of (`float`, `float`)
131 The two MJDs in TAI.
132 """
133 tai = datetime.tai
134 return (tai.jd1, tai.jd2)
137def simple_to_datetime(simple: tuple[float, float], **kwargs: Any) -> astropy.time.Time:
138 """Convert simple form back to `astropy.time.Time`.
140 Parameters
141 ----------
142 simple : `tuple` [`float`, `float`]
143 The time represented by two MJDs.
144 **kwargs : `typing.Any`
145 Keyword arguments. Currently not used.
147 Returns
148 -------
149 t : `astropy.time.Time`
150 An astropy time object.
151 """
152 return astropy.time.Time(simple[0], val2=simple[1], format="jd", scale="tai")
155def exptime_to_simple(exptime: astropy.units.Quantity) -> float:
156 """Convert exposure time Quantity to seconds.
158 Parameters
159 ----------
160 exptime : `astropy.units.Quantity`
161 The exposure time as a quantity.
163 Returns
164 -------
165 e : `float`
166 Exposure time in seconds.
167 """
168 return _quantity_to_float(exptime, astropy.units.s)
171def simple_to_exptime(simple: float, **kwargs: Any) -> astropy.units.Quantity:
172 """Convert simple form back to Quantity.
174 Parameters
175 ----------
176 simple : `float`
177 Exposure time in seconds.
178 **kwargs : `typing.Any`
179 Keyword arguments. Currently not used.
181 Returns
182 -------
183 q : `astropy.units.Quantity`
184 The exposure time as a quantity.
185 """
186 return simple * astropy.units.s
189def angle_to_simple(angle: astropy.coordinates.Angle) -> float:
190 """Convert Angle to degrees.
192 Parameters
193 ----------
194 angle : `astropy.coordinates.Angle`
195 The angle.
197 Returns
198 -------
199 a : `float`
200 The angle in degrees.
201 """
202 return _quantity_to_float(angle, astropy.units.deg)
205def simple_to_angle(simple: float, **kwargs: Any) -> astropy.coordinates.Angle:
206 """Convert degrees to Angle.
208 Parameters
209 ----------
210 simple : `float`
211 The angle in degrees.
212 **kwargs : `typing.Any`
213 Keyword arguments. Currently not used.
215 Returns
216 -------
217 a : `astropy.coordinates.Angle`
218 The angle as an object.
219 """
220 # Quantity of 45. deg is not the same as Angle.
221 if isinstance(simple, astropy.units.Quantity):
222 angle = simple
223 else:
224 angle = simple * astropy.units.deg
225 return astropy.coordinates.Angle(angle)
228def focusz_to_simple(focusz: astropy.units.Quantity) -> float:
229 """Convert focusz to meters.
231 Parameters
232 ----------
233 focusz : `astropy.units.Quantity`
234 The z-focus as a quantity.
236 Returns
237 -------
238 f : `float`
239 The z-focus in meters.
240 """
241 return _quantity_to_float(focusz, astropy.units.m)
244def simple_to_focusz(simple: float, **kwargs: Any) -> astropy.units.Quantity:
245 """Convert simple form back to Quantity.
247 Parameters
248 ----------
249 simple : `float`
250 The z-focus in meters.
251 **kwargs : `typing.Any`
252 Keyword arguments. Currently not used.
254 Returns
255 -------
256 q : `astropy.units.Quantity`
257 The z-focus as a quantity.
258 """
259 return simple * astropy.units.m
262def temperature_to_simple(temp: astropy.units.Quantity) -> float:
263 """Convert temperature to kelvin.
265 Parameters
266 ----------
267 temp : `astropy.units.Quantity`
268 The temperature as a quantity.
270 Returns
271 -------
272 t : `float`
273 The temperature in kelvin.
274 """
275 q = temp.to(astropy.units.K, equivalencies=astropy.units.temperature())
276 return _quantity_to_float(q)
279def simple_to_temperature(simple: float, **kwargs: Any) -> astropy.units.Quantity:
280 """Convert scalar kelvin value back to quantity.
282 Parameters
283 ----------
284 simple : `float`
285 Temperature as a float in units of kelvin.
286 **kwargs : `typing.Any`
287 Keyword arguments. Currently not used.
289 Returns
290 -------
291 q : `astropy.units.Quantity`
292 The temperature as a quantity.
293 """
294 return simple * astropy.units.K
297def pressure_to_simple(press: astropy.units.Quantity) -> float:
298 """Convert pressure Quantity to hPa.
300 Parameters
301 ----------
302 press : `astropy.units.Quantity`
303 The pressure as a quantity.
305 Returns
306 -------
307 hpa : `float`
308 The pressure in units of hPa.
309 """
310 return _quantity_to_float(press, astropy.units.hPa)
313def simple_to_pressure(simple: float, **kwargs: Any) -> astropy.units.Quantity:
314 """Convert the pressure scalar back to Quantity.
316 Parameters
317 ----------
318 simple : `float`
319 Pressure in units of hPa.
320 **kwargs : `typing.Any`
321 Keyword arguments. Currently not used.
323 Returns
324 -------
325 q : `astropy.units.Quantity`
326 The pressure as a quantity.
327 """
328 return simple * astropy.units.hPa
331def skycoord_to_simple(skycoord: astropy.coordinates.SkyCoord) -> tuple[float, float]:
332 """Convert SkyCoord to ICRS RA/Dec tuple.
334 Parameters
335 ----------
336 skycoord : `astropy.coordinates.SkyCoord`
337 Sky coordinates in astropy form.
339 Returns
340 -------
341 simple : `tuple` [`float`, `float`]
342 Sky coordinates as a tuple of two floats in units of degrees.
343 """
344 icrs = skycoord.icrs
345 if not isinstance(icrs, astropy.coordinates.SkyCoord):
346 raise ValueError(f"Could not extract ICRS coordinates from SkyCoord {skycoord}")
347 ra = icrs.ra
348 assert isinstance(ra, astropy.coordinates.Longitude)
349 dec = icrs.dec
350 assert isinstance(dec, astropy.coordinates.Latitude)
351 return (_quantity_to_float(ra, astropy.units.deg), _quantity_to_float(dec, astropy.units.deg))
354def simple_to_skycoord(simple: tuple[float, float], **kwargs: Any) -> astropy.coordinates.SkyCoord:
355 """Convert ICRS tuple to SkyCoord.
357 Parameters
358 ----------
359 simple : `tuple` [`float`, `float`]
360 Sky coordinates in degrees.
361 **kwargs : `typing.Any`
362 Keyword arguments. Currently not used.
364 Returns
365 -------
366 skycoord : `astropy.coordinates.SkyCoord`
367 The sky coordinates in astropy form.
368 """
369 return astropy.coordinates.SkyCoord(*simple, unit=astropy.units.deg)
372def altaz_to_simple(altaz: astropy.coordinates.AltAz) -> tuple[float, float]:
373 """Convert AltAz to Alt/Az tuple.
375 Do not include obstime or location in simplification. It is assumed
376 that those will be present from other properties.
378 Parameters
379 ----------
380 altaz : `astropy.coordinates.AltAz`
381 The alt/az in astropy form.
383 Returns
384 -------
385 simple : `tuple` [`float`, `float`]
386 The Alt/Az as a tuple of two floats representing the position in
387 units of degrees.
388 """
389 return (_quantity_to_float(altaz.az, astropy.units.deg), _quantity_to_float(altaz.alt, astropy.units.deg))
392def simple_to_altaz(simple: tuple[float, float], **kwargs: Any) -> astropy.coordinates.AltAz:
393 """Convert simple altaz tuple to AltAz.
395 Parameters
396 ----------
397 simple : `tuple` [`float`, `float`]
398 Altitude and elevation in degrees.
399 **kwargs : `dict`
400 Additional information. Must contain ``location`` and
401 ``datetime_begin``.
403 Returns
404 -------
405 altaz : `astropy.coordinates.AltAz`
406 The altaz in astropy form.
407 """
408 # Sometimes we get given a SkyCoord that contains an AltAz frame that needs
409 # to be extracted.
410 if isinstance(simple, astropy.coordinates.SkyCoord):
411 frame = simple.frame
412 if isinstance(frame, astropy.coordinates.AltAz):
413 return frame
414 # If there is no AltAz frame, return what we have so that downstream
415 # validation can fail.
416 return simple
418 location = kwargs.get("location")
419 obstime = kwargs.get("datetime_begin")
421 return astropy.coordinates.AltAz(
422 simple[0] * astropy.units.deg, simple[1] * astropy.units.deg, obstime=obstime, location=location
423 )
426def timedelta_to_simple(delta: astropy.time.TimeDelta) -> int:
427 """Convert a TimeDelta to integer seconds.
429 This property does not need to support floating point seconds.
431 Parameters
432 ----------
433 delta : `astropy.time.TimeDelta`
434 The time offset.
436 Returns
437 -------
438 sec : `int`
439 Offset in integer seconds.
440 """
441 return round(_quantity_to_float(delta, astropy.units.s))
444def simple_to_timedelta(simple: int, **kwargs: Any) -> astropy.time.TimeDelta:
445 """Convert integer seconds to a `~astropy.time.TimeDelta`.
447 Parameters
448 ----------
449 simple : `int`
450 The offset in integer seconds.
451 **kwargs : `dict`
452 Additional information. Unused.
454 Returns
455 -------
456 delta : `astropy.time.TimeDelta`
457 The delta object.
458 """
459 return astropy.time.TimeDelta(simple, format="sec", scale="tai")
462class PropertyDefinition:
463 """Definition of an instrumental property.
465 Supports both signatures:
467 - ``(doc, py_type, to_simple=None, from_simple=None)``
468 - ``(doc, legacy_str_type, py_type, to_simple=None, from_simple=None)``
470 Modern preference is to not specify the string type since that can be
471 derived directly from the python type.
473 Parameters
474 ----------
475 doc : `str`
476 Documentation string for the property.
477 *args : `typing.Any`
478 Remaining constructor arguments in one of the supported
479 signatures.
480 """
482 __slots__ = ("doc", "py_type", "to_simple", "from_simple")
484 doc: str
485 py_type: type
486 to_simple: Callable[[Any], Any] | None
487 from_simple: Callable[[Any], Any] | None
489 def __init__(self, doc: str, *args: Any) -> None:
490 if not args: 490 ↛ 491line 490 didn't jump to line 491 because the condition on line 490 was never true
491 raise TypeError("PropertyDefinition requires at least a py_type argument")
493 if isinstance(args[0], str):
494 if len(args) < 2 or not isinstance(args[1], type): 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true
495 raise TypeError("Legacy PropertyDefinition signature requires (doc, str_type, py_type, ...)")
496 py_type = args[1]
497 rest = args[2:]
498 else:
499 if not isinstance(args[0], type): 499 ↛ 500line 499 didn't jump to line 500 because the condition on line 499 was never true
500 raise TypeError("PropertyDefinition py_type must be a type")
501 py_type = args[0]
502 rest = args[1:]
504 if len(rest) > 2: 504 ↛ 505line 504 didn't jump to line 505 because the condition on line 504 was never true
505 raise TypeError("PropertyDefinition accepts at most two converter callables")
507 to_simple: Callable[[Any], Any] | None = rest[0] if rest else None
508 from_simple: Callable[[Any], Any] | None = rest[1] if len(rest) > 1 else None
510 self.doc = doc
511 self.py_type = py_type
512 self.to_simple = to_simple
513 self.from_simple = from_simple
515 @property
516 def str_type(self) -> str:
517 """Python type of property as a string suitable for messages/docs."""
518 if self.py_type.__module__ == "builtins":
519 return self.py_type.__name__
520 return f"{self.py_type.__module__}.{self.py_type.__qualname__}"
522 def is_value_conformant(self, value: Any) -> bool:
523 """Compare the supplied value against the expected type as defined
524 for this property.
526 Parameters
527 ----------
528 value : `object`
529 Value of the property to validate. Can be `None`.
531 Returns
532 -------
533 is_ok : `bool`
534 `True` if the value is of an appropriate type.
536 Notes
537 -----
538 Currently only the type of the property is validated. There is no
539 attempt to check bounds or determine that a Quantity is compatible
540 with the property.
541 """
542 if value is None:
543 return True
545 return isinstance(value, self.py_type)
548# This dict defines all the core properties of an ObservationInfo.
549# The PropertyDefinition is keyed by the property name.
550# The doc string is used to define the Pydantic model.
551# The py_type/doc are used to create the translator methods.
552# The optional callables are used to convert types for serialization and
553# validation.
554PROPERTIES = {
555 "telescope": PropertyDefinition("Full name of the telescope.", str),
556 "instrument": PropertyDefinition("The instrument used to observe the exposure.", str),
557 "location": PropertyDefinition(
558 "Location of the observatory.",
559 astropy.coordinates.EarthLocation,
560 earthlocation_to_simple,
561 simple_to_earthlocation,
562 ),
563 "exposure_id": PropertyDefinition(
564 "Unique (with instrument) integer identifier for this observation.", int
565 ),
566 "visit_id": PropertyDefinition(
567 """ID of the Visit this Exposure is associated with.
569Science observations should essentially always be
570associated with a visit, but calibration observations
571may not be.""",
572 int,
573 ),
574 "physical_filter": PropertyDefinition("The bandpass filter used for this observation.", str),
575 "datetime_begin": PropertyDefinition(
576 "Time of the start of the observation.",
577 astropy.time.Time,
578 datetime_to_simple,
579 simple_to_datetime,
580 ),
581 "datetime_end": PropertyDefinition(
582 "Time of the end of the observation.",
583 astropy.time.Time,
584 datetime_to_simple,
585 simple_to_datetime,
586 ),
587 "exposure_time": PropertyDefinition(
588 "Actual duration of the exposure (seconds).",
589 astropy.units.Quantity,
590 exptime_to_simple,
591 simple_to_exptime,
592 ),
593 "exposure_time_requested": PropertyDefinition(
594 "Requested duration of the exposure (seconds).",
595 astropy.units.Quantity,
596 exptime_to_simple,
597 simple_to_exptime,
598 ),
599 "dark_time": PropertyDefinition(
600 "Duration of the exposure with shutter closed (seconds).",
601 astropy.units.Quantity,
602 exptime_to_simple,
603 simple_to_exptime,
604 ),
605 "boresight_airmass": PropertyDefinition("Airmass of the boresight of the telescope.", float),
606 "boresight_rotation_angle": PropertyDefinition(
607 "Angle of the instrument in boresight_rotation_coord frame.",
608 astropy.coordinates.Angle,
609 angle_to_simple,
610 simple_to_angle,
611 ),
612 "boresight_rotation_coord": PropertyDefinition(
613 "Coordinate frame of the instrument rotation angle (options: sky, unknown).",
614 str,
615 ),
616 "detector_num": PropertyDefinition("Unique (for instrument) integer identifier for the sensor.", int),
617 "detector_name": PropertyDefinition(
618 "Name of the detector within the instrument (might not be unique if there are detector groups).",
619 str,
620 ),
621 "detector_unique_name": PropertyDefinition(
622 (
623 "Unique name of the detector within the focal plane, generally combining detector_group with "
624 "detector_name."
625 ),
626 str,
627 ),
628 "detector_serial": PropertyDefinition("Serial number/string associated with this detector.", str),
629 "detector_group": PropertyDefinition(
630 "Collection name of which this detector is a part. Can be None if there are no detector groupings.",
631 str,
632 ),
633 "detector_exposure_id": PropertyDefinition(
634 "Unique integer identifier for this detector in this exposure.",
635 int,
636 ),
637 "focus_z": PropertyDefinition(
638 "Defocal distance.",
639 astropy.units.Quantity,
640 focusz_to_simple,
641 simple_to_focusz,
642 ),
643 "object": PropertyDefinition("Object of interest or field name.", str),
644 "temperature": PropertyDefinition(
645 "Temperature outside the dome.",
646 astropy.units.Quantity,
647 temperature_to_simple,
648 simple_to_temperature,
649 ),
650 "pressure": PropertyDefinition(
651 "Atmospheric pressure outside the dome.",
652 astropy.units.Quantity,
653 pressure_to_simple,
654 simple_to_pressure,
655 ),
656 "relative_humidity": PropertyDefinition("Relative humidity outside the dome.", float),
657 "tracking_radec": PropertyDefinition(
658 "Requested RA/Dec to track.",
659 astropy.coordinates.SkyCoord,
660 skycoord_to_simple,
661 simple_to_skycoord,
662 ),
663 "altaz_begin": PropertyDefinition(
664 "Telescope boresight azimuth and elevation at start of observation.",
665 astropy.coordinates.AltAz,
666 altaz_to_simple,
667 simple_to_altaz,
668 ),
669 "altaz_end": PropertyDefinition(
670 "Telescope boresight azimuth and elevation at end of observation.",
671 astropy.coordinates.AltAz,
672 altaz_to_simple,
673 simple_to_altaz,
674 ),
675 "science_program": PropertyDefinition("Observing program (survey or proposal) identifier.", str),
676 "observation_type": PropertyDefinition(
677 "Type of observation (currently: science, dark, flat, bias, focus).",
678 str,
679 ),
680 "observation_id": PropertyDefinition(
681 "Label uniquely identifying this observation (can be related to 'exposure_id').",
682 str,
683 ),
684 "observation_reason": PropertyDefinition(
685 "Reason this observation was taken, or its purpose ('science' and 'calibration' are common values)",
686 str,
687 ),
688 "exposure_group": PropertyDefinition(
689 "Label to use to associate this exposure with others (can be related to 'exposure_id').",
690 str,
691 ),
692 "observing_day": PropertyDefinition(
693 "Integer in YYYYMMDD format corresponding to the day of observation.", int
694 ),
695 "observing_day_offset": PropertyDefinition(
696 (
697 "Offset to subtract from an observation date when calculating the observing day. "
698 "Conversely, the offset to add to an observing day when calculating the time span of a day."
699 ),
700 astropy.time.TimeDelta,
701 timedelta_to_simple,
702 simple_to_timedelta,
703 ),
704 "observation_counter": PropertyDefinition(
705 (
706 "Counter of this observation. Can be counter within observing_day or a global counter. "
707 "Likely to be observatory specific."
708 ),
709 int,
710 ),
711 "has_simulated_content": PropertyDefinition(
712 "Boolean indicating whether any part of this observation was simulated.", bool, None, None
713 ),
714 "group_counter_start": PropertyDefinition(
715 "Observation counter for the start of the exposure group."
716 "Depending on the instrument the relevant group may be "
717 "visit_id or exposure_group.",
718 int,
719 None,
720 None,
721 ),
722 "group_counter_end": PropertyDefinition(
723 "Observation counter for the end of the exposure group. "
724 "Depending on the instrument the relevant group may be "
725 "visit_id or exposure_group.",
726 int,
727 None,
728 None,
729 ),
730 "can_see_sky": PropertyDefinition(
731 "True if the observation is looking at sky, False if it is definitely"
732 " not looking at the sky. None indicates that it is not known whether"
733 " sky could be seen.",
734 bool,
735 ),
736}