Coverage for python/lsst/obs/lsst/translators/ts8.py: 27%
106 statements
« prev ^ index » next coverage.py v7.2.6, created at 2023-05-24 09:52 +0000
« prev ^ index » next coverage.py v7.2.6, created at 2023-05-24 09:52 +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__)
28class LsstTS8Translator(LsstBaseTranslator):
29 """Metadata translator for LSST Test Stand 8 data.
30 """
32 name = "LSST-TS8"
33 """Name of this translation class"""
35 _const_map = {
36 # TS8 is not attached to a telescope so many translations are null.
37 "instrument": "LSST-TS8",
38 "telescope": None,
39 "location": None,
40 "boresight_rotation_coord": None,
41 "boresight_rotation_angle": None,
42 "boresight_airmass": None,
43 "tracking_radec": None,
44 "altaz_begin": None,
45 "object": "UNKNOWN",
46 "relative_humidity": None,
47 "temperature": None,
48 "pressure": None,
49 }
51 _trivial_map = {
52 "science_program": "RUNNUM",
53 "exposure_time": ("EXPTIME", dict(unit=u.s)),
54 }
56 cameraPolicyFile = "policy/ts8.yaml"
58 _ROLLOVER_TIME = TimeDelta(8*60*60, scale="tai", format="sec")
59 """Time delta for the definition of a Rubin Test Stand start of day."""
61 @classmethod
62 def can_translate(cls, header, filename=None):
63 """Indicate whether this translation class can translate the
64 supplied header.
66 There is no ``INSTRUME`` header in TS8 data. Instead we use
67 the ``TSTAND`` header. We also look at the file name to see if
68 it starts with "ts8-".
70 Older data has no ``TSTAND`` header so we must use a combination
71 of headers.
73 Parameters
74 ----------
75 header : `dict`-like
76 Header to convert to standardized form.
77 filename : `str`, optional
78 Name of file being translated.
80 Returns
81 -------
82 can : `bool`
83 `True` if the header is recognized by this class. `False`
84 otherwise.
85 """
86 can = cls.can_translate_with_options(header, {"TSTAND": "TS8"}, filename=filename)
87 if can:
88 return True
90 if "LSST_NUM" in header and "REBNAME" in header and \
91 "CONTNUM" in header and \
92 header["CONTNUM"] in ("000018910e0c", "000018ee33b7", "000018ee0f35", "000018ee3b40",
93 "00001891fcc7", "000018edfd65", "0000123b5ba8", "000018911b05",
94 "00001891fa3e", "000018910d7f", "000018ed9f12", "000018edf4a7",
95 "000018ee34e6", "000018ef1464", "000018eda120", "000018edf8a2",
96 "000018ef3819", "000018ed9486", "000018ee02c8", "000018edfb24",
97 "000018ee34c0", "000018edfb51", "0000123b51d1", "0000123b5862",
98 "0000123b8ca9", "0000189208fa", "0000189111af", "0000189126e1",
99 "000018ee0618", "000018ee3b78", "000018ef1534"):
100 return True
102 return False
104 @classmethod
105 def fix_header(cls, header, instrument, obsid, filename=None):
106 """Fix TS8 headers.
108 Notes
109 -----
110 See `~astro_metadata_translator.fix_header` for details of the general
111 process.
112 """
113 modified = False
115 # Calculate the standard label to use for log messages
116 log_label = cls._construct_log_prefix(obsid, filename)
118 if header.get("DATE-OBS", "OBS") == header.get("DATE-TRG", "TRG"):
119 log.warning("%s: DATE-OBS detected referring to end of observation.", log_label)
120 if "DATE-END" not in header:
121 header["DATE-END"] = header["DATE-OBS"]
122 header["MJD-END"] = header["MJD-OBS"]
124 # Time system used to be UTC and at some point became TAI.
125 # Need to include the transition date and update the TIMESYS
126 # header.
127 timesys = header.get("TIMESYS", "utc").lower()
129 # Need to subtract exposure time from DATE-OBS.
130 date_obs = None
131 for (key, format) in (("MJD-OBS", "mjd"), ("DATE-OBS", "isot")):
132 if date_val := header.get(key):
133 date_obs = Time(date_val, format=format, scale=timesys)
134 break
136 if date_obs:
137 exptime = TimeDelta(header["EXPTIME"]*u.s, scale="tai")
138 date_obs = date_obs - exptime
139 header["MJD-OBS"] = date_obs.mjd
140 header["DATE-OBS"] = date_obs.isot
141 header["DATE-BEG"] = header["DATE-OBS"]
142 header["MJD-BEG"] = header["MJD-OBS"]
144 modified = True
145 else:
146 # This should never happen because DATE-OBS is already present.
147 log.warning("%s: Unexpectedly failed to extract date from DATE-OBS/MJD-OBS", log_label)
149 return modified
151 @classmethod
152 def compute_detector_exposure_id(cls, exposure_id, detector_num):
153 # Docstring inherited from LsstBaseTranslator.
154 return compute_detector_exposure_id_generic(exposure_id, detector_num, max_num=cls.DETECTOR_MAX)
156 @cache_translation
157 def to_detector_name(self):
158 # Docstring will be inherited. Property defined in properties.py
159 serial = self.to_detector_serial()
160 detector_info = self.compute_detector_info_from_serial(serial)
161 return detector_info[1]
163 def to_detector_group(self):
164 """Returns the name of the raft.
166 Extracted from RAFTNAME header.
168 Raftname should be of the form: 'LCA-11021_RTM-011-Dev' and
169 the resulting name will have the form "RTM-NNN".
171 Returns
172 -------
173 name : `str`
174 Name of raft.
175 """
176 raft_name = self._header["RAFTNAME"]
177 self._used_these_cards("RAFTNAME")
178 match = re.search(r"(RTM-\d\d\d)", raft_name)
179 if match:
180 return match.group(0)
181 raise ValueError(f"{self._log_prefix}: RAFTNAME has unexpected form of '{raft_name}'")
183 @cache_translation
184 def to_detector_serial(self):
185 """Returns the serial number of the detector.
187 Returns
188 -------
189 serial : `str`
190 LSST assigned serial number.
192 Notes
193 -----
194 This is the LSST assigned serial number (``LSST_NUM``), and not
195 the manufacturer's serial number (``CCD_SERN``).
196 """
197 serial = self._header["LSST_NUM"]
198 self._used_these_cards("LSST_NUM")
200 # this seems to be appended more or less at random and should be
201 # removed.
202 serial = re.sub("-Dev$", "", serial)
203 return serial
205 @cache_translation
206 def to_physical_filter(self):
207 """Return the filter name.
209 Uses the FILTPOS header for older TS8 data. Newer data can use
210 the base class implementation.
212 Returns
213 -------
214 filter : `str`
215 The filter name. Returns "NONE" if no filter can be determined.
217 Notes
218 -----
219 The FILTPOS handling is retained for backwards compatibility.
220 """
222 default = "unknown"
223 try:
224 filter_pos = self._header["FILTPOS"]
225 self._used_these_cards("FILTPOS")
226 except KeyError:
227 # TS8 data from 2023-05-09 and later should be following
228 # DM-38882 conventions.
229 physical_filter = super().to_physical_filter()
230 # Some TS8 taken prior to 2023-05-09 have the string
231 # 'unspecified' as the FILTER keyword and don't really
232 # follow any established convention.
233 if 'unspecified' in physical_filter:
234 return default
235 return physical_filter
237 try:
238 return {
239 2: 'g',
240 3: 'r',
241 4: 'i',
242 5: 'z',
243 6: 'y',
244 }[filter_pos]
245 except KeyError:
246 log.warning("%s: Unknown filter position (assuming %s): %d",
247 self._log_prefix, default, filter_pos)
248 return default
250 @cache_translation
251 def to_exposure_id(self):
252 """Generate a unique exposure ID number
254 Modern TS8 data conforms to standard LSSTCam OBSID, using the "C"
255 controller variant (all TS8 uses "C" controller).
257 For older data SEQNUM is not unique for a given day in TS8 data
258 so instead we convert the ISO date of observation directly to an
259 integer.
261 Returns
262 -------
263 exposure_id : `int`
264 Unique exposure number.
265 """
266 obsid = self.to_observation_id()
267 if obsid.startswith("TS_C_"):
268 return super().to_exposure_id()
270 iso = self._header["DATE-OBS"]
271 self._used_these_cards("DATE-OBS")
273 # There is worry that seconds are too coarse so use 10th of second
274 # and read the first 21 characters.
275 exposure_id = re.sub(r"\D", "", iso[:21])
276 return int(exposure_id)
278 # For now assume that visit IDs and exposure IDs are identical
279 to_visit_id = to_exposure_id
281 @cache_translation
282 def to_observation_id(self):
283 # Docstring will be inherited. Property defined in properties.py
284 if self.is_key_ok("OBSID"):
285 observation_id = self._header["OBSID"]
286 self._used_these_cards("OBSID")
287 return observation_id
288 filename = self._header["FILENAME"]
289 self._used_these_cards("FILENAME")
290 return filename[:filename.rfind(".")]