Radome Design Impact on Antenna Array Performance
This is part 4 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 enclosed in a glass fiber radome. The radome effect is a crucial factor when working with antenna arrays, as it can detune the elements and reduce realized gain. The final 3D geometry is shown in Figure 1.
Fig. 1 Antenna array with a radome
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 the STEP file for the radome. Ensure that the file path is correctly specified.
import step "INSERT PATH HERE/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
imprint all
merge all
Materials & Voltage Sources
Use the material.py script to define the material library. In this example, we add glass fiber to the existing materials (FR-4 and PEC). 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 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
Repeat the meshing as shown in the previous tutorial. Use lambda/10 meshing for the radome part.
###meshing
surface all scheme pave
#{mesh=0.3/1*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} 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
surface not is_meshed in volume {radome} size {mesh}
mesh surface not is_meshed in volume {radome}
block 1 surface all
#save cub5 "radome_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 = 'radome_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.
import matplotlib.pyplot as plt
import numpy as np
from nsem.postprocessing import *
from scipy.interpolate import CubicSpline
config = 'radome_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 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. 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