Skip to content

Link Engine

Snapshot link budget evaluation — the central API that computes EIRP, G/T, C/N0, Eb/N0, and margin.

DefaultLinkEngine

Snapshot link budget engine following spec Section 12.

Computes EIRP, G/T, C/N0, Eb/N0, and margin for a single time instant. Optionally computes throughput when a modem model is attached to LinkInputs.

Examples:

>>> engine = DefaultLinkEngine()
>>> result = engine.evaluate_snapshot(30.0, 0.0, range_m, link_inputs, cond)
>>> result.margin_db
12.34

evaluate_snapshot

evaluate_snapshot(elev_deg, az_deg, range_m, inputs, cond)

Evaluate a snapshot link budget.

Parameters:

Name Type Description Default
elev_deg float

Elevation angle from the receive terminal to the transmit terminal, in degrees.

required
az_deg float

Azimuth angle in degrees.

required
range_m float

Slant range between terminals in metres.

required
inputs LinkInputs

Complete link configuration (terminals, antennas, propagation, RF chain, and optional modem).

required
cond PropagationConditions

Environmental / atmospheric conditions.

required

Returns:

Type Description
LinkOutputs

Full link budget results including EIRP, G/T, C/N0, Eb/N0, margin, optional throughput, and an itemised breakdown dict.

Source code in src/opensatcom/link/engine.py
def evaluate_snapshot(
    self,
    elev_deg: float,
    az_deg: float,
    range_m: float,
    inputs: LinkInputs,
    cond: PropagationConditions,
) -> LinkOutputs:
    """Evaluate a snapshot link budget.

    Parameters
    ----------
    elev_deg : float
        Elevation angle from the receive terminal to the transmit
        terminal, in degrees.
    az_deg : float
        Azimuth angle in degrees.
    range_m : float
        Slant range between terminals in metres.
    inputs : LinkInputs
        Complete link configuration (terminals, antennas, propagation,
        RF chain, and optional modem).
    cond : PropagationConditions
        Environmental / atmospheric conditions.

    Returns
    -------
    LinkOutputs
        Full link budget results including EIRP, G/T, C/N0, Eb/N0,
        margin, optional throughput, and an itemised breakdown dict.
    """
    sc = inputs.scenario
    rf = inputs.rf_chain

    # 1. TX power
    tx_power_dbw = w_to_dbw(rf.tx_power_w)
    tx_losses_db = rf.tx_losses_db

    # 2. TX antenna gain
    theta = np.array([elev_deg])
    phi = np.array([az_deg])
    tx_gain_dbi = float(inputs.tx_antenna.gain_dbi(theta, phi, sc.freq_hz)[0])

    # 3. EIRP
    eirp_dbw = tx_power_dbw - tx_losses_db + tx_gain_dbi

    # 4. Path loss (total from propagation model)
    path_loss_db = inputs.propagation.total_path_loss_db(
        sc.freq_hz, elev_deg, range_m, cond
    )

    # 4a. Per-component losses for breakdown
    from opensatcom.propagation.composite import CompositePropagation

    component_losses: dict[str, float] = {}
    if isinstance(inputs.propagation, CompositePropagation):
        component_losses = inputs.propagation.per_component_losses_db(
            sc.freq_hz, elev_deg, range_m, cond
        )

    # 4b. Polarization mismatch loss
    tx_pol = sc.polarization
    rx_pol = getattr(sc, "rx_polarization", None) or tx_pol
    pol_loss_db = polarization_loss_db(tx_pol, rx_pol)

    # 5. RX antenna gain
    rx_gain_dbi = float(inputs.rx_antenna.gain_dbi(theta, phi, sc.freq_hz)[0])

    # 6. System noise temperature
    # Use terminal system_noise_temp_k if set, else rf.rx_noise_temp_k
    rx_terminal = inputs.rx_terminal
    if rx_terminal.system_noise_temp_k is not None:
        tsys_k = rx_terminal.system_noise_temp_k
    else:
        tsys_k = rf.rx_noise_temp_k

    # 7. G/T
    gt_dbk = rx_gain_dbi - lin_to_db10(tsys_k)

    # 8. C/N0 = EIRP - path_loss - pol_loss + G/T - k_boltzmann
    cn0_dbhz = eirp_dbw - path_loss_db - pol_loss_db + gt_dbk - BOLTZMANN_DBW_PER_K_HZ

    # 9. Eb/N0 = C/N0 - 10*log10(Rb)
    data_rate_bps = sc.bandwidth_hz
    if inputs.modem is not None:
        modem_result = inputs.modem.throughput_mbps(
            cn0_dbhz - lin_to_db10(sc.bandwidth_hz),
            sc.bandwidth_hz,
            0.0,
        )
        data_rate_bps = modem_result["spectral_eff_bps_per_hz"] * sc.bandwidth_hz

    ebn0_db = cn0_dbhz - lin_to_db10(data_rate_bps)

    # 10. Margin calculation
    if sc.required_metric == "ebn0_db":
        margin_db = ebn0_db - sc.required_value
    elif sc.required_metric == "cn0_dbhz":
        margin_db = cn0_dbhz - sc.required_value
    else:
        margin_db = ebn0_db - sc.required_value

    # 11. Throughput (if modem present)
    throughput_mbps: float | None = None
    if inputs.modem is not None:
        modem_result = inputs.modem.throughput_mbps(
            ebn0_db, sc.bandwidth_hz, 0.0
        )
        throughput_mbps = modem_result["throughput_mbps"]

    # 12. Itemized breakdown dict
    breakdown: dict[str, float] = {
        "tx_power_dbw": tx_power_dbw,
        "tx_losses_db": tx_losses_db,
        "tx_antenna_gain_dbi": tx_gain_dbi,
        "eirp_dbw": eirp_dbw,
        "fspl_db": component_losses.get("fspl_db", path_loss_db),
        "rain_attenuation_db": component_losses.get("rain_attenuation_db", 0.0),
        "gaseous_absorption_db": component_losses.get("gaseous_absorption_db", 0.0),
        "scintillation_db": component_losses.get("scintillation_db", 0.0),
        "total_path_loss_db": path_loss_db,
        "polarization_loss_db": pol_loss_db,
        "rx_antenna_gain_dbi": rx_gain_dbi,
        "rx_system_temp_k": tsys_k,
        "gt_dbk": gt_dbk,
        "cn0_dbhz": cn0_dbhz,
        "ebn0_db": ebn0_db,
        "margin_db": margin_db,
    }

    return LinkOutputs(
        eirp_dbw=eirp_dbw,
        gt_dbk=gt_dbk,
        path_loss_db=path_loss_db,
        cn0_dbhz=cn0_dbhz,
        ebn0_db=ebn0_db,
        margin_db=margin_db,
        throughput_mbps=throughput_mbps,
        breakdown=breakdown,
    )