Coverage for python/lsst/images/_obs_info_from_legacy.py: 56%
54 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-13 09:31 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-13 09:31 +0000
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT 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.
12from __future__ import annotations
14__all__ = ("obs_info_from_legacy",)
16import logging
17from typing import TYPE_CHECKING, Any
19import astropy.units as u
20from astro_metadata_translator import ObservationInfo, VisitInfoTranslator
21from astropy.coordinates import AltAz
23_LOG = logging.getLogger("lsst.images")
25if TYPE_CHECKING:
26 try:
27 from lsst.afw.cameraGeom import Detector as LegacyDetector
28 from lsst.afw.image import VisitInfo as LegacyVisitInfo
29 from lsst.daf.butler import DimensionRecord
30 except ImportError:
31 type LegacyDetector = Any # type: ignore[no-redef]
32 type LegacyVisitInfo = Any # type: ignore[no-redef]
33 type DimensionRecord = Any # type: ignore[no-redef]
36def obs_info_from_legacy(
37 visit_info: LegacyVisitInfo,
38 exposure_record: DimensionRecord | None,
39 detector: LegacyDetector,
40 *,
41 detector_exposure_id: int,
42 telescope: str | None = None,
43 physical_filter: str | None = None,
44) -> ObservationInfo:
45 """Reconstruct as much of an observation info struct as possible from
46 what's available from a legacy `lsst.afw.image.Exposure`, an ``exposure``
47 dimension record, and the camera geometry.
49 Parameters
50 ----------
51 visit_info
52 The legacy visit info struct.
53 exposure_record
54 The ``exposure`` `~lsst.daf.butler.DimensionRecord`, or `None` to
55 convert without one. Without a record, fields it would authoritatively
56 supply are set to `None` and a warning is logged.
57 detector
58 The legacy detector object; the source for detector identifiers.
59 detector_exposure_id
60 The combination detector + exposure ID.
61 telescope
62 Value of the FITS ``TELESCOP`` card, if available from the legacy
63 metadata. `~lsst.afw.image.VisitInfo` carries no telescope
64 information, so without this the telescope is reported as
65 ``"Unknown"``.
66 physical_filter
67 Full name of the physical bandpass filter (the ``physicalLabel`` of
68 the legacy exposure's `~lsst.afw.image.FilterLabel`), if available.
69 Only used when there is no ``exposure_record``.
71 Returns
72 -------
73 obs_info : `~astro_metadata_translator.ObservationInfo`
74 The reconstructed observation metadata.
75 """
76 from lsst.afw.image import setVisitInfoMetadata
77 from lsst.daf.base import PropertyList
79 if exposure_record is not None: 79 ↛ 91line 79 didn't jump to line 91 because the condition on line 79 was always true
80 if (label := visit_info.getInstrumentLabel()) and label != exposure_record.instrument: 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true
81 raise ValueError(
82 f"Instrument label {label!r} from VisitInfo does not match instrument "
83 f"{exposure_record.instrument!r} of the given exposure record."
84 )
85 if (visit_id := visit_info.getId()) != exposure_record.id: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true
86 raise ValueError(
87 f"Exposure ID {visit_id} from VisitInfo does not match exposure ID "
88 f"{exposure_record.id} of the given exposure record."
89 )
90 else:
91 _LOG.warning(
92 "Converting without an exposure dimension record: ObservationInfo "
93 "fields the record would supply are set to None."
94 )
96 # Start by exporting the VisitInfo to FITS header cards and translating
97 # those. This populates a lot of fields correctly, a lot of fields not at
98 # all, and some *incorrectly*, at least as compared to the original
99 # ObservationInfo, because VisitInfoTranslator tries too hard to
100 # reconstruct information that has been lost.
101 pl = PropertyList()
102 setVisitInfoMetadata(pl, visit_info)
103 if telescope is not None:
104 # TELESCOP is a standard FITS card that VisitInfo knows nothing about,
105 # but VisitInfoTranslator uses it to populate the telescope field.
106 pl.add("TELESCOP", telescope)
107 obs_info = ObservationInfo.from_header(pl, translator_class=VisitInfoTranslator, quiet=True)
108 with obs_info.edit_copy() as obs_info:
109 obs_info.detector_exposure_id = detector_exposure_id
110 if exposure_record is not None: 110 ↛ 142line 110 didn't jump to line 142 because the condition on line 110 was always true
111 # Since the dimension record is created directly from the original
112 # ObservationInfo, anything it actually holds is authoritative.
113 obs_info.datetime_begin = exposure_record.timespan.begin
114 obs_info.datetime_end = exposure_record.timespan.end
115 obs_info.observing_day = exposure_record.day_obs
116 obs_info.observation_id = exposure_record.obs_id
117 obs_info.exposure_group = exposure_record.group
118 obs_info.physical_filter = exposure_record.physical_filter
119 # The record's exposure_time is the requested exposure time;
120 # the VisitInfo translator's exposure_time is the actual
121 # shutter-open time.
122 obs_info.exposure_time_requested = (
123 None if exposure_record.exposure_time is None else exposure_record.exposure_time * u.s
124 )
125 obs_info.observation_counter = exposure_record.seq_num
126 obs_info.group_counter_start = exposure_record.seq_start
127 obs_info.group_counter_end = exposure_record.seq_end
128 obs_info.can_see_sky = exposure_record.can_see_sky
129 if (azimuth := exposure_record.azimuth) is not None and ( 129 ↛ 154line 129 didn't jump to line 154 because the condition on line 129 was always true
130 zenith_angle := exposure_record.zenith_angle
131 ) is not None:
132 # The record's azimuth/zenith_angle were ingested from
133 # obsInfo.altaz_begin (begin-of-exposure). The VisitInfo stores
134 # mid-exposure az/alt, and neither stores altaz_end.
135 obs_info.altaz_begin = AltAz(
136 az=azimuth * u.deg,
137 alt=(90.0 - zenith_angle) * u.deg,
138 obstime=exposure_record.timespan.begin,
139 location=obs_info.location,
140 )
141 else:
142 obs_info.datetime_begin = None
143 obs_info.datetime_end = None
144 obs_info.observing_day = None
145 obs_info.observation_id = None
146 obs_info.exposure_group = None
147 obs_info.physical_filter = physical_filter
148 obs_info.exposure_time_requested = None
149 obs_info.observation_counter = None
150 obs_info.group_counter_start = None
151 obs_info.group_counter_end = None
152 obs_info.can_see_sky = None
153 obs_info.altaz_begin = None
154 obs_info.detector_num = detector.getId()
155 obs_info.detector_unique_name = detector.getName()
156 obs_info.detector_serial = detector.getSerial()
157 return obs_info