Source code for missiontools.comm.antenna

"""Antenna classes for spacecraft and ground station link analysis.

An antenna can be attached to a :class:`~missiontools.Spacecraft` via
:meth:`~missiontools.Spacecraft.add_antenna` or to a
:class:`~missiontools.GroundStation` via
:meth:`~missiontools.GroundStation.add_antenna`.

The :meth:`~AbstractAntenna.gain` method computes the antenna gain (dBi)
for given direction vectors in a specified reference frame.
"""

from __future__ import annotations

from abc import ABC, abstractmethod

import numpy as np
import numpy.typing as npt

from ..orbit.frames import ecef_to_eci, lvlh_to_eci, azel_to_enu, enu_to_ecef
from ..orbit.constants import EARTH_MEAN_RADIUS
from ..sensor.sensor_law import _euler_zyx_to_boresight


class AbstractAntenna(ABC):
    """Base class for antennas attachable to Spacecraft or GroundStation.

    Mounting is specified via keyword arguments.  Exactly one mounting
    group must be provided (spacecraft or ground station), unless the
    subclass is direction-independent (e.g. :class:`IsotropicAntenna`).

    **Spacecraft mounting** (provide exactly one):

    - ``attitude_law`` — independent :class:`~missiontools.AbstractAttitudeLaw`
    - ``body_vector`` — boresight direction in the spacecraft body frame
    - ``body_euler_deg`` — ``(yaw, pitch, roll)`` ZYX intrinsic Euler
      angles defining the boresight in the body frame

    **Ground station mounting**:

    - ``azimuth_deg`` — azimuth from north (deg), clockwise positive
    - ``elevation_deg`` — elevation from horizon (deg)
    - ``rotation_deg`` — boresight rotation (deg), default 0

    Parameters
    ----------
    attitude_law : AbstractAttitudeLaw, optional
    body_vector : array_like, shape (3,), optional
    body_euler_deg : tuple of float, optional
    azimuth_deg : float, optional
    elevation_deg : float, optional
    rotation_deg : float, optional
    """

    _requires_mounting = True

    def __init__(
        self,
        *,
        attitude_law=None,
        body_vector: npt.ArrayLike | None = None,
        body_euler_deg: tuple[float, float, float] | None = None,
        azimuth_deg: float | None = None,
        elevation_deg: float | None = None,
        rotation_deg: float = 0.0,
    ) -> None:
        sc_opts = sum(
            x is not None for x in (attitude_law, body_vector, body_euler_deg)
        )
        gs_opts = azimuth_deg is not None

        if sc_opts and gs_opts:
            raise ValueError(
                "Cannot mix spacecraft mounting (attitude_law / body_vector "
                "/ body_euler_deg) with ground station mounting "
                "(azimuth_deg / elevation_deg)."
            )

        if not sc_opts and not gs_opts:
            if self._requires_mounting:
                raise ValueError(
                    "Must specify spacecraft mounting (attitude_law, "
                    "body_vector, or body_euler_deg) or ground station "
                    "mounting (azimuth_deg + elevation_deg)."
                )
            self._mode = None
            self._spacecraft = None
            self._ground_station = None
            return

        if sc_opts > 1:
            raise ValueError(
                "Specify exactly one of attitude_law, body_vector, or body_euler_deg."
            )

        self._spacecraft = None
        self._ground_station = None

        if gs_opts:
            # Ground station mounting
            if elevation_deg is None:
                raise ValueError(
                    "elevation_deg is required for ground station mounting."
                )
            if not -90.0 <= float(elevation_deg) <= 90.0:
                raise ValueError(
                    f"elevation_deg must be in [-90, 90], got {elevation_deg}"
                )
            self._mode = "ground"
            az_rad = np.radians(float(azimuth_deg))
            el_rad = np.radians(float(elevation_deg))
            self._boresight_enu = azel_to_enu(az_rad, el_rad)
            self._rotation_deg = float(rotation_deg)
            self._boresight_ecef = None  # set at attachment time
        elif attitude_law is not None:
            self._mode = "independent"
            self._attitude_law = attitude_law
        else:
            # Body-mounted
            self._mode = "body"
            if body_vector is not None:
                bv = np.asarray(body_vector, dtype=np.float64)
                if bv.shape != (3,):
                    raise ValueError(
                        f"body_vector must have shape (3,), got {bv.shape}"
                    )
                norm = np.linalg.norm(bv)
                if norm == 0:
                    raise ValueError("body_vector must be non-zero.")
                self._body_vector = bv / norm
            else:
                yaw, pitch, roll = body_euler_deg
                self._body_vector = _euler_zyx_to_boresight(yaw, pitch, roll)

    # --- properties ---

    @property
    def host(self):
        """The host object (Spacecraft or GroundStation), or ``None``."""
        return self._spacecraft or self._ground_station

    @property
    def spacecraft(self):
        """Spacecraft this antenna is attached to, or ``None``."""
        return self._spacecraft

    @property
    def ground_station(self):
        """GroundStation this antenna is attached to, or ``None``."""
        return self._ground_station

    # --- boresight computation ---

    def boresight_eci(
        self,
        r_eci: npt.ArrayLike,
        v_eci: npt.ArrayLike,
        t: npt.ArrayLike,
    ) -> npt.NDArray[np.floating]:
        """Compute the antenna boresight direction in ECI.

        Parameters
        ----------
        r_eci : array_like, shape (N, 3) or (3,)
            ECI position (m).  Used for spacecraft-mounted antennas.
        v_eci : array_like, shape (N, 3) or (3,)
            ECI velocity (m/s).
        t : array_like of datetime64[us], shape (N,) or scalar
            Timestamps.

        Returns
        -------
        ndarray, shape (N, 3) or (3,)
            Boresight unit vector in ECI.
        """
        if self._mode == "independent":
            return self._attitude_law.pointing_eci(r_eci, v_eci, t)
        elif self._mode == "body":
            if self._spacecraft is None:
                raise RuntimeError(
                    "Body-mounted antenna must be attached to a Spacecraft "
                    "via add_antenna() before computing boresight."
                )
            return self._spacecraft.attitude_law.rotate_from_body(
                self._body_vector,
                r_eci,
                v_eci,
                t,
            )
        elif self._mode == "ground":
            if self._boresight_ecef is None:
                raise RuntimeError(
                    "Ground-mounted antenna must be attached to a "
                    "GroundStation via add_antenna() before computing "
                    "boresight."
                )
            t_arr = np.atleast_1d(np.asarray(t, dtype="datetime64[us]"))
            n = len(t_arr)
            boresight_tiled = np.tile(self._boresight_ecef, (n, 1))
            result = ecef_to_eci(boresight_tiled, t_arr)
            if np.asarray(t).ndim == 0:
                return result[0]
            return result
        else:
            raise RuntimeError(f"Cannot compute boresight for mode '{self._mode}'.")

    # --- gain computation ---

    def gain(
        self,
        t: npt.ArrayLike,
        v: npt.ArrayLike,
        frame: str = "eci",
        *,
        r_eci: npt.ArrayLike | None = None,
        v_eci: npt.ArrayLike | None = None,
    ) -> npt.NDArray[np.floating]:
        """Compute antenna gain for given direction vectors.

        Parameters
        ----------
        t : array_like of datetime64[us], shape (N,) or scalar
            Timestamps.
        v : array_like, shape (N, 3) or (3,)
            Direction vectors pointing from the antenna toward the
            target.  Need not be unit vectors (normalised internally).
        frame : str
            Reference frame of *v*: ``'eci'``, ``'ecef'``, or
            ``'lvlh'``.
        r_eci : array_like, shape (N, 3) or (3,), optional
            ECI position.  Required for spacecraft-mounted antennas
            and for ``frame='lvlh'``.
        v_eci : array_like, shape (N, 3) or (3,), optional
            ECI velocity.  Required for spacecraft-mounted antennas
            and for ``frame='lvlh'``.

        Returns
        -------
        ndarray, shape (N,)
            Gain in dBi for each direction vector.
        """
        t_arr = np.atleast_1d(np.asarray(t, dtype="datetime64[us]"))
        v_arr = np.atleast_2d(np.asarray(v, dtype=np.float64))
        n = len(v_arr)

        # Convert v to ECI
        if frame == "eci":
            v_eci_dir = v_arr
        elif frame == "ecef":
            v_eci_dir = ecef_to_eci(v_arr, t_arr)
        elif frame == "lvlh":
            if r_eci is None or v_eci is None:
                raise ValueError("r_eci and v_eci are required for frame='lvlh'.")
            v_eci_dir = lvlh_to_eci(v_arr, r_eci, v_eci)
        else:
            raise ValueError(f"Unknown frame '{frame}'. Use 'eci', 'ecef', or 'lvlh'.")

        # Normalise direction vectors
        norms = np.linalg.norm(v_eci_dir, axis=1, keepdims=True)
        norms = np.where(norms == 0, 1.0, norms)
        v_hat = v_eci_dir / norms

        # Get boresight in ECI
        boresight = np.atleast_2d(self.boresight_eci(r_eci, v_eci, t_arr))

        # Off-boresight angle
        cos_theta = np.einsum("ij,ij->i", boresight, v_hat)
        theta = np.arccos(np.clip(cos_theta, -1.0, 1.0))

        return self._pattern_gain(theta)

    @property
    def peak_gain_dbi(self) -> float:
        """Peak gain at boresight (dBi)."""
        return float(self._pattern_gain(np.zeros(1))[0])

    @abstractmethod
    def _pattern_gain(
        self,
        off_boresight_rad: npt.NDArray[np.floating],
    ) -> npt.NDArray[np.floating]:
        """Gain in dBi as a function of off-boresight angle.

        Parameters
        ----------
        off_boresight_rad : ndarray, shape (N,)
            Angle from boresight (rad), in [0, pi].

        Returns
        -------
        ndarray, shape (N,)
            Gain in dBi.
        """


