Beamforming & Coupling: Simulating Dual Antenna Arrays on a Telecom Tower
This is the last of our 6-part tutorial series hosted by experienced antenna engineer Katerina Galitskaya who is documenting her experience learning Nullspace as a new user. Over six tutorials she progresses from simulating a single bow-tie antenna to building full arrays and exploring beamforming techniques, sharing direct comparisons to other full-wave 3D EM simulation tools.
The full video version of this tutorial is available to watch on YouTube.
Problem Statement
This tutorial covers the modeling, simulation, and post-processing of an electrically large problem consisting of two 4-element antenna arrays mounted on a 10-meter telecom tower. Modern cellular sites commonly host multiple antennas, making mutual coupling a critical performance consideration. The completed 3D geometry is shown in Figure 1.
Fig. 1 Two integrated antenna arrays on a tower
Model setup
Preparation: Use the installed antenna array model from the fifth tutorial.
Units: CAD model is defined in millimeters, converted to meters during simulation.
Frequency sweep: 0.5–1.5 GHz (11 points).
Radiation data: Dense far‑field grid (2-degree step in theta and phi).
Extracted metrics: S21 coupling between antennas, boresight gain, gain vs. frequency, 2D radiation cuts, and 3D radiation pattern.
CAD Model and Meshing
Begin by opening Prep and setting your working directory to a folder where you plan to complete this example.
Use the parameter definitions and modeling instructions from the previous tutorial.
#{inner_width=1.7}
#{outer_width=30}
#{leng=42}
#{port=1.7}
#{subH=0.767}
#{subW=140}
###substrate geometry
brick x {subW} y {subW} z {subH}
volume 1 rename "substrate"
move Volume substrate z {-subH/2} include_merged
###ports geometry
create vertex {-port/2} {-port/2} 0
#{v1 = Id("vertex")}
create vertex {port/2} {-port/2} 0
create vertex {port/2} {0} {port/4}
create vertex {-port/2} {0} {port/4}
create surface vertex {v1} to {v1+3}
#{port_pos = Id("surface")}
surface {port_pos} copy reflect y
#{port_neg = Id("surface")}
###antenna geometry
create curve location {-outer_width/2} {port/2+leng} 0 location {outer_width/2} {port/2+leng} 0
#{c1 = Id("curve")}
create curve location {-outer_width/2} {port/2+leng} 0 location {-inner_width/2} {port/2} 0
create curve location {outer_width/2} {port/2+leng} 0 location {inner_width/2} {port/2} 0
create curve location {-inner_width/2} {port/2} 0 location {inner_width/2} {port/2} 0
create surface curve {c1} to {c1+3}
#{dipole_top = Id("surface")}
surface {dipole_top} copy reflect y nomesh
#{dipole_bot = Id("surface")}
project surface {port_pos} {port_neg} onto surface 1 imprint
project surface {port_pos} {port_neg} {dipole_top} {dipole_bot} onto surface 2 imprint
###ground plane
create surface rectangle width 300 zplane
#{GND = Id("surface")}
#{gnd_H=70}
move Volume 6 z {-gnd_H} include_merged
###4-element array creation
Volume 1 2 3 4 5 6 copy move x 0 y 170 z 0 repeat 3 group_results
unite surface {GND} 36 53 70
#{GND = Id("surface")}
####radome import
import step "radome.step" heal group "radome"
#{radome = Id("volume")}
move volume {radome} y -170 include_merged
rotate Volume all angle 90 about Y include_merged preview
rotate Volume all angle 90 about Y include_merged
###tower import
import step "TelecomMast_simplified.step" heal group "Tower"
#{tower = Id("volume")}
move volume {tower} x -580 y -11000 z 130 include_merged
### Remove small features and surfaces from tower model
remove surface 181, 200 connected_sets
remove surface 88, 170, 199 connected_sets
select surface 183 include similar
remove surface 179, 183 connected_sets
select surface 198 118 203 include similar
remove surface 118, 198, 203, 204 connected_sets
remove surface 168
remove surface 182, 201 connected_sets
remove surface 154, 202 connected_sets
remove surface 180
remove surface 184
select surface 143 207 include similar
remove surface 143, 151, 206, 207 connected_sets
remove surface 187
remove surface 91, 92, 126 connected_sets
remove surface 122, 150, 158 connected_sets
remove surface 99, 134, 167 connected_sets
webcut volume 26 with sheet extended from surface 146
webcut volume 27 with sheet extended from surface 205
delete volume 27
remove surface 123, 127, 128 connected_sets
Copy and position the first array to the opposite pole.
Volume 1 2 3 4 5 6 7 8 9 10 11 13 14 15 16 17 19 20 21 22 23 25 copy
group 'second_array' add Volume 29 35 40 45 50 30 31 32 33 34 35 36 37 38 39 41 42 43 44 46 47 48 49
group second_array rotate 180 about Y include_merged
second_array move x -1160 y 0 z 250
imprint all
merge all
Materials & Voltage Sources
Use the material.py script to define the material library. Besides PEC, this model uses FR-4 and glass fiber. Open the Nullspace EM environment and load the material.py file before assigning materials.
from nsem import *
ml=MaterialLibrary()
ml.add_material(Material('FR-4', 'green', MaterialProperty('constant',real=4.4, losstan=0.02), MaterialProperty('constant',real=1.0, imag=0.0)))
ml.add_material(Material('glassfiber', 'grey', MaterialProperty('constant',real=4.7, losstan=0.09), MaterialProperty('constant',real=1.0, imag=0.0)))
ml.save('materials')
Assign materials and define four voltage sources per array. Ensure consistent assignment of positive and negative terminals across all ports.
###first array materials and ports
nsem load material library 'materials.h5'
nsem assign volume substrate material 'FR-4'
nsem assign volume 7 13 19 material 'FR-4'
nsem assign volume {radome} material 'glassfiber'
nsem assign surface {dipole_top} {dipole_bot} material 'PEC'
nsem assign surface 34 35 51 52 68 69 material 'PEC'
nsem assign volume {tower} 28 material 'PEC'
nsem assign surface {GND} material 'PEC'
nsem voltage source 'port_101' pos surface {port_pos} neg surface {port_neg} impedance 50
nsem voltage source 'port_102' pos surface 32 neg surface 33 impedance 50
nsem voltage source 'port_103' pos surface 49 neg surface 50 impedance 50
nsem voltage source 'port_104' pos surface 66 neg surface 67 impedance 50
###second array materials and ports
nsem assign volume 29 35 40 45 material 'FR-4'
nsem assign volume 50 material 'glassfiber'
nsem assign surface 227 226 243 244 259 260 275 276 material 'PEC'
nsem assign surface 228 material 'PEC'
nsem voltage source 'port_201' pos surface 224 neg surface 225 impedance 50
nsem voltage source 'port_202' pos surface 241 neg surface 242 impedance 50
nsem voltage source 'port_203' pos surface 257 neg surface 258 impedance 50
nsem voltage source 'port_204' pos surface 273 neg surface 274 impedance 50
Meshing
Repeat the meshing on the second array like the first one.
###meshing
surface all scheme pave
#{mesh=0.3/1*1000/10}
###first array meshing
#port meshing
group 'feed1' add surface {port_pos} {port_neg} 32 33 49 50 66 67
curve common_to surface in feed1 interval 1
mesh surface in feed1
#antenna meshing
surface {dipole_top} {dipole_bot} 34 35 51 52 68 69 size {mesh/4}
mesh surface {dipole_top} {dipole_bot} 34 35 51 52 68 69
surface 14 16 17 18 size {mesh/4}
mesh surface 14 16 17 18
surface {GND} size {mesh/1.5}
mesh surface {GND}
surface not is_meshed in volume 1 7 13 19 size {mesh/4}
mesh surface not is_meshed in volume 1 7 13 19
#radome meshing
surface not is_meshed in volume {radome} size {mesh}
mesh surface not is_meshed in volume {radome}
###second array meshing
group 'feed2' add surface 224 225 241 242 257 258 273 274
curve common_to surface in feed2 interval 1
mesh surface in feed2
#antenna meshing
surface 227 226 243 244 259 260 275 276 size {mesh/4}
mesh surface 227 226 243 244 259 260 275 276
surface 228 size {mesh/1.5}
mesh surface 228
surface not is_meshed in volume 29 35 40 45 size {mesh/4}
mesh surface not is_meshed in volume 29 35 40 45
#radome meshing
surface not is_meshed in volume 50 size {mesh}
mesh surface not is_meshed in volume 50
### telecom tower meshing
surface not is_meshed in volume {tower} size {mesh}
mesh surface not is_meshed in volume {tower}
surface not is_meshed in volume 28 size {mesh}
mesh surface not is_meshed in volume 28
block 1 surface all
quality surface all shape global draw mesh
save cub5 "two_arrays.cub5" overwrite journal
Fig. 2 Model with the mesh quality plotted.
Simulation Setup
Create a simulation.py file to configure the simulation parameters. Since the structure is electrically large, use the compression solver, which performs matrix compression and leverages near- and far-field coupling to efficiently represent the system.
from nsem import *
import numpy as np
config = 'two_arrays'
model = Configuration(f'{config}_sim') #configure simulation name
model.set_cub5_filename(f'{config}.cub5') #load the mesh file
model.set_order(1) #set basis order, default is 1
model.set_model_scale(0.001) #convertion to mm
model.set_frequencies(freq_beg=0.5, freq_end=1.5) #set frequency sweep from 0.5 to 1.5 GHz
model.set_solve_type(solver='COMPRESS') #change the solver type
model.save()
report = Report(f'{config}_report', f'{config}_sim') #create a report object
report.set_frequencies(np.linspace(0.5, 1.5, 11))
step = 2 #set the step for theta and phi angles
theta_scan = np.linspace(0, 180, int(180/step+1)) #theta angles from 0 to 180 degrees
phi_scan = np.linspace(0, 360, int(360/step)) #phi angles from 0 to 360 degrees
report.request_far_fields_grid(theta_scan, phi_scan) #request far-field data
report.save() #save the report configuration
model.run() #run the simulation
report.run() #run the report generation
Post-Processing
Create postprocessing.py to extract simulation data and visualize results. In this example, three scenarios are evaluated:
Both arrays scanned to boresight
Both arrays scanned 10-deg down
One array scanned 10-deg up, one array scanned 10-deg down
import numpy as np
import matplotlib.pyplot as plt
from nsem.postprocessing import *
#simulation data
config = 'two_arrays'
post = PostProcess(f'{config}_sim')
freq = np.asarray(post.get_frequencies())
s = post.get_s_parameters(freq_evaluate=freq)
S21 = s[4:8, 0:4, :] #array1 to array2
S12 = s[0:4, 4:8, :] #array2 to array1
#phase / weights for each array
def progressive_weights(N, phase_deg_per_elem=0.0):
k = np.deg2rad(phase_deg_per_elem)
w = np.exp(1j * k * np.arange(N))
return w.astype(complex)
Set up 0 phase shifts on both arrays in order to study case #1.
#phase progressions for the two arrays
w1 = progressive_weights(4, phase_deg_per_elem=0.0) #array1
w2 = progressive_weights(4, phase_deg_per_elem=0.0) #array2
#coupling vs frequency
C21 = np.einsum('i,ijk,j->k', np.conj(w2), S21, w1) #array1 to array2
C12 = np.einsum('i,ijk,j->k', np.conj(w1), S12, w2) #array2 to array1
# Convert to dB
coupling21_dB = 20 * np.log10(np.abs(C21))
coupling12_dB = 20 * np.log10(np.abs(C12))
#weights for all 8 ports to calculate gain and patterns
w_gain = np.zeros(8, dtype=complex)
w_gain[0:4] = w1 #array1 ports on
w_gain[4:8] = w2 #array2 ports on
post_gain = PostProcess(f'{config}_report') # create a post-processing object for the report
freq_gain = post_gain.get_frequencies()
theta = post_gain.get_obs_theta()
phi = post_gain.get_obs_phi()
gain = post_gain.get_gain('spherical',weights=w_gain) #get gain with specified amplitude/phase distribution
theta_idx,_ = post_gain.get_nearest(theta, 0.0)
phi0_idx,_ = post_gain.get_nearest(phi, 0.0)
phi90_idx,_ = post_gain.get_nearest(phi, 90.0)
theta90_idx,_ = post_gain.get_nearest(theta, 90.0)
theta0_idx,_ = post_gain.get_nearest(theta, 0.0)
freq_idx,_ = post_gain.get_nearest(freq_gain, 1.0)
gain_0 = gain[phi0_idx, theta90_idx, 0, freq_idx, 1]
print(f"Boresight gain (dBi): {gain_0:.2f}")
gain_max = np.squeeze(gain[phi0_idx, theta90_idx, 0, :, 1])
gain_copol_phi0 = gain[phi0_idx, :, 0, freq_idx,1]
gain_copol_phi90 = gain[phi90_idx, :, 0, freq_idx, 0]
gain_xpol_phi0 = gain[phi0_idx, :, 0, freq_idx, 0]
gain_xpol_phi90 = gain[phi90_idx, :, 0, freq_idx, 1]
gain_copol_theta90 = gain[:, theta90_idx, 0, freq_idx, 1]
gain_copol_theta0 = gain[:, theta0_idx, 0, freq_idx, 1]
gain_xpol_theta90 = gain[:, theta90_idx, 0, freq_idx, 0]
#Plots
fig = plt.figure(figsize=(12, 10))
gs = fig.add_gridspec(2, 2, height_ratios=[1, 1])
ax1 = fig.add_subplot(gs[0, :])
ax2 = fig.add_subplot(gs[1, 0])
ax3 = fig.add_subplot(gs[1, 1], projection='polar')
#Coupling vs frequency
ax1.plot(freq, coupling21_dB, label='C21')
ax1.plot(freq, coupling12_dB, '--', label='C12')
ax1.set_ylim(-130, 0)
ax1.set_xlabel('Frequency (GHz)')
ax1.set_ylabel('Coupling (dB)')
ax1.set_title('Coupling vs Frequency')
ax1.grid(True)
ax1.legend()
Fig. 3 Coupling between arrays for the 1st case.
# gain vs frequency
ax2.plot(freq_gain, gain_max, label='Installed Gain', color='blue')
ax2.set_xlabel('Frequency (GHz)')
ax2.set_ylabel('Gain (dBi)')
ax2.set_title('Boresight Gain vs Frequency')
ax2.grid(True)
ax2.legend()
Fig. 4 Boresight gain over frequency for one array
# Polar cut
ax3.plot(np.deg2rad(phi), gain_copol_theta90, color='green', label='Elevation cut (theta=90°)')
ax3.plot(np.deg2rad(theta), gain_copol_phi0, label=f'Azimuth cut', color='red')
ax3.set_title('Cuts')
ax3.set_theta_direction(-1)
ax3.grid(True)
ax3.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1))
plt.tight_layout()
Fig. 5 Elevation and Azimuth cuts: 1st case.
fig3d,ax3d = post_gain.plotPolar3D(gain[:,:,0,freq_idx,1], -20.0,20.0)
fig3d.show()
plt.show()
Fig. 6 3D gain pattern: 1st case.
Change phase shifts on both arrays in order to study case #2.
#phase progressions for the two arrays
w1 = progressive_weights(4, phase_deg_per_elem=-30.0) #array1
w2 = progressive_weights(4, phase_deg_per_elem=-30.0) #array2
Fig. 7 Elevation and Azimuth cuts: 2nd case.
Fig. 8 3D gain pattern: 2nd case.
Change phase shifts on one of the arrays in order to study case #3.
#phase progressions for the two arrays
w1 = progressive_weights(4, phase_deg_per_elem=-30.0) #array1
w2 = progressive_weights(4, phase_deg_per_elem=30.0) #array2
Fig. 9. Coupling between arrays for the 3rd case.
Fig. 10 Elevation and Azimuth cuts: 3rd case.
Fig. 11 3D gain pattern: 3rd case.