Skip to content

Planar Helical Antenna Array

Download as:

Helical antennas enable easier polarization matching, and thus a stronger received signal, than a combination of linearly polarized antennas which typically require complicated and lossy polarization switching circuits.

Although helical antennas are typically curved 3D structures, planar designs using vias have been proposed in literature. This notebook simulates such a quasi-planar design. The limited angular coverage of a single antenna is counteracted by introducing a phased array of 8 elements.

The array demonstrated in this notebook is designed to operate near the 28 GHz 5G high band, based on the design proposed by Syrytsin et al. in [1].

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import flex_rf.tidy3d as rf
import flex_rf.web as web
rf.config.logging.level = 'ERROR'

Key geometry dimensions are defined below. Missing measurements are estimated visually based on available information in [1].

mm = 1000 # Conversion factor to micron (default unit)
# Feed line
LF, WF = (1.486 * mm, 0.85 * mm) # Length and width of feed line
feed_offset = 5 * mm # Length of feed line overhang
# Helical antenna design
alpha = 15 / 180 * np.pi # Helix pitch angle
ratio = 0.96 # Size ratio per turn
LH0, WH0 = (3.9 * mm, 0.97 * mm) # Initial size of trace
LHend = 1.65 * mm # Final size of 1/8 turn trace
VR = 0.1 * mm # Via radius
# Phased array parameters
spacing = 5.17 * mm # Spacing between elements
N_ant = 8 # Number of elements in array
# Substrate/layer dimensions
T = 0.035 * mm # Trace thickness
H = 1 * mm # Substrate thickness
Lgnd, Wgnd = (60 * mm, 120 * mm) # Size of ground plane
Lsub, Wsub = (60 * mm, 129 * mm) # Size of substrate

The design frequency in [1] is the 28 GHz band of the 5G spectrum. Our approximate design has a central frequency of 29.5 GHz.

# Frequencies and bandwidth
(f_min, f_max) = (20e9, 36e9)
f_target = 29.5e9 # target operating frequency
freqs = np.unique(np.append(np.linspace(f_min, f_max, 201), f_target))

The substrate is Rogers RT5880 and the trace material is copper. Both materials are assumed to have constant loss over the frequency range.

med_lossy_sub = rf.FastDispersionFitter.constant_loss_tangent_model(2.2, 0.0009, (f_min, f_max))
med_metal = rf.LossyMetalMedium(conductivity=58, frequency_range=(f_min, f_max))
Best weighted RMS error: 1.34e-06 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:00

We commence to build the geometry below. Because of the repetitive nature of the design, we make use of user-defined functions to create the geometry. First, we define a function to create the planar traces of the helical structure.

def create_trace(size, angle, start_pt, rounded_ends=True):
"""Create trace geometry of given size and angle at start_pt."""
lt, wt = size
x0, y0, z0 = start_pt
x1, y1 = (x0 + lt * np.cos(angle), y0 + lt * np.sin(angle))
verts = [
(x0 + wt / 2 * np.sin(angle), y0 - wt / 2 * np.cos(angle)),
(x0 - wt / 2 * np.sin(angle), y0 + wt / 2 * np.cos(angle)),
(x1 - wt / 2 * np.sin(angle), y1 + wt / 2 * np.cos(angle)),
(x1 + wt / 2 * np.sin(angle), y1 - wt / 2 * np.cos(angle)),
]
geom1 = rf.PolySlab(vertices=verts, axis=2, slab_bounds=(z0, z0 + T))
if rounded_ends:
geom1 += rf.Cylinder(axis=2, center=(x0, y0, z0 + T / 2), length=T, radius=wt / 2)
geom1 += rf.Cylinder(axis=2, center=(x1, y1, z0 + T / 2), length=T, radius=wt / 2)
return geom1, (x1, y1)

The next function creates the vias forming the vertical transition of each helical turn. Note that the user-defined function also optionally returns a MeshOverrideStructure for mesh refinement purposes. This will be used later in the Grid section.

def create_via(center, radius, length, mesh_override=False, dl=None):
"""Create vertical via in helix antenna"""
geom = rf.Cylinder(axis=2, center=center, radius=radius, length=length)
if mesh_override:
rbox = rf.MeshOverrideStructure(geometry=geom.bounding_box, dl=dl)
return geom, rbox
else:
return geom, None

Now, we define a function that creates one single helical turn. The clip_end and end_length options allow for the turn to end prematurely, as in the final turn of the overall helical structure. In addition to the created geometries, the function also returns the start position of the next turn to enable easy generation of multiple turns. The MeshOverrideStructure instances for the vertical vias are also returned if enabled.

