https://github.com/RadioAstronomySoftwareGroup/pyuvdata
Raw File
Tip revision: f9cbf410113a1077ff84769beafddb7bf30f44b4 authored by Bryna Hazelton on 29 March 2024, 15:47:47 UTC
address some of the review comments
Tip revision: f9cbf41
telescopes.py
# -*- mode: python; coding: utf-8 -*-
# Copyright (c) 2018 Radio Astronomy Software Group
# Licensed under the 2-clause BSD License

"""Telescope information and known telescope list."""
import os
import warnings

import numpy as np
from astropy.coordinates import Angle, EarthLocation

from pyuvdata.data import DATA_PATH

from . import parameter as uvp
from . import utils as uvutils
from . import uvbase

__all__ = ["Telescope", "known_telescopes"]

# We use astropy sites for telescope locations. The dict below is for
# telescopes not in astropy sites, or to include extra information for a telescope.

# The center_xyz is the location of the telescope in ITRF (earth-centered frame)

# Antenna positions can be specified via a csv file with the following columns:
# "name" -- antenna name, "number" -- antenna number, "x", "y", "z" -- ECEF coordinates
# relative to the telescope location.
KNOWN_TELESCOPES = {
    "PAPER": {
        "center_xyz": None,
        "latitude": Angle("-30d43m17.5s").radian,
        "longitude": Angle("21d25m41.9s").radian,
        "altitude": 1073.0,
        "citation": (
            "value taken from capo/cals/hsa7458_v000.py, "
            "comment reads KAT/SA  (GPS), altitude from elevationmap.net"
        ),
    },
    "HERA": {
        "center_xyz": None,
        "latitude": Angle("-30.72152612068925d").radian,
        "longitude": Angle("21.42830382686301d").radian,
        "altitude": 1051.69,
        "antenna_diameters": 14.0,
        "antenna_positions_file": "hera_ant_pos.csv",
        "citation": (
            "value taken from hera_mc geo.py script "
            "(using hera_cm_db_updates under the hood.)"
        ),
    },
    "SMA": {
        "center_xyz": None,
        "latitude": Angle("19d49m27.13895s").radian,
        "longitude": Angle("-155d28m39.08279s").radian,
        "altitude": 4083.948144,
        "citation": "Ho, P. T. P., Moran, J. M., & Lo, K. Y. 2004, ApJL, 616, L1",
    },
    "SZA": {
        "center_xyz": None,
        "latitude": Angle("37d16m49.3698s").radian,
        "longitude": Angle("-118d08m29.9126s").radian,
        "altitude": 2400.0,
        "citation": "Unknown",
    },
    "OVRO-LWA": {
        "center_xyz": None,
        "latitude": Angle("37.239777271d").radian,
        "longitude": Angle("-118.281666695d").radian,
        "altitude": 1183.48,
        "citation": "OVRO Sharepoint Documentation",
    },
    "MWA": {"antenna_positions_file": "mwa_ant_pos.csv"},
}


def _parse_antpos_file(antenna_positions_file):
    """
    Interpret the antenna positions file.

    Parameters
    ----------
    antenna_positions_file : str
        Name of the antenna_positions_file, which is assumed to be in DATA_PATH.
        Should contain antenna names, numbers and ECEF positions relative to the
        telescope location.

    Returns
    -------
    antenna_names : array of str
        Antenna names.
    antenna_names : array of int
        Antenna numbers.
    antenna_positions : array of float
        Antenna positions in ECEF relative to the telescope location.

    """
    columns = ["name", "number", "x", "y", "z"]
    formats = ["U10", "i8", np.longdouble, np.longdouble, np.longdouble]

    dt = np.format_parser(formats, columns, [])
    ant_array = np.genfromtxt(
        antenna_positions_file,
        delimiter=",",
        autostrip=True,
        skip_header=1,
        dtype=dt.dtype,
    )
    antenna_names = ant_array["name"]
    antenna_numbers = ant_array["number"]
    antenna_positions = np.stack((ant_array["x"], ant_array["y"], ant_array["z"])).T

    return antenna_names, antenna_numbers, antenna_positions.astype("float")


def known_telescopes():
    """
    Get list of known telescopes.

    Returns
    -------
    list of str
        List of known telescope names.
    """
    astropy_sites = [site for site in EarthLocation.get_site_names() if site != ""]
    known_telescopes = list(set(astropy_sites + list(KNOWN_TELESCOPES.keys())))
    return known_telescopes


