Hello,
We’re Bachelor’s students developing a project to estimate the angle of arrival (AoA) using a BLE antenna array on the nRF52833. Our implementation builds on Zephyr’s direction_finding_connectionless_rx
example. We extract the IQ samples in the cte_recv_cb
callback and feed them into our angle-calculation routine, but our results are inconsistent—only rarely do we hit the correct value.
In our setup, the array alternates between Antenna 1 and Antenna 2, spaced 50 mm apart. In the following capture, the true AoA is 45 °, yet our algorithm fails to reproduce it:
CTE[0]: samples: 8-bit ints, count=45, CTE type=AoA, slot=2 µs, CRC=OK, RSSI=–690
IQ[0]: –5, –30 IQ[1]: 2, 29 IQ[2]: –1, –30 IQ[3]: 4, 26
IQ[4]: –3, –29 IQ[5]: –1, 28 IQ[6]: –1, –29 IQ[7]: –1, 25
IQ[8]: 24, –8 IQ[9]: 11, –26 IQ[10]: 26, 1 IQ[11]: 19, –25
IQ[12]: 26, 11 IQ[13]: 23, –16 IQ[14]: 21, 16 IQ[15]: 28, –10
IQ[16]: 12, 23 IQ[17]: 27, 2 IQ[18]: 4, 25 IQ[19]: 27, 8
IQ[20]: 0, 24 IQ[21]: 20, 15 IQ[22]: –11, 25 IQ[23]: 15, 24
IQ[24]: –20, 19 IQ[25]: 5, 29 IQ[26]: –28, 15 IQ[27]: 1, 28
IQ[28]: –29, 5 IQ[29]: –6, 25 IQ[30]: –24, –6 IQ[31]: –19, 22
IQ[32]: –23, –10 IQ[33]: –26, 15 IQ[34]: –22, –17 IQ[35]: –27, 10
IQ[36]: –12, –25 IQ[37]: –30, –1 IQ[38]: –6, –26 IQ[39]: –27, –12
IQ[40]: 6, –31 IQ[41]: –25, –17 IQ[42]: 10, –27 IQ[43]: –14, –26
IQ[44]: 19, –22
Below is the Python test script we wrote to (1) estimate and remove any constant phase rotation due to CFO, and (2) compute AoA from the inter-antenna phase difference:
import numpy as np
def estimate_cfo_phase_step(iq_samples):
"""Estimate per-sample phase drift (CFO)."""
# Average phase increment between consecutive samples
return np.angle(np.mean(iq_samples[1:] * np.conj(iq_samples[:-1])))
def remove_constant_phase_rotation(iq_samples, delta_phi):
"""Correct IQ stream by subtracting a constant phase rotation."""
n = np.arange(len(iq_samples))
return iq_samples * np.exp(-1j * delta_phi * n)
def calculate_aoa(iq_samples, freq_hz=2.4e9, antenna_spacing_m=0.05):
"""
Compute AoA (degrees) from paired IQ samples.
Args:
iq_samples (np.ndarray): Complex IQ vector alternating Antenna 1 and Antenna 2.
freq_hz (float): Carrier frequency (Hz).
antenna_spacing_m (float): Distance between Rx elements (m).
Returns:
float: Estimated angle of arrival in degrees.
"""
c = 299_792_458 # speed of light (m/s)
# Separate streams
ant1 = iq_samples[0::2]
ant2 = iq_samples[1::2]
# Truncate to equal length
n = min(len(ant1), len(ant2))
ant1, ant2 = ant1[:n], ant2[:n]
# Phase difference per pair
phase_diff = np.angle(ant2) - np.angle(ant1)
# Wrap to [–π, π]
phase_diff = np.arctan2(np.sin(phase_diff), np.cos(phase_diff))
# Mean phase difference
mean_phase = np.mean(phase_diff)
# sin(θ) = Δφ·c / (2π f d)
x = (c * mean_phase) / (2 * np.pi * freq_hz * antenna_spacing_m)
x = np.clip(x, -1.0, 1.0)
# Convert to degrees
theta_rad = np.arcsin(x)
return np.degrees(theta_rad)
We would appreciate it when you can help us, if we do our calculation wrong here.
Here’s a video showing our IQ values plotted over time—could this behavior be caused by hardware or configuration issues?