DOA beam pattern for circular array

Based on the tutorial for a linear array here, I've written the following code for a 6 microphone circular array. The array has a radius of 5cm. A beam pattern is generated but it doesn't look right at all.

This code generates the signals (I think properly?):

import numpy as np
import matplotlib.pyplot as plt

# Recording/positioning properties of the array
sample_rate = 16000
N = 10000  # number of samples to simulate
num_mics = 6

# Microphone positions in a circular array
theta_mics = np.linspace(0, 2 * np.pi, num_mics, endpoint=False)
mic_positions = radius * np.exp(1j * theta_mics)

# Find microphone angles
angles_deg = np.linspace(0, 360, num_mics, endpoint=False)  # Angles for each microphone

# Create tone
t = np.arange(N) / sample_rate
f_tone = 2000
tx = np.exp(2j * np.pi * f_tone * t).reshape(1,-1)

# Define onset angles - here we're using just 1
theta_deg = 45

# Compute phase shifts for each microphone

# Construct signal vector
s1 = phase_shifts.reshape(-1, 1)

# Combine for a single tone
r = s1 @ tx

n = np.random.randn(num_mics, N) + 1j*np.random.randn(num_mics, N)
r = r + 0.05*n


Plotting the signals looks like:

Then I'm using MUSIC to beamform the signal, and this is the code I've tried for that:

# For the MUSIC analysis
num_expected_signals = 1

# Construct the covariance matrix
R = r @ r.conj().T

# Do eigenvalue decomposition
w, v = np.linalg.eig(R)

# Find and sort first order of magnitude of eigenvalues
eig_val_order = np.argsort(np.abs(w))
v = v[:, eig_val_order]

# Noise subspace is the rest of the eigenvalues
V = np.zeros((num_mics, num_mics - num_expected_signals), dtype=np.complex64)

for i in range(num_mics - num_expected_signals):
V[:, i] = v[:, i]

# Perform MUSIC analysis
# between -180 and +180
theta_scan = np.linspace(-1*np.pi, np.pi, 1000)

results = []

for theta_i in theta_scan:
s = np.exp(-2j * np.pi * np.arange(num_mics) * np.sin(theta_i)).reshape(-1, 1)
metric = 1 / (s.conj().T @ V @ V.conj().T @ s)
metric = np.abs(metric.squeeze())
metric = 10 * np.log10(metric)
results.append(metric)
results -= np.max(results)



Which gives the following beam pattern. I know experimentally that the array can localise this kind of signal with high accuracy, so the additional lobes here feel incorrect.

Glad to hear pointers on any part of the code !

My guess (it's before my coffee has kicked in) is that this line in the signal generation:

phase_shifts = np.exp(-2j * np.pi * radius * np.sin(angles_rad - theta1))


doesn't take account of the 2D location of the circular array microphone locations.

See, for example, this derivation which says that the steering vector needs to incorporate both $$\sin$$ and $$\cos$$ terms:

$$ka\left(\cos(2\pi n/N ) \sin \theta \cos \phi + \sin(2\pi n/N ) \sin \theta \sin \phi\right)$$

(though I believe that equation (54) there incorrectly adds an extra $$a$$ in the second term).

I believe the steering vector in the code means the generated signals are assumed to come from a non-uniformly distributed array centered on the look direction.

Be gentle if my lack-of-coffee is causing AI-level hallucinations.