def create_planar_helical_turn(
start_pt,
start_size,
ratio,
helix_angle,
via_radius,
thickness,
clip_end=False,
end_length=None,
mesh_override=False,
dl=None,
):
"""Creates one turn of a planar helical antenna"""
x0, y0, z0 = start_pt
lt0, wt0 = start_size
wt1 = wt0 * ratio
lt1 = end_length * ratio if clip_end else lt0 * ratio
# Via
g1, rbox1 = create_via(
center=(x0, y0, z0), radius=via_radius, length=thickness, mesh_override=mesh_override, dl=dl
)
# Top
g2, (x1, y1) = create_trace(
size=(lt0, wt0), angle=helix_angle, start_pt=(x0, y0, z0 + thickness / 2)
)
# Via
g3, rbox2 = create_via(
center=(x1, y1, z0), radius=via_radius, length=thickness, mesh_override=mesh_override, dl=dl
)
# Btm
g4, (x2, y2) = create_trace(
size=(lt1, wt1), angle=np.pi - helix_angle, start_pt=(x1, y1, z0 - thickness / 2 - T)
)
return rf.GeometryGroup(geometries=[g1, g2, g3, g4]), (x2, y2), [rbox1, rbox2]

Finally, the function below creates the whole antenna structure.

def create_planar_antenna(
start_pt,
start_size,
turns,
ratio,
via_radius,
helix_angle,
thickness,
mesh_override=False,
dl=None,
):
"""Create planar helical antenna based on [1]"""
# if turns < 1 return nothing
if turns <= 1:
return None
# Create first 1/8th of a turn
x0, y0, z0 = start_pt
lt0, wt0 = start_size
geom_ant, (x_next, y_next) = create_trace(
size=(lt0 / 2, wt0), angle=np.pi - helix_angle, start_pt=(x0, y0, z0 - thickness / 2 - T)
)
# iterate over turns
rbox_list = []
lt, wt = (lt0 * ratio, wt0 * ratio)
for ii in range(turns):
clip_end = ii == turns - 1
gturn, (x_next, y_next), rboxes = create_planar_helical_turn(
start_pt=(x_next, y_next, z0),
start_size=(lt, wt),
ratio=ratio,
helix_angle=helix_angle,
via_radius=via_radius,
thickness=thickness,
clip_end=clip_end,
end_length=LHend,
mesh_override=mesh_override,
dl=dl,
)
geom_ant += gturn
rbox_list += rboxes
lt, wt = (lt * ratio * ratio, wt * ratio * ratio)
return geom_ant, rbox_list

We make use of the previously defined functions to create all the necessary geometry. Note that we also define rbox_list_ant that stores a list of MeshOverrideStructures around each vertical via.

# Create geometries
geom_list_ant = []
rbox_list_ant = [] # List of mesh refinement geometries
geom_list_feed = []
xstart = -(N_ant - 1) / 2 * spacing
# Use for loop to create each antenna in the array
for ii in range(N_ant):
xpos = xstart + ii * spacing
g_ant, rbox_ant = create_planar_antenna(
start_pt=(xpos, LF, -H / 2),
start_size=(LH0, WH0),
turns=4,
ratio=ratio,
via_radius=VR,
helix_angle=alpha,
thickness=H,
mesh_override=True,
dl=(VR / 2, VR / 2, None),
)
geom_list_ant += [g_ant]
rbox_list_ant += rbox_ant
geom_list_feed += [
rf.Box.from_bounds(rmin=(xpos - WF / 2, -feed_offset, -H - T), rmax=(xpos + WF / 2, LF, -H))
]
geom_ant = rf.GeometryGroup(geometries=geom_list_ant)
geom_feed = rf.GeometryGroup(geometries=geom_list_feed)
geom_gnd = rf.Box.from_bounds(rmin=(-Lgnd / 2, -Wgnd, 0), rmax=(Lgnd / 2, 0, T))
geom_sub = rf.Box.from_bounds(rmin=(-Lsub / 2, -Wgnd, -H), rmax=(Lsub / 2, Wsub - Wgnd, 0))

The geometries are then combined with materials into Structure instances ready for simulation.

