Source code for ctao_datamodel._fits
"""Serialization of models to and from FITS headers."""
import re
import warnings
from pathlib import Path
from astropy import units as u
from astropy.io import fits
from astropy.io.fits import Card, Header
from astropy.table import Table
from pydantic import BaseModel
from ._core import QuantityFormat
from ._table import (
ColumnValidationIssue,
TableValidationError,
model_validate_astropy_table,
)
from ._visitor import (
extract_keyword_mapping,
flatten_model_instance,
get_field_metadata,
get_field_unit,
unflatten_model_instance,
)
__all__ = [
"instance_to_fits_header",
"fits_header_to_flat_dict",
"fits_header_to_instance",
"validate_fits_bintable_hdu",
]
def _get_field_from_instance(model_instance: BaseModel, flat_key: str, sep: str = "."):
"""Return FieldInfo for an instance at a flattened key."""
path = flat_key.split(sep)
field = path.pop()
instance = model_instance
for attr in path:
# handle expanded list, where the key is an integer:
if isinstance(instance, list) and re.match("^[0-9]+$", attr):
list_index = int(attr)
instance = instance[list_index]
continue
# normal case
instance = getattr(instance, attr)
# now instance is the sub-model, so get the field:
return instance.__class__.model_fields[field]
[docs]
def instance_to_fits_header(
model_instance: BaseModel, use_short: bool = True, hierarch_namespace: str = "CTAO"
) -> Header:
"""
Convert a model instance to a FITS header.
The resulting header will have a Card for each keyword. Long keywords will use the
FITS HIERARCH standard in the given namespace
Parameters
----------
model_instance: BaseModel
model instance to serialize
use_short: bool
if True, replace any log keywords that have fits_keyword mapping with their short form.
hierarch_namespace: str
starting string for the HIERARCH keyword
Returns
-------
Header:
FITS header suitable for writing to a file.
"""
flat = flatten_model_instance(
model_instance,
separator=" ",
to_string=True,
quantity_format=QuantityFormat.FLOAT,
)
cards = []
hierarch_cards = []
for k, v in flat.items():
field = _get_field_from_instance(model_instance, k, sep=" ")
unit = get_field_unit(field=field)
desc = get_field_metadata(field=field, metadata_key="description")
fits_keyword = get_field_metadata(field=field, metadata_key="fits_keyword")
if unit:
unit = u.Unit(unit)
comment = f"[{unit:fits}] {desc}"
else:
comment = desc
if fits_keyword and use_short:
keyword = fits_keyword
cards.append(Card(keyword=keyword, value=v, comment=comment))
else:
keyword = f"HIERARCH {hierarch_namespace} {k.upper()}"
hierarch_cards.append(Card(keyword=keyword, value=v, comment=comment))
return Header(cards + hierarch_cards)
def get_fits_to_ctao_mapping(model: type[BaseModel], sep: str = ".") -> dict[str, str]:
"""Return a dict mapping FITS keyword to flat CTAO keyword."""
# get the mapping between FITS key and CTAO key
ctao_to_fits = extract_keyword_mapping(model, metadata_key="fits_keyword", sep=sep)
# make reverse mapping:
fits_to_ctao = dict()
for ctao_key, fits_key_list in ctao_to_fits.items():
for fits_key in fits_key_list:
fits_to_ctao[fits_key] = ctao_key
return fits_to_ctao
[docs]
def fits_header_to_flat_dict(
header: Header, model: type[BaseModel], ignore_extra_keys: bool = True
) -> dict[str, str]:
"""
Turn a FITS header back into a flat dict of CTAO keywords.
Parameters
----------
header: Header
FITS header with keys and values to extract
model: type[BaseModel]
Model to use for schema and key mapping
ignore_extra_keys: bool
If False, issue warnings for keys in header that do not
map to model.
Returns
-------
dict[str,str]:
mapping of CTAO keyword to string value.
"""
# get the mapping
fits_to_ctao = get_fits_to_ctao_mapping(model=model)
# build the flat dict
flattened = dict()
for fits_key in header.keys():
if fits_key == "COMMENT":
# comments are handled specially by Astropy, and they span multiple
# keys. However, converting them to strings joins them into one.
value = str(header[fits_key])
else:
value = header[
fits_key
] # use this to get value right for handles multi-card values
if fits_key.startswith("CTAO "):
# Hierarchical keys can just be converted:
ctao_key = ".".join(fits_key.replace("CTAO ", "").lower().split(" "))
elif fits_key in fits_to_ctao:
ctao_key = fits_to_ctao[fits_key]
else:
if not ignore_extra_keys:
warnings.warn(f"Key '{fits_key}' is not in model {model.__name__}")
continue
flattened[ctao_key] = value
return flattened
[docs]
def fits_header_to_instance(
header: Header, model: type[BaseModel], ignore_extra_keys: bool = True
) -> BaseModel:
"""
Turn a FITS header back into an instance of a model.
Parameters
----------
header: Header
FITS header with keys and values to extract
model: type[BaseModel]
Model to use for schema and key mapping
ignore_extra_keys: bool
If False, issue warnings for keys in header that do not
map to model.
Returns
-------
BaseModel:
instance of model provided
"""
from ._versioning import FITSHeaderMigration, VersionedModel
# first apply any migrations to the header
if issubclass(model, VersionedModel):
fits_migration = FITSHeaderMigration(header)
for info in model._migrations:
info.apply(fits_migration)
# Then convert from FITS mapping and unflatten it:
flattened = fits_header_to_flat_dict(
header=header, model=model, ignore_extra_keys=ignore_extra_keys
)
return unflatten_model_instance(
flattened, model=model, parent_key="", separator="."
)
[docs]
def validate_fits_bintable_hdu(
fits_file: Path | str,
hdu: int | str,
column_model: type[BaseModel],
header_model: type[BaseModel],
) -> tuple[Table, BaseModel]:
"""
Validate a FITS Bintable HDU.
Parameters
----------
fits_file: Path | str
the file to validate
hdu: int | str
name or number of the HDU to validate in the file
column_model: type[BaseModel]
The model defining the schema for the table columns. It must have fields
that have the fits_column_dtype set.
header_model: type[BaseModel]
The model defining the schema for the header metadata to validate.
Returns
-------
tuple[Table,BaseModel]:
The table in Astropy Table format, and the metadata as a
deserialized model.
"""
issues = []
# Check the table schema:
table = Table.read(Path(fits_file), hdu=hdu)
try:
model_validate_astropy_table(table, model=column_model)
except TableValidationError as err:
issues += err.issues
# check the metadata
with fits.open(fits_file) as fitsfile:
try:
metadata = fits_header_to_instance(fitsfile["EVENTS"].header, header_model)
except ValueError as err:
issues.append(
ColumnValidationIssue(
column="metadata", kind="keyword", message=str(err)
)
)
if issues:
raise TableValidationError(issues=issues)
return table, metadata