Skip to content

Analysis

Interference detection strategies and link budget calculations.

Strategies

InterferenceStrategy

Bases: ABC

Abstract base class for interference detection strategies.

Each strategy implements a different method of determining whether a satellite causes interference and optionally how much.

The calculate method receives data that is common to every trajectory in a simulation run: the antenna pointing, telescope facility, and observation frequency. Model-specific configuration (transmitter characteristics, atmospheric profiles, antenna gain patterns, link-budget functions, etc.) should be provided via the strategy's __init__ and stored on self.

Functions

calculate abstractmethod

calculate(
    satellite_trajectory: SatelliteTrajectory,
    antenna_trajectory: AntennaTrajectory,
    facility: Facility,
    frequency: FrequencyRange,
) -> InterferenceResult | None

Analyze a single satellite pass for interference.

Parameters:

Name Type Description Default
satellite_trajectory SatelliteTrajectory

The satellite's path (times, az, alt, distance).

required
antenna_trajectory AntennaTrajectory

Where the antenna is pointing over time.

required
facility Facility

Telescope location and parameters.

required
frequency FrequencyRange

Observation frequency.

required

Returns:

Type Description
InterferenceResult | None

InterferenceResult if interference detected, None otherwise.

Source code in src/sopp/analysis/strategies.py
@abstractmethod
def calculate(
    self,
    satellite_trajectory: SatelliteTrajectory,
    antenna_trajectory: AntennaTrajectory,
    facility: Facility,
    frequency: FrequencyRange,
) -> InterferenceResult | None:
    """Analyze a single satellite pass for interference.

    Args:
        satellite_trajectory: The satellite's path (times, az, alt, distance).
        antenna_trajectory: Where the antenna is pointing over time.
        facility: Telescope location and parameters.
        frequency: Observation frequency.

    Returns:
        InterferenceResult if interference detected, None otherwise.
    """

GeometricStrategy

Bases: InterferenceStrategy

Binary in/out-of-beam detection.

Determines interference by checking whether the angular separation between the satellite and antenna boresight is less than the beam radius. This is the original SOPP behavior.

SimpleLinkBudgetStrategy

SimpleLinkBudgetStrategy(
    default_eirp_dbw: float | None = None,
)

Bases: InterferenceStrategy

Simple link budget calculation using peak gains and FSPL.

Calculates received power at the telescope using the Friis equation

P_rx(dBW) = EIRP(dBW) - FSPL(dB) + G_rx(dBi)

This is a "Tier 1" worst-case estimate assuming: - Peak satellite EIRP (from transmitter) - Peak telescope gain (boresight) - Free space path loss only (no atmospheric effects)

Unlike GeometricStrategy, this returns results for all trajectory points, not just those within the beam. The caller can filter based on power level or use in conjunction with geometric filtering.

Requires

facility.receiver.peak_gain_dbi must be set.

Parameters:

Name Type Description Default
default_eirp_dbw float | None

Default EIRP to use for satellites that have no transmitter configured. If not provided, satellites without a transmitter will be silently skipped (returns None, no interference detected).

None

Raises:

Type Description
ValueError

If facility.receiver.peak_gain_dbi is not set.

Source code in src/sopp/analysis/strategies.py
def __init__(self, default_eirp_dbw: float | None = None):
    self.default_eirp_dbw = default_eirp_dbw

NadirLinkBudgetStrategy

NadirLinkBudgetStrategy(
    default_eirp_dbw: float | None = None,
)

Bases: InterferenceStrategy

Full link budget with angle-dependent transmitter EIRP and receive gain.

This is a "Tier 2" calculation: P_rx(dBW) = EIRP(θ_tx) - FSPL(dB) + G_rx(θ_rx)

where θ_tx is the nadir angle at the satellite (assuming nadir-pointing antenna) and θ_rx is the receiver off-axis angle.

For Tier 1 transmitters (constant eirp_dbw), the EIRP term is constant and this produces the same result as PatternLinkBudgetStrategy. For Tier 2 transmitters (power_dbw + antenna_pattern), the EIRP varies with the nadir angle.

Requires

facility.receiver.antenna_pattern must be set.

Parameters:

Name Type Description Default
default_eirp_dbw float | None

Default EIRP to use for satellites that have no transmitter configured. If not provided, satellites without a transmitter will be silently skipped (returns None).

None

Raises:

Type Description
ValueError

If facility.receiver.antenna_pattern is not set.

