Angle estimation

ArrayGeometry, beamforming with conventional and MVDR weights, TDM phase compensation, FFT angle estimators and MUSIC in witwin.radar.processing.

This page covers the angle stage of witwin.radar.processing. All directions are in the array's local frame.

from witwin.radar.processing import (ArrayGeometry, BeamCube, beam_cube, conventional_steering, fft2_aoa,
                                     music_image, music_spectrum, mvdr_weights, phase_comparison_aoa,
                                     tdm_compensate)

The virtual array is ordered TX-major: pair p belongs to transmitter p // num_rx and receiver p % num_rx. A TDM-MIMO virtual element sits at the sum of its transmitter and receiver offsets. The offsets are the tx and rx positions of the radar, in its local frame: x to the right, y up, boresight along -z. For a planar array in the x-y plane, only the x and y parts of a direction change the steering phase.

ArrayGeometry#

class ArrayGeometry(element_positions_m, num_tx, num_rx, element_spacing_m, wavelength_m, phase_sign,
                    tx_loc_half_wavelength, rx_loc_half_wavelength)

Positions of every virtual element and the sign of its phase. Get one with frame.array().

Field or propertyMeaning
element_positions_m[P, 3] float64 virtual element positions, m, local frame, TX-major.
num_tx, num_rxArray size. sensor_pair_count is their product.
element_spacing_mOne unit of the element grid, m.
wavelength_mCarrier wavelength, m.
phase_signSign of the propagation phase, from the cube's phasor.
tx_loc_half_wavelength, rx_loc_half_wavelengthElement offsets in grid units.
spacing_wavelengthsGrid unit in wavelengths, 0.5 for a half-wavelength array.
transmitter_index, receiver_index[P] int64 index of each pair's transmitter and receiver.
deviceDevice of element_positions_m.

ArrayGeometry.from_axes#

ArrayGeometry.from_axes(axes) -> ArrayGeometry

Build from a ProcessingAxes. frame.array() calls this.

ArrayGeometry.from_offsets#

ArrayGeometry.from_offsets(tx_loc, rx_loc, *, element_spacing_m, wavelength_m, phase_sign=-1, device='cpu') -> ArrayGeometry

Build from two element-offset grids in units of element_spacing_m. Use it for an array that is not half-wavelength spaced.

conventional_steering#

conventional_steering(array, directions, *, normalize=True, dtype=torch.complex64) -> torch.Tensor

Phase-shift weights [P, *beam] for a grid of look directions.

arrayArrayGeometryrequired

The virtual array.

directionstorch.Tensorrequired

[*beam, 3] unit vectors from the array toward each look direction, local frame. Not normalized for you.

normalizebooldefault True

Scale the weights so w^H a = 1 for a matched wavefront. A beam cube then has the same amplitude convention as the range and Doppler stages.

dtypetorch.dtypedefault torch.complex64

Weight dtype.

mvdr_weights#

mvdr_weights(covariance, steering, *, diagonal_loading) -> torch.Tensor

Minimum-variance distortionless-response weights w = R^-1 a / (a^H R^-1 a), [..., P, B]. They satisfy the same w^H a = 1 constraint as conventional_steering, so the two are interchangeable in beam_cube.

covariancetorch.Tensorrequired

Hermitian [..., P, P] covariance.

steeringtorch.Tensorrequired

[P, *beam], as conventional_steering returns it.

diagonal_loadingfloatrequired

Loading as a fraction of trace(R) / P, added to the diagonal before the solve. No default: a sample covariance from fewer snapshots than elements is singular, and only you know the snapshot count.

beam_cube#

beam_cube(rd, steering, *, directions) -> BeamCube

Apply beamforming weights to a range-Doppler map: y[b] = sum_p conj(w[p, b]) · x[p].

rdRangeDopplerMaprequired

Map with the sensor pairs in the leading axes, [TX, RX, D, R] or [P, D, R].

steeringtorch.Tensorrequired

[P, *beam] weights.

directionstorch.Tensorrequired

[*beam, 3] grid the weights were steered over. Keyword only.

Returns BeamCube with data [*beam, D, R], axes, directions, and the range_axis and doppler_axis properties.

import torch
 