# Create structures
str_sub = rf.Structure(geometry=geom_sub, medium=med_lossy_sub)
str_gnd = rf.Structure(geometry=geom_gnd, medium=med_metal)
str_feed = rf.Structure(geometry=geom_feed, medium=med_metal)
str_ant = rf.Structure(geometry=geom_ant, medium=med_metal)
structure_list = [str_sub, str_gnd, str_feed, str_ant]

As is standard for antenna simulations, we introduce a wavelength/2 padding on all sides to the simulation boundary. The boundaries are automatically terminated with Perfectly Matched Layers (PMLs) by default.

# Define simulation size and center
padding = rf.C_0 / f_min / 2
sim_LX = Lsub + 2 * padding
sim_LY = Wsub + 2 * padding
sim_LZ = H + 2 * padding
sim_center = geom_sub.center

We define LayerRefinementSpec instances to automatically refine the mesh along each metal plane.

# Define layer refinement
lr_options = {
"corner_refinement": rf.GridRefinement(dl=0.5 * mm, num_cells=2),
"min_steps_along_axis": 1,
"axis": 2,
}
lr1 = rf.LayerRefinementSpec(
center=(0, 0, T / 2),
size=(rf.inf, rf.inf, T),
min_steps_along_axis=1,
axis=2,
corner_finder=None,
)
lr2 = rf.LayerRefinementSpec(
center=(0, 0, -H - T / 2),
size=(rf.inf, rf.inf, T),
min_steps_along_axis=1,
axis=2,
corner_finder=None,
)

The overall grid specification is defined below. The maximum grid size is set based on the user-specified number of steps per minimum wavelength. The previously defined rbox_list_ant is also included so that the vertical vias have adequate resolution to ensure electrical connectivity.

# Define overall grid specification
grid_spec = rf.GridSpec.auto(
wavelength=rf.C_0 / f_max,
min_steps_per_wvl=20,
layer_refinement_specs=[lr1, lr2],
override_structures=rbox_list_ant,
)

We define a field monitor for near-field visualization.

# Field Monitor
mon_1 = rf.FieldMonitor(
center=(0, 0, -H / 2),
size=(rf.inf, rf.inf, 0),
freqs=[f_min, f_target, f_max],
name="field in-plane",
)

To calculate the far-field radiation pattern, we define a DirectivityMonitor below. Note that the azimuthal angle phi should range between 00 and 2π2\pi (as opposed to π-\pi to π\pi) for the phased array calculations later.

# Directivity Monitor
theta = np.linspace(0, np.pi, 91)
phi = np.linspace(0, 2 * np.pi, 181)
mon_radiation = rf.DirectivityMonitor(
center=sim_center,
size=(0.9 * sim_LX, 0.9 * sim_LY, 0.9 * sim_LZ),
freqs=[f_target],
name="radiation",
phi=phi,
theta=theta,
)

The antenna array is excited by an array of lumped ports located at the end of each feed line. The ports are numbered 1-8 from left to right.

# Create lumped ports
Zref = 100
port_list = []
for ii in range(N_ant):
xpos = xstart + ii * spacing
port_list += [
rf.LumpedPort(
center=(xpos, -feed_offset, -H / 2),
size=(WF, 0, H),
voltage_axis=2,
name=f"LP{ii + 1}",
impedance=Zref,
)
]

Defining Simulation and TerminalComponentModeler

Section titled “Defining Simulation and TerminalComponentModeler”

The overall simulation and TerminalComponentModeler instances are defined below.

sim = rf.Simulation(
size=(sim_LX, sim_LY, sim_LZ),
center=sim_center,
grid_spec=grid_spec,
structures=structure_list,
monitors=[mon_1],
run_time=3e-9,
plot_length_units="mm",
)
tcm = rf.TerminalComponentModeler(
simulation=sim,
ports=port_list,
freqs=freqs,
radiation_monitors=[mon_radiation],
)

Before running the simulation, we visualize the setup and grid below.

# Plot structures and grid on each layer (or interface between two layers)
fig, ax = plt.subplots(3, 1, figsize=(10, 10), tight_layout=True)
tcm.plot_sim(z=0, ax=ax[0])
tcm.simulation.plot_grid(z=0, ax=ax[0])
tcm.plot_sim(z=-H / 2, ax=ax[1], monitor_alpha=0)
tcm.simulation.plot_grid(z=-H / 2, ax=ax[1])
tcm.plot_sim(z=-H, ax=ax[2])
tcm.simulation.plot_grid(z=-H, ax=ax[2])
for axis in ax:
axis.set_xlim(-N_ant / 2 * spacing, N_ant / 2 * spacing)
axis.set_ylim(-1 * mm, 11 * mm)
plt.show()

