Skip to content

xwr.rsp.jax

Radar Signal Processing in Jax.

Info

This moduel mirrors the functionality of xwr.rsp.numpy.

Warning

This module is not automatically imported; you will need to explicitly import it:

from xwr.rsp import jax as rsp

Since jax is not declared as a required dependency, you will also need to install jax yourself (or install the jax extra with pip install xwr[jax]).

xwr.rsp.jax.AWR1642Boost

Bases: RSPJax

Radar Signal Processing for the AWR1642 or AWR1843 with TX2 disabled.

Antenna Array

The TI AWR1642Boost (or AWR1843Boost with TX2 disabled) has a 1x8 linear MIMO array:

1-1 1-2 1-3 1-4 2-1 2-2 2-3 2-4

Parameters:

Name Type Description Default
window bool | Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], bool]

whether to apply a hanning window. If bool, the same option is applied to all axes. If dict, specify per axis with keys "range", "doppler", "azimuth", and "elevation".

False
size Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], int]

target size for each axis after zero-padding, specified by axis. If an axis is not spacified, it is not padded.

{}
sample_swap bool

if True, swap the I and Q components when un-interleaving IIQQ data.

False
Source code in src/xwr/rsp/jax/rsp.py
class AWR1642Boost(RSPJax):
    """Radar Signal Processing for the AWR1642 or AWR1843 with TX2 disabled.

    !!! info "Antenna Array"

        The TI AWR1642Boost (or AWR1843Boost with TX2 disabled) has a
        1x8 linear MIMO array:
        ```
        1-1 1-2 1-3 1-4 2-1 2-2 2-3 2-4
        ```

    Args:
        window: whether to apply a hanning window. If `bool`, the same option
            is applied to all axes. If `dict`, specify per axis with keys
            "range", "doppler", "azimuth", and "elevation".
        size: target size for each axis after zero-padding, specified by axis.
            If an axis is not spacified, it is not padded.
        sample_swap: if `True`, swap the I and Q components when
            un-interleaving IIQQ data.
    """

    def mimo_virtual_array(
        self, rd: Complex64[Array, "#batch doppler tx rx range"]
    ) -> Complex64[Array, "#batch doppler el az range"]:
        batch, doppler, tx, rx, range = rd.shape
        # 1843Boost cast as 1642Boost
        if tx == 3:
            if rx != 4:
                raise ValueError(
                    f"Expected (tx, rx)=3x4 in 1843Boost -> 1642Boost "
                    f"emulation, got tx={tx} and rx={rx}.")
            rd = rd[:, :, [0, 2], :, :]
        else:
            if tx != 2 or rx != 4:
                raise ValueError(
                    f"Expected (tx, rx)=2x4, got tx={tx} and rx={rx}.")

        return rd.reshape(batch, doppler, 1, -1, range)

xwr.rsp.jax.AWR1843AOP

Bases: RSPJax

Radar Signal Processing for AWR1843AOP.

Antenna Array

In the TI AWR1843AOP, the MIMO virtual array is arranged in a 2D grid:

1-1 2-1 3-1   ^
1-2 2-2 3-2   | Up
1-3 2-3 3-3
1-4 2-4 3-4 (TX-RX pairs)

Parameters:

Name Type Description Default
window bool | Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], bool]

whether to apply a hanning window. If bool, the same option is applied to all axes. If dict, specify per axis with keys "range", "doppler", "azimuth", and "elevation".

False
size Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], int]

target size for each axis after zero-padding, specified by axis. If an axis is not specified, it is not padded.

{}
sample_swap bool

if True, swap the I and Q components when un-interleaving IIQQ data.

False
Source code in src/xwr/rsp/jax/rsp.py
class AWR1843AOP(RSPJax):
    """Radar Signal Processing for AWR1843AOP.

    !!! info "Antenna Array"

        In the TI AWR1843AOP, the MIMO virtual array is arranged in a 2D grid:
            ```
            1-1 2-1 3-1   ^
            1-2 2-2 3-2   | Up
            1-3 2-3 3-3
            1-4 2-4 3-4 (TX-RX pairs)
            ```

    Args:
        window: whether to apply a hanning window. If `bool`, the same option
            is applied to all axes. If `dict`, specify per axis with keys
            "range", "doppler", "azimuth", and "elevation".
        size: target size for each axis after zero-padding, specified by axis.
            If an axis is not specified, it is not padded.
        sample_swap: if `True`, swap the I and Q components when
            un-interleaving IIQQ data.
    """

    def mimo_virtual_array(
        self, rd: Complex64[Array, "#batch doppler tx rx range"]
    ) -> Complex64[Array, "#batch doppler el az range"]:
        _, _, tx, rx, _ = rd.shape
        if tx != 3 or rx != 4:
            raise ValueError(
                f"Expected (tx, rx)=3x4, got tx={tx} and rx={rx}.")

        return jnp.swapaxes(rd, 2, 3)

