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-14 19:06 +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. 

11 

12from __future__ import annotations 

13 

14__all__ = ("obs_info_from_legacy",) 

15 

16import logging 

17from typing import TYPE_CHECKING, Any 

18 

19import astropy.units as u 

20from astro_metadata_translator import ObservationInfo, VisitInfoTranslator 

21from astropy.coordinates import AltAz 

22 

23_LOG = logging.getLogger("lsst.images") 

24 

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] 

34 

35 

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. 

48 

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``. 

70 

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 

78 

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 ) 

95 

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