From 5c8a6f10c9c141a3bd6a5ca5e0624aaaf3f03018 Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Wed, 19 Nov 2025 14:03:28 -0300 Subject: [PATCH 01/13] update(mask): Added the Stepped Spectral mask * It's a simple mask where the user defines the mask steps across adjacent bands. --- sharc/mask/spectral_mask_stepped.py | 88 +++++++++++++++++++++++++++++ tests/test_spectral_mask_stepped.py | 58 +++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 sharc/mask/spectral_mask_stepped.py create mode 100644 tests/test_spectral_mask_stepped.py diff --git a/sharc/mask/spectral_mask_stepped.py b/sharc/mask/spectral_mask_stepped.py new file mode 100644 index 00000000..a70b60b9 --- /dev/null +++ b/sharc/mask/spectral_mask_stepped.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- + +from sharc.mask.spectral_mask import SpectralMask + +import numpy as np +import matplotlib.pyplot as plt + + +class SpectralMaskStepped(SpectralMask): + """ + Implements a stepped spectral mask defined by user-provided steps. + """ + def __init__( + self, + freq_mhz: float, + band_mhz: float, + mask_steps_dBm_mhz: list): + """ + Class constructor. + Parameters: + freq_mhz (float): center frequency of station in MHz + band_mhs (float): transmitting bandwidth of station in MHz + mask_steps_dBm_mhz (list): list of spectral mask step values in dBm/MHz. Each step corresponds to a + multiple of the bandwidth away from the band edge. + The last step is considered to the spurious domain. + """ + + self.mask_steps_dBm_mhz = mask_steps_dBm_mhz + self.freq_mhz = freq_mhz + self.band_mhz = band_mhz + + self.delta_f_lim = np.array( + [self.band_mhz * i for i in range(len(self.mask_steps_dBm_mhz))] + ) + + self.freq_lim = np.concatenate(( + (freq_mhz - band_mhz / 2) - self.delta_f_lim[::-1], + (freq_mhz + band_mhz / 2) + self.delta_f_lim, + )) + + def set_mask(self, p_tx=0): + """ + Set the spectral mask values based on the defined steps. + + Parameters: + p_tx (float): Transmit power in dBm/MHz. + """ + self.p_tx = p_tx - 10 * np.log10(self.band_mhz) + self.mask_dbm = np.concatenate([self.mask_steps_dBm_mhz[::-1], [self.p_tx], self.mask_steps_dBm_mhz]) + + +if __name__ == '__main__': + + freq = 2100 # MHz + band = 5 # MHz + p_tx = 0.0 + 10 * np.log10(band) # dBm + spourious_emissions = -30.0 # dBm/MHz + + mask_steps = [-10, -15, -20] # dBm/MHz + print(mask_steps) + mask_steps = np.concatenate([mask_steps, [-30]]) # dBm/MHz + + # Create mask + msk = SpectralMaskStepped(freq, band, mask_steps) + msk.set_mask(p_tx) + + # Frequencies + freqs = np.linspace(-60, 60, num=1000) + freq + + # Mask values + mask_val = np.ones_like(freqs) * msk.mask_dbm[0] + for k in range(len(msk.freq_lim) - 1, -1, -1): + mask_val[np.where(freqs < msk.freq_lim[k])] = msk.mask_dbm[k] + + # Plot + plt.plot(freqs, mask_val) + plt.title("Stepped Spectral Mask") + plt.xlim([freqs[0], freqs[-1]]) + plt.xlabel(r"$\Delta$f [MHz]") + plt.ylabel("Spectral Mask [dBc]") + plt.grid() + plt.show() + + print(msk.power_calc(center_f=freq + 1 * band, band=band)) + print(msk.power_calc(center_f=freq + 2 * band, band=band)) + print(msk.power_calc(center_f=freq + 3 * band, band=band)) + + diff --git a/tests/test_spectral_mask_stepped.py b/tests/test_spectral_mask_stepped.py new file mode 100644 index 00000000..fc46b7e6 --- /dev/null +++ b/tests/test_spectral_mask_stepped.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +""" +Created on Tue Dec 5 11:56:10 2017 + +@author: Calil +""" + +import unittest +import numpy as np +import numpy.testing as npt + +from sharc.mask.spectral_mask_stepped import SpectralMaskStepped + + +class SpectalMaskSteppedTest(unittest.TestCase): + """Unit tests for the SpectralMaskStepped class and its power calculation method.""" + + def test_power_calc(self): + """Test power calculation for the Stepped spectral mask at a given frequency and bandwidth.""" + freq = 2100 # MHz + band = 5 # MHz + p_tx_density = 0.0 # dBm / MHz + p_tx = p_tx_density + 10 * np.log10(band) # dBm + spurious_emissions = -30.0 # dBm/MHz + mask_steps = [-10, -15, -20] # dBm/MHz + mask_steps = np.concatenate([mask_steps, [spurious_emissions]]) + + # Create mask + msk = SpectralMaskStepped(freq, band, mask_steps) + msk.set_mask(p_tx) + + N = len(msk.delta_f_lim) + + should_eq = np.zeros(2 * N) + eq = np.zeros(2 * N) + for i in range(N): + f_offset = band + (i) * band + + # center to right edge + should_eq[i + N] = mask_steps[i] + 10 * np.log10(band) + eq[i + N] = msk.power_calc(freq + f_offset, band) + + # center to left edge + should_eq[N - i - 1] = should_eq[i + N] + eq[N - i - 1] = msk.power_calc(freq - f_offset, band) + + npt.assert_almost_equal(should_eq, eq) + + npt.assert_equal( + -np.inf, + msk.power_calc( + freq, band, + ), + ) + + +if __name__ == '__main__': + unittest.main() From 2bd0aec722d5d4fe55064fda4421ab142d1ca766 Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Fri, 21 Nov 2025 10:17:46 -0300 Subject: [PATCH 02/13] update(simulation): Implementation of OOB Antenna pattern * Added a new oob_antenna into StationManager that represents the oob antenna pattern to be used for oob emission interference calculations. * WIP: for now only IMT and MSS_D2D stations has this. --- sharc/antenna/antenna_factory.py | 21 +++++- sharc/mask/spectral_mask_stepped.py | 2 - sharc/parameters/imt/parameters_imt.py | 17 +++++ sharc/parameters/parameters_mss_d2d.py | 23 +++++++ sharc/simulation.py | 68 ++++++++++---------- sharc/station_factory.py | 64 +++++++++++++----- sharc/station_manager.py | 1 + tests/parameters/parameters_for_testing.yaml | 20 +++++- tests/parameters/test_parameters.py | 8 +++ tests/test_adjacent_channel.py | 60 +++-------------- tests/test_simulation_downlink_tvro.py | 8 ++- tests/test_station_factory.py | 65 +++++++++++++++++++ tests/test_station_factory_ngso.py | 30 +++++++++ 13 files changed, 278 insertions(+), 109 deletions(-) diff --git a/sharc/antenna/antenna_factory.py b/sharc/antenna/antenna_factory.py index 051e1f33..abf13069 100644 --- a/sharc/antenna/antenna_factory.py +++ b/sharc/antenna/antenna_factory.py @@ -27,7 +27,16 @@ def create_antenna( azimuth: float, elevation: float, ): - """Create and return an antenna instance based on the provided parameters, azimuth, and elevation.""" + """Create and return an antenna instance based on the provided parameters, azimuth, and elevation. + + Args: + antenna_params (ParametersAntenna): The parameters defining the antenna configuration. + azimuth (float): The azimuth angle for the antenna. + elevation (float): The elevation angle for the antenna. + oob_pattern (str, optional): Out-of-band pattern to use instead of the main pattern. + Returns: + Antenna: An instance of the appropriate Antenna subclass. + """ match antenna_params.pattern: case "OMNI": return AntennaOmni(antenna_params.gain) @@ -73,11 +82,19 @@ def create_n_antennas( n_stations: int, ): """ - Creates many antennas based on passed parameters. + Create many antennas based on passed parameters. + If antenna does not require each object to have different state, only a single antenna object will be created, and every position in the array will point to it. This is much more performant. + Args: + antenna_params (ParametersAntenna): The parameters defining the antenna configuration. + azimuth (np.ndarray | float): The azimuth angles for the antennas. + elevation (np.ndarray | float): The elevation angles for the antennas. + n_stations (int): Number of antennas to create. + Returns: + np.ndarray: An array of Antenna instances. """ antennas = np.empty((n_stations,), dtype=Antenna) assert n_stations == len(azimuth) diff --git a/sharc/mask/spectral_mask_stepped.py b/sharc/mask/spectral_mask_stepped.py index a70b60b9..0f7450b3 100644 --- a/sharc/mask/spectral_mask_stepped.py +++ b/sharc/mask/spectral_mask_stepped.py @@ -84,5 +84,3 @@ def set_mask(self, p_tx=0): print(msk.power_calc(center_f=freq + 1 * band, band=band)) print(msk.power_calc(center_f=freq + 2 * band, band=band)) print(msk.power_calc(center_f=freq + 3 * band, band=band)) - - diff --git a/sharc/parameters/imt/parameters_imt.py b/sharc/parameters/imt/parameters_imt.py index 1d007b54..5d502951 100644 --- a/sharc/parameters/imt/parameters_imt.py +++ b/sharc/parameters/imt/parameters_imt.py @@ -61,6 +61,14 @@ class ParametersBS(ParametersBase): default_factory=lambda: ParametersAntenna( pattern="ARRAY", array=ParametersAntennaImt( downtilt=0.0))) + # Flag to indicate if out-of-band antenna pattern should be used + use_oob_antenna: bool = False + # Out-of-band antenna model + oob_antenna: ParametersAntenna = field( + default_factory=lambda: ParametersAntenna( + pattern="ARRAY", array=ParametersAntennaImt( + adjacent_antenna_model="SINGLE_ELEMENT", + downtilt=0.0))) bs: ParametersBS = field(default_factory=ParametersBS) topology: ParametersImtTopology = field( @@ -108,6 +116,15 @@ class ParametersUE(ParametersBase): default_factory=lambda: ParametersAntenna( pattern="ARRAY")) + # Flag to indicate if out-of-band antenna pattern should be used + use_oob_antenna: bool = False + # Out-of-band antenna model + oob_antenna: ParametersAntenna = field( + default_factory=lambda: ParametersAntenna( + pattern="ARRAY", array=ParametersAntennaImt( + adjacent_antenna_model="SINGLE_ELEMENT", + downtilt=0.0))) + def validate(self, ctx: str): """Validate the UE antenna beamsteering range parameters.""" if self.antenna.array.horizontal_beamsteering_range != (-180., 179.9999)\ diff --git a/sharc/parameters/parameters_mss_d2d.py b/sharc/parameters/parameters_mss_d2d.py index c566269c..3fd53625 100644 --- a/sharc/parameters/parameters_mss_d2d.py +++ b/sharc/parameters/parameters_mss_d2d.py @@ -6,6 +6,7 @@ from sharc.parameters.parameters_p619 import ParametersP619 from sharc.parameters.parameters_antenna import ParametersAntenna from sharc.parameters.antenna.parameters_antenna_s1528 import ParametersAntennaS1528 +from sharc.parameters.antenna.parameters_antenna_with_freq import ParametersAntennaWithFreq @dataclass @@ -84,6 +85,16 @@ class ParametersMssD2d(ParametersBase): gain=30.0, itu_r_s_1528=ParametersAntennaS1528())) + # Flag to indicate if out-of-band antenna pattern should be used + use_oob_antenna: bool = False + + # Parameters for the out-of-band antenna pattern + oob_antenna: ParametersAntenna = field( + default_factory=lambda: ParametersAntenna( + pattern="MSS Adjacent", + gain=0.0, + mss_adjacent=ParametersAntennaWithFreq(frequency=None))) + sat_is_active_if: ParametersSelectActiveSatellite = field( default_factory=ParametersSelectActiveSatellite) @@ -181,6 +192,18 @@ def propagate_parameters(self): frequency=self.frequency, bandwidth=self.bandwidth, ) + if self.use_oob_antenna: + if self.oob_antenna.pattern not in ["MSS Adjacent"]: # only supported this pattern for now + raise ValueError( + f"ParametersMssD2d: Invalid out-of-band antenna pattern { + self.oob_antenna.pattern}. Only 'MSS Adjacent' is supported.") + + self.oob_antenna.set_external_parameters( + frequency=self.frequency, + ) + else: + self.oob_antenna = self.antenna # use the same antenna if not specified + if self.beam_positioning.service_grid.beam_radius is None: self.beam_positioning.service_grid.beam_radius = self.cell_radius diff --git a/sharc/simulation.py b/sharc/simulation.py index 45908a07..e67b4121 100644 --- a/sharc/simulation.py +++ b/sharc/simulation.py @@ -569,6 +569,14 @@ def calculate_gains( station_1_active = np.where(station_1.active)[0] station_2_active = np.where(station_2.active)[0] + # Select the antenna for in-band or out-of-band emission. + # TODO: refactor to avoid code duplication + # TODO: station_1 and station_2 naming is confusing here + if c_channel: + tx_antenna = station_1.antenna + else: + tx_antenna = station_1.oob_antenna + # Initialize variables (phi, theta, beams_idx) if (station_1.station_type is StationType.IMT_BS): if (station_2.station_type is StationType.IMT_UE): @@ -599,61 +607,53 @@ def calculate_gains( for b in range( k * self.parameters.imt.ue.k, (k + 1) * self.parameters.imt.ue.k): - gains[b, - station_2_active] = station_1.antenna[k].calculate_gain(phi_vec=phi[b, - station_2_active], - theta_vec=theta[b, - station_2_active, - ], - beams_l=np.repeat(beams_idx[b], - len(station_2_active)), - co_channel=c_channel, - off_axis_angle_vec=off_axis_angle[k, - station_2_active]) + gains[b, station_2_active] = \ + tx_antenna[k].calculate_gain( + phi_vec=phi[b, station_2_active], + theta_vec=theta[b, station_2_active], + beams_l=np.repeat(beams_idx[b], len(station_2_active)), + co_channel=c_channel, + off_axis_angle_vec=off_axis_angle[k, station_2_active]) elif station_1.station_type is StationType.IMT_UE and not station_2.is_imt_station(): off_axis_angle = station_1.get_off_axis_angle(station_2) for k in station_1_active: - gains[k, station_2_active] = station_1.antenna[k].calculate_gain( - off_axis_angle_vec=off_axis_angle[k, station_2_active], - phi_vec=phi[k, station_2_active], - theta_vec=theta[ - k, - station_2_active, - ], - beams_l=beams_idx, - co_channel=c_channel, - ) + gains[k, station_2_active] = \ + tx_antenna[k].calculate_gain( + off_axis_angle_vec=off_axis_angle[k, station_2_active], + phi_vec=phi[k, station_2_active], + theta_vec=theta[k, station_2_active,], + beams_l=beams_idx, + co_channel=c_channel,) + # RNS to non-IMT + # TODO: refactor to avoid code duplication with non-IMT to RNS elif station_1.station_type is StationType.RNS: - gains[0, station_2_active] = station_1.antenna[0].calculate_gain( + gains[0, station_2_active] = tx_antenna[0].calculate_gain( phi_vec=phi[0, station_2_active], theta_vec=theta[0, station_2_active], ) + # non-IMT to IMT elif not station_1.is_imt_station(): off_axis_angle = station_1.get_off_axis_angle(station_2) phi, theta = station_1.get_pointing_vector_to(station_2) for k in station_1_active: gains[k, station_2_active] = \ - station_1.antenna[k].calculate_gain( + tx_antenna[k].calculate_gain( off_axis_angle_vec=off_axis_angle[k, station_2_active], theta_vec=theta[k, station_2_active], - phi_vec=phi[k, station_2_active], - ) + phi_vec=phi[k, station_2_active],) else: # for IMT <-> IMT off_axis_angle = station_1.get_off_axis_angle(station_2) for k in station_1_active: - gains[k, station_2_active] = station_1.antenna[k].calculate_gain( - off_axis_angle_vec=off_axis_angle[k, station_2_active], - phi_vec=phi[k, station_2_active], - theta_vec=theta[ - k, - station_2_active, - ], - beams_l=beams_idx, - ) + gains[k, station_2_active] = \ + tx_antenna[k].calculate_gain( + off_axis_angle_vec=off_axis_angle[k, station_2_active], + phi_vec=phi[k, station_2_active], + theta_vec=theta[k, station_2_active,], + beams_l=beams_idx,) return gains def calculate_imt_tput( diff --git a/sharc/station_factory.py b/sharc/station_factory.py index de9fac81..f2b71e5f 100644 --- a/sharc/station_factory.py +++ b/sharc/station_factory.py @@ -33,7 +33,6 @@ from sharc.antenna.antenna import Antenna from sharc.antenna.antenna_factory import AntennaFactory from sharc.antenna.antenna_fss_ss import AntennaFssSs -from sharc.antenna.antenna_mss_adjacent import AntennaMSSAdjacent from sharc.antenna.antenna_omni import AntennaOmni from sharc.antenna.antenna_f699 import AntennaF699 from sharc.antenna.antenna_f1891 import AntennaF1891 @@ -58,6 +57,7 @@ from sharc.topology.topology_imt_mss_dc import TopologyImtMssDc from sharc.mask.spectral_mask_3gpp import SpectralMask3Gpp from sharc.mask.spectral_mask_mss import SpectralMaskMSS +from sharc.mask.spectral_mask_stepped import SpectralMaskStepped from sharc.support.sharc_geom import CoordinateSystem from sharc.support.sharc_utils import wrap2_180 @@ -162,6 +162,18 @@ def generate_imt_base_stations( num_bs ) + # Create out-of-band antenna patterns if specified. + # IMT Array antenna pattern has the oob model defined inside + if param.bs.use_oob_antenna and param.bs.antenna.pattern != "ARRAY": + imt_base_stations.oob_antenna = AntennaFactory.create_n_antennas( + param.bs.oob_antenna, + imt_base_stations.azimuth, + imt_base_stations.elevation, + num_bs, + ) + else: + imt_base_stations.oob_antenna = imt_base_stations.antenna + # imt_base_stations.antenna = [AntennaOmni(0) for bs in range(num_bs)] imt_base_stations.bandwidth = param.bandwidth * np.ones(num_bs) imt_base_stations.center_freq = param.frequency * np.ones(num_bs) @@ -465,6 +477,16 @@ def generate_imt_ue_outdoor( imt_ue.elevation, num_ue, ) + # Create out-of-band antenna patterns if specified. + if param.ue.use_oob_antenna and param.ue.antenna.pattern != "ARRAY": + imt_ue.oob_antenna = AntennaFactory.create_n_antennas( + param.ue.oob_antenna, + imt_ue.azimuth, + imt_ue.elevation, + num_ue, + ) + else: + imt_ue.oob_antenna = imt_ue.antenna # imt_ue.antenna = [AntennaOmni(0) for bs in range(num_ue)] imt_ue.bandwidth = param.bandwidth * np.ones(num_ue) @@ -638,12 +660,14 @@ def generate_imt_ue_indoor( imt_ue.ext_interference = -500 * np.ones(num_ue) # TODO: this piece of code works only for uplink + # FIXME: Why inddor UEs use AntennaBeamformingImt always? par = ue_param_ant.get_antenna_parameters() for i in range(num_ue): imt_ue.antenna[i] = AntennaBeamformingImt( par, imt_ue.azimuth[i], imt_ue.elevation[i], ) + imt_ue.oob_antenna = imt_ue.antenna # imt_ue.antenna = [AntennaOmni(0) for bs in range(num_ue)] imt_ue.bandwidth = param.bandwidth * np.ones(num_ue) @@ -1665,6 +1689,10 @@ def generate_mss_d2d( mss_d2d.spectral_mask = SpectralMaskMSS(params.frequency, params.bandwidth, params.spurious_emissions) + elif params.spectral_mask == "SpectralMaskStepped": + mss_d2d.spectral_mask = SpectralMaskStepped(params.frequency, + params.bandwidth, + mask_steps_dBm_mhz=[-85.6, -103.6, -113.6]) else: raise ValueError( f"Invalid or not implemented spectral mask - {params.spectral_mask}") @@ -1676,7 +1704,7 @@ def generate_mss_d2d( 1e6) + 30 ) - # Configure satellite positions in the StationManager + # Configure satellite positions in the StationManager mss_d2d.x = mss_d2d_values["sat_x"] mss_d2d.y = mss_d2d_values["sat_y"] mss_d2d.z = mss_d2d_values["sat_z"] @@ -1698,22 +1726,24 @@ def generate_mss_d2d( # Initialize satellites antennas # we need to initialize them after coordinates transformation because of # repeated state (elevation and azimuth) inside multiple transceiver - # implementation - mss_d2d.antenna = np.empty(total_satellites, dtype=AntennaS1528Leo) - if params.antenna.pattern == "ITU-R-S.1528-LEO": - antenna_pattern = AntennaS1528Leo(params.antenna.itu_r_s_1528) - elif params.antenna.pattern == "ITU-R-S.1528-Section1.2": - antenna_pattern = AntennaS1528(params.antenna.itu_r_s_1528) - elif params.antenna.pattern == "ITU-R-S.1528-Taylor": - antenna_pattern = AntennaS1528Taylor(params.antenna.itu_r_s_1528) - elif params.antenna.pattern == "MSS Adjacent": - antenna_pattern = AntennaMSSAdjacent(params.frequency) - else: - raise ValueError( - f"generate_mss_ss: Invalid antenna type: {params.antenna.pattern}") + # implementation. + mss_d2d.antenna = AntennaFactory.create_n_antennas( + params.antenna, + mss_d2d.azimuth, + mss_d2d.elevation, + mss_d2d.num_stations + ) - for i in range(mss_d2d.num_stations): - mss_d2d.antenna[i] = antenna_pattern + # Initialize OOB antennas + if params.use_oob_antenna and params.antenna.pattern != "ARRAY": + mss_d2d.oob_antenna = AntennaFactory.create_n_antennas( + params.oob_antenna, + mss_d2d.azimuth, + mss_d2d.elevation, + mss_d2d.num_stations + ) + else: + mss_d2d.oob_antenna = mss_d2d.antenna return mss_d2d # Return the configured StationManager diff --git a/sharc/station_manager.py b/sharc/station_manager.py index 9ab7a908..a78c14b2 100644 --- a/sharc/station_manager.py +++ b/sharc/station_manager.py @@ -36,6 +36,7 @@ def __init__(self, n): self.rx_interference = np.empty(n) # Rx interferece in dBW self.ext_interference = np.empty(n) # External interferece in dBW self.antenna = np.empty(n, dtype=Antenna) + self.oob_antenna = np.empty(n, dtype=Antenna) # Out-of-band antenna pattern self.bandwidth = np.empty(n) # Bandwidth in MHz self.noise_figure = np.empty(n) self.noise_temperature = np.empty(n) diff --git a/tests/parameters/parameters_for_testing.yaml b/tests/parameters/parameters_for_testing.yaml index ed61cb37..ceea2303 100644 --- a/tests/parameters/parameters_for_testing.yaml +++ b/tests/parameters/parameters_for_testing.yaml @@ -334,7 +334,6 @@ imt: # Base Station Antenna parameters: antenna: pattern: "ARRAY" - # pattern: ITU-R-S.1528-Taylor array: ########################################################################### # If normalization of M2101 should be applied for BS @@ -430,6 +429,17 @@ imt: l_t: 1.6 frequency: 2177.0 bandwidth: 6.0 + ########################################################################### + # Defines the out-of-band antenna model to be used in compatibility studies + use_oob_antenna: true + # Out-of-band antenna pattern + oob_antenna: + gain: 0.0 + pattern: MSS Adjacent + mss_adjacent: + frequency: 2170.0 + + ########################################################################### # User Equipment parameters: ue: @@ -1103,6 +1113,14 @@ mss_d2d: l_t: 1.6 frequency: 2177.0 bandwidth: 6.0 + # Flag to indicate if out-of-band antenna pattern should be used + use_oob_antenna: true + # Out-of-band antenna pattern + oob_antenna: + pattern: MSS Adjacent + mss_adjacent: + frequency: 2170.1 + # channel model, possible values are "FSPL" (free-space path loss), # "SatelliteSimple" (FSPL + 4 + clutter loss) # "P619" diff --git a/tests/parameters/test_parameters.py b/tests/parameters/test_parameters.py index 0169a47d..65866060 100644 --- a/tests/parameters/test_parameters.py +++ b/tests/parameters/test_parameters.py @@ -182,6 +182,10 @@ def test_parameters_imt(self): self.assertEqual(self.parameters.imt.bs.antenna.itu_r_s_1528.l_r, 1.6) self.assertEqual(self.parameters.imt.bs.antenna.itu_r_s_1528.l_t, 1.6) + # Check the OOB antenna pattern parameters + self.assertEqual(self.parameters.imt.bs.oob_antenna.pattern, "MSS Adjacent") + self.assertEqual(self.parameters.imt.bs.oob_antenna.mss_adjacent.frequency, 2170.0) + """Test ParametersSubarrayImt """ # testing default value not enabled @@ -645,6 +649,10 @@ def test_parametes_mss_d2d(self): self.assertEqual(self.parameters.mss_d2d.antenna.itu_r_s_1528.n_side_lobes, 2) self.assertEqual(self.parameters.mss_d2d.antenna.itu_r_s_1528.l_r, 1.6) self.assertEqual(self.parameters.mss_d2d.antenna.itu_r_s_1528.l_t, 1.6) + # Test oob antenna pattern + self.assertEqual(self.parameters.mss_d2d.use_oob_antenna, True) + self.assertEqual(self.parameters.mss_d2d.oob_antenna.pattern, 'MSS Adjacent') + self.assertEqual(self.parameters.mss_d2d.oob_antenna.mss_adjacent.frequency, 2170.1) self.assertEqual(self.parameters.mss_d2d.channel_model, 'P619') self.assertEqual( self.parameters.mss_d2d.param_p619.earth_station_alt_m, 0.0) diff --git a/tests/test_adjacent_channel.py b/tests/test_adjacent_channel.py index aca41822..fa362aad 100644 --- a/tests/test_adjacent_channel.py +++ b/tests/test_adjacent_channel.py @@ -54,7 +54,7 @@ def setUp(self): self.param.imt.bs.conducted_power = 10 self.param.imt.bs.height = 6 - self.param.imt.bs.acs = 30 + self.param.imt.bs.adjacent_ch_selectivity = 30 self.param.imt.bs.noise_figure = 7 self.param.imt.bs.ohmic_loss = 3 self.param.imt.uplink.attenuation_factor = 0.4 @@ -70,10 +70,9 @@ def setUp(self): self.param.imt.ue.p_o_pusch = -95 self.param.imt.ue.alpha = 0.8 self.param.imt.ue.p_cmax = 20 - self.param.imt.ue.conducted_power = 10 self.param.imt.ue.height = 1.5 - self.param.imt.ue.aclr = 20 - self.param.imt.ue.acs = 25 + self.param.imt.ue.adjacent_ch_leak_ratio = 20 + self.param.imt.ue.adjacent_ch_selectivity = 25 self.param.imt.ue.noise_figure = 9 self.param.imt.ue.ohmic_loss = 3 self.param.imt.ue.body_loss = 4 @@ -81,48 +80,10 @@ def setUp(self): self.param.imt.downlink.sinr_min = -10 self.param.imt.downlink.sinr_max = 30 self.param.imt.channel_model = "FSPL" - # probability of line-of-sight (not for FSPL) - self.param.imt.line_of_sight_prob = 0.75 self.param.imt.shadowing = False self.param.imt.noise_temperature = 290 - self.param.imt.bs.antenna.type = "ARRAY" - self.param.imt.ue.antenna.type = "ARRAY" - - self.param.imt.bs.antenna.array.adjacent_antenna_model = "SINGLE_ELEMENT" - self.param.imt.ue.antenna.array.adjacent_antenna_model = "SINGLE_ELEMENT" - self.param.imt.bs.antenna.array.normalization = False - self.param.imt.ue.antenna.array.normalization = False - - self.param.imt.bs.antenna.array.normalization_file = None - self.param.imt.bs.antenna.array.element_pattern = "M2101" - self.param.imt.bs.antenna.array.minimum_array_gain = -200 - self.param.imt.bs.antenna.array.element_max_g = 10 - self.param.imt.bs.antenna.array.element_phi_3db = 80 - self.param.imt.bs.antenna.array.element_theta_3db = 80 - self.param.imt.bs.antenna.array.element_am = 25 - self.param.imt.bs.antenna.array.element_sla_v = 25 - self.param.imt.bs.antenna.array.n_rows = 16 - self.param.imt.bs.antenna.array.n_columns = 16 - self.param.imt.bs.antenna.array.element_horiz_spacing = 1 - self.param.imt.bs.antenna.array.element_vert_spacing = 1 - self.param.imt.bs.antenna.array.multiplication_factor = 12 - self.param.imt.bs.antenna.array.downtilt = 10 - - self.param.imt.ue.antenna.array.element_pattern = "M2101" - self.param.imt.ue.antenna.array.minimum_array_gain = -200 - self.param.imt.ue.antenna.array.normalization_file = None - self.param.imt.ue.antenna.array.element_max_g = 5 - self.param.imt.ue.antenna.array.element_phi_3db = 65 - self.param.imt.ue.antenna.array.element_theta_3db = 65 - self.param.imt.ue.antenna.array.element_am = 30 - self.param.imt.ue.antenna.array.element_sla_v = 30 - self.param.imt.ue.antenna.array.n_rows = 2 - self.param.imt.ue.antenna.array.n_columns = 1 - self.param.imt.ue.antenna.array.element_horiz_spacing = 0.5 - self.param.imt.ue.antenna.array.element_vert_spacing = 0.5 - self.param.imt.ue.antenna.array.multiplication_factor = 12 - + # FSS-SS System Parameters self.param.fss_ss.frequency = 5000 self.param.fss_ss.bandwidth = 100 self.param.fss_ss.altitude = 35786000 @@ -133,15 +94,7 @@ def setUp(self): self.param.fss_ss.noise_temperature = 950 self.param.fss_ss.antenna_gain = 51 self.param.fss_ss.antenna_pattern = "OMNI" - self.param.fss_ss.imt_altitude = 1000 - self.param.fss_ss.imt_lat_deg = -23.5629739 - self.param.fss_ss.imt_long_diff_deg = (-46.6555132 - 75) self.param.fss_ss.channel_model = "FSPL" - self.param.fss_ss.line_of_sight_prob = 0.01 - self.param.fss_ss.surf_water_vapour_density = 7.5 - self.param.fss_ss.specific_gaseous_att = 0.1 - self.param.fss_ss.time_ratio = 0.5 - self.param.fss_ss.antenna_l_s = -20 self.param.fss_ss.adjacent_ch_reception = "ACS" self.param.fss_ss.adjacent_ch_selectivity = 46 self.param.fss_ss.polarization_loss = 3.0 @@ -167,6 +120,7 @@ def test_simulation_2bs_4ue_downlink(self): random_number_gen, ) self.simulation.bs.antenna = np.array([AntennaOmni(1), AntennaOmni(2)]) + self.simulation.bs.oob_antenna = self.simulation.bs.antenna self.simulation.bs.active = np.ones(2, dtype=bool) self.simulation.ue = StationFactory.generate_imt_ue( @@ -180,6 +134,7 @@ def test_simulation_2bs_4ue_downlink(self): self.simulation.ue.antenna = np.array( [AntennaOmni(10), AntennaOmni(11), AntennaOmni(22), AntennaOmni(23)], ) + self.simulation.ue.oob_antenna = self.simulation.ue.antenna self.simulation.ue.active = np.ones(4, dtype=bool) # test connection method @@ -304,7 +259,9 @@ def test_simulation_2bs_4ue_uplink(self): self.simulation.topology, random_number_gen, ) + # FIXME: We should not set the oob_antenna manually self.simulation.bs.antenna = np.array([AntennaOmni(1), AntennaOmni(2)]) + self.simulation.bs.oob_antenna = self.simulation.bs.antenna self.simulation.bs.active = np.ones(2, dtype=bool) self.simulation.ue = StationFactory.generate_imt_ue( @@ -318,6 +275,7 @@ def test_simulation_2bs_4ue_uplink(self): self.simulation.ue.antenna = np.array( [AntennaOmni(10), AntennaOmni(11), AntennaOmni(22), AntennaOmni(23)], ) + self.simulation.ue.oob_antenna = self.simulation.ue.antenna self.simulation.ue.active = np.ones(4, dtype=bool) # test connection method diff --git a/tests/test_simulation_downlink_tvro.py b/tests/test_simulation_downlink_tvro.py index 216181a9..9af38bac 100644 --- a/tests/test_simulation_downlink_tvro.py +++ b/tests/test_simulation_downlink_tvro.py @@ -78,7 +78,7 @@ def setUp(self): self.param.imt.shadowing = False self.param.imt.noise_temperature = 290 - self.param.imt.bs.antenna.type = "ARRAY" + self.param.imt.bs.antenna.pattern = "ARRAY" self.param.imt.bs.antenna.array.adjacent_antenna_model = "BEAMFORMING" self.param.imt.ue.antenna.array.adjacent_antenna_model = "BEAMFORMING" self.param.imt.bs.antenna.array.normalization = False @@ -97,7 +97,9 @@ def setUp(self): self.param.imt.bs.antenna.array.multiplication_factor = 12 self.param.imt.bs.antenna.array.downtilt = 10 - self.param.imt.ue.antenna.type = "ARRAY" + self.param.imt.bs.oob_antenna = self.param.imt.bs.antenna + + self.param.imt.ue.antenna.pattern = "ARRAY" self.param.imt.ue.antenna.array.element_pattern = "FIXED" self.param.imt.ue.antenna.array.normalization = False self.param.imt.ue.antenna.array.normalization_file = None @@ -113,6 +115,8 @@ def setUp(self): self.param.imt.ue.antenna.array.element_vert_spacing = 0.5 self.param.imt.ue.antenna.array.multiplication_factor = 12 + self.param.imt.ue.oob_antenna = self.param.imt.ue.antenna + self.param.fss_es.location = "FIXED" self.param.fss_es.x = 100 self.param.fss_es.y = 0 diff --git a/tests/test_station_factory.py b/tests/test_station_factory.py index 5b212d30..6c462151 100644 --- a/tests/test_station_factory.py +++ b/tests/test_station_factory.py @@ -12,8 +12,10 @@ from sharc.parameters.imt.parameters_imt import ParametersImt from sharc.station_factory import StationFactory from sharc.topology.topology_ntn import TopologyNTN +from sharc.topology.topology_single_base_station import TopologySingleBaseStation from sharc.parameters.parameters_single_space_station import ParametersSingleSpaceStation from sharc.station_manager import StationManager +from sharc.antenna.antenna_mss_adjacent import AntennaMSSAdjacent from sharc.satellite.ngso.constants import EARTH_RADIUS_M @@ -27,6 +29,69 @@ def setUp(self): def test_generate_imt_base_stations(self): """Test IMT base station generation (placeholder).""" + def test_generate_imt_base_stations_oob_antennas(self): + """Test IMT base station generation with and without out-of-band antennas.""" + rng = np.random.RandomState(42) + param_imt = ParametersImt() + + # First test with ARRAY antenna pattern. The oob antenna should be the same object. + param_imt.bs.antenna.pattern = "ARRAY" + param_imt.bs.use_oob_antenna = True + + param_imt.topology.type = "SINGLE_BS" + param_imt.topology.single_bs.num_clusters = 1 + param_imt.topology.single_bs.intersite_distance = 500 + param_imt.topology.single_bs.cell_radius = 500 + param_imt.topology.single_bs.azimuth = "random" + + param_imt.validate("station factory test") + + single_bs_topology = TopologySingleBaseStation( + param_imt.topology.single_bs.cell_radius, + param_imt.topology.single_bs.num_clusters, + param_imt.topology.single_bs.azimuth, + ) + + single_bs_topology.calculate_coordinates() + + imt_bs = StationFactory.generate_imt_base_stations( + param_imt, param_imt.bs.antenna.array, single_bs_topology, rng) + + # When the in-band antenna is ARRAY, the oob antenna should be the same object + self.assertIs(imt_bs.oob_antenna, imt_bs.antenna) # both should point to the same list + + # What if the user sets a non-ARRAY oob-antenna pattern but the in-band is ARRAY? + param_imt.bs.oob_antenna.pattern = "MSS Adjacent" + param_imt.bs.oob_antenna.gain = 0.0 + param_imt.bs.oob_antenna.mss_adjacent.frequency = 2000.0 + param_imt.validate("station factory test 2") + imt_bs = StationFactory.generate_imt_base_stations( + param_imt, param_imt.bs.antenna.array, single_bs_topology, rng) + # When the in-band antenna is ARRAY, the oob antenna should be the same object no matter what + self.assertIs(imt_bs.oob_antenna, imt_bs.antenna) # both should point to the same list + + # Now test with non-ARRAY antenna pattern. The oob antenna should be a different object. + # Re-create the imt_bs with a non-ARRAY oob-antenna pattern + param_imt.bs.use_oob_antenna = True + param_imt.bs.antenna.gain = 30.0 + param_imt.bs.antenna.pattern = "ITU-R-S.1528-Taylor" + param_imt.bs.antenna.itu_r_s_1528.frequency = 2000.0 + param_imt.bs.antenna.itu_r_s_1528.bandwidth = 5.0 + param_imt.bs.antenna.itu_r_s_1528.slr = 20.0 + param_imt.bs.antenna.itu_r_s_1528.n_side_lobes = 2 + param_imt.bs.oob_antenna.pattern = "MSS Adjacent" + param_imt.bs.oob_antenna.gain = 0.0 + param_imt.bs.oob_antenna.mss_adjacent.frequency = 2000.0 + param_imt.validate("station factory test 2") + + imt_bs = StationFactory.generate_imt_base_stations( + param_imt, param_imt.bs.antenna.array, single_bs_topology, rng) + + # When the in-band antenna is not ARRAY, the oob antenna should be a different object + self.assertIsNot(imt_bs.oob_antenna, imt_bs.antenna) + for oob_antenna in imt_bs.oob_antenna: + self.assertIsInstance(oob_antenna, AntennaMSSAdjacent) + def test_generate_imt_base_stations_ntn(self): """Test for IMT-NTN space station generation.""" # seed = 100 # Unused variable removed diff --git a/tests/test_station_factory_ngso.py b/tests/test_station_factory_ngso.py index a34ef349..a3438ea9 100644 --- a/tests/test_station_factory_ngso.py +++ b/tests/test_station_factory_ngso.py @@ -3,6 +3,7 @@ from sharc.support.enumerations import StationType from sharc.station_factory import StationFactory from sharc.station_manager import StationManager +from sharc.antenna.antenna_mss_adjacent import AntennaMSSAdjacent from sharc.support.sharc_geom import CoordinateSystem, lla2ecef import numpy as np @@ -64,6 +65,9 @@ def setUp(self): self.param.antenna.itu_r_s_1528.l_r = 1.6 self.param.antenna.itu_r_s_1528.l_t = 1.6 + self.param.propagate_parameters() + self.param.validate("MSS_D2D_Test") + # Creating an IMT topology # imt_topology = TopologySingleBaseStation( # cell_radius=500, @@ -175,6 +179,32 @@ def test_satellite_coordinate_reversing(self): ngso_original_coord.elevation, atol=1e-500) + def test_ngso_oob_antenna(self): + """Test that out-of-band antenna patterns are created correctly for NGSO stations.""" + rng = np.random.RandomState(seed=self.seed) + + self.param.use_oob_antenna = False + self.param.validate("oob_antenna_test") + + ngso_manager = StationFactory.generate_mss_d2d(self.param, rng, self.coord_sys) + + # If oob_antenna is disabled, both antennas should point to the same object + self.assertIs(ngso_manager.oob_antenna, ngso_manager.antenna) + + self.param.use_oob_antenna = True + self.param.oob_antenna.pattern = "MSS Adjacent" + self.param.oob_antenna.gain = 0.0 + self.param.propagate_parameters() + self.param.validate("oob_antenna_test") + + ngso_manager = StationFactory.generate_mss_d2d(self.param, rng, self.coord_sys) + + # the oob_antenna should be a different object now + self.assertIsNot(ngso_manager.oob_antenna, ngso_manager.antenna) + for a in ngso_manager.oob_antenna: + self.assertIsInstance(a, AntennaMSSAdjacent) + self.assertEqual(a.frequency, self.param.frequency) + if __name__ == '__main__': unittest.main() From 5cc327f2b754a7aa353a87ecc5d566c0730fd8a0 Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Fri, 21 Nov 2025 10:59:12 -0300 Subject: [PATCH 03/13] update(system): Updated the Stepped mask to the MSS-D2D system --- sharc/mask/spectral_mask_stepped.py | 1 - sharc/parameters/parameters_mss_d2d.py | 24 ++++++++++++++++++-- sharc/station_factory.py | 4 ++-- tests/parameters/parameters_for_testing.yaml | 2 ++ tests/parameters/test_parameters.py | 1 + tests/test_station_factory_ngso.py | 18 +++++++++++++++ 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/sharc/mask/spectral_mask_stepped.py b/sharc/mask/spectral_mask_stepped.py index 0f7450b3..0c8092d4 100644 --- a/sharc/mask/spectral_mask_stepped.py +++ b/sharc/mask/spectral_mask_stepped.py @@ -57,7 +57,6 @@ def set_mask(self, p_tx=0): spourious_emissions = -30.0 # dBm/MHz mask_steps = [-10, -15, -20] # dBm/MHz - print(mask_steps) mask_steps = np.concatenate([mask_steps, [-30]]) # dBm/MHz # Create mask diff --git a/sharc/parameters/parameters_mss_d2d.py b/sharc/parameters/parameters_mss_d2d.py index 3fd53625..3f6ec56c 100644 --- a/sharc/parameters/parameters_mss_d2d.py +++ b/sharc/parameters/parameters_mss_d2d.py @@ -1,4 +1,5 @@ import numpy as np +import typing from dataclasses import dataclass, field, asdict from sharc.parameters.parameters_base import ParametersBase from sharc.parameters.parameters_orbit import ParametersOrbit @@ -50,7 +51,14 @@ class ParametersMssD2d(ParametersBase): adjacent_ch_emissions: str = "OFF" # Transmitter spectral mask - spectral_mask: str = "MSS" + spectral_mask: typing.Literal[ + "IMT-2020", + "3GPP E-UTRA", + "MSS", + "STEPPED"] = "MSS" + + # Spectral mask steps in dB for STEPPED mask + spectral_mask_steps: tuple[float | int, float | int] = None # Out-of-band spurious emissions in dB/MHz spurious_emissions: float = -13.0 @@ -167,7 +175,7 @@ def validate(self, ctx): self.adjacent_ch_emissions}""") if self.spectral_mask.upper() not in [ - "IMT-2020", "3GPP E-UTRA", "MSS"]: + "IMT-2020", "3GPP E-UTRA", "MSS", "STEPPED"]: raise ValueError( f"""ParametersMssD2d: Inavlid Spectral Mask Name { self.spectral_mask}""") @@ -182,6 +190,18 @@ def validate(self, ctx): raise ValueError( f"{ctx}.beams_load_factor must be in interval [0.0, 1.0]") + if self.spectral_mask.upper() == "STEPPED": + if self.spectral_mask_steps is None: + raise ValueError( + f"ParametersMssD2d: spectral_mask_steps must be defined for STEPPED mask.") + if len(self.spectral_mask_steps) < 1: + raise ValueError( + f"ParametersMssD2d: spectral_mask_steps must have at least one step defined.") + for step in self.spectral_mask_steps: + if not isinstance(step, (int, float)): + raise ValueError( + f"ParametersMssD2d: spectral_mask_steps must contain only numeric values.") + super().validate(ctx) def propagate_parameters(self): diff --git a/sharc/station_factory.py b/sharc/station_factory.py index f2b71e5f..6413410b 100644 --- a/sharc/station_factory.py +++ b/sharc/station_factory.py @@ -1689,10 +1689,10 @@ def generate_mss_d2d( mss_d2d.spectral_mask = SpectralMaskMSS(params.frequency, params.bandwidth, params.spurious_emissions) - elif params.spectral_mask == "SpectralMaskStepped": + elif params.spectral_mask == "STEPPED": mss_d2d.spectral_mask = SpectralMaskStepped(params.frequency, params.bandwidth, - mask_steps_dBm_mhz=[-85.6, -103.6, -113.6]) + mask_steps_dBm_mhz=list(params.spectral_mask_steps)) else: raise ValueError( f"Invalid or not implemented spectral mask - {params.spectral_mask}") diff --git a/tests/parameters/parameters_for_testing.yaml b/tests/parameters/parameters_for_testing.yaml index ceea2303..a55ef5d3 100644 --- a/tests/parameters/parameters_for_testing.yaml +++ b/tests/parameters/parameters_for_testing.yaml @@ -1004,6 +1004,8 @@ mss_d2d: tx_power_density: -30 # Number of sectors num_sectors: 19 + # Spectral mask steps for mask STEPPED + spectral_mask_steps: !!python/tuple [-10., -15., -20.] beam_positioning: # type may be one of # "ANGLE_FROM_SUBSATELLITE", "ANGLE_AND_DISTANCE_FROM_SUBSATELLITE", diff --git a/tests/parameters/test_parameters.py b/tests/parameters/test_parameters.py index 65866060..faec76ec 100644 --- a/tests/parameters/test_parameters.py +++ b/tests/parameters/test_parameters.py @@ -637,6 +637,7 @@ def test_parametes_mss_d2d(self): self.assertEqual(self.parameters.mss_d2d.cell_radius, 19001) self.assertEqual(self.parameters.mss_d2d.beam_radius, 19001) self.assertEqual(self.parameters.mss_d2d.tx_power_density, -30) + self.assertEqual(self.parameters.mss_d2d.spectral_mask_steps, (-10., -15., -20.)) self.assertEqual(self.parameters.mss_d2d.num_sectors, 19) self.assertEqual( self.parameters.mss_d2d.antenna.pattern, diff --git a/tests/test_station_factory_ngso.py b/tests/test_station_factory_ngso.py index a3438ea9..9d44932f 100644 --- a/tests/test_station_factory_ngso.py +++ b/tests/test_station_factory_ngso.py @@ -205,6 +205,24 @@ def test_ngso_oob_antenna(self): self.assertIsInstance(a, AntennaMSSAdjacent) self.assertEqual(a.frequency, self.param.frequency) + def test_ngso_spectral_mask_stepped(self): + """Test that NGSO stations use the STEPPED spectral mask when specified.""" + rng = np.random.RandomState(seed=self.seed) + + self.param.spectral_mask = "STEPPED" + self.param.spectral_mask_steps = (-10., -15., -20.) + self.param.propagate_parameters() + self.param.validate("spectral_mask_stepped_test") + + ngso_manager = StationFactory.generate_mss_d2d(self.param, rng, self.coord_sys) + + # Check that all stations have the correct spectral mask type + self.assertEqual(ngso_manager.spectral_mask.__class__.__name__, "SpectralMaskStepped") + self.assertEqual( + ngso_manager.spectral_mask.mask_steps_dBm_mhz, + list(self.param.spectral_mask_steps) + ) + if __name__ == '__main__': unittest.main() From c42e140f1fa4d0bff5036616083f6fa83097f513 Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Mon, 24 Nov 2025 08:55:50 -0300 Subject: [PATCH 04/13] fix(simulation): Added the missing "co_channel" parameter when calculating SYS to IMT coupling loss. --- sharc/simulation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sharc/simulation.py b/sharc/simulation.py index e67b4121..67f45032 100644 --- a/sharc/simulation.py +++ b/sharc/simulation.py @@ -305,7 +305,7 @@ def calculate_coupling_loss_system_imt( # system's station if imt_station.station_type is StationType.IMT_UE: # define antenna gains - gain_sys_to_imt = self.calculate_gains(system_station, imt_station) + gain_sys_to_imt = self.calculate_gains(system_station, imt_station, is_co_channel) gain_imt_to_sys = np.transpose( self.calculate_gains( imt_station, @@ -318,7 +318,7 @@ def calculate_coupling_loss_system_imt( # define antenna gains # repeat for each BS beam gain_sys_to_imt = np.repeat( - self.calculate_gains(system_station, imt_station), + self.calculate_gains(system_station, imt_station, is_co_channel), self.parameters.imt.ue.k, 1, ) gain_imt_to_sys = np.transpose( From 8b480f9992d47039f36909a945e62a9272fa2694 Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Mon, 24 Nov 2025 18:42:26 -0300 Subject: [PATCH 05/13] update(simulation): Added sys to imt adjacent channel antenna gain to the results. --- sharc/post_processor.py | 8 ++++++++ sharc/results.py | 2 ++ sharc/simulation.py | 6 +++--- sharc/simulation_downlink.py | 4 ++++ sharc/simulation_uplink.py | 3 +++ 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/sharc/post_processor.py b/sharc/post_processor.py index 9a8f6743..cf9ec6d6 100644 --- a/sharc/post_processor.py +++ b/sharc/post_processor.py @@ -179,10 +179,18 @@ class PostProcessor: "x_label": "Antenna gain [dBi]", "title": "[SYS] system antenna gain towards IMT stations", }, + "system_imt_antenna_gain_adjacent": { + "x_label": "Antenna gain [dBi]", + "title": "[SYS] system adjacent antenna gain towards IMT stations", + }, "imt_system_antenna_gain": { "x_label": "Antenna gain [dBi]", "title": "[IMT] IMT station antenna gain towards system", }, + "imt_system_antenna_gain_adjacnet": { + "x_label": "Antenna gain [dBi]", + "title": "[IMT] IMT station adjacent antenna gain towards system", + }, "imt_system_path_loss": { "x_label": "Path Loss [dB]", "title": "[SYS] IMT to system path loss", diff --git a/sharc/results.py b/sharc/results.py index 92bd005d..3a9426b3 100644 --- a/sharc/results.py +++ b/sharc/results.py @@ -56,6 +56,8 @@ def __init__(self): self.imt_system_antenna_gain = SampleList() # Antenna gain [dBi] self.imt_system_antenna_gain_adjacent = SampleList() + # Antenna gain [dBi] - antenna gain of adjacent channel emissions + self.system_imt_antenna_gain_adjacent = SampleList() # Path Loss [dB] self.imt_system_path_loss = SampleList() diff --git a/sharc/simulation.py b/sharc/simulation.py index 67f45032..0135efdc 100644 --- a/sharc/simulation.py +++ b/sharc/simulation.py @@ -373,12 +373,12 @@ def calculate_coupling_loss_system_imt( path_loss, self.parameters.imt.ue.k, 1, ) - self.system_imt_antenna_gain = gain_sys_to_imt - if is_co_channel: + self.system_imt_antenna_gain = gain_sys_to_imt self.imt_system_antenna_gain = gain_imt_to_sys else: self.imt_system_antenna_gain_adjacent = gain_imt_to_sys + self.system_imt_antenna_gain_adjacent = gain_sys_to_imt # calculate coupling loss coupling_loss = \ @@ -571,7 +571,7 @@ def calculate_gains( # Select the antenna for in-band or out-of-band emission. # TODO: refactor to avoid code duplication - # TODO: station_1 and station_2 naming is confusing here + # TODO: station_1 and station_2 naming is confusing here. We are assuming that the emitting station is station_1 if c_channel: tx_antenna = station_1.antenna else: diff --git a/sharc/simulation_downlink.py b/sharc/simulation_downlink.py index 04a24109..ee9bfd00 100644 --- a/sharc/simulation_downlink.py +++ b/sharc/simulation_downlink.py @@ -665,6 +665,10 @@ def collect_results(self, write_to_file: bool, snapshot_number: int): self.results.imt_system_antenna_gain_adjacent.extend( self.imt_system_antenna_gain_adjacent[sys_active[:, np.newaxis], ue].flatten(), ) + if len(self.system_imt_antenna_gain_adjacent): + self.results.system_imt_antenna_gain_adjacent.extend( + self.system_imt_antenna_gain_adjacent[sys_active[:, np.newaxis], ue].flatten(), + ) self.results.imt_system_path_loss.extend( self.imt_system_path_loss[sys_active[:, np.newaxis], ue].flatten(), ) diff --git a/sharc/simulation_uplink.py b/sharc/simulation_uplink.py index 7d547f11..74ec41f2 100644 --- a/sharc/simulation_uplink.py +++ b/sharc/simulation_uplink.py @@ -561,6 +561,9 @@ def collect_results(self, write_to_file: bool, snapshot_number: int): if len(self.imt_system_antenna_gain_adjacent): self.results.imt_system_antenna_gain_adjacent.extend( self.imt_system_antenna_gain_adjacent[np.ix_(sys_active, active_beams)].flatten(),) + if len(self.system_imt_antenna_gain_adjacent): + self.results.system_imt_antenna_gain_adjacent.extend( + self.system_imt_antenna_gain_adjacent[np.ix_(sys_active, active_beams)].flatten(),) self.results.imt_system_path_loss.extend( self.imt_system_path_loss[np.ix_(sys_active, active_beams)].flatten(), ) From a49ca9b8bdebf6ff56688022fd3912e7c1fbe180 Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Mon, 24 Nov 2025 19:44:29 -0300 Subject: [PATCH 06/13] fix(simulation): Added the adjacent coupling loss the tx oob emission. --- sharc/simulation_downlink.py | 8 ++++---- sharc/simulation_uplink.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sharc/simulation_downlink.py b/sharc/simulation_downlink.py index ee9bfd00..4367cb7f 100644 --- a/sharc/simulation_downlink.py +++ b/sharc/simulation_downlink.py @@ -201,6 +201,8 @@ def calculate_sinr_ext(self): is_co_channel=True, ) if self.adjacent_channel: + # Calculate coupling loss for adjacent channel case - the antenna gain model may be different for the + # adjacent channel case. self.coupling_loss_imt_system_adjacent = \ self.calculate_coupling_loss_system_imt( self.system, @@ -344,7 +346,7 @@ def calculate_sinr_ext(self): self.param_system.adjacent_ch_emissions}") if self.param_system.adjacent_ch_emissions != "OFF": - tx_oob = tx_oob[:, np.newaxis] - self.coupling_loss_imt_system[ue, :][:, active_sys] + tx_oob = tx_oob[:, np.newaxis] - self.coupling_loss_imt_system_adjacent[ue, :][:, active_sys] rx_oob = rx_oob[:, np.newaxis] - self.coupling_loss_imt_system_adjacent[ue, :][:, active_sys] @@ -355,9 +357,7 @@ def calculate_sinr_ext(self): 10 ** (0.1 * tx_oob) + 10 ** (0.1 * rx_oob) ) # Total external interference into the UE in dBm - ue_ext_int = 10 * np.log10(np.power(10, - 0.1 * in_band_interf_power) + np.power(10, - 0.1 * oob_power)) + ue_ext_int = 10 * np.log10(np.power(10, 0.1 * in_band_interf_power) + np.power(10, 0.1 * oob_power)) # Sum all the interferers for each UE self.ue.ext_interference[ue] = 10 * \ diff --git a/sharc/simulation_uplink.py b/sharc/simulation_uplink.py index 74ec41f2..04c84973 100644 --- a/sharc/simulation_uplink.py +++ b/sharc/simulation_uplink.py @@ -306,7 +306,7 @@ def calculate_sinr_ext(self): if self.param_system.adjacent_ch_emissions != "OFF": # oob for system is inband for IMT - tx_oob = tx_oob[:, np.newaxis] - self.coupling_loss_imt_system[active_beams, :][:, sys_active] + tx_oob = tx_oob[:, np.newaxis] - self.coupling_loss_imt_system_adjacent[active_beams, :][:, sys_active] # oob for IMT rx_oob = rx_oob[:, np.newaxis] - self.coupling_loss_imt_system_adjacent[active_beams, :][:, sys_active] From f10fbafefef6720327e9304508c901c17cfe33fe Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Tue, 25 Nov 2025 08:41:57 -0300 Subject: [PATCH 07/13] update(antenna): Removed the frequency factor from Mss Adjacent antenna pattern. * The frequency factor should embedded into the Stepped Mask not to the antenna. --- sharc/antenna/antenna_mss_adjacent.py | 84 ++++----------------------- 1 file changed, 11 insertions(+), 73 deletions(-) diff --git a/sharc/antenna/antenna_mss_adjacent.py b/sharc/antenna/antenna_mss_adjacent.py index 840a4f92..a1d19c4c 100644 --- a/sharc/antenna/antenna_mss_adjacent.py +++ b/sharc/antenna/antenna_mss_adjacent.py @@ -15,17 +15,12 @@ class AntennaMSSAdjacent(Antenna): mess the EIRP up. """ - def __init__(self, frequency_MHz: float): + def __init__(self,): """ Initialize the AntennaMSSAdjacent class. - Parameters - ---------- - frequency_MHz : float - Frequency in MHz for the antenna model. """ super().__init__() - self.frequency = frequency_MHz def calculate_gain(self, *args, **kwargs) -> np.array: """ @@ -44,9 +39,7 @@ def calculate_gain(self, *args, **kwargs) -> np.array: Calculated antenna gain values. """ theta_rad = np.deg2rad(np.absolute(kwargs["off_axis_angle_vec"])) - theta_rad = np.minimum(theta_rad, np.pi / 2 - 1e-4) - return 20 * np.log10(self.frequency / 2e3) + 10 * \ - np.log10(np.cos(theta_rad)) + return 10 * np.log10(np.cos(theta_rad) + 1e-5) if __name__ == '__main__': @@ -54,69 +47,14 @@ def calculate_gain(self, *args, **kwargs) -> np.array: frequency = 2170 theta = np.linspace(0.01, 90, num=100000) - antenna = AntennaMSSAdjacent(frequency) gain = antenna.calculate_gain(off_axis_angle_vec=theta) - - def create_plot_adj_channel(frequency, theta, chn, ax=None): - """ - Create a plot for the adjacent channel mask for MSS antennas. - - Parameters - ---------- - frequency : float - Frequency in MHz. - theta : np.array - Array of off-axis angles in degrees. - chn : int - Channel number (-1 or 1). - ax : matplotlib.axes.Axes, optional - Matplotlib axis to plot on. If None, a new figure and axis are created. - - Returns - ------- - matplotlib.axes.Axes - The axis with the plot. - """ - tranls = { - -1: 0, - 1: -55.6, - 2: -73.6, - 3: -83.6, - } - if chn == -1: - ylabel = "Gain [dB]" - else: - ylabel = f"EIRP_{chn}" - - if ax is None: - fig = plt.figure(facecolor='w', edgecolor='k') - ax1 = fig.add_subplot() - else: - ax1 = ax - - ax1.plot(theta, gain + tranls[chn]) - ax1.grid(True) - ax1.set_xlabel(r"Off-axis angle $\theta$ [deg]") - ax1.set_ylabel(ylabel) - - label = f"$f = {frequency / 1e3: .3f}$ GHz" - if chn != -1: - label += f", channel {chn}" - # ax1.semilogx( - ax1.plot( - theta, gain, - label=label, - ) - ax1.legend(loc="lower left") - ax1.set_xlim((theta[0], theta[-1])) - ax1.set_ylim((-80, 10)) - - return ax1 - - create_plot_adj_channel(frequency, theta, -1) - plt.show() - ax = create_plot_adj_channel(frequency, theta, 1) - create_plot_adj_channel(frequency, theta, 2, ax) - create_plot_adj_channel(frequency, theta, 3, ax) - plt.show() + fig = plt.figure(facecolor='w', edgecolor='k') + ax = fig.add_subplot() + ax.plot(theta, gain) + ax.grid(True) + ax.set_xlabel(r"Off-axis angle $\theta$ [deg]") + ax.set_ylabel("Antenna Gain [dBi]") + ax.set_xlim((theta[0], theta[-1])) + ax.set_ylim((-80, 10)) + plt.show() \ No newline at end of file From 3c0605a3d83509098717b012be1011462d85a049 Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Tue, 25 Nov 2025 08:51:59 -0300 Subject: [PATCH 08/13] refactor(antenna): Renamed MSSAdjacent antenna to Antenna Element Cosine. --- ..._adjacent.py => antenna_element_cosine.py} | 21 ++++++++----------- sharc/antenna/antenna_factory.py | 7 +++---- sharc/antenna/antenna_mss_hibleo_x_ue.py | 2 +- sharc/parameters/parameters_antenna.py | 6 +++--- sharc/parameters/parameters_mss_d2d.py | 6 +++--- sharc/station_factory.py | 6 ++++++ tests/e2e/test_integration_imt_victim.py | 2 ++ tests/e2e/test_integration_sys_victim.py | 2 ++ tests/parameters/parameters_for_testing.yaml | 4 ++-- tests/parameters/test_parameters.py | 4 ++-- tests/test_station_factory.py | 8 +++---- tests/test_station_factory_ngso.py | 7 +++---- 12 files changed, 40 insertions(+), 35 deletions(-) rename sharc/antenna/{antenna_mss_adjacent.py => antenna_element_cosine.py} (67%) diff --git a/sharc/antenna/antenna_mss_adjacent.py b/sharc/antenna/antenna_element_cosine.py similarity index 67% rename from sharc/antenna/antenna_mss_adjacent.py rename to sharc/antenna/antenna_element_cosine.py index a1d19c4c..70a73bae 100644 --- a/sharc/antenna/antenna_mss_adjacent.py +++ b/sharc/antenna/antenna_element_cosine.py @@ -1,23 +1,20 @@ # -*- coding: utf-8 -*- -"""Antenna model for MSS adjacent channel systems.""" +"""Antenna model for Cosine Antenna channel systems.""" from sharc.antenna.antenna import Antenna import numpy as np -class AntennaMSSAdjacent(Antenna): +class AntennaElementCosine(Antenna): """ - Implements part of EIRP mask for MSS-DC systems given in document WPGC - as defined in the WP4C Working Document 4C/356-E - You can choose the adjacent channel by choosing the tx power - You need to also make sure ACLR_db = 0, otherwise SHARC's implementation will - mess the EIRP up. + Implements antenna part of EIRP mask for MSS-DC systems + as defined in the WP4C Working Document 4C/356-E. """ def __init__(self,): """ - Initialize the AntennaMSSAdjacent class. + Initialize the AntennaElementCosine class. """ super().__init__() @@ -45,16 +42,16 @@ def calculate_gain(self, *args, **kwargs) -> np.array: if __name__ == '__main__': import matplotlib.pyplot as plt - frequency = 2170 - theta = np.linspace(0.01, 90, num=100000) - antenna = AntennaMSSAdjacent(frequency) + theta = np.linspace(0.01, 90, num=1000) + antenna = AntennaElementCosine() gain = antenna.calculate_gain(off_axis_angle_vec=theta) fig = plt.figure(facecolor='w', edgecolor='k') ax = fig.add_subplot() ax.plot(theta, gain) ax.grid(True) + ax.set_title("Antenna Element Cosine Pattern") ax.set_xlabel(r"Off-axis angle $\theta$ [deg]") ax.set_ylabel("Antenna Gain [dBi]") ax.set_xlim((theta[0], theta[-1])) ax.set_ylim((-80, 10)) - plt.show() \ No newline at end of file + plt.show() diff --git a/sharc/antenna/antenna_factory.py b/sharc/antenna/antenna_factory.py index abf13069..56ba5b96 100644 --- a/sharc/antenna/antenna_factory.py +++ b/sharc/antenna/antenna_factory.py @@ -3,7 +3,7 @@ from sharc.parameters.parameters_antenna import ParametersAntenna from sharc.antenna.antenna import Antenna -from sharc.antenna.antenna_mss_adjacent import AntennaMSSAdjacent +from sharc.antenna.antenna_element_cosine import AntennaElementCosine from sharc.antenna.antenna_omni import AntennaOmni from sharc.antenna.antenna_mss_hibleo_x_ue import AntennaMssHibleoXUe from sharc.antenna.antenna_f699 import AntennaF699 @@ -60,9 +60,8 @@ def create_antenna( return AntennaS1855(antenna_params.itu_r_s_1855) case "ITU-R Reg. RR. Appendice 7 Annex 3": return AntennaReg_RR_A7_3(antenna_params.itu_reg_rr_a7_3) - case "MSS Adjacent": - return AntennaMSSAdjacent( - antenna_params.mss_adjacent.frequency) + case "Cosine Antenna": + return AntennaElementCosine() case "ARRAY": return AntennaBeamformingImt( antenna_params.array.get_antenna_parameters(), diff --git a/sharc/antenna/antenna_mss_hibleo_x_ue.py b/sharc/antenna/antenna_mss_hibleo_x_ue.py index 10efaae3..329ae5cf 100644 --- a/sharc/antenna/antenna_mss_hibleo_x_ue.py +++ b/sharc/antenna/antenna_mss_hibleo_x_ue.py @@ -35,7 +35,7 @@ class AntennaMssHibleoXUe(Antenna): def __init__(self, frequency_MHz: float): """ - Initialize the AntennaMSSAdjacent class. + Initialize the AntennaElementCosine class. Parameters ---------- diff --git a/sharc/parameters/parameters_antenna.py b/sharc/parameters/parameters_antenna.py index a8fc8a0e..2dda15f1 100644 --- a/sharc/parameters/parameters_antenna.py +++ b/sharc/parameters/parameters_antenna.py @@ -29,7 +29,7 @@ class ParametersAntenna(ParametersBase): "ITU-R-S.1528-Taylor", "ITU-R-S.1528-Section1.2", "ITU-R-S.1528-LEO", - "MSS Adjacent"] + "Cosine Antenna"] # chosen antenna radiation pattern pattern: typing.Literal["OMNI", @@ -44,7 +44,7 @@ class ParametersAntenna(ParametersBase): "ITU-R-S.1528-Taylor", "ITU-R-S.1528-Section1.2", "ITU-R-S.1528-LEO", - "MSS Adjacent"] = None + "Cosine Antenna"] = None # antenna gain [dBi] gain: float = None @@ -196,7 +196,7 @@ def validate(self, ctx): self.itu_r_s_1528.validate(f"{ctx}.itu_r_s_1528") case "ITU-R-S.672": self.itu_r_s_672.validate(f"{ctx}.itu_r_s_672") - case "MSS Adjacent": + case "Cosine Antenna": self.mss_adjacent.validate(f"{ctx}.mss_adjacent") case _: raise NotImplementedError( diff --git a/sharc/parameters/parameters_mss_d2d.py b/sharc/parameters/parameters_mss_d2d.py index 3f6ec56c..e013142a 100644 --- a/sharc/parameters/parameters_mss_d2d.py +++ b/sharc/parameters/parameters_mss_d2d.py @@ -99,7 +99,7 @@ class ParametersMssD2d(ParametersBase): # Parameters for the out-of-band antenna pattern oob_antenna: ParametersAntenna = field( default_factory=lambda: ParametersAntenna( - pattern="MSS Adjacent", + pattern="Cosine Antenna", gain=0.0, mss_adjacent=ParametersAntennaWithFreq(frequency=None))) @@ -213,10 +213,10 @@ def propagate_parameters(self): bandwidth=self.bandwidth, ) if self.use_oob_antenna: - if self.oob_antenna.pattern not in ["MSS Adjacent"]: # only supported this pattern for now + if self.oob_antenna.pattern not in ["Cosine Antenna"]: # only supported this pattern for now raise ValueError( f"ParametersMssD2d: Invalid out-of-band antenna pattern { - self.oob_antenna.pattern}. Only 'MSS Adjacent' is supported.") + self.oob_antenna.pattern}. Only 'Cosine Antenna' is supported.") self.oob_antenna.set_external_parameters( frequency=self.frequency, diff --git a/sharc/station_factory.py b/sharc/station_factory.py index 6413410b..bfc13761 100644 --- a/sharc/station_factory.py +++ b/sharc/station_factory.py @@ -955,6 +955,9 @@ def generate_fss_space_station(param: ParametersFssSs): ) sys.exit(1) + # Same OOB antenna pattern as in-band + fss_space_station.oob_antenna = fss_space_station.antenna + fss_space_station.bandwidth = np.array([param.bandwidth]) fss_space_station.noise_temperature = np.array( [param.noise_temperature]) @@ -1083,6 +1086,9 @@ def generate_fss_earth_station( ) sys.exit(1) + # Same OOB antenna pattern as in-band + fss_earth_station.oob_antenna = fss_earth_station.antenna + fss_earth_station.noise_temperature = np.array( [param.noise_temperature]) fss_earth_station.bandwidth = np.array([param.bandwidth]) diff --git a/tests/e2e/test_integration_imt_victim.py b/tests/e2e/test_integration_imt_victim.py index d5bc3994..6b254bb1 100644 --- a/tests/e2e/test_integration_imt_victim.py +++ b/tests/e2e/test_integration_imt_victim.py @@ -152,6 +152,7 @@ def assert_sys_to_imt_dl_results_attr( # testing attributes that should be per ue towards system self.assertEqual(len(res.imt_system_antenna_gain_adjacent), n_ue * n_sys) + self.assertEqual(len(res.system_imt_antenna_gain_adjacent), n_ue * n_sys) self.assertEqual(len(res.imt_system_path_loss), n_ue * n_sys) # NOTE: it may not have co-channel since this test is for adjacent # self.assertEqual(len(res.imt_system_antenna_gain), n_ue * n_sys) @@ -197,6 +198,7 @@ def assert_sys_to_imt_ul_results_attr( # testing attributes that should be per ue towards system self.assertEqual(len(res.imt_system_antenna_gain_adjacent), n_ue * n_sys) + self.assertEqual(len(res.system_imt_antenna_gain_adjacent), n_ue * n_sys) self.assertEqual(len(res.imt_system_path_loss), n_ue * n_sys) # NOTE: it may not have co-channel since this test is for adjacent, # so if need be, remove this from here and put it elsewhere diff --git a/tests/e2e/test_integration_sys_victim.py b/tests/e2e/test_integration_sys_victim.py index 717cccb5..71ae14bc 100644 --- a/tests/e2e/test_integration_sys_victim.py +++ b/tests/e2e/test_integration_sys_victim.py @@ -136,6 +136,7 @@ def assert_imt_dl_to_sys_results_attr( # testing attributes that should be per beam towards system self.assertEqual(len(res.imt_system_antenna_gain_adjacent), n_beams * n_sys) + self.assertEqual(len(res.system_imt_antenna_gain_adjacent), n_beams * n_sys) self.assertEqual(len(res.imt_system_path_loss), n_beams * n_sys) # NOTE: it may not have co-channel since this test is for adjacent # self.assertEqual(len(res.imt_system_antenna_gain), n_beams * n_sys) @@ -170,6 +171,7 @@ def assert_imt_ul_to_sys_results_attr( # testing attributes that should be per ue towards system self.assertEqual(len(res.imt_system_antenna_gain_adjacent), n_ue * n_sys) + self.assertEqual(len(res.system_imt_antenna_gain_adjacent), n_ue * n_sys) self.assertEqual(len(res.imt_system_path_loss), n_ue * n_sys) # NOTE: it may not have co-channel since this test is for adjacent # self.assertEqual(len(res.imt_system_antenna_gain), n_ue * n_sys) diff --git a/tests/parameters/parameters_for_testing.yaml b/tests/parameters/parameters_for_testing.yaml index a55ef5d3..6fc9af71 100644 --- a/tests/parameters/parameters_for_testing.yaml +++ b/tests/parameters/parameters_for_testing.yaml @@ -435,7 +435,7 @@ imt: # Out-of-band antenna pattern oob_antenna: gain: 0.0 - pattern: MSS Adjacent + pattern: Cosine Antenna mss_adjacent: frequency: 2170.0 @@ -1119,7 +1119,7 @@ mss_d2d: use_oob_antenna: true # Out-of-band antenna pattern oob_antenna: - pattern: MSS Adjacent + pattern: Cosine Antenna mss_adjacent: frequency: 2170.1 diff --git a/tests/parameters/test_parameters.py b/tests/parameters/test_parameters.py index faec76ec..8fc55c43 100644 --- a/tests/parameters/test_parameters.py +++ b/tests/parameters/test_parameters.py @@ -183,7 +183,7 @@ def test_parameters_imt(self): self.assertEqual(self.parameters.imt.bs.antenna.itu_r_s_1528.l_t, 1.6) # Check the OOB antenna pattern parameters - self.assertEqual(self.parameters.imt.bs.oob_antenna.pattern, "MSS Adjacent") + self.assertEqual(self.parameters.imt.bs.oob_antenna.pattern, "Cosine Antenna") self.assertEqual(self.parameters.imt.bs.oob_antenna.mss_adjacent.frequency, 2170.0) """Test ParametersSubarrayImt @@ -652,7 +652,7 @@ def test_parametes_mss_d2d(self): self.assertEqual(self.parameters.mss_d2d.antenna.itu_r_s_1528.l_t, 1.6) # Test oob antenna pattern self.assertEqual(self.parameters.mss_d2d.use_oob_antenna, True) - self.assertEqual(self.parameters.mss_d2d.oob_antenna.pattern, 'MSS Adjacent') + self.assertEqual(self.parameters.mss_d2d.oob_antenna.pattern, 'Cosine Antenna') self.assertEqual(self.parameters.mss_d2d.oob_antenna.mss_adjacent.frequency, 2170.1) self.assertEqual(self.parameters.mss_d2d.channel_model, 'P619') self.assertEqual( diff --git a/tests/test_station_factory.py b/tests/test_station_factory.py index 6c462151..9b50f603 100644 --- a/tests/test_station_factory.py +++ b/tests/test_station_factory.py @@ -15,7 +15,7 @@ from sharc.topology.topology_single_base_station import TopologySingleBaseStation from sharc.parameters.parameters_single_space_station import ParametersSingleSpaceStation from sharc.station_manager import StationManager -from sharc.antenna.antenna_mss_adjacent import AntennaMSSAdjacent +from sharc.antenna.antenna_element_cosine import AntennaElementCosine from sharc.satellite.ngso.constants import EARTH_RADIUS_M @@ -61,7 +61,7 @@ def test_generate_imt_base_stations_oob_antennas(self): self.assertIs(imt_bs.oob_antenna, imt_bs.antenna) # both should point to the same list # What if the user sets a non-ARRAY oob-antenna pattern but the in-band is ARRAY? - param_imt.bs.oob_antenna.pattern = "MSS Adjacent" + param_imt.bs.oob_antenna.pattern = "Cosine Antenna" param_imt.bs.oob_antenna.gain = 0.0 param_imt.bs.oob_antenna.mss_adjacent.frequency = 2000.0 param_imt.validate("station factory test 2") @@ -79,7 +79,7 @@ def test_generate_imt_base_stations_oob_antennas(self): param_imt.bs.antenna.itu_r_s_1528.bandwidth = 5.0 param_imt.bs.antenna.itu_r_s_1528.slr = 20.0 param_imt.bs.antenna.itu_r_s_1528.n_side_lobes = 2 - param_imt.bs.oob_antenna.pattern = "MSS Adjacent" + param_imt.bs.oob_antenna.pattern = "Cosine Antenna" param_imt.bs.oob_antenna.gain = 0.0 param_imt.bs.oob_antenna.mss_adjacent.frequency = 2000.0 param_imt.validate("station factory test 2") @@ -90,7 +90,7 @@ def test_generate_imt_base_stations_oob_antennas(self): # When the in-band antenna is not ARRAY, the oob antenna should be a different object self.assertIsNot(imt_bs.oob_antenna, imt_bs.antenna) for oob_antenna in imt_bs.oob_antenna: - self.assertIsInstance(oob_antenna, AntennaMSSAdjacent) + self.assertIsInstance(oob_antenna, AntennaElementCosine) def test_generate_imt_base_stations_ntn(self): """Test for IMT-NTN space station generation.""" diff --git a/tests/test_station_factory_ngso.py b/tests/test_station_factory_ngso.py index 9d44932f..ad157738 100644 --- a/tests/test_station_factory_ngso.py +++ b/tests/test_station_factory_ngso.py @@ -3,7 +3,7 @@ from sharc.support.enumerations import StationType from sharc.station_factory import StationFactory from sharc.station_manager import StationManager -from sharc.antenna.antenna_mss_adjacent import AntennaMSSAdjacent +from sharc.antenna.antenna_element_cosine import AntennaElementCosine from sharc.support.sharc_geom import CoordinateSystem, lla2ecef import numpy as np @@ -192,7 +192,7 @@ def test_ngso_oob_antenna(self): self.assertIs(ngso_manager.oob_antenna, ngso_manager.antenna) self.param.use_oob_antenna = True - self.param.oob_antenna.pattern = "MSS Adjacent" + self.param.oob_antenna.pattern = "Cosine Antenna" self.param.oob_antenna.gain = 0.0 self.param.propagate_parameters() self.param.validate("oob_antenna_test") @@ -202,8 +202,7 @@ def test_ngso_oob_antenna(self): # the oob_antenna should be a different object now self.assertIsNot(ngso_manager.oob_antenna, ngso_manager.antenna) for a in ngso_manager.oob_antenna: - self.assertIsInstance(a, AntennaMSSAdjacent) - self.assertEqual(a.frequency, self.param.frequency) + self.assertIsInstance(a, AntennaElementCosine) def test_ngso_spectral_mask_stepped(self): """Test that NGSO stations use the STEPPED spectral mask when specified.""" From f247ac78be0ede2a8ddd91a2011b47e4d5c30175 Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Tue, 25 Nov 2025 09:26:55 -0300 Subject: [PATCH 09/13] update(station_factory): Added oob antenna initialization to the station factory methods. --- sharc/parameters/parameters_antenna.py | 2 +- sharc/parameters/parameters_mss_ss.py | 6 ++ .../parameters_single_earth_station.py | 37 +++++++++--- .../parameters_single_space_station.py | 14 +++++ sharc/station_factory.py | 56 +++++++++++++++---- 5 files changed, 96 insertions(+), 19 deletions(-) diff --git a/sharc/parameters/parameters_antenna.py b/sharc/parameters/parameters_antenna.py index 2dda15f1..f478effb 100644 --- a/sharc/parameters/parameters_antenna.py +++ b/sharc/parameters/parameters_antenna.py @@ -147,7 +147,7 @@ def validate(self, ctx): """ if None in [self.pattern]: raise ValueError( - f"{ctx}.pattern should be set. Is None instead", + f"{ctx}.pattern should be set. It is None instead", ) if self.pattern != "ARRAY" and self.gain is None: diff --git a/sharc/parameters/parameters_mss_ss.py b/sharc/parameters/parameters_mss_ss.py index 50a3d3ac..77d64e92 100644 --- a/sharc/parameters/parameters_mss_ss.py +++ b/sharc/parameters/parameters_mss_ss.py @@ -70,6 +70,12 @@ class ParametersMssSs(ParametersBase): antenna: ParametersAntenna = field( default_factory=ParametersAntenna) + # Use out-of-band antenna for emissions outside the assigned bandwidth + use_oob_antenna: bool = False + + # Out-of-band antenna parameters - only used if use_oob_antenna is True + oob_antenna: ParametersAntenna = field(default_factory=ParametersAntenna) + # paramters for channel model param_p619: ParametersP619 = field(default_factory=ParametersP619) diff --git a/sharc/parameters/parameters_single_earth_station.py b/sharc/parameters/parameters_single_earth_station.py index afc077e8..b372474c 100644 --- a/sharc/parameters/parameters_single_earth_station.py +++ b/sharc/parameters/parameters_single_earth_station.py @@ -69,6 +69,12 @@ class ParametersSingleEarthStation(ParametersBase): # Antenna pattern of the sensor antenna: ParametersAntenna = field(default_factory=ParametersAntenna) + # Use out-of-band antenna for emissions outside the assigned bandwidth + use_oob_antenna: bool = False + + # Out-of-band antenna parameters - only used if use_oob_antenna is True + oob_antenna: ParametersAntenna = field(default_factory=ParametersAntenna) + # Channel model, possible values are "FSPL" (free-space path loss), "P619" channel_model: typing.Literal[ "FSPL", "P619", @@ -283,14 +289,7 @@ def load_parameters_from_file(self, config_file: str): """ super().load_parameters_from_file(config_file) - # this is needed because nested parameters - # don't know/cannot access parents attributes - self.antenna.set_external_parameters( - frequency=self.frequency, - ) - - # this parameter is required in system get description - self.antenna_pattern = self.antenna.pattern + self.propagate_parameters() # this should be done by validating this parameters only if it is the selected system on the general section # TODO: make this better by changing the Parameters class itself @@ -303,6 +302,28 @@ def load_parameters_from_file(self, config_file: str): if should_validate: self.validate(self.section_name) + def propagate_parameters(self): + """ + Propagate relevant parameters to nested objects. + """ + + # this is needed because nested parameters + # don't know/cannot access parents attributes + self.antenna.set_external_parameters( + frequency=self.frequency, + ) + + if self.use_oob_antenna: + self.oob_antenna.set_external_parameters( + frequency=self.frequency + ) + else: + # The oob antenna parameters is replicated here to prevent validation failure. + self.oob_antenna = self.antenna + + # this parameter is required in system get description + self.antenna_pattern = self.antenna.pattern + def validate(self, ctx="single_earth_station"): """ Validate the single earth station parameters for correctness. diff --git a/sharc/parameters/parameters_single_space_station.py b/sharc/parameters/parameters_single_space_station.py index b1bacfbe..2b598964 100644 --- a/sharc/parameters/parameters_single_space_station.py +++ b/sharc/parameters/parameters_single_space_station.py @@ -33,6 +33,12 @@ class ParametersSingleSpaceStation(ParametersBase): # Antenna pattern of the sensor antenna: ParametersAntenna = field(default_factory=ParametersAntenna) + # Use out-of-band antenna for emissions outside the assigned bandwidth + use_oob_antenna: bool = False + + # Out-of-band antenna parameters - only used if use_oob_antenna is True + oob_antenna: ParametersAntenna = field(default_factory=ParametersAntenna) + # Receiver polarization loss # e.g. could come from polarization mismatch or depolarization # check if IMT parameters don't come in values for single polarization @@ -192,6 +198,14 @@ def propagate_parameters(self): frequency=self.frequency, ) + if self.use_oob_antenna: + self.oob_antenna.set_external_parameters( + frequency=self.frequency + ) + else: + # The oob antenna parameters is replicated here to prevent validation failure. + self.oob_antenna = self.antenna + # this parameter is required in system get description self.antenna_pattern = self.antenna.pattern diff --git a/sharc/station_factory.py b/sharc/station_factory.py index bfc13761..6fb5362c 100644 --- a/sharc/station_factory.py +++ b/sharc/station_factory.py @@ -868,6 +868,15 @@ def generate_single_space_station( space_station.elevation[0]) ]) + # Set the OOB antenna pattern if specified + if param.use_oob_antenna: + space_station.oob_antenna = np.array([ + AntennaFactory.create_antenna(param.oob_antenna, space_station.azimuth[0], + space_station.elevation[0]) + ]) + else: + space_station.oob_antenna = space_station.antenna + space_station.z = space_station.height space_station.bandwidth = param.bandwidth space_station.noise_temperature = param.noise_temperature @@ -1225,6 +1234,15 @@ def generate_single_earth_station( ) ]) + if param.use_oob_antenna: + single_earth_station.oob_antenna = np.array([ + AntennaFactory.create_antenna( + param.oob_antenna, single_earth_station.azimuth, single_earth_station.elevation + ) + ]) + else: + single_earth_station.oob_antenna = single_earth_station.antenna + single_earth_station.active = np.array([True]) single_earth_station.bandwidth = np.array([param.bandwidth]) @@ -1308,6 +1326,9 @@ def generate_fs_station(param: ParametersFs): ) sys.exit(1) + # The OOB antenna pattern should be the same as in-band. + fs_station.oob_antenna = fs_station.antenna + fs_station.noise_temperature = param.noise_temperature fs_station.bandwidth = np.array([param.bandwidth]) @@ -1371,6 +1392,9 @@ def generate_haps( ) sys.exit(1) + # TODO: Set the OOB antenna pattern if needed + haps.oob_antenna = haps.antenna + haps.bandwidth = np.array([param.bandwidth]) return haps @@ -1426,6 +1450,9 @@ def generate_rns( ) sys.exit(1) + # TODO: Set the oob antenna pattern if needed + rns.oob_antenna = rns.antenna + rns.bandwidth = np.array([param.bandwidth]) rns.noise_temperature = param.noise_temperature rns.thermal_noise = -500 @@ -1554,6 +1581,9 @@ def generate_space_station( ) sys.exit(1) + # TODO: Set the oob antenna pattern if needed + space_station.oob_antenna = space_station.antenna + space_station.bandwidth = np.array([param.bandwidth]) # Noise temperature is not an input parameter for yet used systems. # It is included here to calculate the useless I/N values @@ -1600,16 +1630,22 @@ def generate_mss_ss(param_mss: ParametersMssSs): param_mss.bandwidth * 10**6) mss_ss.antenna = np.empty(num_bs, dtype=AntennaS1528Leo) - for i in range(num_bs): - if param_mss.antenna_pattern == "ITU-R-S.1528-LEO": - mss_ss.antenna[i] = AntennaS1528Leo(param_mss.antenna_s1528) - elif param_mss.antenna_pattern == "ITU-R-S.1528-Section1.2": - mss_ss.antenna[i] = AntennaS1528(param_mss.antenna_s1528) - elif param_mss.antenna_pattern == "ITU-R-S.1528-Taylor": - mss_ss.antenna[i] = AntennaS1528Taylor(param_mss.antenna_s1528) - else: - raise ValueError( - "generate_mss_ss: Invalid antenna type: {param_mss.antenna_pattern}") + mss_ss.antenna = AntennaFactory.create_n_antennas( + param_mss.antenna, + mss_ss.azimuth, + mss_ss.elevation, + mss_ss.num_stations + ) + + if param_mss.use_oob_antenna: + mss_ss.oob_antenna = AntennaFactory.create_n_antennas( + param_mss.oob_antenna, + mss_ss.azimuth, + mss_ss.elevation, + mss_ss.num_stations + ) + else: + mss_ss.oob_antenna = mss_ss.antenna if param_mss.spectral_mask == "IMT-2020": mss_ss.spectral_mask = SpectralMaskImt( From 06f2176400bca51c5de3061533437a1d15b9e6d6 Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Wed, 26 Nov 2025 10:55:00 -0300 Subject: [PATCH 10/13] fix(antenna): Added angle cut-off to Cosine Antenna Element --- sharc/antenna/antenna_element_cosine.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sharc/antenna/antenna_element_cosine.py b/sharc/antenna/antenna_element_cosine.py index 70a73bae..a357b8a2 100644 --- a/sharc/antenna/antenna_element_cosine.py +++ b/sharc/antenna/antenna_element_cosine.py @@ -36,7 +36,8 @@ def calculate_gain(self, *args, **kwargs) -> np.array: Calculated antenna gain values. """ theta_rad = np.deg2rad(np.absolute(kwargs["off_axis_angle_vec"])) - return 10 * np.log10(np.cos(theta_rad) + 1e-5) + theta_rad = np.minimum(theta_rad, np.pi / 2 - 1e-5) + return np.log10(np.cos(theta_rad)) if __name__ == '__main__': From 0d0d69b787a5ea97aa786411811ada58466bbbcb Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Wed, 26 Nov 2025 15:30:12 -0300 Subject: [PATCH 11/13] fix(simulation): BRAKING CHANGE - Added a new way to handle coupling loss calcultion for inband/oob emission cases. --- sharc/post_processor.py | 4 - sharc/results.py | 2 - sharc/simulation.py | 29 ++++--- sharc/simulation_downlink.py | 101 +++++++++++++++-------- sharc/simulation_uplink.py | 72 +++++++++++----- tests/e2e/test_integration_imt_victim.py | 28 ++++--- tests/e2e/test_integration_sys_victim.py | 97 +++++++++++----------- 7 files changed, 199 insertions(+), 134 deletions(-) diff --git a/sharc/post_processor.py b/sharc/post_processor.py index cf9ec6d6..24c83357 100644 --- a/sharc/post_processor.py +++ b/sharc/post_processor.py @@ -195,10 +195,6 @@ class PostProcessor: "x_label": "Path Loss [dB]", "title": "[SYS] IMT to system path loss", }, - "sys_to_imt_coupling_loss": { - "x_label": "Coupling Loss [dB]", - "title": "[SYS] IMT to system coupling loss", - }, "system_dl_interf_power": { "x_label": "Interference Power [dB]", "title": "[SYS] system interference power from IMT DL", diff --git a/sharc/results.py b/sharc/results.py index 3a9426b3..f14477e1 100644 --- a/sharc/results.py +++ b/sharc/results.py @@ -65,8 +65,6 @@ def __init__(self): self.imt_system_build_entry_loss = SampleList() # System diffraction loss [dB] self.imt_system_diffraction_loss = SampleList() - # System to IMT coupling loss - self.sys_to_imt_coupling_loss = SampleList() self.imt_dl_tx_power_density = SampleList() # Transmit power [dBm] diff --git a/sharc/simulation.py b/sharc/simulation.py index 0135efdc..e5538c95 100644 --- a/sharc/simulation.py +++ b/sharc/simulation.py @@ -113,6 +113,7 @@ def __init__(self, parameters: Parameters, parameter_file: str): self.coupling_loss_imt = np.empty(0) self.coupling_loss_imt_system = np.empty(0) self.coupling_loss_imt_system_adjacent = np.empty(0) + self.coupling_loss_oob_tx_inband_rx = np.empty(0) # Used to store coupling loss for oob emissions self.bs_to_ue_d_2D = np.empty(0) self.bs_to_ue_d_3D = np.empty(0) @@ -271,7 +272,8 @@ def calculate_coupling_loss_system_imt( self, system_station: StationManager, imt_station: StationManager, - is_co_channel=True, + system_inband=True, + imt_inband=True, ) -> np.array: """ Calculates the coupling loss (path loss + antenna gains + other losses) between @@ -286,9 +288,11 @@ def calculate_coupling_loss_system_imt( A StationManager object with system stations imt_station : StationManager A StationManager object with IMT stations - is_co_channel : bool, optional - Whether the interference analysis is co-channel or not, by default True - + system_inband : bool, optional + Whether the interference analysis on system is in-band or adjacent. Default, True. + imt_inband: bool, optional + Whether the interference analysis on IMT is in-band or adjacent. Default, True. +b Returns ------- np.array @@ -305,12 +309,12 @@ def calculate_coupling_loss_system_imt( # system's station if imt_station.station_type is StationType.IMT_UE: # define antenna gains - gain_sys_to_imt = self.calculate_gains(system_station, imt_station, is_co_channel) + gain_sys_to_imt = self.calculate_gains(system_station, imt_station, system_inband) gain_imt_to_sys = np.transpose( self.calculate_gains( imt_station, system_station, - is_co_channel)) + imt_inband)) additional_loss = self.parameters.imt.ue.ohmic_loss \ + self.parameters.imt.ue.body_loss \ + self.polarization_loss @@ -318,12 +322,12 @@ def calculate_coupling_loss_system_imt( # define antenna gains # repeat for each BS beam gain_sys_to_imt = np.repeat( - self.calculate_gains(system_station, imt_station, is_co_channel), + self.calculate_gains(system_station, imt_station, system_inband), self.parameters.imt.ue.k, 1, ) gain_imt_to_sys = np.transpose( self.calculate_gains( - imt_station, system_station, is_co_channel, + imt_station, system_station, imt_inband, ), ) additional_loss = self.parameters.imt.bs.ohmic_loss \ @@ -373,16 +377,19 @@ def calculate_coupling_loss_system_imt( path_loss, self.parameters.imt.ue.k, 1, ) - if is_co_channel: + if system_inband: self.system_imt_antenna_gain = gain_sys_to_imt + else: + self.system_imt_antenna_gain_adjacent = gain_sys_to_imt + + if imt_inband: self.imt_system_antenna_gain = gain_imt_to_sys else: self.imt_system_antenna_gain_adjacent = gain_imt_to_sys - self.system_imt_antenna_gain_adjacent = gain_sys_to_imt # calculate coupling loss coupling_loss = \ - self.imt_system_path_loss - self.system_imt_antenna_gain - gain_imt_to_sys + additional_loss + self.imt_system_path_loss - gain_sys_to_imt - gain_imt_to_sys + additional_loss # Simulator expects imt_stations x system_stations shape return np.transpose(coupling_loss) diff --git a/sharc/simulation_downlink.py b/sharc/simulation_downlink.py index 4367cb7f..70a3e802 100644 --- a/sharc/simulation_downlink.py +++ b/sharc/simulation_downlink.py @@ -192,23 +192,34 @@ def calculate_sinr_ext(self): Calculates the downlink SINR and INR for each UE taking into account the interference that is generated by the other system into IMT system. """ - if self.co_channel or ( - self.adjacent_channel and self.param_system.adjacent_ch_emissions != "OFF" - ): + + if self.co_channel: + # coupling-loss between system's in-band transmission into IMT in-band reception. self.coupling_loss_imt_system = self.calculate_coupling_loss_system_imt( self.system, self.ue, - is_co_channel=True, + system_inband=True, + imt_inband=True, ) + if self.adjacent_channel: - # Calculate coupling loss for adjacent channel case - the antenna gain model may be different for the - # adjacent channel case. - self.coupling_loss_imt_system_adjacent = \ - self.calculate_coupling_loss_system_imt( + # coupling-loss from system's out-of-band emissions into IMT in-band reception + if self.param_system.adjacent_ch_emissions != "OFF": + # TODO: Store this coupling loss for results later. + self.coupling_loss_oob_tx_inband_rx = self.calculate_coupling_loss_system_imt( self.system, self.ue, - is_co_channel=False, + system_inband=False, + imt_inband=True, ) + # coupling-loss from system's in-band transmission into IMT out-of-band reception + # (e.g. due to reception filter imperfections). + self.coupling_loss_imt_system_adjacent = self.calculate_coupling_loss_system_imt( + self.system, + self.ue, + system_inband=True, + imt_inband=False, + ) # applying a bandwidth scaling factor since UE transmits on a portion # of the satellite's bandwidth @@ -346,7 +357,7 @@ def calculate_sinr_ext(self): self.param_system.adjacent_ch_emissions}") if self.param_system.adjacent_ch_emissions != "OFF": - tx_oob = tx_oob[:, np.newaxis] - self.coupling_loss_imt_system_adjacent[ue, :][:, active_sys] + tx_oob = tx_oob[:, np.newaxis] - self.coupling_loss_oob_tx_inband_rx[ue, :][:, active_sys] rx_oob = rx_oob[:, np.newaxis] - self.coupling_loss_imt_system_adjacent[ue, :][:, active_sys] @@ -404,18 +415,33 @@ def calculate_external_interference(self): """ Calculates interference that IMT system generates on other system """ - if self.co_channel or ( - self.adjacent_channel and self.param_system.adjacent_ch_reception != "OFF" - ): + if self.co_channel: + # coupling-loss between IMT in-band transmission into system in-band reception. self.coupling_loss_imt_system = self.calculate_coupling_loss_system_imt( - self.system, self.bs, is_co_channel=True, ) + self.system, + self.bs, + system_inband=True, + imt_inband=True, + ) + if self.adjacent_channel: - self.coupling_loss_imt_system_adjacent = \ - self.calculate_coupling_loss_system_imt( + # coupling-loss from IMT's out-of-band emissions into System in-band reception + if self.parameters.imt.adjacent_ch_emissions != "OFF": + # TODO: Store this coupling loss for results later. + self.coupling_loss_oob_tx_inband_rx = self.calculate_coupling_loss_system_imt( self.system, self.bs, - is_co_channel=False, + system_inband=True, + imt_inband=False, ) + # coupling-loss from IMT's in-band transmission into Systems out-of-band reception + # (e.g. due to reception filter imperfections). + self.coupling_loss_imt_system_adjacent = self.calculate_coupling_loss_system_imt( + self.system, + self.bs, + system_inband=False, + imt_inband=True, + ) # applying a bandwidth scaling factor since UE transmits on a portion # of the interfered systems bandwidth @@ -533,20 +559,22 @@ def calculate_external_interference(self): ) if self.adjacent_channel: - # oob_power per beam - # NOTE: we only consider one beam since all beams should have gain - # of a single element for IMT, and as such the coupling loss should be the - # same for all beams - adj_loss = self.coupling_loss_imt_system_adjacent[np.ix_(active_beams, sys_active)] - - # FIXME: for more than 1 sys - # NOTE: sharc impl already doesn't really work with n_sys > 1 - # so more would have to be fixed before this - assert np.all(adj_loss == adj_loss.flat[0]) - - tx_oob_s = tx_oob - adj_loss[0, :] + tx_oob_s = - np.inf + if self.parameters.imt.adjacent_ch_emissions != "OFF": + # oob_power per beam + # NOTE: we only consider one beam since all beams should have gain + # of a single element for IMT, and as such the coupling loss should be the + # same for all beams + adj_loss = self.coupling_loss_oob_tx_inband_rx[np.ix_(active_beams, sys_active)] + + # FIXME: for more than 1 sys + # NOTE: sharc impl already doesn't really work with n_sys > 1 + # so more would have to be fixed before this + assert np.all(adj_loss == adj_loss.flat[0]) + + tx_oob_s = tx_oob - adj_loss[0, :] if self.param_system.adjacent_ch_reception != "OFF": - rx_oob_s = rx_oob - self.coupling_loss_imt_system[active_beams, sys_active] + rx_oob_s = rx_oob - self.coupling_loss_imt_system_adjacent[active_beams, sys_active] else: rx_oob_s = -np.inf @@ -679,12 +707,15 @@ def collect_results(self, write_to_file: bool, snapshot_number: int): self.results.imt_system_diffraction_loss.extend( self.imt_system_diffraction_loss[sys_active[:, np.newaxis], ue].flatten(), ) - self.results.sys_to_imt_coupling_loss.extend( - self.coupling_loss_imt_system[np.array(ue)[:, np.newaxis], sys_active].flatten()) else: # IMT is the interferer - self.results.system_imt_antenna_gain.extend( - self.system_imt_antenna_gain[sys_active[:, np.newaxis], ue].flatten(), - ) + if len(self.system_imt_antenna_gain): + self.results.system_imt_antenna_gain.extend( + self.system_imt_antenna_gain[sys_active[:, np.newaxis], ue].flatten(), + ) + if len(self.system_imt_antenna_gain_adjacent): + self.results.system_imt_antenna_gain_adjacent.extend( + self.system_imt_antenna_gain_adjacent[sys_active[:, np.newaxis], ue].flatten(), + ) if len(self.imt_system_antenna_gain): self.results.imt_system_antenna_gain.extend( self.imt_system_antenna_gain[sys_active[:, np.newaxis], ue].flatten(), diff --git a/sharc/simulation_uplink.py b/sharc/simulation_uplink.py index 04c84973..74909266 100644 --- a/sharc/simulation_uplink.py +++ b/sharc/simulation_uplink.py @@ -174,22 +174,33 @@ def calculate_sinr_ext(self): interference that is generated by the other system into IMT system. """ - if self.co_channel or ( - self.adjacent_channel and self.param_system.adjacent_ch_emissions != "OFF" - ): + if self.co_channel: + # coupling-loss between system's in-band transmission into IMT in-band reception. self.coupling_loss_imt_system = self.calculate_coupling_loss_system_imt( self.system, self.bs, - is_co_channel=True, + system_inband=True, + imt_inband=True, ) if self.adjacent_channel: - self.coupling_loss_imt_system_adjacent = \ - self.calculate_coupling_loss_system_imt( + # coupling-loss from system's out-of-band emissions into IMT in-band reception + if self.param_system.adjacent_ch_emissions != "OFF": + # TODO: Store this for results later + self.coupling_loss_oob_tx_inband_rx = self.calculate_coupling_loss_system_imt( self.system, self.bs, - is_co_channel=False, + system_inband=False, + imt_inband=True, ) + # coupling-loss from system's in-band transmission into IMT out-of-band reception + # (e.g. due to reception filter imperfections). + self.coupling_loss_imt_system_adjacent = self.calculate_coupling_loss_system_imt( + self.system, + self.bs, + system_inband=True, + imt_inband=False, + ) bs_active = np.where(self.bs.active)[0] sys_active = np.where(self.system.active)[0] @@ -306,7 +317,7 @@ def calculate_sinr_ext(self): if self.param_system.adjacent_ch_emissions != "OFF": # oob for system is inband for IMT - tx_oob = tx_oob[:, np.newaxis] - self.coupling_loss_imt_system_adjacent[active_beams, :][:, sys_active] + tx_oob = tx_oob[:, np.newaxis] - self.coupling_loss_oob_tx_inband_rx[active_beams, :][:, sys_active] # oob for IMT rx_oob = rx_oob[:, np.newaxis] - self.coupling_loss_imt_system_adjacent[active_beams, :][:, sys_active] @@ -337,19 +348,33 @@ def calculate_external_interference(self): Calculates interference that IMT system generates on other system """ - if self.co_channel or ( - # then rx receives emission inside the tx band, so it is co-channel with IMT - self.adjacent_channel and self.param_system.adjacent_ch_reception != "OFF" - ): + if self.co_channel: + # coupling-loss between IMT in-band transmission into system in-band reception. self.coupling_loss_imt_system = self.calculate_coupling_loss_system_imt( - self.system, self.ue, is_co_channel=True, ) + self.system, + self.ue, + system_inband=True, + imt_inband=True, + ) + if self.adjacent_channel: - self.coupling_loss_imt_system_adjacent = \ - self.calculate_coupling_loss_system_imt( + # coupling-loss from IMT's out-of-band emissions into System in-band reception + if self.parameters.imt.adjacent_ch_emissions != "OFF": + # TODO: Store this for results later + self.coupling_loss_oob_tx_inband_rx = self.calculate_coupling_loss_system_imt( self.system, self.ue, - is_co_channel=False, + system_inband=True, + imt_inband=False, ) + # coupling-loss from IMT's in-band transmission into Systems out-of-band reception + # (e.g. due to reception filter imperfections). + self.coupling_loss_imt_system_adjacent = self.calculate_coupling_loss_system_imt( + self.system, + self.ue, + system_inband=False, + imt_inband=True, + ) # applying a bandwidth scaling factor since UE transmits on a portion # of the satellite's bandwidth @@ -451,10 +476,11 @@ def calculate_external_interference(self): ) # Out of band power - tx_oob -= self.coupling_loss_imt_system_adjacent[ue, sys_active] + if self.parameters.imt.adjacent_ch_emissions != "OFF": + tx_oob -= self.coupling_loss_oob_tx_inband_rx[ue, sys_active] if self.param_system.adjacent_ch_reception != "OFF": - rx_oob -= self.coupling_loss_imt_system[ue, sys_active] + rx_oob -= self.coupling_loss_imt_system_adjacent[ue, sys_active] # Out of band power # sum linearly power leaked into band and power received in the adjacent band oob_power_lin = 10 ** (0.1 * tx_oob) + 10 ** (0.1 * rx_oob) @@ -575,9 +601,13 @@ def collect_results(self, write_to_file: bool, snapshot_number: int): self.imt_system_diffraction_loss[np.ix_(sys_active, active_beams)], ) else: # IMT is the interferer - self.results.system_imt_antenna_gain.extend( - self.system_imt_antenna_gain[np.ix_(sys_active, ue)].flatten(), - ) + if len(self.system_imt_antenna_gain): + self.results.system_imt_antenna_gain.extend( + self.system_imt_antenna_gain[np.ix_(sys_active, ue)].flatten(), + ) + if len(self.system_imt_antenna_gain_adjacent): + self.results.system_imt_antenna_gain_adjacent.extend( + self.system_imt_antenna_gain_adjacent[np.ix_(sys_active, ue)].flatten(),) if len(self.imt_system_antenna_gain): self.results.imt_system_antenna_gain.extend( self.imt_system_antenna_gain[np.ix_(sys_active, ue)].flatten(), diff --git a/tests/e2e/test_integration_imt_victim.py b/tests/e2e/test_integration_imt_victim.py index 6b254bb1..bc483a73 100644 --- a/tests/e2e/test_integration_imt_victim.py +++ b/tests/e2e/test_integration_imt_victim.py @@ -159,8 +159,6 @@ def assert_sys_to_imt_dl_results_attr( # testing attributes that should be per system towards imt self.assertEqual(len(res.system_imt_antenna_gain), n_sys * n_ue) - # FIXME: why is this attr only on system -> IMT DL?? - self.assertEqual(len(res.sys_to_imt_coupling_loss), n_sys * n_ue) def assert_sys_to_imt_ul_results_attr( self, @@ -732,6 +730,8 @@ def test_es_to_ue_mask(self): """ self.param.general.imt_link = "DOWNLINK" self.param.imt.interfered_with = True + self.param.general.enable_adjacent_channel = True + self.param.general.enable_cochannel = False self.param.single_earth_station.adjacent_ch_emissions = "SPECTRAL_MASK" self.param.single_earth_station.spectral_mask = "MSS" self.param.single_earth_station.spurious_emissions = -13. @@ -832,11 +832,11 @@ def test_es_to_ue_mask(self): npt.assert_allclose( coc_coupling_1k, - simulation_1k.coupling_loss_imt_system + simulation_1k.coupling_loss_oob_tx_inband_rx ) npt.assert_allclose( coc_coupling_3k, - simulation_3k.coupling_loss_imt_system + simulation_3k.coupling_loss_oob_tx_inband_rx ) mask_power_1k = self.param.single_earth_station.spurious_emissions + \ @@ -876,6 +876,8 @@ def test_es_to_bs_mask(self): This simplifies spectral mask calculation by only getting the spurious emissions """ self.param.general.imt_link = "UPLINK" + self.param.general.enable_adjacent_channel = True + self.param.general.enable_cochannel = False self.param.imt.interfered_with = True self.param.single_earth_station.adjacent_ch_emissions = "SPECTRAL_MASK" self.param.single_earth_station.spectral_mask = "MSS" @@ -967,22 +969,22 @@ def test_es_to_bs_mask(self): g2 = self.param.single_earth_station.antenna.gain - coc_coupling_1k = np.transpose(p_loss_1k - g1_co_1k - g2) - coc_coupling_3k = np.transpose(p_loss_3k - g1_co_3k - g2) + coc_oob_coupling_1k = np.transpose(p_loss_1k - g1_co_1k - g2) + coc_oob_coupling_3k = np.transpose(p_loss_3k - g1_co_3k - g2) npt.assert_allclose( - coc_coupling_1k, - simulation_1k.coupling_loss_imt_system + coc_oob_coupling_1k, + simulation_1k.coupling_loss_oob_tx_inband_rx ) npt.assert_allclose( - coc_coupling_3k, - simulation_3k.coupling_loss_imt_system + coc_oob_coupling_3k, + simulation_3k.coupling_loss_oob_tx_inband_rx ) mask_power_1k = self.param.single_earth_station.spurious_emissions + \ self.dB(simulation_1k.bs.bandwidth) mask_power_3k = np.repeat( - self.param.single_earth_station.spurious_emissions + \ + self.param.single_earth_station.spurious_emissions + self.dB(simulation_3k.bs.bandwidth), 3 ) @@ -994,8 +996,8 @@ def test_es_to_bs_mask(self): 1 ) - rx_power_1k = mask_power_1k - coc_coupling_1k.reshape(mask_power_1k.shape) - rx_power_3k = mask_power_3k - coc_coupling_3k.reshape(mask_power_3k.shape) + rx_power_1k = mask_power_1k - coc_oob_coupling_1k.reshape(mask_power_1k.shape) + rx_power_3k = mask_power_3k - coc_oob_coupling_3k.reshape(mask_power_3k.shape) npt.assert_almost_equal( np.ravel(list(simulation_1k.bs.ext_interference.values())), diff --git a/tests/e2e/test_integration_sys_victim.py b/tests/e2e/test_integration_sys_victim.py index 71ae14bc..a72b235b 100644 --- a/tests/e2e/test_integration_sys_victim.py +++ b/tests/e2e/test_integration_sys_victim.py @@ -238,18 +238,18 @@ def test_2bs_to_es_mask(self): g1 = self.param.imt.bs.antenna.array.element_max_g g2 = self.param.single_earth_station.antenna.gain - coupling = p_loss - g1 - g2 + oob_tx_inband_rx_coupling = p_loss - g1 - g2 npt.assert_allclose( - coupling, - simulation_1k.coupling_loss_imt_system_adjacent + oob_tx_inband_rx_coupling, + simulation_1k.coupling_loss_oob_tx_inband_rx ) npt.assert_allclose( - simulation_1k.coupling_loss_imt_system_adjacent.repeat(3), - np.ravel(simulation_3k.coupling_loss_imt_system_adjacent) + simulation_1k.coupling_loss_oob_tx_inband_rx.repeat(3), + np.ravel(simulation_3k.coupling_loss_oob_tx_inband_rx) ) mask_power = self.param.imt.spurious_emissions + self.dB(self.param.single_earth_station.bandwidth) - rx_power = self.dB(self.lin(mask_power - coupling).sum()) + rx_power = self.dB(self.lin(mask_power - oob_tx_inband_rx_coupling).sum()) self.assertEqual( len(simulation_1k.results.system_dl_interf_power), 1 @@ -260,8 +260,8 @@ def test_2bs_to_es_mask(self): ) npt.assert_allclose( - simulation_3k.coupling_loss_imt_system_adjacent, - simulation_1k.coupling_loss_imt_system_adjacent[0][0], + simulation_3k.coupling_loss_oob_tx_inband_rx, + simulation_1k.coupling_loss_oob_tx_inband_rx[0][0], ) npt.assert_almost_equal( simulation_3k.results.system_dl_interf_power, @@ -282,6 +282,8 @@ def test_2bs_to_es_aclr_and_acs_partial_overlap(self): Testing BS acs and aclr with partial co-channel """ self.param.general.imt_link = "DOWNLINK" + self.param.general.enable_adjacent_channel = True + self.param.general.enable_cochannel = False self.param.imt.interfered_with = False self.param.imt.adjacent_ch_emissions = "ACLR" @@ -300,7 +302,7 @@ def test_2bs_to_es_aclr_and_acs_partial_overlap(self): simulation_1k = SimulationDownlink(self.param, "") simulation_1k.initialize() - self.assertFalse(simulation_1k.co_channel) + # self.assertFalse(simulation_1k.co_channel) simulation_1k.snapshot( write_to_file=False, @@ -379,26 +381,26 @@ def test_2bs_to_es_aclr_and_acs_partial_overlap(self): g2 = self.param.single_earth_station.antenna.gain - adj_coupling = p_loss - np.transpose(g1_adj) - g2 - coc_coupling_1k = p_loss - np.transpose(g1_co_1k) - g2 - coc_coupling_3k = np.reshape(p_loss.repeat(3), (6, 1)) - np.transpose(g1_co_3k) - g2 + oob_tx_indband_tx_coupling = p_loss - np.transpose(g1_adj) - g2 + inband_tx_oob_rx_coupling_1k = p_loss - np.transpose(g1_co_1k) - g2 + indbanx_tx_oob_rx_coupling_3k = np.reshape(p_loss.repeat(3), (6, 1)) - np.transpose(g1_co_3k) - g2 npt.assert_allclose( - simulation_1k.coupling_loss_imt_system_adjacent, - adj_coupling, + simulation_1k.coupling_loss_oob_tx_inband_rx, + oob_tx_indband_tx_coupling, ) npt.assert_allclose( - np.ravel(simulation_3k.coupling_loss_imt_system_adjacent), - np.ravel(adj_coupling.repeat(3)), + np.ravel(simulation_3k.coupling_loss_oob_tx_inband_rx), + np.ravel(oob_tx_indband_tx_coupling.repeat(3)), ) npt.assert_allclose( - coc_coupling_1k, - simulation_1k.coupling_loss_imt_system + inband_tx_oob_rx_coupling_1k, + simulation_1k.coupling_loss_imt_system_adjacent ) npt.assert_allclose( - coc_coupling_3k, - simulation_3k.coupling_loss_imt_system + indbanx_tx_oob_rx_coupling_3k, + simulation_3k.coupling_loss_imt_system_adjacent ) imt_non_overlap = self.param.imt.bandwidth - overlap sys_non_overlap = self.param.single_earth_station.bandwidth - overlap @@ -447,12 +449,12 @@ def test_2bs_to_es_aclr_and_acs_partial_overlap(self): tx_oob_3k = self.dB(psd_3k * sys_non_overlap) rx_power_1k = self.dB( - self.lin(rx_oob_1k - coc_coupling_1k).sum() + \ - self.lin(tx_oob_1k - adj_coupling).sum() + self.lin(rx_oob_1k - inband_tx_oob_rx_coupling_1k).sum() + + self.lin(tx_oob_1k - oob_tx_indband_tx_coupling).sum() ) rx_power_3k = self.dB( - self.lin(rx_oob_3k.reshape(coc_coupling_3k.shape) - coc_coupling_3k).sum() + \ - self.lin(tx_oob_3k - adj_coupling).sum() + self.lin(rx_oob_3k.reshape(indbanx_tx_oob_rx_coupling_3k.shape) - indbanx_tx_oob_rx_coupling_3k).sum() + + self.lin(tx_oob_3k - oob_tx_indband_tx_coupling).sum() ) self.assertEqual( @@ -545,19 +547,19 @@ def test_ue_to_es_mask(self): g1 = self.param.imt.ue.antenna.array.element_max_g g2 = self.param.single_earth_station.antenna.gain - coupling_1k = p_loss_1k - g1 - g2 - coupling_3k = p_loss_3k - g1 - g2 + oob_tx_inband_rx_coupling_1k = p_loss_1k - g1 - g2 + oob_tx_inband_rx_coupling_3k = p_loss_3k - g1 - g2 npt.assert_allclose( - coupling_1k, - simulation_1k.coupling_loss_imt_system_adjacent + oob_tx_inband_rx_coupling_1k, + simulation_1k.coupling_loss_oob_tx_inband_rx ) npt.assert_allclose( - coupling_3k.flatten(), - simulation_3k.coupling_loss_imt_system_adjacent.flatten() + oob_tx_inband_rx_coupling_3k.flatten(), + simulation_3k.coupling_loss_oob_tx_inband_rx.flatten() ) mask_power = self.param.imt.spurious_emissions + self.dB(self.param.single_earth_station.bandwidth) - rx_power = self.dB(self.lin(mask_power - coupling_1k).sum()) + rx_power = self.dB(self.lin(mask_power - oob_tx_inband_rx_coupling_1k).sum()) self.assertEqual( len(simulation_1k.results.system_ul_interf_power), 1 @@ -710,28 +712,27 @@ def test_ue_to_es_aclr_and_acs_partial_overlap(self): g2 = self.param.single_earth_station.antenna.gain - adj_coupling_1k = p_loss_1k - np.transpose(g1_adj_1k) - g2 - - adj_coupling_3k = p_loss_3k - np.transpose(g1_adj_3k) - g2 - coc_coupling_1k = p_loss_1k - np.transpose(g1_co_1k) - g2 - coc_coupling_3k = p_loss_3k - np.transpose(g1_co_3k) - g2 + oob_tx_inband_rx_coupling_1k = p_loss_1k - np.transpose(g1_adj_1k) - g2 + oob_tx_inband_rx_coupling_3k = p_loss_3k - np.transpose(g1_adj_3k) - g2 + inband_tx_oob_rx_coupling_1k = p_loss_1k - np.transpose(g1_co_1k) - g2 + inband_tx_oob_rx_coupling_3k = p_loss_3k - np.transpose(g1_co_3k) - g2 npt.assert_allclose( - simulation_1k.coupling_loss_imt_system_adjacent, - adj_coupling_1k, + simulation_1k.coupling_loss_oob_tx_inband_rx, + oob_tx_inband_rx_coupling_1k, ) npt.assert_allclose( - simulation_3k.coupling_loss_imt_system_adjacent, - adj_coupling_3k, + simulation_3k.coupling_loss_oob_tx_inband_rx, + oob_tx_inband_rx_coupling_3k, ) npt.assert_allclose( - coc_coupling_1k, - simulation_1k.coupling_loss_imt_system + inband_tx_oob_rx_coupling_1k, + simulation_1k.coupling_loss_imt_system_adjacent ) npt.assert_allclose( - coc_coupling_3k, - simulation_3k.coupling_loss_imt_system + inband_tx_oob_rx_coupling_3k, + simulation_3k.coupling_loss_imt_system_adjacent ) imt_non_overlap = self.param.imt.bandwidth - overlap sys_non_overlap = self.param.single_earth_station.bandwidth - overlap @@ -780,13 +781,13 @@ def test_ue_to_es_aclr_and_acs_partial_overlap(self): tx_oob_3k = self.dB(psd_3k * sys_non_overlap) rx_power_1k = self.dB( - self.lin(rx_oob_1k.flatten() - coc_coupling_1k.flatten()).sum() + \ - self.lin(tx_oob_1k.flatten() - adj_coupling_1k.flatten()).sum() + self.lin(rx_oob_1k.flatten() - inband_tx_oob_rx_coupling_1k.flatten()).sum() + \ + self.lin(tx_oob_1k.flatten() - oob_tx_inband_rx_coupling_1k.flatten()).sum() ) rx_power_3k = self.dB( - self.lin(rx_oob_3k.flatten() - coc_coupling_3k.flatten()).sum() + \ - self.lin(tx_oob_3k.flatten() - adj_coupling_3k.flatten()).sum() + self.lin(rx_oob_3k.flatten() - inband_tx_oob_rx_coupling_3k.flatten()).sum() + \ + self.lin(tx_oob_3k.flatten() - oob_tx_inband_rx_coupling_3k.flatten()).sum() ) self.assertEqual( From cf8f85b32e9d115c8d9d4d071aec237ada13090c Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Tue, 2 Dec 2025 11:41:28 -0300 Subject: [PATCH 12/13] update(tests): Added new tests to the Stepped Mask implementation --- tests/test_spectral_mask_stepped.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_spectral_mask_stepped.py b/tests/test_spectral_mask_stepped.py index fc46b7e6..6d87141c 100644 --- a/tests/test_spectral_mask_stepped.py +++ b/tests/test_spectral_mask_stepped.py @@ -53,6 +53,25 @@ def test_power_calc(self): ), ) + # test between step edges + for i in range(len(msk.mask_steps_dBm_mhz) - 1): + center_f = freq + 3 * band / 2 + i * band + actual_tx_oob = msk.power_calc(center_f=center_f, band=5) + desired_tx_oob = 10 * np.log10(np.power(10, (msk.mask_steps_dBm_mhz[i] + 10 * np.log10(band / 2)) / 10) + + np.power(10, (msk.mask_steps_dBm_mhz[i + 1] + 10 * np.log10(band / 2)) / 10)) + npt.assert_almost_equal(actual_tx_oob, desired_tx_oob) + + # test between step edges plus an offset + for _ in range(1000): # test for flaky behavior + for i in range(len(msk.mask_steps_dBm_mhz) - 1): + center_f = freq + 3 * band / 2 + i * band + offset = np.random.random() * (2.5) + actual_tx_oob = msk.power_calc(center_f=center_f + offset, band=5) + desired_tx_oob = 10 * np.log10( + np.power(10, (msk.mask_steps_dBm_mhz[i] + 10 * np.log10(band / 2 - offset)) / 10) + + np.power(10, (msk.mask_steps_dBm_mhz[i + 1] + 10 * np.log10(band / 2 + offset)) / 10)) + npt.assert_almost_equal(actual_tx_oob, desired_tx_oob) + if __name__ == '__main__': unittest.main() From 81ddf81fb0ba6a54261c133eb5daebec62efc66b Mon Sep 17 00:00:00 2001 From: Bruno Faria Date: Tue, 2 Dec 2025 11:41:56 -0300 Subject: [PATCH 13/13] fix(antenna): Fixed gain calculation in Cosine Antenna Element --- sharc/antenna/antenna_element_cosine.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sharc/antenna/antenna_element_cosine.py b/sharc/antenna/antenna_element_cosine.py index a357b8a2..7c5422f0 100644 --- a/sharc/antenna/antenna_element_cosine.py +++ b/sharc/antenna/antenna_element_cosine.py @@ -37,13 +37,13 @@ def calculate_gain(self, *args, **kwargs) -> np.array: """ theta_rad = np.deg2rad(np.absolute(kwargs["off_axis_angle_vec"])) theta_rad = np.minimum(theta_rad, np.pi / 2 - 1e-5) - return np.log10(np.cos(theta_rad)) + return 10 * np.log10(np.cos(theta_rad)) if __name__ == '__main__': import matplotlib.pyplot as plt - theta = np.linspace(0.01, 90, num=1000) + theta = np.linspace(0.01, 90.01, num=1000) antenna = AntennaElementCosine() gain = antenna.calculate_gain(off_axis_angle_vec=theta) fig = plt.figure(facecolor='w', edgecolor='k') @@ -54,5 +54,5 @@ def calculate_gain(self, *args, **kwargs) -> np.array: ax.set_xlabel(r"Off-axis angle $\theta$ [deg]") ax.set_ylabel("Antenna Gain [dBi]") ax.set_xlim((theta[0], theta[-1])) - ax.set_ylim((-80, 10)) + ax.set_ylim((-40, 10)) plt.show()