xwr.rsp.jax.AWR1843Boost

Bases: RSPJax

Radar Signal Processing for AWR1843Boost.

Antenna Array

In the TI AWR1843Boost, the MIMO virtual array has resolution 2x8, with a single 1/2-wavelength elevated middle antenna element:

TX-RX:  2-1 2-2 2-3 2-4           ^
1-1 1-2 1-3 1-4 3-1 3-2 3-3 3-4   | Up

Parameters:

Name Type Description Default
window bool | Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], bool]

whether to apply a hanning window. If bool, the same option is applied to all axes. If dict, specify per axis with keys "range", "doppler", "azimuth", and "elevation".

False
size Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], int]

target size for each axis after zero-padding, specified by axis. If an axis is not spacified, it is not padded.

{}
sample_swap bool

if True, swap the I and Q components when un-interleaving IIQQ data.

False
Source code in src/xwr/rsp/jax/rsp.py
class AWR1843Boost(RSPJax):
    """Radar Signal Processing for AWR1843Boost.

    !!! info "Antenna Array"

        In the TI AWR1843Boost, the MIMO virtual array has resolution 2x8, with
        a single 1/2-wavelength elevated middle antenna element:
        ```
        TX-RX:  2-1 2-2 2-3 2-4           ^
        1-1 1-2 1-3 1-4 3-1 3-2 3-3 3-4   | Up
        ```

    Args:
        window: whether to apply a hanning window. If `bool`, the same option
            is applied to all axes. If `dict`, specify per axis with keys
            "range", "doppler", "azimuth", and "elevation".
        size: target size for each axis after zero-padding, specified by axis.
            If an axis is not spacified, it is not padded.
        sample_swap: if `True`, swap the I and Q components when
            un-interleaving IIQQ data.
    """

    def mimo_virtual_array(
        self, rd: Complex64[Array, "#batch doppler tx rx range"]
    ) -> Complex64[Array, "#batch doppler el az range"]:
        batch, doppler, tx, rx, range = rd.shape
        if tx != 3 or rx != 4:
            raise ValueError(
                f"Expected (tx, rx)=3x4, got tx={tx} and rx={rx}.")

        mimo = jnp.zeros(
            (batch, doppler, 2, 8, range), dtype=jnp.complex64
        ).at[:, :, 0, 2:6, :].set(rd[:, :, 1, :, :]
        ).at[:, :, 1, 0:4, :].set(rd[:, :, 0, :, :]
        ).at[:, :, 1, 4:8, :].set(rd[:, :, 2, :, :])
        return mimo

    def elevation_aoa(
        self, iq: Complex64[Array, "batch slow tx rx fast"]
        | Int16[Array, "batch slow tx rx fast*2"]
    ) -> Float32[Array, "batch doppler range"]:
        """Estimate elevation angle of arrival (AoA).

        Args:
            iq: raw IQ data.

        Returns:
            Estimated elevation angle of arrival (AoA) in radians for each
                range-Doppler bin.
        """
        iq = iq_from_iiqq(iq, sample_swap=self.sample_swap)
        rd = self.doppler_range(iq)
        mimo = self.mimo_virtual_array(rd)[:, :, :, 2:-2]

        angle = jnp.angle(mimo)
        phase_diff: Float32[Array, "batch doppler range"] = jnp.median(
            angle[:, :, 0] - angle[:, :, 1], axis=3)
        el_angle = jnp.arcsin((phase_diff / jnp.pi + 1) % 2 - 1)
        return el_angle

elevation_aoa

elevation_aoa(
    iq: Complex64[Array, "batch slow tx rx fast"]
    | Int16[Array, "batch slow tx rx fast*2"],
) -> Float32[Array, "batch doppler range"]

Estimate elevation angle of arrival (AoA).

Parameters:

Name Type Description Default
iq Complex64[Array, 'batch slow tx rx fast'] | Int16[Array, 'batch slow tx rx fast*2']

raw IQ data.

required

Returns:

Type Description
Float32[Array, 'batch doppler range']

Estimated elevation angle of arrival (AoA) in radians for each range-Doppler bin.

Source code in src/xwr/rsp/jax/rsp.py
def elevation_aoa(
    self, iq: Complex64[Array, "batch slow tx rx fast"]
    | Int16[Array, "batch slow tx rx fast*2"]
) -> Float32[Array, "batch doppler range"]:
    """Estimate elevation angle of arrival (AoA).

    Args:
        iq: raw IQ data.

    Returns:
        Estimated elevation angle of arrival (AoA) in radians for each
            range-Doppler bin.
    """
    iq = iq_from_iiqq(iq, sample_swap=self.sample_swap)
    rd = self.doppler_range(iq)
    mimo = self.mimo_virtual_array(rd)[:, :, :, 2:-2]

    angle = jnp.angle(mimo)
    phase_diff: Float32[Array, "batch doppler range"] = jnp.median(
        angle[:, :, 0] - angle[:, :, 1], axis=3)
    el_angle = jnp.arcsin((phase_diff / jnp.pi + 1) % 2 - 1)
    return el_angle