az = torch.linspace(-0.8, 0.8, 61, device=radar.device)
directions = torch.stack((torch.sin(az), torch.zeros_like(az), -torch.cos(az)), dim=-1)   # local -z is boresight
weights = conventional_steering(frame.array(), directions)
beams = beam_cube(frame.range_doppler(), weights, directions=directions)
print(beams.data.shape)   # [61, doppler, range]

tdm_compensate#

tdm_compensate(aoa_input, velocities, array, axes) -> torch.Tensor

Remove the phase a moving target writes across TDM transmitters, which are sampled one chirp apart. Apply it before an angle estimate on a moving target.

aoa_inputtorch.Tensorrequired

[P, N] complex virtual-array snapshots for N cells, TX-major.

velocitiestorch.Tensorrequired

[N] radial velocity of each cell, m/s, closing positive.

arrayArrayGeometryrequired

The virtual array.

axesProcessingAxesrequired

The cube's axes. The raw chirp period is axes.slow_time_period_s / num_tx.

Returns a tensor of the same shape and dtype.

phase_comparison_aoa#

phase_comparison_aoa(virtual_ant, array, *, fft_size=64) -> torch.Tensor

Direction cosines [3, N] from two one-dimensional FFTs: azimuth from the first 2 · num_rx elements, elevation from the phase difference to the sub-aperture 2 · num_rx elements later. On a half-wavelength array, x = sin(azimuth). Elevation is coarse: the correction uses the azimuth peak bin, not the continuous azimuth.

virtual_anttorch.Tensorrequired

[P, N] complex snapshots, TX-major.

arrayArrayGeometryrequired

The virtual array. Needs an elevation sub-aperture.

fft_sizeintdefault 64

FFT size of each angle transform.

fft2_aoa#

fft2_aoa(virtual_ant, array, *, fft_size=64) -> torch.Tensor

Direction cosines [3, N] from one zero-padded two-dimensional FFT over a planar virtual array of num_tx // 2 by 2 · num_rx elements. Reads both angles off one grid, so elevation is more accurate than with phase_comparison_aoa.

Raises ValueError for an array with fewer than four transmitters.

music_spectrum#

music_spectrum(angle_data, array, *, elevation_rad, azimuth_rad, num_signals=7, spatial_smooth=3) -> torch.Tensor

MUSIC pseudo-spectrum [B, len(elevation_rad), len(azimuth_rad)] from [B, M, N, T] data: B range bins, an M by N planar array, T snapshots. Forward spatial smoothing averages the covariance over sub-arrays to decorrelate coherent multipath. Differentiable.

angle_datatorch.Tensorrequired

Complex [B, M, N, T].

arrayArrayGeometryrequired

The virtual array. Its spacing_wavelengths sets the steering phase.

elevation_radtorch.Tensorrequired

Elevation scan grid, rad.

azimuth_radtorch.Tensorrequired

Azimuth scan grid, rad.

num_signalsintdefault 7

Size of the signal subspace.

spatial_smoothintdefault 3

Smoothing window, elements.

music_image#

music_image(profile, array, *, elevation_rad, azimuth_rad, range_bins, num_signals=7, spatial_smooth=3,
            num_snapshots=8) -> torch.Tensor

A MUSIC image [elevation, azimuth, bins] from a range profile. Differentiable.

profileRangeProfilerequired

The range profile, so the image uses the same range grid as the rest of the pipeline.

arrayArrayGeometryrequired

The virtual array.

elevation_radtorch.Tensorrequired

Elevation scan grid, rad.

azimuth_radtorch.Tensorrequired

Azimuth scan grid, rad.

range_binstorch.Tensorrequired

Range bins to image. Required: picking a peak automatically would be a silent modelling choice.

num_signalsintdefault 7

Size of the signal subspace.

spatial_smoothintdefault 3

Smoothing window, elements.

num_snapshotsintdefault 8

Slow-time snapshots used for the covariance.

See Resolve two targets with MUSIC for a complete example.

beam_cube, conventional_steering, mvdr_weights, tdm_compensate, music_spectrum and music_image are differentiable. phase_comparison_aoa and fft2_aoa pick peaks and refuse inputs that carry a gradient.