phased-array-modeling 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phased_array/__init__.py +263 -0
- phased_array/beamforming.py +705 -0
- phased_array/core.py +598 -0
- phased_array/geometry.py +943 -0
- phased_array/impairments.py +719 -0
- phased_array/utils.py +346 -0
- phased_array/visualization.py +824 -0
- phased_array_modeling-1.0.0.dist-info/METADATA +259 -0
- phased_array_modeling-1.0.0.dist-info/RECORD +12 -0
- phased_array_modeling-1.0.0.dist-info/WHEEL +5 -0
- phased_array_modeling-1.0.0.dist-info/licenses/LICENSE +21 -0
- phased_array_modeling-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Beamforming functions for phased arrays.
|
|
3
|
+
|
|
4
|
+
Includes amplitude tapering, null steering, and multiple simultaneous beams.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy import signal
|
|
9
|
+
from typing import Tuple, Optional, List, Union
|
|
10
|
+
from .geometry import ArrayGeometry
|
|
11
|
+
from .core import steering_vector
|
|
12
|
+
|
|
13
|
+
ArrayLike = Union[np.ndarray, float]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ============== Amplitude Tapering ==============
|
|
17
|
+
|
|
18
|
+
def taylor_taper_1d(
|
|
19
|
+
n: int,
|
|
20
|
+
sidelobe_dB: float = -30.0,
|
|
21
|
+
nbar: int = 4
|
|
22
|
+
) -> np.ndarray:
|
|
23
|
+
"""
|
|
24
|
+
Generate 1D Taylor window for sidelobe control.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
n : int
|
|
29
|
+
Number of elements
|
|
30
|
+
sidelobe_dB : float
|
|
31
|
+
Desired peak sidelobe level in dB (negative)
|
|
32
|
+
nbar : int
|
|
33
|
+
Number of nearly equal-level sidelobes
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
taper : ndarray
|
|
38
|
+
Amplitude taper weights (n,)
|
|
39
|
+
"""
|
|
40
|
+
# Use scipy's Taylor window
|
|
41
|
+
return signal.windows.taylor(n, nbar=nbar, sll=-sidelobe_dB, norm=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def taylor_taper_2d(
|
|
45
|
+
Nx: int,
|
|
46
|
+
Ny: int,
|
|
47
|
+
sidelobe_dB: float = -30.0,
|
|
48
|
+
nbar: int = 4
|
|
49
|
+
) -> np.ndarray:
|
|
50
|
+
"""
|
|
51
|
+
Generate 2D Taylor window (separable product).
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
Nx, Ny : int
|
|
56
|
+
Number of elements in x and y
|
|
57
|
+
sidelobe_dB : float
|
|
58
|
+
Desired peak sidelobe level in dB
|
|
59
|
+
nbar : int
|
|
60
|
+
Number of nearly equal-level sidelobes
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
taper : ndarray
|
|
65
|
+
2D amplitude taper (Nx, Ny), flattened row-major
|
|
66
|
+
"""
|
|
67
|
+
tx = taylor_taper_1d(Nx, sidelobe_dB, nbar)
|
|
68
|
+
ty = taylor_taper_1d(Ny, sidelobe_dB, nbar)
|
|
69
|
+
taper_2d = np.outer(tx, ty)
|
|
70
|
+
return taper_2d.ravel()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def chebyshev_taper_1d(
|
|
74
|
+
n: int,
|
|
75
|
+
sidelobe_dB: float = -30.0
|
|
76
|
+
) -> np.ndarray:
|
|
77
|
+
"""
|
|
78
|
+
Generate 1D Dolph-Chebyshev window for equi-ripple sidelobes.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
n : int
|
|
83
|
+
Number of elements
|
|
84
|
+
sidelobe_dB : float
|
|
85
|
+
Desired sidelobe level in dB (negative)
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
taper : ndarray
|
|
90
|
+
Amplitude taper weights
|
|
91
|
+
"""
|
|
92
|
+
return signal.windows.chebwin(n, at=-sidelobe_dB)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def chebyshev_taper_2d(
|
|
96
|
+
Nx: int,
|
|
97
|
+
Ny: int,
|
|
98
|
+
sidelobe_dB: float = -30.0
|
|
99
|
+
) -> np.ndarray:
|
|
100
|
+
"""
|
|
101
|
+
Generate 2D Chebyshev window (separable product).
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
Nx, Ny : int
|
|
106
|
+
Number of elements
|
|
107
|
+
sidelobe_dB : float
|
|
108
|
+
Desired sidelobe level in dB
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
taper : ndarray
|
|
113
|
+
2D taper, flattened
|
|
114
|
+
"""
|
|
115
|
+
tx = chebyshev_taper_1d(Nx, sidelobe_dB)
|
|
116
|
+
ty = chebyshev_taper_1d(Ny, sidelobe_dB)
|
|
117
|
+
return np.outer(tx, ty).ravel()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def hamming_taper_1d(n: int) -> np.ndarray:
|
|
121
|
+
"""Generate 1D Hamming window."""
|
|
122
|
+
return np.hamming(n)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def hamming_taper_2d(Nx: int, Ny: int) -> np.ndarray:
|
|
126
|
+
"""Generate 2D Hamming window."""
|
|
127
|
+
return np.outer(np.hamming(Nx), np.hamming(Ny)).ravel()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def hanning_taper_1d(n: int) -> np.ndarray:
|
|
131
|
+
"""Generate 1D Hanning (Hann) window."""
|
|
132
|
+
return np.hanning(n)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def hanning_taper_2d(Nx: int, Ny: int) -> np.ndarray:
|
|
136
|
+
"""Generate 2D Hanning window."""
|
|
137
|
+
return np.outer(np.hanning(Nx), np.hanning(Ny)).ravel()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cosine_taper_1d(n: int) -> np.ndarray:
|
|
141
|
+
"""Generate 1D cosine (sine) window."""
|
|
142
|
+
return np.sin(np.pi * np.arange(n) / (n - 1))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cosine_taper_2d(Nx: int, Ny: int) -> np.ndarray:
|
|
146
|
+
"""Generate 2D cosine window."""
|
|
147
|
+
return np.outer(cosine_taper_1d(Nx), cosine_taper_1d(Ny)).ravel()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cosine_on_pedestal_taper_1d(
|
|
151
|
+
n: int,
|
|
152
|
+
pedestal: float = 0.1
|
|
153
|
+
) -> np.ndarray:
|
|
154
|
+
"""
|
|
155
|
+
Generate 1D cosine-on-pedestal window.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
n : int
|
|
160
|
+
Number of elements
|
|
161
|
+
pedestal : float
|
|
162
|
+
Minimum amplitude at edges (0 to 1)
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
taper : ndarray
|
|
167
|
+
Amplitude taper
|
|
168
|
+
"""
|
|
169
|
+
t = np.linspace(0, np.pi, n)
|
|
170
|
+
return pedestal + (1 - pedestal) * np.sin(t)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cosine_on_pedestal_taper_2d(
|
|
174
|
+
Nx: int,
|
|
175
|
+
Ny: int,
|
|
176
|
+
pedestal: float = 0.1
|
|
177
|
+
) -> np.ndarray:
|
|
178
|
+
"""Generate 2D cosine-on-pedestal window."""
|
|
179
|
+
tx = cosine_on_pedestal_taper_1d(Nx, pedestal)
|
|
180
|
+
ty = cosine_on_pedestal_taper_1d(Ny, pedestal)
|
|
181
|
+
return np.outer(tx, ty).ravel()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def gaussian_taper_1d(n: int, sigma: float = 0.4) -> np.ndarray:
|
|
185
|
+
"""
|
|
186
|
+
Generate 1D Gaussian window.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
n : int
|
|
191
|
+
Number of elements
|
|
192
|
+
sigma : float
|
|
193
|
+
Standard deviation as fraction of array (typical: 0.3-0.5)
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
taper : ndarray
|
|
198
|
+
"""
|
|
199
|
+
x = np.linspace(-0.5, 0.5, n)
|
|
200
|
+
return np.exp(-0.5 * (x / sigma) ** 2)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def gaussian_taper_2d(Nx: int, Ny: int, sigma: float = 0.4) -> np.ndarray:
|
|
204
|
+
"""Generate 2D Gaussian window."""
|
|
205
|
+
return np.outer(gaussian_taper_1d(Nx, sigma), gaussian_taper_1d(Ny, sigma)).ravel()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def compute_taper_efficiency(taper: np.ndarray) -> float:
|
|
209
|
+
"""
|
|
210
|
+
Compute aperture efficiency for a taper.
|
|
211
|
+
|
|
212
|
+
Efficiency = (sum of weights)^2 / (N * sum of weights^2)
|
|
213
|
+
Uniform illumination gives 100% efficiency.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
taper : ndarray
|
|
218
|
+
Amplitude taper weights
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
efficiency : float
|
|
223
|
+
Aperture efficiency (0 to 1)
|
|
224
|
+
"""
|
|
225
|
+
n = len(taper)
|
|
226
|
+
return (np.sum(taper) ** 2) / (n * np.sum(taper ** 2))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def compute_taper_directivity_loss(taper: np.ndarray) -> float:
|
|
230
|
+
"""
|
|
231
|
+
Compute directivity loss due to tapering in dB.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
taper : ndarray
|
|
236
|
+
Amplitude taper weights
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
loss_dB : float
|
|
241
|
+
Directivity loss relative to uniform illumination (negative or zero)
|
|
242
|
+
"""
|
|
243
|
+
efficiency = compute_taper_efficiency(taper)
|
|
244
|
+
return 10 * np.log10(efficiency)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def apply_taper_to_geometry(
|
|
248
|
+
geometry: ArrayGeometry,
|
|
249
|
+
taper_func: str = 'taylor',
|
|
250
|
+
Nx: Optional[int] = None,
|
|
251
|
+
Ny: Optional[int] = None,
|
|
252
|
+
**taper_kwargs
|
|
253
|
+
) -> np.ndarray:
|
|
254
|
+
"""
|
|
255
|
+
Apply an amplitude taper based on element positions.
|
|
256
|
+
|
|
257
|
+
For non-rectangular arrays, uses radial distance from center.
|
|
258
|
+
|
|
259
|
+
Parameters
|
|
260
|
+
----------
|
|
261
|
+
geometry : ArrayGeometry
|
|
262
|
+
Array geometry
|
|
263
|
+
taper_func : str
|
|
264
|
+
'taylor', 'chebyshev', 'hamming', 'hanning', 'gaussian', 'cosine'
|
|
265
|
+
Nx, Ny : int, optional
|
|
266
|
+
For rectangular arrays, specify dimensions
|
|
267
|
+
**taper_kwargs
|
|
268
|
+
Additional arguments for the taper function
|
|
269
|
+
|
|
270
|
+
Returns
|
|
271
|
+
-------
|
|
272
|
+
taper : ndarray
|
|
273
|
+
Amplitude weights for each element
|
|
274
|
+
"""
|
|
275
|
+
n = geometry.n_elements
|
|
276
|
+
|
|
277
|
+
# If Nx, Ny provided, use 2D separable taper
|
|
278
|
+
if Nx is not None and Ny is not None:
|
|
279
|
+
if taper_func == 'taylor':
|
|
280
|
+
return taylor_taper_2d(Nx, Ny, **taper_kwargs)
|
|
281
|
+
elif taper_func == 'chebyshev':
|
|
282
|
+
return chebyshev_taper_2d(Nx, Ny, **taper_kwargs)
|
|
283
|
+
elif taper_func == 'hamming':
|
|
284
|
+
return hamming_taper_2d(Nx, Ny)
|
|
285
|
+
elif taper_func == 'hanning':
|
|
286
|
+
return hanning_taper_2d(Nx, Ny)
|
|
287
|
+
elif taper_func == 'gaussian':
|
|
288
|
+
sigma = taper_kwargs.get('sigma', 0.4)
|
|
289
|
+
return gaussian_taper_2d(Nx, Ny, sigma)
|
|
290
|
+
elif taper_func == 'cosine':
|
|
291
|
+
return cosine_taper_2d(Nx, Ny)
|
|
292
|
+
|
|
293
|
+
# For irregular arrays, use radial taper
|
|
294
|
+
r = np.sqrt(geometry.x**2 + geometry.y**2)
|
|
295
|
+
r_max = np.max(r) if np.max(r) > 0 else 1.0
|
|
296
|
+
r_norm = r / r_max # 0 at center, 1 at edge
|
|
297
|
+
|
|
298
|
+
if taper_func == 'gaussian':
|
|
299
|
+
sigma = taper_kwargs.get('sigma', 0.4)
|
|
300
|
+
return np.exp(-0.5 * (r_norm / sigma) ** 2)
|
|
301
|
+
elif taper_func == 'cosine':
|
|
302
|
+
return np.cos(np.pi * r_norm / 2)
|
|
303
|
+
elif taper_func == 'taylor':
|
|
304
|
+
# Approximate Taylor with a polynomial
|
|
305
|
+
sll = -taper_kwargs.get('sidelobe_dB', -30.0)
|
|
306
|
+
# Simple approximation: cosine-squared on pedestal
|
|
307
|
+
pedestal = 10 ** (-sll / 40)
|
|
308
|
+
return pedestal + (1 - pedestal) * np.cos(np.pi * r_norm / 2) ** 2
|
|
309
|
+
else:
|
|
310
|
+
# Default to uniform
|
|
311
|
+
return np.ones(n)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ============== Null Steering ==============
|
|
315
|
+
|
|
316
|
+
def null_steering_projection(
|
|
317
|
+
geometry: ArrayGeometry,
|
|
318
|
+
k: float,
|
|
319
|
+
theta_main_deg: float,
|
|
320
|
+
phi_main_deg: float,
|
|
321
|
+
null_directions: List[Tuple[float, float]],
|
|
322
|
+
initial_weights: Optional[np.ndarray] = None
|
|
323
|
+
) -> np.ndarray:
|
|
324
|
+
"""
|
|
325
|
+
Compute weights with nulls using orthogonal projection.
|
|
326
|
+
|
|
327
|
+
Projects the desired weight vector onto the null-space of the
|
|
328
|
+
steering vectors for the null directions.
|
|
329
|
+
|
|
330
|
+
Parameters
|
|
331
|
+
----------
|
|
332
|
+
geometry : ArrayGeometry
|
|
333
|
+
Array geometry
|
|
334
|
+
k : float
|
|
335
|
+
Wavenumber
|
|
336
|
+
theta_main_deg : float
|
|
337
|
+
Main beam theta in degrees
|
|
338
|
+
phi_main_deg : float
|
|
339
|
+
Main beam phi in degrees
|
|
340
|
+
null_directions : list of (theta_deg, phi_deg) tuples
|
|
341
|
+
Directions for placing nulls
|
|
342
|
+
initial_weights : ndarray, optional
|
|
343
|
+
Starting weights (default: uniform with steering)
|
|
344
|
+
|
|
345
|
+
Returns
|
|
346
|
+
-------
|
|
347
|
+
weights : ndarray
|
|
348
|
+
Complex weights with nulls placed
|
|
349
|
+
"""
|
|
350
|
+
n = geometry.n_elements
|
|
351
|
+
|
|
352
|
+
# Initial steering weights
|
|
353
|
+
if initial_weights is None:
|
|
354
|
+
initial_weights = steering_vector(
|
|
355
|
+
k, geometry.x, geometry.y,
|
|
356
|
+
theta_main_deg, phi_main_deg,
|
|
357
|
+
geometry.z
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if len(null_directions) == 0:
|
|
361
|
+
return initial_weights
|
|
362
|
+
|
|
363
|
+
# Build constraint matrix (columns are steering vectors for null directions)
|
|
364
|
+
C = np.zeros((n, len(null_directions)), dtype=complex)
|
|
365
|
+
for i, (theta_null, phi_null) in enumerate(null_directions):
|
|
366
|
+
C[:, i] = steering_vector(
|
|
367
|
+
k, geometry.x, geometry.y,
|
|
368
|
+
theta_null, phi_null,
|
|
369
|
+
geometry.z
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Project initial weights onto null space of C
|
|
373
|
+
# P_null = I - C(C^H C)^-1 C^H
|
|
374
|
+
try:
|
|
375
|
+
CTC_inv = np.linalg.inv(C.conj().T @ C)
|
|
376
|
+
P_null = np.eye(n) - C @ CTC_inv @ C.conj().T
|
|
377
|
+
weights = P_null @ initial_weights
|
|
378
|
+
except np.linalg.LinAlgError:
|
|
379
|
+
# If matrix is singular, use pseudoinverse
|
|
380
|
+
P_null = np.eye(n) - C @ np.linalg.pinv(C)
|
|
381
|
+
weights = P_null @ initial_weights
|
|
382
|
+
|
|
383
|
+
return weights
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def null_steering_lcmv(
|
|
387
|
+
geometry: ArrayGeometry,
|
|
388
|
+
k: float,
|
|
389
|
+
constraints: List[Tuple[float, float, complex]],
|
|
390
|
+
noise_covariance: Optional[np.ndarray] = None
|
|
391
|
+
) -> np.ndarray:
|
|
392
|
+
"""
|
|
393
|
+
LCMV (Linearly Constrained Minimum Variance) beamformer.
|
|
394
|
+
|
|
395
|
+
Minimizes output power subject to linear constraints on response
|
|
396
|
+
in specified directions.
|
|
397
|
+
|
|
398
|
+
Parameters
|
|
399
|
+
----------
|
|
400
|
+
geometry : ArrayGeometry
|
|
401
|
+
Array geometry
|
|
402
|
+
k : float
|
|
403
|
+
Wavenumber
|
|
404
|
+
constraints : list of (theta_deg, phi_deg, response) tuples
|
|
405
|
+
Each constraint specifies (direction, desired complex response)
|
|
406
|
+
Use response=1+0j for unity gain, response=0 for null
|
|
407
|
+
noise_covariance : ndarray, optional
|
|
408
|
+
N x N noise covariance matrix (default: identity)
|
|
409
|
+
|
|
410
|
+
Returns
|
|
411
|
+
-------
|
|
412
|
+
weights : ndarray
|
|
413
|
+
Complex LCMV weights
|
|
414
|
+
"""
|
|
415
|
+
n = geometry.n_elements
|
|
416
|
+
|
|
417
|
+
if noise_covariance is None:
|
|
418
|
+
R = np.eye(n, dtype=complex)
|
|
419
|
+
else:
|
|
420
|
+
R = noise_covariance
|
|
421
|
+
|
|
422
|
+
# Build constraint matrix and response vector
|
|
423
|
+
n_constraints = len(constraints)
|
|
424
|
+
C = np.zeros((n, n_constraints), dtype=complex)
|
|
425
|
+
f = np.zeros(n_constraints, dtype=complex)
|
|
426
|
+
|
|
427
|
+
for i, (theta, phi, response) in enumerate(constraints):
|
|
428
|
+
C[:, i] = steering_vector(
|
|
429
|
+
k, geometry.x, geometry.y,
|
|
430
|
+
theta, phi, geometry.z
|
|
431
|
+
)
|
|
432
|
+
f[i] = response
|
|
433
|
+
|
|
434
|
+
# LCMV solution: w = R^-1 C (C^H R^-1 C)^-1 f
|
|
435
|
+
try:
|
|
436
|
+
R_inv = np.linalg.inv(R)
|
|
437
|
+
R_inv_C = R_inv @ C
|
|
438
|
+
weights = R_inv_C @ np.linalg.inv(C.conj().T @ R_inv_C) @ f
|
|
439
|
+
except np.linalg.LinAlgError:
|
|
440
|
+
# Use pseudoinverse if singular
|
|
441
|
+
R_inv = np.linalg.pinv(R)
|
|
442
|
+
R_inv_C = R_inv @ C
|
|
443
|
+
weights = R_inv_C @ np.linalg.pinv(C.conj().T @ R_inv_C) @ f
|
|
444
|
+
|
|
445
|
+
return weights
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def compute_null_depth(
|
|
449
|
+
weights: np.ndarray,
|
|
450
|
+
geometry: ArrayGeometry,
|
|
451
|
+
k: float,
|
|
452
|
+
theta_deg: float,
|
|
453
|
+
phi_deg: float,
|
|
454
|
+
theta_main_deg: float,
|
|
455
|
+
phi_main_deg: float
|
|
456
|
+
) -> float:
|
|
457
|
+
"""
|
|
458
|
+
Compute null depth relative to main beam in dB.
|
|
459
|
+
|
|
460
|
+
Parameters
|
|
461
|
+
----------
|
|
462
|
+
weights : ndarray
|
|
463
|
+
Array weights
|
|
464
|
+
geometry : ArrayGeometry
|
|
465
|
+
Array geometry
|
|
466
|
+
k : float
|
|
467
|
+
Wavenumber
|
|
468
|
+
theta_deg, phi_deg : float
|
|
469
|
+
Null direction
|
|
470
|
+
theta_main_deg, phi_main_deg : float
|
|
471
|
+
Main beam direction
|
|
472
|
+
|
|
473
|
+
Returns
|
|
474
|
+
-------
|
|
475
|
+
depth_dB : float
|
|
476
|
+
Null depth (negative, lower is deeper)
|
|
477
|
+
"""
|
|
478
|
+
from .core import array_factor_vectorized
|
|
479
|
+
|
|
480
|
+
# Response at null
|
|
481
|
+
theta_null = np.array([[np.deg2rad(theta_deg)]])
|
|
482
|
+
phi_null = np.array([[np.deg2rad(phi_deg)]])
|
|
483
|
+
AF_null = array_factor_vectorized(
|
|
484
|
+
theta_null, phi_null,
|
|
485
|
+
geometry.x, geometry.y, weights, k, geometry.z
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# Response at main beam
|
|
489
|
+
theta_main = np.array([[np.deg2rad(theta_main_deg)]])
|
|
490
|
+
phi_main = np.array([[np.deg2rad(phi_main_deg)]])
|
|
491
|
+
AF_main = array_factor_vectorized(
|
|
492
|
+
theta_main, phi_main,
|
|
493
|
+
geometry.x, geometry.y, weights, k, geometry.z
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
ratio = np.abs(AF_null) / np.abs(AF_main)
|
|
497
|
+
if ratio > 0:
|
|
498
|
+
return 20 * np.log10(ratio.item())
|
|
499
|
+
else:
|
|
500
|
+
return -200.0 # Very deep null
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ============== Multiple Simultaneous Beams ==============
|
|
504
|
+
|
|
505
|
+
def multi_beam_weights_superposition(
|
|
506
|
+
geometry: ArrayGeometry,
|
|
507
|
+
k: float,
|
|
508
|
+
beam_directions: List[Tuple[float, float]],
|
|
509
|
+
amplitudes: Optional[List[float]] = None,
|
|
510
|
+
tapers: Optional[List[np.ndarray]] = None
|
|
511
|
+
) -> np.ndarray:
|
|
512
|
+
"""
|
|
513
|
+
Compute weights for multiple simultaneous beams via superposition.
|
|
514
|
+
|
|
515
|
+
The weights are a weighted sum of individual steering vectors.
|
|
516
|
+
This creates multiple beams but with reduced gain per beam.
|
|
517
|
+
|
|
518
|
+
Parameters
|
|
519
|
+
----------
|
|
520
|
+
geometry : ArrayGeometry
|
|
521
|
+
Array geometry
|
|
522
|
+
k : float
|
|
523
|
+
Wavenumber
|
|
524
|
+
beam_directions : list of (theta_deg, phi_deg) tuples
|
|
525
|
+
Directions for each beam
|
|
526
|
+
amplitudes : list of float, optional
|
|
527
|
+
Relative amplitude for each beam (default: equal)
|
|
528
|
+
tapers : list of ndarray, optional
|
|
529
|
+
Amplitude taper for each beam (default: uniform)
|
|
530
|
+
|
|
531
|
+
Returns
|
|
532
|
+
-------
|
|
533
|
+
weights : ndarray
|
|
534
|
+
Combined complex weights
|
|
535
|
+
"""
|
|
536
|
+
n_beams = len(beam_directions)
|
|
537
|
+
|
|
538
|
+
if amplitudes is None:
|
|
539
|
+
amplitudes = [1.0] * n_beams
|
|
540
|
+
|
|
541
|
+
weights = np.zeros(geometry.n_elements, dtype=complex)
|
|
542
|
+
|
|
543
|
+
for i, (theta, phi) in enumerate(beam_directions):
|
|
544
|
+
sv = steering_vector(
|
|
545
|
+
k, geometry.x, geometry.y,
|
|
546
|
+
theta, phi, geometry.z
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
if tapers is not None and i < len(tapers):
|
|
550
|
+
sv = sv * tapers[i]
|
|
551
|
+
|
|
552
|
+
weights += amplitudes[i] * sv
|
|
553
|
+
|
|
554
|
+
return weights
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def multi_beam_weights_orthogonal(
|
|
558
|
+
geometry: ArrayGeometry,
|
|
559
|
+
k: float,
|
|
560
|
+
beam_directions: List[Tuple[float, float]]
|
|
561
|
+
) -> List[np.ndarray]:
|
|
562
|
+
"""
|
|
563
|
+
Compute separate weight vectors for orthogonal digital beamforming.
|
|
564
|
+
|
|
565
|
+
Each beam has its own weight vector, allowing digital combination
|
|
566
|
+
with no loss per beam (requires N receive channels).
|
|
567
|
+
|
|
568
|
+
Parameters
|
|
569
|
+
----------
|
|
570
|
+
geometry : ArrayGeometry
|
|
571
|
+
Array geometry
|
|
572
|
+
k : float
|
|
573
|
+
Wavenumber
|
|
574
|
+
beam_directions : list of (theta_deg, phi_deg) tuples
|
|
575
|
+
Directions for each beam
|
|
576
|
+
|
|
577
|
+
Returns
|
|
578
|
+
-------
|
|
579
|
+
weight_vectors : list of ndarray
|
|
580
|
+
List of complex weight vectors, one per beam
|
|
581
|
+
"""
|
|
582
|
+
weight_vectors = []
|
|
583
|
+
|
|
584
|
+
for theta, phi in beam_directions:
|
|
585
|
+
weights = steering_vector(
|
|
586
|
+
k, geometry.x, geometry.y,
|
|
587
|
+
theta, phi, geometry.z
|
|
588
|
+
)
|
|
589
|
+
weight_vectors.append(weights)
|
|
590
|
+
|
|
591
|
+
return weight_vectors
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def compute_beam_isolation(
|
|
595
|
+
weights_list: List[np.ndarray],
|
|
596
|
+
geometry: ArrayGeometry,
|
|
597
|
+
k: float,
|
|
598
|
+
beam_directions: List[Tuple[float, float]]
|
|
599
|
+
) -> np.ndarray:
|
|
600
|
+
"""
|
|
601
|
+
Compute isolation between multiple beams.
|
|
602
|
+
|
|
603
|
+
Parameters
|
|
604
|
+
----------
|
|
605
|
+
weights_list : list of ndarray
|
|
606
|
+
Weight vector for each beam
|
|
607
|
+
geometry : ArrayGeometry
|
|
608
|
+
Array geometry
|
|
609
|
+
k : float
|
|
610
|
+
Wavenumber
|
|
611
|
+
beam_directions : list of (theta_deg, phi_deg)
|
|
612
|
+
Direction of each beam
|
|
613
|
+
|
|
614
|
+
Returns
|
|
615
|
+
-------
|
|
616
|
+
isolation_matrix : ndarray
|
|
617
|
+
N_beams x N_beams matrix of isolation in dB
|
|
618
|
+
Diagonal is 0 dB, off-diagonal is negative (isolation)
|
|
619
|
+
"""
|
|
620
|
+
from .core import array_factor_vectorized
|
|
621
|
+
|
|
622
|
+
n_beams = len(weights_list)
|
|
623
|
+
isolation = np.zeros((n_beams, n_beams))
|
|
624
|
+
|
|
625
|
+
# Compute response of each beam's weights at each beam's direction
|
|
626
|
+
for i in range(n_beams):
|
|
627
|
+
for j in range(n_beams):
|
|
628
|
+
theta = np.array([[np.deg2rad(beam_directions[j][0])]])
|
|
629
|
+
phi = np.array([[np.deg2rad(beam_directions[j][1])]])
|
|
630
|
+
|
|
631
|
+
AF = array_factor_vectorized(
|
|
632
|
+
theta, phi,
|
|
633
|
+
geometry.x, geometry.y,
|
|
634
|
+
weights_list[i], k, geometry.z
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
AF_main = array_factor_vectorized(
|
|
638
|
+
np.array([[np.deg2rad(beam_directions[i][0])]]),
|
|
639
|
+
np.array([[np.deg2rad(beam_directions[i][1])]]),
|
|
640
|
+
geometry.x, geometry.y,
|
|
641
|
+
weights_list[i], k, geometry.z
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
if np.abs(AF_main) > 0:
|
|
645
|
+
ratio = np.abs(AF) / np.abs(AF_main)
|
|
646
|
+
isolation[i, j] = 20 * np.log10(ratio.item()) if ratio > 0 else -200
|
|
647
|
+
else:
|
|
648
|
+
isolation[i, j] = -200
|
|
649
|
+
|
|
650
|
+
return isolation
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def monopulse_weights(
|
|
654
|
+
geometry: ArrayGeometry,
|
|
655
|
+
k: float,
|
|
656
|
+
theta0_deg: float,
|
|
657
|
+
phi0_deg: float,
|
|
658
|
+
mode: str = 'sum'
|
|
659
|
+
) -> np.ndarray:
|
|
660
|
+
"""
|
|
661
|
+
Compute monopulse tracking weights.
|
|
662
|
+
|
|
663
|
+
Parameters
|
|
664
|
+
----------
|
|
665
|
+
geometry : ArrayGeometry
|
|
666
|
+
Array geometry
|
|
667
|
+
k : float
|
|
668
|
+
Wavenumber
|
|
669
|
+
theta0_deg : float
|
|
670
|
+
Nominal beam direction theta
|
|
671
|
+
phi0_deg : float
|
|
672
|
+
Nominal beam direction phi
|
|
673
|
+
mode : str
|
|
674
|
+
'sum' - sum pattern (normal beam)
|
|
675
|
+
'delta_az' - azimuth difference pattern
|
|
676
|
+
'delta_el' - elevation difference pattern
|
|
677
|
+
|
|
678
|
+
Returns
|
|
679
|
+
-------
|
|
680
|
+
weights : ndarray
|
|
681
|
+
Complex weights for the specified mode
|
|
682
|
+
"""
|
|
683
|
+
# Base steering vector
|
|
684
|
+
sv = steering_vector(
|
|
685
|
+
k, geometry.x, geometry.y,
|
|
686
|
+
theta0_deg, phi0_deg, geometry.z
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
if mode == 'sum':
|
|
690
|
+
return sv
|
|
691
|
+
|
|
692
|
+
elif mode == 'delta_az':
|
|
693
|
+
# Difference in azimuth: flip sign for half the array in x
|
|
694
|
+
sign = np.sign(geometry.x)
|
|
695
|
+
sign[sign == 0] = 1 # Handle elements at x=0
|
|
696
|
+
return sv * sign
|
|
697
|
+
|
|
698
|
+
elif mode == 'delta_el':
|
|
699
|
+
# Difference in elevation: flip sign for half the array in y
|
|
700
|
+
sign = np.sign(geometry.y)
|
|
701
|
+
sign[sign == 0] = 1
|
|
702
|
+
return sv * sign
|
|
703
|
+
|
|
704
|
+
else:
|
|
705
|
+
raise ValueError(f"Unknown monopulse mode: {mode}")
|