xwr.rsp.jax.AWR2944EVM

Bases: RSPJax

Radar Signal Processing for AWR2944EVM.

Antenna Array

The AWR2944EVM has a virtual array on a 2x12 grid:

        2-1 2-2 2-3 2-4
1-1 1-2 1-3 1-4 3-1 3-2 3-3 3-4 4-1 4-2 4-3 4-4
The horizontal spacing is 1/2 wavelength, and the vertical spacing is 0.8 wavelength.

Parameters:

Name Type Description Default
window bool | Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], bool]

whether to apply a hanning window. If bool, the same option is applied to all axes. If dict, specify per axis with keys "range", "doppler", "azimuth", and "elevation".

False
size Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], int]

target size for each axis after zero-padding, specified by axis. If an axis is not spacified, it is not padded.

{}
Source code in src/xwr/rsp/jax/rsp.py
class AWR2944EVM(RSPJax):
    """Radar Signal Processing for AWR2944EVM.

    !!! info "Antenna Array"

        The AWR2944EVM has a virtual array on a 2x12 grid:
        ```
                2-1 2-2 2-3 2-4
        1-1 1-2 1-3 1-4 3-1 3-2 3-3 3-4 4-1 4-2 4-3 4-4
        ```
        The horizontal spacing is 1/2 wavelength, and the vertical spacing is
        0.8 wavelength.

    Args:
        window: whether to apply a hanning window. If `bool`, the same option
            is applied to all axes. If `dict`, specify per axis with keys
            "range", "doppler", "azimuth", and "elevation".
        size: target size for each axis after zero-padding, specified by axis.
            If an axis is not spacified, it is not padded.
    """

    SAMPLE_TYPE = "I"

    def mimo_virtual_array(
        self, rd: Complex64[Array, "#batch doppler tx rx range"]
    ) -> Complex64[Array, "#batch doppler el az range"]:
        batch, doppler, tx, rx, range = rd.shape
        mimo = jnp.zeros(
            (batch, doppler, 2, 12, range), dtype=jnp.complex64
        ).at[:, :, 0, 2:6, :].set(rd[:, :, 1, :, :]
        ).at[:, :, 1, 0:4, :].set(rd[:, :, 0, :, :]
        ).at[:, :, 1, 4:8, :].set(rd[:, :, 2, :, :]
        ).at[:, :, 1, 8:12, :].set(rd[:, :, 3, :, :])
        return mimo

    def elevation_azimuth(
        self, rd: Complex64[Array, "#batch doppler tx rx range"]
    ) -> Complex64[Array, "#batch doppler el az range"]:
        """Calculate elevation-azimuth spectrum from range-doppler spectrum.

        !!! warning

            Special treatment is needed for the AWR2944EVM since the two
            rows of virtual elements are 0.8 wavelength apart instead of
            0.5. We compute the DTFT along the elevation axis with the
            steering matrix corresponding to the 0.8 lambda spacing.

        Args:
            rd: range-doppler spectrum.

        Returns:
            Computed elevation-azimuth spectrum, with windowing and padding if
                specified.
        """
        mimo = self.mimo_virtual_array(rd)

        if self.window.get("elevation", self._default_window):
            mimo = self.hann(mimo, 2)
        if self.window.get("azimuth", self._default_window):
            mimo = self.hann(mimo, 3)

        az_size = self.size.get("azimuth", mimo.shape[3])
        spectrum = self.fft(mimo, axes=(3,), shift=(3,), size=(az_size,))

        el_size = self.size.get("elevation", mimo.shape[2])
        sin_theta = jnp.linspace(-1, 1, el_size)
        el_elements = jnp.arange(mimo.shape[2])
        phases = -2j * jnp.pi * 0.8 * jnp.outer(sin_theta, el_elements)
        steering_matrix = jnp.exp(phases).astype(jnp.complex64)

        el_az_spectrum = jnp.einsum(
            'bdear,ke->bdkar',
            spectrum, steering_matrix, optimize=True
        )

        return el_az_spectrum

elevation_azimuth

elevation_azimuth(
    rd: Complex64[Array, "#batch doppler tx rx range"],
) -> Complex64[Array, "#batch doppler el az range"]

Calculate elevation-azimuth spectrum from range-doppler spectrum.

Warning

Special treatment is needed for the AWR2944EVM since the two rows of virtual elements are 0.8 wavelength apart instead of 0.5. We compute the DTFT along the elevation axis with the steering matrix corresponding to the 0.8 lambda spacing.

Parameters:

Name Type Description Default
rd Complex64[Array, '#batch doppler tx rx range']

range-doppler spectrum.

required

Returns:

Type Description
Complex64[Array, '#batch doppler el az range']

