Nullspace EM Tutorial Series: Antenna Array Design

This is part 2 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 a 4-element antenna array based on the over-the-ground bow-tie antenna created in the previous tutorial. Antenna arrays are employed in various applications where high gain and controlled beam patterns are required. The final 3D geometry is shown in Figure 1.

Fig. 1 4-element antenna array

Model setup

Preparation: Use the single bow-tie element model from the first tutorial.

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

Frequency sweep: 0.5–1.5 GHz.

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

Extracted metrics: Return loss, input impedance, 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 single antenna element as described in detail in the first tutorial.

#{inner_width=1.7}

#{outer_width=30}

#{leng=42}

#{port=1.7}

#{subH=0.767}

#{subW=140}

brick x {subW} y {subW} z {subH}

volume 1 rename "substrate"

move Volume substrate z {-subH/2} include_merged 

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

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

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

Now, replicate the bow-tie element to form a 4-element array. Ensure that spacing between elements lies within the range λ > D > λ/2 to prevent grating lobes. 

Volume 1 2 3 4 5 6 copy move x 0 y 300 z 0 repeat 3 group_results ###copying/moving the element along Y axis

rotate Volume all angle 90 about Y include_merged preview 

rotate Volume all angle 90 about Y include_merged 

imprint all

merge all

Materials & Voltage Sources

Use the provided Python script material.py to define a material library. Only FR-4 and PEC are used in this example. 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.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 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 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

nsem assign surface {GND} material 'PEC'


Meshing

Group the two port surfaces for easier reference and assign appropriate mesh sizes.

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

Assign the port mesh keeping in mind that the curve common to the voltage source surfaces for each feed should have a single mesh edge.

curve common_to surface in feed interval 1

mesh surface in feed

Apply a denser mesh to the antenna arms for improved accuracy. Assign mesh to the rest of the model normally.

surface all scheme pave

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

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

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} 36 53 70 size {mesh/1.5}

mesh surface {GND} 36 53 70

surface not is_meshed in volume 1 7 13 19 size {mesh/4}

mesh surface not is_meshed in volume 1 7 13 19

block 1 surface all

save cub5 "Bowtie_array.cub5" overwrite journal

Fig.2 Model with the final mesh.

Simulation Setup

Create a simulation.py file to configure the simulation parameters.

from nsem import *

import numpy as np

config = 'Bowtie_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.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 antenna array simulations, active S-parameters are more relevant than traditional return loss, as they also account for coupling between elements.

The new variable w defines the amplitude and phase excitation for each element. Here we use equal amplitude and zero phase shift.

import matplotlib.pyplot as plt

import numpy as np

from nsem.postprocessing import *

config = 'Bowtie_array'

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

freq = post.get_frequencies() # get the frequencies from the report

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

sparam = post.get_s_parameters(freq_evaluate=freq) # get the S-parameters

rl = dB20(np.squeeze(sparam[0, 0, :])) # convert S-parameters to dB scale

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

active_s = post.get_active_s_parameters(freq_evaluate=freq,weights=w) #get active s-parameters with defined weights

active_s11 = dB20(np.abs(np.mean(active_s, axis=0))) #get the combined active parameters for all 4 ports

#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 combined', color='blue')

ax1.set_xlabel('Frequency (GHz)')

ax1.set_ylabel('active S11 (dB)')

ax1.set_title('Active S-parameters')

ax1.grid(True)

ax1.legend()

Fig. 3 Combined active S11

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 vs frequency ---

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

ax2.plot(freq, gain_max, label='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

# Elevation cut

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

ax3.set_xlabel('phi (deg)')

ax3.set_ylabel('Gain (dBi)')

ax3.set_title('Elevation Cuts')

ax3.grid(True)

ax3.legend()

fig.tight_layout()

Fig. 6 Boresight gain over frequency

#pattern cut

ax4.plot(np.deg2rad(phi), gain_copol_theta90, label='Theta 90 copol', color='blue')

ax4.set_xlabel('Phi (deg)')

ax4.set_ylabel('Gain (dB)')

ax4.set_ylim([-30, 10])

ax4.grid(True)

ax4.legend()


fig.tight_layout()

fig.delaxes(ax6)  

fig.delaxes(ax5)  

Fig. 5 Gain plot along phi, theta = 90

#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

Nullspace EM Tutorial Series: Bow-Tie Antenna Simulation