Source code for ctao_datamodel.models.dataproducts.observation

"""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 )