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
phased_array/core.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core computation functions for phased array antennas.
|
|
3
|
+
|
|
4
|
+
Includes vectorized array factor, FFT-based computation, steering vectors,
|
|
5
|
+
and element patterns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from typing import Tuple, Optional, Union
|
|
10
|
+
from .utils import theta_phi_to_uv, create_theta_phi_grid, linear_to_db
|
|
11
|
+
|
|
12
|
+
ArrayLike = Union[np.ndarray, float]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def steering_vector(
|
|
16
|
+
k: float,
|
|
17
|
+
x: np.ndarray,
|
|
18
|
+
y: np.ndarray,
|
|
19
|
+
theta0_deg: float,
|
|
20
|
+
phi0_deg: float,
|
|
21
|
+
z: Optional[np.ndarray] = None
|
|
22
|
+
) -> np.ndarray:
|
|
23
|
+
"""
|
|
24
|
+
Compute steering vector (phase weights) for beam pointing.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
k : float
|
|
29
|
+
Wavenumber (2*pi/wavelength) in rad/m
|
|
30
|
+
x : ndarray
|
|
31
|
+
Element x-positions in meters (flattened)
|
|
32
|
+
y : ndarray
|
|
33
|
+
Element y-positions in meters (flattened)
|
|
34
|
+
theta0_deg : float
|
|
35
|
+
Desired beam steering angle theta in degrees (from z-axis)
|
|
36
|
+
phi0_deg : float
|
|
37
|
+
Desired beam steering angle phi in degrees (azimuth)
|
|
38
|
+
z : ndarray, optional
|
|
39
|
+
Element z-positions in meters (for 3D arrays)
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
weights : ndarray
|
|
44
|
+
Complex steering weights (unit magnitude, appropriate phases)
|
|
45
|
+
"""
|
|
46
|
+
theta0 = np.deg2rad(theta0_deg)
|
|
47
|
+
phi0 = np.deg2rad(phi0_deg)
|
|
48
|
+
|
|
49
|
+
# Direction cosines for steering direction
|
|
50
|
+
u0 = np.sin(theta0) * np.cos(phi0)
|
|
51
|
+
v0 = np.sin(theta0) * np.sin(phi0)
|
|
52
|
+
w0 = np.cos(theta0)
|
|
53
|
+
|
|
54
|
+
# Phase shift to steer beam
|
|
55
|
+
if z is None:
|
|
56
|
+
phase = k * (x * u0 + y * v0)
|
|
57
|
+
else:
|
|
58
|
+
phase = k * (x * u0 + y * v0 + z * w0)
|
|
59
|
+
|
|
60
|
+
return np.exp(-1j * phase)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def array_factor_vectorized(
|
|
64
|
+
theta: np.ndarray,
|
|
65
|
+
phi: np.ndarray,
|
|
66
|
+
x: np.ndarray,
|
|
67
|
+
y: np.ndarray,
|
|
68
|
+
weights: np.ndarray,
|
|
69
|
+
k: float,
|
|
70
|
+
z: Optional[np.ndarray] = None
|
|
71
|
+
) -> np.ndarray:
|
|
72
|
+
"""
|
|
73
|
+
Compute array factor using vectorized NumPy operations.
|
|
74
|
+
|
|
75
|
+
This is 50-100x faster than nested loop implementations for typical
|
|
76
|
+
grid sizes (181x181 angular points).
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
theta : ndarray
|
|
81
|
+
Polar angles in radians, shape (n_theta, n_phi) or (n_points,)
|
|
82
|
+
phi : ndarray
|
|
83
|
+
Azimuthal angles in radians, same shape as theta
|
|
84
|
+
x : ndarray
|
|
85
|
+
Element x-positions in meters, shape (n_elements,)
|
|
86
|
+
y : ndarray
|
|
87
|
+
Element y-positions in meters, shape (n_elements,)
|
|
88
|
+
weights : ndarray
|
|
89
|
+
Complex element weights, shape (n_elements,)
|
|
90
|
+
k : float
|
|
91
|
+
Wavenumber (2*pi/wavelength) in rad/m
|
|
92
|
+
z : ndarray, optional
|
|
93
|
+
Element z-positions in meters, shape (n_elements,)
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
AF : ndarray
|
|
98
|
+
Complex array factor, same shape as theta/phi
|
|
99
|
+
"""
|
|
100
|
+
# Flatten inputs for computation
|
|
101
|
+
original_shape = theta.shape
|
|
102
|
+
theta_flat = theta.ravel()
|
|
103
|
+
phi_flat = phi.ravel()
|
|
104
|
+
|
|
105
|
+
# Direction cosines for all observation angles: shape (n_angles,)
|
|
106
|
+
u = np.sin(theta_flat) * np.cos(phi_flat)
|
|
107
|
+
v = np.sin(theta_flat) * np.sin(phi_flat)
|
|
108
|
+
|
|
109
|
+
# Element positions: shape (n_elements,)
|
|
110
|
+
x = np.asarray(x).ravel()
|
|
111
|
+
y = np.asarray(y).ravel()
|
|
112
|
+
weights = np.asarray(weights).ravel()
|
|
113
|
+
|
|
114
|
+
# Phase contributions: shape (n_angles, n_elements)
|
|
115
|
+
# Using broadcasting: (n_angles, 1) * (1, n_elements)
|
|
116
|
+
phase = k * (np.outer(u, x) + np.outer(v, y))
|
|
117
|
+
|
|
118
|
+
if z is not None:
|
|
119
|
+
w = np.cos(theta_flat)
|
|
120
|
+
z = np.asarray(z).ravel()
|
|
121
|
+
phase += k * np.outer(w, z)
|
|
122
|
+
|
|
123
|
+
# Array factor: sum over elements
|
|
124
|
+
AF = np.sum(weights * np.exp(1j * phase), axis=1)
|
|
125
|
+
|
|
126
|
+
return AF.reshape(original_shape)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def array_factor_uv(
|
|
130
|
+
u: np.ndarray,
|
|
131
|
+
v: np.ndarray,
|
|
132
|
+
x: np.ndarray,
|
|
133
|
+
y: np.ndarray,
|
|
134
|
+
weights: np.ndarray,
|
|
135
|
+
k: float
|
|
136
|
+
) -> np.ndarray:
|
|
137
|
+
"""
|
|
138
|
+
Compute array factor directly in UV-space.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
u : ndarray
|
|
143
|
+
Direction cosine u, shape (n_u, n_v) or (n_points,)
|
|
144
|
+
v : ndarray
|
|
145
|
+
Direction cosine v, same shape as u
|
|
146
|
+
x : ndarray
|
|
147
|
+
Element x-positions in meters
|
|
148
|
+
y : ndarray
|
|
149
|
+
Element y-positions in meters
|
|
150
|
+
weights : ndarray
|
|
151
|
+
Complex element weights
|
|
152
|
+
k : float
|
|
153
|
+
Wavenumber in rad/m
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
AF : ndarray
|
|
158
|
+
Complex array factor, same shape as u/v
|
|
159
|
+
"""
|
|
160
|
+
original_shape = u.shape
|
|
161
|
+
u_flat = u.ravel()
|
|
162
|
+
v_flat = v.ravel()
|
|
163
|
+
|
|
164
|
+
x = np.asarray(x).ravel()
|
|
165
|
+
y = np.asarray(y).ravel()
|
|
166
|
+
weights = np.asarray(weights).ravel()
|
|
167
|
+
|
|
168
|
+
# Phase: (n_angles, n_elements)
|
|
169
|
+
phase = k * (np.outer(u_flat, x) + np.outer(v_flat, y))
|
|
170
|
+
|
|
171
|
+
AF = np.sum(weights * np.exp(1j * phase), axis=1)
|
|
172
|
+
|
|
173
|
+
return AF.reshape(original_shape)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def array_factor_fft(
|
|
177
|
+
weights_2d: np.ndarray,
|
|
178
|
+
dx: float,
|
|
179
|
+
dy: float,
|
|
180
|
+
n_u: int = 512,
|
|
181
|
+
n_v: int = 512,
|
|
182
|
+
wavelength: float = 1.0
|
|
183
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
184
|
+
"""
|
|
185
|
+
Compute array factor using 2D FFT for uniform rectangular arrays.
|
|
186
|
+
|
|
187
|
+
This is O(N log N) instead of O(N * M) and is fastest for large
|
|
188
|
+
uniform rectangular arrays.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
weights_2d : ndarray
|
|
193
|
+
Complex weights on 2D grid, shape (Nx, Ny)
|
|
194
|
+
dx : float
|
|
195
|
+
Element spacing in x (in wavelengths)
|
|
196
|
+
dy : float
|
|
197
|
+
Element spacing in y (in wavelengths)
|
|
198
|
+
n_u : int
|
|
199
|
+
Number of output points in u direction
|
|
200
|
+
n_v : int
|
|
201
|
+
Number of output points in v direction
|
|
202
|
+
wavelength : float
|
|
203
|
+
Wavelength (for normalizing spacing, default=1 means dx, dy in wavelengths)
|
|
204
|
+
|
|
205
|
+
Returns
|
|
206
|
+
-------
|
|
207
|
+
u : ndarray
|
|
208
|
+
Direction cosine u values, shape (n_u,)
|
|
209
|
+
v : ndarray
|
|
210
|
+
Direction cosine v values, shape (n_v,)
|
|
211
|
+
AF : ndarray
|
|
212
|
+
Complex array factor, shape (n_u, n_v)
|
|
213
|
+
"""
|
|
214
|
+
Nx, Ny = weights_2d.shape
|
|
215
|
+
|
|
216
|
+
# Zero-pad for desired resolution
|
|
217
|
+
pad_x = max(0, n_u - Nx)
|
|
218
|
+
pad_y = max(0, n_v - Ny)
|
|
219
|
+
|
|
220
|
+
weights_padded = np.pad(
|
|
221
|
+
weights_2d,
|
|
222
|
+
((pad_x // 2, pad_x - pad_x // 2), (pad_y // 2, pad_y - pad_y // 2)),
|
|
223
|
+
mode='constant'
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# 2D FFT
|
|
227
|
+
AF = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(weights_padded)))
|
|
228
|
+
|
|
229
|
+
# UV coordinates from FFT frequencies
|
|
230
|
+
# For element spacing d, maximum unambiguous u is lambda/(2*d)
|
|
231
|
+
# FFT gives spatial frequencies, need to scale to direction cosines
|
|
232
|
+
n_u_actual, n_v_actual = weights_padded.shape
|
|
233
|
+
|
|
234
|
+
# Spatial frequency to direction cosine: u = lambda * f_x
|
|
235
|
+
# FFT frequency spacing: df = 1/(N*d) where d is element spacing
|
|
236
|
+
# So u spacing = lambda * df = lambda / (N * d)
|
|
237
|
+
u_max = wavelength / (2 * dx) if dx > 0 else 1.0
|
|
238
|
+
v_max = wavelength / (2 * dy) if dy > 0 else 1.0
|
|
239
|
+
|
|
240
|
+
u = np.linspace(-u_max, u_max, n_u_actual)
|
|
241
|
+
v = np.linspace(-v_max, v_max, n_v_actual)
|
|
242
|
+
|
|
243
|
+
return u, v, AF
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def element_pattern(
|
|
247
|
+
theta: np.ndarray,
|
|
248
|
+
phi: np.ndarray,
|
|
249
|
+
cos_exp_theta: float = 1.0,
|
|
250
|
+
cos_exp_phi: float = 1.0,
|
|
251
|
+
max_gain_dBi: float = 0.0
|
|
252
|
+
) -> np.ndarray:
|
|
253
|
+
"""
|
|
254
|
+
Compute element pattern using raised cosine model.
|
|
255
|
+
|
|
256
|
+
Parameters
|
|
257
|
+
----------
|
|
258
|
+
theta : ndarray
|
|
259
|
+
Polar angle in radians
|
|
260
|
+
phi : ndarray
|
|
261
|
+
Azimuthal angle in radians (not used in basic model)
|
|
262
|
+
cos_exp_theta : float
|
|
263
|
+
Cosine exponent for theta dependence (1.0 = simple cosine)
|
|
264
|
+
cos_exp_phi : float
|
|
265
|
+
Cosine exponent for additional roll-off
|
|
266
|
+
max_gain_dBi : float
|
|
267
|
+
Peak element gain in dBi
|
|
268
|
+
|
|
269
|
+
Returns
|
|
270
|
+
-------
|
|
271
|
+
pattern : ndarray
|
|
272
|
+
Element pattern (linear scale, same shape as theta)
|
|
273
|
+
"""
|
|
274
|
+
# Convert max gain to linear
|
|
275
|
+
max_gain_linear = 10 ** (max_gain_dBi / 10)
|
|
276
|
+
|
|
277
|
+
# Raised cosine pattern (only valid for forward hemisphere)
|
|
278
|
+
cos_theta = np.cos(theta)
|
|
279
|
+
pattern = np.where(
|
|
280
|
+
cos_theta > 0,
|
|
281
|
+
max_gain_linear * (cos_theta ** cos_exp_theta),
|
|
282
|
+
0.0
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return pattern
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def element_pattern_cosine_tapered(
|
|
289
|
+
theta: np.ndarray,
|
|
290
|
+
phi: np.ndarray,
|
|
291
|
+
theta_3dB_deg: float = 65.0,
|
|
292
|
+
max_gain_dBi: float = 5.0
|
|
293
|
+
) -> np.ndarray:
|
|
294
|
+
"""
|
|
295
|
+
Compute element pattern with specified 3dB beamwidth.
|
|
296
|
+
|
|
297
|
+
The cosine exponent is computed to achieve the desired beamwidth.
|
|
298
|
+
|
|
299
|
+
Parameters
|
|
300
|
+
----------
|
|
301
|
+
theta : ndarray
|
|
302
|
+
Polar angle in radians
|
|
303
|
+
phi : ndarray
|
|
304
|
+
Azimuthal angle in radians
|
|
305
|
+
theta_3dB_deg : float
|
|
306
|
+
Half-power beamwidth in degrees
|
|
307
|
+
max_gain_dBi : float
|
|
308
|
+
Peak element gain in dBi
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
pattern : ndarray
|
|
313
|
+
Element pattern (linear scale)
|
|
314
|
+
"""
|
|
315
|
+
# Compute cosine exponent for desired beamwidth
|
|
316
|
+
# At theta_3dB, pattern = 0.5 * max
|
|
317
|
+
# cos(theta_3dB)^n = 0.5
|
|
318
|
+
# n = log(0.5) / log(cos(theta_3dB))
|
|
319
|
+
theta_3dB = np.deg2rad(theta_3dB_deg)
|
|
320
|
+
if np.cos(theta_3dB) > 0:
|
|
321
|
+
cos_exp = np.log(0.5) / np.log(np.cos(theta_3dB))
|
|
322
|
+
else:
|
|
323
|
+
cos_exp = 1.0
|
|
324
|
+
|
|
325
|
+
return element_pattern(theta, phi, cos_exp, 1.0, max_gain_dBi)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def total_pattern(
|
|
329
|
+
theta: np.ndarray,
|
|
330
|
+
phi: np.ndarray,
|
|
331
|
+
x: np.ndarray,
|
|
332
|
+
y: np.ndarray,
|
|
333
|
+
weights: np.ndarray,
|
|
334
|
+
k: float,
|
|
335
|
+
element_pattern_func: Optional[callable] = None,
|
|
336
|
+
z: Optional[np.ndarray] = None,
|
|
337
|
+
**element_kwargs
|
|
338
|
+
) -> np.ndarray:
|
|
339
|
+
"""
|
|
340
|
+
Compute total radiation pattern (element pattern * array factor).
|
|
341
|
+
|
|
342
|
+
Parameters
|
|
343
|
+
----------
|
|
344
|
+
theta : ndarray
|
|
345
|
+
Polar angles in radians
|
|
346
|
+
phi : ndarray
|
|
347
|
+
Azimuthal angles in radians
|
|
348
|
+
x, y : ndarray
|
|
349
|
+
Element positions in meters
|
|
350
|
+
weights : ndarray
|
|
351
|
+
Complex element weights
|
|
352
|
+
k : float
|
|
353
|
+
Wavenumber in rad/m
|
|
354
|
+
element_pattern_func : callable, optional
|
|
355
|
+
Function to compute element pattern. If None, uses isotropic elements.
|
|
356
|
+
Should have signature: func(theta, phi, **kwargs) -> ndarray
|
|
357
|
+
z : ndarray, optional
|
|
358
|
+
Element z-positions for 3D arrays
|
|
359
|
+
**element_kwargs
|
|
360
|
+
Additional arguments passed to element_pattern_func
|
|
361
|
+
|
|
362
|
+
Returns
|
|
363
|
+
-------
|
|
364
|
+
pattern : ndarray
|
|
365
|
+
Total radiation pattern (complex)
|
|
366
|
+
"""
|
|
367
|
+
# Compute array factor
|
|
368
|
+
AF = array_factor_vectorized(theta, phi, x, y, weights, k, z)
|
|
369
|
+
|
|
370
|
+
# Apply element pattern
|
|
371
|
+
if element_pattern_func is not None:
|
|
372
|
+
EP = element_pattern_func(theta, phi, **element_kwargs)
|
|
373
|
+
pattern = EP * AF
|
|
374
|
+
else:
|
|
375
|
+
pattern = AF
|
|
376
|
+
|
|
377
|
+
return pattern
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def compute_pattern_cuts(
|
|
381
|
+
x: np.ndarray,
|
|
382
|
+
y: np.ndarray,
|
|
383
|
+
weights: np.ndarray,
|
|
384
|
+
k: float,
|
|
385
|
+
theta0_deg: float = 0.0,
|
|
386
|
+
phi0_deg: float = 0.0,
|
|
387
|
+
n_points: int = 361,
|
|
388
|
+
theta_range_deg: Tuple[float, float] = (-90, 90),
|
|
389
|
+
element_pattern_func: Optional[callable] = None,
|
|
390
|
+
**element_kwargs
|
|
391
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
392
|
+
"""
|
|
393
|
+
Compute principal plane pattern cuts (E-plane and H-plane).
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
x, y : ndarray
|
|
398
|
+
Element positions in meters
|
|
399
|
+
weights : ndarray
|
|
400
|
+
Complex element weights
|
|
401
|
+
k : float
|
|
402
|
+
Wavenumber in rad/m
|
|
403
|
+
theta0_deg : float
|
|
404
|
+
Beam steering theta angle
|
|
405
|
+
phi0_deg : float
|
|
406
|
+
Beam steering phi angle
|
|
407
|
+
n_points : int
|
|
408
|
+
Number of points in each cut
|
|
409
|
+
theta_range_deg : tuple
|
|
410
|
+
Angular range in degrees
|
|
411
|
+
element_pattern_func : callable, optional
|
|
412
|
+
Element pattern function
|
|
413
|
+
**element_kwargs
|
|
414
|
+
Arguments for element pattern
|
|
415
|
+
|
|
416
|
+
Returns
|
|
417
|
+
-------
|
|
418
|
+
theta_deg : ndarray
|
|
419
|
+
Angle values in degrees
|
|
420
|
+
E_plane_dB : ndarray
|
|
421
|
+
E-plane pattern in dB (phi = phi0)
|
|
422
|
+
H_plane_dB : ndarray
|
|
423
|
+
H-plane pattern in dB (phi = phi0 + 90)
|
|
424
|
+
"""
|
|
425
|
+
theta_deg = np.linspace(theta_range_deg[0], theta_range_deg[1], n_points)
|
|
426
|
+
theta_rad = np.deg2rad(theta_deg)
|
|
427
|
+
|
|
428
|
+
# For theta range including negative values, map to standard coordinates
|
|
429
|
+
# theta < 0 means scanning on opposite side
|
|
430
|
+
theta_positive = np.abs(theta_rad)
|
|
431
|
+
phi_e = np.where(theta_rad >= 0, np.deg2rad(phi0_deg), np.deg2rad(phi0_deg + 180))
|
|
432
|
+
phi_h = np.where(theta_rad >= 0, np.deg2rad(phi0_deg + 90), np.deg2rad(phi0_deg + 270))
|
|
433
|
+
|
|
434
|
+
# E-plane cut
|
|
435
|
+
pattern_e = total_pattern(
|
|
436
|
+
theta_positive, phi_e, x, y, weights, k,
|
|
437
|
+
element_pattern_func, **element_kwargs
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# H-plane cut
|
|
441
|
+
pattern_h = total_pattern(
|
|
442
|
+
theta_positive, phi_h, x, y, weights, k,
|
|
443
|
+
element_pattern_func, **element_kwargs
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
# Convert to dB
|
|
447
|
+
E_plane_dB = linear_to_db(np.abs(pattern_e)**2)
|
|
448
|
+
H_plane_dB = linear_to_db(np.abs(pattern_h)**2)
|
|
449
|
+
|
|
450
|
+
# Normalize to peak
|
|
451
|
+
E_plane_dB -= np.max(E_plane_dB)
|
|
452
|
+
H_plane_dB -= np.max(H_plane_dB)
|
|
453
|
+
|
|
454
|
+
return theta_deg, E_plane_dB, H_plane_dB
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def compute_full_pattern(
|
|
458
|
+
x: np.ndarray,
|
|
459
|
+
y: np.ndarray,
|
|
460
|
+
weights: np.ndarray,
|
|
461
|
+
k: float,
|
|
462
|
+
n_theta: int = 181,
|
|
463
|
+
n_phi: int = 361,
|
|
464
|
+
theta_range: Tuple[float, float] = (0, np.pi/2),
|
|
465
|
+
phi_range: Tuple[float, float] = (0, 2*np.pi),
|
|
466
|
+
element_pattern_func: Optional[callable] = None,
|
|
467
|
+
z: Optional[np.ndarray] = None,
|
|
468
|
+
**element_kwargs
|
|
469
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
470
|
+
"""
|
|
471
|
+
Compute full 2D radiation pattern.
|
|
472
|
+
|
|
473
|
+
Parameters
|
|
474
|
+
----------
|
|
475
|
+
x, y : ndarray
|
|
476
|
+
Element positions in meters
|
|
477
|
+
weights : ndarray
|
|
478
|
+
Complex element weights
|
|
479
|
+
k : float
|
|
480
|
+
Wavenumber in rad/m
|
|
481
|
+
n_theta, n_phi : int
|
|
482
|
+
Number of angular points
|
|
483
|
+
theta_range, phi_range : tuple
|
|
484
|
+
Angular ranges in radians
|
|
485
|
+
element_pattern_func : callable, optional
|
|
486
|
+
Element pattern function
|
|
487
|
+
z : ndarray, optional
|
|
488
|
+
Element z-positions
|
|
489
|
+
**element_kwargs
|
|
490
|
+
Arguments for element pattern
|
|
491
|
+
|
|
492
|
+
Returns
|
|
493
|
+
-------
|
|
494
|
+
theta : ndarray
|
|
495
|
+
Theta values in radians, shape (n_theta,)
|
|
496
|
+
phi : ndarray
|
|
497
|
+
Phi values in radians, shape (n_phi,)
|
|
498
|
+
pattern_dB : ndarray
|
|
499
|
+
Pattern in dB, shape (n_theta, n_phi)
|
|
500
|
+
"""
|
|
501
|
+
theta_1d, phi_1d, theta_grid, phi_grid = create_theta_phi_grid(
|
|
502
|
+
theta_range, phi_range, n_theta, n_phi
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
pattern = total_pattern(
|
|
506
|
+
theta_grid, phi_grid, x, y, weights, k,
|
|
507
|
+
element_pattern_func, z, **element_kwargs
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
pattern_dB = linear_to_db(np.abs(pattern)**2)
|
|
511
|
+
pattern_dB -= np.max(pattern_dB)
|
|
512
|
+
|
|
513
|
+
return theta_1d, phi_1d, pattern_dB
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def compute_directivity(
|
|
517
|
+
theta: np.ndarray,
|
|
518
|
+
phi: np.ndarray,
|
|
519
|
+
pattern: np.ndarray
|
|
520
|
+
) -> float:
|
|
521
|
+
"""
|
|
522
|
+
Compute directivity from a full-sphere pattern.
|
|
523
|
+
|
|
524
|
+
Parameters
|
|
525
|
+
----------
|
|
526
|
+
theta : ndarray
|
|
527
|
+
2D theta grid in radians
|
|
528
|
+
phi : ndarray
|
|
529
|
+
2D phi grid in radians
|
|
530
|
+
pattern : ndarray
|
|
531
|
+
Complex or magnitude pattern, same shape
|
|
532
|
+
|
|
533
|
+
Returns
|
|
534
|
+
-------
|
|
535
|
+
directivity : float
|
|
536
|
+
Directivity in linear scale
|
|
537
|
+
"""
|
|
538
|
+
power = np.abs(pattern)**2
|
|
539
|
+
|
|
540
|
+
# Find peak
|
|
541
|
+
peak_power = np.max(power)
|
|
542
|
+
|
|
543
|
+
# Integrate over sphere: integral of P(theta,phi) * sin(theta) dtheta dphi
|
|
544
|
+
# Using trapezoidal integration
|
|
545
|
+
d_theta = theta[1, 0] - theta[0, 0] if theta.shape[0] > 1 else np.pi
|
|
546
|
+
d_phi = phi[0, 1] - phi[0, 0] if phi.shape[1] > 1 else 2*np.pi
|
|
547
|
+
|
|
548
|
+
integrand = power * np.sin(theta)
|
|
549
|
+
total_power = np.trapz(np.trapz(integrand, dx=d_phi, axis=1), dx=d_theta)
|
|
550
|
+
|
|
551
|
+
if total_power > 0:
|
|
552
|
+
directivity = 4 * np.pi * peak_power / total_power
|
|
553
|
+
else:
|
|
554
|
+
directivity = 1.0
|
|
555
|
+
|
|
556
|
+
return directivity
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def compute_half_power_beamwidth(
|
|
560
|
+
angles_deg: np.ndarray,
|
|
561
|
+
pattern_dB: np.ndarray
|
|
562
|
+
) -> float:
|
|
563
|
+
"""
|
|
564
|
+
Compute half-power beamwidth from a 1D pattern cut.
|
|
565
|
+
|
|
566
|
+
Parameters
|
|
567
|
+
----------
|
|
568
|
+
angles_deg : ndarray
|
|
569
|
+
Angle values in degrees
|
|
570
|
+
pattern_dB : ndarray
|
|
571
|
+
Normalized pattern in dB (0 dB at peak)
|
|
572
|
+
|
|
573
|
+
Returns
|
|
574
|
+
-------
|
|
575
|
+
hpbw : float
|
|
576
|
+
Half-power beamwidth in degrees
|
|
577
|
+
"""
|
|
578
|
+
# Find points at -3 dB
|
|
579
|
+
above_3dB = pattern_dB >= -3.0
|
|
580
|
+
|
|
581
|
+
if not np.any(above_3dB):
|
|
582
|
+
return 180.0
|
|
583
|
+
|
|
584
|
+
# Find contiguous region around peak
|
|
585
|
+
peak_idx = np.argmax(pattern_dB)
|
|
586
|
+
left_idx = peak_idx
|
|
587
|
+
right_idx = peak_idx
|
|
588
|
+
|
|
589
|
+
while left_idx > 0 and above_3dB[left_idx - 1]:
|
|
590
|
+
left_idx -= 1
|
|
591
|
+
|
|
592
|
+
while right_idx < len(pattern_dB) - 1 and above_3dB[right_idx + 1]:
|
|
593
|
+
right_idx += 1
|
|
594
|
+
|
|
595
|
+
# Interpolate for more accurate beamwidth
|
|
596
|
+
hpbw = angles_deg[right_idx] - angles_deg[left_idx]
|
|
597
|
+
|
|
598
|
+
return abs(hpbw)
|