Source code in src/sopp/analysis/strategies.py
def __init__(self, default_eirp_dbw: float | None = None):
    self.default_eirp_dbw = default_eirp_dbw

PatternLinkBudgetStrategy

PatternLinkBudgetStrategy(
    default_eirp_dbw: float | None = None,
)

Bases: InterferenceStrategy

Link budget using telescope antenna pattern for realistic receive gain.

This is a "Tier 1.5" calculation: - Uses the telescope's antenna pattern to look up G_rx at the actual off-axis angle to the satellite (realistic receive gain) - Uses peak satellite EIRP (worst case for transmit side)

The off-axis angle is calculated as the angular separation between where the antenna is pointing and where the satellite is in the sky.

P_rx(dBW) = EIRP(dBW) - FSPL(dB) + G_rx(off_axis_angle)

This gives a more realistic estimate than SimpleLinkBudgetStrategy because it accounts for how much the satellite is in the telescope's sidelobes vs main beam.

Requires

facility.receiver.antenna_pattern must be set.

Parameters:

Name Type Description Default
default_eirp_dbw float | None

Default EIRP to use for satellites that have no transmitter configured. If not provided, satellites without a transmitter will be silently skipped (returns None, no interference detected).

None

Raises:

Type Description
ValueError

If facility.receiver.antenna_pattern is not set.

Source code in src/sopp/analysis/strategies.py
def __init__(self, default_eirp_dbw: float | None = None):
    self.default_eirp_dbw = default_eirp_dbw
free_space_path_loss_db(
    distance_m: float | ndarray, frequency_hz: float
) -> float | ndarray

Calculate free space path loss in dB.

Uses the standard FSPL formula

FSPL(dB) = 20log10(d) + 20log10(f) - 147.55

where

d = distance in meters f = frequency in Hz 147.55 = 20log10(c/(4pi)) with c = speed of light

Parameters:

Name Type Description Default
distance_m float | ndarray

Distance between transmitter and receiver in meters. Can be a scalar or numpy array.

required
frequency_hz float

Frequency in Hz.

required

Returns:

Type Description
float | ndarray

Path loss in dB (positive value). Same shape as distance_m.

Source code in src/sopp/analysis/link_budget.py
def free_space_path_loss_db(
    distance_m: float | np.ndarray,
    frequency_hz: float,
) -> float | np.ndarray:
    """Calculate free space path loss in dB.

    Uses the standard FSPL formula:
        FSPL(dB) = 20*log10(d) + 20*log10(f) - 147.55

    where:
        d = distance in meters
        f = frequency in Hz
        147.55 = 20*log10(c/(4*pi)) with c = speed of light

    Args:
        distance_m: Distance between transmitter and receiver in meters.
            Can be a scalar or numpy array.
        frequency_hz: Frequency in Hz.

    Returns:
        Path loss in dB (positive value). Same shape as distance_m.
    """
    return 20.0 * np.log10(distance_m) + 20.0 * np.log10(frequency_hz) - 147.55
received_power_dbw(
    eirp_dbw: float,
    distance_m: float | ndarray,
    frequency_hz: float,
    gain_rx_dbi: float,
) -> float | ndarray

Calculate received power using the Friis equation.

This is a worst-case "Tier 1" estimate assuming: - Main beam alignment (peak gains at both ends) - No atmospheric losses - No polarization mismatch

Parameters:

Name Type Description Default
eirp_dbw float

Effective Isotropic Radiated Power in dBW.

required
distance_m float | ndarray

Distance between transmitter and receiver in meters. Can be a scalar or numpy array.

required
frequency_hz float

Frequency in Hz.

required
gain_rx_dbi float

Receiver antenna gain in dBi.

required

Returns:

Type Description
float | ndarray

Received power in dBW. Same shape as distance_m.

Source code in src/sopp/analysis/link_budget.py
def received_power_dbw(
    eirp_dbw: float,
    distance_m: float | np.ndarray,
    frequency_hz: float,
    gain_rx_dbi: float,
) -> float | np.ndarray:
    """Calculate received power using the Friis equation.

    Uses the link budget formula:
        P_rx(dBW) = EIRP(dBW) - FSPL(dB) + G_rx(dBi)

    This is a worst-case "Tier 1" estimate assuming:
    - Main beam alignment (peak gains at both ends)
    - No atmospheric losses
    - No polarization mismatch

    Args:
        eirp_dbw: Effective Isotropic Radiated Power in dBW.
        distance_m: Distance between transmitter and receiver in meters.
            Can be a scalar or numpy array.
        frequency_hz: Frequency in Hz.
        gain_rx_dbi: Receiver antenna gain in dBi.

    Returns:
        Received power in dBW. Same shape as distance_m.
    """
    fspl = free_space_path_loss_db(distance_m, frequency_hz)
    return eirp_dbw - fspl + gain_rx_dbi

