Inconsistent AoA Estimation from nRF52833 BLE CTE IQ Samples in the example "direction_finding_connectionless_rx"

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?

Related