[docs] class IsotropicAntenna(AbstractAntenna): """Antenna with constant gain in all directions. Since the gain is direction-independent, no mounting information is needed. Parameters ---------- gain_dbi : float Constant gain (dBi). Default 0.0. """ _requires_mounting = False def __init__(self, gain_dbi: float = 0.0) -> None: super().__init__() self._gain_dbi = float(gain_dbi)
[docs] def gain( self, t: npt.ArrayLike, v: npt.ArrayLike, frame: str = "eci", *, r_eci: npt.ArrayLike | None = None, v_eci: npt.ArrayLike | None = None, ) -> npt.NDArray[np.floating]: """Return constant gain regardless of direction. Parameters are accepted for interface compatibility but ignored. """ v_arr = np.atleast_2d(np.asarray(v, dtype=np.float64)) return np.full(v_arr.shape[0], self._gain_dbi)
@property def peak_gain_dbi(self) -> float: """Peak gain (dBi), constant for isotropic antenna.""" return self._gain_dbi def _pattern_gain( self, off_boresight_rad: npt.NDArray[np.floating], ) -> npt.NDArray[np.floating]: return np.full_like(off_boresight_rad, self._gain_dbi)
[docs] class SymmetricAntenna(AbstractAntenna): """Axially symmetric antenna defined by a gain-vs-angle table. The radiation pattern is symmetric about the boresight. Gain values are linearly interpolated between the tabulated points. Parameters ---------- angles_deg : array_like, shape (K,) Off-boresight angles (deg). Must be monotonically increasing and span a range within [0, 180]. gains_dbi : array_like, shape (K,) Gain at each angle (dBi). **kwargs Mounting keyword arguments passed to :class:`AbstractAntenna`. Notes ----- Gain values are linearly interpolated using ``numpy.interp``, which **clamps** angles outside the tabulated range to the nearest endpoint value. If the table only covers ``[0°, 90°]``, angles beyond 90° will return the gain at 90° rather than a lower back-hemisphere value, which may overstate gain in that region. Extend the table to 180° (e.g. with a low back-lobe gain) to avoid this clamping. """ def __init__( self, angles_deg: npt.ArrayLike, gains_dbi: npt.ArrayLike, **kwargs, ) -> None: super().__init__(**kwargs) angles = np.asarray(angles_deg, dtype=np.float64) gains = np.asarray(gains_dbi, dtype=np.float64) if angles.ndim != 1: raise ValueError(f"angles_deg must be 1-D, got shape {angles.shape}") if gains.ndim != 1: raise ValueError(f"gains_dbi must be 1-D, got shape {gains.shape}") if len(angles) != len(gains): raise ValueError( f"angles_deg length ({len(angles)}) must match " f"gains_dbi length ({len(gains)})." ) if len(angles) < 2: raise ValueError("Need at least 2 angle/gain pairs.") if np.any(np.diff(angles) <= 0): raise ValueError("angles_deg must be monotonically increasing.") if angles[0] < 0 or angles[-1] > 180: raise ValueError("angles_deg must be within [0, 180] degrees.") self._angles_rad = np.radians(angles) self._gains_dbi = gains.copy() @property def angles_deg(self) -> npt.NDArray[np.floating]: """Tabulated off-boresight angles (deg).""" return np.degrees(self._angles_rad).copy() @property def gains_dbi(self) -> npt.NDArray[np.floating]: """Tabulated gain values (dBi).""" return self._gains_dbi.copy() # --- factory classmethods ---
[docs] @classmethod def from_isoflux( cls, altitude_km: float, min_elev_deg: float = 5.0, edge_gain: float | None = None, central_body_radius: float = EARTH_MEAN_RADIUS, **kwargs, ) -> "SymmetricAntenna": """Isoflux antenna pattern for a fixed nadir-pointing orbit altitude. Shapes the beam so that power flux density at the spherical body surface is constant across the coverage footprint. The gain increases from boresight (nadir) toward the edge of coverage to compensate for the increasing slant range. Parameters ---------- altitude_km : float Orbital altitude above the body surface (km). min_elev_deg : float, optional Minimum surface elevation angle defining the coverage edge (deg). Default 5.0°. edge_gain : float or None, optional Desired gain at the edge of coverage (dBi). If *None* (default), the boresight gain is derived from the constraint that the total antenna directivity equals unity (0 dBi on average), assuming zero radiation beyond the coverage zone. central_body_radius : float, optional Mean radius of the central body (m). Defaults to ``EARTH_MEAN_RADIUS``. **kwargs Mounting keyword arguments forwarded to :class:`SymmetricAntenna`. """ h = float(altitude_km) * 1e3 R = float(central_body_radius) d = R + h el_min_rad = np.radians(float(min_elev_deg)) theta_max = np.arcsin(np.clip((R / d) * np.cos(el_min_rad), -1.0, 1.0)) # Slant range at each off-nadir angle n_main = 200 thetas = np.linspace(0.0, theta_max, n_main) ranges = d * np.cos(thetas) - np.sqrt( np.maximum(0.0, R**2 - d**2 * np.sin(thetas) ** 2) ) # Relative gain shape (dB), normalised to boresight = 0 gain_shape_db = 20.0 * np.log10(ranges / h) if edge_gain is not None: # Back-compute boresight gain from specified edge gain g0 = float(edge_gain) - gain_shape_db[-1] else: # Unity directivity: D₀ = 2 / ∫₀^θ_max [(r/h)² · sin(θ)] dθ integrand = (ranges / h) ** 2 * np.sin(thetas) integral = np.trapz(integrand, thetas) g0 = 10.0 * np.log10(2.0 / integral) gains_main = g0 + gain_shape_db computed_edge = float(gains_main[-1]) # Immediate rolloff just beyond θ_max so np.interp doesn't clamp to # the edge gain for out-of-coverage angles. Use a tiny 0.01° step to # keep the linear transition zone negligibly small. rolloff_angle = min(theta_max + np.radians(0.01), np.radians(90.0)) angles_out = np.concatenate([thetas, [rolloff_angle, np.radians(90.0)]]) gains_out = np.concatenate([gains_main, [-60.0, -60.0]]) # Remove duplicate angles (e.g. when theta_max is already near 90°) _, unique_idx = np.unique(angles_out, return_index=True) angles_out = angles_out[unique_idx] gains_out = gains_out[unique_idx] return cls(np.degrees(angles_out), gains_out, **kwargs)
[docs] @classmethod def from_gaussian( cls, gain_dbi: float, **kwargs, ) -> "SymmetricAntenna": """Ideal Gaussian beam pattern with automatically scaled beamwidth. The half-power beamwidth is derived from the normalisation condition that total directivity equals *gain_dbi* (i.e. the Gaussian integral over the full sphere gives the correct peak value). Parameters ---------- gain_dbi : float Peak gain at boresight (dBi). **kwargs Mounting keyword arguments forwarded to :class:`SymmetricAntenna`. """ from scipy.optimize import brentq gain_dbi = float(gain_dbi) if gain_dbi <= 0.0: raise ValueError( f"gain_dbi must be positive for a Gaussian beam (got {gain_dbi}). " "A Gaussian pattern always has peak directivity > 0 dBi; " "use IsotropicAntenna for gain_dbi ≤ 0." ) D = 10.0 ** (gain_dbi / 10.0) # linear directivity def _residual(sigma: float) -> float: # D = 2 / ∫₀^π exp(−θ²/(2σ²)) · sin(θ) dθ th = np.linspace(0.0, np.pi, 4000) integ = np.trapz(np.exp(-(th**2) / (2.0 * sigma**2)) * np.sin(th), th) return 2.0 / integ - D sigma = brentq(_residual, 1e-4, 50.0) # Angle at which gain drops to −60 dBi theta_cutoff = sigma * np.sqrt( 2.0 * (float(gain_dbi) + 60.0) * np.log(10.0) / 10.0 ) theta_cutoff = min(theta_cutoff, np.pi / 2.0) thetas = np.linspace(0.0, theta_cutoff, 500) gains = float(gain_dbi) + 10.0 * np.log10( np.maximum(np.exp(-(thetas**2) / (2.0 * sigma**2)), 1e-20) ) # Append a far-field point at 180° to cover the full back hemisphere angles_out = np.append(np.degrees(thetas), 180.0) gains_out = np.append(gains, -60.0) return cls(angles_out, gains_out, **kwargs)
[docs] @classmethod def from_parabolic( cls, diameter: float, frequency: float, eff: float = 0.6, envelope: bool = False, **kwargs, ) -> "SymmetricAntenna": """Uniformly illuminated parabolic reflector antenna pattern. Parameters ---------- diameter : float Reflector diameter (m). frequency : float Centre frequency (Hz). eff : float, optional Antenna efficiency (dimensionless, 0 < eff ≤ 1). Accounts for spillover, blockage, surface errors, etc. Default 0.6. envelope : bool, optional If *False* (default), the full pattern including sidelobes is returned. If *True*, the sidelobe envelope is used: the main lobe is exact and sidelobes beyond the first null are replaced by the asymptotic envelope ``f_env(u) = 8 / (π · u³)`` derived from the large-argument approximation ``J₁(u) ~ √(2/(πu)) · cos(u − 3π/4)``. **kwargs Mounting keyword arguments forwarded to :class:`SymmetricAntenna`. """ from scipy.special import j1 _C = 299_792_458.0 lam = _C / float(frequency) D = float(diameter) eta = float(eff) g_peak_lin = eta * (np.pi * D / lam) ** 2 g_peak_dbi = 10.0 * np.log10(g_peak_lin) # Adaptive sampling: ≥20 points per first-null width sin_null = min(1.0, 1.22 * lam / D) theta_null = np.arcsin(sin_null) n_pts = max(500, int(np.ceil((np.pi / 2.0) / (theta_null / 20.0)))) thetas = np.linspace(0.0, np.pi / 2.0, n_pts) u = np.pi * D * np.sin(thetas) / lam # [2 J₁(u)/u]² with limit 1 at u=0 u_safe = np.where(u < 1e-12, 1e-12, u) j1u = np.where(u < 1e-12, 1.0, 2.0 * j1(u_safe) / u_safe) f = j1u**2 if envelope: # Beyond the first zero of J₁ (u ≈ 3.8317), replace with asymptotic envelope first_zero = 3.8317 beyond = u > first_zero f_env = np.where(u > 0, 8.0 / (np.pi * np.maximum(u, 1e-30) ** 3), 1.0) f = np.where(beyond, f_env, f) g_lin = g_peak_lin * f gains = np.maximum(10.0 * np.log10(np.maximum(g_lin, 1e-20)), -60.0) # Extend to 180° with low gain (dish back-lobe region) angles_out = np.concatenate([np.degrees(thetas), [90.0, 180.0]]) gains_out = np.concatenate([gains, [-60.0, -60.0]]) # Remove duplicates at 90° _, unique_idx = np.unique(angles_out, return_index=True) angles_out = angles_out[unique_idx] gains_out = gains_out[unique_idx] return cls(angles_out, gains_out, **kwargs)
[docs] @classmethod def from_s465( cls, diameter: float, frequency: float, main_lobe_model: bool = False, gmax_dbi: float | None = None, **kwargs, ) -> "SymmetricAntenna": """ITU-R S.465 reference Earth station antenna pattern. Parameters ---------- diameter : float Reflector diameter (m). frequency : float Centre frequency (Hz). Valid range per the Recommendation: 2–31 GHz. main_lobe_model : bool, optional If *False* (default), the canonical ITU-R S.465-6 sidelobe envelope is used; the inner region (0° to φ_min) is completed with a flat plateau at *G*_max. If *True*, the smooth parabolic main-lobe extension from APEREC013V01 is used, producing a continuous pattern from boresight to 180°. gmax_dbi : float, optional Peak on-axis gain (dBi). When provided, overrides the value computed from *diameter* and *frequency* (which assumes η = 0.7). This matches the behaviour of tools such as ANSYS STK that accept *G*_max as a direct input. **kwargs Mounting keyword arguments forwarded to :class:`SymmetricAntenna`. References ---------- .. [1] ITU Radiocommunication Assembly, "Reference radiation pattern of earth station antennas in the fixed-satellite service for use in coordination and interference assessment in the frequency range from 2 to 31 GHz," Recommendation ITU-R S.465-6, Jan. 2010. (RRECS.4656201001IPDFE) .. [2] ITU-R BR Software, "Recommendation ITU-R S.465-5 reference Earth station antenna pattern for earth stations coordinated after 1993 in the frequency range from 2 to about 30 GHz," APEREC013V01, Apr. 2022. """ _C = 299_792_458.0 D = float(diameter) lam = _C / float(frequency) dl = D / lam # D/λ if gmax_dbi is not None: g_peak_dbi = float(gmax_dbi) else: eta = 0.7 # BR-software default efficiency g_peak_dbi = 10.0 * np.log10(eta * (np.pi * dl) ** 2) if main_lobe_model: # APEREC013V01 smooth main-lobe extension g1 = 32.0 if dl > 100 else -18.0 + 25.0 * np.log10(dl) phi_r = 1.0 if dl > 100 else 100.0 / dl # degrees phi_m = (20.0 / dl) * np.sqrt(max(g_peak_dbi - g1, 0.0)) phi_b = 10.0 ** (42.0 / 25.0) # ≈ 48.0° # Parabolic main lobe: 0 → φ_m phi_ml = np.linspace(0.0, phi_m, 200, endpoint=False) g_ml = g_peak_dbi - 2.5e-3 * (dl * phi_ml) ** 2 parts_a: list[npt.NDArray[np.floating]] = [phi_ml] parts_g: list[npt.NDArray[np.floating]] = [g_ml] # Transition plateau: φ_m → φ_r (may be zero-width) if phi_r > phi_m: parts_a.append(np.array([phi_m, phi_r])) parts_g.append(np.array([g1, g1])) # Sidelobe envelope: φ_r → φ_b phi_sl = np.linspace(phi_r, phi_b, 300, endpoint=False) parts_a.append(phi_sl) parts_g.append(32.0 - 25.0 * np.log10(np.maximum(phi_sl, 1e-10))) # Far sidelobe: φ_b → 180° parts_a.append(np.array([phi_b, 180.0])) parts_g.append(np.array([-10.0, -10.0])) else: # Canonical S.465-6 sidelobe envelope if dl >= 50: phi_min = max(1.0, 100.0 / dl) else: phi_min = max(2.0, 114.0 * (lam / D) ** 1.09) phi_b = 48.0 # degrees # Flat main lobe: 0 → φ_min parts_a = [np.array([0.0, phi_min])] parts_g = [np.array([g_peak_dbi, g_peak_dbi])] # Sidelobe envelope: φ_min → 48° phi_sl = np.linspace(phi_min, phi_b, 500, endpoint=False) parts_a.append(phi_sl) parts_g.append(32.0 - 25.0 * np.log10(phi_sl)) # Far sidelobe: 48° → 180° parts_a.append(np.array([phi_b, 180.0])) parts_g.append(np.array([-10.0, -10.0])) angles_out = np.concatenate(parts_a) gains_out = np.concatenate(parts_g) _, uniq = np.unique(angles_out, return_index=True) return cls(angles_out[uniq], gains_out[uniq], **kwargs)
def _pattern_gain( self, off_boresight_rad: npt.NDArray[np.floating], ) -> npt.NDArray[np.floating]: return np.interp( off_boresight_rad, self._angles_rad, self._gains_dbi, )