Coverage for python/astro_metadata_translator/translator.py : 39%

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 part of astro_metadata_translator.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the LICENSE file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12"""Classes and support code for metadata translation"""
14__all__ = ("MetadataTranslator", "StubTranslator", "cache_translation")
16from abc import abstractmethod
17import inspect
18import importlib
19import logging
20import warnings
21import math
23import astropy.units as u
24import astropy.io.fits.card
25from astropy.coordinates import Angle
27from .properties import PROPERTIES
29log = logging.getLogger(__name__)
31# Location of the root of the corrections resource files
32CORRECTIONS_RESOURCE_ROOT = "corrections"
34"""Cache of version strings indexed by class."""
35_VERSION_CACHE = dict()
38def cache_translation(func, method=None):
39 """Decorator to cache the result of a translation method.
41 Especially useful when a translation uses many other translation
42 methods. Should be used only on ``to_x()`` methods.
44 Parameters
45 ----------
46 func : `function`
47 Translation method to cache.
48 method : `str`, optional
49 Name of the translation method to cache. Not needed if the decorator
50 is used around a normal method, but necessary when the decorator is
51 being used in a metaclass.
53 Returns
54 -------
55 wrapped : `function`
56 Method wrapped by the caching function.
57 """
58 name = func.__name__ if method is None else method
60 def func_wrapper(self):
61 if name not in self._translation_cache:
62 self._translation_cache[name] = func(self)
63 return self._translation_cache[name]
64 func_wrapper.__doc__ = func.__doc__
65 func_wrapper.__name__ = f"{name}_cached"
66 return func_wrapper
69class MetadataTranslator:
70 """Per-instrument metadata translation support
72 Parameters
73 ----------
74 header : `dict`-like
75 Representation of an instrument header that can be manipulated
76 as if it was a `dict`.
77 filename : `str`, optional
78 Name of the file whose header is being translated. For some
79 datasets with missing header information this can sometimes
80 allow for some fixups in translations.
81 """
83 # These are all deliberately empty in the base class.
84 default_search_path = None
85 """Default search path to use to locate header correction files."""
87 default_resource_package = __name__.split(".")[0]
88 """Module name to use to locate the correction resources."""
90 default_resource_root = None
91 """Default package resource path root to use to locate header correction
92 files within the ``default_resource_package`` package."""
94 _trivial_map = {}
95 """Dict of one-to-one mappings for header translation from standard
96 property to corresponding keyword."""
98 _const_map = {}
99 """Dict defining a constant for specified standard properties."""
101 translators = dict()
102 """All registered metadata translation classes."""
104 supported_instrument = None
105 """Name of instrument understood by this translation class."""
107 @classmethod
108 def defined_in_this_class(cls, name):
109 """Report if the specified class attribute is defined specifically in
110 this class.
112 Parameters
113 ----------
114 name : `str`
115 Name of the attribute to test.
117 Returns
118 -------
119 in_class : `bool`
120 `True` if there is a attribute of that name defined in this
121 specific subclass.
122 `False` if the method is not defined in this specific subclass
123 but is defined in a parent class.
124 Returns `None` if the attribute is not defined anywhere
125 in the class hierarchy (which can happen if translators have
126 typos in their mapping tables).
128 Notes
129 -----
130 Retrieves the attribute associated with the given name.
131 Then looks in all the parent classes to determine whether that
132 attribute comes from a parent class or from the current class.
133 Attributes are compared using `id()`.
134 """
135 # The attribute to compare.
136 if not hasattr(cls, name):
137 return None
138 attr_id = id(getattr(cls, name))
140 # Get all the classes in the hierarchy
141 mro = list(inspect.getmro(cls))
143 # Remove the first entry from the list since that will be the
144 # current class
145 mro.pop(0)
147 for parent in mro:
148 # Some attributes may only exist in subclasses. Skip base classes
149 # that are missing the attribute (such as object).
150 if hasattr(parent, name):
151 if id(getattr(parent, name)) == attr_id:
152 return False
153 return True
155 @staticmethod
156 def _make_const_mapping(property_key, constant):
157 """Make a translator method that returns a constant value.
159 Parameters
160 ----------
161 property_key : `str`
162 Name of the property to be calculated (for the docstring).
163 constant : `str` or `numbers.Number`
164 Value to return for this translator.
166 Returns
167 -------
168 f : `function`
169 Function returning the constant.
170 """
171 def constant_translator(self):
172 return constant
174 if property_key in PROPERTIES: 174 ↛ 177line 174 didn't jump to line 177, because the condition on line 174 was never false
175 property_doc, return_type = PROPERTIES[property_key][:2]
176 else:
177 return_type = type(constant).__name__
178 property_doc = f"Returns constant value for '{property_key}' property"
180 constant_translator.__doc__ = f"""{property_doc}
182 Returns
183 -------
184 translation : `{return_type}`
185 Translated property.
186 """
187 return constant_translator
189 @staticmethod
190 def _make_trivial_mapping(property_key, header_key, default=None, minimum=None, maximum=None,
191 unit=None, checker=None):
192 """Make a translator method returning a header value.
194 The header value can be converted to a `~astropy.units.Quantity`
195 if desired, and can also have its value validated.
197 See `MetadataTranslator.validate_value()` for details on the use
198 of default parameters.
200 Parameters
201 ----------
202 property_key : `str`
203 Name of the translator to be constructed (for the docstring).
204 header_key : `str` or `list` of `str`
205 Name of the key to look up in the header. If a `list` each
206 key will be tested in turn until one matches. This can deal with
207 header styles that evolve over time.
208 default : `numbers.Number` or `astropy.units.Quantity`, `str`, optional
209 If not `None`, default value to be used if the parameter read from
210 the header is not defined or if the header is missing.
211 minimum : `numbers.Number` or `astropy.units.Quantity`, optional
212 If not `None`, and if ``default`` is not `None`, minimum value
213 acceptable for this parameter.
214 maximum : `numbers.Number` or `astropy.units.Quantity`, optional
215 If not `None`, and if ``default`` is not `None`, maximum value
216 acceptable for this parameter.
217 unit : `astropy.units.Unit`, optional
218 If not `None`, the value read from the header will be converted
219 to a `~astropy.units.Quantity`. Only supported for numeric values.
220 checker : `function`, optional
221 Callback function to be used by the translator method in case the
222 keyword is not present. Function will be executed as if it is
223 a method of the translator class. Running without raising an
224 exception will allow the default to be used. Should usually raise
225 `KeyError`.
227 Returns
228 -------
229 t : `function`
230 Function implementing a translator with the specified
231 parameters.
232 """
233 if property_key in PROPERTIES: 233 ↛ 236line 233 didn't jump to line 236, because the condition on line 233 was never false
234 property_doc, return_type = PROPERTIES[property_key][:2]
235 else:
236 return_type = "str` or `numbers.Number"
237 property_doc = f"Map '{header_key}' header keyword to '{property_key}' property"
239 def trivial_translator(self):
240 if unit is not None:
241 q = self.quantity_from_card(header_key, unit,
242 default=default, minimum=minimum, maximum=maximum,
243 checker=checker)
244 # Convert to Angle if this quantity is an angle
245 if return_type == "astropy.coordinates.Angle":
246 q = Angle(q)
247 return q
249 keywords = header_key if isinstance(header_key, list) else [header_key]
250 for key in keywords:
251 if self.is_key_ok(key):
252 value = self._header[key]
253 if default is not None and not isinstance(value, str):
254 value = self.validate_value(value, default, minimum=minimum, maximum=maximum)
255 self._used_these_cards(key)
256 break
257 else:
258 # No keywords found, use default, checking first, or raise
259 # A None default is only allowed if a checker is provided.
260 if checker is not None:
261 try:
262 checker(self)
263 return default
264 except Exception:
265 raise KeyError(f"Could not find {keywords} in header")
266 value = default
267 elif default is not None:
268 value = default
269 else:
270 raise KeyError(f"Could not find {keywords} in header")
272 # If we know this is meant to be a string, force to a string.
273 # Sometimes headers represent items as integers which generically
274 # we want as strings (eg OBSID). Sometimes also floats are
275 # written as "NaN" strings.
276 casts = {"str": str, "float": float, "int": int}
277 if return_type in casts and not isinstance(value, casts[return_type]) and value is not None:
278 value = casts[return_type](value)
280 return value
282 # Docstring inheritance means it is confusing to specify here
283 # exactly which header value is being used.
284 trivial_translator.__doc__ = f"""{property_doc}
286 Returns
287 -------
288 translation : `{return_type}`
289 Translated value derived from the header.
290 """
291 return trivial_translator
293 @classmethod
294 def __init_subclass__(cls, **kwargs):
295 """Register all subclasses with the base class and create dynamic
296 translator methods.
298 The method provides two facilities. Firstly, every subclass
299 of `MetadataTranslator` that includes a ``name`` class property is
300 registered as a translator class that could be selected when automatic
301 header translation is attempted. Only name translator subclasses that
302 correspond to a complete instrument. Translation classes providing
303 generic translation support for multiple instrument translators should
304 not be named.
306 The second feature of this method is to convert simple translations
307 to full translator methods. Sometimes a translation is fixed (for
308 example a specific instrument name should be used) and rather than
309 provide a full ``to_property()`` translation method the mapping can be
310 defined in a class variable named ``_constMap``. Similarly, for
311 one-to-one trivial mappings from a header to a property,
312 ``_trivialMap`` can be defined. Trivial mappings are a dict mapping a
313 generic property to either a header keyword, or a tuple consisting of
314 the header keyword and a dict containing key value pairs suitable for
315 the `MetadataTranslator.quantity_from_card()` method.
316 """
317 super().__init_subclass__(**kwargs)
319 # Only register classes with declared names
320 if hasattr(cls, "name") and cls.name is not None:
321 if cls.name in MetadataTranslator.translators: 321 ↛ 322line 321 didn't jump to line 322, because the condition on line 321 was never true
322 log.warning("%s: Replacing %s translator with %s",
323 cls.name, MetadataTranslator.translators[cls.name], cls)
324 MetadataTranslator.translators[cls.name] = cls
326 # Check that we have not inherited constant/trivial mappings from
327 # parent class that we have already applied. Empty maps are always
328 # assumed okay
329 const_map = cls._const_map if cls._const_map and cls.defined_in_this_class("_const_map") else {}
330 trivial_map = cls._trivial_map \
331 if cls._trivial_map and cls.defined_in_this_class("_trivial_map") else {}
333 # Check for shadowing
334 trivials = set(trivial_map.keys())
335 constants = set(const_map.keys())
336 both = trivials & constants
337 if both: 337 ↛ 338line 337 didn't jump to line 338, because the condition on line 337 was never true
338 log.warning("%s: defined in both const_map and trivial_map: %s",
339 cls.__name__, ", ".join(both))
341 all = trivials | constants
342 for name in all:
343 if cls.defined_in_this_class(f"to_{name}"): 343 ↛ 346line 343 didn't jump to line 346, because the condition on line 343 was never true
344 # Must be one of trivial or constant. If in both then constant
345 # overrides trivial.
346 location = "by _trivial_map"
347 if name in constants:
348 location = "by _const_map"
349 log.warning("%s: %s is defined explicitly but will be replaced %s",
350 cls.__name__, name, location)
352 # Go through the trival mappings for this class and create
353 # corresponding translator methods
354 for property_key, header_key in trivial_map.items():
355 kwargs = {}
356 if type(header_key) == tuple:
357 kwargs = header_key[1]
358 header_key = header_key[0]
359 translator = cls._make_trivial_mapping(property_key, header_key, **kwargs)
360 method = f"to_{property_key}"
361 translator.__name__ = f"{method}_trivial_in_{cls.__name__}"
362 setattr(cls, method, cache_translation(translator, method=method))
363 if property_key not in PROPERTIES: 363 ↛ 364line 363 didn't jump to line 364, because the condition on line 363 was never true
364 log.warning(f"Unexpected trivial translator for '{property_key}' defined in {cls}")
366 # Go through the constant mappings for this class and create
367 # corresponding translator methods
368 for property_key, constant in const_map.items():
369 translator = cls._make_const_mapping(property_key, constant)
370 method = f"to_{property_key}"
371 translator.__name__ = f"{method}_constant_in_{cls.__name__}"
372 setattr(cls, method, translator)
373 if property_key not in PROPERTIES: 373 ↛ 374line 373 didn't jump to line 374, because the condition on line 373 was never true
374 log.warning(f"Unexpected constant translator for '{property_key}' defined in {cls}")
376 def __init__(self, header, filename=None):
377 self._header = header
378 self.filename = filename
379 self._used_cards = set()
381 # Prefix to use for warnings about failed translations
382 self._log_prefix_cache = None
384 # Cache assumes header is read-only once stored in object
385 self._translation_cache = {}
387 @classmethod
388 @abstractmethod
389 def can_translate(cls, header, filename=None):
390 """Indicate whether this translation class can translate the
391 supplied header.
393 Parameters
394 ----------
395 header : `dict`-like
396 Header to convert to standardized form.
397 filename : `str`, optional
398 Name of file being translated.
400 Returns
401 -------
402 can : `bool`
403 `True` if the header is recognized by this class. `False`
404 otherwise.
405 """
406 raise NotImplementedError()
408 @classmethod
409 def can_translate_with_options(cls, header, options, filename=None):
410 """Helper method for `can_translate` allowing options.
412 Parameters
413 ----------
414 header : `dict`-like
415 Header to convert to standardized form.
416 options : `dict`
417 Headers to try to determine whether this header can
418 be translated by this class. If a card is found it will
419 be compared with the expected value and will return that
420 comparison. Each card will be tried in turn until one is
421 found.
422 filename : `str`, optional
423 Name of file being translated.
425 Returns
426 -------
427 can : `bool`
428 `True` if the header is recognized by this class. `False`
429 otherwise.
431 Notes
432 -----
433 Intended to be used from within `can_translate` implementations
434 for specific translators. Is not intended to be called directly
435 from `determine_translator`.
436 """
437 for card, value in options.items():
438 if card in header:
439 return header[card] == value
440 return False
442 @classmethod
443 def determine_translator(cls, header, filename=None):
444 """Determine a translation class by examining the header
446 Parameters
447 ----------
448 header : `dict`-like
449 Representation of a header.
450 filename : `str`, optional
451 Name of file being translated.
453 Returns
454 -------
455 translator : `MetadataTranslator`
456 Translation class that knows how to extract metadata from
457 the supplied header.
459 Raises
460 ------
461 ValueError
462 None of the registered translation classes understood the supplied
463 header.
464 """
465 for name, trans in cls.translators.items():
466 if trans.can_translate(header, filename=filename):
467 log.debug(f"Using translation class {name}")
468 return trans
469 else:
470 raise ValueError(f"None of the registered translation classes {list(cls.translators.keys())}"
471 " understood this header")
473 @classmethod
474 def translator_version(cls):
475 """Return the version string for this translator class.
477 Returns
478 -------
479 version : `str`
480 String identifying the version of this translator.
482 Notes
483 -----
484 Assumes that the version is available from the ``__version__``
485 variable in the parent module. If this is not the case a translator
486 should subclass this method.
487 """
488 if cls in _VERSION_CACHE:
489 return _VERSION_CACHE[cls]
491 version = "unknown"
492 module_name = cls.__module__
493 components = module_name.split(".")
494 while components:
495 # This class has already been imported so importing it
496 # should work.
497 module = importlib.import_module(".".join(components))
498 if hasattr(module, v := "__version__"):
499 version = getattr(module, v)
500 if version == "unknown":
501 # LSST software will have a fingerprint
502 if hasattr(module, v := "__fingerprint__"):
503 version = getattr(module, v)
504 break
505 else:
506 # Remove last component from module name and try again
507 components.pop()
509 _VERSION_CACHE[cls] = version
510 return version
512 @classmethod
513 def fix_header(cls, header, instrument, obsid, filename=None):
514 """Apply global fixes to a supplied header.
516 Parameters
517 ----------
518 header : `dict`
519 The header to correct. Correction is in place.
520 instrument : `str`
521 The name of the instrument.
522 obsid : `str`
523 Unique observation identifier associated with this header.
524 Will always be provided.
525 filename : `str`, optional
526 Filename associated with this header. May not be set since headers
527 can be fixed independently of any filename being known.
529 Returns
530 -------
531 modified : `bool`
532 `True` if a correction was applied.
534 Notes
535 -----
536 This method is intended to support major discrepancies in headers
537 such as:
539 * Periods of time where headers are known to be incorrect in some
540 way that can be fixed either by deriving the correct value from
541 the existing value or understanding the that correction is static
542 for the given time. This requires that the date header is
543 known.
544 * The presence of a certain value is always wrong and should be
545 corrected with a new static value regardless of date.
547 It is assumed that one off problems with headers have been applied
548 before this method is called using the per-obsid correction system.
550 Usually called from `astro_metadata_translator.fix_header`.
552 For log messages, do not assume that the filename will be present.
553 Always write log messages to fall back on using the ``obsid`` if
554 ``filename`` is `None`.
555 """
556 return False
558 @staticmethod
559 def _construct_log_prefix(obsid, filename=None):
560 """Construct a log prefix string from the obsid and filename.
562 Parameters
563 ----------
564 obsid : `str`
565 The observation identifier.
566 filename : `str`, optional
567 The filename associated with the header being translated.
568 Can be `None`.
569 """
570 if filename:
571 return f"{filename}({obsid})"
572 return obsid
574 @property
575 def _log_prefix(self):
576 """Standard prefix that can be used for log messages to report
577 useful context.
579 Will be either the filename and obsid, or just the obsid depending
580 on whether a filename is known.
582 Returns
583 -------
584 prefix : `str`
585 The prefix to use.
586 """
587 if self._log_prefix_cache is None:
588 # Protect against the unfortunate event of the obsid failing to
589 # be calculated. This should be rare but should not prevent a log
590 # message from appearing.
591 try:
592 obsid = self.to_observation_id()
593 except Exception:
594 obsid = "unknown_obsid"
595 self._log_prefix_cache = self._construct_log_prefix(obsid, self.filename)
596 return self._log_prefix_cache
598 def _used_these_cards(self, *args):
599 """Indicate that the supplied cards have been used for translation.
601 Parameters
602 ----------
603 args : sequence of `str`
604 Keywords used to process a translation.
605 """
606 self._used_cards.update(set(args))
608 def cards_used(self):
609 """Cards used during metadata extraction.
611 Returns
612 -------
613 used : `frozenset` of `str`
614 Cards used when extracting metadata.
615 """
616 return frozenset(self._used_cards)
618 @staticmethod
619 def validate_value(value, default, minimum=None, maximum=None):
620 """Validate the supplied value, returning a new value if out of range
622 Parameters
623 ----------
624 value : `float`
625 Value to be validated.
626 default : `float`
627 Default value to use if supplied value is invalid or out of range.
628 Assumed to be in the same units as the value expected in the
629 header.
630 minimum : `float`
631 Minimum possible valid value, optional. If the calculated value
632 is below this value, the default value will be used.
633 maximum : `float`
634 Maximum possible valid value, optional. If the calculated value
635 is above this value, the default value will be used.
637 Returns
638 -------
639 value : `float`
640 Either the supplied value, or a default value.
641 """
642 if value is None or math.isnan(value):
643 value = default
644 else:
645 if minimum is not None and value < minimum:
646 value = default
647 elif maximum is not None and value > maximum:
648 value = default
649 return value
651 @staticmethod
652 def is_keyword_defined(header, keyword):
653 """Return `True` if the value associated with the named keyword is
654 present in the supplied header and defined.
656 Parameters
657 ----------
658 header : `dict`-lik
659 Header to use as reference.
660 keyword : `str`
661 Keyword to check against header.
663 Returns
664 -------
665 is_defined : `bool`
666 `True` if the header is present and not-`None`. `False` otherwise.
667 """
668 if keyword not in header:
669 return False
671 if header[keyword] is None:
672 return False
674 # Special case Astropy undefined value
675 if isinstance(header[keyword], astropy.io.fits.card.Undefined):
676 return False
678 return True
680 def resource_root(self):
681 """Package resource to use to locate correction resources within an
682 installed package.
684 Returns
685 -------
686 resource_package : `str`
687 Package resource name. `None` if no package resource are to be
688 used.
689 resource_root : `str`
690 The name of the resource root. `None` if no package resources
691 are to be used.
692 """
693 return (self.default_resource_package, self.default_resource_root)
695 def search_paths(self):
696 """Search paths to use when searching for header fix up correction
697 files.
699 Returns
700 -------
701 paths : `list`
702 Directory paths to search. Can be an empty list if no special
703 directories are defined.
705 Notes
706 -----
707 Uses the classes ``default_search_path`` property if defined.
708 """
709 if self.default_search_path is not None:
710 return [self.default_search_path]
711 return []
713 def is_key_ok(self, keyword):
714 """Return `True` if the value associated with the named keyword is
715 present in this header and defined.
717 Parameters
718 ----------
719 keyword : `str`
720 Keyword to check against header.
722 Returns
723 -------
724 is_ok : `bool`
725 `True` if the header is present and not-`None`. `False` otherwise.
726 """
727 return self.is_keyword_defined(self._header, keyword)
729 def are_keys_ok(self, keywords):
730 """Are the supplied keys all present and defined?
732 Parameters
733 ----------
734 keywords : iterable of `str`
735 Keywords to test.
737 Returns
738 -------
739 all_ok : `bool`
740 `True` if all supplied keys are present and defined.
741 """
742 for k in keywords:
743 if not self.is_key_ok(k):
744 return False
745 return True
747 def quantity_from_card(self, keywords, unit, default=None, minimum=None, maximum=None, checker=None):
748 """Calculate a Astropy Quantity from a header card and a unit.
750 Parameters
751 ----------
752 keywords : `str` or `list` of `str`
753 Keyword to use from header. If a list each keyword will be tried
754 in turn until one matches.
755 unit : `astropy.units.UnitBase`
756 Unit of the item in the header.
757 default : `float`, optional
758 Default value to use if the header value is invalid. Assumed
759 to be in the same units as the value expected in the header. If
760 None, no default value is used.
761 minimum : `float`, optional
762 Minimum possible valid value, optional. If the calculated value
763 is below this value, the default value will be used.
764 maximum : `float`, optional
765 Maximum possible valid value, optional. If the calculated value
766 is above this value, the default value will be used.
767 checker : `function`, optional
768 Callback function to be used by the translator method in case the
769 keyword is not present. Function will be executed as if it is
770 a method of the translator class. Running without raising an
771 exception will allow the default to be used. Should usually raise
772 `KeyError`.
774 Returns
775 -------
776 q : `astropy.units.Quantity`
777 Quantity representing the header value.
779 Raises
780 ------
781 KeyError
782 The supplied header key is not present.
783 """
784 keywords = keywords if isinstance(keywords, list) else [keywords]
785 for k in keywords:
786 if self.is_key_ok(k):
787 value = self._header[k]
788 keyword = k
789 break
790 else:
791 if checker is not None:
792 try:
793 checker(self)
794 value = default
795 if value is not None:
796 value = u.Quantity(value, unit=unit)
797 return value
798 except Exception:
799 pass
800 raise KeyError(f"Could not find {keywords} in header")
801 if isinstance(value, str):
802 # Sometimes the header has the wrong type in it but this must
803 # be a number if we are creating a quantity.
804 value = float(value)
805 self._used_these_cards(keyword)
806 if default is not None:
807 value = self.validate_value(value, default, maximum=maximum, minimum=minimum)
808 return u.Quantity(value, unit=unit)
810 def _join_keyword_values(self, keywords, delim="+"):
811 """Join values of all defined keywords with the specified delimiter.
813 Parameters
814 ----------
815 keywords : iterable of `str`
816 Keywords to look for in header.
817 delim : `str`, optional
818 Character to use to join the values together.
820 Returns
821 -------
822 joined : `str`
823 String formed from all the keywords found in the header with
824 defined values joined by the delimiter. Empty string if no
825 defined keywords found.
826 """
827 values = []
828 for k in keywords:
829 if self.is_key_ok(k):
830 values.append(self._header[k])
831 self._used_these_cards(k)
833 if values:
834 joined = delim.join(str(v) for v in values)
835 else:
836 joined = ""
838 return joined
840 @cache_translation
841 def to_detector_unique_name(self):
842 """Return a unique name for the detector.
844 Base class implementation attempts to combine ``detector_name`` with
845 ``detector_group``. Group is only used if not `None`.
847 Can be over-ridden by specialist translator class.
849 Returns
850 -------
851 name : `str`
852 ``detector_group``_``detector_name`` if ``detector_group`` is
853 defined, else the ``detector_name`` is assumed to be unique.
854 If neither return a valid value an exception is raised.
856 Raises
857 ------
858 NotImplementedError
859 Raised if neither detector_name nor detector_group is defined.
860 """
861 name = self.to_detector_name()
862 group = self.to_detector_group()
864 if group is None and name is None:
865 raise NotImplementedError("Can not determine unique name from detector_group and detector_name")
867 if group is not None:
868 return f"{group}_{name}"
870 return name
872 @cache_translation
873 def to_exposure_group(self):
874 """Return the group label associated with this exposure.
876 Base class implementation returns the ``exposure_id`` in string
877 form. A subclass may do something different.
879 Returns
880 -------
881 name : `str`
882 The ``exposure_id`` converted to a string.
883 """
884 exposure_id = self.to_exposure_id()
885 if exposure_id is None:
886 return None
887 else:
888 return str(exposure_id)
890 @cache_translation
891 def to_observation_reason(self):
892 """Return the reason this observation was taken.
894 Base class implementation returns the ``science`` if the
895 ``observation_type`` is science, else ``unknown``.
896 A subclass may do something different.
898 Returns
899 -------
900 name : `str`
901 The reason for this observation.
902 """
903 obstype = self.to_observation_type()
904 if obstype == "science":
905 return "science"
906 return "unknown"
908 @cache_translation
909 def to_observing_day(self):
910 """Return the YYYYMMDD integer corresponding to the observing day.
912 Base class implementation uses the TAI date of the start of the
913 observation.
915 Returns
916 -------
917 day : `int`
918 The observing day as an integer of form YYYYMMDD. If the header
919 is broken and is unable to obtain a date of observation, ``0``
920 is returned and the assumption is made that the problem will
921 be caught elsewhere.
922 """
923 datetime_begin = self.to_datetime_begin()
924 if datetime_begin is None:
925 return 0
926 return int(datetime_begin.tai.strftime("%Y%m%d"))
928 @cache_translation
929 def to_observation_counter(self):
930 """Return an integer corresponding to how this observation relates
931 to other observations.
933 Base class implementation returns ``0`` to indicate that it is not
934 known how an observatory will define a counter. Some observatories
935 may not use the concept, others may use a counter that increases
936 for every observation taken for that instrument, and others may
937 define it to be a counter within an observing day.
939 Returns
940 -------
941 sequence : `int`
942 The observation counter. Always ``0`` for this implementation.
943 """
944 return 0
947def _make_abstract_translator_method(property, doc, return_typedoc, return_type):
948 """Create a an abstract translation method for this property.
950 Parameters
951 ----------
952 property : `str`
953 Name of the translator for property to be created.
954 doc : `str`
955 Description of the property.
956 return_typedoc : `str`
957 Type string of this property (used in the doc string).
958 return_type : `class`
959 Type of this property.
961 Returns
962 -------
963 m : `function`
964 Translator method for this property.
965 """
966 def to_property(self):
967 raise NotImplementedError(f"Translator for '{property}' undefined.")
969 to_property.__doc__ = f"""Return value of {property} from headers.
971 {doc}
973 Returns
974 -------
975 {property} : `{return_typedoc}`
976 The translated property.
977 """
978 return to_property
981# Make abstract methods for all the translators methods.
982# Unfortunately registering them as abstractmethods does not work
983# as these assignments come after the class has been created.
984# Assigning to __abstractmethods__ directly does work but interacts
985# poorly with the metaclass automatically generating methods from
986# _trivialMap and _constMap.
988# Allow for concrete translator methods to exist in the base class
989# These translator methods can be defined in terms of other properties
990CONCRETE = set()
992for name, description in PROPERTIES.items():
993 method = f"to_{name}"
994 if not MetadataTranslator.defined_in_this_class(method):
995 setattr(MetadataTranslator, f"to_{name}",
996 abstractmethod(_make_abstract_translator_method(name, *description[:3])))
997 else:
998 CONCRETE.add(method)
1001class StubTranslator(MetadataTranslator):
1002 """Translator where all the translations are stubbed out and issue
1003 warnings.
1005 This translator can be used as a base class whilst developing a new
1006 translator. It allows testing to proceed without being required to fully
1007 define all translation methods. Once complete the class should be
1008 removed from the inheritance tree.
1010 """
1011 pass
1014def _make_forwarded_stub_translator_method(cls, property, doc, return_typedoc, return_type):
1015 """Create a stub translation method for this property that calls the
1016 base method and catches `NotImplementedError`.
1018 Parameters
1019 ----------
1020 cls : `class`
1021 Class to use when referencing `super()`. This would usually be
1022 `StubTranslator`.
1023 property : `str`
1024 Name of the translator for property to be created.
1025 doc : `str`
1026 Description of the property.
1027 return_typedoc : `str`
1028 Type string of this property (used in the doc string).
1029 return_type : `class`
1030 Type of this property.
1032 Returns
1033 -------
1034 m : `function`
1035 Stub translator method for this property.
1036 """
1037 method = f"to_{property}"
1039 def to_stub(self):
1040 parent = getattr(super(cls, self), method, None)
1041 try:
1042 if parent is not None:
1043 return parent()
1044 except NotImplementedError:
1045 pass
1047 warnings.warn(f"Please implement translator for property '{property}' for translator {self}",
1048 stacklevel=3)
1049 return None
1051 to_stub.__doc__ = f"""Unimplemented forwarding translator for {property}.
1053 {doc}
1055 Calls the base class translation method and if that fails with
1056 `NotImplementedError` issues a warning reminding the implementer to
1057 override this method.
1059 Returns
1060 -------
1061 {property} : `None` or `{return_typedoc}`
1062 Always returns `None`.
1063 """
1064 return to_stub
1067# Create stub translation methods for each property. These stubs warn
1068# rather than fail and should be overridden by translators.
1069for name, description in PROPERTIES.items():
1070 setattr(StubTranslator, f"to_{name}", _make_forwarded_stub_translator_method(StubTranslator,
1071 name, *description[:3]))