"""Context metadata."""
from enum import StrEnum, nonmember
from typing import ClassVar
import astropy.units as u
from astropydantic import AstroPydanticTime
from ..._core import AstroField, ModelBase, Quantity, doc, enum_doc
from .. import sciops
from ..common import TimeReference
_NAMESPACE = "CTAO.DataProducts.Context"
__all__ = [
"EnergyCoverage",
"TimeCoverage",
"SpaceCoverage",
"GeodeticEarthLocation",
"SpatialFrame",
"Observation",
"Atmosphere",
"TrackingCoverage",
]
class EnergyCoverage(ModelBase):
"""Describes spectral energy coverage of a data product."""
_namespace: ClassVar[str] = _NAMESPACE
energy_min: Quantity[u.TeV] = AstroField(
description=(
"Approximate minimum energy of the dataset, precise enough for discovering"
" the data product by energy range. Note that this maps to the IVOA ObsCore"
" ``em_min`` keyword, however that is expressed as a wavelength."
),
ucd="em.energy;stat.min",
ivoa_keyword="",
fits_keyword="EMIN",
)
energy_max: Quantity[u.TeV] = AstroField(
description=(
"Approximate maximum energy of the dataset, precise enough for discovering"
" the data product by energy range. Note that this maps to the IVOA ObsCore"
" ``em_max`` keyword, however that is expressed as a wavelength."
),
ucd="em.energy;stat.max",
fits_keyword="EMAX",
)
class TimeCoverage(ModelBase):
"""Time bounds for discoverability of a data product with time coverage."""
_namespace: ClassVar[str] = _NAMESPACE
t_min: AstroPydanticTime = AstroField(
"Start of time range of the data as either an ISO string or a float in UTC",
fits_keyword="TSTART",
)
t_max: AstroPydanticTime = AstroField(
"End of time range of the data, as either an ISO string or a float UTC",
fits_keyword="TSTOP",
)
# this must be kept for backward-compatibility:
reference: TimeReference | None = AstroField(
"DEPRECATED", default=None, deprecated="removed in v2.0.0, MET not allowed"
)
class SpatialFrame(StrEnum):
"""
Standard equatorial coordinate system used for RA/Dec coordinates.
Note that for tablular data products, columns of coordinate values have
their own standards in FITS and IVOA for specifying the frame, e.g. the
``RADESYSn`` column metadata attribute.
"""
_namespace = nonmember(_NAMESPACE)
ICRS = "ICRS" #: only ICRS is currently supported.
class SpaceCoverage(ModelBase):
"""
Defines spatial coverage of a data product.
The circle defined by ra, dec, field_of_view is used for the discoverability
of data products by cone search, and should be considered a bounding circle
if the true region covered is non-circular. The optional fields
region_of_interest and moc can be used for more precise definition of the
coverage.
"""
_namespace: ClassVar[str] = _NAMESPACE
frame: SpatialFrame = AstroField(
description=doc(SpatialFrame), default=SpatialFrame.ICRS, fits_keyword="RADESYS"
)
ra: Quantity[u.deg] = AstroField(
description="ICRS Right ascension of the center of the region covered.",
fits_keyword="RA_PNT",
ivoa_keyword="s_ra",
ucd="pos.eq.ra",
)
dec: Quantity[u.deg] = AstroField(
description="ICRS Declination of the center of the region covered",
fits_keyword="DEC_PNT",
ivoa_keyword="s_dec",
ucd="pos.eq.dec",
)
field_of_view: Quantity[u.deg] = AstroField(
description=(
"Approximate diameter (not radius) of the region covered by this data"
" product if approximated as a circle on a sphere. For energy-dependent"
" FOVs, a representative value should be chosen such that the data product"
" can be identified in a cone search. Note that more accurate ROI shapes"
" can be specified using the `region_of_interest` field, but"
" `field_of_view` should always be filled because it provides a simple way"
" to do a rough filtering."
),
fits_keyword="FOV",
ivoa_keyword="s_fov",
ucd="pos.spherical.r;instr.fov",
)
region_of_interest: str | None = AstroField(
default=None,
description="IVOA ADQL-format region string describing the region of interest.",
ivoa_keyword="s_region",
fits_keyword="REGION",
examples=[
"Box ICRS 83.633083 22.0145 5 5",
"Circle ICRS 83.633083 22.0145 5",
(
"Polygon ICRS 81.133083 19.5145 86.133083 19.5145 86.133083 24.5145 "
" 81.133083 24.5145"
),
],
ucd="meta.coverage;obs.field",
)
moc: str | None = AstroField(
description=(
"Multi-order coverage (MOC) string describing the region "
"covered by the data product. https://www.ivoa.net/documents/MOC/"
),
default=None,
examples=[
"4/2609 2611-2612 5/10350-10351 10362-10363 10433-10435 10440-10441 10443"
" 10452 10456-10458 10468-10469 6/41398-41399 41442-41443 41446-41447"
" 41464 41466-41467 41769-41771 41816-41818 41836 41840 41857 41860-41861"
" 41863 41920"
],
ucd="meta.coverage;obs.field",
)
class TrackingCoverage(ModelBase):
"""
Describes the coverage of ground-referenced observing parameters.
This is required only for data products where knowing the horizontal (ground
based alt/az) coverage is relevant. For equatorial tracking, elevation and
azimuth vary with time. For fixed-horizontal tracking, ranges may collapse
to single values.
"""
_namespace: ClassVar[str] = _NAMESPACE
elevation_min: Quantity[u.deg] = AstroField(
"Minimum elevation above horizon of observation.",
ucd="pos.az.alt;stat.min",
fits_keyword="ALT_MIN",
)
elevation_max: Quantity[u.deg] = AstroField(
"Maximum elevation above horizon of observation.",
ucd="pos.az.alt;stat.max",
fits_keyword="ALT_MAX",
)
azimuth_mean: Quantity[u.deg] = AstroField(
"Mean azimith of observation.",
ucd="pos.az.azi;stat.mean",
fits_keyword="AZ_MEAN",
)
azimuth_range: Quantity[u.deg] = AstroField(
"Full angular width of the observation in azimuth. Defines the horizontal"
" coverage around `azimuth_mean`. The value should be capped at 360 deg, even"
" if the telecope moves for larger than one rotation during the observation.",
ge=0.0,
le=360.0,
ucd="pos.az.azi;stat.interval",
fits_keyword="AZ_RANGE",
)
mode: sciops.TrackingMode = AstroField(
doc(sciops.TrackingMode), fits_keyword="TRACKING"
)
class Coverage(ModelBase):
"""
Describes the spatial, temporal, spectral, and tracking coverage of a data product.
This information is for the discovery of data products, not to specify
precise bounds for science analysis. Typically this information can only be
attached to higher-level data products, e.g. DL3 and above, as the
information may not be available before processing.
"""
_namespace: ClassVar[str] = _NAMESPACE
time: TimeCoverage | None = AstroField(description=doc(TimeCoverage), default=None)
space: SpaceCoverage | None = AstroField(
description=doc(SpaceCoverage), default=None
)
energy: EnergyCoverage | None = AstroField(
description=doc(EnergyCoverage), default=None
)
tracking: TrackingCoverage | None = AstroField(
description=doc(TrackingCoverage), default=None
)
class GeodeticEarthLocation(ModelBase):
"""Position on Earth of the observatory in geodetic coordinates."""
# TODO: allow serialization to "classic" keywords "GEOLAT, GEOLON, ALTITUDE."
_namespace: ClassVar[str] = _NAMESPACE
longitude: Quantity[u.deg] = AstroField(
"Longitude, with East positive.", fits_keyword="OBSGEO-L"
)
latitude: Quantity[u.deg] = AstroField(
"Latitude, with North positive.", fits_keyword="OBSGEO-B"
)
height: Quantity[u.m] = AstroField(
"Height above sea level", fits_keyword="OBSGEO-H"
)
[docs]
def to_earthlocation(self):
"""Convert this to an astropy EarthLocation."""
from astropy.coordinates import EarthLocation
return EarthLocation.from_geodetic(
lon=self.longitude, lat=self.latitude, height=self.height
)
class Atmosphere(ModelBase):
"""Overall atmospheric quality of the observation."""
_namespace: ClassVar[str] = _NAMESPACE
quality: sciops.SkyQuality = AstroField(
enum_doc(sciops.SkyQuality), fits_keyword="SKYQUAL"
)
brightness: sciops.SkyBrightness = AstroField(
enum_doc(sciops.SkyBrightness), fits_keyword="SKYBRIGH"
)
class Observation(ModelBase):
"""Context info for data products associated with an observation."""
_namespace: ClassVar[str] = _NAMESPACE
coverage: Coverage | None = AstroField(doc(Coverage), default=None)
location: GeodeticEarthLocation | None = AstroField(
doc(GeodeticEarthLocation), default=None
)