Coverage for python/lsst/obs/lsst/translators/lsst.py : 24%

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 support code for LSST headers"""
13__all__ = ("TZERO", "SIMONYI_LOCATION", "read_detector_ids",
14 "compute_detector_exposure_id_generic", "LsstBaseTranslator",
15 "SIMONYI_TELESCOPE")
17import os.path
18import yaml
19import logging
20import re
21import datetime
22import hashlib
24import astropy.coordinates
25import astropy.units as u
26from astropy.time import Time, TimeDelta
27from astropy.coordinates import EarthLocation
29from lsst.utils import getPackageDir
31from astro_metadata_translator import cache_translation, FitsTranslator
32from astro_metadata_translator.translators.helpers import tracking_from_degree_headers, \
33 altaz_from_degree_headers
36TZERO = Time("2015-01-01T00:00", format="isot", scale="utc")
37TZERO_DATETIME = TZERO.to_datetime()
39# Delimiter to use for multiple filters/gratings
40FILTER_DELIMITER = "~"
42# Regex to use for parsing a GROUPID string
43GROUP_RE = re.compile(r"^(\d\d\d\d\-\d\d\-\d\dT\d\d:\d\d:\d\d)\.(\d\d\d)(?:[\+#](\d+))?$")
45# LSST Default location in the absence of headers
46SIMONYI_LOCATION = EarthLocation.from_geodetic(-70.749417, -30.244639, 2663.0)
48# Name of the main survey telescope
49SIMONYI_TELESCOPE = "Simonyi Survey Telescope"
51obs_lsst_packageDir = getPackageDir("obs_lsst")
53log = logging.getLogger(__name__)
56def read_detector_ids(policyFile):
57 """Read a camera policy file and retrieve the mapping from CCD name
58 to ID.
60 Parameters
61 ----------
62 policyFile : `str`
63 Name of YAML policy file to read, relative to the obs_lsst
64 package.
66 Returns
67 -------
68 mapping : `dict` of `str` to (`int`, `str`)
69 A `dict` with keys being the full names of the detectors, and the
70 value is a `tuple` containing the integer detector number and the
71 detector serial number.
73 Notes
74 -----
75 Reads the camera YAML definition file directly and extracts just the
76 IDs and serials. This routine does not use the standard
77 `~lsst.obs.base.yamlCamera.YAMLCamera` infrastructure or
78 `lsst.afw.cameraGeom`. This is because the translators are intended to
79 have minimal dependencies on LSST infrastructure.
80 """
82 file = os.path.join(obs_lsst_packageDir, policyFile)
83 try:
84 with open(file) as fh:
85 # Use the fast parser since these files are large
86 camera = yaml.load(fh, Loader=yaml.CSafeLoader)
87 except OSError as e:
88 raise ValueError(f"Could not load camera policy file {file}") from e
90 mapping = {}
91 for ccd, value in camera["CCDs"].items():
92 mapping[ccd] = (int(value["id"]), value["serial"])
94 return mapping
97def compute_detector_exposure_id_generic(exposure_id, detector_num, max_num=1000, mode="concat"):
98 """Compute the detector_exposure_id from the exposure id and the
99 detector number.
101 Parameters
102 ----------
103 exposure_id : `int`
104 The exposure ID.
105 detector_num : `int`
106 The detector number.
107 max_num : `int`, optional
108 Maximum number of detectors to make space for. Defaults to 1000.
109 mode : `str`, optional
110 Computation mode. Defaults to "concat".
111 - concat : Concatenate the exposure ID and detector number, making
112 sure that there is space for max_num and zero padding.
113 - multiply : Multiply the exposure ID by the maximum detector
114 number and add the detector number.
116 Returns
117 -------
118 detector_exposure_id : `int`
119 Computed ID.
121 Raises
122 ------
123 ValueError
124 The detector number is out of range.
125 """
127 if detector_num is None:
128 raise ValueError("Detector number must be defined.")
129 if detector_num > max_num or detector_num < 0:
130 raise ValueError(f"Detector number out of range 0 <= {detector_num} <= {max_num}")
132 if mode == "concat":
133 npad = len(str(max_num))
134 return int(f"{exposure_id}{detector_num:0{npad}d}")
135 elif mode == "multiply":
136 return max_num*exposure_id + detector_num
137 else:
138 raise ValueError(f"Computation mode of '{mode}' is not understood")
141class LsstBaseTranslator(FitsTranslator):
142 """Translation methods useful for all LSST-style headers."""
144 _const_map = {}
145 _trivial_map = {}
147 # Do not specify a name for this translator
148 cameraPolicyFile = None
149 """Path to policy file relative to obs_lsst root."""
151 detectorMapping = None
152 """Mapping of detector name to detector number and serial."""
154 detectorSerials = None
155 """Mapping of detector serial number to raft, number, and name."""
157 DETECTOR_MAX = 999
158 """Maximum number of detectors to use when calculating the
159 detector_exposure_id."""
161 _DEFAULT_LOCATION = SIMONYI_LOCATION
162 """Default telescope location in absence of relevant FITS headers."""
164 _ROLLOVER_TIME = TimeDelta(12*60*60, scale="tai", format="sec")
165 """Time delta for the definition of a Rubin Observatory start of day.
166 Used when the header is missing. See LSE-400 for details."""
168 @classmethod
169 def __init_subclass__(cls, **kwargs):
170 """Ensure that subclasses clear their own detector mapping entries
171 such that subclasses of translators that use detector mappings
172 do not pick up the incorrect values from a parent."""
174 cls.detectorMapping = None
175 cls.detectorSerials = None
177 super().__init_subclass__(**kwargs)
179 def search_paths(self):
180 """Search paths to use for LSST data when looking for header correction
181 files.
183 Returns
184 -------
185 path : `list`
186 List with a single element containing the full path to the
187 ``corrections`` directory within the ``obs_lsst`` package.
188 """
189 return [os.path.join(obs_lsst_packageDir, "corrections")]
191 @classmethod
192 def compute_detector_exposure_id(cls, exposure_id, detector_num):
193 """Compute the detector exposure ID from detector number and
194 exposure ID.
196 This is a helper method to allow code working outside the translator
197 infrastructure to use the same algorithm.
199 Parameters
200 ----------
201 exposure_id : `int`
202 Unique exposure ID.
203 detector_num : `int`
204 Detector number.
206 Returns
207 -------
208 detector_exposure_id : `int`
209 The calculated ID.
210 """
211 return compute_detector_exposure_id_generic(exposure_id, detector_num,
212 max_num=cls.DETECTOR_MAX,
213 mode="concat")
215 @classmethod
216 def max_detector_exposure_id(cls):
217 """The maximum detector exposure ID expected to be generated by
218 this instrument.
220 Returns
221 -------
222 max_id : `int`
223 The maximum value.
224 """
225 max_exposure_id = cls.max_exposure_id()
226 return cls.compute_detector_exposure_id(max_exposure_id, cls.DETECTOR_MAX)
228 @classmethod
229 def max_exposure_id(cls):
230 """The maximum exposure ID expected from this instrument.
232 Returns
233 -------
234 max_exposure_id : `int`
235 The maximum value.
236 """
237 max_date = "2050-12-31T23:59.999"
238 max_seqnum = 99_999
239 max_controller = "C" # This controller triggers the largest numbers
240 return cls.compute_exposure_id(max_date, max_seqnum, max_controller)
242 @classmethod
243 def detector_mapping(cls):
244 """Returns the mapping of full name to detector ID and serial.
246 Returns
247 -------
248 mapping : `dict` of `str`:`tuple`
249 Returns the mapping of full detector name (group+detector)
250 to detector number and serial.
252 Raises
253 ------
254 ValueError
255 Raised if no camera policy file has been registered with this
256 translation class.
258 Notes
259 -----
260 Will construct the mapping if none has previously been constructed.
261 """
262 if cls.cameraPolicyFile is not None:
263 if cls.detectorMapping is None:
264 cls.detectorMapping = read_detector_ids(cls.cameraPolicyFile)
265 else:
266 raise ValueError(f"Translation class '{cls.__name__}' has no registered camera policy file")
268 return cls.detectorMapping
270 @classmethod
271 def detector_serials(cls):
272 """Obtain the mapping of detector serial to detector group, name,
273 and number.
275 Returns
276 -------
277 info : `dict` of `tuple` of (`str`, `str`, `int`)
278 A `dict` with the serial numbers as keys and values of detector
279 group, name, and number.
280 """
281 if cls.detectorSerials is None:
282 detector_mapping = cls.detector_mapping()
284 if detector_mapping is not None:
285 # Form mapping to go from serial number to names/numbers
286 serials = {}
287 for fullname, (id, serial) in cls.detectorMapping.items():
288 raft, detector_name = fullname.split("_")
289 if serial in serials:
290 raise RuntimeError(f"Serial {serial} is defined in multiple places")
291 serials[serial] = (raft, detector_name, id)
292 cls.detectorSerials = serials
293 else:
294 raise RuntimeError("Unable to obtain detector mapping information")
296 return cls.detectorSerials
298 @classmethod
299 def compute_detector_num_from_name(cls, detector_group, detector_name):
300 """Helper method to return the detector number from the name.
302 Parameters
303 ----------
304 detector_group : `str`
305 Name of the detector grouping. This is generally the raft name.
306 detector_name : `str`
307 Detector name.
309 Returns
310 -------
311 num : `int`
312 Detector number.
313 """
314 fullname = f"{detector_group}_{detector_name}"
316 num = None
317 detector_mapping = cls.detector_mapping()
318 if detector_mapping is None:
319 raise RuntimeError("Unable to obtain detector mapping information")
321 if fullname in detector_mapping:
322 num = detector_mapping[fullname]
323 else:
324 log.warning(f"Unable to determine detector number from detector name {fullname}")
325 return None
327 return num[0]
329 @classmethod
330 def compute_detector_info_from_serial(cls, detector_serial):
331 """Helper method to return the detector information from the serial.
333 Parameters
334 ----------
335 detector_serial : `str`
336 Detector serial ID.
338 Returns
339 -------
340 info : `tuple` of (`str`, `str`, `int`)
341 Detector group, name, and number.
342 """
343 serial_mapping = cls.detector_serials()
344 if serial_mapping is None:
345 raise RuntimeError("Unable to obtain serial mapping information")
347 if detector_serial in serial_mapping:
348 info = serial_mapping[detector_serial]
349 else:
350 raise RuntimeError("Unable to determine detector information from detector serial"
351 f" {detector_serial}")
353 return info
355 @staticmethod
356 def compute_exposure_id(dayobs, seqnum, controller=None):
357 """Helper method to calculate the exposure_id.
359 Parameters
360 ----------
361 dayobs : `str`
362 Day of observation in either YYYYMMDD or YYYY-MM-DD format.
363 If the string looks like ISO format it will be truncated before the
364 ``T`` before being handled.
365 seqnum : `int` or `str`
366 Sequence number.
367 controller : `str`, optional
368 Controller to use. If this is "O", no change is made to the
369 exposure ID. If it is "C" a 1000 is added to the year component
370 of the exposure ID. If it is "H" a 2000 is added to the year
371 component.
372 `None` indicates that the controller is not relevant to the
373 exposure ID calculation (generally this is the case for test
374 stand data).
376 Returns
377 -------
378 exposure_id : `int`
379 Exposure ID in form YYYYMMDDnnnnn form.
380 """
381 if "T" in dayobs:
382 dayobs = dayobs[:dayobs.find("T")]
384 dayobs = dayobs.replace("-", "")
386 if len(dayobs) != 8:
387 raise ValueError(f"Malformed dayobs: {dayobs}")
389 # Expect no more than 99,999 exposures in a day
390 maxdigits = 5
391 if seqnum >= 10**maxdigits:
392 raise ValueError(f"Sequence number ({seqnum}) exceeds limit")
394 # Camera control changes the exposure ID
395 if controller is not None:
396 if controller == "O":
397 pass
398 elif controller == "C":
399 # Add 1000 to the year component
400 dayobs = int(dayobs)
401 dayobs += 1000_00_00
402 elif controller == "H":
403 # Add 2000 to the year component for pHosim
404 dayobs = int(dayobs)
405 dayobs += 2000_00_00
406 else:
407 raise ValueError(f"Supplied controller, '{controller}' is neither 'O' nor 'C' nor 'H'")
409 # Form the number as a string zero padding the sequence number
410 idstr = f"{dayobs}{seqnum:0{maxdigits}d}"
412 # Exposure ID has to be an integer
413 return int(idstr)
415 def _is_on_mountain(self):
416 """Indicate whether these data are coming from the instrument
417 installed on the mountain.
419 Returns
420 -------
421 is : `bool`
422 `True` if instrument is on the mountain.
423 """
424 if "TSTAND" in self._header:
425 return False
426 return True
428 def is_on_sky(self):
429 """Determine if this is an on-sky observation.
431 Returns
432 -------
433 is_on_sky : `bool`
434 Returns True if this is a observation on sky on the
435 summit.
436 """
437 # For LSST we think on sky unless tracksys is local
438 if self.is_key_ok("TRACKSYS"):
439 if self._header["TRACKSYS"].lower() == "local":
440 # not on sky
441 return False
443 # These are obviously not on sky
444 if self.to_observation_type() in ("bias", "dark", "flat"):
445 return False
447 return self._is_on_mountain()
449 @cache_translation
450 def to_location(self):
451 # Docstring will be inherited. Property defined in properties.py
452 if not self._is_on_mountain():
453 return None
454 try:
455 # Try standard FITS headers
456 return super().to_location()
457 except KeyError:
458 return self._DEFAULT_LOCATION
460 @cache_translation
461 def to_datetime_begin(self):
462 # Docstring will be inherited. Property defined in properties.py
463 self._used_these_cards("MJD-OBS")
464 return Time(self._header["MJD-OBS"], scale="tai", format="mjd")
466 @cache_translation
467 def to_datetime_end(self):
468 # Docstring will be inherited. Property defined in properties.py
469 if self.is_key_ok("DATE-END"):
470 return super().to_datetime_end()
472 return self.to_datetime_begin() + self.to_exposure_time()
474 @cache_translation
475 def to_detector_num(self):
476 # Docstring will be inherited. Property defined in properties.py
477 raft = self.to_detector_group()
478 detector = self.to_detector_name()
479 return self.compute_detector_num_from_name(raft, detector)
481 @cache_translation
482 def to_detector_exposure_id(self):
483 # Docstring will be inherited. Property defined in properties.py
484 exposure_id = self.to_exposure_id()
485 num = self.to_detector_num()
486 return self.compute_detector_exposure_id(exposure_id, num)
488 @cache_translation
489 def to_observation_type(self):
490 # Docstring will be inherited. Property defined in properties.py
491 obstype = self._header["IMGTYPE"]
492 self._used_these_cards("IMGTYPE")
493 obstype = obstype.lower()
494 if obstype in ("skyexp", "object"):
495 obstype = "science"
496 return obstype
498 @cache_translation
499 def to_observation_reason(self):
500 # Docstring will be inherited. Property defined in properties.py
501 if self.is_key_ok("TESTTYPE"):
502 reason = self._header["TESTTYPE"]
503 self._used_these_cards("TESTTYPE")
504 return reason.lower()
505 # no specific header present so use the default translation
506 return super().to_observation_reason()
508 @cache_translation
509 def to_dark_time(self):
510 """Calculate the dark time.
512 If a DARKTIME header is not found, the value is assumed to be
513 identical to the exposure time.
515 Returns
516 -------
517 dark : `astropy.units.Quantity`
518 The dark time in seconds.
519 """
520 if self.is_key_ok("DARKTIME"):
521 darktime = self._header["DARKTIME"]*u.s
522 self._used_these_cards("DARKTIME")
523 else:
524 log.warning("%s: Unable to determine dark time. Setting from exposure time.",
525 self._log_prefix)
526 darktime = self.to_exposure_time()
527 return darktime
529 @cache_translation
530 def to_exposure_id(self):
531 """Generate a unique exposure ID number
533 This is a combination of DAYOBS and SEQNUM, and optionally
534 CONTRLLR.
536 Returns
537 -------
538 exposure_id : `int`
539 Unique exposure number.
540 """
541 if "CALIB_ID" in self._header:
542 self._used_these_cards("CALIB_ID")
543 return None
545 dayobs = self._header["DAYOBS"]
546 seqnum = self._header["SEQNUM"]
547 self._used_these_cards("DAYOBS", "SEQNUM")
549 if self.is_key_ok("CONTRLLR"):
550 controller = self._header["CONTRLLR"]
551 self._used_these_cards("CONTRLLR")
552 else:
553 controller = None
555 return self.compute_exposure_id(dayobs, seqnum, controller=controller)
557 @cache_translation
558 def to_visit_id(self):
559 """Calculate the visit associated with this exposure.
561 Notes
562 -----
563 For LATISS and LSSTCam the default visit is derived from the
564 exposure group. For other instruments we return the exposure_id.
565 """
567 exposure_group = self.to_exposure_group()
568 # If the group is an int we return it
569 try:
570 visit_id = int(exposure_group)
571 return visit_id
572 except ValueError:
573 pass
575 # A Group is defined as ISO date with an extension
576 # The integer must be the same for a given group so we can never
577 # use datetime_begin.
578 # Nominally a GROUPID looks like "ISODATE+N" where the +N is
579 # optional. This can be converted to seconds since epoch with
580 # an adjustment for N.
581 # For early data lacking that form we hash the group and return
582 # the int.
583 matches_date = GROUP_RE.match(exposure_group)
584 if matches_date:
585 iso_str = matches_date.group(1)
586 fraction = matches_date.group(2)
587 n = matches_date.group(3)
588 if n is not None:
589 n = int(n)
590 else:
591 n = 0
592 iso = datetime.datetime.strptime(iso_str, "%Y-%m-%dT%H:%M:%S")
594 tdelta = iso - TZERO_DATETIME
595 epoch = int(tdelta.total_seconds())
597 # Form the integer from EPOCH + 3 DIGIT FRAC + 0-pad N
598 visit_id = int(f"{epoch}{fraction}{n:04d}")
599 else:
600 # Non-standard string so convert to numbers
601 # using a hash function. Use the first N hex digits
602 group_bytes = exposure_group.encode("us-ascii")
603 hasher = hashlib.blake2b(group_bytes)
604 # Need to be big enough it does not possibly clash with the
605 # date-based version above
606 digest = hasher.hexdigest()[:14]
607 visit_id = int(digest, base=16)
609 # To help with hash collision, append the string length
610 visit_id = int(f"{visit_id}{len(exposure_group):02d}")
612 return visit_id
614 @cache_translation
615 def to_physical_filter(self):
616 """Calculate the physical filter name.
618 Returns
619 -------
620 filter : `str`
621 Name of filter. Can be a combination of FILTER, FILTER1 and FILTER2
622 headers joined by a "~". Returns "unknown" if no filter is declared
623 """
624 joined = self._join_keyword_values(["FILTER", "FILTER1", "FILTER2"], delim=FILTER_DELIMITER)
625 if not joined:
626 joined = "unknown"
628 return joined
630 @cache_translation
631 def to_tracking_radec(self):
632 if not self.is_on_sky():
633 return None
635 # RA/DEC are *derived* headers and for the case where the DATE-BEG
636 # is 1970 they are garbage and should not be used.
637 if self._header["DATE-OBS"] == self._header["DATE"]:
638 # A fixed up date -- use AZEL as source of truth
639 altaz = self.to_altaz_begin()
640 radec = astropy.coordinates.SkyCoord(altaz.transform_to(astropy.coordinates.ICRS),
641 obstime=altaz.obstime,
642 location=altaz.location)
643 else:
644 radecsys = ("RADESYS",)
645 radecpairs = (("RASTART", "DECSTART"), ("RA", "DEC"))
646 radec = tracking_from_degree_headers(self, radecsys, radecpairs)
648 return radec
650 @cache_translation
651 def to_altaz_begin(self):
652 if not self._is_on_mountain():
653 return None
655 # ALTAZ always relevant unless bias or dark
656 if self.to_observation_type() in ("bias", "dark"):
657 return None
659 return altaz_from_degree_headers(self, (("ELSTART", "AZSTART"),),
660 self.to_datetime_begin(), is_zd=False)
662 @cache_translation
663 def to_exposure_group(self):
664 """Calculate the exposure group string.
666 For LSSTCam and LATISS this is read from the ``GROUPID`` header.
667 If that header is missing the exposure_id is returned instead as
668 a string.
669 """
670 if self.is_key_ok("GROUPID"):
671 exposure_group = self._header["GROUPID"]
672 self._used_these_cards("GROUPID")
673 return exposure_group
674 return super().to_exposure_group()
676 @staticmethod
677 def _is_filter_empty(filter):
678 """Return true if the supplied filter indicates an empty filter slot
680 Parameters
681 ----------
682 filter : `str`
683 The filter string to check.
685 Returns
686 -------
687 is_empty : `bool`
688 `True` if the filter string looks like it is referring to an
689 empty filter slot. For example this can be if the filter is
690 "empty" or "empty_2".
691 """
692 return bool(re.match(r"empty_?\d*$", filter.lower()))
694 def _determine_primary_filter(self):
695 """Determine the primary filter from the ``FILTER`` header.
697 Returns
698 -------
699 filter : `str`
700 The contents of the ``FILTER`` header with some appropriate
701 defaulting.
702 """
704 if self.is_key_ok("FILTER"):
705 physical_filter = self._header["FILTER"]
706 self._used_these_cards("FILTER")
708 if self._is_filter_empty(physical_filter):
709 physical_filter = "empty"
710 else:
711 # Be explicit about having no knowledge of the filter
712 # by setting it to "unknown". It should always have a value.
713 physical_filter = "unknown"
715 # Warn if the filter being unknown is important
716 obstype = self.to_observation_type()
717 if obstype not in ("bias", "dark"):
718 log.warning("%s: Unable to determine the filter",
719 self._log_prefix)
721 return physical_filter
723 @cache_translation
724 def to_observing_day(self):
725 """Return the day of observation as YYYYMMDD integer.
727 For LSSTCam and other compliant instruments this is the value
728 of the DAYOBS header.
730 Returns
731 -------
732 obs_day : `int`
733 The day of observation.
734 """
735 if self.is_key_ok("DAYOBS"):
736 self._used_these_cards("DAYOBS")
737 return int(self._header["DAYOBS"])
739 # Calculate it ourselves correcting for the Rubin offset
740 date = self.to_datetime_begin().tai
741 date -= self._ROLLOVER_TIME
742 return int(date.strftime("%Y%m%d"))
744 @cache_translation
745 def to_observation_counter(self):
746 """Return the sequence number within the observing day.
748 Returns
749 -------
750 counter : `int`
751 The sequence number for this day.
752 """
753 if self.is_key_ok("SEQNUM"):
754 # Some older LATISS data may not have the header
755 # but this is corrected in fix_header for LATISS.
756 self._used_these_cards("SEQNUM")
757 return int(self._header["SEQNUM"])
759 # This indicates a problem so we warn and return a 0
760 log.warning("%s: Unable to determine the observation counter so returning 0",
761 self._log_prefix)
762 return 0