Source code for ska_tmc_cdm.messages.subarray_node.configure.core

"""
The configure.common module contains simple Python representations of the
structured request and response for the TMC SubArrayNode.Configure command.

As configurations become more complex, they may be rehomed in a submodule of
this package.
"""
import math
from dataclasses import InitVar, field
from enum import Enum
from typing import ClassVar, Optional

from astropy import units as u
from astropy.coordinates import SkyCoord
from pydantic import ConfigDict, model_validator
from pydantic.dataclasses import dataclass

__all__ = [
    "PointingConfiguration",
    "Target",
    "PointingCorrection",
    "ReceiverBand",
    "DishConfiguration",
]

UnitStr = str | u.Unit


@dataclass(
    config=ConfigDict(arbitrary_types_allowed=True)
)  # Required because AstroPy types aren't Pydantic models
class Target:
    """
    Target encapsulates source coordinates and source metadata.

    The SubArrayNode ICD specifies that RA and Dec must be provided, hence
    non-ra/dec frames such as galactic are not supported.
    """

    ra: InitVar[Optional[str | int | float | u.Quantity]] = None
    dec: InitVar[Optional[str | int | float | u.Quantity]] = None
    target_name: str = ""
    reference_frame: InitVar[str] = "icrs"
    unit: InitVar[UnitStr | tuple[UnitStr, UnitStr]] = (
        u.hourangle,
        u.deg,
    )
    ca_offset_arcsec: float = 0.0
    ie_offset_arcsec: float = 0.0
    coord: Optional[SkyCoord] = field(init=False)

    OFFSET_MARGIN_IN_RAD: ClassVar[float] = 6e-17  # Arbitrary small number

    def __post_init__(
        self,
        ra: str | u.Quantity,
        dec: str | u.Quantity,
        reference_frame: str,
        unit: u.Unit,
    ):
        if ra is None and dec is None:
            self.coord = None
        else:
            self.coord = SkyCoord(
                ra=ra, dec=dec, unit=unit, frame=reference_frame
            )

    @model_validator(mode="after")
    def coord_or_offsets_required(self) -> "Target":
        if self.coord is None:
            if not (self.ca_offset_arcsec or self.ie_offset_arcsec):
                raise ValueError(
                    "A Target() must specify either ra/dec or one nonzero ca_offset_arcsec or ie_offset_arcsec"
                )
        return self

    def __eq__(self, other):
        if not isinstance(other, Target):
            return False
        # Either both are None or both defined...
        if bool(self.coord) != bool(other.coord):
            return False

        # Common checks:
        name_and_offsets_matching = (
            self.target_name == other.target_name
            and math.isclose(
                self.ca_offset_arcsec,
                other.ca_offset_arcsec,
                abs_tol=self.OFFSET_MARGIN_IN_RAD,
            )
            and math.isclose(
                self.ie_offset_arcsec,
                other.ie_offset_arcsec,
                abs_tol=self.OFFSET_MARGIN_IN_RAD,
            )
        )
        if not name_and_offsets_matching:
            return False

        # Please replace this with a more elegant way of dealing with differences
        # comparing targets with different properties...
        if self.coord is not None:
            sep = self.coord.separation(other.coord)
            return (
                self.coord.frame.name == other.coord.frame.name
                and sep.radian < self.OFFSET_MARGIN_IN_RAD
            )
        return True

    def __repr__(self):
        if self.coord is None:
            return "Target(target_name={!r}, ca_offset_arcsect={!r}, ie_offset_arcsec={!r})".format(
                self.target_name, self.ca_offset_arcsec, self.ie_offset_arcsec
            )
        else:
            raw_ra = self.coord.ra.value
            raw_dec = self.coord.dec.value
            units = (self.coord.ra.unit.name, self.coord.dec.unit.name)
            reference_frame = self.coord.frame.name
            target_name = self.target_name
            return "Target(ra={!r}, dec={!r}, target_name={!r}, reference_frame={!r}, unit={!r}, ca_offset_arcsec={!r}, ie_offset_arcsec={!r})".format(
                raw_ra,
                raw_dec,
                target_name,
                reference_frame,
                units,
                self.ca_offset_arcsec,
                self.ie_offset_arcsec,
            )

    def __str__(self):
        reference_frame = self.coord.frame.name
        target_name = self.target_name
        hmsdms = self.coord.to_string(style="hmsdms")
        return "<Target: {!r} ({} {})>".format(
            target_name, hmsdms, reference_frame
        )


[docs] class PointingCorrection(Enum): """ Operation to apply to the pointing correction model. MAINTAIN: continue applying the current pointing correction model UPDATE: wait for (if necessary) and apply new pointing calibration solution RESET: reset the applied pointing correction to the pointing model defaults """ MAINTAIN = "MAINTAIN" UPDATE = "UPDATE" RESET = "RESET"
@dataclass class PointingConfiguration: """ PointingConfiguration specifies where the subarray receptors are going to point. """ target: Optional[Target] = None correction: Optional[PointingCorrection] = None
[docs] class ReceiverBand(Enum): """ ReceiverBand is an enumeration of SKA MID receiver bands. """ BAND_1 = "1" BAND_2 = "2" BAND_5A = "5a" BAND_5B = "5b"
@dataclass class DishConfiguration: """ DishConfiguration specifies how SKA MID dishes in a sub-array should be configured. At the moment, this is limited to setting the receiver band. """ receiver_band: ReceiverBand