Source code for yippy.header

"""Module for handling FITS header data."""

import re
from dataclasses import dataclass

import astropy.units as u
from astropy.io import fits
from lod_unit import lod

from .logger import logger


[docs] @dataclass(frozen=True) class HeaderData: """A dataclass for storing the header data with units attached. I think this will be a useful system, however this has not been tested considerably. This is just a suggestion for a more robust way to keep the FITS header data attached to the coronagraph. Right now, the header data is loaded by Coronagraph from the stellar_intens file. This may make more sense if Offax, StellarIntens, and SkyTrans each load their own header. """ simple: bool bitpix: int naxis: int naxis1: int naxis2: int naxis3: int design: str diameter: u.Quantity | None = None diameter_inscribed: u.Quantity | None = None pixscale: u.Quantity | None = None lambda0: u.Quantity | None = None minlam: u.Quantity | None = None maxlam: u.Quantity | None = None xcenter: float | None = None ycenter: float | None = None obscured: float | None = None jitter: u.Quantity | None = None n_lam: int | None = None n_star: int | None = None zernike: str | None = None wfe: u.Quantity | None = None
[docs] @staticmethod def extract_unit(comment: str, default_unit: u.Unit, key: str) -> u.Unit: """Extract a unit from the comment string of a FITS header entry. This attempts to read the FITS header comment and search for an astropy Unit. This seems fairly robust, but it may not catch all cases in which case a warning is raised. It is also possible that the unit is not explicitly stated in the comment, in which case a default unit is returned. Args: comment (str): The comment string associated with a FITS header key. default_unit (u.Unit): The default unit to return if no unit is found in the comment. key (str): The key of the header entry to extract the unit from. Returns: u.Unit: The extracted astropy unit or the default unit if no recognizable unit is found. Raises: Warning: Logs a warning if no unit could be extracted and the default unit is used. """ # Define a regex pattern for commonly expected units unit_patterns = { "nm": u.nm, "micron": u.micron, "microns": u.micron, "um": u.um, "mm": u.mm, "cm": u.cm, "meter": u.m, "m": u.m, "arcsec": u.arcsec, "arcmin": u.arcmin, "deg": u.deg, "mas": u.mas, "pm": u.pm, } # Match for patterns that look like compound units, e.g., "mas/pixel" compound_unit_pattern = r"\b(\w+)/(\w+)\b" match = re.search(compound_unit_pattern, comment, re.IGNORECASE) if match: num_unit, den_unit = match.groups() num_astropy_unit = unit_patterns.get(num_unit.lower(), None) den_astropy_unit = unit_patterns.get(den_unit.lower(), None) if num_astropy_unit and den_astropy_unit: logger.debug( f"Extracted compound unit {num_astropy_unit}/{den_astropy_unit}" f' for {key} from "{comment}"' ) return num_astropy_unit / den_astropy_unit # Search for any of these single units in the comment for pattern, unit in unit_patterns.items(): if re.search(r"\b" + re.escape(pattern) + r"\b", comment, re.IGNORECASE): logger.debug(f'Extracted {unit} from "{comment}" for {key}') return unit logger.warning( f"Using default unit for {key}: {default_unit}. " f'Could not extract unit from comment: "{comment}"' ) return default_unit
[docs] @staticmethod def get_header_value(header: fits.Header, key: str, default_unit: u.Unit): """Retrieves a header value by key and converts it to a specified unit. Args: header (fits.Header): The FITS header from which to retrieve the value. key (str): The key of the header entry to retrieve. default_unit (u.Unit): The unit to associate with the returned value. Returns: Optional[u.Quantity]: The header value with the specified unit, or None if the key is not present. """ if key in header: value = float(header[key]) unit = HeaderData.extract_unit(header.comments[key], default_unit, key) return value * unit else: return None
[docs] @staticmethod def from_fits_header(header: fits.Header): """Parses a FITS header. This parses the FITS header to initialize the HeaderData class, checking for any unhandled fields. Args: header (fits.Header): The FITS header to parse. Returns: HeaderData: An initialized HeaderData object populated with values from the FITS header. Raises: Warning: Logs a warning for any unhandled fields in the FITS header. """ recognized_keys = set( [ "SIMPLE", "BITPIX", "NAXIS", "NAXIS1", "NAXIS2", "NAXIS3", "DESIGN", "D", "D_INSC", "PIXSCALE", "LAMBDA", "MINLAM", "MAXLAM", "XCENTER", "YCENTER", "OBSCURED", "JITTER", "N_LAM", "N_STAR", "ZERNIKE", "WFE", ] ) header_keys = set(header.keys()) unhandled_keys = header_keys - recognized_keys if unhandled_keys: logger.warning(f"Unhandled header fields: {unhandled_keys}") return HeaderData( simple=header.get("SIMPLE", "F") == "T", bitpix=int(header.get("BITPIX", 0)), naxis=int(header.get("NAXIS", 0)), naxis1=int(header.get("NAXIS1", 0)), naxis2=int(header.get("NAXIS2", 0)), naxis3=int(header.get("NAXIS3", 0)), design=header.get("DESIGN", ""), diameter=HeaderData.get_header_value(header, "D", u.meter), diameter_inscribed=HeaderData.get_header_value(header, "D_INSC", u.meter), pixscale=header.get("PIXSCALE") * lod / u.pix, lambda0=HeaderData.get_header_value(header, "LAMBDA", u.micron), minlam=HeaderData.get_header_value(header, "MINLAM", u.micron), maxlam=HeaderData.get_header_value(header, "MAXLAM", u.micron), xcenter=header.get("XCENTER", None), ycenter=header.get("YCENTER", None), obscured=header.get("OBSCURED", None), jitter=HeaderData.get_header_value(header, "JITTER", u.mas), n_lam=header.get("N_LAM", None), n_star=header.get("N_STAR", None), zernike=header.get("ZERNIKE", ""), wfe=HeaderData.get_header_value(header, "WFE", u.pm), )