Computed elevation-azimuth spectrum, with windowing and padding if specified.

Source code in src/xwr/rsp/jax/rsp.py
def elevation_azimuth(
    self, rd: Complex64[Array, "#batch doppler tx rx range"]
) -> Complex64[Array, "#batch doppler el az range"]:
    """Calculate elevation-azimuth spectrum from range-doppler spectrum.

    !!! warning

        Special treatment is needed for the AWR2944EVM since the two
        rows of virtual elements are 0.8 wavelength apart instead of
        0.5. We compute the DTFT along the elevation axis with the
        steering matrix corresponding to the 0.8 lambda spacing.

    Args:
        rd: range-doppler spectrum.

    Returns:
        Computed elevation-azimuth spectrum, with windowing and padding if
            specified.
    """
    mimo = self.mimo_virtual_array(rd)

    if self.window.get("elevation", self._default_window):
        mimo = self.hann(mimo, 2)
    if self.window.get("azimuth", self._default_window):
        mimo = self.hann(mimo, 3)

    az_size = self.size.get("azimuth", mimo.shape[3])
    spectrum = self.fft(mimo, axes=(3,), shift=(3,), size=(az_size,))

    el_size = self.size.get("elevation", mimo.shape[2])
    sin_theta = jnp.linspace(-1, 1, el_size)
    el_elements = jnp.arange(mimo.shape[2])
    phases = -2j * jnp.pi * 0.8 * jnp.outer(sin_theta, el_elements)
    steering_matrix = jnp.exp(phases).astype(jnp.complex64)

    el_az_spectrum = jnp.einsum(
        'bdear,ke->bdkar',
        spectrum, steering_matrix, optimize=True
    )

    return el_az_spectrum

xwr.rsp.jax.AWRL6844EVM

Bases: RSPJax

Radar Signal Processing for AWRL6844.

Antenna Array

The AWRL6844 has a 4x4 MIMO virtual array with λ/2 spacing:

2-1 2-4 1-1 1-4   ^
2-2 2-3 1-2 1-3   | Up
3-1 3-4 4-1 4-4
3-2 3-3 4-2 4-3 (TX-RX pairs)

TX Phase Relationship

TX1 and TX3 are in phase with each other. TX2 and TX4 are also in phase with each other, but are 180° out of phase with TX1 and TX3. Their contributions to the virtual array are negated accordingly.

Source: Table 3-1, EVM User's Guide: AWRL6844EVM IWRL6844EVM.

Parameters:

Name Type Description Default
window bool | Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], bool]

whether to apply a hanning window. If bool, the same option is applied to all axes. If dict, specify per axis with keys "range", "doppler", "azimuth", and "elevation".

False
size Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], int]

target size for each axis after zero-padding, specified by axis. If an axis is not specified, it is not padded.

{}
Source code in src/xwr/rsp/jax/rsp.py
class AWRL6844EVM(RSPJax):
    """Radar Signal Processing for AWRL6844.

    !!! info "Antenna Array"

        The AWRL6844 has a 4x4 MIMO virtual array with λ/2 spacing:
        ```
        2-1 2-4 1-1 1-4   ^
        2-2 2-3 1-2 1-3   | Up
        3-1 3-4 4-1 4-4
        3-2 3-3 4-2 4-3 (TX-RX pairs)
        ```

    !!! info "TX Phase Relationship"

        TX1 and TX3 are in phase with each other. TX2 and TX4 are also in
        phase with each other, but are 180° out of phase with TX1 and TX3.
        Their contributions to the virtual array are negated accordingly.

        Source: Table 3-1, *EVM User's Guide: AWRL6844EVM IWRL6844EVM*.

    Args:
        window: whether to apply a hanning window. If `bool`, the same option
            is applied to all axes. If `dict`, specify per axis with keys
            "range", "doppler", "azimuth", and "elevation".
        size: target size for each axis after zero-padding, specified by axis.
            If an axis is not specified, it is not padded.
    """

    SAMPLE_TYPE = "I"

    def mimo_virtual_array(
        self, rd: Complex64[Array, "#batch doppler tx rx range"]
    ) -> Complex64[Array, "#batch doppler el az range"]:
        _, _, tx, rx, _ = rd.shape
        if tx != 4 or rx != 4:
            raise ValueError(
                f"Expected (tx, rx)=4x4, got tx={tx} and rx={rx}.")

        tx_idx = jnp.array(
            [[1, 1, 0, 0], [1, 1, 0, 0], [2, 2, 3, 3], [2, 2, 3, 3]])
        rx_idx = jnp.array(
            [[0, 3, 0, 3], [1, 2, 1, 2], [0, 3, 0, 3], [1, 2, 1, 2]])
        phase = jnp.array(
            [[-1, -1, 1, 1], [-1, -1, 1, 1], [1, 1, -1, -1], [1, 1, -1, -1]],
            dtype=jnp.float32)
        return rd[:, :, tx_idx, rx_idx, :] * phase[None, None, :, :, None]

