Coverage for python/lsst/obs/lsst/translators/latiss.py : 13%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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 LATISS headers"""
13__all__ = ("LatissTranslator", )
15import logging
16import math
18import astropy.units as u
19from astropy.time import Time
20from astropy.coordinates import EarthLocation
22from astro_metadata_translator import cache_translation
23from astro_metadata_translator.translators.helpers import is_non_science
24from .lsst import LsstBaseTranslator, FILTER_DELIMITER
26log = logging.getLogger(__name__)
29# AuxTel is not the same place as LSST
30# These coordinates read from Apple Maps
31AUXTEL_LOCATION = EarthLocation.from_geodetic(-70.747698, -30.244728, 2663.0)
33# Date instrument is taking data at telescope
34# Prior to this date many parameters are automatically nulled out
35# since the headers have not historically been reliable
36TSTART = Time("2020-01-01T00:00", format="isot", scale="utc")
38# Define the sensor and group name for AuxTel globally so that it can be used
39# in multiple places. There is no raft but for consistency with other LSST
40# cameras we define one.
41_DETECTOR_GROUP_NAME = "RXX"
42_DETECTOR_NAME = "S00"
44# Date 068 detector was put in LATISS
45DETECTOR_068_DATE = Time("2019-06-24T00:00", format="isot", scale="utc")
47# IMGTYPE header is filled in after this date
48IMGTYPE_OKAY_DATE = Time("2019-11-07T00:00", format="isot", scale="utc")
50# OBJECT IMGTYPE really means ENGTEST until this date
51OBJECT_IS_ENGTEST = Time("2020-01-27T20:00", format="isot", scale="utc")
53# RA and DEC headers are in radians until this date
54RADEC_IS_RADIANS = Time("2020-01-28T22:00", format="isot", scale="utc")
56# RASTART/DECSTART/RAEND/DECEND used wrong telescope location before this
57# 2020-02-01T00:00 we fixed the telescope location, but RASTART is still
58# in mount coordinates, so off by pointing model.
59RASTART_IS_BAD = Time("2020-05-01T00:00", format="isot", scale="utc")
61# DATE-END is not to be trusted before this date
62DATE_END_IS_BAD = Time("2020-02-01T00:00", format="isot", scale="utc")
64# Scaling factor radians to degrees. Keep it simple.
65RAD2DEG = 180.0 / math.pi
68def is_non_science_or_lab(self):
69 """Pseudo method to determine whether this is a lab or non-science
70 header.
72 Raises
73 ------
74 KeyError
75 If this is a science observation and on the mountain.
76 """
77 # Return without raising if this is not a science observation
78 # since the defaults are fine.
79 try:
80 # This will raise if it is a science observation
81 is_non_science(self)
82 return
83 except KeyError:
84 pass
86 # We are still in the lab, return and use the default
87 if not self._is_on_mountain():
88 return
90 # This is a science observation on the mountain so we should not
91 # use defaults
92 raise KeyError(f"{self._log_prefix}: Required key is missing and this is a mountain science observation")
95class LatissTranslator(LsstBaseTranslator):
96 """Metadata translator for LSST LATISS data from AuxTel.
98 For lab measurements many values are masked out.
99 """
101 name = "LSST_LATISS"
102 """Name of this translation class"""
104 supported_instrument = "LATISS"
105 """Supports the LATISS instrument."""
107 _const_map = {
108 "instrument": "LATISS",
109 "telescope": "Rubin Auxiliary Telescope",
110 "detector_group": _DETECTOR_GROUP_NAME,
111 "detector_num": 0,
112 "detector_name": _DETECTOR_NAME, # Single sensor
113 "science_program": "unknown",
114 "relative_humidity": None,
115 "pressure": None,
116 "temperature": None,
117 }
119 _trivial_map = {
120 "observation_id": (["OBSID", "IMGNAME"], dict(default=None, checker=is_non_science)),
121 "detector_serial": ["LSST_NUM", "DETSER"],
122 "object": ("OBJECT", dict(checker=is_non_science_or_lab, default="UNKNOWN")),
123 "boresight_rotation_angle": (["ROTPA", "ROTANGLE"], dict(checker=is_non_science_or_lab,
124 default=float("nan"), unit=u.deg)),
125 }
127 DETECTOR_GROUP_NAME = _DETECTOR_GROUP_NAME
128 """Fixed name of detector group."""
130 DETECTOR_NAME = _DETECTOR_NAME
131 """Fixed name of single sensor."""
133 DETECTOR_MAX = 0
134 """Maximum number of detectors to use when calculating the
135 detector_exposure_id."""
137 _DEFAULT_LOCATION = AUXTEL_LOCATION
138 """Default telescope location in absence of relevant FITS headers."""
140 @classmethod
141 def can_translate(cls, header, filename=None):
142 """Indicate whether this translation class can translate the
143 supplied header.
145 Parameters
146 ----------
147 header : `dict`-like
148 Header to convert to standardized form.
149 filename : `str`, optional
150 Name of file being translated.
152 Returns
153 -------
154 can : `bool`
155 `True` if the header is recognized by this class. `False`
156 otherwise.
157 """
158 # INSTRUME keyword might be of two types
159 if "INSTRUME" in header:
160 instrume = header["INSTRUME"]
161 for v in ("LSST_ATISS", "LATISS"):
162 if instrume == v:
163 return True
164 # Calibration files strip important headers at the moment so guess
165 if "DETNAME" in header and header["DETNAME"] == "RXX_S00":
166 return True
167 return False
169 @classmethod
170 def fix_header(cls, header, instrument, obsid, filename=None):
171 """Fix an incorrect LATISS header.
173 Parameters
174 ----------
175 header : `dict`
176 The header to update. Updates are in place.
177 instrument : `str`
178 The name of the instrument.
179 obsid : `str`
180 Unique observation identifier associated with this header.
181 Will always be provided.
182 filename : `str`, optional
183 Filename associated with this header. May not be set since headers
184 can be fixed independently of any filename being known.
186 Returns
187 -------
188 modified = `bool`
189 Returns `True` if the header was updated.
191 Notes
192 -----
193 This method does not apply per-obsid corrections. The following
194 corrections are applied:
196 * On June 24th 2019 the detector was changed from ITL-3800C-098
197 to ITL-3800C-068. The header is intended to be correct in the
198 future.
199 * In late 2019 the DATE-OBS and MJD-OBS headers were reporting
200 1970 dates. To correct, the DATE/MJD headers are copied in to
201 replace them and the -END headers are cleared.
202 * Until November 2019 the IMGTYPE was set in the GROUPID header.
203 The value is moved to IMGTYPE.
204 * SHUTTIME is always forced to be `None`.
206 Corrections are reported as debug level log messages.
208 See `~astro_metadata_translator.fix_header` for details of the general
209 process.
210 """
211 modified = False
213 # Calculate the standard label to use for log messages
214 log_label = cls._construct_log_prefix(obsid, filename)
216 if "OBSID" not in header:
217 # Very old data used IMGNAME
218 header["OBSID"] = obsid
219 modified = True
220 # We are reporting the OBSID so no need to repeat it at start
221 # of log message. Use filename if we have it.
222 log_prefix = f"{filename}: " if filename else ""
223 log.debug("%sAssigning OBSID to a value of '%s'", log_prefix, header["OBSID"])
225 if "DAYOBS" not in header:
226 # OBS-NITE could have the value for DAYOBS but it is safer
227 # for older data to set it from the OBSID. Fall back to OBS-NITE
228 # if we have no alternative
229 dayObs = None
230 try:
231 dayObs = obsid.split("_", 3)[2]
232 except (AttributeError, ValueError):
233 # did not split as expected
234 pass
235 if dayObs is None or len(dayObs) != 8:
236 if "OBS-NITE" in header:
237 dayObs = header["OBS-NITE"]
238 log.debug("%s: Setting DAYOBS to '%s' from OBS-NITE header", log_label, dayObs)
239 else:
240 log.debug("%s: Unable to determine DAYOBS from header", log_label)
241 else:
242 log.debug("%s: Setting DAYOBS to '%s' from OBSID", log_label, dayObs)
243 if dayObs:
244 header["DAYOBS"] = dayObs
245 modified = True
247 if "SEQNUM" not in header:
248 try:
249 seqnum = obsid.split("_", 3)[3]
250 except (AttributeError, ValueError):
251 # did not split as expected
252 pass
253 else:
254 header["SEQNUM"] = int(seqnum)
255 modified = True
256 log.debug("%s: Extracting SEQNUM of '%s' from OBSID", log_label, header["SEQNUM"])
258 # The DATE-OBS / MJD-OBS keys can be 1970
259 if "DATE-OBS" in header and header["DATE-OBS"].startswith("1970"):
260 # Copy the headers from the DATE and MJD since we have no other
261 # choice.
262 header["DATE-OBS"] = header["DATE"]
263 header["DATE-BEG"] = header["DATE-OBS"]
264 header["MJD-OBS"] = header["MJD"]
265 header["MJD-BEG"] = header["MJD-OBS"]
267 # And clear the DATE-END and MJD-END -- the translator will use
268 # EXPTIME instead.
269 header["DATE-END"] = None
270 header["MJD-END"] = None
272 log.debug("%s: Forcing 1970 dates to '%s'", log_label, header["DATE"])
273 modified = True
275 # Create a translator since we need the date
276 translator = cls(header)
277 date = translator.to_datetime_begin()
278 if date > DETECTOR_068_DATE:
279 header["LSST_NUM"] = "ITL-3800C-068"
280 log.debug("%s: Forcing detector serial to %s", log_label, header["LSST_NUM"])
281 modified = True
283 if date < DATE_END_IS_BAD:
284 # DATE-END may or may not be in TAI and may or may not be
285 # before DATE-BEG. Simpler to clear it
286 if header.get("DATE-END"):
287 header["DATE-END"] = None
288 header["MJD-END"] = None
290 log.debug("%s: Clearing DATE-END as being untrustworthy", log_label)
291 modified = True
293 # Up until a certain date GROUPID was the IMGTYPE
294 if date < IMGTYPE_OKAY_DATE:
295 groupId = header.get("GROUPID")
296 if groupId and not groupId.startswith("test"):
297 imgType = header.get("IMGTYPE")
298 if not imgType:
299 if "_" in groupId:
300 # Sometimes have the form dark_0001_0002
301 # in this case we pull the IMGTYPE off the front and
302 # do not clear groupId (although groupId may now
303 # repeat on different days).
304 groupId, _ = groupId.split("_", 1)
305 elif groupId.upper() != "FOCUS" and groupId.upper().startswith("FOCUS"):
306 # If it is exactly FOCUS we want groupId cleared
307 groupId = "FOCUS"
308 else:
309 header["GROUPID"] = None
310 header["IMGTYPE"] = groupId
311 log.debug("%s: Setting IMGTYPE to '%s' from GROUPID", log_label, header["IMGTYPE"])
312 modified = True
313 else:
314 # Someone could be fixing headers in old data
315 # and we do not want GROUPID == IMGTYPE
316 if imgType == groupId:
317 # Clear the group so we default to original
318 header["GROUPID"] = None
320 # We were using OBJECT for engineering observations early on
321 if date < OBJECT_IS_ENGTEST:
322 imgType = header.get("IMGTYPE")
323 if imgType == "OBJECT":
324 header["IMGTYPE"] = "ENGTEST"
325 log.debug("%s: Changing OBJECT observation type to %s",
326 log_label, header["IMGTYPE"])
327 modified = True
329 # Early on the RA/DEC headers were stored in radians
330 if date < RADEC_IS_RADIANS:
331 if header.get("RA") is not None:
332 header["RA"] *= RAD2DEG
333 log.debug("%s: Changing RA header to degrees", log_label)
334 modified = True
335 if header.get("DEC") is not None:
336 header["DEC"] *= RAD2DEG
337 log.debug("%s: Changing DEC header to degrees", log_label)
338 modified = True
340 if header.get("SHUTTIME"):
341 log.debug("%s: Forcing SHUTTIME header to be None", log_label)
342 header["SHUTTIME"] = None
343 modified = True
345 if "OBJECT" not in header:
346 # Only patch OBJECT IMGTYPE
347 if "IMGTYPE" in header and header["IMGTYPE"] == "OBJECT":
348 log.debug("%s: Forcing OBJECT header to exist", log_label)
349 header["OBJECT"] = "NOTSET"
350 modified = True
352 if "RADESYS" in header:
353 if header["RADESYS"] == "":
354 # Default to ICRS
355 header["RADESYS"] = "ICRS"
356 log.debug("%s: Forcing blank RADESYS to '%s'", log_label, header["RADESYS"])
357 modified = True
359 if date < RASTART_IS_BAD:
360 # The wrong telescope position was used. Unsetting these will force
361 # the RA/DEC demand headers to be used instead.
362 for h in ("RASTART", "DECSTART", "RAEND", "DECEND"):
363 header[h] = None
364 log.debug("%s: Forcing derived RA/Dec headers to undefined", log_label)
366 return modified
368 def _is_on_mountain(self):
369 date = self.to_datetime_begin()
370 if date > TSTART:
371 return True
372 return False
374 @staticmethod
375 def compute_detector_exposure_id(exposure_id, detector_num):
376 """Compute the detector exposure ID from detector number and
377 exposure ID.
379 This is a helper method to allow code working outside the translator
380 infrastructure to use the same algorithm.
382 Parameters
383 ----------
384 exposure_id : `int`
385 Unique exposure ID.
386 detector_num : `int`
387 Detector number.
389 Returns
390 -------
391 detector_exposure_id : `int`
392 The calculated ID.
393 """
394 if detector_num != 0:
395 log.warning("Unexpected non-zero detector number for LATISS")
396 return exposure_id
398 @cache_translation
399 def to_dark_time(self):
400 # Docstring will be inherited. Property defined in properties.py
402 # Always compare with exposure time
403 # We may revisit this later if there is a cutoff date where we
404 # can always trust the header.
405 exptime = self.to_exposure_time()
407 if self.is_key_ok("DARKTIME"):
408 darktime = self.quantity_from_card("DARKTIME", u.s)
409 if darktime >= exptime:
410 return darktime
411 reason = "Dark time less than exposure time."
412 else:
413 reason = "Dark time not defined."
415 log.warning("%s: %s Setting dark time to the exposure time.",
416 self._log_prefix, reason)
417 return exptime
419 @cache_translation
420 def to_exposure_time(self):
421 # Docstring will be inherited. Property defined in properties.py
422 # Some data is missing a value for EXPTIME.
423 # Have to be careful we do not have circular logic when trying to
424 # guess
425 if self.is_key_ok("EXPTIME"):
426 return self.quantity_from_card("EXPTIME", u.s)
428 # A missing or undefined EXPTIME is problematic. Set to -1
429 # to indicate that none was found.
430 log.warning("%s: Insufficient information to derive exposure time. Setting to -1.0s",
431 self._log_prefix)
432 return -1.0 * u.s
434 @cache_translation
435 def to_observation_type(self):
436 """Determine the observation type.
438 In the absence of an ``IMGTYPE`` header, assumes lab data is always a
439 dark if exposure time is non-zero, else bias.
441 Returns
442 -------
443 obstype : `str`
444 Observation type.
445 """
447 # LATISS observation type is documented to appear in OBSTYPE
448 # but for historical reasons prefers IMGTYPE.
449 # Test the keys in order until we find one that contains a
450 # defined value.
451 obstype_keys = ["OBSTYPE", "IMGTYPE"]
453 obstype = None
454 for k in obstype_keys:
455 if self.is_key_ok(k):
456 obstype = self._header[k]
457 self._used_these_cards(k)
458 obstype = obstype.lower()
459 break
461 if obstype is not None:
462 if obstype == "object" and not self._is_on_mountain():
463 # Do not map object to science in lab since most
464 # code assume science is on sky with RA/Dec.
465 obstype = "labobject"
466 elif obstype in ("skyexp", "object"):
467 obstype = "science"
469 return obstype
471 # In the absence of any observation type information, return
472 # unknown unless we think it might be a bias.
473 exptime = self.to_exposure_time()
474 if exptime == 0.0:
475 obstype = "bias"
476 else:
477 obstype = "unknown"
478 log.warning("%s: Unable to determine observation type. Guessing '%s'",
479 self._log_prefix, obstype)
480 return obstype
482 @cache_translation
483 def to_physical_filter(self):
484 """Calculate the physical filter name.
486 Returns
487 -------
488 filter : `str`
489 Name of filter. A combination of FILTER and GRATING
490 headers joined by a "~". The filter and grating are always
491 combined. The filter or grating part will be "none" if no value
492 is specified. Uses "empty" if any of the filters or gratings
493 indicate an "empty_N" name. "unknown" indicates that the filter is
494 not defined anywhere but we think it should be. "none" indicates
495 that the filter was not defined but the observation is a dark
496 or bias.
497 """
499 physical_filter = self._determine_primary_filter()
501 if self.is_key_ok("GRATING"):
502 grating = self._header["GRATING"]
503 self._used_these_cards("GRATING")
505 if not grating or grating.lower().startswith("empty"):
506 grating = "empty"
507 else:
508 # Be explicit about having no knowledge of the grating
509 grating = "unknown"
511 physical_filter = f"{physical_filter}{FILTER_DELIMITER}{grating}"
513 return physical_filter
515 @cache_translation
516 def to_boresight_rotation_coord(self):
517 """Boresight rotation angle.
519 Only relevant for science observations.
520 """
521 unknown = "unknown"
522 if not self.is_on_sky():
523 return unknown
525 self._used_these_cards("ROTCOORD")
526 coord = self._header.get("ROTCOORD", unknown)
527 if coord is None:
528 coord = unknown
529 return coord
531 @cache_translation
532 def to_boresight_airmass(self):
533 """Calculate airmass at boresight at start of observation.
535 Notes
536 -----
537 Early data are missing AMSTART header so we fall back to calculating
538 it from ELSTART.
539 """
540 if not self.is_on_sky():
541 return None
543 # This observation should have AMSTART
544 amkey = "AMSTART"
545 if self.is_key_ok(amkey):
546 self._used_these_cards(amkey)
547 return self._header[amkey]
549 # Instead we need to look at azel
550 altaz = self.to_altaz_begin()
551 if altaz is not None:
552 return altaz.secz.to_value()
554 log.warning("%s: Unable to determine airmass of a science observation, returning 1.",
555 self._log_prefix)
556 return 1.0