Antenna Array Installation Simulation Tutorial

This is part 5 of a 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 will progress 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 presents the modeling, simulation, and post-processing of an electrically large problem - a 4-element antenna array installed on a 10-meter telecom tower. The free-space performance of an antenna can differ significantly from its installed performance. Therefore, simulating the antenna array in its actual installation environment is essential for accurate performance evaluation. The final 3D geometry is shown in Figure 1.

Fig. 1 Integrated antenna array on a tower

Model setup

Preparation: Use the antenna array model from the third tutorial.

Units: CAD model is defined in millimeters, converted to meters during simulation.

Frequency sweep: 0.5–1.5 GHz (51 points).

Radiation data: Dense far‑field grid (2-degree step in theta and phi).

Extracted metrics: Active S-parameters, 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.

Complete the modeling of a 4-element antenna array as described in 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 preview 

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")}


import step "radome.step" heal group "radome" ### importing step file of the radome

#{radome = Id("volume")} ### getting a variable for the radome volume ID

move volume {radome} y -170 include_merged #### center the radome to array geometry

rotate Volume all angle 90 about Y include_merged preview 

rotate Volume all angle 90 about Y include_merged 

Import the STEP file for the telecom tower. Ensure that the file path is correctly specified. 

###tower import

import step "TelecomMast_simplified.step" heal group "Tower" 

#{tower = Id("volume")}

Position the tower so the antenna array is mounted on one of the poles.

move volume {tower} x -580 y -11000 z 130 include_merged

Simplify the mast geometry by removing unnecessary small features and surfaces. Some simplifications require decomposing a volume into simpler parts using the webcut function.

### 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


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. Ensure consistent assignment of positive and negative terminals across all ports.

nsem load material library 'materials.h5'

nsem assign volume substrate material 'FR-4' ###the original substrate 

nsem assign volume 7 13 19 material 'FR-4' ###the 3 copies

nsem assign volume {radome} material 'glassfiber' ### glass fiber material to the radome

nsem assign surface {dipole_top} {dipole_bot} material 'PEC' ###the original antenna

nsem assign surface 34 35 36 51 52 53 68 69 70 material 'PEC' ###the 3 copies

nsem assign surface {GND} material 'PEC' ### ground plane material

nsem assign volume {tower} 28 material 'PEC' ### telecom tower material


nsem voltage source 'port_001' pos surface {port_pos} neg surface {port_neg} impedance 50 

nsem voltage source 'port_002' pos surface 32 neg surface 33 impedance 50

nsem voltage source 'port_003' pos surface 49 neg surface 50 impedance 50

nsem voltage source 'port_004' pos surface 66 neg surface 67 impedance 50

Meshing

Repeat the meshing as shown in the previous tutorial. Use lambda/10 meshing for the telecom mast. 

###meshing

surface all scheme pave

#{mesh=0.3/1*1000/10}


###port meshing

group 'feed' add surface {port_pos} {port_neg} 32 33 49 50 66 67

curve common_to surface in feed interval 1

mesh surface in feed


###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}


### telecom tower meshing


###small features 

surface 102 size 20

mesh surface 102 

surface 224 size 20

mesh surface 224 


###rest of the 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

Generate a mesh quality plot to identify potential problem areas.

quality surface all shape global draw mesh

save cub5 "tower_array.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 = 'tower_array'

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.

import matplotlib.pyplot as plt

import numpy as np

from nsem.postprocessing import *  

from scipy.interpolate import CubicSpline


config = 'tower_array'

post = PostProcess(f'{config}_sim')  # create a post-processing object for the simulation

freq = post.get_frequencies()  # get the frequencies

freq = np.linspace(np.min(freq), np.max(freq), 501)  # create a frequency array for evaluation

w = [1.0, 1.0, 1.0, 1.0] # excitation of each port

active_s = post.get_active_s_parameters(freq_evaluate=freq,weights=w)

active_s11 = dB20(np.squeeze(active_s[0, :]))

active_s22 = dB20(np.squeeze(active_s[1, :]))

active_s33 = dB20(np.squeeze(active_s[2, :]))

active_s44 = dB20(np.squeeze(active_s[3, :]))


#plots

fig = plt.figure(figsize=(18, 6))

ax1 = plt.subplot(1, 3, 1)

ax2 = plt.subplot(1, 3, 2)

ax3 = plt.subplot(1, 3, 3, polar=True)


# active S-parameters

ax1.plot(freq, active_s11, label='active S11', color='blue')

ax1.plot(freq, active_s22, label='active S22', color='orange')

ax1.plot(freq, active_s33, label='active S33', color='green')

ax1.plot(freq, active_s44, label='active S44', color='red')

ax1.set_xlabel('Frequency (GHz)')

ax1.set_ylabel('S11 (dB)')

ax1.set_title('Active S-parameters')

ax1.grid(True)

ax1.legend()


Fig. 3 Active S11 for each port

post = PostProcess(f'{config}_report')  # create a post-processing object for the report

freq = post.get_frequencies()

theta = post.get_obs_theta()

phi = post.get_obs_phi()

gain = post.get_gain('spherical',weights=w)  #get gain with specified amplitude/phase distribution

theta_idx,_ = post.get_nearest(theta, 0.0)

phi0_idx,_  = post.get_nearest(phi, 0.0)

phi90_idx,_  = post.get_nearest(phi, 90.0)

theta90_idx,_ = post.get_nearest(theta, 90.0)

theta0_idx,_ = post.get_nearest(theta, 0.0)

freq_idx,_ = post.get_nearest(freq, 1.0)


gain_0 = gain[phi0_idx, theta90_idx, 0, freq_idx, 1]

print(f"Boresight gain (dBi): {gain_0:.2f}")


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_xpol_theta90  = gain[:, theta90_idx, 0, freq_idx, 0]


#Gain plot interpolation

gain_max = np.squeeze(gain[phi0_idx, theta90_idx, 0, :, 1])

x = np.asarray(freq).ravel()

y = np.asarray(gain_max).ravel()

x_unique, idx = np.unique(x, return_index=True)

y_unique = y[idx]

cs = CubicSpline(x_unique, y_unique, bc_type='natural', extrapolate=False)

x_dense = np.linspace(x_unique[0], x_unique[-1], 501)

y_dense = cs(x_dense)


# Gain vs frequency 

ax2.plot(x_dense, y_dense, label='Gain with radome', 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

# Elevation and azimuth cuts

ax3.plot(np.deg2rad(phi), gain_copol_theta90, label=f'Co-pol theta=90°', color='green')

ax3.plot(np.deg2rad(theta), gain_copol_phi0, label=f'Azimuth cut', color='red')

ax3.set_xlabel('phi (deg)')

ax3.set_ylabel('Gain (dBi)')

ax3.set_title('Cuts')

ax3.grid(True)

ax3.legend()

fig.tight_layout()

Fig. 5 Elevation and Azimuth cuts

#3D radiation pattern

fig3d,ax3d = post.plotPolar3D(gain[:,:,0,freq_idx,1], -20.0,20.0)

fig3d.show()

plt.show()

Fig. 6 3D gain pattern

Next
Next

Radome Design Impact on Antenna Array Performance