# Show lumped ports
fig, ax = plt.subplots(figsize=(10, 4), tight_layout=True)
tcm.plot_sim(y=-feed_offset, ax=ax, monitor_alpha=0)
ax.set_xlim(-20 * mm, 20 * mm)
ax.set_ylim(-2 * mm, 1 * mm)
plt.show()

# Show set up in 3D viewer
sim.plot_3d()

The simulation is executed below.

tcm_data = web.run(
tcm, task_name="planar_helical_antenna_array", path="data/planar_helical_ant_array.hdf5", verbose=False
)

The field monitor data corresponding to lumped port 4 is loaded below.

sim_data = tcm_data.data["LP4"]

The field magnitude data at the operating frequency is plotted below.

fig, ax = plt.subplots(figsize=(10, 10), tight_layout=True)
sim_data.plot_field(
"field in-plane",
field_name="E",
val="abs",
scale="lin",
f=f_target,
ax=ax,
)
ax.set_xlim(-40 * mm, 40 * mm)
ax.set_ylim(-140 * mm, 15 * mm)
plt.show()

We define some convenience functions to extract the individual S-parameters from the simulation data.

# Calculate full S-matrix
smat = tcm_data.smatrix()
# Convenience functions to get S_ij
# Note that port_in and port_out are zero-indexed (first port is number zero)
def sparam(i, j):
return np.conjugate(smat.data.isel(port_in=j - 1, port_out=i - 1))
def sparam_abs(i, j):
return np.abs(sparam(i, j))
def sparam_dB(i, j):
return 20 * np.log10(sparam_abs(i, j))

The diagonal S-parameters are plotted below. The target operating frequency is indicated with the black dashed line. All 8 antennas resonate reasonably well near the operating frequency.

# Plot diagonal S-parameters
fig, ax = plt.subplots(figsize=(10, 5))
for ii in range(N_ant):
ax.plot(freqs / 1e9, sparam_dB(ii + 1, ii + 1), label=f"S{ii + 1}{ii + 1}")
ax.axline(
(f_target / 1e9, 0), (f_target / 1e9, -30), ls="--", color="black", label="Target frequency"
)
ax.grid()
ax.legend()
ax.set_xlabel("f (GHz)")
ax.set_ylabel("dB")
plt.show()

Below, we visualize the S-matrix (dB) at the target frequency. Good isolation is observed between adjacent elements (off-diagonal matrix values).

SdB_f_target = 20 * np.log10(np.abs(smat.data.sel(f=f_target, method="nearest")))
# 2D plot S-matrix (dB)
qq, pp = np.meshgrid(np.arange(1, 9), np.arange(1, 9))
fig, ax = plt.subplots(figsize=(8, 6), tight_layout=True)
pcm = ax.pcolormesh(qq, pp, SdB_f_target, shading="nearest", cmap="viridis_r")
cbar = fig.colorbar(pcm)
ax.set_xlabel("Element number")
ax.set_ylabel("Element number")
ax.set_title("S-matrix (dB) at 29.5 GHz")
ax.set_aspect(1)
plt.show()

We can specify arbitrary combinations of port feed patterns using the port_amplitudes parameter of get_antenna_metrics_data(). This allows us to visualize the radiation pattern for different scan angles, as well as generate the total scan pattern.

The port_amplitudes parameter accepts a dictionary where each key corresponds to the port name, e.g. LP1, and the value is a complex number representing the port phase and amplitude. Below, we use a for loop to generate a list of feed patterns corresponding to different phase shifts between each antenna element (same amplitude).

# List of fractional phase shifts
target_frac = np.linspace(-1, 1, 33)
# Generate list of feed patterns
feed_patterns = []
for frac in target_frac:
phase_shift = np.pi * frac
feed_dict = {}
for ii in range(N_ant):
port_name = f"LP{ii + 1}"
feed_phase = np.exp(1j * phase_shift * ii)
feed_dict[port_name] = feed_phase
feed_patterns += [feed_dict]

Using the list of different feed patterns, we can then calculate a list of AntennaMetricsData data corresponding to each feed pattern. Note that the calculation can take some time for large datasets.

# Generate a list of antenna metrics corresponding to each feed pattern
AM_list = [tcm_data.get_antenna_metrics_data(port_amplitudes=feed) for feed in feed_patterns]

