Nullspace EM Tutorial Series: Phased Array Beamforming Tutorial

This is part 3 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 demonstrates the post-processing capabilities for beamforming using the 4-element antenna array created in the previous tutorial.

Beamforming is a technique in which an antenna array is used to "steer" or direct radio signals toward a specific direction, rather than broadcasting energy uniformly in all directions within a sector. By controlling the amplitude and phase of the signal emitted from each antenna element, the resulting transmitted wave can be focused constructively in a desired direction or suppressed destructively in others. This approach enables precise beam control and enhanced signal strength in the targeted region.

The 4-element antenna array model used here is similar to the one that was created in the previous tutorial and is shown in Fig. 1.

Fig. 1 4-element antenna array

Model setup

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: 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 single antenna element as described in detail in the first tutorial.

CAD Model and Meshing

Begin by opening Prep and setting your working directory to a folder where you plan to complete this example.

Model the 4-element antenna array as described in the previous tutorial. Material and port assignments, as well as meshing strategies, remain unchanged. To minimize the grating lobes the distance between elements in array is decreased and is now close to lambda/2.


#{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 ### creating a single surface for a common ground plane

#{GND = Id("surface")} #changing GND ID to the common one

rotate Volume all angle 90 about Y include_merged preview 

rotate Volume all angle 90 about Y include_merged 

imprint all

merge all

###assigning 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 surface {dipole_top} {dipole_bot} material 'PEC'

nsem assign surface 34 35 51 52 68 69 material 'PEC'

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

surface all scheme pave

#{mesh=0.3/1.5*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

###GND meshing

surface {GND} size {mesh/1.5}

mesh surface {GND} 

###substrate meshing

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 "Beamforming.cub5" overwrite journal

Simulation Setup

Simulation setup is identical to the previous tutorial, with the only change in scanning step to allow better accuracy while beamforming. 

step = 1 #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))

Post-Processing

Create postprocessing.py to extract simulation data and visualize beamforming effects.

Beamforming is performed by adjusting the amplitude and phase at each of the four elements. In this example, two scenarios are evaluated:

  1. 10-degree tilt using equal amplitudes (1,1,1,1).

  2. 10-degree tilt with side-lobe suppression using unequal amplitudes (0.5, 1, 1, 0.5).


import matplotlib.pyplot as plt

import numpy as np

from scipy.signal import find_peaks

from nsem.postprocessing import *

from scipy.interpolate import CubicSpline

#Function to find weight at scan angle

def maximize_pattern_at_scan(ff, th, ph, th_scan, ph_scan):

    th_idx, _ = post.get_nearest(th, th_scan)

    ph_idx, _ = post.get_nearest(ph, ph_scan)

    # Extract far fields at scan angle: shape becomes [N_VS, N_f, 2]

    ff_scan = ff[ph_idx, th_idx, :, :, :]

    # Conjugate match for maximum gain (per element, per frequency, per polarization)

    weights = np.conj(ff_scan)

    # Normalize weights by total power

    norm = np.sqrt(np.sum(np.abs(weights) ** 2, axis=0, keepdims=True))

    weights_normalized = weights / norm

    return weights_normalized[:, :, 0], weights_normalized[:, :, 1]  # co-pol, cross-pol

config = 'Beamforming'

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

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

freq_report = post_report.get_frequencies()

theta = post_report.get_obs_theta()

phi = post_report.get_obs_phi()

th_scan = 90.0

ph_scan = 10.0 #set up the scan angle

w_co, w_cx = maximize_pattern_at_scan(post_report.get_far_fields(), theta, phi, th_scan, ph_scan)

# amplitude/phase tapering

N = w_co.shape[0]

amps = np.array([1, 1, 1, 1], dtype=float)[:N]

w_co *= amps[:, None]

# active S-parameters with the new weights

active_s = post.get_active_s_parameters(freq_evaluate=freq_report, weights=w_co)

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, :]))

