Coverage for python / lsst / obs / lsst / translators / ts8.py: 28%
114 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 08:58 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 08:58 +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 LSST TestStand 8 headers"""
13__all__ = ("LsstTS8Translator", )
15import logging
16import re
18import astropy.units as u
19from astropy.time import Time, TimeDelta
21from astro_metadata_translator import cache_translation
23from .lsst import LsstBaseTranslator, compute_detector_exposure_id_generic
25log = logging.getLogger(__name__)
27# First observation with new exposure ID is TS_C_20230524_000906.
28_EXPOSURE_ID_DATE_CHANGE = Time("2023-05-24T23:00:00.0", format="isot", scale="tai")
29_UNMODIFIED_DATE_OBS_HEADER = "HIERARCH LSST-TS8 DATE-OBS"
32class LsstTS8Translator(LsstBaseTranslator):
33 """Metadata translator for LSST Test Stand 8 data.
34 """
36 name = "LSST-TS8"
37 """Name of this translation class"""
39 _const_map = {
40 # TS8 is not attached to a telescope so many translations are null.
41 "instrument": "LSST-TS8",
42 "telescope": None,
43 "location": None,
44 "boresight_rotation_coord": None,
45 "boresight_rotation_angle": None,
46 "boresight_airmass": None,
47 "tracking_radec": None,
48 "altaz_begin": None,
49 "object": "UNKNOWN",
50 "relative_humidity": None,
51 "temperature": None,
52 "pressure": None,
53 "can_see_sky": False,
54 }
56 _trivial_map = {
57 "science_program": "RUNNUM",
58 "exposure_time": ("EXPTIME", dict(unit=u.s)),
59 }
61 cameraPolicyFile = "policy/ts8.yaml"
63 _ROLLOVER_TIME = TimeDelta(8*60*60, scale="tai", format="sec")
64 """Time delta for the definition of a Rubin Test Stand start of day."""
66 @classmethod
67 def can_translate(cls, header, filename=None):
68 """Indicate whether this translation class can translate the
69 supplied header.
71 There is no ``INSTRUME`` header in TS8 data. Instead we use
72 the ``TSTAND`` header. We also look at the file name to see if
73 it starts with "ts8-".
75 Older data has no ``TSTAND`` header so we must use a combination
76 of headers.
78 Parameters
79 ----------
80 header : `dict`-like
81 Header to convert to standardized form.
82 filename : `str`, optional
83 Name of file being translated.
85 Returns
86 -------
87 can : `bool`
88 `True` if the header is recognized by this class. `False`
89 otherwise.
90 """
91 can = cls.can_translate_with_options(header, {"TSTAND": "TS8"}, filename=filename)
92 if can:
93 return True
95 if "LSST_NUM" in header and "REBNAME" in header and \
96 "CONTNUM" in header and \
97 header["CONTNUM"] in ("000018910e0c", "000018ee33b7", "000018ee0f35", "000018ee3b40",
98 "00001891fcc7", "000018edfd65", "0000123b5ba8", "000018911b05",
99 "00001891fa3e", "000018910d7f", "000018ed9f12", "000018edf4a7",
100 "000018ee34e6", "000018ef1464", "000018eda120", "000018edf8a2",
101 "000018ef3819", "000018ed9486", "000018ee02c8", "000018edfb24",
102 "000018ee34c0", "000018edfb51", "0000123b51d1", "0000123b5862",
103 "0000123b8ca9", "0000189208fa", "0000189111af", "0000189126e1",
104 "000018ee0618", "000018ee3b78", "000018ef1534"):
105 return True
107 return False
109 @classmethod
110 def fix_header(cls, header, instrument, obsid, filename=None):
111 """Fix TS8 headers.
113 Notes
114 -----
115 See `~astro_metadata_translator.fix_header` for details of the general
116 process.
117 """
118 modified = False
120 # Calculate the standard label to use for log messages
121 log_label = cls._construct_log_prefix(obsid, filename)
123 if header.get("DATE-OBS", "OBS") == header.get("DATE-TRG", "TRG"):
124 log.warning("%s: DATE-OBS detected referring to end of observation.", log_label)
125 if "DATE-END" not in header:
126 header["DATE-END"] = header["DATE-OBS"]
127 header["MJD-END"] = header["MJD-OBS"]
129 # Time system used to be UTC and at some point became TAI.
130 # Need to include the transition date and update the TIMESYS
131 # header.
132 timesys = header.get("TIMESYS", "utc").lower()
134 # Need to subtract exposure time from DATE-OBS.
135 date_obs = None
136 for (key, format) in (("MJD-OBS", "mjd"), ("DATE-OBS", "isot")):
137 if date_val := header.get(key):
138 date_obs = Time(date_val, format=format, scale=timesys)
139 break
141 if date_obs:
142 # The historical exposure ID calculation requires that we
143 # have access to the unmodified DATE-OBS value.
144 header[_UNMODIFIED_DATE_OBS_HEADER] = header["DATE-OBS"]
146 exptime = TimeDelta(header["EXPTIME"]*u.s, scale="tai")
147 date_obs = date_obs - exptime
148 header["MJD-OBS"] = float(date_obs.mjd)
149 header["DATE-OBS"] = date_obs.isot
150 header["DATE-BEG"] = header["DATE-OBS"]
151 header["MJD-BEG"] = header["MJD-OBS"]
153 modified = True
154 else:
155 # This should never happen because DATE-OBS is already present.
156 log.warning("%s: Unexpectedly failed to extract date from DATE-OBS/MJD-OBS", log_label)
158 return modified
160 @classmethod
161 def compute_detector_exposure_id(cls, exposure_id, detector_num):
162 # Docstring inherited from LsstBaseTranslator.
163 return compute_detector_exposure_id_generic(exposure_id, detector_num, max_num=cls.DETECTOR_MAX)
165 @classmethod
166 def max_exposure_id(cls):
167 """The maximum exposure ID expected from this instrument.
169 The TS8 implementation is non-standard because TS8 data can create
170 two different forms of exposure_id based on the date but we need
171 the largest form to be the one returned.
173 Returns
174 -------
175 max_exposure_id : `int`
176 The maximum value.
177 """
178 max_date = "2050-12-31T23:59.999"
179 return int(re.sub(r"\D", "", max_date[:21]))
181 @cache_translation
182 def to_detector_name(self):
183 # Docstring will be inherited. Property defined in properties.py
184 serial = self.to_detector_serial()
185 detector_info = self.compute_detector_info_from_serial(serial)
186 return detector_info[1]
188 def to_detector_group(self):
189 """Returns the name of the raft.
191 Extracted from RAFTNAME header.
193 Raftname should be of the form: 'LCA-11021_RTM-011-Dev' and
194 the resulting name will have the form "RTM-NNN".
196 Returns
197 -------
198 name : `str`
199 Name of raft.
200 """
201 raft_name = self._header["RAFTNAME"]
202 self._used_these_cards("RAFTNAME")
203 match = re.search(r"(RTM-\d\d\d)", raft_name)
204 if match:
205 return match.group(0)
206 raise ValueError(f"{self._log_prefix}: RAFTNAME has unexpected form of '{raft_name}'")
208 @cache_translation
209 def to_detector_serial(self):
210 """Returns the serial number of the detector.
212 Returns
213 -------
214 serial : `str`
215 LSST assigned serial number.
217 Notes
218 -----
219 This is the LSST assigned serial number (``LSST_NUM``), and not
220 the manufacturer's serial number (``CCD_SERN``).
221 """
222 serial = self._header["LSST_NUM"]
223 self._used_these_cards("LSST_NUM")
225 # this seems to be appended more or less at random and should be
226 # removed.
227 serial = re.sub("-Dev$", "", serial)
228 return serial
230 @cache_translation
231 def to_physical_filter(self):
232 """Return the filter name.
234 Uses the FILTPOS header for older TS8 data. Newer data can use
235 the base class implementation.
237 Returns
238 -------
239 filter : `str`
240 The filter name. Returns "NONE" if no filter can be determined.
242 Notes
243 -----
244 The FILTPOS handling is retained for backwards compatibility.
245 """
247 default = "unknown"
248 try:
249 filter_pos = self._header["FILTPOS"]
250 self._used_these_cards("FILTPOS")
251 except KeyError:
252 # TS8 data from 2023-05-09 and later should be following
253 # DM-38882 conventions.
254 physical_filter = super().to_physical_filter()
255 # Some TS8 taken prior to 2023-05-09 have the string
256 # 'unspecified' as the FILTER keyword and don't really
257 # follow any established convention.
258 if 'unspecified' in physical_filter:
259 return default
260 return physical_filter
262 try:
263 return {
264 2: 'g',
265 3: 'r',
266 4: 'i',
267 5: 'z',
268 6: 'y',
269 }[filter_pos]
270 except KeyError:
271 log.warning("%s: Unknown filter position (assuming %s): %d",
272 self._log_prefix, default, filter_pos)
273 return default
275 @cache_translation
276 def to_exposure_id(self):
277 """Generate a unique exposure ID number
279 Modern TS8 data conforms to standard LSSTCam OBSID, using the "C"
280 controller variant (all TS8 uses "C" controller). Due to existing
281 ingests, data taken before 2023-05-25 must use the old style
282 timestamp ID.
284 For older data SEQNUM is not unique for a given day in TS8 data
285 so instead we convert the ISO date of observation directly to an
286 integer.
288 Returns
289 -------
290 exposure_id : `int`
291 Unique exposure number.
292 """
293 begin = self.to_datetime_begin()
295 if begin > _EXPOSURE_ID_DATE_CHANGE:
296 obsid = self.to_observation_id()
297 if obsid.startswith("TS_C_"):
298 return super().to_exposure_id()
300 iso = self._header.get(_UNMODIFIED_DATE_OBS_HEADER, self._header["DATE-OBS"])
301 self._used_these_cards("DATE-OBS")
303 # There is worry that seconds are too coarse so use 10th of second
304 # and read the first 21 characters.
305 exposure_id = re.sub(r"\D", "", iso[:21])
306 return int(exposure_id)
308 # For now assume that visit IDs and exposure IDs are identical
309 to_visit_id = to_exposure_id
311 @cache_translation
312 def to_observation_id(self):
313 # Docstring will be inherited. Property defined in properties.py
314 if self.is_key_ok("OBSID"):
315 observation_id = self._header["OBSID"]
316 self._used_these_cards("OBSID")
317 return observation_id
318 filename = self._header["FILENAME"]
319 self._used_these_cards("FILENAME")
320 return filename[:filename.rfind(".")]