#
# ISC License
#
# Copyright (c) 2026, Department of Engineering Cybernetics, NTNU
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#
r"""
**Test Description**
This unit test validates the ``linkBudget`` module which computes end-to-end
radio link budget between two antennas. The test writes directly to
``AntennaLogMsgPayload`` messages to isolate the linkBudget module from
simpleAntenna dependencies.
**Test Coverage**
- Free Space Path Loss (FSPL) calculations
- Carrier-to-Noise Ratio (CNR) calculations
- Pointing loss calculations
- Frequency offset loss calculations
- Atmospheric attenuation (space-to-ground links)
- Space-to-space and space-to-ground link configurations
- Antenna state handling (Tx/Rx/RxTx combinations)
- Edge cases (no bandwidth overlap, grazing angles, extreme distances, etc.)
- Robustness tests (invalid inputs, boundary conditions)
"""
import numpy as np
import pytest
from Basilisk import __path__
from Basilisk.utilities import SimulationBaseClass
from Basilisk.utilities import unitTestSupport
from Basilisk.architecture import messaging
from Basilisk.utilities import macros, orbitalMotion
from Basilisk.simulation import linkBudget
from Basilisk.utilities import RigidBodyKinematics as rbk
bskPath = __path__[0]
# =============================================================================
# Physical Constants
# =============================================================================
SPEED_LIGHT = 299792458.0 # [m/s]
K_BOLTZMANN = 1.38064852e-23 # [J/K]
REQ_EARTH = 6378.1366e3 # [m] Earth equatorial radius (matches Basilisk astroConstants.h)
# Test tolerances
ACCURACY = 1e-6
ACCURACY_DB = 0.1 # [dB] tolerance for dB comparisons
ACCURACY_CNR_REL = 0.05 # 5% relative tolerance for CNR
# Antenna state enums (matching AntennaDefinitions.h)
ANTENNA_OFF = 0
ANTENNA_RX = 1
ANTENNA_TX = 2
ANTENNA_RXTX = 3
# Environment enums
ENV_SPACE = 0
ENV_EARTH = 1
# CNR value when link is invalid (based on code review - linkValid flag sets CNR to 0.0)
CNR_INVALID = 0.0
# =============================================================================
# Helper Functions - Analytical Calculations
# =============================================================================
[docs]
def compute_spacecraft_position_from_central_angle(Re_m, alt_m, central_angle_rad):
"""
Returns ECI-like position for spacecraft given a ground station at [Re,0,0],
with spacecraft on a circle of radius (Re+alt) rotated by central_angle about +Z.
Ground is fixed at +X axis.
"""
r = Re_m + alt_m
return [r * np.cos(central_angle_rad), r * np.sin(central_angle_rad), 0.0]
[docs]
def compute_expected_overlap(f1, B1, f2, B2):
"""
Computes overlap bandwidth and overlap center frequency.
Overlap region is intersection of [f - B/2, f + B/2] for each.
"""
lo1 = f1 - 0.5 * B1
hi1 = f1 + 0.5 * B1
lo2 = f2 - 0.5 * B2
hi2 = f2 + 0.5 * B2
lo = max(lo1, lo2)
hi = min(hi1, hi2)
B_ov = hi - lo
f_center = 0.5 * (hi + lo) if B_ov > 0 else 0.0
return B_ov, f_center
[docs]
def compute_fspl(distance_m, frequency_Hz):
"""
Compute Free Space Path Loss in dB.
FSPL = 20*log10(4*pi*d/lambda) = 20*log10(4*pi*d*f/c)
Args:
distance_m: Distance between antennas [m]
frequency_Hz: Operating frequency [Hz]
Returns:
FSPL in [dB]
"""
wavelength = SPEED_LIGHT / frequency_Hz
fspl = 20.0 * np.log10((4.0 * np.pi * distance_m) / wavelength)
return fspl
[docs]
def compute_pointing_loss(theta_az, theta_el, HPBW_az, HPBW_el):
"""
Compute pointing loss for Gaussian beam pattern.
L_point = 10*log10(e)*4*ln(2) * (theta_az^2/HPBW_az^2 + theta_el^2/HPBW_el^2)
Args:
theta_az: Azimuth pointing error [rad]
theta_el: Elevation pointing error [rad]
HPBW_az: Half-power beamwidth in azimuth [rad]
HPBW_el: Half-power beamwidth in elevation [rad]
Returns:
Pointing loss [dB]
"""
coeff = 10.0 * np.log10(np.exp(1.0)) * 4.0 * np.log(2.0) # ≈ 12.04
L_point = coeff * ((theta_az**2) / (HPBW_az**2) + (theta_el**2) / (HPBW_el**2))
return L_point
[docs]
def compute_cnr(P_eirp_dB, G_rx_dB, L_fspl, L_atm, L_point, L_freq, P_N_watts):
"""
Compute Carrier-to-Noise Ratio.
P_Rx [dBW] = P_eirp [dBW] + G_rx [dB] - L_fspl [dB] - L_atm [dB] - L_point [dB] - L_freq [dB]
CNR = P_Rx / P_N (linear)
Args:
P_eirp_dB: EIRP of transmitter [dBW]
G_rx_dB: Receiver antenna gain [dB]
L_fspl: Free space path loss [dB]
L_atm: Atmospheric loss [dB]
L_point: Pointing loss [dB]
L_freq: Frequency offset loss [dB]
P_N_watts: Noise power [W] (linear)
Returns:
CNR (linear, dimensionless)
"""
P_rx_dBW = P_eirp_dB + G_rx_dB - L_fspl - L_atm - L_point - L_freq
P_N_dBW = 10.0 * np.log10(P_N_watts)
cnr_dB = P_rx_dBW - P_N_dBW
cnr_linear = 10.0 ** (cnr_dB / 10.0)
return cnr_linear
[docs]
def compute_antenna_derived_params(directivity_dB, k, P_Tx, eta_r, T_E, T_Ambient, T_sky, bandwidth):
"""
Compute derived antenna parameters for test setup.
Args:
directivity_dB: Antenna directivity [dB]
k: HPBW ratio (az/el)
P_Tx: Transmit power [W]
eta_r: Radiation efficiency [-]
T_E: Equivalent noise temperature [K]
T_Ambient: Ambient temperature [K]
T_sky: Sky temperature [K]
bandwidth: Bandwidth [Hz]
Returns:
dict with HPBW_az, HPBW_el, P_eirp_dB, G_rx_dB, P_N, T_S
"""
dir_lin = 10.0 ** (directivity_dB / 10.0)
HPBW_el = np.sqrt((np.log(2) * 16) / (dir_lin * k))
HPBW_az = k * HPBW_el
P_eirp_dB = 10.0 * np.log10(P_Tx) + 10.0 * np.log10(eta_r) + directivity_dB
G_rx_dB = directivity_dB + 10.0 * np.log10(eta_r)
T_ant = (1.0 - eta_r) * T_Ambient + eta_r * T_sky
T_S = T_E + T_ant
P_N = K_BOLTZMANN * T_S * bandwidth
return {
'HPBW_az': HPBW_az,
'HPBW_el': HPBW_el,
'P_eirp_dB': P_eirp_dB,
'G_rx_dB': G_rx_dB,
'P_N': P_N,
'T_S': T_S
}
[docs]
def compute_pointing_sigma(from_pos, to_pos):
"""
Compute MRP (sigma) to point antenna boresight (+Z) from from_pos toward to_pos.
The antenna frame convention is that +Z is the boresight direction.
This function computes the MRP representing the rotation needed to
align the antenna +Z axis with the direction from from_pos to to_pos.
Args:
from_pos: Antenna position [m] (list or array of 3 elements)
to_pos: Target position [m] (list or array of 3 elements)
Returns:
MRP sigma as list [s1, s2, s3]
"""
from_pos = np.array(from_pos, dtype=float)
to_pos = np.array(to_pos, dtype=float)
# Desired boresight direction (unit vector toward target)
direction = to_pos - from_pos
dist = np.linalg.norm(direction)
if dist < 1e-6:
# Co-located antennas, no rotation needed (or undefined)
return [0.0, 0.0, 0.0]
d_hat = direction / dist # Unit vector toward target
# Antenna boresight is +Z in antenna frame
z_hat = np.array([0.0, 0.0, 1.0])
# Find rotation from z_hat to d_hat
dot = np.dot(z_hat, d_hat)
if dot > 0.99999:
# Already aligned with +Z, no rotation needed
return [0.0, 0.0, 0.0]
elif dot < -0.99999:
# Opposite direction (-Z), 180° rotation about X-axis
# MRP for 180° about X: sigma = tan(pi/4) * [1,0,0] = [1,0,0]
return [1.0, 0.0, 0.0]
else:
# General case: rotation axis = z_hat × d_hat, angle = acos(dot)
axis = np.cross(z_hat, d_hat)
axis = axis / np.linalg.norm(axis)
angle = np.arccos(np.clip(dot, -1.0, 1.0))
# MRP: sigma = tan(angle/4) * axis
sigma = np.tan(angle / 4.0) * axis
return sigma.tolist()
[docs]
def apply_pointing_error(base_sigma, error_az_rad, error_el_rad):
"""
Apply pointing error to a base orientation.
Args:
base_sigma: Base MRP orientation [s1, s2, s3]
error_az_rad: Azimuth error [rad] (rotation about Y in antenna frame)
error_el_rad: Elevation error [rad] (rotation about X in antenna frame)
Returns:
New MRP with pointing error applied
"""
# Convert base sigma to DCM
dcm_base = rbk.MRP2C(base_sigma)
# Create error rotation (small angle approximation or full rotation)
# Error in antenna frame: first rotate about X (elevation), then Y (azimuth)
dcm_err_el = rbk.euler1(error_el_rad) # Rotation about X
dcm_err_az = rbk.euler2(error_az_rad) # Rotation about Y
dcm_error = dcm_err_az @ dcm_err_el # Combined error rotation
# Apply error: new DCM = base DCM * error DCM
dcm_new = dcm_base @ dcm_error
# Convert back to MRP
sigma_new = rbk.C2MRP(dcm_new)
return sigma_new.tolist()
[docs]
def compute_bandwidth_overlap(f1, B1, f2, B2):
"""
Compute the overlapping bandwidth between two antennas.
Args:
f1: Center frequency of antenna 1 [Hz]
B1: Bandwidth of antenna 1 [Hz]
f2: Center frequency of antenna 2 [Hz]
B2: Bandwidth of antenna 2 [Hz]
Returns:
Overlapping bandwidth [Hz] (can be negative if no overlap)
"""
f_low = max(f1 - B1/2, f2 - B2/2)
f_high = min(f1 + B1/2, f2 + B2/2)
return f_high - f_low
[docs]
def compute_frequency_loss(B_overlap, B_min):
"""
Compute frequency offset loss.
Args:
B_overlap: Overlapping bandwidth [Hz]
B_min: Smaller of the two bandwidths [Hz]
Returns:
Frequency loss [dB] (positive or zero)
"""
if B_overlap <= 0:
return float('inf')
elif B_overlap >= B_min:
return 0.0
else:
return 10.0 * np.log10(B_min / B_overlap)
# =============================================================================
# Helper Functions - Message Creation
# =============================================================================
[docs]
def create_antenna_msg_payload(
name="Antenna",
environment=ENV_SPACE,
state=ANTENNA_OFF,
frequency=2.2e9,
bandwidth=5e6,
directivity_dB=20.0,
k=1.0,
P_Tx=100.0,
eta_r=0.6,
T_E=50.0,
T_Ambient=150.0,
T_sky=5.0,
position=[0.0, 0.0, 0.0],
sigma=None,
velocity=[0, 0, 0],
r_AP_N=[0, 0, 0],
nHat_LP_N=[0, 0, 1],
target_position=None,
pointing_error_az_deg=0.0,
pointing_error_el_deg=0.0,
):
"""
Create an AntennaLogMsgPayload with computed derived values.
This allows tests to specify high-level parameters and automatically
computes HPBW, P_eirp, P_N, etc.
Args:
name: Antenna name string
environment: ENV_SPACE (0) or ENV_EARTH (1)
state: ANTENNA_OFF/RX/TX/RXTX
frequency: Operating frequency [Hz]
bandwidth: Bandwidth [Hz]
directivity_dB: Antenna directivity [dB]
k: HPBW ratio (az/el), 1.0 for symmetric beam
P_Tx: Transmit power [W]
eta_r: Radiation efficiency [0-1]
T_E: Equivalent noise temperature [K]
T_Ambient: Ambient temperature [K]
T_sky: Sky noise temperature [K]
position: Antenna position in inertial frame [m]
sigma: MRP orientation (overrides target_position if both given)
velocity: Antenna velocity [m/s]
r_AP_N: Position relative to planet (for ground stations) [m]
nHat_LP_N: Surface normal vector (for ground stations)
target_position: If provided, compute sigma to point toward this position
pointing_error_az_deg: Azimuth pointing error [degrees]
pointing_error_el_deg: Elevation pointing error [degrees]
Returns:
(payload, params) tuple where payload is AntennaLogMsgPayload
and params is dict of derived parameters
"""
# Compute derived parameters
params = compute_antenna_derived_params(
directivity_dB, k, P_Tx, eta_r, T_E, T_Ambient, T_sky, bandwidth
)
# Determine antenna orientation
if sigma is not None:
# Use explicitly provided sigma
final_sigma = list(sigma)
elif target_position is not None:
# Compute sigma to point at target
final_sigma = compute_pointing_sigma(position, target_position)
else:
# Default: no rotation (boresight along +Z)
final_sigma = [0.0, 0.0, 0.0]
# Apply pointing error if specified
if pointing_error_az_deg != 0.0 or pointing_error_el_deg != 0.0:
final_sigma = apply_pointing_error(
final_sigma,
np.deg2rad(pointing_error_az_deg),
np.deg2rad(pointing_error_el_deg)
)
# Create payload
payload = messaging.AntennaLogMsgPayload()
payload.antennaName = name
payload.environment = environment
payload.antennaState = state
payload.frequency = frequency
payload.B = bandwidth
payload.HPBW_az = params['HPBW_az']
payload.HPBW_el = params['HPBW_el']
payload.P_Tx = P_Tx
payload.P_Rx = 0.0
payload.DdB = directivity_dB
payload.eta_r = eta_r
payload.P_N = params['P_N']
payload.P_eirp_dB = params['P_eirp_dB']
payload.G_TN = directivity_dB + 10.0 * np.log10(eta_r) - 10.0 * np.log10(params['T_S'])
payload.T_Ambient = T_Ambient
payload.r_AN_N = list(position) if position is not None else [0.0, 0.0, 0.0]
payload.sigma_AN = list(final_sigma)
payload.v_AN_N = list(velocity)
payload.r_AP_N = r_AP_N
payload.r_AP_P = r_AP_N # Simplified: same as r_AP_N for testing
payload.nHat_LP_N = nHat_LP_N
return payload, params
[docs]
def setup_link_budget_sim(ant1_payload, ant2_payload, enable_atm=False):
"""
Set up a simulation with linkBudget module and two antenna messages.
Args:
ant1_payload: AntennaLogMsgPayload for antenna 1
ant2_payload: AntennaLogMsgPayload for antenna 2
enable_atm: Enable atmospheric attenuation calculation
Returns:
(unitTestSim, linkBudgetModule, linkDataLog)
"""
unitTaskName = "unitTask"
unitProcessName = "TestProcess"
unitTestSim = SimulationBaseClass.SimBaseClass()
testProcessRate = macros.sec2nano(0.5)
testProc = unitTestSim.CreateNewProcess(unitProcessName)
testProc.addTask(unitTestSim.CreateNewTask(unitTaskName, testProcessRate))
# Write antenna messages
ant1Publisher = messaging.AntennaLogMsg()
ant2Publisher = messaging.AntennaLogMsg()
ant1Msg = ant1Publisher.write(ant1_payload)
ant2Msg = ant2Publisher.write(ant2_payload)
# Create link budget module
linkBudgetModule = linkBudget.LinkBudget()
linkBudgetModule.ModelTag = "linkBudget"
# Enable atmospheric attenuation if requested
if enable_atm:
if hasattr(linkBudgetModule, "atmosAtt"):
linkBudgetModule.atmosAtt = True
elif hasattr(linkBudgetModule, "setAtmosAtt"):
linkBudgetModule.setAtmosAtt(True)
unitTestSim.AddModelToTask(unitTaskName, linkBudgetModule)
# Subscribe to antenna messages
linkBudgetModule.antennaInPayload_1.subscribeTo(ant1Msg)
linkBudgetModule.antennaInPayload_2.subscribeTo(ant2Msg)
# Output recorder
linkDataLog = linkBudgetModule.linkBudgetOutPayload.recorder()
unitTestSim.AddModelToTask(unitTaskName, linkDataLog)
unitTestSim._ant1Publisher = ant1Publisher
unitTestSim._ant2Publisher = ant2Publisher
return unitTestSim, linkBudgetModule, linkDataLog
[docs]
def run_simulation(unitTestSim, duration_sec=1.0):
"""Initialize and run simulation."""
unitTestSim.InitializeSimulation()
unitTestSim.ConfigureStopTime(macros.sec2nano(duration_sec))
unitTestSim.ExecuteSimulation()
# =============================================================================
# Parameterized Tests
# =============================================================================
[docs]
@pytest.mark.parametrize(
"ant1_state, ant2_state, link_type, distance_km, freq_offset_Hz, pointing_error_deg",
[
# Basic space-to-space links
(ANTENNA_TX, ANTENNA_RX, "space_space", 1000, 0, 0),
(ANTENNA_RX, ANTENNA_TX, "space_space", 1000, 0, 0),
(ANTENNA_RXTX, ANTENNA_RXTX, "space_space", 1000, 0, 0),
# Different distances
(ANTENNA_TX, ANTENNA_RX, "space_space", 100, 0, 0),
(ANTENNA_TX, ANTENNA_RX, "space_space", 10000, 0, 0),
(ANTENNA_TX, ANTENNA_RX, "space_space", 36000, 0, 0), # ~GEO altitude
# With pointing errors (antenna 1 only)
(ANTENNA_TX, ANTENNA_RX, "space_space", 1000, 0, 5),
(ANTENNA_TX, ANTENNA_RX, "space_space", 1000, 0, 10),
# Space-to-ground link
(ANTENNA_TX, ANTENNA_RX, "space_ground", 400, 0, 0), # LEO pass
# Both antennas OFF - CNR should be 0
(ANTENNA_OFF, ANTENNA_OFF, "space_space", 1000, 0, 0),
# Mismatched states - TX to TX (no receiver) - both CNR should be 0
(ANTENNA_TX, ANTENNA_TX, "space_space", 1000, 0, 0),
# RX to RX (no transmitter) - CNR should be 0
(ANTENNA_RX, ANTENNA_RX, "space_space", 1000, 0, 0),
]
)
def test_linkBudget(ant1_state, ant2_state, link_type, distance_km, freq_offset_Hz, pointing_error_deg):
r"""
**Validation Test Description**
This unit test validates the link budget calculations between two antennas
under various configurations including different antenna states, distances,
pointing errors, and link types (space-to-space, space-to-ground).
**Test Parameters**
Args:
ant1_state: State of antenna 1 (OFF/RX/TX/RXTX)
ant2_state: State of antenna 2 (OFF/RX/TX/RXTX)
link_type: Type of link ("space_space" or "space_ground")
distance_km: Distance between antennas [km]
freq_offset_Hz: Frequency offset between antennas [Hz]
pointing_error_deg: Pointing error for antenna 1 [degrees]
"""
[testResults, testMessage] = linkBudgetTestFunction(
ant1_state, ant2_state, link_type, distance_km,
freq_offset_Hz, pointing_error_deg
)
assert testResults < 1, testMessage
[docs]
def linkBudgetTestFunction(ant1_state, ant2_state, link_type, distance_km,
freq_offset_Hz, pointing_error_deg):
"""Main test function for parameterized link budget tests."""
testFailCount = 0
testMessages = []
# Common parameters
frequency = 2.2e9 # Hz (S-band)
bandwidth = 5e6 # Hz
directivity_dB = 20.0 # dB
k = 1.0 # Symmetric beam
P_Tx = 100.0 # W
eta_r = 0.6 # Radiation efficiency
T_E = 50.0 # K
T_Ambient = 150.0 # K
T_sky = 5.0 # K (deep space)
distance_m = distance_km * 1000.0
# Compute expected derived parameters
params = compute_antenna_derived_params(
directivity_dB, k, P_Tx, eta_r, T_E, T_Ambient, T_sky, bandwidth
)
# Define positions
ant1_position = [0.0, 0.0, 0.0]
ant2_position = [0.0, 0.0, distance_m]
# === Create Antenna 1 Payload ===
# Antenna 1 points toward antenna 2, with optional pointing error
ant1_payload, _ = create_antenna_msg_payload(
name="Antenna1",
environment=ENV_SPACE,
state=ant1_state,
frequency=frequency,
bandwidth=bandwidth,
directivity_dB=directivity_dB,
k=k,
P_Tx=P_Tx,
eta_r=eta_r,
T_E=T_E,
T_Ambient=T_Ambient,
T_sky=T_sky,
position=ant1_position,
target_position=ant2_position,
pointing_error_az_deg=pointing_error_deg,
pointing_error_el_deg=0.0,
)
# === Create Antenna 2 Payload ===
env2 = ENV_EARTH if (link_type == "space_ground") else ENV_SPACE
# For ground station, set surface properties
if link_type == "space_ground":
r_AP_N = [REQ_EARTH, 0, 0]
nHat = [1, 0, 0]
T_Ambient_2 = 288.0 # Ground temperature
T_sky_2 = 200.0 # Ground sky temperature
else:
r_AP_N = [0, 0, 0]
nHat = [0, 0, 1]
T_Ambient_2 = T_Ambient
T_sky_2 = T_sky
# Antenna 2 points toward antenna 1 (perfect pointing)
ant2_payload, _ = create_antenna_msg_payload(
name="Antenna2",
environment=env2,
state=ant2_state,
frequency=frequency + freq_offset_Hz,
bandwidth=bandwidth,
directivity_dB=directivity_dB,
k=k,
P_Tx=P_Tx,
eta_r=eta_r,
T_E=T_E,
T_Ambient=T_Ambient_2,
T_sky=T_sky_2,
position=ant2_position,
target_position=ant1_position,
r_AP_N=r_AP_N,
nHat_LP_N=nHat,
)
# === Run Simulation ===
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
# === Extract Results ===
dist_sim = float(linkDataLog.distance[-1])
cnr1_sim = float(linkDataLog.CNR1[-1])
cnr2_sim = float(linkDataLog.CNR2[-1])
fspl_sim = linkBudgetModule.getL_FSPL()
L_point_sim = linkBudgetModule.getL_point()
# === Validate Results ===
# 1. Check distance
if abs(dist_sim - distance_m) > ACCURACY:
testFailCount += 1
testMessages.append(
f"Distance error: got {dist_sim/1e3:.3f} km, expected {distance_km:.3f} km"
)
# 2. Check FSPL
fspl_truth = compute_fspl(distance_m, frequency)
if abs(fspl_sim - fspl_truth) > ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"FSPL error: got {fspl_sim:.2f} dB, expected {fspl_truth:.2f} dB"
)
# 3. Check CNR based on antenna states
ant1_is_rx = ant1_state in [ANTENNA_RX, ANTENNA_RXTX]
ant2_is_rx = ant2_state in [ANTENNA_RX, ANTENNA_RXTX]
ant1_is_tx = ant1_state in [ANTENNA_TX, ANTENNA_RXTX]
ant2_is_tx = ant2_state in [ANTENNA_TX, ANTENNA_RXTX]
# CNR1: Antenna 1 receiving from Antenna 2
if ant1_is_rx and ant2_is_tx:
if cnr1_sim <= 0:
testFailCount += 1
testMessages.append(f"CNR1 should be positive when ant1=RX and ant2=TX, got {cnr1_sim}")
# When ant1 is TX only or OFF, CNR1 should be 0
elif not ant1_is_rx:
if cnr1_sim != CNR_INVALID:
testFailCount += 1
testMessages.append(f"CNR1 should be {CNR_INVALID} when ant1 is not RX, got {cnr1_sim}")
# CNR2: Antenna 2 receiving from Antenna 1
if ant2_is_rx and ant1_is_tx:
if cnr2_sim <= 0:
testFailCount += 1
testMessages.append(f"CNR2 should be positive when ant2=RX and ant1=TX, got {cnr2_sim}")
# When ant2 is TX only or OFF, CNR2 should be 0
elif not ant2_is_rx:
if cnr2_sim != CNR_INVALID:
testFailCount += 1
testMessages.append(f"CNR2 should be {CNR_INVALID} when ant2 is not RX, got {cnr2_sim}")
# 4. Check pointing loss (only for space-space with pointing error)
if pointing_error_deg > 0 and link_type == "space_space":
# Antenna 1 has the pointing error, antenna 2 is perfect
pointing_error_rad = np.deg2rad(pointing_error_deg)
L_point_truth = compute_pointing_loss(
pointing_error_rad, 0, params['HPBW_az'], params['HPBW_el']
)
# Allow for some tolerance due to different calculation methods
if abs(L_point_sim - L_point_truth) > 2 * ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"Pointing loss error: got {L_point_sim:.2f} dB, expected {L_point_truth:.2f} dB"
)
# Report results
if testFailCount == 0:
print(f"PASSED: linkBudget (d={distance_km}km, type={link_type}, "
f"states={ant1_state}/{ant2_state})")
else:
print(f"FAILED: {testFailCount} errors")
for msg in testMessages:
print(f" {msg}")
return [testFailCount, "\n".join(testMessages)]
# =============================================================================
# Focused Validation Tests
# =============================================================================
[docs]
def test_linkBudget_fspl_analytical():
"""
Validate FSPL calculation against analytical formula.
Tests multiple distance/frequency combinations to ensure
FSPL = 20*log10(4*pi*d/lambda) is computed correctly.
"""
testFailCount = 0
testMessages = []
# Test cases: (distance_m, frequency_Hz, description)
test_cases = [
(1000e3, 2.2e9, "1000 km at S-band"),
(400e3, 2.2e9, "400 km LEO at S-band"),
(36000e3, 2.2e9, "GEO at S-band"),
(1000e3, 8.0e9, "1000 km at X-band"),
(1000e3, 400e6, "1000 km at UHF"),
(100e3, 2.2e9, "100 km at S-band"),
(1000e3, 26e9, "1000 km at Ka-band"),
]
for distance_m, frequency_Hz, desc in test_cases:
# Analytical FSPL
fspl_truth = compute_fspl(distance_m, frequency_Hz)
# Define positions
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
# Create antenna payloads with proper pointing
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=frequency_Hz,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=frequency_Hz,
position=pos2, target_position=pos1
)
# Run simulation
unitTestSim, linkBudgetModule, _ = setup_link_budget_sim(ant1_payload, ant2_payload)
run_simulation(unitTestSim)
fspl_sim = linkBudgetModule.getL_FSPL()
if abs(fspl_sim - fspl_truth) > ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"FSPL mismatch ({desc}): got {fspl_sim:.2f} dB, expected {fspl_truth:.2f} dB"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_fspl_analytical")
else:
print(f"FAILED: {testFailCount} errors")
for msg in testMessages:
print(f" {msg}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_cnr_calculation():
"""
Validate CNR calculation against analytical formula.
Sets up a simple link with known parameters and verifies
the CNR output matches the expected value.
"""
testFailCount = 0
testMessages = []
# Known parameters
distance_m = 1000e3 # 1000 km
frequency_Hz = 2.2e9 # S-band
bandwidth_Hz = 5e6 # 5 MHz
directivity_dB = 20.0 # 20 dBi
P_Tx = 100.0 # 100 W
eta_r = 0.6 # 60% efficiency
T_E = 50.0 # 50 K
T_Ambient = 150.0 # 150 K
T_sky = 5.0 # 5 K
k = 1.0
# Compute expected values
params = compute_antenna_derived_params(
directivity_dB, k, P_Tx, eta_r, T_E, T_Ambient, T_sky, bandwidth_Hz
)
fspl_truth = compute_fspl(distance_m, frequency_Hz)
# Perfect pointing, no atmospheric loss, no frequency offset
cnr_truth = compute_cnr(
params['P_eirp_dB'],
params['G_rx_dB'],
fspl_truth,
L_atm=0.0,
L_point=0.0,
L_freq=0.0,
P_N_watts=params['P_N']
)
# Define positions
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
# Create antenna payloads with proper pointing
ant1_payload, _ = create_antenna_msg_payload(
name="TxAnt", state=ANTENNA_TX, frequency=frequency_Hz,
bandwidth=bandwidth_Hz, directivity_dB=directivity_dB, k=k,
P_Tx=P_Tx, eta_r=eta_r, T_E=T_E, T_Ambient=T_Ambient, T_sky=T_sky,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="RxAnt", state=ANTENNA_RX, frequency=frequency_Hz,
bandwidth=bandwidth_Hz, directivity_dB=directivity_dB, k=k,
P_Tx=P_Tx, eta_r=eta_r, T_E=T_E, T_Ambient=T_Ambient, T_sky=T_sky,
position=pos2, target_position=pos1
)
# Run simulation
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
cnr_sim = float(linkDataLog.CNR2[-1]) # Antenna 2 is receiver
# Compare with relative tolerance
if cnr_truth > 0:
rel_error = abs(cnr_sim - cnr_truth) / cnr_truth
if rel_error > ACCURACY_CNR_REL:
testFailCount += 1
testMessages.append(
f"CNR mismatch: got {cnr_sim:.4e}, expected {cnr_truth:.4e} "
f"(rel error: {rel_error*100:.2f}%)"
)
if testFailCount == 0:
print(f"PASSED: test_linkBudget_cnr_calculation (CNR = {cnr_sim:.4e})")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_cnr_with_pointing_loss():
"""
Numeric CNR truth check including pointing loss.
Uses space-space link with a controlled pointing error.
"""
testFailCount = 0
testMessages = []
distance_m = 1200e3
frequency_Hz = 8.4e9
B = 1e6
pos_tx = [0.0, 0.0, 0.0]
pos_rx = [0.0, 0.0, distance_m]
# Apply a known pointing error at TX (azimuth error in antenna frame).
# The existing helpers already used elsewhere: compute_pointing_sigma() and apply_pointing_error().
sigma_tx = compute_pointing_sigma(pos_tx, pos_rx)
sigma_tx_err = apply_pointing_error(sigma_tx, np.deg2rad(5.0), 0.0)
ant_tx, _ = create_antenna_msg_payload(
name="TX_Ant", state=ANTENNA_TX,
environment=ENV_SPACE,
frequency=frequency_Hz, bandwidth=B,
position=pos_tx, target_position=pos_rx,
sigma=sigma_tx_err
)
ant_rx, _ = create_antenna_msg_payload(
name="RX_Ant", state=ANTENNA_RX,
environment=ENV_SPACE,
frequency=frequency_Hz, bandwidth=B,
position=pos_rx, target_position=pos_tx
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(ant_tx, ant_rx)
run_simulation(unitTestSim)
# Truth model pieces
fspl_truth = compute_fspl(distance_m, frequency_Hz)
# Use the module's own computed pointing loss for the angle extraction path consistency,
# but verify CNR bookkeeping (pointing included in budget).
L_point = linkBudgetModule.getL_point()
L_freq = linkBudgetModule.getL_freq()
L_atm = linkBudgetModule.getL_atm()
# Derived params from payloads (already used in existing cnr_calculation test)
# derived = compute_antenna_derived_params(ant_tx, ant_rx)
derived = compute_antenna_derived_params(
ant_tx.DdB,
1.0, # k
ant_tx.P_Tx,
ant_tx.eta_r,
50.0, # T_E
ant_tx.T_Ambient,
5.0, # T_sky
ant_tx.B
)
P_eirp_dB = derived["P_eirp_dB"]
G_rx_dB = derived["G_rx_dB"]
P_N = derived["P_N"]
cnr_truth = compute_cnr(P_eirp_dB, G_rx_dB, fspl_truth, L_atm, L_point, L_freq, P_N)
cnr_sim = float(linkDataLog.CNR2[-1])
# Relative tolerance like the existing cnr test (5%)
if cnr_truth <= 0 or cnr_sim <= 0:
testFailCount += 1
testMessages.append(f"CNR should be positive: truth={cnr_truth:.4e}, sim={cnr_sim:.4e}")
else:
rel_err = abs(cnr_sim - cnr_truth) / cnr_truth
if rel_err > 0.05:
testFailCount += 1
testMessages.append(
f"CNR mismatch with pointing: sim={cnr_sim:.4e}, truth={cnr_truth:.4e}, rel_err={rel_err:.3%}"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_cnr_with_pointing_loss")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_cnr_with_frequency_loss_partial_overlap():
"""
Numeric CNR truth check including frequency overlap loss.
Uses partial overlap and computes FSPL at overlap-center frequency.
"""
testFailCount = 0
testMessages = []
distance_m = 900e3
pos_tx = [0.0, 0.0, 0.0]
pos_rx = [0.0, 0.0, distance_m]
B1 = 10e6
B2 = 10e6
# Construct overlap = 7 MHz by shifting one by 3 MHz
f1 = 10.000e9
f2 = f1 + 3.0e6
ant_tx, _ = create_antenna_msg_payload(
name="TX_Ant", state=ANTENNA_TX,
environment=ENV_SPACE,
frequency=f1, bandwidth=B1,
position=pos_tx, target_position=pos_rx
)
ant_rx, _ = create_antenna_msg_payload(
name="RX_Ant", state=ANTENNA_RX,
environment=ENV_SPACE,
frequency=f2, bandwidth=B2,
position=pos_rx, target_position=pos_tx
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(ant_tx, ant_rx)
run_simulation(unitTestSim)
# Overlap truth and center frequency truth
B_ov_truth, f_center_truth = compute_expected_overlap(f1, B1, f2, B2)
# Confirm overlap is positive and matches the module output bandwidth
B_sim = float(linkDataLog.bandwidth[-1])
if abs(B_sim - B_ov_truth) > 1e-6:
testFailCount += 1
testMessages.append(f"Overlap bandwidth mismatch: sim={B_sim:.3f}, truth={B_ov_truth:.3f}")
# FSPL evaluated at overlap-center
fspl_truth = compute_fspl(distance_m, f_center_truth)
# Frequency loss truth: 10log10(B_min / B_overlap)
L_freq_truth = 10.0 * np.log10(min(B1, B2) / B_ov_truth) # [dB]
L_point = linkBudgetModule.getL_point()
L_atm = linkBudgetModule.getL_atm()
L_freq_sim = linkBudgetModule.getL_freq()
if abs(L_freq_sim - L_freq_truth) > ACCURACY_DB:
testFailCount += 1
testMessages.append(f"L_freq mismatch: sim={L_freq_sim:.3f} dB, truth={L_freq_truth:.3f} dB")
# derived = compute_antenna_derived_params(ant_tx, ant_rx)
derived = compute_antenna_derived_params(
ant_tx.DdB,
1.0, # k
ant_tx.P_Tx,
ant_tx.eta_r,
50.0, # T_E
ant_tx.T_Ambient,
5.0, # T_sky
ant_tx.B
)
P_eirp_dB = derived["P_eirp_dB"]
G_rx_dB = derived["G_rx_dB"]
P_N = derived["P_N"]
cnr_truth = compute_cnr(P_eirp_dB, G_rx_dB, fspl_truth, L_point, L_freq_truth, L_atm, P_N)
cnr_sim = float(linkDataLog.CNR2[-1])
if cnr_truth <= 0 or cnr_sim <= 0:
testFailCount += 1
testMessages.append(f"CNR should be positive: truth={cnr_truth:.4e}, sim={cnr_sim:.4e}")
else:
rel_err = abs(cnr_sim - cnr_truth) / cnr_truth
if rel_err > 0.05:
testFailCount += 1
testMessages.append(
f"CNR mismatch with freq loss: sim={cnr_sim:.4e}, truth={cnr_truth:.4e}, rel_err={rel_err:.3%}"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_cnr_with_frequency_loss_partial_overlap")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_cnr_bookkeeping_with_atmos_pointing_and_freq_loss():
"""
Checks that CNR bookkeeping includes all losses (FSPL, pointing, frequency, atmospheric).
Uses module-computed losses, recomputes CNR from TX/RX derived parameters, and compares.
"""
testFailCount = 0
testMessages = []
spacecraft_alt = 400e3
pos_ground = [REQ_EARTH, 0.0, 0.0]
# Non-overhead case: central angle 5 deg (~32 deg elevation)
central_angle = np.radians(5.0)
pos_spacecraft = compute_spacecraft_position_from_central_angle(REQ_EARTH, spacecraft_alt, central_angle)
B1 = 5e6
B2 = 5e6
f1 = 10.000e9
f2 = f1 + 1.0e6 # partial overlap but not zero
# Pointing error on spacecraft TX (small)
sigma_sc = compute_pointing_sigma(pos_spacecraft, pos_ground)
sigma_sc_err = apply_pointing_error(sigma_sc, np.deg2rad(3.0),np.deg2rad(2.0))
ant_sc, _ = create_antenna_msg_payload(
name="SC_Ant", state=ANTENNA_TX,
environment=ENV_SPACE,
frequency=f1, bandwidth=B1,
position=pos_spacecraft, target_position=pos_ground,
sigma=sigma_sc_err
)
ant_gnd, _ = create_antenna_msg_payload(
name="GND_Ant", state=ANTENNA_RX,
environment=ENV_EARTH,
frequency=f2, bandwidth=B2,
position=pos_ground, target_position=pos_spacecraft,
r_AP_N=pos_ground,
nHat_LP_N=[1, 0, 0]
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(ant_sc, ant_gnd, enable_atm=True)
run_simulation(unitTestSim)
# Recompute overlap center for FSPL
B_ov_truth, f_center_truth = compute_expected_overlap(f1, B1, f2, B2)
print("B_ov_truth, f_center_truth",B_ov_truth, f_center_truth)
if B_ov_truth <= 0:
testFailCount += 1
testMessages.append("Expected positive overlap for bookkeeping test")
# Distance truth from geometry (matches module distance)
distance_truth = np.linalg.norm(np.array(pos_spacecraft) - np.array(pos_ground))
fspl_truth = compute_fspl(distance_truth, f_center_truth)
# Use module computed loss terms
L_point = linkBudgetModule.getL_point()
L_freq = linkBudgetModule.getL_freq()
L_atm = linkBudgetModule.getL_atm()
derived = compute_antenna_derived_params(
ant_sc.DdB,
1.0, # k
ant_sc.P_Tx,
ant_sc.eta_r,
50.0, # T_E
ant_sc.T_Ambient,
5.0, # T_sky
ant_sc.B
)
P_eirp_dB = derived["P_eirp_dB"]
G_rx_dB = derived["G_rx_dB"]
P_N = derived["P_N"]
cnr_truth = compute_cnr(P_eirp_dB, G_rx_dB, fspl_truth, L_atm, L_point, L_freq, P_N)
cnr_sim = float(linkDataLog.CNR2[-1])
if cnr_truth <= 0 or cnr_sim <= 0:
testFailCount += 1
testMessages.append(f"CNR should be positive: truth={cnr_truth:.4e}, sim={cnr_sim:.4e}")
else:
rel_err = abs(cnr_sim - cnr_truth) / cnr_truth
if rel_err > 0.05:
testFailCount += 1
testMessages.append(
f"CNR bookkeeping mismatch: sim={cnr_sim:.4e}, truth={cnr_truth:.4e}, rel_err={rel_err:.3%}"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_cnr_bookkeeping_with_atmos_pointing_and_freq_loss")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_pointing_loss():
"""
Validate pointing loss calculation for various off-axis angles.
"""
testFailCount = 0
testMessages = []
# Parameters
frequency_Hz = 2.2e9
distance_m = 1000e3
directivity_dB = 20.0
k = 1.0
params = compute_antenna_derived_params(
directivity_dB, k, 100.0, 0.6, 50.0, 150.0, 5.0, 5e6
)
# Define positions
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
# Test various pointing errors
pointing_errors_deg = [0, 2, 5, 8, 10]
for error_deg in pointing_errors_deg:
# Antenna 1 with pointing error, antenna 2 with perfect pointing
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=frequency_Hz,
directivity_dB=directivity_dB, k=k,
position=pos1, target_position=pos2,
pointing_error_az_deg=error_deg
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=frequency_Hz,
directivity_dB=directivity_dB, k=k,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, _ = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
L_point_sim = linkBudgetModule.getL_point()
# Expected: only antenna 1 contributes (antenna 2 has perfect pointing)
error_rad = np.deg2rad(error_deg)
L_point_truth = compute_pointing_loss(
error_rad, 0, params['HPBW_az'], params['HPBW_el']
)
if abs(L_point_sim - L_point_truth) > ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"Pointing loss at {error_deg}°: got {L_point_sim:.3f} dB, "
f"expected {L_point_truth:.3f} dB"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_pointing_loss")
else:
print(f"FAILED: {testFailCount} errors")
for msg in testMessages:
print(f" {msg}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_no_bandwidth_overlap():
"""
Test behavior when antennas have no overlapping bandwidth.
CNR should be 0 to indicate link failure.
"""
testFailCount = 0
testMessages = []
# Antenna 1: 2.2 GHz ± 2.5 MHz -> [2.1975, 2.2025] GHz
# Antenna 2: 2.3 GHz ± 2.5 MHz -> [2.2975, 2.3025] GHz
# Gap of ~97.5 MHz -> no overlap
distance_m = 1000e3
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_RXTX, frequency=2.2e9, bandwidth=5e6,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RXTX, frequency=2.3e9, bandwidth=5e6,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
cnr1 = float(linkDataLog.CNR1[-1])
cnr2 = float(linkDataLog.CNR2[-1])
bandwidth_overlap = float(linkDataLog.bandwidth[-1])
# With no bandwidth overlap, link should fail
if bandwidth_overlap > 0:
testFailCount += 1
testMessages.append(
f"Expected zero or negative bandwidth overlap, got {bandwidth_overlap/1e6:.2f} MHz"
)
# CNR should indicate failed link (0)
if cnr1 != CNR_INVALID or cnr2 != CNR_INVALID:
testFailCount += 1
testMessages.append(
f"Expected CNR={CNR_INVALID} for no bandwidth overlap, got CNR1={cnr1}, CNR2={cnr2}"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_no_bandwidth_overlap")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_partial_bandwidth_overlap():
"""
Test behavior with partial bandwidth overlap.
Should see frequency offset loss.
"""
testFailCount = 0
testMessages = []
# Antenna 1: 2.200 GHz ± 5 MHz -> [2.195, 2.205] GHz
# Antenna 2: 2.203 GHz ± 5 MHz -> [2.198, 2.208] GHz
# Overlap: [2.198, 2.205] = 7 MHz overlap out of 10 MHz
bandwidth = 10e6 # 10 MHz each
freq1 = 2.200e9
freq2 = 2.203e9 # 3 MHz offset
distance_m = 1000e3
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=freq1, bandwidth=bandwidth,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=freq2, bandwidth=bandwidth,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
bandwidth_overlap = float(linkDataLog.bandwidth[-1])
L_freq = linkBudgetModule.getL_freq()
# Expected overlap: 7 MHz
expected_overlap = 7e6
if abs(bandwidth_overlap - expected_overlap) > 1e3: # 1 kHz tolerance
testFailCount += 1
testMessages.append(
f"Bandwidth overlap: got {bandwidth_overlap/1e6:.3f} MHz, "
f"expected {expected_overlap/1e6:.3f} MHz"
)
# Frequency loss should be: 10*log10(10/7) = +1.55 dB
expected_L_freq = 10.0 * np.log10(bandwidth / expected_overlap) # [dB]
if abs(L_freq - expected_L_freq) > ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"Frequency loss: got {L_freq:.3f} dB, expected {expected_L_freq:.3f} dB"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_partial_bandwidth_overlap")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_distance_calculation():
"""
Validate distance calculation between antenna positions.
"""
testFailCount = 0
testMessages = []
# Test cases: (pos1, pos2, expected_distance_m, description)
test_cases = [
([0, 0, 0], [1000e3, 0, 0], 1000e3, "Along X"),
([0, 0, 0], [0, 1000e3, 0], 1000e3, "Along Y"),
([0, 0, 0], [0, 0, 1000e3], 1000e3, "Along Z"),
([1e6, 2e6, 3e6], [2e6, 2e6, 3e6], 1e6, "Offset along X"),
([0, 0, 0], [1e6, 1e6, 1e6], np.sqrt(3)*1e6, "Diagonal"),
([100e3, 200e3, 300e3], [400e3, 600e3, 800e3],
np.sqrt(300e3**2 + 400e3**2 + 500e3**2), "General 3D"),
]
for pos1, pos2, expected_dist, desc in test_cases:
# Create payloads with proper pointing
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX,
position=pos2, target_position=pos1
)
unitTestSim, _, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
dist_sim = float(linkDataLog.distance[-1])
if abs(dist_sim - expected_dist) > 1.0: # 1 meter tolerance
testFailCount += 1
testMessages.append(
f"Distance ({desc}): got {dist_sim:.1f} m, expected {expected_dist:.1f} m"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_distance_calculation")
else:
print(f"FAILED: {testFailCount} errors")
for msg in testMessages:
print(f" {msg}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_symmetric_bidirectional():
"""
Test that a bidirectional link (RXTX <-> RXTX) produces
symmetric CNR values for identical antennas.
"""
testFailCount = 0
testMessages = []
distance_m = 1000e3
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
# Identical antennas, symmetric geometry, both pointing at each other
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_RXTX,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RXTX,
position=pos2, target_position=pos1
)
unitTestSim, _, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
cnr1 = float(linkDataLog.CNR1[-1])
cnr2 = float(linkDataLog.CNR2[-1])
# Both should be positive
if cnr1 <= 0 or cnr2 <= 0:
testFailCount += 1
testMessages.append(f"CNR values should be positive: CNR1={cnr1}, CNR2={cnr2}")
# Should be approximately equal (symmetric link)
if cnr1 > 0 and cnr2 > 0:
rel_diff = abs(cnr1 - cnr2) / max(cnr1, cnr2)
if rel_diff > 0.01: # 1% tolerance
testFailCount += 1
testMessages.append(
f"Symmetric link should have equal CNR: CNR1={cnr1:.4e}, CNR2={cnr2:.4e}"
)
if testFailCount == 0:
print(f"PASSED: test_linkBudget_symmetric_bidirectional (CNR={cnr1:.4e})")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_fspl_vs_distance():
"""
Verify FSPL increases by 6 dB when distance doubles (inverse square law).
"""
testFailCount = 0
testMessages = []
frequency = 2.2e9
base_distance = 1000e3 # 1000 km
pos1 = [0.0, 0.0, 0.0]
pos2_base = [0.0, 0.0, base_distance]
pos2_double = [0.0, 0.0, 2 * base_distance]
# Get FSPL at base distance
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=frequency,
position=pos1, target_position=pos2_base
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=frequency,
position=pos2_base, target_position=pos1
)
unitTestSim, linkBudgetModule, _ = setup_link_budget_sim(ant1_payload, ant2_payload)
run_simulation(unitTestSim)
fspl_base = linkBudgetModule.getL_FSPL()
# Get FSPL at double distance
ant1_payload_2, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=frequency,
position=pos1, target_position=pos2_double
)
ant2_payload_2x, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=frequency,
position=pos2_double, target_position=pos1
)
unitTestSim2, linkBudgetModule2, _ = setup_link_budget_sim(ant1_payload_2, ant2_payload_2x)
run_simulation(unitTestSim2)
fspl_double = linkBudgetModule2.getL_FSPL()
# FSPL should increase by ~6.02 dB when distance doubles
expected_increase = 20.0 * np.log10(2) # = 6.02 dB
actual_increase = fspl_double - fspl_base
if abs(actual_increase - expected_increase) > 0.05: # 0.05 dB tolerance
testFailCount += 1
testMessages.append(
f"FSPL increase for 2x distance: got {actual_increase:.3f} dB, "
f"expected {expected_increase:.3f} dB"
)
if testFailCount == 0:
print(f"PASSED: test_linkBudget_fspl_vs_distance (Δ={actual_increase:.3f} dB)")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_fspl_vs_frequency():
"""
Verify FSPL increases by 6 dB when frequency doubles.
"""
testFailCount = 0
testMessages = []
distance_m = 1000e3
base_frequency = 2.2e9
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
# Get FSPL at base frequency
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=base_frequency,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=base_frequency,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, _ = setup_link_budget_sim(ant1_payload, ant2_payload)
run_simulation(unitTestSim)
fspl_base = linkBudgetModule.getL_FSPL()
# Get FSPL at double frequency
ant1_payload_2, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=2*base_frequency,
position=pos1, target_position=pos2
)
ant2_payload_2, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=2*base_frequency,
position=pos2, target_position=pos1
)
unitTestSim2, linkBudgetModule2, _ = setup_link_budget_sim(ant1_payload_2, ant2_payload_2)
run_simulation(unitTestSim2)
fspl_double = linkBudgetModule2.getL_FSPL()
# FSPL should increase by ~6.02 dB when frequency doubles
expected_increase = 20.0 * np.log10(2) # = 6.02 dB
actual_increase = fspl_double - fspl_base
if abs(actual_increase - expected_increase) > 0.05: # 0.05 dB tolerance
testFailCount += 1
testMessages.append(
f"FSPL increase for 2x frequency: got {actual_increase:.3f} dB, "
f"expected {expected_increase:.3f} dB"
)
if testFailCount == 0:
print(f"PASSED: test_linkBudget_fspl_vs_frequency (Δ={actual_increase:.3f} dB)")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_antenna_state_transitions():
"""
Test that CNR values correctly reflect antenna state combinations.
NOTE: CNR is set to 0.0 (not -1.0) when antenna is not in receive mode
or when link is invalid, based on the linkValid flag behavior in the code.
"""
testFailCount = 0
testMessages = []
distance_m = 1000e3
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
# Test matrix: (ant1_state, ant2_state, expect_cnr1_valid, expect_cnr2_valid)
test_matrix = [
(ANTENNA_OFF, ANTENNA_OFF, False, False),
(ANTENNA_OFF, ANTENNA_RX, False, False), # No TX
(ANTENNA_OFF, ANTENNA_TX, False, False), # No RX
(ANTENNA_OFF, ANTENNA_RXTX, False, False), # Ant1 is OFF
(ANTENNA_RX, ANTENNA_OFF, False, False), # Ant2 is OFF
(ANTENNA_RX, ANTENNA_RX, False, False), # No TX
(ANTENNA_RX, ANTENNA_TX, True, False), # Valid: 2->1
(ANTENNA_RX, ANTENNA_RXTX, True, False), # Valid: 2->1, but 1 not TX
(ANTENNA_TX, ANTENNA_OFF, False, False), # Ant2 is OFF
(ANTENNA_TX, ANTENNA_RX, False, True), # Valid: 1->2
(ANTENNA_TX, ANTENNA_TX, False, False), # No RX
(ANTENNA_TX, ANTENNA_RXTX, False, True), # Valid: 1->2
(ANTENNA_RXTX, ANTENNA_OFF, False, False), # Ant2 is OFF
(ANTENNA_RXTX, ANTENNA_RX, False, True), # Valid: 1->2
(ANTENNA_RXTX, ANTENNA_TX, True, False), # Valid: 2->1
(ANTENNA_RXTX, ANTENNA_RXTX, True, True), # Both directions valid
]
for ant1_state, ant2_state, expect_cnr1, expect_cnr2 in test_matrix:
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ant1_state,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ant2_state,
position=pos2, target_position=pos1
)
unitTestSim, _, linkDataLog = setup_link_budget_sim(ant1_payload, ant2_payload)
run_simulation(unitTestSim)
cnr1 = float(linkDataLog.CNR1[-1])
cnr2 = float(linkDataLog.CNR2[-1])
# Check CNR1
if expect_cnr1:
if cnr1 <= 0:
testFailCount += 1
testMessages.append(
f"State {ant1_state}/{ant2_state}: CNR1 should be positive, got {cnr1}"
)
else:
# CNR should be 0 when link is invalid (based on code review)
if cnr1 != CNR_INVALID:
testFailCount += 1
testMessages.append(
f"State {ant1_state}/{ant2_state}: CNR1 should be {CNR_INVALID}, got {cnr1}"
)
# Check CNR2
if expect_cnr2:
if cnr2 <= 0:
testFailCount += 1
testMessages.append(
f"State {ant1_state}/{ant2_state}: CNR2 should be positive, got {cnr2}"
)
else:
# CNR should be 0 when link is invalid (based on code review)
if cnr2 != CNR_INVALID:
testFailCount += 1
testMessages.append(
f"State {ant1_state}/{ant2_state}: CNR2 should be {CNR_INVALID}, got {cnr2}"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_antenna_state_transitions")
else:
print(f"FAILED: {testFailCount} errors")
for msg in testMessages:
print(f" {msg}")
assert testFailCount == 0, "\n".join(testMessages)
# =============================================================================
# Atmospheric Attenuation Tests
# =============================================================================
[docs]
def test_linkBudget_atmospheric_attenuation_space_ground():
"""
Test that atmospheric attenuation is computed for space-to-ground links
when enabled.
"""
testFailCount = 0
testMessages = []
# Spacecraft at 400 km altitude
spacecraft_alt = 400e3
pos_spacecraft = [REQ_EARTH + spacecraft_alt, 0, 0]
pos_ground = [REQ_EARTH, 0, 0]
frequency = 10e9 # X-band (higher atmospheric effect)
# Create spacecraft antenna
ant_sc, _ = create_antenna_msg_payload(
name="SC_Ant", state=ANTENNA_TX,
environment=ENV_SPACE,
frequency=frequency,
position=pos_spacecraft, target_position=pos_ground
)
# Create ground antenna
ant_gnd, _ = create_antenna_msg_payload(
name="GND_Ant", state=ANTENNA_RX,
environment=ENV_EARTH,
frequency=frequency,
position=pos_ground, target_position=pos_spacecraft,
r_AP_N=pos_ground,
nHat_LP_N=[1, 0, 0] # Surface normal pointing radially outward
)
# Test WITH atmospheric attenuation enabled
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant_sc, ant_gnd, enable_atm=True
)
run_simulation(unitTestSim)
L_atm = linkBudgetModule.getL_atm()
cnr_with_atm = float(linkDataLog.CNR2[-1])
# Test WITHOUT atmospheric attenuation
unitTestSim2, linkBudgetModule2, linkDataLog2 = setup_link_budget_sim(
ant_sc, ant_gnd, enable_atm=False
)
run_simulation(unitTestSim2)
L_atm_disabled = linkBudgetModule2.getL_atm()
cnr_without_atm = float(linkDataLog2.CNR2[-1])
# Check that atmospheric attenuation is positive when enabled
if L_atm <= 0:
testFailCount += 1
testMessages.append(f"L_atm should be > 0 when enabled, got {L_atm:.3f} dB")
# Check that atmospheric attenuation is 0 when disabled
if L_atm_disabled != 0.0:
testFailCount += 1
testMessages.append(f"L_atm should be 0 when disabled, got {L_atm_disabled:.3f} dB")
# CNR with attenuation should be lower than without
if cnr_with_atm >= cnr_without_atm and L_atm > 0:
testFailCount += 1
testMessages.append(
f"CNR with atm ({cnr_with_atm:.4e}) should be < CNR without ({cnr_without_atm:.4e})"
)
if testFailCount == 0:
print(f"PASSED: test_linkBudget_atmospheric_attenuation_space_ground (L_atm={L_atm:.2f} dB)")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_atmospheric_attenuation_elevation_dependence():
"""
Test that atmospheric attenuation increases as elevation decreases:
~90 deg (overhead) -> moderate elevation -> low elevation.
Also checks that values remain finite and non-negative.
"""
testFailCount = 0
testMessages = []
spacecraft_alt = 400e3
pos_ground = [REQ_EARTH, 0.0, 0.0]
frequency = 10e9 # X-band
# Central angle cases (deg): 0 -> overhead (~90 deg elev), 5 -> ~32 deg elev, 17 -> ~3 deg elev
angles_deg = [0.0, 5.0, 17.0]
L_atm_values = []
CNR_values = []
for ang_deg in angles_deg:
pos_spacecraft = compute_spacecraft_position_from_central_angle(
REQ_EARTH, spacecraft_alt, np.radians(ang_deg)
)
ant_sc, _ = create_antenna_msg_payload(
name="SC_Ant", state=ANTENNA_TX,
environment=ENV_SPACE,
frequency=frequency,
position=pos_spacecraft, target_position=pos_ground
)
ant_gnd, _ = create_antenna_msg_payload(
name="GND_Ant", state=ANTENNA_RX,
environment=ENV_EARTH,
frequency=frequency,
position=pos_ground, target_position=pos_spacecraft,
r_AP_N=pos_ground,
nHat_LP_N=[1, 0, 0]
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(ant_sc, ant_gnd, enable_atm=True)
run_simulation(unitTestSim)
L_atm = linkBudgetModule.getL_atm()
cnr = float(linkDataLog.CNR2[-1])
if not np.isfinite(L_atm):
testFailCount += 1
testMessages.append(f"L_atm not finite at central angle {ang_deg:.1f} deg: {L_atm}")
if L_atm < 0.0:
testFailCount += 1
testMessages.append(f"L_atm negative at central angle {ang_deg:.1f} deg: {L_atm:.3f} dB")
if cnr < 0.0:
testFailCount += 1
testMessages.append(f"CNR negative at central angle {ang_deg:.1f} deg: {cnr:.4e}")
L_atm_values.append(L_atm)
CNR_values.append(cnr)
# Enforce monotonic trend: lower elevation -> higher attenuation
# angles_deg increasing corresponds to lower elevation in this geometry.
if not (L_atm_values[0] <= L_atm_values[1] <= L_atm_values[2]):
testFailCount += 1
testMessages.append(
f"Expected L_atm to increase as elevation decreases: "
f"ang={angles_deg[0]:.1f}:{L_atm_values[0]:.3f}, "
f"ang={angles_deg[1]:.1f}:{L_atm_values[1]:.3f}, "
f"ang={angles_deg[2]:.1f}:{L_atm_values[2]:.3f}"
)
# CNR should decrease (or at least not increase) as attenuation increases
if not (CNR_values[0] >= CNR_values[1] >= CNR_values[2]):
testFailCount += 1
testMessages.append(
f"Expected CNR to decrease as elevation decreases: "
f"ang={angles_deg[0]:.1f}:{CNR_values[0]:.4e}, "
f"ang={angles_deg[1]:.1f}:{CNR_values[1]:.4e}, "
f"ang={angles_deg[2]:.1f}:{CNR_values[2]:.4e}"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_atmospheric_attenuation_elevation_dependence")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_atmospheric_attenuation_low_elevation_stability():
"""
Low-elevation stability test to ensure attenuation does not blow up or become NaN/inf.
Uses a central angle that produces ~2 deg elevation region.
"""
testFailCount = 0
testMessages = []
spacecraft_alt = 400e3
pos_ground = [REQ_EARTH, 0.0, 0.0]
frequency = 10e9
# Central angle ~18 deg yields ~1-2 deg elevation for 400 km altitude
pos_spacecraft = compute_spacecraft_position_from_central_angle(
REQ_EARTH, spacecraft_alt, np.radians(18.0)
)
ant_sc, _ = create_antenna_msg_payload(
name="SC_Ant", state=ANTENNA_TX,
environment=ENV_SPACE,
frequency=frequency,
position=pos_spacecraft, target_position=pos_ground
)
ant_gnd, _ = create_antenna_msg_payload(
name="GND_Ant", state=ANTENNA_RX,
environment=ENV_EARTH,
frequency=frequency,
position=pos_ground, target_position=pos_spacecraft,
r_AP_N=pos_ground,
nHat_LP_N=[1, 0, 0]
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(ant_sc, ant_gnd, enable_atm=True)
run_simulation(unitTestSim)
L_atm = linkBudgetModule.getL_atm()
cnr = float(linkDataLog.CNR2[-1])
if not np.isfinite(L_atm):
testFailCount += 1
testMessages.append(f"L_atm not finite at low elevation: {L_atm}")
if L_atm < 0.0:
testFailCount += 1
testMessages.append(f"L_atm negative at low elevation: {L_atm:.3f} dB")
# Sanity upper bound to catch blow-ups; adjust if needed based on expected model range.
if L_atm > 200.0:
testFailCount += 1
testMessages.append(f"L_atm unrealistically large at low elevation: {L_atm:.3f} dB")
if cnr < 0.0:
testFailCount += 1
testMessages.append(f"CNR negative at low elevation: {cnr:.4e}")
if testFailCount == 0:
print("PASSED: test_linkBudget_atmospheric_attenuation_low_elevation_stability")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_atmospheric_attenuation_space_space():
"""
Test that atmospheric attenuation is NOT computed for space-to-space links
even when the flag is enabled.
"""
testFailCount = 0
testMessages = []
distance_m = 1000e3
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
# Both antennas in space
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX,
environment=ENV_SPACE,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX,
environment=ENV_SPACE,
position=pos2, target_position=pos1
)
# Enable atmospheric attenuation (should be ignored for space-space)
unitTestSim, linkBudgetModule, _ = setup_link_budget_sim(
ant1_payload, ant2_payload, enable_atm=True
)
run_simulation(unitTestSim)
L_atm = linkBudgetModule.getL_atm()
# Atmospheric attenuation should be 0 for space-to-space links
if L_atm != 0.0:
testFailCount += 1
testMessages.append(f"L_atm should be 0 for space-space link, got {L_atm:.3f} dB")
if testFailCount == 0:
print("PASSED: test_linkBudget_atmospheric_attenuation_space_space")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_atmospheric_attenuation_frequency_dependence():
"""
Test that atmospheric attenuation increases with frequency.
Higher frequencies experience more atmospheric absorption.
"""
testFailCount = 0
testMessages = []
# Spacecraft at 400 km altitude, directly overhead
spacecraft_alt = 400e3
pos_spacecraft = [REQ_EARTH + spacecraft_alt, 0, 0]
pos_ground = [REQ_EARTH, 0, 0]
# Test at different frequencies (should see increasing attenuation)
frequencies = [2e9, 10e9, 20e9] # S-band, X-band, K-band
L_atm_values = []
for freq in frequencies:
ant_sc, _ = create_antenna_msg_payload(
name="SC_Ant", state=ANTENNA_TX,
environment=ENV_SPACE,
frequency=freq,
position=pos_spacecraft, target_position=pos_ground
)
ant_gnd, _ = create_antenna_msg_payload(
name="GND_Ant", state=ANTENNA_RX,
environment=ENV_EARTH,
frequency=freq,
position=pos_ground, target_position=pos_spacecraft,
r_AP_N=pos_ground,
nHat_LP_N=[1, 0, 0]
)
unitTestSim, linkBudgetModule, _ = setup_link_budget_sim(
ant_sc, ant_gnd, enable_atm=True
)
run_simulation(unitTestSim)
L_atm_values.append(linkBudgetModule.getL_atm())
# Check that attenuation generally increases with frequency
# (Note: there can be local variations due to absorption lines)
if L_atm_values[0] >= L_atm_values[2]:
testFailCount += 1
testMessages.append(
f"L_atm should generally increase with frequency: "
f"2 GHz={L_atm_values[0]:.3f} dB, 20 GHz={L_atm_values[2]:.3f} dB"
)
if testFailCount == 0:
print(f"PASSED: test_linkBudget_atmospheric_attenuation_frequency_dependence "
f"(L_atm: {L_atm_values[0]:.3f} -> {L_atm_values[2]:.3f} dB)")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
# =============================================================================
# Robustness / Edge Case Tests
# =============================================================================
[docs]
def test_linkBudget_very_short_distance():
"""
Test behavior with very short distances (near-field concerns).
Module should still compute valid FSPL.
"""
testFailCount = 0
testMessages = []
# Very short distance: 100 m (note: may be outside far-field region)
distance_m = 100.0
frequency_Hz = 2.2e9
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=frequency_Hz,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=frequency_Hz,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
fspl_sim = linkBudgetModule.getL_FSPL()
fspl_truth = compute_fspl(distance_m, frequency_Hz)
cnr = float(linkDataLog.CNR2[-1])
# FSPL should still match analytical formula
if abs(fspl_sim - fspl_truth) > ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"FSPL at 100m: got {fspl_sim:.2f} dB, expected {fspl_truth:.2f} dB"
)
# CNR should be positive (and quite large due to short distance)
if cnr <= 0:
testFailCount += 1
testMessages.append(f"CNR should be positive at short distance, got {cnr}")
if testFailCount == 0:
print(f"PASSED: test_linkBudget_very_short_distance (FSPL={fspl_sim:.2f} dB)")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_very_long_distance():
"""
Test behavior with very long distances (deep space communication).
"""
testFailCount = 0
testMessages = []
# Very long distance: lunar distance (~384,400 km)
distance_m = 384400e3
frequency_Hz = 8.4e9 # X-band, typical for deep space
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
# Use high-gain antennas for deep space
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=frequency_Hz,
directivity_dB=45.0, P_Tx=400.0, # DSN-like
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=frequency_Hz,
directivity_dB=45.0,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
fspl_sim = linkBudgetModule.getL_FSPL()
fspl_truth = compute_fspl(distance_m, frequency_Hz)
dist_sim = float(linkDataLog.distance[-1])
# Distance should be computed correctly
if abs(dist_sim - distance_m) / distance_m > 1e-6:
testFailCount += 1
testMessages.append(
f"Distance: got {dist_sim/1e3:.1f} km, expected {distance_m/1e3:.1f} km"
)
# FSPL should match analytical formula
if abs(fspl_sim - fspl_truth) > ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"FSPL at lunar distance: got {fspl_sim:.2f} dB, expected {fspl_truth:.2f} dB"
)
if testFailCount == 0:
print(f"PASSED: test_linkBudget_very_long_distance (FSPL={fspl_sim:.2f} dB)")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_high_frequency():
"""
Test behavior at high frequencies (within ITU-R P.676 valid range: 1-1000 GHz).
"""
testFailCount = 0
testMessages = []
distance_m = 1000e3
frequency_Hz = 100e9 # 100 GHz (W-band)
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=frequency_Hz,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=frequency_Hz,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
fspl_sim = linkBudgetModule.getL_FSPL()
fspl_truth = compute_fspl(distance_m, frequency_Hz)
cnr = float(linkDataLog.CNR2[-1])
# FSPL should match analytical formula
if abs(fspl_sim - fspl_truth) > ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"FSPL at 100 GHz: got {fspl_sim:.2f} dB, expected {fspl_truth:.2f} dB"
)
# CNR should be computed (even if small due to high FSPL)
if cnr <= 0:
testFailCount += 1
testMessages.append(f"CNR should be positive at high frequency, got {cnr}")
if testFailCount == 0:
print(f"PASSED: test_linkBudget_high_frequency (FSPL={fspl_sim:.2f} dB)")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_low_frequency():
"""
Test behavior at low frequencies (within ITU-R P.676 valid range: 1-1000 GHz).
"""
testFailCount = 0
testMessages = []
distance_m = 1000e3
frequency_Hz = 1e9 # 1 GHz (L-band)
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=frequency_Hz,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=frequency_Hz,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
fspl_sim = linkBudgetModule.getL_FSPL()
fspl_truth = compute_fspl(distance_m, frequency_Hz)
# FSPL should match analytical formula
if abs(fspl_sim - fspl_truth) > ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"FSPL at 1 GHz: got {fspl_sim:.2f} dB, expected {fspl_truth:.2f} dB"
)
if testFailCount == 0:
print(f"PASSED: test_linkBudget_low_frequency (FSPL={fspl_sim:.2f} dB)")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_different_bandwidths():
"""
Test frequency overlap calculation with different bandwidths on each antenna.
"""
testFailCount = 0
testMessages = []
frequency = 2.2e9
bandwidth1 = 20e6 # 20 MHz
bandwidth2 = 5e6 # 5 MHz (smaller)
distance_m = 1000e3
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=frequency, bandwidth=bandwidth1,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=frequency, bandwidth=bandwidth2,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
bandwidth_overlap = float(linkDataLog.bandwidth[-1])
L_freq = linkBudgetModule.getL_freq()
# Overlap should be the smaller bandwidth
expected_overlap = min(bandwidth1, bandwidth2)
if abs(bandwidth_overlap - expected_overlap) > 1e3:
testFailCount += 1
testMessages.append(
f"Bandwidth overlap: got {bandwidth_overlap/1e6:.3f} MHz, "
f"expected {expected_overlap/1e6:.3f} MHz"
)
# Frequency loss should be 0 since overlap equals the smaller bandwidth
if abs(L_freq) > ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"Frequency loss: got {L_freq:.3f} dB, expected 0 dB"
)
if testFailCount == 0:
print("PASSED: test_linkBudget_different_bandwidths")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_combined_pointing_errors():
"""
Test pointing loss with both azimuth and elevation errors.
"""
testFailCount = 0
testMessages = []
frequency_Hz = 2.2e9
distance_m = 1000e3
directivity_dB = 20.0
k = 1.0
params = compute_antenna_derived_params(
directivity_dB, k, 100.0, 0.6, 50.0, 150.0, 5.0, 5e6
)
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
# Apply both azimuth and elevation pointing errors
error_az_deg = 3.0
error_el_deg = 4.0
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_TX, frequency=frequency_Hz,
directivity_dB=directivity_dB, k=k,
position=pos1, target_position=pos2,
pointing_error_az_deg=error_az_deg,
pointing_error_el_deg=error_el_deg
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RX, frequency=frequency_Hz,
directivity_dB=directivity_dB, k=k,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, _ = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
L_point_sim = linkBudgetModule.getL_point()
# Expected pointing loss with both az and el errors
error_az_rad = np.deg2rad(error_az_deg)
error_el_rad = np.deg2rad(error_el_deg)
L_point_truth = compute_pointing_loss(
error_az_rad, error_el_rad, params['HPBW_az'], params['HPBW_el']
)
if abs(L_point_sim - L_point_truth) > ACCURACY_DB:
testFailCount += 1
testMessages.append(
f"Combined pointing loss: got {L_point_sim:.3f} dB, "
f"expected {L_point_truth:.3f} dB"
)
if testFailCount == 0:
print(f"PASSED: test_linkBudget_combined_pointing_errors (L_point={L_point_sim:.3f} dB)")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_output_message_structure():
"""
Verify all expected fields in the output message are populated.
"""
testFailCount = 0
testMessages = []
distance_m = 1000e3
frequency = 2.2e9
bandwidth = 5e6
pos1 = [0.0, 0.0, 0.0]
pos2 = [0.0, 0.0, distance_m]
ant1_payload, _ = create_antenna_msg_payload(
name="Ant1", state=ANTENNA_RXTX, frequency=frequency, bandwidth=bandwidth,
position=pos1, target_position=pos2
)
ant2_payload, _ = create_antenna_msg_payload(
name="Ant2", state=ANTENNA_RXTX, frequency=frequency, bandwidth=bandwidth,
position=pos2, target_position=pos1
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(
ant1_payload, ant2_payload
)
run_simulation(unitTestSim)
# Check all output fields
fields_to_check = {
'distance': distance_m,
'bandwidth': bandwidth,
'frequency': frequency,
}
for field_name, expected_value in fields_to_check.items():
actual_value = float(getattr(linkDataLog, field_name)[-1])
if abs(actual_value - expected_value) / expected_value > 1e-6:
testFailCount += 1
testMessages.append(
f"Field '{field_name}': got {actual_value}, expected {expected_value}"
)
# fields that should be finite scalars
finite_fields = [
"CNR1", "CNR2",
]
for field_name in finite_fields:
val = float(getattr(linkDataLog, field_name)[-1])
if not np.isfinite(val):
testFailCount += 1
testMessages.append(f"Field '{field_name}' not finite: {val}")
# Check CNR values are positive for RXTX <-> RXTX
cnr1 = float(linkDataLog.CNR1[-1])
cnr2 = float(linkDataLog.CNR2[-1])
if cnr1 <= 0 or cnr2 <= 0:
testFailCount += 1
testMessages.append(f"CNR values should be positive: CNR1={cnr1}, CNR2={cnr2}")
if testFailCount == 0:
print("PASSED: test_linkBudget_output_message_structure")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
[docs]
def test_linkBudget_output_message_consistency():
"""
Verify that output message fields are consistent between:
- the recorder log arrays (linkDataLog.*)
- the most recent output payload read directly
Also checks distance / overlap bandwidth / overlap-center frequency consistency.
"""
testFailCount = 0
testMessages = []
# Partial overlap case to make center frequency non-trivial
distance_m = 700e3
pos_tx = [0.0, 0.0, 0.0]
pos_rx = [0.0, 0.0, distance_m]
B = 10e6
f1 = 10.000e9
f2 = f1 + 3.0e6 # overlap = 7 MHz
ant_tx, _ = create_antenna_msg_payload(
name="TX_Ant", state=ANTENNA_TX,
environment=ENV_SPACE,
frequency=f1, bandwidth=B,
position=pos_tx, target_position=pos_rx
)
ant_rx, _ = create_antenna_msg_payload(
name="RX_Ant", state=ANTENNA_RX,
environment=ENV_SPACE,
frequency=f2, bandwidth=B,
position=pos_rx, target_position=pos_tx
)
unitTestSim, linkBudgetModule, linkDataLog = setup_link_budget_sim(ant_tx, ant_rx)
run_simulation(unitTestSim)
# Recorder values
dist_log = float(linkDataLog.distance[-1])
B_log = float(linkDataLog.bandwidth[-1])
f_log = float(linkDataLog.frequency[-1])
cnr1_log = float(linkDataLog.CNR1[-1])
cnr2_log = float(linkDataLog.CNR2[-1])
# Direct payload snapshot
out = linkBudgetModule.linkBudgetOutPayload.read()
# Recorder vs payload consistency
if abs(dist_log - out.distance) > 1e-6:
testFailCount += 1
testMessages.append(f"distance mismatch: log={dist_log:.6f}, msg={out.distance:.6f}")
if abs(B_log - out.bandwidth) > 1e-6:
testFailCount += 1
testMessages.append(f"bandwidth mismatch: log={B_log:.6f}, msg={out.bandwidth:.6f}")
if abs(f_log - out.frequency) > 1e-3:
testFailCount += 1
testMessages.append(f"frequency mismatch: log={f_log:.6f}, msg={out.frequency:.6f}")
if abs(cnr1_log - out.CNR1) > 1e-12:
testFailCount += 1
testMessages.append(f"CNR1 mismatch: log={cnr1_log:.6e}, msg={out.CNR1:.6e}")
if abs(cnr2_log - out.CNR2) > 1e-12:
testFailCount += 1
testMessages.append(f"CNR2 mismatch: log={cnr2_log:.6e}, msg={out.CNR2:.6e}")
# Check computed truth for distance and overlap values
dist_truth = np.linalg.norm(np.array(pos_rx) - np.array(pos_tx))
if abs(dist_log - dist_truth) / dist_truth > 1e-9:
testFailCount += 1
testMessages.append(f"distance truth mismatch: got={dist_log:.6f}, truth={dist_truth:.6f}")
B_ov_truth, f_center_truth = compute_expected_overlap(f1, B, f2, B)
if abs(B_log - B_ov_truth) > 1e-6:
testFailCount += 1
testMessages.append(f"overlap bandwidth truth mismatch: got={B_log:.6f}, truth={B_ov_truth:.6f}")
if abs(f_log - f_center_truth) > 1e-3:
testFailCount += 1
testMessages.append(f"overlap center freq truth mismatch: got={f_log:.6f}, truth={f_center_truth:.6f}")
# Basic passthrough checks
if "TX_Ant" not in out.antennaName1 and "TX_Ant" not in out.antennaName2:
testFailCount += 1
testMessages.append("TX antenna name not present in output payload")
if "RX_Ant" not in out.antennaName1 and "RX_Ant" not in out.antennaName2:
testFailCount += 1
testMessages.append("RX antenna name not present in output payload")
if testFailCount == 0:
print("PASSED: test_linkBudget_output_message_consistency")
else:
print(f"FAILED: {testMessages}")
assert testFailCount == 0, "\n".join(testMessages)
# =============================================================================
# Main Execution
# =============================================================================
if __name__ == "__main__":
# Parameterized test cases
param_test_cases = [
(ANTENNA_TX, ANTENNA_RX, "space_space", 1000, 0, 0),
(ANTENNA_RX, ANTENNA_TX, "space_space", 1000, 0, 0),
(ANTENNA_RXTX, ANTENNA_RXTX, "space_space", 1000, 0, 0),
(ANTENNA_TX, ANTENNA_RX, "space_space", 100, 0, 0),
(ANTENNA_TX, ANTENNA_RX, "space_space", 10000, 0, 0),
(ANTENNA_TX, ANTENNA_RX, "space_space", 36000, 0, 0),
(ANTENNA_TX, ANTENNA_RX, "space_space", 1000, 0, 5),
(ANTENNA_TX, ANTENNA_RX, "space_space", 1000, 0, 10),
(ANTENNA_TX, ANTENNA_RX, "space_ground", 400, 0, 0),
(ANTENNA_OFF, ANTENNA_OFF, "space_space", 1000, 0, 0),
(ANTENNA_TX, ANTENNA_TX, "space_space", 1000, 0, 0),
(ANTENNA_RX, ANTENNA_RX, "space_space", 1000, 0, 0),
]
print("=" * 70)
print("Running parameterized test_linkBudget cases")
print("=" * 70)
passed = 0
failed = 0
for i, (ant1, ant2, ltype, dist, foff, perr) in enumerate(param_test_cases):
try:
test_linkBudget(ant1, ant2, ltype, dist, foff, perr)
passed += 1
except AssertionError as e:
print(f" Case {i+1:2d}: FAILED")
failed += 1
except Exception as e:
print(f" Case {i+1:2d}: ERROR - {type(e).__name__}: {e}")
failed += 1
print("-" * 70)
print(f"Parameterized tests: {passed} passed, {failed} failed")
print()
# Focused validation tests
print("=" * 70)
print("Running focused validation tests")
print("=" * 70)
focused_tests = [
("FSPL Analytical", test_linkBudget_fspl_analytical),
("CNR Calculation", test_linkBudget_cnr_calculation),
("Pointing Loss", test_linkBudget_pointing_loss),
("No Bandwidth Overlap", test_linkBudget_no_bandwidth_overlap),
("Partial Bandwidth Overlap", test_linkBudget_partial_bandwidth_overlap),
("Distance Calculation", test_linkBudget_distance_calculation),
("Symmetric Bidirectional", test_linkBudget_symmetric_bidirectional),
("FSPL vs Distance (6dB rule)", test_linkBudget_fspl_vs_distance),
("FSPL vs Frequency (6dB rule)", test_linkBudget_fspl_vs_frequency),
("Antenna State Transitions", test_linkBudget_antenna_state_transitions),
]
passed_focused = 0
failed_focused = 0
for name, test_func in focused_tests:
try:
test_func()
passed_focused += 1
except AssertionError as e:
print(f" {name}: FAILED")
failed_focused += 1
except Exception as e:
print(f" {name}: ERROR - {type(e).__name__}: {e}")
failed_focused += 1
print("-" * 70)
print(f"Focused tests: {passed_focused} passed, {failed_focused} failed")
print()
# Atmospheric attenuation tests
print("=" * 70)
print("Running atmospheric attenuation tests")
print("=" * 70)
atm_tests = [
("Atm Atten Space-Ground", test_linkBudget_atmospheric_attenuation_space_ground),
("Atm Atten Space-Space (should be 0)", test_linkBudget_atmospheric_attenuation_space_space),
("Atm Atten Frequency Dependence", test_linkBudget_atmospheric_attenuation_frequency_dependence),
("Atm Atten Elevation Dependence", test_linkBudget_atmospheric_attenuation_elevation_dependence),
("Atm Atten Low Elevation Stability", test_linkBudget_atmospheric_attenuation_low_elevation_stability),
]
passed_atm = 0
failed_atm = 0
for name, test_func in atm_tests:
try:
test_func()
passed_atm += 1
except AssertionError as e:
print(f" {name}: FAILED")
failed_atm += 1
except Exception as e:
print(f" {name}: ERROR - {type(e).__name__}: {e}")
failed_atm += 1
print("-" * 70)
print(f"Atmospheric tests: {passed_atm} passed, {failed_atm} failed")
print()
# Robustness tests
print("=" * 70)
print("Running robustness / edge case tests")
print("=" * 70)
robustness_tests = [
("Very Short Distance", test_linkBudget_very_short_distance),
("Very Long Distance", test_linkBudget_very_long_distance),
("High Frequency (100 GHz)", test_linkBudget_high_frequency),
("Low Frequency (1 GHz)", test_linkBudget_low_frequency),
("Different Bandwidths", test_linkBudget_different_bandwidths),
("Combined Pointing Errors", test_linkBudget_combined_pointing_errors),
("Output Message Structure", test_linkBudget_output_message_structure),
("Output Message Consistency", test_linkBudget_output_message_consistency),
]
passed_robust = 0
failed_robust = 0
for name, test_func in robustness_tests:
try:
test_func()
passed_robust += 1
except AssertionError as e:
print(f" {name}: FAILED")
failed_robust += 1
except Exception as e:
print(f" {name}: ERROR - {type(e).__name__}: {e}")
failed_robust += 1
print("-" * 70)
print(f"Robustness tests: {passed_robust} passed, {failed_robust} failed")
print("=" * 70)
# Summary
total_passed = passed + passed_focused + passed_atm + passed_robust
total_failed = failed + failed_focused + failed_atm + failed_robust
print()
print(f"TOTAL: {total_passed} passed, {total_failed} failed")
print("=" * 70)