class Telescope(uvbase.UVBase):
    """
    A class for defining a telescope for use with UVData objects.

    Attributes
    ----------
    citation : str
        text giving source of telescope information
    telescope_name : UVParameter of str
        name of the telescope
    telescope_location : UVParameter of array_like
        telescope location xyz coordinates in ITRF (earth-centered frame).
    antenna_diameters : UVParameter of float
        Optional, antenna diameters in meters. Used by CASA to construct a
        default beam if no beam is supplied.
    """

    def __init__(self):
        """Create a new Telescope object."""
        # add the UVParameters to the class
        # use the same names as in UVData so they can be automatically set
        self.citation = None

        self._name = uvp.UVParameter(
            "name", description="name of telescope (string)", form="str"
        )
        desc = (
            "telescope location: xyz in ITRF (earth-centered frame). "
            "Can also be set using telescope_location_lat_lon_alt or "
            "telescope_location_lat_lon_alt_degrees properties"
        )
        self._location = uvp.LocationParameter("location", description=desc, tols=1e-3)

        self._instrument = uvp.UVParameter(
            "instrument",
            description="Receiver or backend. Sometimes identical to telescope_name.",
            required=False,
            form="str",
            expected_type=str,
        )

        desc = "Number of antennas in the array."
        self._Nants = uvp.UVParameter(
            "Nants", required=False, description=desc, expected_type=int
        )

        desc = (
            "Array of antenna names, shape (Nants), "
            "with numbers given by antenna_numbers."
        )
        self._antenna_names = uvp.UVParameter(
            "antenna_names",
            description=desc,
            required=False,
            form=("Nants",),
            expected_type=str,
        )

        desc = (
            "Array of integer antenna numbers corresponding to antenna_names, "
            "shape (Nants)."
        )
        self._antenna_numbers = uvp.UVParameter(
            "antenna_numbers",
            description=desc,
            required=False,
            form=("Nants",),
            expected_type=int,
        )

        desc = (
            "Array giving coordinates of antennas relative to "
            "telescope_location (ITRF frame), shape (Nants, 3), "
            "units meters. See the tutorial page in the documentation "
            "for an example of how to convert this to topocentric frame."
        )
        self._antenna_positions = uvp.UVParameter(
            "antenna_positions",
            description=desc,
            required=False,
            form=("Nants", 3),
            expected_type=float,
            tols=1e-3,  # 1 mm
        )

        desc = (
            "Orientation of the physical dipole corresponding to what is "
            "labelled as the x polarization. Options are 'east' "
            "(indicating east/west orientation) and 'north (indicating "
            "north/south orientation)."
        )
        self._x_orientation = uvp.UVParameter(
            "x_orientation",
            description=desc,
            required=False,
            expected_type=str,
            acceptable_vals=["east", "north"],
        )

        desc = (
            "Antenna diameters in meters. Used by CASA to "
            "construct a default beam if no beam is supplied."
        )
        self._antenna_diameters = uvp.UVParameter(
            "antenna_diameters",
            description=desc,
            required=False,
            form=("Nants",),
            expected_type=float,
            tols=1e-3,  # 1 mm
        )

        super(Telescope, self).__init__()

    def check(self, *, check_extra=True, run_check_acceptability=True):
        """
        Add some extra checks on top of checks on UVBase class.

        Check that required parameters exist. Check that parameters have
        appropriate shapes and optionally that the values are acceptable.

        Parameters
        ----------
        check_extra : bool
            If true, check all parameters, otherwise only check required parameters.
        run_check_acceptability : bool
            Option to check if values in parameters are acceptable.

        Returns
        -------
        bool
            True if check passes

        Raises
        ------
        ValueError
            if parameter shapes or types are wrong or do not have acceptable
            values (if run_check_acceptability is True)

        """
        # first run the basic check from UVBase

        super(Telescope, self).check(
            check_extra=check_extra, run_check_acceptability=run_check_acceptability
        )

        if run_check_acceptability:
            # Check antenna positions
            uvutils.check_surface_based_positions(
                antenna_positions=self.antenna_positions,
                telescope_loc=self.location,
                telescope_frame=self._location.frame,
                raise_error=False,
            )

        return True

    @property
    def location_obj(self):
        """The location as an EarthLocation or MoonLocation object."""
        if self.location is None:
            return None

        if self._location.frame == "itrs":
            return EarthLocation.from_geocentric(*self.location, unit="m")
        elif uvutils.hasmoon and self._location.frame == "mcmf":
            moon_loc = uvutils.MoonLocation.from_selenocentric(*self.location, unit="m")
            moon_loc.ellipsoid = self._location.ellipsoid
            return moon_loc

    @location_obj.setter
    def location_obj(self, val):
        if isinstance(val, EarthLocation):
            self._location.frame = "itrs"
        elif uvutils.hasmoon and isinstance(val, uvutils.MoonLocation):
            self._location.frame = "mcmf"
            self._location.ellipsoid = val.ellipsoid
        else:
            raise ValueError(
                "location_obj is not a recognized location object. Must be an "
                "EarthLocation or MoonLocation object."
            )
        self.location = np.array(
            [val.x.to("m").value, val.y.to("m").value, val.z.to("m").value]
        )

    def update_params_from_known_telescopes(
        self,
        *,
        overwrite: bool = False,
        warn: bool = True,
        run_check: bool = True,
        check_extra: bool = True,
        run_check_acceptability: bool = True,
        known_telescope_dict: dict = KNOWN_TELESCOPES,
    ):
        """
        Update the parameters based on telescope in known_telescopes.

        This fills in any missing parameters (or to overwrite parameters that
        have inaccurate values) on self that are available for a telescope from
        either Astropy sites and/or from the KNOWN_TELESCOPES dict. This is
        primarily used on UVData, UVCal and UVFlag to fill in information that
        is missing, especially in older files.

        Parameters
        ----------
        overwrite : bool
            If set, overwrite parameters with information from Astropy sites
            and/or from the KNOWN_TELESCOPES dict. Defaults to False.
        warn : bool
            Option to issue a warning listing all modified parameters.
            Defaults to True.
        run_check : bool
            Option to check for the existence and proper shapes of parameters
            after updating.
        check_extra : bool
            Option to check optional parameters as well as required ones.
        run_check_acceptability : bool
            Option to check acceptable range of the values of parameters after
            updating.
        known_telescope_dict: dict
            This should only be used for testing. This allows passing in a
            different dict to use in place of the KNOWN_TELESCOPES dict.

        """
        astropy_sites = EarthLocation.get_site_names()
        telescope_keys = list(known_telescope_dict.keys())
        telescope_list = [tel.lower() for tel in telescope_keys]

        astropy_sites_list = []
        known_telescope_list = []
        # first deal with location.
        if overwrite or self.location is None:
            if self.name in astropy_sites:
                tel_loc = EarthLocation.of_site(self.name)

                self.citation = "astropy sites"
                self.location = np.array(
                    [tel_loc.x.value, tel_loc.y.value, tel_loc.z.value]
                )
                astropy_sites_list.append("telescope_location")

            elif self.name.lower() in telescope_list:
                telescope_index = telescope_list.index(self.name.lower())
                telescope_dict = known_telescope_dict[telescope_keys[telescope_index]]
                self.citation = telescope_dict["citation"]

                known_telescope_list.append("telescope_location")
                if telescope_dict["center_xyz"] is not None:
                    self.location = telescope_dict["center_xyz"]
                else:
                    if (
                        telescope_dict["latitude"] is None
                        or telescope_dict["longitude"] is None
                        or telescope_dict["altitude"] is None
                    ):
                        raise ValueError(
                            "Bad location information in known_telescopes_dict "
                            f"for telescope {self.name}. Either the center_xyz "
                            "or the latitude, longitude and altitude of the "
                            "telescope must be specified."
                        )
                    self.location_lat_lon_alt = (
                        telescope_dict["latitude"],
                        telescope_dict["longitude"],
                        telescope_dict["altitude"],
                    )
            else:
                # no telescope matching this name
                raise ValueError(
                    f"Telescope {self.name} is not in astropy_sites or "
                    "known_telescopes_dict."
                )

        # check for extra info
        if self.name.lower() in telescope_list:
            telescope_index = telescope_list.index(self.name.lower())
            telescope_dict = known_telescope_dict[telescope_keys[telescope_index]]

            if "antenna_positions_file" in telescope_dict.keys() and (
                overwrite
                or self.antenna_names is None
                or self.antenna_numbers is None
                or self.antenna_positions is None
                or self.Nants is None
            ):
                antpos_file = os.path.join(
                    DATA_PATH, telescope_dict["antenna_positions_file"]
                )
                antenna_names, antenna_numbers, antenna_positions = _parse_antpos_file(
                    antpos_file
                )
                ant_info = {
                    "Nants": antenna_names.size,
                    "antenna_names": antenna_names,
                    "antenna_numbers": antenna_numbers,
                    "antenna_positions": antenna_positions,
                }
                ant_params_missing = []
                for key in ant_info.keys():
                    if getattr(self, key) is None:
                        ant_params_missing.append(key)
                if overwrite or len(ant_params_missing) == len(ant_info.keys()):
                    for key, value in ant_info.items():
                        known_telescope_list.append(key)
                        setattr(self, key, value)
                elif self.antenna_names is not None or self.antenna_numbers is not None:
                    ant_inds = []
                    telescope_ant_inds = []
                    # first try to match using names only
                    if self.antenna_names is not None:
                        for index, antname in enumerate(self.antenna_names):
                            if antname in ant_info["antenna_names"]:
                                ant_inds.append(index)
                                telescope_ant_inds.append(
                                    np.where(ant_info["antenna_names"] == antname)[0][0]
                                )
                    # next try using numbers
                    if self.antenna_numbers is not None:
                        if len(ant_inds) != self.Nants:
                            for index, antnum in enumerate(self.antenna_numbers):
                                # only update if not already found
                                if (
                                    index not in ant_inds
                                    and antnum in ant_info["antenna_numbers"]
                                ):
                                    this_ant_ind = np.where(
                                        ant_info["antenna_numbers"] == antnum
                                    )[0][0]
                                    # make sure we don't already have this antenna
                                    #  associated with another antenna
                                    if this_ant_ind not in telescope_ant_inds:
                                        ant_inds.append(index)
                                        telescope_ant_inds.append(this_ant_ind)
                    if len(ant_inds) != self.Nants:
                        warnings.warn(
                            "Not all antennas have metadata in the "
                            f"known_telescope data. Not setting {ant_params_missing}."
                        )
                    else:
                        known_telescope_list.extend(ant_params_missing)
                        if "antenna_positions" in ant_params_missing:
                            self.antenna_positions = ant_info["antenna_positions"][
                                telescope_ant_inds, :
                            ]
                        if "antenna_names" in ant_params_missing:
                            self.antenna_names = ant_info["antenna_names"][
                                telescope_ant_inds
                            ]

                        if "antenna_numbers" in ant_params_missing:
                            self.antenna_numbers = ant_info["antenna_numbers"][
                                telescope_ant_inds
                            ]

            if "antenna_diameters" in telescope_dict.keys() and (
                overwrite or self.antenna_diameters is None
            ):
                antenna_diameters = np.atleast_1d(telescope_dict["antenna_diameters"])
                if antenna_diameters.size == 1:
                    known_telescope_list.append("antenna_diameters")
                    self.antenna_diameters = (
                        np.zeros(self.Nants, dtype=float) + antenna_diameters[0]
                    )
                elif antenna_diameters.size == self.Nants:
                    known_telescope_list.append("antenna_diameters")
                    self.antenna_diameters = antenna_diameters
                else:
                    if warn:
                        warnings.warn(
                            "antenna_diameters are not set because the number "
                            "of antenna_diameters on known_telescopes_dict is "
                            "more than one and does not match Nants for "
                            f"telescope {self.name}."
                        )

            if "x_orientation" in telescope_dict.keys() and (
                overwrite or self.x_orientation is None
            ):
                known_telescope_list.append("x_orientation")
                self.x_orientation = telescope_dict["x_orientation"]

        full_list = astropy_sites_list + known_telescope_list
        if warn and len(full_list) > 0:
            warn_str = ", ".join(full_list) + " are not set or are being overwritten. "
            specific_str = []
            if len(astropy_sites_list) > 0:
                specific_str.append(
                    ", ".join(astropy_sites_list) + " are set using values from "
                    f"astropy sites for {self.name}."
                )
            if len(known_telescope_list) > 0:
                specific_str.append(
                    ", ".join(known_telescope_list) + " are set using values "
                    f"from known telescopes for {self.name}."
                )
            warn_str += " ".join(specific_str)
            warnings.warn(warn_str)

        if run_check:
            self.check(
                check_extra=check_extra, run_check_acceptability=run_check_acceptability
            )

    @classmethod
    def from_known_telescopes(
        cls,
        name: str,
        *,
        run_check: bool = True,
        check_extra: bool = True,
        run_check_acceptability: bool = True,
        known_telescope_dict: dict = KNOWN_TELESCOPES,
    ):
        """
        Create a new Telescope object using information from known_telescopes.

        Parameters
        ----------
        name : str
            Name of the telescope.
        run_check : bool
            Option to check for the existence and proper shapes of parameters.
        check_extra : bool
            Option to check optional parameters as well as required ones.
        run_check_acceptability : bool
            Option to check acceptable range of the values of parameters.
        known_telescope_dict: dict
            This should only be used for testing. This allows passing in a
            different dict to use in place of the KNOWN_TELESCOPES dict.

        Returns
        -------
        Telescope
            A new Telescope object populated with information from
            known_telescopes.

        """
        tel_obj = cls()
        tel_obj.name = name
        tel_obj.update_params_from_known_telescopes(
            warn=False,
            run_check=run_check,
            check_extra=check_extra,
            run_check_acceptability=run_check_acceptability,
            known_telescope_dict=known_telescope_dict,
        )
        return tel_obj
back to top