Source code for pyobs.utils.fits

"""
TODO: write doc
"""

__title__ = "FITS utilities"

import logging
import re
from collections.abc import Callable
from typing import Any, cast

from astropy.io import fits
from numpy.typing import NDArray

from pyobs.utils.time import Time

log = logging.getLogger(__name__)


[docs] def parse_section_bounds(header: fits.Header, keyword: str = "TRIMSEC") -> tuple[int, int, int, int] | None: """Parse a FITS section keyword (e.g. TRIMSEC) into 0-based, half-open slice bounds. Args: header: Header to look up the keyword in. keyword: Header keyword for section. Returns: A tuple ``(x0, x1, y0, y1)`` -- ``data[y0:y1, x0:x1]`` gives the section -- or ``None`` if the keyword is not present in the header. Raises: ValueError: If the keyword is present but its value is malformed. """ # keyword not given? if keyword not in header: return None # get value of section sec = header[keyword] # split values try: s = sec[1:-1].split(",") x = s[0].split(":") y = s[1].split(":") x0 = int(x[0]) - 1 x1 = int(x[1]) y0 = int(y[0]) - 1 y1 = int(y[1]) except (IndexError, ValueError) as e: raise ValueError(f"Invalid {keyword} value: {sec!r}") from e return x0, x1, y0, y1
[docs] def fitssec(hdu: Any, keyword: str = "TRIMSEC") -> NDArray[Any]: """Trim an image to TRIMSEC or BIASSEC. Args: hdu: HDU to take data from. keyword: Header keyword for section. Returns: Numpy array with image data. """ # parse section bounds bounds = parse_section_bounds(hdu.header, keyword) if bounds is None: # return whole data return cast(NDArray[Any], hdu.data) # return data x0, x1, y0, y1 = bounds return cast(NDArray[Any], hdu.data[y0:y1, x0:x1])
[docs] class FilenameFormatter: def __init__(self, fmt: str | list[str], keys: dict[str, int | float | str] | None = None): """Initializes a new filename formatter. Args: fmt: Filename format or list of formats. If list is given, first valid one is used. keys: Additional keys to pass to the formatter. """ self.format = fmt self.keys: dict[str, int | float | str] = {} if keys is None else keys # define functions self.funcs: dict[str, Callable[..., str]] = { "lower": self._format_lower, "time": self._format_time, "date": self._format_date, "filter": self._format_filter, "string": self._format_string, "type": self._format_type, "exptime": self._format_exptime, } def _value(self, hdr: fits.Header, key: str) -> int | float | str: """Returns value for given key. Args: hdr: fits.Header to take value from. key: Key to return value for. Returns: Value for given key. """ # first check keys if key in self.keys: return self.keys[key] # then check header return cast(int | float | str, hdr[key]) def __call__(self, hdr: fits.Header) -> str: """Formats a filename given a format template and a FITS header. Args: hdr: FITS header to take values from. Returns: Formatted filename. Raises: KeyError: If either keyword could not be found in header or method could not be found. """ # make fmt a list if self.format is None: return "" if isinstance(self.format, str): self.format = [self.format] # loop formats for f in self.format: try: # find all placeholders in format placeholders = re.findall(r"\{[\w\d_-]+(?:\|[\w\d_-]+\:?(?:[\w\d_-]+)*)?\}", f) if len(placeholders) == 0: return f # create output output = f # loop all placeholders for ph in placeholders: # call method and replace output = output.replace(ph, str(self._format_placeholder(ph, hdr))) # finished return output except KeyError: # this format didn't work pass # still here? raise KeyError("No valid format found.") def _format_placeholder(self, placeholder: str, hdr: fits.Header) -> str: """Format a given placeholder. Args: placeholder: Placeholder to format. hdr: FITS header to take values from. Returns: Formatted placeholder. Raises: KeyError: If method or FITS header keyword don't exist. """ # remove curly brackets key = placeholder[1:-1] method = None params = [] # do we have a pipe in here? if "|" in key: key, method = key.split("|") # parameters for method? if method is not None and ":" in method: method, *params = method.split(":") # if no method is given, just replace if method is None: return str(self._value(hdr, key)) else: # get function (may raise KeyError) func = self.funcs[method] # call method and replace return func(hdr, key, *params) def _format_lower(self, hdr: fits.Header, key: str) -> str: """Sets a given string to lowercase. Args: hdr: FITS header to take values from. key: The name of the FITS header key to use. Returns: Formatted string. """ return str(self._value(hdr, key)).lower() def _format_time(self, hdr: fits.Header, key: str, delimiter: str = "-") -> str: """Formats time using the given delimiter. Args: hdr: FITS header to take values from. key: The name of the FITS header key to use. delimiter: Delimiter for time formatting. Returns: Formatted string. """ fmt = "%H" + delimiter + "%M" + delimiter + "%S" date_obs = Time(self._value(hdr, key)) return str(date_obs.datetime.strftime(fmt)) def _format_date(self, hdr: fits.Header, key: str, delimiter: str = "-") -> str: """Formats date using the given delimiter. Args: hdr: FITS header to take values from. key: The name of the FITS header key to use. delimiter: Delimiter for date formatting. Returns: Formatted string. """ fmt = "%Y" + delimiter + "%m" + delimiter + "%d" date_obs = Time(self._value(hdr, key)) return str(date_obs.datetime.strftime(fmt)) def _format_filter(self, hdr: fits.Header, key: str, image_type: str = "IMAGETYP", prefix: str = "-") -> str: """Formats a filter, prefixed by a given separator, only if the image type requires it. Args: hdr: FITS header to take values from. key: The name of the FITS header key to use. image_type: FITS header key for IMAGETYP. prefix: Prefix to add to filter. Returns: Formatted string. """ it = hdr[image_type].lower() if it in ["light", "object", "flat", "skyflat"]: return prefix + str(self._value(hdr, key)) else: return "" def _format_string(self, hdr: fits.Header, key: str, fmt: str) -> str: """Formats a string using Python string substitution. Args: hdr: FITS header to take values from. key: The name of the FITS header key to use. fmt: A Python string format like %d, %05d, or %4.1f. Returns: Formatted string. """ fmt = "%" + fmt return str(fmt % self._value(hdr, key)) def _format_type(self, hdr: fits.Header, key: str) -> str: """Formats an image type to a one-letter code. Args: hdr: FITS header to take values from. key: The name of the FITS header key to use. Returns: Formatted string. """ if self._value(hdr, key) == "bias": return "b" elif self._value(hdr, key) == "skyflat": return "f" elif self._value(hdr, key) == "dark": return "d" else: return "e" def _format_exptime(self, hdr: fits.Header, key: str) -> str: """Formats an exposure time, rendering whole-second values without a trailing ".0" (e.g. 600.0 -> "600", 0.333 -> "0.333") so per-exptime master filenames stay readable. Unlike the other format functions, a missing key renders as "unknown" rather than raising -- a legacy dark master combined from raw frames that never carried EXPTIME at all has no numeric value to render, and this placeholder lets filename formatting (and therefore storing the master at all) still succeed for that master. Args: hdr: FITS header to take values from. key: The name of the FITS header key to use. Returns: Formatted string. """ if key not in self.keys and key not in hdr: return "unknown" value = float(self._value(hdr, key)) return str(int(value)) if value == int(value) else str(value)
[docs] def format_filename(hdr: fits.Header, fmt: str | list[str], keys: dict[str, Any] | None = None) -> str: """Formats a filename given a format template and a FITS header. Args: hdr: FITS header to take values from. fmt: Filename format or list of formats. If multiple formats are given, the first valid one is used. keys: Additional keys to pass to the format string. Returns: Filename Raises: KeyError: If either keyword could not be found in header or method could not be found. """ ff = FilenameFormatter(fmt, keys) return ff(hdr)
__all__ = ["format_filename", "FilenameFormatter", "fitssec", "parse_section_bounds"]