From 8c4892ed0fbb49da36c017435dec831edf26ccfa Mon Sep 17 00:00:00 2001 From: Garrett 'Karto' Keating Date: Fri, 7 Feb 2025 10:30:38 -0500 Subject: [PATCH] Removing and deprecating x-orientation attribute in Telescope --- src/pyuvdata/telescopes.py | 263 ++++++++++++++++++++++++--- src/pyuvdata/utils/antenna.py | 9 +- src/pyuvdata/utils/bls.py | 7 +- src/pyuvdata/utils/io/hdf5.py | 35 +++- src/pyuvdata/utils/io/ms.py | 8 +- src/pyuvdata/utils/pol.py | 180 ++++++++++++++++++ src/pyuvdata/utils/uvcalibrate.py | 12 +- src/pyuvdata/uvcal/calfits.py | 75 +++++++- src/pyuvdata/uvcal/calh5.py | 10 +- src/pyuvdata/uvcal/fhd_cal.py | 6 +- src/pyuvdata/uvcal/initializers.py | 40 +++- src/pyuvdata/uvcal/ms_cal.py | 40 +++- src/pyuvdata/uvcal/uvcal.py | 43 ++++- src/pyuvdata/uvdata/mir.py | 1 - src/pyuvdata/uvdata/miriad.py | 14 +- src/pyuvdata/uvdata/ms.py | 22 ++- src/pyuvdata/uvdata/mwa_corr_fits.py | 9 +- src/pyuvdata/uvdata/uvdata.py | 42 ++++- src/pyuvdata/uvdata/uvfits.py | 23 ++- src/pyuvdata/uvdata/uvh5.py | 43 ++--- src/pyuvdata/uvflag/uvflag.py | 51 ++++-- tests/conftest.py | 7 +- tests/test_telescopes.py | 24 ++- tests/test_uvbase.py | 104 +++++++---- tests/utils/test_uvcalibrate.py | 19 +- tests/uvcal/test_initializers.py | 7 +- tests/uvcal/test_ms_cal.py | 10 +- tests/uvcal/test_uvcal.py | 20 +- tests/uvdata/test_miriad.py | 10 +- tests/uvdata/test_ms.py | 6 +- tests/uvdata/test_uvdata.py | 20 +- tests/uvdata/test_uvfits.py | 6 +- tests/uvdata/test_uvh5.py | 4 +- tests/uvflag/test_uvflag.py | 55 ++++-- 34 files changed, 986 insertions(+), 239 deletions(-) diff --git a/src/pyuvdata/telescopes.py b/src/pyuvdata/telescopes.py index e9a7b7ae38..45dc365b6b 100644 --- a/src/pyuvdata/telescopes.py +++ b/src/pyuvdata/telescopes.py @@ -71,7 +71,6 @@ ), "Nants": 8, "antenna_diameters": 6.0, - "x_orientation": "east", "citation": "Ho, P. T. P., Moran, J. M., & Lo, K. Y. 2004, ApJL, 616, L1", }, "SZA": { @@ -402,20 +401,6 @@ def __init__(self): expected_type=str, ) - 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." @@ -431,7 +416,8 @@ def __init__(self): desc = ( 'Antenna mount type, shape (Nants,). Options are: "alt-az", "equitorial", ' - '"orbiting", "x-y", "alt-az+nasmyth-r", "alt-az+nasmyth-l", and "phased".' + '"orbiting", "x-y", "alt-az+nasmyth-r", "alt-az+nasmyth-l", "phased", ' + '"fixed", and "other".' ) self._mount_type = uvp.UVParameter( "mount_type", @@ -447,6 +433,8 @@ def __init__(self): "alt-az+nasmyth-r", "alt-az+nasmyth-l", "phased", + "fixed", + "other", ], ) @@ -459,8 +447,8 @@ def __init__(self): ) desc = ( - "Array of feed orientations. shape (Nfeeds). Options are: n/e or x/y or " - "r/l. Optional parameter, required if feed_angle is set." + "Array of feed orientations. shape (Nants, Nfeeds), type str. Options are: " + "x/y or r/l. Optional parameter, required if feed_angle is set." ) self._feed_array = uvp.UVParameter( "feed_array", @@ -468,12 +456,17 @@ def __init__(self): required=False, expected_type=str, form=("Nants", "Nfeeds"), - acceptable_vals=["x", "y", "n", "e", "r", "l"], + acceptable_vals=["x", "y", "r", "l"], ) desc = ( "Position angle of a given feed, shape (Nants, Nfeeds), units of radians. " - "Optional parameter, required if feed_array is set." + "The feed angle is defined with respect to the great circle going through " + "constant azimuth and zenith (for pointed telescopes) or north (for fixed " + "telescopes like HERA or MWA), where a feed angle of 0 would correspond to " + "vertical polarization for linear feeds, and pi/2 to horizontal " + "polarization (as seen by the observer with reference to a horizontal " + "coordinate system). Optional parameter, required if feed_array is set." ) self._feed_angle = uvp.UVParameter( "feed_angle", @@ -503,6 +496,16 @@ def __getattr__(self, __name): DeprecationWarning, ) return self.name + elif __name == "x_orientation": + warnings.warn( + "The Telescope.x_orientation attribute is deprecated, and has " + "been superseded by Telescope.feed_angle and Telescope.feed_array. " + "This will become an error in version 3.4. To set the equivalent " + "value in the future, you can substitute accessing this parameter " + "with a call to Telescope.get_x_orientation_from_feeds().", + DeprecationWarning, + ) + return self.get_x_orientation_from_feeds() return super().__getattribute__(__name) @@ -525,9 +528,84 @@ def __setattr__(self, __name, __value): ) self.name = __value return + elif __name == "x_orientation": + warnings.warn( + "The Telescope.x_orientation attribute is deprecated, and has " + "been superseded by Telescope.feed_angle and Telescope.feed_array. " + "This will become an error in version 3.4. To get the equivalent " + "value in the future, you can substitute accessing this parameter " + "with a call to Telescope.set_feeds_from_x_orientation().", + DeprecationWarning, + ) + return self.set_feeds_from_x_orientation(__value) return super().__setattr__(__name, __value) + def get_x_orientation_from_feeds(self) -> Literal["east", "north", None]: + """ + Get x-orientation equivalent value based on feed information. + + Returns + ------- + x_orientation : str + One of "east", "north", or None, based on values present in + Telescope.feed_array and Telescope.feed_angle. + """ + return utils.pol.get_x_orientation_from_feeds( + feed_array=self.feed_array, + feed_angle=self.feed_angle, + tols=self._feed_angle.tols, + ) + + def set_feeds_from_x_orientation( + self, + x_orientation, + feeds=None, + polarization_array=None, + flex_polarization_array=None, + ): + """ + Set feed information based on x-orientation value. + + Populates newer parameters describing feed-orientation (`Telescope.feed_array` + and `Telescope.feed_angle`) based on the "older" x-orientation string. Note that + this method will overwrite any previously populated values. + + Parameters + ---------- + x_orientation : str + String describing how the x-orientation is oriented. Must be either "north"/ + "n"/"ns" (x-polarization of antenna has a position angle of 0 degrees with + respect to zenith/north) or "east"/"e"/"ew" (x-polarization of antenna has a + position angle of 90 degrees with respect to zenith/north). + feeds : list of str or None + List of strings denoting feed orientations/polarizations. Must be one of + "x", "y", "l", "r" (the former two for linearly polarized feeds, the latter + for circularly polarized feeds). Default assumes a pair of linearly + polarized feeds (["x", "y"]). + polarization_array : array-like of int or None + Array listing the polarization codes present, based on the UVFITS numbering + scheme. See utils.POL_NUM2STR_DICT for a mapping between codes and + polarization types. Used with `utils.pol.get_feeds_from_pols` to determine + feeds present if not supplied, ignored if flex_polarization_array is set + to anything but None. + flex_polarization_array : array-like of int or None + Array listing the polarization codes present per spectral window (used with + certain "flexible-polarization" objects), based on the UVFITS numbering + scheme. See utils.POL_NUM2STR_DICT for a mapping between codes and + polarization types. Used with `utils.pol.get_feeds_from_pols` to determine + feeds present if not supplied. + """ + self.Nfeeds, self.feed_array, self.feed_angle = ( + utils.pol.get_feeds_from_x_orientation( + x_orientation=x_orientation, + feeds=feeds, + polarization_array=polarization_array, + flex_polarization_array=flex_polarization_array, + nants=self.Nants, + ) + ) + def check(self, *, check_extra=True, run_check_acceptability=True): """ Add some extra checks on top of checks on UVBase class. @@ -585,6 +663,10 @@ def update_params_from_known_telescopes( check_extra: bool = True, run_check_acceptability: bool = True, known_telescope_dict: dict = _KNOWN_TELESCOPES, + x_orientation: str | None = None, + feeds: str | list[str] | None = None, + polarization_array: np.ndarray | None = None, + flex_polarization_array: np.ndarray | None = None, ): """ Update the parameters based on telescope in known_telescopes. @@ -614,6 +696,29 @@ def update_params_from_known_telescopes( 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. + x_orientation : str + String describing how the x-orientation is oriented. Must be either "north"/ + "n"/"ns" (x-polarization of antenna has a position angle of 0 degrees with + respect to zenith/north) or "east"/"e"/"ew" (x-polarization of antenna has a + position angle of 90 degrees with respect to zenith/north). Ignored if + "x_orientation" is relevant entry for the KNOWN_TELESCOPES dict. + feeds : list of str or None + List of strings denoting feed orientations/polarizations. Must be one of + "x", "y", "l", "r" (the former two for linearly polarized feeds, the latter + for circularly polarized feeds). Default assumes a pair of linearly + polarized feeds (["x", "y"]). + polarization_array : array-like of int or None + Array listing the polarization codes present, based on the UVFITS numbering + scheme. See utils.POL_NUM2STR_DICT for a mapping between codes and + polarization types. Used with `utils.pol.get_feeds_from_pols` to determine + feeds present if not supplied, ignored if flex_polarization_array is set + to anything but None. + flex_polarization_array : array-like of int or None + Array listing the polarization codes present per spectral window (used with + certain "flexible-polarization" objects), based on the UVFITS numbering + scheme. See utils.POL_NUM2STR_DICT for a mapping between codes and + polarization types. Used with `utils.pol.get_feeds_from_pols` to determine + feeds present if not supplied. Raises ------ @@ -750,10 +855,10 @@ def update_params_from_known_telescopes( ) if "x_orientation" in telescope_dict and ( - overwrite or self.x_orientation is None + overwrite or (self.feed_array is None and self.feed_angle is None) ): known_telescope_list.append("x_orientation") - self.x_orientation = telescope_dict["x_orientation"] + x_orientation = telescope_dict["x_orientation"] full_list = astropy_sites_list + known_telescope_list if warn and _WARN_STATUS.get(self.name.lower(), True) and len(full_list) > 0: @@ -772,6 +877,30 @@ def update_params_from_known_telescopes( warn_str += " ".join(specific_str) warnings.warn(warn_str) + if "Nants" in known_telescope_list and x_orientation is None: + # If this changed, then we want to force an update, so capture this + # from the previous time it was set. + x_orientation = self.get_x_orientation_from_feeds() + if x_orientation is not None and warn: + warnings.warn( + "Nants has changed, setting feed_array and feed_angle " + "automatically as these values are consistent with " + f'x_orientation="{x_orientation}".' + ) + + # Set this separately, since if we've specified x-orientation we want to + # propagate that information to the relevant parameters. + if x_orientation is not None and ( + (overwrite or "Nants" in known_telescope_list) + or (self.feed_array is None and self.feed_angle is None) + ): + self.set_feeds_from_x_orientation( + x_orientation=x_orientation, + feeds=feeds, + polarization_array=polarization_array, + flex_polarization_array=flex_polarization_array, + ) + if run_check: self.check( check_extra=check_extra, run_check_acceptability=run_check_acceptability @@ -834,6 +963,9 @@ def new( instrument: str | None = None, x_orientation: Literal["east", "north", "e", "n", "ew", "ns"] | None = None, antenna_diameters: list[float] | np.ndarray | None = None, + feeds: Literal["x", "y", "l", "r"] | list[str] | None = None, + feed_array: np.ndarray | None = None, + feed_angle: np.ndarray | None = None, ): """ Initialize a new Telescope object from keyword arguments. @@ -866,9 +998,22 @@ def new( Instrument name. x_orientation : str Orientation of the x-axis. Options are 'east', 'north', 'e', 'n', - 'ew', 'ns'. + 'ew', 'ns'. Ignored if feed_array and feed_angle are provided. antenna_diameters : list or np.ndarray of float, optional List or array of antenna diameters. + feeds : list of str or None: + List of feeds present in the Telescope, which must be one of "x", "y", "l", + "r". Length of the list must be either 1 or 2. Used to populate feed_array + and feed_angle parameters if only supplying x_orientation, default is + ["x", "y"]. + feed_array : array-like of str or None + List of feeds for each antenna in the Telescope object, must be one of + "x", "y", "l", "r". Shape (Nants, Nfeeds), dtype str. + feed_angle : array-like of float or None + Orientation of the feed with respect to zenith (or with respect to north if + pointed at zenith). Units is in rads, vertical polarization is nominally 0, + and horizontal polarization is nominally pi / 2. Shape (Nants, Nfeeds), + dtype float. Returns ------- @@ -902,9 +1047,11 @@ def new( if instrument is not None: tel_obj.instrument = instrument - if x_orientation is not None: - x_orientation = utils.XORIENTMAP[x_orientation.lower()] - tel_obj.x_orientation = x_orientation + if feed_angle is not None and feed_array is not None: + tel_obj.feed_array = feed_array + tel_obj.feed_angle = feed_angle + elif x_orientation is not None: + tel_obj.set_feeds_from_x_orientation(x_orientation.lower(), feeds=feeds) if antenna_diameters is not None: tel_obj.antenna_diameters = np.asarray(antenna_diameters) @@ -959,7 +1106,6 @@ def from_hdf5( "antenna_numbers": "antenna_numbers", "antenna_positions": "antenna_positions", "instrument": "instrument", - "x_orientation": "x_orientation", "antenna_diameters": "antenna_diameters", "mount_type": "mount_type", "Nfeeds": "Nfeeds", @@ -975,6 +1121,10 @@ def from_hdf5( else: pass + # Handle the retired x-orientation parameter + if (tel_obj.feed_array is None) or (tel_obj.feed_angle is None): + tel_obj.set_feeds_from_x_orientation(meta.x_orientation, feeds=["x", "y"]) + if run_check: tel_obj.check( check_extra=check_extra, run_check_acceptability=run_check_acceptability @@ -1009,8 +1159,6 @@ def write_hdf5_header(self, header): if self.instrument is not None: header["instrument"] = np.bytes_(self.instrument) - if self.x_orientation is not None: - header["x_orientation"] = np.bytes_(self.x_orientation) if self.antenna_diameters is not None: header["antenna_diameters"] = self.antenna_diameters if self.Nfeeds is not None: @@ -1037,3 +1185,60 @@ def get_enu_antpos(self): antpos = utils.ENU_from_ECEF(antenna_xyz, center_loc=self.location) return antpos + + def reorder_feeds( + self, + order="AIPS", + *, + run_check=True, + check_extra=True, + run_check_acceptability=True, + ): + """ + Arrange feed axis according to desired order. + + Parameters + ---------- + order : str + Either a string specifying a canonical ordering ('AIPS' or 'CASA') + or list of strings specifying the preferred ordering of the four + feed types ("x", "y", "l", and "r"). + run_check : bool + Option to check for the existence and proper shapes of parameters + after reordering. + 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 + reordering. + + Raises + ------ + ValueError + If the order is not one of the allowed values. + + """ + if self.Nfeeds is None or self.Nfeeds == 1: + # Nothing to do but bail! + return + + if (order == "AIPS") or (order == "CASA"): + order = {"x": 1, "y": 2, "r": 3, "l": 4} + elif isinstance(order, list) and all(f in ["x", "y", "l", "r"] for f in order): + order = {item: idx for idx, item in enumerate(order)} + else: + raise ValueError( + "order must be one of: 'AIPS', 'CASA', or a " + 'list of length 4 containing only "x", "y", "r", or "l".' + ) + + for idx in range(self.Nants): + feed_a, feed_b = self.feed_array[idx] + if order.get(feed_a, 999999) > order.get(feed_b, 999999): + self.feed_array[idx] = self.feed_array[idx, ::-1] + self.feed_angle[idx] = self.feed_angle[idx, ::-1] + + if run_check: + self.check( + check_extra=check_extra, run_check_acceptability=run_check_acceptability + ) diff --git a/src/pyuvdata/utils/antenna.py b/src/pyuvdata/utils/antenna.py index a58c690246..ecfae7a359 100644 --- a/src/pyuvdata/utils/antenna.py +++ b/src/pyuvdata/utils/antenna.py @@ -14,6 +14,9 @@ "alt-az+nasmyth-r": 4, "alt-az+nasmyth-l": 5, "phased": 6, + "fixed": 7, + "other": 8, + "bizarre": 8, # Semi-common code in UVFITS/CASA for "unknown" types } MOUNT_NUM2STR_DICT = { @@ -22,8 +25,10 @@ 2: "orbiting", 3: "x-y", 4: "alt-az+nasmyth-r", - 5: "alt-az+nasmyth-l", - 6: "phased", + 5: "alt-az+nasmyth-l", # here and above, UVFITS-defined + 6: "phased", # <- pyuvdata defined, but not uncommon in UVFITS + 7: "fixed", # <- pyuvdata defined + 8: "other", # <- pyuvdata defined } diff --git a/src/pyuvdata/utils/bls.py b/src/pyuvdata/utils/bls.py index b3feb65c61..7d6612aacc 100644 --- a/src/pyuvdata/utils/bls.py +++ b/src/pyuvdata/utils/bls.py @@ -182,11 +182,8 @@ def parse_ants(uv, ant_str, *, print_toggle=False, x_orientation=None): f"to call 'parse_ants': {required_attrs}." ) - if x_orientation is None and ( - hasattr(uv.telescope, "x_orientation") - and uv.telescope.x_orientation is not None - ): - x_orientation = uv.telescope.x_orientation + if x_orientation is None: + x_orientation = uv.telescope.get_x_orientation_from_feeds() ant_re = r"(\(((-?\d+[lrxy]?,?)+)\)|-?\d+[lrxy]?)" bl_re = f"(^({ant_re}_{ant_re}|{ant_re}),?)" diff --git a/src/pyuvdata/utils/io/hdf5.py b/src/pyuvdata/utils/io/hdf5.py index dbbdc0df5e..f4ad890b70 100644 --- a/src/pyuvdata/utils/io/hdf5.py +++ b/src/pyuvdata/utils/io/hdf5.py @@ -24,6 +24,7 @@ import contextlib from ..coordinates import ENU_from_ECEF, LatLonAlt_from_XYZ +from ..pol import get_x_orientation_from_feeds hdf5plugin_present = True try: @@ -689,9 +690,37 @@ def mount_type(self) -> list[str]: @cached_property def feed_array(self) -> np.ndarray[str]: """The antenna names in the file.""" - return np.char.decode( - self.header["feed_array"][:].astype("|S"), encoding="utf8" - ).astype(np.object_) + if "feed_array" in self.header: + return np.char.decode( + self.header["feed_array"][:].astype("|S"), encoding="utf8" + ) + + return None + + @cached_property + def feed_angle(self) -> np.ndarray[float]: + """The antenna names in the file.""" + if "feed_angle" in self.header: + return self.header["feed_angle"][()] + + return None + + @cached_property + def x_orientation(self) -> str: + """The x_orientation in the file.""" + if "feed_array" in self.header and "feed_angle" in self.header: + # If these are present, then x-orientation can be "calculated" based + # on the feed orientations. Note that this will return None if what is + # listed doesn't match either "east" or "north" configuration + return get_x_orientation_from_feeds( + feed_array=self.feed_array, feed_angle=self.feed_angle, tols=(1e-6, 0) + ) + if "x_orientation" in self.header: + # If the header is present, then just load that up. + return bytes(self.header["x_orientation"][()]).decode("utf8") + + # Otherwise, no x_orientation is present, so just return None + return None @cached_property def extra_keywords(self) -> dict: diff --git a/src/pyuvdata/utils/io/ms.py b/src/pyuvdata/utils/io/ms.py index 21d31ade90..a7ad47d6d3 100644 --- a/src/pyuvdata/utils/io/ms.py +++ b/src/pyuvdata/utils/io/ms.py @@ -1475,13 +1475,11 @@ def write_ms_feed( has_feed = True else: if uvobj.flex_spw_polarization_array is None: - pols = utils.polnum2str(uvobj.polarization_array) + pols = uvobj.polarization_array else: - pols = utils.polnum2str(uvobj.flex_spw_polarization_array) + pols = uvobj.flex_spw_polarization_array - feed_pols = { - feed for pol in pols for feed in utils.pol.POL_TO_FEED_DICT[pol] - } + feed_pols = utils.pol.get_feeds_from_pols(pols) nfeeds = len(feed_pols) feed_array = np.tile(sorted(feed_pols), (uvobj.telescope.Nants, 1)) feed_angle = np.zeros((uvobj.telescope.Nants, nfeeds)) diff --git a/src/pyuvdata/utils/pol.py b/src/pyuvdata/utils/pol.py index 8b02b78de3..fefdcb5d3e 100644 --- a/src/pyuvdata/utils/pol.py +++ b/src/pyuvdata/utils/pol.py @@ -527,3 +527,183 @@ def determine_pol_order(pols, *, order="AIPS"): raise ValueError('order must be either "AIPS" or "CASA".') return index_array + + +def get_feeds_from_pols(polarization_array): + """ + Return a list of expected feeds based on polarization values. + + Translates values in polarization_array or jones_array into + + Parameters + ---------- + polarization_array : array_like of int + Array listing the polarization codes present, based on the UVFITS numbering + schedule. See utils.POL_NUM2STR_DICT for a mapping between codes and + polarization types. + + Returns + ------- + feed_array : list of str + List of expected feed types given the polarizations present in the data. Will + be one of "x", "y", "l", "r", and generally of length <= 2. + """ + # Preserve order of feeds based on pols using dict.fromkeys + feed_pols = list( + dict.fromkeys( + feed + for pol in polarization_array + for feed in POL_TO_FEED_DICT[POL_NUM2STR_DICT[pol]] + ) + ) + return sorted(feed_pols) + + +def get_x_orientation_from_feeds(feed_array, feed_angle, tols=None): + """ + Determine x-orientation equivalent value based on feed information. + + This is a helper function meant to provide a way of translating newer parameters + (feed_array and feed_angle) describing feed orientation with the older + "x-orientation" parameter. + + Parameters + ---------- + feed_array : array-like of str or None + List of feeds for a given telescope, should be one of "x", "y", "l", "r". + Shape (Nants, Nfeeds) or (Nfeeds,), must match that of feed_angle, dtype str. + feed_angle : array-like of float + Orientation of the feed with respect to zenith (or with respect to north if + pointed at zenith). Units is in rads, vertical polarization is nominally 0, + and horizontal polarization is nominally pi / 2. Shape (Nants, Nfeeds) or + (Nfeeds,), must match that of feed_array, dtype float. + tols : tuple of float + Tolerances for feed_angle, used with `isclose`. + + Returns + ------- + x_orientation : str + One of "east", "north", or None, based on values present in feed_array and + feed_angle. None denotes that either one (or both) of feed_array and feed_angle + were None, or that the values were inconsistent with either "north" or "east" + orientation. + """ + if feed_array is None or feed_angle is None: + # If feed info is unset, then return None + return None + + if tols is None: + tols = (0, 0) + rtol, atol = tols + + x_mask = np.isin(feed_array, ["x", "X"]) + + # Anything that's not 'x' should be oriented straight up (0 deg) for "east" + # orientation, otherwise at -90 deg for "north". + if np.allclose(feed_angle, np.where(x_mask, np.pi / 2, 0), rtol=rtol, atol=atol): + # x is horizontal + return "east" + if np.allclose(feed_angle, np.where(x_mask, 0, -np.pi / 2), rtol=rtol, atol=atol): + # x is vertical for observer ("east is up"!) + return "north" + + # No match? Then time to declare defeat. + return None + + +def get_feeds_from_x_orientation( + *, + x_orientation, + nants, + feeds=None, + polarization_array=None, + flex_polarization_array=None, +): + """ + Determine feed angles based on equivalent x-orientation. + + This is a helper function meant to provide a way of translating the older + "x-orientation" parameter into the newer parameters describing feed orientation + (feed_array and feed_angle). + + Parameters + ---------- + x_orientation : str + String describing the orientation of the x-polarization. Must be one of "east" + or "north" (or the associated aliases "n", "e", "ns", "ew") + nants : int + Number of antennas, used to determine the shape of the output. + feeds : str or array-like of str + List of feeds expected for the telescope. Must be one of "x", "y", "l", or "r". + A single feed type can be provided as a string, otherwise the list should + contain no more than two elements. If not provided (and polarization_array is + also not provided), default is ["x", "y"]. + polarization_array : array-like of int or None + Array listing the polarization codes present, based on the UVFITS numbering + scheme. See utils.POL_NUM2STR_DICT for a mapping between codes and + polarization types. Used with `utils.pol.get_feeds_from_pols` to determine + feeds present if not supplied, ignored if flex_polarization_array is set + to anything but None. + flex_polarization_array : array-like of int or None + Array listing the polarization codes present per spectral window (used with + certain "flexible-polarization" objects), based on the UVFITS numbering + scheme. See utils.POL_NUM2STR_DICT for a mapping between codes and + polarization types. Used with `utils.pol.get_feeds_from_pols` to determine + feeds present if not supplied. + + Returns + ------- + Nfeeds : int + Length of feeds (or None of x_orientation is None). + feed_array : array-like of str or None + List of feeds, given on a per-antenna basis. Each feed will be listed as one of + "x", "y", "l", "r". Shape (Nants, Nfeeds), dtype str (or None of x_orientation + is None). + feed_angle : array-like of float + Orientation of the feed with respect to zenith (or with respect to north if + pointed at zenith). Units is in rads, vertical polarization is nominally 0, + and horizontal polarization is nominally pi / 2. Shape (Nants, Nfeeds) (or None + of x_orientation is None). + """ + if x_orientation is None: + # If x_orientation is None, then there isn't anything to determine + return None, None, None + + if feeds is None: + if flex_polarization_array is not None: + feeds = get_feeds_from_pols(polarization_array=flex_polarization_array) + elif polarization_array is not None: + feeds = get_feeds_from_pols(polarization_array=polarization_array) + else: + warnings.warn( + "Unknown polarization basis -- assuming linearly polarized (x/y) " + "feeds for Telescope.feed_array." + ) + feeds = ["x", "y"] + elif isinstance(feeds, str): + feeds = [feeds] + + if x_orientation.lower() not in XORIENTMAP: + raise ValueError( + f"x_orientation not recognized, must be one of {list(XORIENTMAP)}." + ) + + x_orientation = XORIENTMAP[x_orientation.lower()] + + # Check to make sure inputs here are valid + if not isinstance(feeds, list | tuple | np.ndarray) or len(feeds) not in [1, 2]: + raise ValueError("feeds must be a list or tuple of length 1 or 2.") + if not all(item in ["l", "r", "x", "y"] for item in feeds): + raise ValueError('feeds must contain only "x", "y", "l", and/or "r".') + + Nfeeds = len(feeds) + feed_array = np.tile(feeds, (nants, 1)) + feed_angle = np.zeros((nants, Nfeeds), dtype=float) + + x_mask = feed_array == "x" + if x_orientation == "east": + feed_angle[x_mask] = np.pi / 2 + if x_orientation == "north": + feed_angle[~x_mask] = -np.pi / 2 + + return Nfeeds, feed_array, feed_angle diff --git a/src/pyuvdata/utils/uvcalibrate.py b/src/pyuvdata/utils/uvcalibrate.py index bab7e24e54..d56f0c5a89 100644 --- a/src/pyuvdata/utils/uvcalibrate.py +++ b/src/pyuvdata/utils/uvcalibrate.py @@ -440,11 +440,11 @@ def uvcalibrate( if len(uvcal_freqs_to_keep) < uvcal.Nfreqs: downselect_cal_freq = True - # check if uvdata.telescope.x_orientation isn't set (it's required for uvcal) - uvd_x = uvdata.telescope.x_orientation + # check if x_orientation-equivalent in uvdata isn't set (it's required for uvcal) + uvd_x = uvdata.telescope.get_x_orientation_from_feeds() if uvd_x is None: # use the uvcal x_orientation throughout - uvd_x = uvcal.telescope.x_orientation + uvd_x = uvcal.telescope.get_x_orientation_from_feeds() warnings.warn( "UVData object does not have `x_orientation` specified but UVCal does. " "Matching based on `x` and `y` only " @@ -452,14 +452,16 @@ def uvcalibrate( uvdata_pol_strs = polnum2str(uvdata.polarization_array, x_orientation=uvd_x) uvcal_pol_strs = jnum2str( - uvcal.jones_array, x_orientation=uvcal.telescope.x_orientation + uvcal.jones_array, x_orientation=uvcal.telescope.get_x_orientation_from_feeds() ) uvdata_feed_pols = { feed for pol in uvdata_pol_strs for feed in POL_TO_FEED_DICT[pol] } for feed in uvdata_feed_pols: # get diagonal jones str - jones_str = parse_jpolstr(feed, x_orientation=uvcal.telescope.x_orientation) + jones_str = parse_jpolstr( + feed, x_orientation=uvcal.telescope.get_x_orientation_from_feeds() + ) if jones_str not in uvcal_pol_strs: raise ValueError( f"Feed polarization {feed} exists on UVData but not on UVCal. " diff --git a/src/pyuvdata/uvcal/calfits.py b/src/pyuvdata/uvcal/calfits.py index b21727f8fb..fb65cbd777 100644 --- a/src/pyuvdata/uvcal/calfits.py +++ b/src/pyuvdata/uvcal/calfits.py @@ -219,7 +219,6 @@ def write_calfits( else: prihdr["CHWIDTH"] = delta_freq_array - prihdr["XORIENT"] = self.telescope.x_orientation if self.freq_range is not None: freq_range_use = self.freq_range[0, :] else: @@ -446,6 +445,28 @@ def write_calfits( # make HDUs prihdu = fits.PrimaryHDU(data=pridata, header=prihdr) + polaa = np.zeros(self.telescope.Nants, dtype=float) + polab = np.zeros(self.telescope.Nants, dtype=float) + poltya = np.full(self.telescope.Nants, "", dtype=" 1 else field_ids) - # Finally, record extra keywords and x_orientation, both of which the MS format + # Finally, record extra keywords and pol_convention, both of which the MS format # doesn't quite have equivalent fields to stuff data into (and instead is put # into the main header as a keyword). if len(self.extra_keywords) != 0: ms.putkeyword("pyuvdata_extra", self.extra_keywords) - if self.telescope.x_orientation is not None: - ms.putkeyword("pyuvdata_xorient", self.telescope.x_orientation) if self.pol_convention is not None: ms.putkeyword("pyuvdata_polconv", self.pol_convention) @@ -452,8 +450,9 @@ def _read_ms_main( if "pyuvdata_extra" in main_keywords: self.extra_keywords = main_keywords["pyuvdata_extra"] + x_orientation = None if "pyuvdata_xorient" in main_keywords: - self.telescope.x_orientation = main_keywords["pyuvdata_xorient"] + x_orientation = main_keywords["pyuvdata_xorient"] if "pyuvdata_polconv" in main_keywords: self.pol_convention = main_keywords["pyuvdata_polconv"] @@ -507,7 +506,7 @@ def _read_ms_main( if data_desc_count == 0: # If there are no records selected, then there isn't a whole lot to do - return None, None, None, None + return None, None, None, None, None elif data_desc_count == 1: # If we only have a single spectral window, then we can bypass a whole lot # of slicing and dicing on account of there being a one-to-one relationship @@ -556,7 +555,7 @@ def _read_ms_main( ] tb_main.close() - return spw_list, field_list, pol_list, None + return spw_list, field_list, pol_list, None, x_orientation tb_main.close() @@ -820,7 +819,7 @@ def _read_ms_main( field_list = np.unique(field_arr).astype(int).tolist() - return spw_list, field_list, pol_list, flex_pol + return spw_list, field_list, pol_list, flex_pol, x_orientation @copy_replace_short_description(UVData.read_ms, style=DocstringStyle.NUMPYDOC) def read_ms( @@ -900,7 +899,7 @@ def read_ms( # convention change. So if the data in the MS came via that task and was not # written by pyuvdata, we do need to flip the uvws & conjugate the data flip_conj = ("importuvfits" in self.history) and (not pyuvdata_written) - spw_list, field_list, pol_list, flex_pol = self._read_ms_main( + spw_list, field_list, pol_list, flex_pol, x_orientation = self._read_ms_main( filepath, data_column=data_column, data_desc_dict=data_desc_dict, @@ -1032,6 +1031,13 @@ def read_ms( if pol_order is not None: self.reorder_pols(order=pol_order, run_check=False) + self.set_telescope_params( + x_orientation=x_orientation, + check_extra=check_extra, + run_check=run_check, + run_check_acceptability=run_check_acceptability, + ) + if run_check: self.check( check_extra=check_extra, diff --git a/src/pyuvdata/uvdata/mwa_corr_fits.py b/src/pyuvdata/uvdata/mwa_corr_fits.py index 1868d26d13..a37e904def 100644 --- a/src/pyuvdata/uvdata/mwa_corr_fits.py +++ b/src/pyuvdata/uvdata/mwa_corr_fits.py @@ -1706,7 +1706,6 @@ def read_mwa_corr_fits( self.spw_array = np.array([0]) self.vis_units = "uncalib" self.Npols = 4 - self.telescope.x_orientation = "east" meta_dict = read_metafits( metafits_file, @@ -1875,6 +1874,11 @@ def read_mwa_corr_fits( # reorder polarization_array here to avoid memory spike from self.reorder_pols self.polarization_array = file_pol_array[pol_index_array] + # Set values for feed-array/feed-angle based on east x-orientation + self.telescope.set_feeds_from_x_orientation( + "east", polarization_array=self.polarization_array + ) + if read_data: if not mwax: # build mapper from antenna numbers and polarizations to pfb inputs @@ -1970,7 +1974,8 @@ def read_mwa_corr_fits( for p in polarizations: if isinstance(p, str): p_num = utils.polstr2num( - p, x_orientation=self.telescope.x_orientation + p, + x_orientation=self.telescope.get_x_orientation_from_feeds(), ) else: p_num = p diff --git a/src/pyuvdata/uvdata/uvdata.py b/src/pyuvdata/uvdata/uvdata.py index 40204bab7c..8fe894a19c 100644 --- a/src/pyuvdata/uvdata/uvdata.py +++ b/src/pyuvdata/uvdata/uvdata.py @@ -612,7 +612,8 @@ def __init__(self): def _set_telescope_requirements(self): """Set the UVParameter required fields appropriately for UVData.""" self.telescope._instrument.required = True - self.telescope._x_orientation.required = False + self.telescope._feed_array.required = False + self.telescope._feed_angle.required = False # This is required for eq_coeffs, which has Nants_telescope as one of its # shapes. That's to allow us to line up the antenna_numbers/names with @@ -1475,6 +1476,7 @@ def known_telescopes(self): def set_telescope_params( self, *, + x_orientation=None, overwrite=False, warn=True, run_check=True, @@ -1490,9 +1492,28 @@ def set_telescope_params( Parameters ---------- + x_orientation : str or None + String describing how the x-orientation is oriented. Must be either "north"/ + "n"/"ns" (x-polarization of antenna has a position angle of 0 degrees with + respect to zenith/north) or "east"/"e"/"ew" (x-polarization of antenna has a + position angle of 90 degrees with respect to zenith/north). Ignored if + "x_orientation" is relevant entry for the known telescope, or if set to + None. overwrite : bool Option to overwrite existing telescope-associated parameters with - the values from the known telescope. + the values from the known telescope. Default is 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. Default is True. + check_extra : bool + Option to check optional parameters as well as required ones. Default is + True. + run_check_acceptability : bool + Option to check acceptable range of the values of parameters after + updating. Default is True Raises ------ @@ -1505,6 +1526,9 @@ def set_telescope_params( run_check=run_check, check_extra=check_extra, run_check_acceptability=run_check_acceptability, + x_orientation=x_orientation, + polarization_array=self.polarization_array, + flex_polarization_array=self.flex_spw_polarization_array, ) def _calc_single_integration_time(self): @@ -2768,7 +2792,9 @@ def _key2inds(self, key: str | tuple[int] | tuple[int, int] | tuple[int, int, st # Single string given, assume it is polarization pol_ind1 = np.where( self.polarization_array - == utils.polstr2num(key, x_orientation=self.telescope.x_orientation) + == utils.polstr2num( + key, x_orientation=self.telescope.get_x_orientation_from_feeds() + ) )[0] if len(pol_ind1) > 0: blt_ind1 = slice(None) @@ -2815,7 +2841,8 @@ def _key2inds(self, key: str | tuple[int] | tuple[int, int] | tuple[int, int, st orig_pol = key[2] if isinstance(key[2], str): pol = utils.polstr2num( - key[2], x_orientation=self.telescope.x_orientation + key[2], + x_orientation=self.telescope.get_x_orientation_from_feeds(), ) else: pol = key[2] @@ -3000,7 +3027,8 @@ def get_pols(self): list of polarizations (as strings) in the data. """ return utils.polnum2str( - self.polarization_array, x_orientation=self.telescope.x_orientation + self.polarization_array, + x_orientation=self.telescope.get_x_orientation_from_feeds(), ) def get_antpairpols(self): @@ -6568,7 +6596,7 @@ def parse_ants(self, ant_str, *, print_toggle=False): uv=self, ant_str=ant_str, print_toggle=print_toggle, - x_orientation=self.telescope.x_orientation, + x_orientation=self.telescope.get_x_orientation_from_feeds(), ) def _select_preprocess( @@ -6763,7 +6791,7 @@ def _select_preprocess( for p in polarizations: if isinstance(p, str): p_num = utils.polstr2num( - p, x_orientation=self.telescope.x_orientation + p, x_orientation=self.telescope.get_x_orientation_from_feeds() ) else: p_num = p diff --git a/src/pyuvdata/uvdata/uvfits.py b/src/pyuvdata/uvdata/uvfits.py index dab9cd2601..6a52bc0162 100644 --- a/src/pyuvdata/uvdata/uvfits.py +++ b/src/pyuvdata/uvdata/uvfits.py @@ -3,7 +3,6 @@ """Class for reading and writing uvfits files.""" -import contextlib import copy import os import warnings @@ -465,7 +464,7 @@ def read_uvfits( latitude_degrees = vis_hdr.pop("LAT", None) longitude_degrees = vis_hdr.pop("LON", None) altitude = vis_hdr.pop("ALT", None) - self.telescope.x_orientation = vis_hdr.pop("XORIENT", None) + x_orientation = vis_hdr.pop("XORIENT", None) blt_order_str = vis_hdr.pop("BLTORDER", None) if blt_order_str is not None: self.blt_order = tuple(blt_order_str.split(", ")) @@ -693,13 +692,15 @@ def read_uvfits( # If written by older versions of pyuvdata, we don't neccessarily want to # trust the mount information, otherwise read it in. if ant_hdu.header.get("HASMNT", not pyuvdata_written): - with contextlib.suppress(KeyError): - # If KeyError is thrown, there's an unknown code that we don't - # know how to handle, so skip setting the mount_type parameter - self.telescope.mount_type = [ - utils.antenna.MOUNT_NUM2STR_DICT[mount] - for mount in ant_hdu.data["MNTSTA"] - ] + ref_dict = utils.antenna.MOUNT_NUM2STR_DICT + if not pyuvdata_written: + # Standard UVFITS only supports codes 0-6, so remove the other + # codes so as to prevent mislabeling + ref_dict = {k: v for k, v in ref_dict.items() if k in range(7)} + # Default to other if mount code isn't found + self.telescope.mount_type = [ + ref_dict.get(mount, "other") for mount in ant_hdu.data["MNTSTA"] + ] if ant_hdu.header.get("HASFEED", not pyuvdata_written): # Tranpose here so that the shape is (Nants, Nfeeds) @@ -725,6 +726,7 @@ def read_uvfits( # This will not error because uvfits required keywords ensure we # have everything that is required for this method. self.set_telescope_params( + x_orientation=x_orientation, run_check=run_check, check_extra=check_extra, run_check_acceptability=run_check_acceptability, @@ -1244,9 +1246,6 @@ def write_uvfits( if self.pol_convention is not None: hdu.header["POLCONV"] = self.pol_convention - if self.telescope.x_orientation is not None: - hdu.header["XORIENT"] = self.telescope.x_orientation - if self.blt_order is not None: blt_order_str = ", ".join(self.blt_order) hdu.header["BLTORDER"] = blt_order_str diff --git a/src/pyuvdata/uvdata/uvh5.py b/src/pyuvdata/uvdata/uvh5.py index 1b4c86bbcb..6f3c64d84e 100644 --- a/src/pyuvdata/uvdata/uvh5.py +++ b/src/pyuvdata/uvdata/uvh5.py @@ -484,7 +484,7 @@ def _read_header_with_fast_meta( astrometry_library: str | None = None, ): if not isinstance(filename, FastUVH5Meta): - obj = FastUVH5Meta( + meta = FastUVH5Meta( filename, blt_order=blt_order, blts_are_rectangular=blts_are_rectangular, @@ -493,11 +493,11 @@ def _read_header_with_fast_meta( astrometry_library=astrometry_library, ) else: - obj = filename + meta = filename # First, get the things relevant for setting LSTs, so that can be run in the # background if desired. - self.time_array = obj.time_array + self.time_array = meta.time_array required_telescope_keys = [ "telescope_name", "latitude", @@ -518,8 +518,8 @@ def _read_header_with_fast_meta( ) self._set_telescope_requirements() - if "lst_array" in obj.header: - self.lst_array = obj.header["lst_array"][:] + if "lst_array" in meta.header: + self.lst_array = meta.header["lst_array"][:] proc = None if run_check_acceptability: @@ -557,7 +557,7 @@ def _read_header_with_fast_meta( # Take care of the phase center attributes separately, in order to support # older versions of the format. - if obj.phase_center_catalog is not None: + if meta.phase_center_catalog is not None: req_params += [ "phase_center_catalog", "phase_center_app_ra", @@ -570,7 +570,7 @@ def _read_header_with_fast_meta( # Required parameters for attr in req_params: try: - setattr(self, attr, getattr(obj, attr)) + setattr(self, attr, getattr(meta, attr)) except AttributeError as e: raise KeyError(str(e)) from e @@ -592,10 +592,10 @@ def _read_header_with_fast_meta( # For now, only set the rectangularity parameters if they exist in the header of # the file. These could be set automatically later on, but for now we'll leave # that up to the user dealing with the UVData object. - if "blts_are_rectangular" in obj.header: - self.blts_are_rectangular = obj.blts_are_rectangular - if "time_axis_faster_than_bls" in obj.header: - self.time_axis_faster_than_bls = obj.time_axis_faster_than_bls + if "blts_are_rectangular" in meta.header: + self.blts_are_rectangular = meta.blts_are_rectangular + if "time_axis_faster_than_bls" in meta.header: + self.time_axis_faster_than_bls = meta.time_axis_faster_than_bls if not utils.history._check_history_version( self.history, self.pyuvdata_version_str @@ -619,7 +619,7 @@ def _read_header_with_fast_meta( "pol_convention", ]: with contextlib.suppress(AttributeError): - setattr(self, attr, getattr(obj, attr)) + setattr(self, attr, getattr(meta, attr)) if self.blt_order is not None: self._blt_order.form = (len(self.blt_order),) @@ -629,16 +629,16 @@ def _read_header_with_fast_meta( # Here is where we start handling phase center information. If we have a # multi phase center dataset, we need to get different header items - if obj.phase_center_catalog is None: - phase_type = bytes(obj.header["phase_type"][()]).decode("utf8") + if meta.phase_center_catalog is None: + phase_type = bytes(meta.header["phase_type"][()]).decode("utf8") if phase_type == "phased": cat_id = self._add_phase_center( - getattr(obj, "object_name", "unknown"), + getattr(meta, "object_name", "unknown"), cat_type="sidereal", - cat_lon=obj.phase_center_ra, - cat_lat=obj.phase_center_dec, - cat_frame=obj.phase_center_frame, - cat_epoch=obj.phase_center_epoch, + cat_lon=meta.phase_center_ra, + cat_lat=meta.phase_center_dec, + cat_frame=meta.phase_center_frame, + cat_epoch=meta.phase_center_epoch, ) else: if phase_type != "drift": @@ -647,12 +647,13 @@ def _read_header_with_fast_meta( "(unphased) by default." ) cat_id = self._add_phase_center( - getattr(obj, "object_name", "unprojected"), cat_type="unprojected" + getattr(meta, "object_name", "unprojected"), cat_type="unprojected" ) - self.phase_center_id_array = np.full(obj.Nblts, cat_id, dtype=int) + self.phase_center_id_array = np.full(meta.Nblts, cat_id, dtype=int) # set any extra telescope params self.set_telescope_params( + x_orientation=meta.x_orientation, run_check=run_check, check_extra=check_extra, run_check_acceptability=run_check_acceptability, diff --git a/src/pyuvdata/uvflag/uvflag.py b/src/pyuvdata/uvflag/uvflag.py index 9431151793..0c238003ef 100644 --- a/src/pyuvdata/uvflag/uvflag.py +++ b/src/pyuvdata/uvflag/uvflag.py @@ -26,7 +26,8 @@ "antenna_numbers": "antenna_numbers", "antenna_positions": "antenna_positions", "antenna_diameters": "antenna_diameters", - "x_orientation": "x_orientation", + "feed_array": "feed_array", + "feed_angle": "feed_angle", "instrument": "instrument", } @@ -585,7 +586,8 @@ def __init__( def _set_telescope_requirements(self): """Set the UVParameter required fields appropriately for UVCal.""" self.telescope._instrument.required = False - self.telescope._x_orientation.required = False + self.telescope._feed_array.required = False + self.telescope._feed_angle.required = False @property def _data_params(self): @@ -833,7 +835,7 @@ def clear_unused_attributes(self): """Remove unused attributes. Useful when changing type or mode or to save memory. - Will set all non-required attributes to None, except x_orientation, + Will set all non-required attributes to None, except feed_array, feed_angle, extra_keywords, weights_square_array and filename. """ @@ -845,7 +847,8 @@ def clear_unused_attributes(self): "antenna_numbers", "antenna_positions", "Nants_telescope", - "x_orientation", + "feed_array", + "feed_angle", "weights_square_array", "extra_keywords", "filename", @@ -940,6 +943,7 @@ def set_lsts_from_time_array(self, *, background=False, astrometry_library=None) def set_telescope_params( self, *, + x_orientation=None, overwrite=False, warn=True, run_check=True, @@ -972,6 +976,8 @@ def set_telescope_params( run_check=run_check, check_extra=check_extra, run_check_acceptability=run_check_acceptability, + x_orientation=x_orientation, + polarization_array=self.polarization_array, ) def antpair2ind(self, ant1, ant2): @@ -1076,7 +1082,8 @@ def get_pols(self): list of polarizations (as strings) in the data. """ return utils.polnum2str( - self.polarization_array, x_orientation=self.telescope.x_orientation + self.polarization_array, + x_orientation=self.telescope.get_x_orientation_from_feeds(), ) def parse_ants(self, ant_str, *, print_toggle=False): @@ -1122,7 +1129,7 @@ def parse_ants(self, ant_str, *, print_toggle=False): self, ant_str=ant_str, print_toggle=print_toggle, - x_orientation=self.telescope.x_orientation, + x_orientation=self.telescope.get_x_orientation_from_feeds(), ) def collapse_pol( @@ -1439,7 +1446,7 @@ def to_baseline( "spw_array", "flex_spw_id_array", ] - warning_params = ["instrument", "x_orientation", "antenna_diameters"] + warning_params = ["instrument", "antenna_diameters", "feed_array", "feed_angle"] # sometimes the antenna sorting for the antenna names/numbers/positions # is different. If the sets are the same, re-sort self to match uv @@ -1668,7 +1675,7 @@ def to_antenna( "spw_array", "flex_spw_id_array", ] - warning_params = ["instrument", "x_orientation", "antenna_diameters"] + warning_params = ["instrument", "feed_array", "feed_angle", "antenna_diameters"] # sometimes the antenna sorting for the antenna names/numbers/positions # is different. If the sets are the same, re-sort self to match uv @@ -1971,7 +1978,7 @@ def __add__( ax = axis_nums[axis][type_nums[self.type]] compatibility_params = ["telescope_name", "telescope_location"] - warning_params = ["instrument", "x_orientation", "antenna_diameters"] + warning_params = ["instrument", "feed_array", "feed_angle", "antenna_diameters"] if axis != "frequency": compatibility_params.extend( @@ -2090,6 +2097,26 @@ def __add__( this.telescope.antenna_diameters = temp_ant_diameters[unique_inds] else: this.telescope.antenna_diameters = None + if ( + this.telescope.feed_array is not None + and other.telescope.feed_array is not None + ): + temp_feed_array = np.concatenate( + [this.telescope.feed_array, other.telescope.feed_array], axis=0 + ) + this.telescope.feed_array = temp_feed_array[unique_inds] + else: + this.telescope.feed_array = None + if ( + this.telescope.feed_angle is not None + and other.telescope.feed_angle is not None + ): + temp_feed_angle = np.concatenate( + [this.telescope.feed_angle, other.telescope.feed_angle], axis=0 + ) + this.telescope.feed_angle = temp_feed_angle[unique_inds] + else: + this.telescope.feed_angle = None elif axis == "frequency": this.freq_array = np.concatenate( @@ -2437,8 +2464,8 @@ def _select_preprocess( The polarizations numbers to keep in the object, each value passed here should exist in the polarization_array. If passing strings, the canonical polarization strings (e.g. "xx", "rr") are supported and if the - `x_orientation` attribute is set, the physical dipole strings - (e.g. "nn", "ee") are also supported. + `telescope.feed_array` and `telescope.feed_angle` attributes are set, the + physical dipole strings (e.g. "nn", "ee") are also supported. blt_inds : array_like of int, optional The baseline-time indices to keep in the object. This is not commonly used. @@ -2581,7 +2608,7 @@ def _select_preprocess( for p in polarizations: if isinstance(p, str): p_num = utils.polstr2num( - p, x_orientation=self.telescope.x_orientation + p, x_orientation=self.telescope.get_x_orientation_from_feeds() ) else: p_num = p diff --git a/tests/conftest.py b/tests/conftest.py index 3dd03caf54..d7186cc7f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,13 +84,14 @@ def uvcalibrate_data_main(uvcalibrate_init_data_main): uvdata = uvdata_in.copy() uvcal = uvcal_in.copy() - warn_str = ( + warn_str = [ "telescope_location, Nants, antenna_names, antenna_numbers, " "antenna_positions, antenna_diameters are not set or are being " "overwritten. telescope_location, Nants, antenna_names, " "antenna_numbers, antenna_positions, antenna_diameters are set " - "using values from known telescopes for HERA." - ) + "using values from known telescopes for HERA.", + "Nants has changed, setting feed_array and feed_angle ", + ] with check_warnings(UserWarning, warn_str): uvcal.set_telescope_params(overwrite=True) diff --git a/tests/test_telescopes.py b/tests/test_telescopes.py index de152a51ab..be574dc3af 100644 --- a/tests/test_telescopes.py +++ b/tests/test_telescopes.py @@ -41,7 +41,6 @@ ] extra_parameters = [ "_antenna_diameters", - "_x_orientation", "_instrument", "_mount_type", "_Nfeeds", @@ -50,7 +49,6 @@ ] extra_properties = [ "antenna_diameters", - "x_orientation", "instrument", "mount_type", "Nfeeds", @@ -195,13 +193,19 @@ def test_update_params_from_known(): "for telescope hera.", "x_orientation are not set or are being overwritten. x_orientation " "are set using values from known telescopes for hera.", + "Unknown polarization basis -- assuming linearly polarized (x/y) feeds for " + "Telescope.feed_array.", ], ): hera_tel_test.update_params_from_known_telescopes( known_telescope_dict=known_dict ) assert hera_tel_test.antenna_diameters is None - assert hera_tel_test.x_orientation == "east" + with check_warnings( + DeprecationWarning, "The Telescope.x_orientation attribute is deprecated" + ): + assert hera_tel_test.x_orientation == "east" + assert hera_tel_test.get_x_orientation_from_feeds() == "east" mwa_tel = Telescope.from_known_telescopes("mwa") mwa_tel2 = Telescope() @@ -482,11 +486,15 @@ def test_bad_antenna_inputs(kwargs, err_msg): @pytest.mark.parametrize("xorient", ["e", "n", "east", "NORTH"]) def test_passing_xorient(simplest_working_params, xorient): - tel = Telescope.new(x_orientation=xorient, **simplest_working_params) - if xorient.lower().startswith("e"): - assert tel.x_orientation == "east" - else: - assert tel.x_orientation == "north" + with check_warnings(UserWarning, "Unknown polarization basis"): + tel = Telescope.new(x_orientation=xorient, **simplest_working_params) + + with check_warnings( + DeprecationWarning, "The Telescope.x_orientation attribute is deprecated" + ): + name = "east" if xorient.lower().startswith("e") else "north" + assert tel.x_orientation == name + assert tel.get_x_orientation_from_feeds() == name def test_passing_diameters(simplest_working_params): diff --git a/tests/test_uvbase.py b/tests/test_uvbase.py index 1f4a894ad7..d7393648d6 100644 --- a/tests/test_uvbase.py +++ b/tests/test_uvbase.py @@ -521,31 +521,46 @@ def test_telescope_inequality(capsys): def test_getattr_old_telescope(): test_obj = UVTest() + x_warn_msg = ( + "The Telescope.x_orientation attribute is deprecated, and has " + "been superseded by Telescope.feed_angle and Telescope.feed_array. " + "This will become an error in version 3.4. To set the equivalent " + "value in the future, you can substitute accessing this parameter " + "with a call to Telescope.get_x_orientation_from_feeds()." + ) + for param, tel_param in old_telescope_metadata_attrs.items(): if tel_param is not None: - with check_warnings( - DeprecationWarning, - match=f"The UVData.{param} attribute now just points to the " + warn_msg = [ + f"The UVData.{param} attribute now just points to the " f"{tel_param} attribute on the " "telescope object (at UVData.telescope). Accessing it this " "way is deprecated, please access it via the telescope " - "object. This will become an error in version 3.2.", - ): + "object. This will become an error in version 3.2." + ] + if param == "x_orientation": + warn_msg.append(x_warn_msg) + + with check_warnings(DeprecationWarning, match=warn_msg): param_val = getattr(test_obj, param) - tel_param_val = getattr(test_obj.telescope, tel_param) - tel_param_obj = getattr(test_obj.telescope, "_" + tel_param) - if not isinstance(param_val, np.ndarray): - assert param_val == tel_param_val - else: - if not isinstance(param_val.flat[0], str): - np.testing.assert_allclose( - param_val, - tel_param_val, - rtol=tel_param_obj.tols[0], - atol=tel_param_obj.tols[1], - ) + if tel_param != "x_orientation": + tel_param_val = getattr(test_obj.telescope, tel_param) + tel_param_obj = getattr(test_obj.telescope, "_" + tel_param) + if not isinstance(param_val, np.ndarray): + assert param_val == tel_param_val else: - assert param_val.tolist() == tel_param_val.tolist() + if not isinstance(param_val.flat[0], str): + np.testing.assert_allclose( + param_val, + tel_param_val, + rtol=tel_param_obj.tols[0], + atol=tel_param_obj.tols[1], + ) + else: + assert param_val.tolist() == tel_param_val.tolist() + else: + assert test_obj.telescope.get_x_orientation_from_feeds() == param_val + elif param == "telescope_location": with check_warnings( DeprecationWarning, @@ -599,29 +614,52 @@ def test_getattr_old_telescope(): def test_setattr_old_telescope(): test_obj = UVTest() + x_get_warn_msg = ( + "The Telescope.x_orientation attribute is deprecated, and has " + "been superseded by Telescope.feed_angle and Telescope.feed_array. " + "This will become an error in version 3.4. To set the equivalent " + "value in the future, you can substitute accessing this parameter " + "with a call to Telescope.get_x_orientation_from_feeds()." + ) + + x_set_warn_msg = ( + "The Telescope.x_orientation attribute is deprecated, and has " + "been superseded by Telescope.feed_angle and Telescope.feed_array. " + "This will become an error in version 3.4. To get the equivalent " + "value in the future, you can substitute accessing this parameter " + "with a call to Telescope.set_feeds_from_x_orientation()." + ) + new_telescope = Telescope.from_known_telescopes("hera") for param, tel_param in old_telescope_metadata_attrs.items(): if tel_param is not None: - tel_val = getattr(new_telescope, tel_param) - tel_param_obj = getattr(new_telescope, "_" + tel_param) - with check_warnings( - DeprecationWarning, - match=f"The UVData.{param} attribute now just points to the " + warn_msg = [ + f"The UVData.{param} attribute now just points to the " f"{tel_param} attribute on the " "telescope object (at UVData.telescope). Accessing it this " "way is deprecated, please access it via the telescope " - "object. This will become an error in version 3.2.", - ): + "object. This will become an error in version 3.2." + ] + + if param == "x_orientation": + with check_warnings(DeprecationWarning, match=x_get_warn_msg): + tel_val = getattr(new_telescope, tel_param) + tel_param_obj = None + else: + tel_val = getattr(new_telescope, tel_param) + tel_param_obj = getattr(new_telescope, "_" + tel_param) + + if param == "x_orientation": + warn_msg.append(x_set_warn_msg) + with check_warnings(DeprecationWarning, match=warn_msg): setattr(test_obj, param, tel_val) - with check_warnings( - DeprecationWarning, - match=f"The UVData.{param} attribute now just points to the " - f"{tel_param} attribute on the " - "telescope object (at UVData.telescope). Accessing it this " - "way is deprecated, please access it via the telescope " - "object. This will become an error in version 3.2.", - ): + + if param == "x_orientation": + _ = warn_msg.pop() + warn_msg.append(x_get_warn_msg) + + with check_warnings(DeprecationWarning, match=warn_msg): param_val = getattr(test_obj, param) if not isinstance(param_val, np.ndarray): assert param_val == tel_val diff --git a/tests/utils/test_uvcalibrate.py b/tests/utils/test_uvcalibrate.py index 4e12db84f3..72d2ab8028 100644 --- a/tests/utils/test_uvcalibrate.py +++ b/tests/utils/test_uvcalibrate.py @@ -133,7 +133,12 @@ def test_uvcalibrate_apply_gains_oldfiles(uvcalibrate_uvdata_oldfiles): uvd = uvcalibrate_uvdata_oldfiles # give it an x_orientation - uvd.telescope.x_orientation = "east" + uvd.telescope.set_feeds_from_x_orientation( + x_orientation="east", + polarization_array=uvd.polarization_array, + flex_polarization_array=uvd.flex_spw_polarization_array, + ) + uvc = UVCal() uvc.read_calfits(os.path.join(DATA_PATH, "zen.2457698.40355.xx.gain.calfits")) # downselect to match each other in shape (but not in actual values!) @@ -668,7 +673,11 @@ def test_uvcalibrate_feedpol_mismatch(uvcalibrate_data): uvd, uvc = uvcalibrate_data # downselect the feed polarization to get warnings - uvc.select(jones=utils.jstr2num("Jnn", x_orientation=uvc.telescope.x_orientation)) + uvc.select( + jones=utils.jstr2num( + "Jnn", x_orientation=uvc.telescope.get_x_orientation_from_feeds() + ) + ) with pytest.raises( ValueError, match=("Feed polarization e exists on UVData but not on UVCal.") ): @@ -679,8 +688,10 @@ def test_uvcalibrate_x_orientation_mismatch(uvcalibrate_data): uvd, uvc = uvcalibrate_data # next check None uvd_x - uvd.telescope.x_orientation = None - uvc.telescope.x_orientation = "east" + uvd.telescope.set_feeds_from_x_orientation(None) + uvc.telescope.set_feeds_from_x_orientation( + "east", polarization_array=uvc.jones_array + ) with pytest.warns( UserWarning, match=r"UVData object does not have `x_orientation` specified but UVCal does", diff --git a/tests/uvcal/test_initializers.py b/tests/uvcal/test_initializers.py index 1fb71325a2..7c8b0a3ecf 100644 --- a/tests/uvcal/test_initializers.py +++ b/tests/uvcal/test_initializers.py @@ -53,6 +53,7 @@ def uvc_simplest_no_telescope(): "telescope_location": EarthLocation.from_geodetic(0, 0, 0), "telescope_name": "mock", "x_orientation": "n", + "feeds": ["x", "y"], "antenna_positions": { 0: [0.0, 0.0, 0.0], 1: [0.0, 0.0, 1.0], @@ -74,6 +75,7 @@ def uvc_simplest(): location=EarthLocation.from_geodetic(0, 0, 0), name="mock", x_orientation="n", + feeds=["x", "y"], antenna_positions={ 0: [0.0, 0.0, 0.0], 1: [0.0, 0.0, 1.0], @@ -99,6 +101,7 @@ def uvc_simplest_moon(): location=MoonLocation.from_selenodetic(0, 0, 0), name="mock", x_orientation="n", + feeds=["x", "y"], antenna_positions={ 0: [0.0, 0.0, 0.0], 1: [0.0, 0.0, 1.0], @@ -195,7 +198,7 @@ def test_new_uvcal_time_range(uvc_simplest): }, ) }, - "x_orientation must be set on the Telescope object passed to `telescope`.", + "feed_array must be set on the Telescope object passed to `telescope`.", ], ], ) @@ -300,7 +303,7 @@ def test_new_uvcal_from_uvdata_errors(uvd_kw, uvc_only_kw): uvc_only_kw.pop("x_orientation") with pytest.raises( ValueError, - match=("x_orientation must be provided if it is not set on the UVData object."), + match=("Telescope feed info must be provided if not set on the UVData object."), ): new_uvcal_from_uvdata(uvd, **uvc_only_kw) diff --git a/tests/uvcal/test_ms_cal.py b/tests/uvcal/test_ms_cal.py index 147ef615f4..3d98ced96f 100644 --- a/tests/uvcal/test_ms_cal.py +++ b/tests/uvcal/test_ms_cal.py @@ -205,16 +205,16 @@ def test_ms_default_setting(): uvc1.read_ms_cal( testfile, default_x_orientation="north", - default_jones_array=np.array([-5, -6]), + default_jones_array=np.array([-1, -2]), ) with check_warnings(UserWarning, match=sma_warnings): uvc2.read(testfile) - assert uvc1.telescope.x_orientation == "north" - assert uvc2.telescope.x_orientation == "east" - assert np.array_equal(uvc1.jones_array, [-5, -6]) - assert np.array_equal(uvc2.jones_array, [0, 0]) + assert uvc1.telescope.get_x_orientation_from_feeds() == "north" + assert uvc2.telescope.get_x_orientation_from_feeds() == "east" + assert np.array_equal(uvc1.jones_array, [-1, -2]) + assert np.array_equal(uvc2.jones_array, [-5, -6]) def test_ms_muck_ants(sma_pcal, tmp_path): diff --git a/tests/uvcal/test_uvcal.py b/tests/uvcal/test_uvcal.py index 4120abde42..a41d8aa6db 100644 --- a/tests/uvcal/test_uvcal.py +++ b/tests/uvcal/test_uvcal.py @@ -464,6 +464,12 @@ def test_nants_data_telescope_larger(gain_data): gain_data.telescope.antenna_positions = np.concatenate( (gain_data.telescope.antenna_positions, np.zeros((1, 3), dtype=float)) ) + gain_data.telescope.feed_array = np.concatenate( + (gain_data.telescope.feed_array, np.array([["x"]], dtype=str)) + ) + gain_data.telescope.feed_angle = np.concatenate( + (gain_data.telescope.feed_angle, np.full((1, 1), np.pi / 2, dtype=float)) + ) if gain_data.telescope.antenna_diameters is not None: gain_data.telescope.antenna_diameters = np.concatenate( (gain_data.telescope.antenna_diameters, np.ones((1,), dtype=float)) @@ -478,6 +484,8 @@ def test_ant_array_not_in_antnums(gain_data): gain_data.telescope.antenna_names = gain_data.telescope.antenna_names[1:] gain_data.telescope.antenna_numbers = gain_data.telescope.antenna_numbers[1:] gain_data.telescope.antenna_positions = gain_data.telescope.antenna_positions[1:, :] + gain_data.telescope.feed_array = gain_data.telescope.feed_array[1:, :] + gain_data.telescope.feed_angle = gain_data.telescope.feed_angle[1:, :] if gain_data.telescope.antenna_diameters is not None: gain_data.telescope.antenna_diameters = gain_data.telescope.antenna_diameters[ 1: @@ -1170,7 +1178,9 @@ def test_select_polarizations(caltype, jones_to_keep, gain_data, delay_data, tmp assert j in calobj2.jones_array else: assert ( - utils.jstr2num(j, x_orientation=calobj2.telescope.x_orientation) + utils.jstr2num( + j, x_orientation=calobj2.telescope.get_x_orientation_from_feeds() + ) in calobj2.jones_array ) for j in np.unique(calobj2.jones_array): @@ -1178,7 +1188,8 @@ def test_select_polarizations(caltype, jones_to_keep, gain_data, delay_data, tmp assert j in jones_to_keep else: assert j in utils.jstr2num( - jones_to_keep, x_orientation=calobj2.telescope.x_orientation + jones_to_keep, + x_orientation=calobj2.telescope.get_x_orientation_from_feeds(), ) assert utils.history._check_histories( @@ -1741,7 +1752,8 @@ def test_reorder_jones(caltype, metadata_only, gain_data, delay_data): calobj2.reorder_jones() name_array = np.asarray( utils.jnum2str( - calobj2.jones_array, x_orientation=calobj2.telescope.x_orientation + calobj2.jones_array, + x_orientation=calobj2.telescope.get_x_orientation_from_feeds(), ) ) sorted_names = np.sort(name_array) @@ -1749,7 +1761,7 @@ def test_reorder_jones(caltype, metadata_only, gain_data, delay_data): # test sorting with an index array. Sort back to number first so indexing works sorted_nums = utils.jstr2num( - sorted_names, x_orientation=calobj.telescope.x_orientation + sorted_names, x_orientation=calobj.telescope.get_x_orientation_from_feeds() ) index_array = [np.nonzero(calobj.jones_array == num)[0][0] for num in sorted_nums] calobj.reorder_jones(index_array) diff --git a/tests/uvdata/test_miriad.py b/tests/uvdata/test_miriad.py index 5b3edbd11c..241d194b63 100644 --- a/tests/uvdata/test_miriad.py +++ b/tests/uvdata/test_miriad.py @@ -1163,10 +1163,12 @@ def test_miriad_extra_keywords(uv_in_paper, tmp_path, kwd_names, kwd_values): @pytest.mark.filterwarnings("ignore:The uvw_array does not match the expected values") -def test_roundtrip_optional_params(uv_in_paper, tmp_path): +def test_roundtrip_optional_params(uv_in_paper): uv_in, uv_out, testfile = uv_in_paper - uv_in.telescope.x_orientation = "east" + uv_in.telescope.set_feeds_from_x_orientation( + "east", polarization_array=uv_in.polarization_array + ) uv_in.pol_convention = "sum" uv_in.vis_units = "Jy" uv_in.reorder_blts() @@ -1279,7 +1281,9 @@ def test_read_write_read_miriad(uv_in_paper): assert str(cm.value).startswith("File exists: skipping") # check that if x_orientation is set, it's read back out properly - uv_in.telescope.x_orientation = "east" + uv_in.telescope.set_feeds_from_x_orientation( + "east", polarization_array=uv_in.polarization_array + ) _write_miriad(uv_in, write_file, clobber=True) uv_out.read(write_file) uv_out._consolidate_phase_center_catalogs(other=uv_in) diff --git a/tests/uvdata/test_ms.py b/tests/uvdata/test_ms.py index 11c6aeabed..0f00114986 100644 --- a/tests/uvdata/test_ms.py +++ b/tests/uvdata/test_ms.py @@ -960,9 +960,13 @@ def test_antenna_diameter_handling(hera_uvh5, tmp_path): def test_ms_optional_parameters(nrao_uv, tmp_path): uv_obj = nrao_uv - uv_obj.telescope.x_orientation = "east" + uv_obj.telescope.set_feeds_from_x_orientation( + "east", polarization_array=uv_obj.polarization_array + ) uv_obj.pol_convention = "sum" uv_obj.vis_units = "Jy" + # Update the order so as to be UVFITS compliant + uv_obj.telescope.reorder_feeds("AIPS") test_file = os.path.join(tmp_path, "dish_diameter_out.ms") uv_obj.write_ms(test_file, force_phase=True) diff --git a/tests/uvdata/test_uvdata.py b/tests/uvdata/test_uvdata.py index eee9a00172..456c8637c2 100644 --- a/tests/uvdata/test_uvdata.py +++ b/tests/uvdata/test_uvdata.py @@ -2444,7 +2444,9 @@ def test_select_polarizations(hera_uvh5, pols_to_keep): assert p in uv_object2.polarization_array else: assert ( - utils.polstr2num(p, x_orientation=uv_object2.telescope.x_orientation) + utils.polstr2num( + p, x_orientation=uv_object2.telescope.get_x_orientation_from_feeds() + ) in uv_object2.polarization_array ) for p in np.unique(uv_object2.polarization_array): @@ -2452,7 +2454,8 @@ def test_select_polarizations(hera_uvh5, pols_to_keep): assert p in pols_to_keep else: assert p in utils.polstr2num( - pols_to_keep, x_orientation=uv_object2.telescope.x_orientation + pols_to_keep, + x_orientation=uv_object2.telescope.get_x_orientation_from_feeds(), ) assert utils.history._check_histories( @@ -5040,14 +5043,17 @@ def test_get_pols(casa_uvfits): def test_get_pols_x_orientation(paper_uvh5): uv_in = paper_uvh5 - uv_in.telescope.x_orientation = "east" + uv_in.telescope.set_feeds_from_x_orientation( + "east", polarization_array=uv_in.polarization_array + ) pols = uv_in.get_pols() pols_data = ["en"] assert pols == pols_data - uv_in.telescope.x_orientation = "north" - + uv_in.telescope.set_feeds_from_x_orientation( + "north", polarization_array=uv_in.polarization_array + ) pols = uv_in.get_pols() pols_data = ["ne"] assert pols == pols_data @@ -12245,7 +12251,9 @@ def test_init_like_hera_cal(hera_uvh5, tmp_path, projected, check_before_write): "name", "location", "instrument", - "x_orientation", + "Nfeeds", + "feed_array", + "feed_angle", "Nants", "antenna_names", "antenna_numbers", diff --git a/tests/uvdata/test_uvfits.py b/tests/uvdata/test_uvfits.py index d0f41fad6c..70e14a5580 100644 --- a/tests/uvdata/test_uvfits.py +++ b/tests/uvdata/test_uvfits.py @@ -713,8 +713,12 @@ def test_uvfits_optional_params(tmp_path, casa_uvfits): write_file = str(tmp_path / "outtest_casa.uvfits") # check that if optional params are set, they are read back out properly - uv_in.telescope.x_orientation = "east" + uv_in.telescope.set_feeds_from_x_orientation( + "east", polarization_array=uv_in.polarization_array + ) uv_in.telescope.pol_convention = "sum" + # Order feeds in AIPS convention for round-tripping + uv_in.telescope.reorder_feeds("AIPS") uv_in.write_uvfits(write_file) uv_out.read(write_file) diff --git a/tests/uvdata/test_uvh5.py b/tests/uvdata/test_uvh5.py index b3950080eb..d239f182ea 100644 --- a/tests/uvdata/test_uvh5.py +++ b/tests/uvdata/test_uvh5.py @@ -328,7 +328,9 @@ def test_uvh5_optional_parameters(casa_uvfits, tmp_path): testfile = str(tmp_path / "outtest_uvfits.uvh5") # set optional parameters - uv_in.telescope.x_orientation = "east" + uv_in.telescope.set_feeds_from_x_orientation( + "east", polarization_array=uv_in.polarization_array + ) uv_in.pol_conventions = "avg" uv_in.telescope.antenna_diameters = ( np.ones_like(uv_in.telescope.antenna_numbers) * 1.0 diff --git a/tests/uvflag/test_uvflag.py b/tests/uvflag/test_uvflag.py index 23fd3bf114..193c5aab58 100644 --- a/tests/uvflag/test_uvflag.py +++ b/tests/uvflag/test_uvflag.py @@ -358,9 +358,13 @@ def test_read_extra_keywords(uvdata_obj): @pytest.mark.filterwarnings("ignore:The uvw_array does not match the expected values") def test_init_uvdata_x_orientation(uvdata_obj): uv = uvdata_obj - uv.telescope.x_orientation = "east" + uv.telescope.set_feeds_from_x_orientation( + "east", polarization_array=uv.polarization_array + ) uvf = UVFlag(uv, history="I made a UVFlag object", label="test") - assert uvf.telescope.x_orientation == uv.telescope.x_orientation + assert uvf.telescope.get_x_orientation_from_feeds() == ( + uv.telescope.get_x_orientation_from_feeds() + ) @pytest.mark.filterwarnings("ignore:The uvw_array does not match the expected values") @@ -442,7 +446,8 @@ def test_init_uvcal(uvcal_obj): assert uvf.type == "antenna" assert uvf.mode == "metric" assert np.all(uvf.time_array == uvc.time_array) - assert uvf.telescope.x_orientation == uvc.telescope.x_orientation + assert np.all(uvf.telescope.feed_array == uvc.telescope.feed_array) + assert np.all(uvf.telescope.feed_angle == uvc.telescope.feed_angle) assert np.all(uvf.lst_array == uvc.lst_array) assert np.all(uvf.freq_array == uvc.freq_array) assert np.all(uvf.polarization_array == uvc.jones_array) @@ -1056,6 +1061,10 @@ def test_read_write_loop_missing_telescope_info( uv.telescope.antenna_diameters = uv.telescope.antenna_diameters[ ant_inds_keep ] + if uv.telescope.feed_array is not None: + uv.telescope.feed_array = uv.telescope.feed_array[ant_inds_keep] + if uv.telescope.feed_angle is not None: + uv.telescope.feed_angle = uv.telescope.feed_angle[ant_inds_keep] uv.telescope.Nants = ant_inds_keep.size uv.check() else: @@ -1276,7 +1285,9 @@ def test_read_write_loop_missing_spw_array(uvdata_obj, test_outfile): def test_read_write_loop_with_optional_x_orientation(uvdata_obj, test_outfile): uv = uvdata_obj uvf = UVFlag(uv, label="test") - uvf.telescope.x_orientation = "east" + uvf.telescope.set_feeds_from_x_orientation( + "east", polarization_array=uvf.polarization_array + ) uvf.write(test_outfile, clobber=True) uvf2 = UVFlag(test_outfile) assert uvf.__eq__(uvf2, check_history=True) @@ -1764,19 +1775,27 @@ def test_add_antenna(uvcal_obj, diameters): ) if diameters == "left" or diameters == "right": uv2.telescope.antenna_diameters = None + uv2.telescope.feed_array = None + uv2.telescope.feed_angle = None if diameters == "both": warn_type = None warn_msg = "" else: warn_type = UserWarning - warn_msg = "UVParameter antenna_diameters does not match. Combining anyway." + warn_msg = [ + "UVParameter antenna_diameters does not match. Combining anyway.", + "UVParameter feed_array does not match. Combining anyway.", + "UVParameter feed_angle does not match. Combining anyway.", + ] with check_warnings(warn_type, match=warn_msg): uv3 = uv1.__add__(uv2, axis="antenna") if diameters != "both": assert uv3.telescope.antenna_diameters is None + assert uv3.telescope.feed_array is None + assert uv3.telescope.feed_angle is None assert np.array_equal(np.concatenate((uv1.ant_array, uv2.ant_array)), uv3.ant_array) assert np.array_equal( @@ -2458,7 +2477,8 @@ def test_to_baseline_from_antenna(uvdata_obj, uvf_from_uvcal): with check_warnings( UserWarning, - match=["telescope_location, Nants, antenna_names, antenna_numbers, "] * 2, + match=["telescope_location, Nants, antenna_names, antenna_numbers, "] * 2 + + ["Nants has changed, setting feed_array and feed_angle automatically"], ): uvf.set_telescope_params(overwrite=True) uv.set_telescope_params(overwrite=True) @@ -2483,14 +2503,18 @@ def test_to_baseline_from_antenna(uvdata_obj, uvf_from_uvcal): with check_warnings( UserWarning, - match="x_orientation is not the same on this object and on uv. Keeping " - "the value on this object.", + match=[ + "feed_array is not the same on this object and on uv.", + "feed_angle is not the same on this object and on uv.", + ], ): uvf.to_baseline(uv, force_pol=True) with check_warnings( UserWarning, - match="x_orientation is not the same on this object and on uv. Keeping " - "the value on this object.", + match=[ + "feed_array is not the same on this object and on uv.", + "feed_angle is not the same on this object and on uv.", + ], ): uvf2.to_baseline(uv2, force_pol=True) uvf.check() @@ -3750,7 +3774,9 @@ def test_select_polarizations(uvf_mode, pols_to_keep, input_uvf): np.random.seed(0) old_history = uvf.history - uvf.telescope.x_orientation = "north" + uvf.telescope.set_feeds_from_x_orientation( + "north", polarization_array=uvf.polarization_array + ) uvf2 = uvf.copy() uvf2.select(polarizations=pols_to_keep) @@ -3763,7 +3789,9 @@ def test_select_polarizations(uvf_mode, pols_to_keep, input_uvf): assert p in uvf2.polarization_array else: assert ( - utils.polstr2num(p, x_orientation=uvf2.telescope.x_orientation) + utils.polstr2num( + p, x_orientation=uvf2.telescope.get_x_orientation_from_feeds() + ) in uvf2.polarization_array ) for p in np.unique(uvf2.polarization_array): @@ -3771,7 +3799,8 @@ def test_select_polarizations(uvf_mode, pols_to_keep, input_uvf): assert p in pols_to_keep else: assert p in utils.polstr2num( - pols_to_keep, x_orientation=uvf2.telescope.x_orientation + pols_to_keep, + x_orientation=uvf2.telescope.get_x_orientation_from_feeds(), ) assert utils.history._check_histories(