Coverage for python / lsst / obs / lsst / translators / lsstCam.py: 19%
135 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-18 09:09 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-18 09:09 +0000
1# This file is currently part of obs_lsst but is written to allow it
2# to be migrated to the astro_metadata_translator package at a later date.
3#
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the LICENSE file in this directory for details of code ownership.
7#
8# Use of this source code is governed by a 3-clause BSD-style
9# license that can be found in the LICENSE file.
11"""Metadata translation code for the main LSST Camera"""
13__all__ = ("LsstCamTranslator", )
15import logging
17import pytz
18import astropy.time
19import astropy.units as u
20from astropy.coordinates import Angle
22from astro_metadata_translator import cache_translation
23from astro_metadata_translator.translators.helpers import is_non_science
25from .lsst import LsstBaseTranslator, SIMONYI_TELESCOPE
27log = logging.getLogger(__name__)
29# Normalized name of the LSST Camera
30LSST_CAM = "LSSTCam"
33def is_non_science_or_lab(self):
34 """Pseudo method to determine whether this is a lab or non-science
35 header.
37 Raises
38 ------
39 KeyError
40 If this is a science observation and on the mountain.
41 """
42 # Return without raising if this is not a science observation
43 # since the defaults are fine.
44 try:
45 # This will raise if it is a science observation.
46 is_non_science(self)
47 return
48 except KeyError:
49 pass
51 # We are still in the lab, return and use the default.
52 if not self._is_on_mountain():
53 return
55 # This is a science observation on the mountain so we should not
56 # use defaults.
57 raise KeyError(f"{self._log_prefix}: Required key is missing and this is a mountain science observation")
60class LsstCamTranslator(LsstBaseTranslator):
61 """Metadata translation for the main LSST Camera."""
63 name = LSST_CAM
64 """Name of this translation class"""
66 supported_instrument = LSST_CAM
67 """Supports the lsstCam instrument."""
69 _const_map = {
70 "instrument": LSST_CAM,
71 "telescope": SIMONYI_TELESCOPE,
72 }
74 _trivial_map = {
75 "detector_group": "RAFTBAY",
76 "detector_name": "CCDSLOT",
77 "observation_id": "OBSID",
78 "exposure_time_requested": ("EXPTIME", dict(unit=u.s)),
79 "detector_serial": "LSST_NUM",
80 "object": ("OBJECT", dict(default="UNKNOWN")),
81 "science_program": (["PROGRAM", "RUNNUM"], dict(default="unknown")),
82 "boresight_rotation_angle": (["ROTPA", "ROTANGLE"], dict(checker=is_non_science_or_lab,
83 default=0.0, unit=u.deg)),
84 }
86 # Use Imsim raft definitions until a true lsstCam definition exists
87 cameraPolicyFile = "policy/lsstCam.yaml"
89 # Date (YYYYMM) the camera changes from using lab day_offset (Pacific time)
90 # to summit day_offset (12 hours).
91 _CAMERA_SHIP_DATE = 202405
93 # Date we know camera is in Chile and potentially taking on-sky data.
94 _CAMERA_ON_TELESCOPE_DATE = astropy.time.Time("2025-03-01T00:00", format="isot", scale="utc")
96 # Not allowed to use obstype for can_see_sky fallback.
97 _can_check_obstype_for_can_see_sky = False
99 @classmethod
100 def fix_header(cls, header, instrument, obsid, filename=None):
101 """Fix LSSTCam headers.
103 Notes
104 -----
105 See `~astro_metadata_translator.fix_header` for details of the general
106 process.
107 """
109 modified = False
111 # Calculate the standard label to use for log messages
112 log_label = cls._construct_log_prefix(obsid, filename)
114 if "FILTER" not in header and header.get("FILTER2") is not None:
115 ccdslot = header.get("CCDSLOT", "unknown")
116 raftbay = header.get("RAFTBAY", "unknown")
118 log.warning("%s %s_%s: No FILTER key found but FILTER2=\"%s\" (removed)",
119 log_label, raftbay, ccdslot, header["FILTER2"])
120 header["FILTER2"] = None
121 modified = True
123 day_obs = header.get("DAYOBS")
124 i_day_obs = int(day_obs) if day_obs else None
125 if (
126 day_obs in ("20231107", "20231108", "20241015", "20241016")
127 and header["FILTER"] == "ph_05"
128 ):
129 header["FILTER"] = "ph_5"
130 modified = True
132 # For first ~ week of observing the ROTPA in the header was the ComCam
133 # value and needed to be adjusted by 90 degrees to match LSSTCam.
134 # Fixed for day_obs 20250422.
135 rotpa_fixed_on_day = 20250422
136 if i_day_obs and i_day_obs > 20250301 and i_day_obs < rotpa_fixed_on_day:
137 if rotpa := header.get("ROTPA"):
138 header["ROTPA"] = rotpa - 90.0
139 modified = True
140 log.debug("%s: Correcting ROTPA by -90.0", log_label)
142 # For half a night the ROTPA was out by 180 degrees.
143 if i_day_obs == 20250422:
144 seq_num = header["SEQNUM"]
145 if seq_num < 251 and (rotpa := header.get("ROTPA")):
146 rotpa_corrected = rotpa - 180.0
147 angle = Angle(rotpa_corrected * u.deg)
148 header["ROTPA"] = float(angle.wrap_at("180d").value)
149 modified = True
150 log.debug(
151 "%s: Correcting ROTPA of %f by 180 degrees to %f", log_label, rotpa, header["ROTPA"]
152 )
154 # For the night of 20250518 the dome was closed but many
155 # calibs had the wrong header because of dome/TMA faults.
156 if i_day_obs == 20250518:
157 if header["VIGN_MIN"] != "FULLY":
158 header["VIGN_MIN"] = "FULLY"
159 modified = True
160 log.debug("%s: Correcting VIGN_MIN to FULLY", log_label)
162 # DM-51847: For several nights in July, the dome was closed but many
163 # flats had the wrong header because of a dome CSC regression
164 fix_ranges = {
165 20250703: [(743, 745)],
166 20250704: [(832, 834)],
167 20250705: [(1, 736)],
168 20250707: [(784, 823), (744, 783), (864, 903), (904, 943)],
169 20250714: [(258, 781)],
170 20250715: [(205, 1218)],
171 20250819: [(4, 1052)], # DM-52249
172 }
173 if i_day_obs in fix_ranges:
174 i_seq_num = header["SEQNUM"]
175 for seq_range in fix_ranges[i_day_obs]:
176 if (seq_range[0] <= i_seq_num <= seq_range[1]
177 and header["VIGN_MIN"] != "FULLY"):
178 header["VIGN_MIN"] = "FULLY"
179 modified = True
180 log.debug("%s: Correcting VIGN_MIN to FULLY", log_label)
182 # DM-52711: The filter was incorrect for the start of the night.
183 if i_day_obs == 20250609:
184 i_seq_num = header["SEQNUM"]
185 if i_seq_num >= 76 and i_seq_num <= 578:
186 header["FILTER"] = "z_20"
187 header["FILTBAND"] = "z"
188 header["FILTPOS"] = 201.0
189 header["FILTSLOT"] = 4
190 modified = True
191 log.debug("%s: Correcting filter to z", log_label)
193 # DM-53949: Incorrect block number.
194 if i_day_obs in [
195 20250415, 20250416, 20250423, 20250417, 20250418, 20250421,
196 20250424, 20250425, 20250504, 20250503, 20250512, 20250522,
197 20250524, 20250826, 20250827, 20250828
198 ]:
199 if header["PROGRAM"] == "BLOCK-417":
200 header["PROGRAM"] = "BLOCK-T417"
201 modified = True
202 log.debug("%s: Correcting BLOCK-417 to BLOCK-T417", log_label)
204 if i_day_obs == 20260315:
205 i_seq_num = header["SEQNUM"]
206 if i_seq_num >=49 and i_seq_num <= 109:
207 header["FILTER"] = "i_39"
208 header["FILTBAND"] = "i"
209 header["FILTPOS"] = 304.0
210 header["FILTSLOT"] = 1
211 modified = True
213 return modified
215 @classmethod
216 def can_translate(cls, header, filename=None):
217 """Indicate whether this translation class can translate the
218 supplied header.
220 Parameters
221 ----------
222 header : `dict`-like
223 Header to convert to standardized form.
224 filename : `str`, optional
225 Name of file being translated.
227 Returns
228 -------
229 can : `bool`
230 `True` if the header is recognized by this class. `False`
231 otherwise.
232 """
233 # INSTRUME keyword might be of two types
234 if "INSTRUME" in header:
235 instrume = header["INSTRUME"].lower()
236 if instrume == cls.supported_instrument.lower():
237 return True
238 return False
240 @cache_translation
241 def to_physical_filter(self):
242 """Calculate the physical filter name.
244 Returns
245 -------
246 filter : `str`
247 Name of filter. Can be a combination of FILTER, FILTER1, and
248 FILTER2 headers joined by a "~". Trailing "~empty" components
249 are stripped.
250 Returns "unknown" if no filter is declared.
251 """
252 joined = super().to_physical_filter()
253 while joined.endswith("~empty"):
254 joined = joined.removesuffix("~empty")
256 return joined
258 def _is_on_mountain(self):
259 """Indicate whether these data are coming from the instrument
260 installed on the mountain.
262 Returns
263 -------
264 is : `bool`
265 `True` if instrument is on the mountain.
267 Notes
268 -----
269 LSSTCam was installed on the Simonyi Telescope in March 2025.
270 This flag is true even if the telescope is on the test stand or a
271 simulated file has come from the BTS.
272 """
273 # phosim is always on mountain.
274 if self._get_controller_code() == "H":
275 return True
277 date = self.to_datetime_begin()
278 if date > self._CAMERA_ON_TELESCOPE_DATE:
279 return True
281 return False
283 @classmethod
284 def observing_date_to_offset(cls, observing_date: astropy.time.Time) -> astropy.time.TimeDelta | None:
285 """Return the offset to use when calculating the observing day.
287 Parameters
288 ----------
289 observing_date : `astropy.time.Time`
290 The date of the observation. Unused.
292 Returns
293 -------
294 offset : `astropy.time.TimeDelta`
295 The offset to apply. During lab testing the offset is Pacific
296 Time which can mean UTC-7 or UTC-8 depending on daylight savings.
297 In Chile the offset is always UTC-12.
298 """
299 # Timezone calculations are slow. Only do this if the instrument
300 # is in the lab.
301 if int(observing_date.strftime("%Y%m")) >= cls._CAMERA_SHIP_DATE:
302 return cls._ROLLOVER_TIME # 12 hours in base class
304 # Convert the date to a datetime UTC.
305 pacific_tz = pytz.timezone("US/Pacific")
306 pacific_time = observing_date.utc.to_datetime(timezone=pacific_tz)
308 # We need the offset to go the other way.
309 offset = pacific_time.utcoffset() * -1
310 return astropy.time.TimeDelta(offset)
312 @cache_translation
313 def to_exposure_time(self):
314 # Use shutter time if greater than 0 (for a dark the shutter never
315 # opens).
316 if self.is_key_ok("SHUTTIME"):
317 if (shuttime := self._header["SHUTTIME"]) > 0.0:
318 return shuttime * u.s
319 return self.to_exposure_time_requested()
321 @cache_translation
322 def to_can_see_sky(self) -> bool | None:
323 if not self._is_on_mountain():
324 # Lab data cannot see sky.
325 return False
326 return super().to_can_see_sky()