Geometry

calculate_angular_separation

calculate_angular_separation(
    az1: NDArray[float64],
    alt1: NDArray[float64],
    az2: NDArray[float64],
    alt2: NDArray[float64],
) -> NDArray[float64]

Calculates the angular separation between two sets of coordinates.

Parameters:

Name Type Description Default
az1 NDArray[float64]

Azimuth of the first object in degrees.

required
alt1 NDArray[float64]

Altitude of the first object in degrees.

required
az2 NDArray[float64]

Azimuth of the second object in degrees.

required
alt2 NDArray[float64]

Altitude of the second object in degrees.

required

Returns:

Type Description
NDArray[float64]

The angular separation in degrees.

Source code in src/sopp/analysis/geometry.py
def calculate_angular_separation(
    az1: npt.NDArray[np.float64],
    alt1: npt.NDArray[np.float64],
    az2: npt.NDArray[np.float64],
    alt2: npt.NDArray[np.float64],
) -> npt.NDArray[np.float64]:
    """
    Calculates the angular separation between two sets of coordinates.

    Args:
        az1: Azimuth of the first object in degrees.
        alt1: Altitude of the first object in degrees.
        az2: Azimuth of the second object in degrees.
        alt2: Altitude of the second object in degrees.

    Returns:
        The angular separation in degrees.
    """
    sep_sq = calculate_angular_separation_sq(az1, alt1, az2, alt2)
    return np.sqrt(sep_sq)

calculate_nadir_angle

calculate_nadir_angle(
    elevation_deg: NDArray[float64],
    distance_km: NDArray[float64],
) -> NDArray[float64]

Calculate the nadir angle at the satellite for each trajectory point.

The nadir angle is the angle at the satellite between the nadir direction (toward Earth center) and the direction to the ground observer. For nadir-pointing satellite antennas, this equals the transmitter off-axis angle.

Derived from the law of sines on the Earth-center / satellite / observer triangle.

Parameters:

Name Type Description Default
elevation_deg NDArray[float64]

Elevation of the satellite as seen from the ground, in degrees.

required
distance_km NDArray[float64]

Slant range from observer to satellite in km.

required

Returns:

Type Description
NDArray[float64]

Nadir angle in degrees.

Source code in src/sopp/analysis/geometry.py
def calculate_nadir_angle(
    elevation_deg: npt.NDArray[np.float64],
    distance_km: npt.NDArray[np.float64],
) -> npt.NDArray[np.float64]:
    """Calculate the nadir angle at the satellite for each trajectory point.

    The nadir angle is the angle at the satellite between the nadir direction
    (toward Earth center) and the direction to the ground observer. For
    nadir-pointing satellite antennas, this equals the transmitter off-axis
    angle.

    Derived from the law of sines on the Earth-center / satellite / observer
    triangle.

    Args:
        elevation_deg: Elevation of the satellite as seen from the ground, in degrees.
        distance_km: Slant range from observer to satellite in km.

    Returns:
        Nadir angle in degrees.
    """
    el_rad = np.radians(elevation_deg)
    R = EARTH_RADIUS_KM

    #          S (satellite)
    #         /B\
    #        /   \
    #  r_sat/     \ d (slant range)
    #      /       \
    #     /         \
    #    /           \
    #   O----- R -----T (telescope, on surface)
    # (Earth center)
    #
    # Why y (angle at T) = 90 + el:
    #
    #         S       el is measured from the horizon up,
    #        / el     but T->O goes 90 deg below the horizon,
    #       T------   so y = el + 90.
    #       | 90
    #       |
    #       O
    y = np.pi / 2 + el_rad  # angle at T = 90 + el

    # Cosine rule: r_sat^2 = R^2 + d^2 - 2*R*d*cos(y)
    r_sat = np.sqrt(R**2 + distance_km**2 - 2.0 * R * distance_km * np.cos(y))

    # Law of sines: sin(y)/r_sat = sin(B)/R, so sin(B) = R*sin(y)/r_sat
    sin_nadir = np.clip(R * np.sin(y) / r_sat, -1.0, 1.0)

    return np.degrees(np.arcsin(sin_nadir))