xwr.rsp.jax.CACFAR

Bases: CACFAR[Array]

Cell-averaging CFAR.

Source code in src/xwr/rsp/jax/spectrum.py
class CACFAR(base.CACFAR[Array]):
    """Cell-averaging CFAR."""

    def _noise(
        self, signal: Float[Array, "range doppler"]
    ) -> Float[Array, "range doppler"]:
        """Get the ring-averaged noise floor for a range-doppler image."""
        mask = jnp.asarray(self.mask)
        # Jax currently only supports 'fill', but this should be changed to
        # 'wrap' if they ever decide to add support; the training cell count
        # is normalized out to compensate at the edges.
        valid = convolve2d(jnp.ones_like(signal), mask, mode="same")
        return convolve2d(signal, mask, mode="same") / valid

    def _cfar(
        self, signal_cube: Float[Array, "batch doppler channel range"]
    ) -> base.Detection[Array]:
        # Offset by 1 to prevent division by zero for SNR calculations.
        signal = jnp.sum(signal_cube**2, axis=2).transpose(0, 2, 1) + 1
        _, s_r, _ = signal.shape

        noise_r = jax.vmap(self._noise)(signal)

        near, far = self.discard_r[0], self.discard_r[1]
        noise = jnp.ones_like(signal).at[:, near : s_r - far].set(
            noise_r[:, near : s_r - far])

        snr = signal / noise
        obj_mask = jnp.zeros(signal.shape, dtype=bool).at[
            :, near : s_r - far].set(snr[:, near : s_r - far] > self.snr_thresh)

        return base.Detection(obj_mask, signal, snr)

xwr.rsp.jax.CASOCFAR

Bases: CASOCFAR[Array]

Cell-averaging Smallest of CFAR.

Source code in src/xwr/rsp/jax/spectrum.py
class CASOCFAR(base.CASOCFAR[Array]):
    """Cell-averaging Smallest of CFAR."""

    @staticmethod
    def _caso(
        signal: Float[Array, "..."],
        axis: int,
        train: int,
        pad: int,
        snr: float,
    ) -> tuple[Bool[Array, "..."], Float[Array, "..."]]:
        """Run 1D CFAR CASO along `axis` of an arbitrarily batched array.

        Implementation notes:

        - The training cells are a contiguous box on each side of the cell
            under test, so the leading and trailing one-sided means are
            accumulated directly from shifted slices, rather than correlated
            against a mostly zero kernel.
        - `train` is a static Python int, so the sum unrolls at trace time.

        Args:
            signal: signal, already padded by `pad` on both ends of `axis`.
            axis: axis to run CFAR along.
            train: number of training cells on each side.
            pad: number of training plus guard cells on each side.
            snr: signal to noise ratio threshold, as a linear power ratio.

        Returns:
            detection mask and noise level, with `axis` trimmed by `2 * pad`
                back to the unpadded length.
        """
        size = signal.shape[axis] - 2 * pad

        def one_sided(start: int) -> Float[Array, "..."]:
            acc = jax.lax.slice_in_dim(signal, start, start + size, axis=axis)
            for i in range(1, train):
                acc = acc + jax.lax.slice_in_dim(
                    signal, start + i, start + i + size, axis=axis)
            return acc / train

        noise = jnp.minimum(one_sided(0), one_sided(2 * pad + 1 - train))
        cut = jax.lax.slice_in_dim(signal, pad, pad + size, axis=axis)
        return cut > snr * noise, noise

    def _cfar(
        self, signal_cube: Float[Array, "batch doppler channel range"]
    ) -> base.Detection[Array]:
        # Offset by 1 to prevent division by zero for SNR calculations.
        signal = jnp.sum(signal_cube**2, axis=2).transpose(0, 2, 1) + 1
        _, s_r, _ = signal.shape

        near, far = self.discard_r[0], self.discard_r[1]
        sig_discard = signal[:, near : s_r - far]
        sig_pad_r = jnp.concat(
            (
                sig_discard[:, : self.pad_r],
                sig_discard,
                sig_discard[:, -self.pad_r :],
            ),
            axis=1,
        )
        sig_pad_d = jnp.pad(
            signal, ((0, 0), (0, 0), (self.pad_d, self.pad_d)), mode="wrap"
        )

        detect_r, noise_r = self._caso(
            sig_pad_r, 1, self.train_r, self.pad_r, self.snr_r)
        detect_r = jnp.pad(detect_r, ((0, 0), (near, far), (0, 0)))
        noise = jnp.pad(
            noise_r, ((0, 0), (near, far), (0, 0)), constant_values=1)
        detect_d, _ = self._caso(
            sig_pad_d, 2, self.train_d, self.pad_d, self.snr_d)

        snr = signal / noise
        obj_mask = jnp.logical_and(detect_r, detect_d)

        return base.Detection(obj_mask, signal, snr)

