diff --git a/sharc/parameters/imt/parameters_antenna_imt.py b/sharc/parameters/imt/parameters_antenna_imt.py index 293fe694..a2eed50d 100644 --- a/sharc/parameters/imt/parameters_antenna_imt.py +++ b/sharc/parameters/imt/parameters_antenna_imt.py @@ -57,8 +57,8 @@ class ParametersAntennaImt(ParametersBase): # PS: it isn't implemented for UEs # and current implementation doesn't make sense for UEs horizontal_beamsteering_range: tuple[float | - int, float | int] = (-180., 180.) - vertical_beamsteering_range: tuple[float | int, float | int] = (0., 180.) + int, float | int] = (-180., 179.9999) + vertical_beamsteering_range: tuple[float | int, float | int] = (0., 179.9999) # Mechanical downtilt [degrees]. # PS: downtilt doesn't make sense on UE's @@ -174,11 +174,11 @@ def validate(self, ctx: str): f"Invalid {ctx}.horizontal_beamsteering_range={self.horizontal_beamsteering_range}\n" "The second value must be bigger than the first" ) - if not all(map(lambda x: x >= -180. and x <= 180., + if not all(map(lambda x: x >= -180. and x < 180., self.horizontal_beamsteering_range)): raise ValueError( f"Invalid {ctx}.horizontal_beamsteering_range={self.horizontal_beamsteering_range}\n" - "Horizontal beamsteering limit angles must be in the range [-180, 180]" + "Horizontal beamsteering limit angles must be in the range [-180, 180)" ) if isinstance(self.vertical_beamsteering_range, list): @@ -199,11 +199,11 @@ def validate(self, ctx: str): f"Invalid {ctx}.vertical_beamsteering_range={self.vertical_beamsteering_range}\n" "The second value must be bigger than the first" ) - if not all(map(lambda x: x >= 0. and x <= 180., + if not all(map(lambda x: x >= 0. and x < 180., self.vertical_beamsteering_range)): raise ValueError( f"Invalid {ctx}.vertical_beamsteering_range={self.vertical_beamsteering_range}\n" - "vertical beamsteering limit angles must be in the range [0, 180]" + "vertical beamsteering limit angles must be in the range [0, 180)" ) def get_normalization_data_if_needed(self): diff --git a/sharc/parameters/imt/parameters_imt.py b/sharc/parameters/imt/parameters_imt.py index 5fd077c7..dfb13f2f 100644 --- a/sharc/parameters/imt/parameters_imt.py +++ b/sharc/parameters/imt/parameters_imt.py @@ -110,11 +110,11 @@ class ParametersUE(ParametersBase): def validate(self, ctx: str): """Validate the UE antenna beamsteering range parameters.""" - if self.antenna.array.horizontal_beamsteering_range != (-180., 180.)\ - or self.antenna.array.vertical_beamsteering_range != (0., 180.): + if self.antenna.array.horizontal_beamsteering_range != (-180., 179.9999)\ + or self.antenna.array.vertical_beamsteering_range != (0., 179.9999): raise NotImplementedError( "UE antenna beamsteering limit has not been implemented. Default values of\n" - "horizontal = (-180., 180.), vertical = (0., 180.) should not be changed") + "horizontal = (-180., 179.9999), vertical = (0., 179.9999) should not be changed") ue: ParametersUE = field(default_factory=ParametersUE) diff --git a/sharc/simulation.py b/sharc/simulation.py index a89bd627..12080722 100644 --- a/sharc/simulation.py +++ b/sharc/simulation.py @@ -20,6 +20,7 @@ from sharc.station_manager import StationManager from sharc.results import Results from sharc.propagation.propagation_factory import PropagationFactory +from sharc.support.sharc_utils import wrap2_180, clip_angle class Simulation(ABC, Observable): @@ -482,6 +483,8 @@ def select_ue(self, random_number_gen: np.random.RandomState): self.ue, ) bs_active = np.where(self.bs.active)[0] + + assert np.all((-180 <= self.bs.azimuth) & (self.bs.azimuth <= 180)), "BS azimuth angles should be in [-180, 180] range" for bs in bs_active: # select K UE's among the ones that are connected to BS random_number_gen.shuffle(self.link[bs]) @@ -494,9 +497,14 @@ def select_ue(self, random_number_gen: np.random.RandomState): # add beam to BS antennas # limit beamforming angle - bs_beam_phi = np.clip( + beam_h_min, beam_h_max = wrap2_180( + self.parameters.imt.bs.antenna.array.horizontal_beamsteering_range + self.bs.azimuth[bs] + ) + + bs_beam_phi = clip_angle( self.bs_to_ue_phi[bs, ue], - *(self.parameters.imt.bs.antenna.array.horizontal_beamsteering_range + self.bs.azimuth[bs]) + beam_h_min, + beam_h_max, ) bs_beam_theta = np.clip( diff --git a/sharc/station_factory.py b/sharc/station_factory.py index 8db42b09..16874e7a 100644 --- a/sharc/station_factory.py +++ b/sharc/station_factory.py @@ -59,6 +59,7 @@ from sharc.mask.spectral_mask_3gpp import SpectralMask3Gpp from sharc.mask.spectral_mask_mss import SpectralMaskMSS from sharc.support.sharc_geom import GeometryConverter +from sharc.support.sharc_utils import wrap2_180 class StationFactory(object): @@ -119,7 +120,7 @@ def generate_imt_base_stations( else: imt_base_stations.height = param.bs.height * np.ones(num_bs) - imt_base_stations.azimuth = topology.azimuth + imt_base_stations.azimuth = wrap2_180(topology.azimuth) imt_base_stations.active = random_number_gen.rand( num_bs, ) < param.bs.load_probability diff --git a/sharc/support/sharc_utils.py b/sharc/support/sharc_utils.py index bc7644d5..c5d12398 100644 --- a/sharc/support/sharc_utils.py +++ b/sharc/support/sharc_utils.py @@ -26,6 +26,49 @@ def is_float(s: str) -> bool: return False +def wrap2_180(a): + """ + Wraps angles to the [-180, 180) range + """ + return (a + 180) % 360 - 180 + + +def angular_dist(a1, a2): + """ + Returns smallest angular distance between 2 angles + in the [0, 180] range + """ + return np.abs(wrap2_180(a1 - a2)) + + +def clip_angle(a, a_min, a_max): + """ + Returns `a` if it is inside the angle range defined by [`a_min`, `a_max`] + otherwise returns the closest bound, be it `a_min` or `a_max` + It is assumed all angles passes are already in [-180, 180] range + """ + outside_rng = False + # NOTE: angle limits such as -180 + [-60, 60] + # should be passed as wrapped around [120, -120] + # So it is important to check for this case + if a_min > a_max: + if a > a_max and a < a_min: + outside_rng = True + else: + # Normal interval + if a < a_min or a > a_max: + outside_rng = True + + # always clip to the closest bound + if outside_rng: + if angular_dist(a, a_min) < angular_dist(a, a_max): + return a_min + else: + return a_max + + return a + + def to_scalar(x): """Convert a numpy scalar or array to a Python scalar if possible.""" if isinstance(x, np.ndarray): diff --git a/tests/parameters/test_parameters.py b/tests/parameters/test_parameters.py index f41ea6d7..070de8a7 100644 --- a/tests/parameters/test_parameters.py +++ b/tests/parameters/test_parameters.py @@ -63,9 +63,9 @@ def test_parameters_imt(self): self.assertEqual( self.parameters.imt.ue.antenna.array.horizontal_beamsteering_range, (-180., - 180.)) + 179.9999)) self.assertEqual( - self.parameters.imt.ue.antenna.array.vertical_beamsteering_range, (0., 180.)) + self.parameters.imt.ue.antenna.array.vertical_beamsteering_range, (0., 179.9999)) self.assertEqual(self.parameters.imt.ue.k, 3) self.assertEqual(self.parameters.imt.ue.k_m, 1) self.assertEqual(self.parameters.imt.ue.indoor_percent, 5.0) diff --git a/tests/test_sharc_utils.py b/tests/test_sharc_utils.py new file mode 100644 index 00000000..f6a1ae15 --- /dev/null +++ b/tests/test_sharc_utils.py @@ -0,0 +1,65 @@ +import unittest + +from sharc.support.sharc_utils import clip_angle + + +class StationTest(unittest.TestCase): + """Unit tests for some utilities.""" + + def setUp(self): + """ + setup + """ + pass + + def test_clip_angle(self): + """ + Testing in range for non wrapping around angles + """ + for a in [90, 100, 180, -180, -135.01]: + self.assertEqual(clip_angle(a, 0, 90), 90) + for a in [-134.99, -90, -45, 0]: + self.assertEqual(clip_angle(a, 0, 90), 0) + for a in [0, 45, 90]: + self.assertEqual(clip_angle(a, 0, 90), a) + + for a in [-90, -80, 0, 44.99]: + self.assertEqual(clip_angle(a, -180, -90), -90) + for a in [45.01, 90, 135, 180, -180]: + self.assertEqual(clip_angle(a, -180, -90), -180) + for a in [-180, -135, -90]: + self.assertEqual(clip_angle(a, -180, -90), a) + + for a in [180, -180, -135, -90.01]: + self.assertEqual(clip_angle(a, 0, 180), 180) + for a in [-89.99, -45, 0]: + self.assertEqual(clip_angle(a, 0, 180), 0) + for a in [0, 45, 90, 135, 180]: + self.assertEqual(clip_angle(a, 0, 180), a) + + """ + Testing in range for wrapping around angles + """ + for a in [-90, -80, 0, 44.99]: + self.assertEqual(clip_angle(a, 180, -90), -90) + for a in [45.01, 90, 135, 180, 180]: + self.assertEqual(clip_angle(a, 180, -90), 180) + for a in [-180, -135, -90]: + self.assertEqual(clip_angle(a, 180, -90), a) + + for a in [-180, -135, -90.01]: + self.assertEqual(clip_angle(a, 0, -180), -180) + for a in [-89.99, -45, 0]: + self.assertEqual(clip_angle(a, 0, -180), 0) + for a in [0, 45, 90, 135, -180]: + self.assertEqual(clip_angle(a, 0, -180), a) + + self.assertEqual(clip_angle(91, 180, 0), 180) + self.assertEqual(clip_angle(89, 180, 0), 0) + + self.assertEqual(clip_angle(91, -180, 0), -180) + self.assertEqual(clip_angle(89, -180, 0), 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_simulation_uplink.py b/tests/test_simulation_uplink.py index 9bf681ec..def02bb6 100644 --- a/tests/test_simulation_uplink.py +++ b/tests/test_simulation_uplink.py @@ -868,7 +868,7 @@ def test_beamforming_gains(self): # Physical pointing angles self.assertEqual(self.simulation.bs.antenna[0].azimuth, 0) self.assertEqual(self.simulation.bs.antenna[0].elevation, -10) - self.assertEqual(self.simulation.bs.antenna[1].azimuth, 180) + self.assertEqual(self.simulation.bs.antenna[1].azimuth, -180) self.assertEqual(self.simulation.bs.antenna[0].elevation, -10) # Change UE pointing