Coverage for python/lsst/obs/lsst/translators/ts8.py: 36%
115 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-26 17:39 +0000
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-26 17:39 +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 }
55 _trivial_map = {
56 "science_program": "RUNNUM",
57 "exposure_time": ("EXPTIME", dict(unit=u.s)),
58 }
60 cameraPolicyFile = "policy/ts8.yaml"
62 _ROLLOVER_TIME = TimeDelta(8*60*60, scale="tai", format="sec")
63 """Time delta for the definition of a Rubin Test Stand start of day."""
65 @classmethod
66 def can_translate(cls, header, filename=None):
67 """Indicate whether this translation class can translate the
68 supplied header.
70 There is no ``INSTRUME`` header in TS8 data. Instead we use
71 the ``TSTAND`` header. We also look at the file name to see if
72 it starts with "ts8-".
74 Older data has no ``TSTAND`` header so we must use a combination
75 of headers.
77 Parameters
78 ----------
79 header : `dict`-like
80 Header to convert to standardized form.
81 filename : `str`, optional
82 Name of file being translated.
84 Returns
85 -------
86 can : `bool`
87 `True` if the header is recognized by this class. `False`
88 otherwise.
89 """
90 can = cls.can_translate_with_options(header, {"TSTAND": "TS8"}, filename=filename)
91 if can:
92 return True
94 if "LSST_NUM" in header and "REBNAME" in header and \
95 "CONTNUM" in header and \
96 header["CONTNUM"] in ("000018910e0c", "000018ee33b7", "000018ee0f35", "000018ee3b40",
97 "00001891fcc7", "000018edfd65", "0000123b5ba8", "000018911b05",
98 "00001891fa3e", "000018910d7f", "000018ed9f12", "000018edf4a7",
99 "000018ee34e6", "000018ef1464", "000018eda120", "000018edf8a2",
100 "000018ef3819", "000018ed9486", "000018ee02c8", "000018edfb24",
101 "000018ee34c0", "000018edfb51", "0000123b51d1", "0000123b5862",
102 "0000123b8ca9", "0000189208fa", "0000189111af", "0000189126e1",
103 "000018ee0618", "000018ee3b78", "000018ef1534"):
104 return True
106 return False
108 @classmethod
109 def fix_header(cls, header, instrument, obsid, filename=None):
110 """Fix TS8 headers.
112 Notes
113 -----
114 See `~astro_metadata_translator.fix_header` for details of the general
115 process.
116 """
117 modified = False
119 # Calculate the standard label to use for log messages
120 log_label = cls._construct_log_prefix(obsid, filename)
122 if header.get("DATE-OBS", "OBS") == header.get("DATE-TRG", "TRG"):
123 log.warning("%s: DATE-OBS detected referring to end of observation.", log_label)
124 if "DATE-END" not in header:
125 header["DATE-END"] = header["DATE-OBS"]
126 header["MJD-END"] = header["MJD-OBS"]
128 # Time system used to be UTC and at some point became TAI.
129 # Need to include the transition date and update the TIMESYS
130 # header.
131 timesys = header.get("TIMESYS", "utc").lower()
133 # Need to subtract exposure time from DATE-OBS.
134 date_obs = None
135 for (key, format) in (("MJD-OBS", "mjd"), ("DATE-OBS", "isot")):
136 if date_val := header.get(key):
137 date_obs = Time(date_val, format=format, scale=timesys)
138 break
140 if date_obs:
141 # The historical exposure ID calculation requires that we
142 # have access to the unmodified DATE-OBS value.
143 header[_UNMODIFIED_DATE_OBS_HEADER] = header["DATE-OBS"]
145 exptime = TimeDelta(header["EXPTIME"]*u.s, scale="tai")
146 date_obs = date_obs - exptime
147 header["MJD-OBS"] = float(date_obs.mjd)
148 header["DATE-OBS"] = date_obs.isot
149 header["DATE-BEG"] = header["DATE-OBS"]
150 header["MJD-BEG"] = header["MJD-OBS"]
152 modified = True
153 else:
154 # This should never happen because DATE-OBS is already present.
155 log.warning("%s: Unexpectedly failed to extract date from DATE-OBS/MJD-OBS", log_label)
157 return modified
159 @classmethod
160 def compute_detector_exposure_id(cls, exposure_id, detector_num):
161 # Docstring inherited from LsstBaseTranslator.
162 return compute_detector_exposure_id_generic(exposure_id, detector_num, max_num=cls.DETECTOR_MAX)
164 @classmethod
165 def max_exposure_id(cls):
166 """The maximum exposure ID expected from this instrument.
168 The TS8 implementation is non-standard because TS8 data can create
169 two different forms of exposure_id based on the date but we need
170 the largest form to be the one returned.
172 Returns
173 -------
174 max_exposure_id : `int`
175 The maximum value.
176 """
177 max_date = "2050-12-31T23:59.999"
178 return int(re.sub(r"\D", "", max_date[:21]))
180 @cache_translation
181 def to_detector_name(self):
182 # Docstring will be inherited. Property defined in properties.py
183 serial = self.to_detector_serial()
184 detector_info = self.compute_detector_info_from_serial(serial)
185 return detector_info[1]
187 def to_detector_group(self):
188 """Returns the name of the raft.
190 Extracted from RAFTNAME header.
192 Raftname should be of the form: 'LCA-11021_RTM-011-Dev' and
193 the resulting name will have the form "RTM-NNN".
195 Returns
196 -------
197 name : `str`
198 Name of raft.
199 """
200 raft_name = self._header["RAFTNAME"]
201 self._used_these_cards("RAFTNAME")
202 match = re.search(r"(RTM-\d\d\d)", raft_name)
203 if match:
204 return match.group(0)
205 raise ValueError(f"{self._log_prefix}: RAFTNAME has unexpected form of '{raft_name}'")
207 @cache_translation
208 def to_detector_serial(self):
209 """Returns the serial number of the detector.
211 Returns
212 -------
213 serial : `str`
214 LSST assigned serial number.
216 Notes
217 -----
218 This is the LSST assigned serial number (``LSST_NUM``), and not
219 the manufacturer's serial number (``CCD_SERN``).
220 """
221 serial = self._header["LSST_NUM"]
222 self._used_these_cards("LSST_NUM")
224 # this seems to be appended more or less at random and should be
225 # removed.
226 serial = re.sub("-Dev$", "", serial)
227 return serial
229 @cache_translation
230 def to_physical_filter(self):
231 """Return the filter name.
233 Uses the FILTPOS header for older TS8 data. Newer data can use
234 the base class implementation.
236 Returns
237 -------
238 filter : `str`
239 The filter name. Returns "NONE" if no filter can be determined.
241 Notes
242 -----
243 The FILTPOS handling is retained for backwards compatibility.
244 """
246 default = "unknown"
247 try:
248 filter_pos = self._header["FILTPOS"]
249 self._used_these_cards("FILTPOS")
250 except KeyError:
251 # TS8 data from 2023-05-09 and later should be following
252 # DM-38882 conventions.
253 physical_filter = super().to_physical_filter()
254 # Some TS8 taken prior to 2023-05-09 have the string
255 # 'unspecified' as the FILTER keyword and don't really
256 # follow any established convention.
257 if 'unspecified' in physical_filter:
258 return default
259 return physical_filter
261 try:
262 return {
263 2: 'g',
264 3: 'r',
265 4: 'i',
266 5: 'z',
267 6: 'y',
268 }[filter_pos]
269 except KeyError:
270 log.warning("%s: Unknown filter position (assuming %s): %d",
271 self._log_prefix, default, filter_pos)
272 return default
274 @cache_translation
275 def to_exposure_id(self):
276 """Generate a unique exposure ID number
278 Modern TS8 data conforms to standard LSSTCam OBSID, using the "C"
279 controller variant (all TS8 uses "C" controller). Due to existing
280 ingests, data taken before 2023-05-25 must use the old style
281 timestamp ID.
283 For older data SEQNUM is not unique for a given day in TS8 data
284 so instead we convert the ISO date of observation directly to an
285 integer.
287 Returns
288 -------
289 exposure_id : `int`
290 Unique exposure number.
291 """
292 begin = self.to_datetime_begin()
294 if begin > _EXPOSURE_ID_DATE_CHANGE:
295 obsid = self.to_observation_id()
296 if obsid.startswith("TS_C_"):
297 return super().to_exposure_id()
299 iso = self._header.get(_UNMODIFIED_DATE_OBS_HEADER, self._header["DATE-OBS"])
300 self._used_these_cards("DATE-OBS")
302 # There is worry that seconds are too coarse so use 10th of second
303 # and read the first 21 characters.
304 exposure_id = re.sub(r"\D", "", iso[:21])
305 return int(exposure_id)
307 # For now assume that visit IDs and exposure IDs are identical
308 to_visit_id = to_exposure_id
310 @cache_translation
311 def to_observation_id(self):
312 # Docstring will be inherited. Property defined in properties.py
313 if self.is_key_ok("OBSID"):
314 observation_id = self._header["OBSID"]
315 self._used_these_cards("OBSID")
316 return observation_id
317 filename = self._header["FILENAME"]
318 self._used_these_cards("FILENAME")
319 return filename[:filename.rfind(".")]