xwr.rsp.jax.CalibratedSpectrum

Bases: Generic[TRSP]

Radar processing with zero-doppler calibration.

Zero Doppler Calibration

Due to the antenna geometry and radar returns from the data collection rig which is mounted rigidly to the radar, the radar spectrum has a substantial constant offset in the zero-doppler bins.

  • We assume that the range-Doppler plots are sparse, and take the median across a number of sample frames for the zero-doppler bin to estimate this offset.
  • If a hanning window is applied, we instead calculate the offset across doppler bins [-1, 1] to account for doppler bleed.
  • This calculated offset is subtracted from the calculated spectrum.

Parameters:

Name Type Description Default
rsp TRSP

RSP pipeline to use.

required
Source code in src/xwr/rsp/jax/spectrum.py
class CalibratedSpectrum(Generic[TRSP]):
    """Radar processing with zero-doppler calibration.

    !!! info "Zero Doppler Calibration"

        Due to the antenna geometry and radar returns from the data collection
        rig which is mounted rigidly to the radar, the radar spectrum has a
        substantial constant offset in the zero-doppler bins.

        - We assume that the range-Doppler plots are sparse, and take the
          median across a number of sample frames for the zero-doppler bin to
          estimate this offset.
        - If a hanning window is applied, we instead calculate the offset
          across doppler bins `[-1, 1]` to account for doppler bleed.
        - This calculated offset is subtracted from the calculated spectrum.

    Args:
        rsp: RSP pipeline to use.
    """

    def __init__(
        self,
        rsp: TRSP,
    ) -> None:
        self.rsp = rsp

    def calibration_patch(
        self,
        sample: Complex64[Array, "n slow tx rx fast"]
        | Int16[Array, "n slow tx rx fast2"],
        batch: int = 1,
    ) -> Float32[Array, "doppler el az range"]:
        """Create a calibration patch for zero-doppler correction.

        Args:
            sample: sample IQ data to use for calibration.
            batch: sample size for RSP processing. Uses batch size `1` by
                default; should evenly divide the number of samples.

        Returns:
            Patch of the doppler-range-azimuth image which should be subracted
                from the zero-doppler bins of the range-doppler-angle spectrum.
        """
        sample = iq_from_iiqq(sample)

        s0 = self.rsp(sample[:batch])
        shape = s0.shape[1:]

        zero = shape[0] // 2
        start, stop = zero, zero + 1
        if "doppler" in self.rsp.window:
            start -= 1
            stop += 1
        self.slice = (slice(None), slice(start, stop))

        @jax.jit
        def _calib(frames) -> Float32[Array, "batch slice az el range"]:
            return jnp.abs(self.rsp(frames))[self.slice]

        batched = sample.reshape(-1, batch, *sample.shape[1:])
        slices = [s0[self.slice]] + [_calib(batch) for batch in batched]
        return jnp.median(jnp.concatenate(slices, axis=0))

    def __call__(
        self,
        iq: Complex64[Array, "#batch doppler tx rx range"]
        | Int16[Array, "#batch doppler tx rx range2"],
        calib: Float32[Array, "doppler el az range"],
    ) -> Float32[Array, "batch doppler el az range"]:
        """Run radar spectrum processing pipeline.

        !!! note

            After subtracting the calibration patch, any negative values are
            clipped to zero.

        Args:
            iq: batch of IQ data to run.
            calib: calibration patch to apply.

        Returns:
            Doppler-elevation-azimuth-range real spectrum, with zero doppler
                correction applied.
        """
        raw = jnp.abs(self.rsp(iq))
        return raw.at[self.slice].set(jnp.maximum(raw[self.slice] - calib, 0.0))

__call__

__call__(
    iq: Complex64[Array, "#batch doppler tx rx range"]
    | Int16[Array, "#batch doppler tx rx range2"],
    calib: Float32[Array, "doppler el az range"],
) -> Float32[Array, "batch doppler el az range"]

Run radar spectrum processing pipeline.

Note

After subtracting the calibration patch, any negative values are clipped to zero.

Parameters:

Name Type Description Default
iq Complex64[Array, '#batch doppler tx rx range'] | Int16[Array, '#batch doppler tx rx range2']

batch of IQ data to run.

required
calib Float32[Array, 'doppler el az range']

calibration patch to apply.

required

Returns:

Type Description
Float32[Array, 'batch doppler el az range']

Doppler-elevation-azimuth-range real spectrum, with zero doppler correction applied.