We visualize the azimuthal gain pattern in the x-y plane below. The main lobe of the array can be clearly observed to sweep from 45 degrees to 135 degrees as the phase difference between each element is changed. (For legibility reasons, only a subset of the scan patterns are shown and the plotting minimum range is set to -10 dB.)

# Extract gain from antenna metrics
gain_list = [am.gain for am in AM_list]
# Gain comparison plot
fig, ax = plt.subplots(figsize=(8, 8), tight_layout=True, subplot_kw={"projection": "polar"})
for ii, gain in enumerate(gain_list[::4]):
gplot = gain.sel(theta=np.pi / 2, method="nearest").squeeze()
ax.plot(phi, 10 * np.log10(gplot), label=f"Phase shift={target_frac[4 * ii] * 180:.0f} deg")
ax.set_title("Gain (dB) in azimuthal plane ($\\theta=90$ deg)", pad=30)
ax.set_rmin(-10)
ax.legend()
plt.show()

Using the LobeMeasurer utility, we can obtain metrics for the main and side lobes, such as direction, magnitude, and -3 dB beamwidth. Below, we measure the main lobe and the two adjacent side lobes for each feed pattern and record their properties in a pandas.DataFrame table.

# Get main and side lobe characteristics for each feed pattern
df_list = []
label_list = []
for ii, gain in enumerate(gain_list):
# Define string label for each feed pattern
label_list += [f"{target_frac[ii] * 180:.0f}"]
# Use rf.LobeMeasurer to get lobe metrics
data = gain.sel(theta=np.pi / 2, method="nearest").squeeze()
lm = rf.LobeMeasurer(angle=phi, radiation_pattern=data)
# Pick out main lobe and two adjacent side lobes
max_index = lm.lobe_measures["magnitude"].idxmax()
lobe_data = lm.lobe_measures.iloc[max_index - 1 : max_index + 2].reset_index()[
["direction", "magnitude", "beamwidth"]
]
# Append to lobe metrics to list
df_list += [lobe_data]
# Concatenate individual tables into one big table
lobe_data_table = pd.concat(df_list, keys=label_list)
# Re-arrange columns and convert units
lobe_data_table = lobe_data_table.unstack()
lobe_data_table.index.name = "Phase shift (deg)"
lobe_data_table["direction"] = lobe_data_table["direction"] / np.pi * 180
lobe_data_table["beamwidth"] = lobe_data_table["beamwidth"] / np.pi * 180
lobe_data_table = lobe_data_table.rename(
columns={
"direction": "Direction (deg)",
"magnitude": "Mag.",
"beamwidth": "-3dB Beamwidth (deg)",
},
level=0,
)
# Regroup columns by main/side lobes
lobe_data_table = lobe_data_table.sort_index(axis=1, level=1, sort_remaining=False).reorder_levels(
[1, 0], axis=1
)
# Relabel columns
lobe_data_table = lobe_data_table.rename(
columns={0: "Side Lobe 1", 1: "Main Lobe", 2: "Side Lobe 2"}, level=0
)
# Display table
lobe_data_table
Side Lobe 1 Main Lobe Side Lobe 2
Direction (deg) Mag. -3dB Beamwidth (deg) Direction (deg) Mag. -3dB Beamwidth (deg) Direction (deg) Mag. -3dB Beamwidth (deg)
Phase shift (deg)
-180 134.0 0.682953 12.720545 186.0 8.120710 29.549144 212.0 2.906620 55.812405
-169 308.0 0.087422 161.611726 354.0 8.414495 39.344977 NaN NaN NaN
-158 308.0 0.231002 121.501361 358.0 8.472639 35.043950 NaN NaN NaN
-135 110.0 1.321349 7.257779 128.0 4.288553 27.351207 182.0 0.078463 327.648428
-124 108.0 1.263536 7.465482 126.0 6.796161 19.413966 174.0 0.170118 89.604435
-112 104.0 1.261027 6.951683 124.0 8.492721 17.381187 186.0 0.115971 29.037099
-101 102.0 1.169070 7.067932 120.0 9.876102 15.379162 162.0 0.046030 15.425412
-90 98.0 1.060612 6.895332 118.0 10.930524 14.231357 156.0 0.088040 17.377893
-79 94.0 0.926611 7.255159 114.0 11.732276 13.397253 148.0 0.173131 15.853355
-68 92.0 0.876795 7.460206 110.0 11.925904 13.013011 144.0 0.280229 15.793165
-56 88.0 0.807721 7.504391 108.0 11.821983 12.883920 138.0 0.275909 15.972384
-45 84.0 0.736229 8.261822 104.0 12.136929 12.238208 128.0 0.192068 16.410444
-34 80.0 0.731166 8.984163 100.0 11.801035 12.085809 122.0 0.246953 9.968147
-22 78.0 0.767672 8.780864 98.0 10.988288 12.519639 118.0 0.327703 9.132031
-11 74.0 0.795083 8.349923 94.0 11.001050 12.307302 114.0 0.376754 8.910138
0 70.0 0.710948 9.215007 90.0 11.319326 12.060534 110.0 0.460517 8.490000
11 66.0 0.681140 8.799981 86.0 11.165157 12.648047 106.0 0.585586 8.615843
22 62.0 0.609008 9.290056 82.0 11.367168 12.863869 102.0 0.682140 8.196328
34 58.0 0.569061 9.171254 80.0 12.065424 12.738977 98.0 0.657080 8.245330
45 54.0 0.469909 9.532556 76.0 12.422077 13.148237 96.0 0.670646 8.052319
56 52.0 0.360117 9.986502 72.0 12.586578 13.362931 92.0 0.765948 7.682423
68 46.0 0.263818 67.729186 68.0 12.395055 13.514582 88.0 0.830143 7.838846
79 42.0 0.217301 95.463455 64.0 11.890734 13.759392 86.0 0.844440 7.847450
90 38.0 0.242401 70.923779 62.0 11.388302 13.852241 82.0 0.826120 8.428515
101 34.0 0.318174 71.909950 58.0 10.464647 13.989622 78.0 0.824247 8.588184
124 18.0 0.187895 112.807648 50.0 8.431613 14.197227 70.0 0.750322 9.359010
135 4.0 0.206699 29.442390 46.0 7.463610 14.481403 66.0 1.005146 8.108760
158 150.0 0.868708 70.530279 184.0 8.047600 28.680106 210.0 1.147629 68.534976
169 148.0 0.363946 92.392439 184.0 9.071629 29.196349 210.0 2.017853 56.078505
180 134.0 0.682953 12.720545 186.0 8.120710 29.549144 212.0 2.906620 55.812405

