Use antenna arrays
Give an antenna several elements, get per-element path coefficients from the path solver, and steer a beam with PathResult.beamform.
An antenna in Channel can have several elements. The path solver then returns a coefficient for every transmit element and receive element, which is the raw MIMO channel. PathResult.beamform applies precoding and combining weights to it.
Only the path solver supports arrays. The deterministic and Monte Carlo solvers treat every antenna as one element.
A four-element array#
import math
import torch
from witwin.core import AntennaState, Scene
from witwin.channel import path
FREQUENCY_HZ = 3.0e9
wavelength = 299_792_458.0 / FREQUENCY_HZ
k = 2 * math.pi / wavelength
# Four elements along y, half a wavelength apart, centred on the antenna position.
offsets = torch.tensor([[0.0, (i - 1.5) * wavelength / 2, 0.0] for i in range(4)])
tx = AntennaState(1, "tx", torch.tensor([0.0, 0.0, 1.5]), element_positions=offsets, power_w=1.0)
rx = AntennaState(2, "rx", torch.tensor([10.0, 5.0, 1.5]))
scene = Scene(structures=[], endpoints=[tx, rx])
paths = path.solve(scene, path.Config(max_depth=0, components={"los"}), reference_frequency_hz=FREQUENCY_HZ)
print("a:", tuple(paths.a.shape), "tx elements:", paths.num_tx_ant)
single = paths.a[0, 0, 0, 0, 0, 0].abs().square().item()
print(f"one element: {single:.4e}")a: (1, 1, 1, 4, 1, 1) tx elements: 4
one element: 5.0591e-07element_positions is an (N, 3) tensor of element offsets in metres from the antenna position. The fourth axis of a is now the transmit element.
Steer a beam#
beamform(tx_weights, rx_weights) multiplies each path coefficient by conj(rx_weight) * tx_weight and returns a view with cir(), cfr() and taps(). Both weights are required: pass them, or set weights= on the endpoints.
y = offsets[:, 1].to(paths.a.device)
rx_weight = torch.ones(1, dtype=torch.complex64, device=paths.a.device)
def gain_toward(angle_rad):
weights = (torch.exp(-1j * k * y * math.sin(angle_rad)) / 2).to(torch.complex64)
h = paths.beamform(tx_weights=weights, rx_weights=rx_weight).cfr(torch.zeros(1, device=paths.a.device))
return h.abs().square().sum().item()
target = math.atan2(5.0, 10.0)
print(f"steered at the receiver ({math.degrees(target):.1f} deg): {gain_toward(target):.4e}")
print(f"steered at -{math.degrees(target):.1f} deg: {gain_toward(-target):.4e}")
scan = [(deg, gain_toward(math.radians(deg))) for deg in range(-90, 91)]
print(f"scan peak at {max(scan, key=lambda item: item[1])[0]} deg")steered at the receiver (26.6 deg): 2.0236e-06
steered at -26.6 deg: 4.9296e-08
scan peak at 27 degThe weights have unit total power (four elements, amplitude 1/2 each). Steered at the receiver, the four elements add in phase and the gain is 4 times one element (6 dB array gain). Steered to the mirror angle, they mostly cancel. A scan over angles peaks at the receiver's azimuth.
The sign convention follows from the phase convention exp(-j*k*d): an element closer to the receiver by y * sin(angle) sees its phase advanced, so the matching weight is exp(-j * k * y * sin(angle)).
Synthetic and per-element arrays#
synthetic_array=True (the default) traces rays once, from the array position, and gives each element a phase offset from its position. synthetic_array=False traces every element separately.
synthetic_array=True | synthetic_array=False | |
|---|---|---|
| Ray tracing cost | One trace per antenna | One trace per element |
| Accuracy | Plane-wave phase per element; same paths for all elements | Exact path for every element |
| Use for | Compact arrays far from scatterers | Large arrays, near-field geometry, or elements that see different paths |
For the example above the two modes differ by about 1% in coefficient magnitude, because the receiver is only 11 m from an array of 0.15 m.
Shapes after beamforming#
| Call | Shape |
|---|---|
paths.a | (R, Ra, T, Ta, P, time) |
view.cir() | (R, T, Ra * Ta * P, time): one entry per element pair and path, not summed |
view.cfr(frequencies) | (R, T, time, F): summed over elements and paths |
view.taps(bandwidth, num_taps) | (R, T, time, num_taps) |
Default weights on endpoints#
tx = AntennaState(1, "tx", position, element_positions=offsets, weights=torch.ones(4, dtype=torch.complex64) / 2, power_w=1.0)
rx = AntennaState(2, "rx", rx_position, weights=torch.ones(1, dtype=torch.complex64))When every endpoint has weights, PathResult.tx_weights and rx_weights hold them (shapes (T, Ta) and (R, Ra)), and paths.beamform() with no arguments uses them.
Patterns and polarization#
Each element uses the antenna's pattern ("isotropic" by default) and polarization (vertical by default). Set them on the AntennaState.