Source code in src/xwr/rsp/jax/spectrum.py
def __call__(
    self,
    iq: Complex64[Array, "#batch doppler tx rx range"]
    | Int16[Array, "#batch doppler tx rx range2"],
    calib: Float32[Array, "doppler el az range"],
) -> Float32[Array, "batch doppler el az range"]:
    """Run radar spectrum processing pipeline.

    !!! note

        After subtracting the calibration patch, any negative values are
        clipped to zero.

    Args:
        iq: batch of IQ data to run.
        calib: calibration patch to apply.

    Returns:
        Doppler-elevation-azimuth-range real spectrum, with zero doppler
            correction applied.
    """
    raw = jnp.abs(self.rsp(iq))
    return raw.at[self.slice].set(jnp.maximum(raw[self.slice] - calib, 0.0))

calibration_patch

calibration_patch(
    sample: Complex64[Array, "n slow tx rx fast"]
    | Int16[Array, "n slow tx rx fast2"],
    batch: int = 1,
) -> Float32[Array, "doppler el az range"]

Create a calibration patch for zero-doppler correction.

Parameters:

Name Type Description Default
sample Complex64[Array, 'n slow tx rx fast'] | Int16[Array, 'n slow tx rx fast2']

sample IQ data to use for calibration.

required
batch int

sample size for RSP processing. Uses batch size 1 by default; should evenly divide the number of samples.

1

Returns:

Type Description
Float32[Array, 'doppler el az range']

Patch of the doppler-range-azimuth image which should be subracted from the zero-doppler bins of the range-doppler-angle spectrum.

Source code in src/xwr/rsp/jax/spectrum.py
def calibration_patch(
    self,
    sample: Complex64[Array, "n slow tx rx fast"]
    | Int16[Array, "n slow tx rx fast2"],
    batch: int = 1,
) -> Float32[Array, "doppler el az range"]:
    """Create a calibration patch for zero-doppler correction.

    Args:
        sample: sample IQ data to use for calibration.
        batch: sample size for RSP processing. Uses batch size `1` by
            default; should evenly divide the number of samples.

    Returns:
        Patch of the doppler-range-azimuth image which should be subracted
            from the zero-doppler bins of the range-doppler-angle spectrum.
    """
    sample = iq_from_iiqq(sample)

    s0 = self.rsp(sample[:batch])
    shape = s0.shape[1:]

    zero = shape[0] // 2
    start, stop = zero, zero + 1
    if "doppler" in self.rsp.window:
        start -= 1
        stop += 1
    self.slice = (slice(None), slice(start, stop))

    @jax.jit
    def _calib(frames) -> Float32[Array, "batch slice az el range"]:
        return jnp.abs(self.rsp(frames))[self.slice]

    batched = sample.reshape(-1, batch, *sample.shape[1:])
    slices = [s0[self.slice]] + [_calib(batch) for batch in batched]
    return jnp.median(jnp.concatenate(slices, axis=0))

xwr.rsp.jax.PointCloud

Bases: PointCloud[Array]

Get radar point cloud from post FFT cube.