We can also combine all the gain patterns using np.max() to generate the total scan pattern. The total scan pattern shows the maximum achievable gain for the whole array at a given angle coordinate.

# Calculate total scan pattern
full_gain_list = 10 * np.log10(np.abs(np.array(gain_list))).squeeze()
scan_max = np.max(full_gain_list, axis=0) # max gain at given angle
# 2D plot of total scan pattern
qq, pp = np.meshgrid(phi / np.pi * 180, theta / np.pi * 180)
fig, ax = plt.subplots(figsize=(10, 6), tight_layout=True)
pcm = ax.pcolormesh(qq, pp, scan_max, shading="nearest", cmap="rainbow")
cbat = fig.colorbar(pcm)
ax.set_xlabel("Azimuthal angle (deg)")
ax.set_ylabel("Elevation angle (deg)")
ax.set_title("Total scan pattern (dBi)")
plt.show()

We calculate the axial ratio (AR) of the main lobe in the azimuthal (x-y) plane for each scan angle. We will use a subset of the scan pattern that covers the 45 to 135 degree arc. First, we locate the main lobe angle.

# Calculate main lobe azimuthal angle for each scan pattern
main_lobe_angles = []
for gain in gain_list[5:29]:
lobe_angle = phi[np.argmax(gain.sel(theta=np.pi / 2, method="nearest").squeeze().data)]
main_lobe_angles += [lobe_angle]
main_lobe_angles = np.array(main_lobe_angles)

Then, we extract the axial ratio at each scan angle.

# Extract AR for phi = main lobe angle, theta = 90 degrees
AR_list = [
am.axial_ratio.sel(theta=np.pi / 2, phi=main_lobe_angles[ii], method="nearest").squeeze()
for ii, am in enumerate(AM_list[5:29])
]

The AR is plotted against the scan angle below.

# Plot AR vs main lobe angle
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(main_lobe_angles / np.pi * 180, AR_list)
ax.grid()
ax.set_title("Axial ratio vs azimuthal scan angle")
ax.set_xlabel("Main lobe angle (degrees)")
ax.set_ylabel("Axial ratio")
plt.show()

[1] Igor Syrytsin, Zhang Shuai, and Gert Pedersen. “Circularly polarized planar helix phased antenna array for 5G mobile terminals.” In 2017 International Conference on Electromagnetics in Advanced Applications (ICEAA), pp. 1105-1108. IEEE, 2017.