"""
Wideband beamforming functions for phased array antennas.
Includes true-time delay (TTD), hybrid phase/TTD steering,
beam squint analysis, and wideband pattern computation.
"""
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
from .geometry import ArrayGeometry, SubarrayArchitecture
ArrayLike = Union[np.ndarray, float]
# Speed of light
C = 299792458.0 # m/s
[docs]
def steering_vector_ttd(
x: np.ndarray,
y: np.ndarray,
theta0_deg: float,
phi0_deg: float,
frequency: float,
c: float = C
) -> np.ndarray:
"""
Compute true-time delay (TTD) steering vector.
TTD provides frequency-independent beam pointing by applying
actual time delays rather than phase shifts.
Parameters
----------
x : ndarray
Element x-positions in meters
y : ndarray
Element y-positions in meters
theta0_deg : float
Steering elevation angle in degrees (from broadside)
phi0_deg : float
Steering azimuth angle in degrees
frequency : float
Operating frequency in Hz
c : float, optional
Speed of light in m/s (default: 299792458)
Returns
-------
weights : ndarray
Complex steering weights with TTD phases
Notes
-----
The time delay for each element is:
tau_n = (x_n * sin(theta) * cos(phi) + y_n * sin(theta) * sin(phi)) / c
The phase is: phi_n = -2 * pi * f * tau_n
This is equivalent to phase steering at the given frequency, but
the key difference is that the TIME DELAY is what's physically
implemented, so the beam points correctly at all frequencies.
Examples
--------
TTD steering for a 10 GHz array with physical positions:
>>> import numpy as np
>>> import phased_array as pa
>>> # Array positions in meters (e.g., 16x16 at half-wavelength for 10 GHz)
>>> wavelength = 0.03 # 10 GHz
>>> geom = pa.create_rectangular_array(16, 16, dx=0.5, dy=0.5, wavelength=wavelength)
>>> weights_ttd = pa.steering_vector_ttd(
... geom.x, geom.y,
... theta0_deg=30, phi0_deg=0,
... frequency=10e9
... )
>>> weights_ttd.shape
(256,)
Compare TTD vs phase steering beam squint:
>>> # TTD maintains beam direction across bandwidth
>>> # Phase steering causes beam squint at off-center frequencies
>>> squint = pa.compute_beam_squint(
... geom.x, geom.y, theta0_deg=30, phi0_deg=0,
... center_freq=10e9, bandwidth=2e9, n_freqs=11
... )
"""
theta0 = np.deg2rad(theta0_deg)
phi0 = np.deg2rad(phi0_deg)
# Direction cosines
u0 = np.sin(theta0) * np.cos(phi0)
v0 = np.sin(theta0) * np.sin(phi0)
# Time delay for each element (negative for steering towards direction)
tau = -(x * u0 + y * v0) / c
# Phase at the given frequency
phase = 2 * np.pi * frequency * tau
return np.exp(1j * phase)
[docs]
def steering_delays_ttd(
x: np.ndarray,
y: np.ndarray,
theta0_deg: float,
phi0_deg: float,
c: float = C
) -> np.ndarray:
"""
Compute true-time delays for steering (frequency-independent).
Parameters
----------
x : ndarray
Element x-positions in meters
y : ndarray
Element y-positions in meters
theta0_deg : float
Steering elevation angle in degrees
phi0_deg : float
Steering azimuth angle in degrees
c : float, optional
Speed of light in m/s
Returns
-------
delays : ndarray
Time delays in seconds for each element
"""
theta0 = np.deg2rad(theta0_deg)
phi0 = np.deg2rad(phi0_deg)
u0 = np.sin(theta0) * np.cos(phi0)
v0 = np.sin(theta0) * np.sin(phi0)
# Time delay (negative = delay for positive steering)
delays = -(x * u0 + y * v0) / c
# Normalize so minimum delay is zero
delays = delays - np.min(delays)
return delays
[docs]
def steering_vector_hybrid(
geometry: ArrayGeometry,
architecture: SubarrayArchitecture,
theta0_deg: float,
phi0_deg: float,
frequency: float,
c: float = C
) -> np.ndarray:
"""
Compute hybrid TTD + phase steering vector.
True-time delay is applied at the subarray level (coarse steering),
and phase shifters provide fine adjustment within each subarray.
This is the most common architecture for wideband phased arrays.
Parameters
----------
geometry : ArrayGeometry
Array geometry with element positions
architecture : SubarrayArchitecture
Subarray partitioning
theta0_deg : float
Steering elevation angle in degrees
phi0_deg : float
Steering azimuth angle in degrees
frequency : float
Operating frequency in Hz
c : float, optional
Speed of light in m/s
Returns
-------
weights : ndarray
Complex steering weights
Notes
-----
For each element:
1. TTD is computed based on subarray center position
2. Phase shift compensates for element offset from subarray center
This reduces beam squint compared to phase-only steering,
with squint now determined by subarray size rather than array size.
"""
theta0 = np.deg2rad(theta0_deg)
phi0 = np.deg2rad(phi0_deg)
u0 = np.sin(theta0) * np.cos(phi0)
v0 = np.sin(theta0) * np.sin(phi0)
k = 2 * np.pi * frequency / c
weights = np.zeros(geometry.n_elements, dtype=complex)
for sub_idx in range(architecture.n_subarrays):
# Get elements in this subarray
mask = architecture.subarray_assignments == sub_idx
elem_indices = np.where(mask)[0]
# Subarray center position
center_x = architecture.subarray_centers[sub_idx, 0]
center_y = architecture.subarray_centers[sub_idx, 1]
# TTD phase based on subarray center (frequency-independent pointing)
tau_subarray = -(center_x * u0 + center_y * v0) / c
ttd_phase = 2 * np.pi * frequency * tau_subarray
# Phase shifter compensation for element offset from center
for idx in elem_indices:
dx = geometry.x[idx] - center_x
dy = geometry.y[idx] - center_y
# Phase shift for offset (this causes residual squint)
phase_offset = -k * (dx * u0 + dy * v0)
weights[idx] = np.exp(1j * (ttd_phase + phase_offset))
return weights
[docs]
def compute_subarray_delays_ttd(
architecture: SubarrayArchitecture,
theta0_deg: float,
phi0_deg: float,
c: float = C
) -> np.ndarray:
"""
Compute TTD values for each subarray.
Parameters
----------
architecture : SubarrayArchitecture
Subarray partitioning with center positions
theta0_deg : float
Steering elevation angle in degrees
phi0_deg : float
Steering azimuth angle in degrees
c : float, optional
Speed of light in m/s
Returns
-------
delays : ndarray
Time delay for each subarray in seconds (shape: n_subarrays,)
"""
theta0 = np.deg2rad(theta0_deg)
phi0 = np.deg2rad(phi0_deg)
u0 = np.sin(theta0) * np.cos(phi0)
v0 = np.sin(theta0) * np.sin(phi0)
delays = np.zeros(architecture.n_subarrays)
for sub_idx in range(architecture.n_subarrays):
center_x = architecture.subarray_centers[sub_idx, 0]
center_y = architecture.subarray_centers[sub_idx, 1]
delays[sub_idx] = -(center_x * u0 + center_y * v0) / c
# Normalize so minimum delay is zero
delays = delays - np.min(delays)
return delays
[docs]
def compute_beam_squint(
x: np.ndarray,
y: np.ndarray,
theta0_deg: float,
phi0_deg: float,
center_frequency: float,
frequencies: np.ndarray,
steering_mode: str = 'phase',
architecture: Optional[SubarrayArchitecture] = None,
n_points: int = 361
) -> Dict[str, np.ndarray]:
"""
Compute beam squint (pointing error) vs frequency.
Parameters
----------
x : ndarray
Element x-positions in meters
y : ndarray
Element y-positions in meters
theta0_deg : float
Intended steering angle in degrees
phi0_deg : float
Intended azimuth angle in degrees
center_frequency : float
Center frequency in Hz (where weights are computed)
frequencies : ndarray
Frequencies to evaluate in Hz
steering_mode : str
'phase' - phase-only steering (maximum squint)
'ttd' - true-time delay (no squint)
'hybrid' - TTD at subarray + phase at element
architecture : SubarrayArchitecture, optional
Required for 'hybrid' mode
n_points : int
Number of angle points for pattern computation
Returns
-------
results : dict
'frequencies' : Frequency values (Hz)
'beam_angles' : Actual beam pointing angle at each frequency (deg)
'squint' : Pointing error (deg), positive = beam moved towards broadside
'relative_gain' : Gain relative to center frequency (dB)
Examples
--------
Analyze beam squint for a phase-steered array:
>>> import numpy as np
>>> import phased_array as pa
>>> wavelength = 0.03 # 10 GHz center frequency
>>> geom = pa.create_rectangular_array(16, 16, dx=0.5, dy=0.5, wavelength=wavelength)
>>> freqs = np.linspace(9e9, 11e9, 11) # 9-11 GHz
>>> results = pa.compute_beam_squint(
... geom.x, geom.y,
... theta0_deg=30, phi0_deg=0,
... center_frequency=10e9,
... frequencies=freqs,
... steering_mode='phase'
... )
>>> results['squint'].shape
(11,)
Compare steering modes:
>>> # Phase steering: beam squint increases with bandwidth
>>> # TTD steering: no beam squint (squint ~ 0)
>>> results_ttd = pa.compute_beam_squint(
... geom.x, geom.y, 30, 0, 10e9, freqs, steering_mode='ttd'
... )
"""
from .core import array_factor_vectorized
c = C
k_center = 2 * np.pi * center_frequency / c
# Compute steering weights at center frequency
theta0 = np.deg2rad(theta0_deg)
phi0 = np.deg2rad(phi0_deg)
u0 = np.sin(theta0) * np.cos(phi0)
v0 = np.sin(theta0) * np.sin(phi0)
if steering_mode == 'phase':
# Phase-only steering - compute at center frequency
weights = np.exp(-1j * k_center * (x * u0 + y * v0))
elif steering_mode == 'ttd':
# TTD - delays are frequency-independent
weights = steering_vector_ttd(x, y, theta0_deg, phi0_deg, center_frequency)
elif steering_mode == 'hybrid':
if architecture is None:
raise ValueError("SubarrayArchitecture required for hybrid mode")
geom = ArrayGeometry(x=x, y=y)
weights = steering_vector_hybrid(geom, architecture, theta0_deg, phi0_deg, center_frequency)
else:
raise ValueError(f"Unknown steering_mode: {steering_mode}")
# Scan angles around the intended steering direction
scan_range = 30 # degrees
angles = np.linspace(theta0_deg - scan_range, theta0_deg + scan_range, n_points)
angles = np.clip(angles, -90, 90)
theta_rad = np.deg2rad(angles)
beam_angles = []
relative_gains = []
for freq in frequencies:
k = 2 * np.pi * freq / c
if steering_mode == 'ttd':
# For TTD, recompute weights at each frequency
# (the TIME delays are fixed, but phase = 2*pi*f*tau)
weights_freq = steering_vector_ttd(x, y, theta0_deg, phi0_deg, freq)
elif steering_mode == 'hybrid' and architecture is not None:
geom = ArrayGeometry(x=x, y=y)
weights_freq = steering_vector_hybrid(geom, architecture, theta0_deg, phi0_deg, freq)
else:
# Phase-only: weights are fixed, but k changes
weights_freq = weights
# Compute pattern along the scan cut (phi = phi0)
pattern = np.zeros(len(angles), dtype=complex)
for i, th in enumerate(theta_rad):
u = np.sin(th) * np.cos(phi0)
v = np.sin(th) * np.sin(phi0)
pattern[i] = np.sum(weights_freq * np.exp(1j * k * (x * u + y * v)))
pattern_mag = np.abs(pattern)
peak_idx = np.argmax(pattern_mag)
beam_angles.append(angles[peak_idx])
# Relative gain (compared to peak at center frequency)
relative_gains.append(20 * np.log10(pattern_mag[peak_idx] / len(x)))
beam_angles = np.array(beam_angles)
squint = beam_angles - theta0_deg
# Normalize relative gain to 0 dB at center frequency
center_idx = np.argmin(np.abs(frequencies - center_frequency))
relative_gains = np.array(relative_gains) - relative_gains[center_idx]
return {
'frequencies': frequencies,
'beam_angles': beam_angles,
'squint': squint,
'relative_gain': relative_gains
}
[docs]
def analyze_instantaneous_bandwidth(
x: np.ndarray,
y: np.ndarray,
theta0_deg: float,
phi0_deg: float,
center_frequency: float,
squint_tolerance_deg: float = 0.5,
steering_mode: str = 'phase',
architecture: Optional[SubarrayArchitecture] = None
) -> Dict[str, float]:
"""
Analyze instantaneous bandwidth for given squint tolerance.
Parameters
----------
x : ndarray
Element x-positions in meters
y : ndarray
Element y-positions in meters
theta0_deg : float
Steering angle in degrees
phi0_deg : float
Azimuth angle in degrees
center_frequency : float
Center frequency in Hz
squint_tolerance_deg : float
Maximum acceptable beam squint in degrees
steering_mode : str
'phase', 'ttd', or 'hybrid'
architecture : SubarrayArchitecture, optional
Required for 'hybrid' mode
Returns
-------
results : dict
'ibw_hz' : Instantaneous bandwidth in Hz
'ibw_percent' : Bandwidth as percentage of center frequency
'ibw_ratio' : Bandwidth ratio (f_high / f_low)
"""
# For TTD, bandwidth is theoretically infinite (limited by other factors)
if steering_mode == 'ttd':
return {
'ibw_hz': np.inf,
'ibw_percent': np.inf,
'ibw_ratio': np.inf,
'note': 'TTD provides theoretically unlimited instantaneous bandwidth'
}
# Search for bandwidth limits
# Start with a wide range and narrow down
max_bw_percent = 100 # Start with +/- 50%
for bw_percent in np.linspace(1, max_bw_percent, 100):
bw_hz = center_frequency * bw_percent / 100
frequencies = np.array([
center_frequency - bw_hz / 2,
center_frequency,
center_frequency + bw_hz / 2
])
results = compute_beam_squint(
x, y, theta0_deg, phi0_deg, center_frequency, frequencies,
steering_mode=steering_mode, architecture=architecture
)
max_squint = np.max(np.abs(results['squint']))
if max_squint > squint_tolerance_deg:
# Found the limit
ibw_hz = bw_hz * squint_tolerance_deg / max_squint
ibw_percent = ibw_hz / center_frequency * 100
f_low = center_frequency - ibw_hz / 2
f_high = center_frequency + ibw_hz / 2
return {
'ibw_hz': ibw_hz,
'ibw_percent': ibw_percent,
'ibw_ratio': f_high / f_low,
'f_low': f_low,
'f_high': f_high
}
# If we get here, bandwidth exceeds our search range
return {
'ibw_hz': center_frequency * max_bw_percent / 100,
'ibw_percent': max_bw_percent,
'ibw_ratio': 1.5,
'note': f'Exceeds {max_bw_percent}% bandwidth'
}
[docs]
def compute_pattern_vs_frequency(
x: np.ndarray,
y: np.ndarray,
theta0_deg: float,
phi0_deg: float,
center_frequency: float,
frequencies: np.ndarray,
steering_mode: str = 'phase',
architecture: Optional[SubarrayArchitecture] = None,
n_points: int = 181,
phi_cut_deg: Optional[float] = None
) -> Dict[str, np.ndarray]:
"""
Compute radiation pattern at multiple frequencies.
Parameters
----------
x : ndarray
Element x-positions in meters
y : ndarray
Element y-positions in meters
theta0_deg : float
Steering angle in degrees
phi0_deg : float
Azimuth angle in degrees
center_frequency : float
Center frequency in Hz
frequencies : ndarray
Frequencies to compute patterns at
steering_mode : str
'phase', 'ttd', or 'hybrid'
architecture : SubarrayArchitecture, optional
Required for 'hybrid' mode
n_points : int
Number of angle points
phi_cut_deg : float, optional
Phi angle for cut (default: phi0_deg)
Returns
-------
results : dict
'angles' : Theta angles in degrees
'frequencies' : Frequency values
'patterns' : 2D array of patterns (n_freq x n_angles) in dB
"""
c = C
k_center = 2 * np.pi * center_frequency / c
if phi_cut_deg is None:
phi_cut_deg = phi0_deg
phi_cut = np.deg2rad(phi_cut_deg)
# Steering direction
theta0 = np.deg2rad(theta0_deg)
phi0 = np.deg2rad(phi0_deg)
u0 = np.sin(theta0) * np.cos(phi0)
v0 = np.sin(theta0) * np.sin(phi0)
# Compute base weights at center frequency
if steering_mode == 'phase':
weights_base = np.exp(-1j * k_center * (x * u0 + y * v0))
elif steering_mode == 'ttd':
weights_base = None # Will compute per-frequency
elif steering_mode == 'hybrid':
if architecture is None:
raise ValueError("SubarrayArchitecture required for hybrid mode")
weights_base = None
else:
raise ValueError(f"Unknown steering_mode: {steering_mode}")
angles = np.linspace(-90, 90, n_points)
theta_rad = np.deg2rad(angles)
patterns = np.zeros((len(frequencies), n_points))
for f_idx, freq in enumerate(frequencies):
k = 2 * np.pi * freq / c
if steering_mode == 'ttd':
weights = steering_vector_ttd(x, y, theta0_deg, phi0_deg, freq)
elif steering_mode == 'hybrid':
geom = ArrayGeometry(x=x, y=y)
weights = steering_vector_hybrid(geom, architecture, theta0_deg, phi0_deg, freq)
else:
weights = weights_base
# Compute pattern
for i, th in enumerate(theta_rad):
u = np.sin(th) * np.cos(phi_cut)
v = np.sin(th) * np.sin(phi_cut)
af = np.abs(np.sum(weights * np.exp(1j * k * (x * u + y * v))))
patterns[f_idx, i] = af
# Normalize to peak and convert to dB
peak = np.max(patterns[f_idx, :])
if peak > 0:
patterns[f_idx, :] = 20 * np.log10(patterns[f_idx, :] / peak + 1e-10)
return {
'angles': angles,
'frequencies': frequencies,
'patterns': patterns
}
[docs]
def compute_subarray_weights_hybrid(
geometry: ArrayGeometry,
architecture: SubarrayArchitecture,
theta0_deg: float,
phi0_deg: float,
frequency: float,
amplitude_taper: Optional[np.ndarray] = None,
ttd_quantization_bits: Optional[int] = None,
phase_quantization_bits: Optional[int] = None,
c: float = C
) -> Dict[str, np.ndarray]:
"""
Compute hybrid TTD + phase weights with optional quantization.
Parameters
----------
geometry : ArrayGeometry
Array geometry
architecture : SubarrayArchitecture
Subarray partitioning
theta0_deg : float
Steering elevation angle in degrees
phi0_deg : float
Steering azimuth angle in degrees
frequency : float
Operating frequency in Hz
amplitude_taper : ndarray, optional
Amplitude weights for each element
ttd_quantization_bits : int, optional
Bits for TTD quantization (e.g., 6 bits = 64 delay steps)
phase_quantization_bits : int, optional
Bits for phase shifter quantization
c : float, optional
Speed of light
Returns
-------
results : dict
'weights' : Complex element weights
'subarray_delays' : TTD value for each subarray (seconds)
'element_phases' : Phase shift for each element (radians)
"""
theta0 = np.deg2rad(theta0_deg)
phi0 = np.deg2rad(phi0_deg)
u0 = np.sin(theta0) * np.cos(phi0)
v0 = np.sin(theta0) * np.sin(phi0)
k = 2 * np.pi * frequency / c
wavelength = c / frequency
weights = np.ones(geometry.n_elements, dtype=complex)
subarray_delays = np.zeros(architecture.n_subarrays)
element_phases = np.zeros(geometry.n_elements)
# Compute maximum delay for quantization
if ttd_quantization_bits is not None:
max_delay = np.max(np.abs(architecture.subarray_centers[:, 0] * u0 +
architecture.subarray_centers[:, 1] * v0)) / c
delay_step = 2 * max_delay / (2**ttd_quantization_bits - 1)
for sub_idx in range(architecture.n_subarrays):
mask = architecture.subarray_assignments == sub_idx
elem_indices = np.where(mask)[0]
center_x = architecture.subarray_centers[sub_idx, 0]
center_y = architecture.subarray_centers[sub_idx, 1]
# TTD for subarray center
tau = -(center_x * u0 + center_y * v0) / c
# Quantize TTD if specified
if ttd_quantization_bits is not None and delay_step > 0:
tau = np.round(tau / delay_step) * delay_step
subarray_delays[sub_idx] = tau
ttd_phase = 2 * np.pi * frequency * tau
# Phase shifter for each element
for idx in elem_indices:
dx = geometry.x[idx] - center_x
dy = geometry.y[idx] - center_y
phase = -k * (dx * u0 + dy * v0)
# Quantize phase if specified
if phase_quantization_bits is not None:
n_levels = 2**phase_quantization_bits
phase_step = 2 * np.pi / n_levels
phase = np.round(phase / phase_step) * phase_step
element_phases[idx] = phase
weights[idx] = np.exp(1j * (ttd_phase + phase))
# Apply amplitude taper
if amplitude_taper is not None:
weights = weights * amplitude_taper
return {
'weights': weights,
'subarray_delays': subarray_delays,
'element_phases': element_phases
}
[docs]
def compare_steering_modes(
geometry: ArrayGeometry,
architecture: SubarrayArchitecture,
theta0_deg: float,
phi0_deg: float,
center_frequency: float,
bandwidth_percent: float = 20.0,
n_freq_points: int = 11
) -> Dict[str, Dict]:
"""
Compare beam squint for different steering modes.
Parameters
----------
geometry : ArrayGeometry
Array geometry
architecture : SubarrayArchitecture
Subarray partitioning
theta0_deg : float
Steering angle in degrees
phi0_deg : float
Azimuth angle in degrees
center_frequency : float
Center frequency in Hz
bandwidth_percent : float
Total bandwidth as percentage of center frequency
n_freq_points : int
Number of frequency points to evaluate
Returns
-------
results : dict
Results for each steering mode ('phase', 'hybrid', 'ttd')
"""
bw_hz = center_frequency * bandwidth_percent / 100
frequencies = np.linspace(
center_frequency - bw_hz / 2,
center_frequency + bw_hz / 2,
n_freq_points
)
results = {}
for mode in ['phase', 'hybrid', 'ttd']:
arch = architecture if mode == 'hybrid' else None
squint_results = compute_beam_squint(
geometry.x, geometry.y,
theta0_deg, phi0_deg,
center_frequency, frequencies,
steering_mode=mode,
architecture=arch
)
results[mode] = {
'frequencies': frequencies,
'squint': squint_results['squint'],
'max_squint': np.max(np.abs(squint_results['squint'])),
'beam_angles': squint_results['beam_angles']
}
return results