Source code in src/xwr/rsp/jax/aoa.py
class PointCloud(base.PointCloud[Array]):
    """Get radar point cloud from post FFT cube."""

    def aoa(
        self, cube: Float32[Array, "batch range doppler el az"]
    ) -> Int[Array, "batch range doppler 2"]:
        el, az = cube.shape[-2:]
        idx = jnp.argmax(cube.reshape(*cube.shape[:-2], el * az), -1)
        return jnp.stack((idx // az, idx % az), axis=-1)

    def __call__(
        self,
        cube: Float32[Array, "batch doppler el az range"],
        mask: Bool[Array, "batch range doppler"],
    ) -> base.DensePoints[Array]:
        el_angles = jnp.asarray(self._angle_table(cube.shape[2]))
        az_angles = jnp.asarray(self._angle_table(cube.shape[3]))

        _, r_size, d_size = mask.shape
        range_v = jnp.arange(r_size) * self.range_res
        doppler_v = (jnp.arange(d_size) - d_size // 2) * self.doppler_res
        r_grid, d_grid = jnp.meshgrid(range_v, doppler_v, indexing="ij")

        # (batch doppler el az range) -> (batch range doppler el az)
        angle_idx = self.aoa(jnp.moveaxis(cube, -1, 1))
        ang_e = el_angles[angle_idx[..., 0]]
        ang_a = az_angles[angle_idx[..., 1]]
        mask_e = jnp.logical_and(ang_e < self.el_fov, ang_e > -self.el_fov)
        mask_a = jnp.logical_and(ang_a < self.az_fov, ang_a > -self.az_fov)
        mask_ang = jnp.logical_and(mask_a, mask_e)

        x = r_grid * jnp.cos(-ang_a) * jnp.cos(ang_e)
        y = r_grid * jnp.sin(-ang_a) * jnp.cos(ang_e)
        z = r_grid * jnp.sin(ang_e)
        v = jnp.broadcast_to(d_grid, x.shape)

        pc_mask = jnp.logical_and(mask, mask_ang)
        pc = jnp.stack((x, y, z, v), axis=-1)

        return base.DensePoints(pc_mask, pc)

xwr.rsp.jax.RSPJax

Bases: RSP[Array], ABC

Base Radar Signal Processing with common functionality.

Parameters:

Name Type Description Default
window bool | Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], bool]

whether to apply a hanning window. If bool, the same option is applied to all axes. If dict, specify per axis with keys "range", "doppler", "azimuth", and "elevation".

False
size Mapping[Literal['range', 'doppler', 'azimuth', 'elevation'], int]

target size for each axis after zero-padding, specified by axis. If an axis is not spacified, it is not padded.

{}
sample_swap bool

if True, swap the I and Q components when un-interleaving IIQQ data.

False
Source code in src/xwr/rsp/jax/rsp.py
class RSPJax(RSP[Array], ABC):
    """Base Radar Signal Processing with common functionality.

    Args:
        window: whether to apply a hanning window. If `bool`, the same option
            is applied to all axes. If `dict`, specify per axis with keys
            "range", "doppler", "azimuth", and "elevation".
        size: target size for each axis after zero-padding, specified by axis.
            If an axis is not spacified, it is not padded.
        sample_swap: if `True`, swap the I and Q components when
            un-interleaving IIQQ data.
    """

    def fft(
        self, array: Complex64[Array, "..."] | Float32[Array, "..."],
        axes: tuple[int, ...],
        size: tuple[int, ...] | None = None,
        shift: tuple[int, ...] | None = None
    ) -> Complex64[Array, "..."]:
        if array.dtype == jnp.float32:
            fftd = jnp.fft.rfftn(array, s=size, axes=axes)
        else:
            fftd = jnp.fft.fftn(array, s=size, axes=axes)
        if shift is None:
            return fftd
        else:
            return jnp.fft.fftshift(fftd, axes=shift)

    @staticmethod
    def pad(
        x: Shaped[Array, "..."], axis: int, size: int
    ) -> Shaped[Array, "..."]:
        if size <= x.shape[axis]:
            raise ValueError(
                f"Cannot zero-pad axis {axis} to target size {size}, which is "
                f"less than or equal the current size {x.shape[axis]}.")

        shape = list(x.shape)
        shape[axis] = size - x.shape[axis]
        zeros = jnp.zeros(shape, dtype=x.dtype)

        return jnp.concatenate([x, zeros], axis=axis)

    @staticmethod
    def hann(
        x: Complex64[Array, "..."] | Float32[Array, "..."], axis: int
    ) -> Complex64[Array, "..."] | Float32[Array, "..."]:
        hann = jnp.hanning(x.shape[axis] + 2)[1:-1]
        broadcast: list[None | slice] = [None] * x.ndim
        broadcast[axis] = slice(None)
        return x * (hann / jnp.mean(hann))[tuple(broadcast)]

    def azimuth_aoa(
        self, iq: Complex64[Array, "batch slow tx rx fast"]
        | Int16[Array, "batch slow tx rx fast*2"]
    ) -> Int[Array, "batch doppler range"]:
        """Estimate angle of arrival (AoA).

        !!! note

            The AOA bin resolution is determined by the number of bins this
            RSP instance is configured with.

        Args:
            iq: raw IQ data.

        Returns:
            Estimated angle of arrival (AoA) index for each range-Doppler bin.
        """
        spec: Complex64[Array, "batch doppler el az range"] = self(iq)
        az_spec: Float32[Array, "batch doppler az range"] = (
            jnp.mean(jnp.abs(spec), axis=2))
        return jnp.argmax(az_spec, axis=2)

azimuth_aoa

azimuth_aoa(
    iq: Complex64[Array, "batch slow tx rx fast"]
    | Int16[Array, "batch slow tx rx fast*2"],
) -> Int[Array, "batch doppler range"]

Estimate angle of arrival (AoA).

Note

The AOA bin resolution is determined by the number of bins this RSP instance is configured with.

Parameters:

Name Type Description Default
iq Complex64[Array, 'batch slow tx rx fast'] | Int16[Array, 'batch slow tx rx fast*2']

raw IQ data.

required

Returns:

Type Description
Int[Array, 'batch doppler range']

Estimated angle of arrival (AoA) index for each range-Doppler bin.

Source code in src/xwr/rsp/jax/rsp.py
def azimuth_aoa(
    self, iq: Complex64[Array, "batch slow tx rx fast"]
    | Int16[Array, "batch slow tx rx fast*2"]
) -> Int[Array, "batch doppler range"]:
    """Estimate angle of arrival (AoA).

    !!! note

        The AOA bin resolution is determined by the number of bins this
        RSP instance is configured with.

    Args:
        iq: raw IQ data.

    Returns:
        Estimated angle of arrival (AoA) index for each range-Doppler bin.
    """
    spec: Complex64[Array, "batch doppler el az range"] = self(iq)
    az_spec: Float32[Array, "batch doppler az range"] = (
        jnp.mean(jnp.abs(spec), axis=2))
    return jnp.argmax(az_spec, axis=2)