gain = post_report.get_gain('spherical', weights=w_co)  # get the gain

# plots

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

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

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

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

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

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

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

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

ax1.set_xlabel('Frequency (GHz)')

ax1.set_ylabel('dB')

ax1.set_title('Active S-parameters')

ax1.grid(True)

ax1.legend()

Fig. 2 Active S-parameters, [1, 1, 1, 1], 10-degree tilt

#calculating indexes 

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

phi0_idx = np.argmin(np.abs(phi - 0))

phi90_idx = np.argmin(np.abs(phi - 90))

theta90_idx = np.argmin(np.abs(theta - 90))

theta0_idx = np.argmin(np.abs(theta - 0))

freq_idx, freq_val = post.get_nearest(freq_report, 1)

# Max gain

gain_0 = gain[:, :, 0, freq_idx, 1]

max_gain = np.max(gain_0)

ph_idx_scan, ph_val_scan = post.get_nearest(phi, ph_scan)

th_idx_scan, th_val_scan = post.get_nearest(theta, th_scan)

#Printing max gain and tilt info

print(f'Max gain = {max_gain:.2f}dBi at Theta={th_scan:.1f}deg, Phi={ph_scan:.1f}deg')

print(f'Beam tilt = {ph_scan:.1f}deg')

# Find first upper side lobe and SLS

cut = gain_0[:, th_idx_scan]

main_idx = np.argmax(cut)

main_gain = cut[main_idx]

peaks, _ = find_peaks(cut)

side_lobe_candidates = [p for p in peaks if p > main_idx]  # Find all local peaks

if side_lobe_candidates:

    first_sidelobe_idx = side_lobe_candidates[0]

    first_sidelobe_gain = cut[first_sidelobe_idx]

    SLS = abs(first_sidelobe_gain - main_gain)

    print(f'First upper sidelobe = {first_sidelobe_gain:.1f}dBi (SLS = {SLS:.1f}dB)')

else:

    print('No upper sidelobe found')

# Cuts

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[ph_idx_scan, th_idx_scan, 0, :, 1])

#Gain plot interpolation

x = np.asarray(freq_report).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 over frequency plot

ax2.plot(x_dense, y_dense, label=f'Gain at tilt {ph_scan:.1f}°', color='blue')

ax2.set_xlabel('Frequency (GHz)')

ax2.set_ylabel('Gain (dBi)')

ax2.set_title('Gain vs Frequency')

ax2.grid(True)

ax2.legend()

Fig. 3 Gain over frequency [1, 1, 1, 1], 10-degree tilt

# Cuts

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. 4 Elevation cut, [1, 1, 1, 1], 10-degree tilt

# 3D radiation pattern

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

fig3d.show()

plt.show()

Fig. 5 3D radiation pattern, [1,1,1,1], 10-degree tilt

Note that the script also searches for first upper side lobe level and calculates side lobe suppression. 

Max gain = 12.61dBi at Theta=90.0deg, Phi=10.0deg

Beam tilt = 10.0deg

First upper sidelobe = -0.5dBi (SLS = 13.1dB)

By changing amplitudes variable, implement unequal amplitude distribution. 

# amplitude/phase tapering

N = w_co.shape[0]

amps = np.array([0.5, 1, 1, 0.5], dtype=float)[:N]

w_co *= amps[:, None]

The result radiation pattern has better Side Lobe Suppression Level, but lower gain.

Max gain = 12.25dBi at Theta=90.0deg, Phi=10.0deg

Beam tilt = 10.0deg

First upper sidelobe = -8.6dBi (SLS = 20.9dB)

Fig. 6 Gain over frequency and Elevation, [0.5,1,1,0.5], 10-degree tilt

Previous
Previous

Electromagnetic Simulation Software Comparison: Nullspace EM vs. HFSS vs. CST Studio Suite

Next
Next

Nullspace EM Tutorial Series: Antenna Array Design