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/geometry.py
ADDED
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Array geometry definitions and generation functions.
|
|
3
|
+
|
|
4
|
+
Includes rectangular, circular, conformal, sparse, and subarray architectures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Tuple, Optional, List, Callable, Union
|
|
10
|
+
import warnings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ArrayGeometry:
|
|
15
|
+
"""
|
|
16
|
+
Container for phased array element positions and orientations.
|
|
17
|
+
|
|
18
|
+
Attributes
|
|
19
|
+
----------
|
|
20
|
+
x : ndarray
|
|
21
|
+
Element x-positions in meters
|
|
22
|
+
y : ndarray
|
|
23
|
+
Element y-positions in meters
|
|
24
|
+
z : ndarray, optional
|
|
25
|
+
Element z-positions in meters (for 3D/conformal arrays)
|
|
26
|
+
nx : ndarray, optional
|
|
27
|
+
Element normal x-components (unit vectors)
|
|
28
|
+
ny : ndarray, optional
|
|
29
|
+
Element normal y-components
|
|
30
|
+
nz : ndarray, optional
|
|
31
|
+
Element normal z-components
|
|
32
|
+
element_indices : ndarray, optional
|
|
33
|
+
Original indices before any thinning (for tracking)
|
|
34
|
+
"""
|
|
35
|
+
x: np.ndarray
|
|
36
|
+
y: np.ndarray
|
|
37
|
+
z: Optional[np.ndarray] = None
|
|
38
|
+
nx: Optional[np.ndarray] = None
|
|
39
|
+
ny: Optional[np.ndarray] = None
|
|
40
|
+
nz: Optional[np.ndarray] = None
|
|
41
|
+
element_indices: Optional[np.ndarray] = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def n_elements(self) -> int:
|
|
45
|
+
"""Number of elements in the array."""
|
|
46
|
+
return len(self.x)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_planar(self) -> bool:
|
|
50
|
+
"""Check if array is planar (all z and normals the same)."""
|
|
51
|
+
if self.z is None:
|
|
52
|
+
return True
|
|
53
|
+
return np.allclose(self.z, self.z[0])
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_conformal(self) -> bool:
|
|
57
|
+
"""Check if array has varying element orientations."""
|
|
58
|
+
if self.nx is None or self.ny is None or self.nz is None:
|
|
59
|
+
return False
|
|
60
|
+
# Check if all normals point the same direction
|
|
61
|
+
if np.allclose(self.nx, self.nx[0]) and \
|
|
62
|
+
np.allclose(self.ny, self.ny[0]) and \
|
|
63
|
+
np.allclose(self.nz, self.nz[0]):
|
|
64
|
+
return False
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
def copy(self) -> 'ArrayGeometry':
|
|
68
|
+
"""Create a deep copy of the geometry."""
|
|
69
|
+
return ArrayGeometry(
|
|
70
|
+
x=self.x.copy(),
|
|
71
|
+
y=self.y.copy(),
|
|
72
|
+
z=self.z.copy() if self.z is not None else None,
|
|
73
|
+
nx=self.nx.copy() if self.nx is not None else None,
|
|
74
|
+
ny=self.ny.copy() if self.ny is not None else None,
|
|
75
|
+
nz=self.nz.copy() if self.nz is not None else None,
|
|
76
|
+
element_indices=self.element_indices.copy() if self.element_indices is not None else None
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class SubarrayArchitecture:
|
|
82
|
+
"""
|
|
83
|
+
Defines subarray partitioning of a phased array.
|
|
84
|
+
|
|
85
|
+
Attributes
|
|
86
|
+
----------
|
|
87
|
+
geometry : ArrayGeometry
|
|
88
|
+
Full array geometry
|
|
89
|
+
subarray_assignments : ndarray
|
|
90
|
+
Subarray index for each element (shape: n_elements,)
|
|
91
|
+
n_subarrays : int
|
|
92
|
+
Number of subarrays
|
|
93
|
+
subarray_centers : ndarray, optional
|
|
94
|
+
Center positions of each subarray (n_subarrays, 2 or 3)
|
|
95
|
+
"""
|
|
96
|
+
geometry: ArrayGeometry
|
|
97
|
+
subarray_assignments: np.ndarray
|
|
98
|
+
n_subarrays: int
|
|
99
|
+
subarray_centers: Optional[np.ndarray] = None
|
|
100
|
+
|
|
101
|
+
def get_subarray_elements(self, subarray_idx: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
102
|
+
"""Get element indices and mask for a specific subarray."""
|
|
103
|
+
mask = self.subarray_assignments == subarray_idx
|
|
104
|
+
indices = np.where(mask)[0]
|
|
105
|
+
return indices, mask
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def create_rectangular_array(
|
|
109
|
+
Nx: int,
|
|
110
|
+
Ny: int,
|
|
111
|
+
dx: float,
|
|
112
|
+
dy: float,
|
|
113
|
+
wavelength: float = 1.0,
|
|
114
|
+
center: bool = True
|
|
115
|
+
) -> ArrayGeometry:
|
|
116
|
+
"""
|
|
117
|
+
Create a rectangular planar array.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
Nx : int
|
|
122
|
+
Number of elements in x
|
|
123
|
+
Ny : int
|
|
124
|
+
Number of elements in y
|
|
125
|
+
dx : float
|
|
126
|
+
Element spacing in x (in wavelengths)
|
|
127
|
+
dy : float
|
|
128
|
+
Element spacing in y (in wavelengths)
|
|
129
|
+
wavelength : float
|
|
130
|
+
Wavelength in meters (converts spacing to meters)
|
|
131
|
+
center : bool
|
|
132
|
+
If True, center array at origin
|
|
133
|
+
|
|
134
|
+
Returns
|
|
135
|
+
-------
|
|
136
|
+
geometry : ArrayGeometry
|
|
137
|
+
Array geometry with element positions
|
|
138
|
+
"""
|
|
139
|
+
# Convert spacing to meters
|
|
140
|
+
dx_m = dx * wavelength
|
|
141
|
+
dy_m = dy * wavelength
|
|
142
|
+
|
|
143
|
+
# Create grid
|
|
144
|
+
x_1d = np.arange(Nx) * dx_m
|
|
145
|
+
y_1d = np.arange(Ny) * dy_m
|
|
146
|
+
|
|
147
|
+
if center:
|
|
148
|
+
x_1d -= x_1d.mean()
|
|
149
|
+
y_1d -= y_1d.mean()
|
|
150
|
+
|
|
151
|
+
x_grid, y_grid = np.meshgrid(x_1d, y_1d, indexing='ij')
|
|
152
|
+
x = x_grid.ravel()
|
|
153
|
+
y = y_grid.ravel()
|
|
154
|
+
|
|
155
|
+
return ArrayGeometry(
|
|
156
|
+
x=x, y=y,
|
|
157
|
+
z=np.zeros_like(x),
|
|
158
|
+
nx=np.zeros_like(x),
|
|
159
|
+
ny=np.zeros_like(x),
|
|
160
|
+
nz=np.ones_like(x),
|
|
161
|
+
element_indices=np.arange(len(x))
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def create_triangular_array(
|
|
166
|
+
Nx: int,
|
|
167
|
+
Ny: int,
|
|
168
|
+
dx: float,
|
|
169
|
+
dy: Optional[float] = None,
|
|
170
|
+
wavelength: float = 1.0,
|
|
171
|
+
center: bool = True
|
|
172
|
+
) -> ArrayGeometry:
|
|
173
|
+
"""
|
|
174
|
+
Create an offset triangular (hexagonal) array.
|
|
175
|
+
|
|
176
|
+
Rows are offset by half the x-spacing for optimal packing.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
Nx : int
|
|
181
|
+
Approximate number of elements in x
|
|
182
|
+
Ny : int
|
|
183
|
+
Number of rows in y
|
|
184
|
+
dx : float
|
|
185
|
+
Element spacing in x (in wavelengths)
|
|
186
|
+
dy : float, optional
|
|
187
|
+
Row spacing in y. Default: dx * sqrt(3)/2 for equilateral
|
|
188
|
+
wavelength : float
|
|
189
|
+
Wavelength in meters
|
|
190
|
+
center : bool
|
|
191
|
+
If True, center array at origin
|
|
192
|
+
|
|
193
|
+
Returns
|
|
194
|
+
-------
|
|
195
|
+
geometry : ArrayGeometry
|
|
196
|
+
"""
|
|
197
|
+
if dy is None:
|
|
198
|
+
dy = dx * np.sqrt(3) / 2
|
|
199
|
+
|
|
200
|
+
dx_m = dx * wavelength
|
|
201
|
+
dy_m = dy * wavelength
|
|
202
|
+
|
|
203
|
+
x_list = []
|
|
204
|
+
y_list = []
|
|
205
|
+
|
|
206
|
+
for row in range(Ny):
|
|
207
|
+
offset = (dx_m / 2) if (row % 2 == 1) else 0
|
|
208
|
+
n_in_row = Nx if (row % 2 == 0) else (Nx - 1)
|
|
209
|
+
|
|
210
|
+
for col in range(n_in_row):
|
|
211
|
+
x_list.append(col * dx_m + offset)
|
|
212
|
+
y_list.append(row * dy_m)
|
|
213
|
+
|
|
214
|
+
x = np.array(x_list)
|
|
215
|
+
y = np.array(y_list)
|
|
216
|
+
|
|
217
|
+
if center:
|
|
218
|
+
x -= x.mean()
|
|
219
|
+
y -= y.mean()
|
|
220
|
+
|
|
221
|
+
return ArrayGeometry(
|
|
222
|
+
x=x, y=y,
|
|
223
|
+
z=np.zeros_like(x),
|
|
224
|
+
nx=np.zeros_like(x),
|
|
225
|
+
ny=np.zeros_like(x),
|
|
226
|
+
nz=np.ones_like(x),
|
|
227
|
+
element_indices=np.arange(len(x))
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def create_elliptical_array(
|
|
232
|
+
a: float,
|
|
233
|
+
b: float,
|
|
234
|
+
dx: float,
|
|
235
|
+
dy: Optional[float] = None,
|
|
236
|
+
grid_type: str = 'rectangular',
|
|
237
|
+
wavelength: float = 1.0
|
|
238
|
+
) -> ArrayGeometry:
|
|
239
|
+
"""
|
|
240
|
+
Create an array within an elliptical boundary.
|
|
241
|
+
|
|
242
|
+
Parameters
|
|
243
|
+
----------
|
|
244
|
+
a : float
|
|
245
|
+
Ellipse semi-axis in x (in wavelengths)
|
|
246
|
+
b : float
|
|
247
|
+
Ellipse semi-axis in y (in wavelengths)
|
|
248
|
+
dx : float
|
|
249
|
+
Element spacing in x (in wavelengths)
|
|
250
|
+
dy : float, optional
|
|
251
|
+
Element spacing in y (default: same as dx)
|
|
252
|
+
grid_type : str
|
|
253
|
+
'rectangular' or 'triangular'
|
|
254
|
+
wavelength : float
|
|
255
|
+
Wavelength in meters
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
geometry : ArrayGeometry
|
|
260
|
+
"""
|
|
261
|
+
if dy is None:
|
|
262
|
+
dy = dx
|
|
263
|
+
|
|
264
|
+
a_m = a * wavelength
|
|
265
|
+
b_m = b * wavelength
|
|
266
|
+
dx_m = dx * wavelength
|
|
267
|
+
dy_m = dy * wavelength if grid_type == 'rectangular' else dx * np.sqrt(3) / 2 * wavelength
|
|
268
|
+
|
|
269
|
+
# Generate grid larger than ellipse
|
|
270
|
+
nx_max = int(np.ceil(2 * a / dx)) + 2
|
|
271
|
+
ny_max = int(np.ceil(2 * b / dy)) + 2
|
|
272
|
+
|
|
273
|
+
x_list = []
|
|
274
|
+
y_list = []
|
|
275
|
+
|
|
276
|
+
for row in range(ny_max):
|
|
277
|
+
if grid_type == 'triangular':
|
|
278
|
+
offset = (dx_m / 2) if (row % 2 == 1) else 0
|
|
279
|
+
else:
|
|
280
|
+
offset = 0
|
|
281
|
+
|
|
282
|
+
for col in range(nx_max):
|
|
283
|
+
x_pos = col * dx_m + offset - a_m
|
|
284
|
+
y_pos = row * dy_m - b_m
|
|
285
|
+
|
|
286
|
+
# Check if inside ellipse
|
|
287
|
+
if (x_pos / a_m) ** 2 + (y_pos / b_m) ** 2 <= 1.0:
|
|
288
|
+
x_list.append(x_pos)
|
|
289
|
+
y_list.append(y_pos)
|
|
290
|
+
|
|
291
|
+
x = np.array(x_list)
|
|
292
|
+
y = np.array(y_list)
|
|
293
|
+
|
|
294
|
+
# Center
|
|
295
|
+
x -= x.mean()
|
|
296
|
+
y -= y.mean()
|
|
297
|
+
|
|
298
|
+
return ArrayGeometry(
|
|
299
|
+
x=x, y=y,
|
|
300
|
+
z=np.zeros_like(x),
|
|
301
|
+
nx=np.zeros_like(x),
|
|
302
|
+
ny=np.zeros_like(x),
|
|
303
|
+
nz=np.ones_like(x),
|
|
304
|
+
element_indices=np.arange(len(x))
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def create_circular_array(
|
|
309
|
+
n_elements: int,
|
|
310
|
+
radius: float,
|
|
311
|
+
wavelength: float = 1.0,
|
|
312
|
+
start_angle: float = 0.0
|
|
313
|
+
) -> ArrayGeometry:
|
|
314
|
+
"""
|
|
315
|
+
Create a circular (ring) array in the xy-plane.
|
|
316
|
+
|
|
317
|
+
Parameters
|
|
318
|
+
----------
|
|
319
|
+
n_elements : int
|
|
320
|
+
Number of elements
|
|
321
|
+
radius : float
|
|
322
|
+
Ring radius in wavelengths
|
|
323
|
+
wavelength : float
|
|
324
|
+
Wavelength in meters
|
|
325
|
+
start_angle : float
|
|
326
|
+
Starting angle in radians
|
|
327
|
+
|
|
328
|
+
Returns
|
|
329
|
+
-------
|
|
330
|
+
geometry : ArrayGeometry
|
|
331
|
+
"""
|
|
332
|
+
r_m = radius * wavelength
|
|
333
|
+
angles = np.linspace(start_angle, start_angle + 2*np.pi, n_elements, endpoint=False)
|
|
334
|
+
|
|
335
|
+
x = r_m * np.cos(angles)
|
|
336
|
+
y = r_m * np.sin(angles)
|
|
337
|
+
|
|
338
|
+
# Normals point radially outward for circular array
|
|
339
|
+
nx = np.cos(angles)
|
|
340
|
+
ny = np.sin(angles)
|
|
341
|
+
nz = np.zeros_like(x)
|
|
342
|
+
|
|
343
|
+
return ArrayGeometry(
|
|
344
|
+
x=x, y=y,
|
|
345
|
+
z=np.zeros_like(x),
|
|
346
|
+
nx=nx, ny=ny, nz=nz,
|
|
347
|
+
element_indices=np.arange(n_elements)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def create_concentric_rings_array(
|
|
352
|
+
n_rings: int,
|
|
353
|
+
elements_per_ring: Union[int, List[int]],
|
|
354
|
+
ring_spacing: float,
|
|
355
|
+
wavelength: float = 1.0,
|
|
356
|
+
include_center: bool = True
|
|
357
|
+
) -> ArrayGeometry:
|
|
358
|
+
"""
|
|
359
|
+
Create a concentric rings array.
|
|
360
|
+
|
|
361
|
+
Parameters
|
|
362
|
+
----------
|
|
363
|
+
n_rings : int
|
|
364
|
+
Number of rings
|
|
365
|
+
elements_per_ring : int or list
|
|
366
|
+
Elements per ring (int applies to all, list for custom)
|
|
367
|
+
ring_spacing : float
|
|
368
|
+
Spacing between rings in wavelengths
|
|
369
|
+
wavelength : float
|
|
370
|
+
Wavelength in meters
|
|
371
|
+
include_center : bool
|
|
372
|
+
Include a center element
|
|
373
|
+
|
|
374
|
+
Returns
|
|
375
|
+
-------
|
|
376
|
+
geometry : ArrayGeometry
|
|
377
|
+
"""
|
|
378
|
+
x_list = []
|
|
379
|
+
y_list = []
|
|
380
|
+
nx_list = []
|
|
381
|
+
ny_list = []
|
|
382
|
+
|
|
383
|
+
if include_center:
|
|
384
|
+
x_list.append(0.0)
|
|
385
|
+
y_list.append(0.0)
|
|
386
|
+
nx_list.append(0.0)
|
|
387
|
+
ny_list.append(0.0)
|
|
388
|
+
|
|
389
|
+
if isinstance(elements_per_ring, int):
|
|
390
|
+
elements_per_ring = [elements_per_ring] * n_rings
|
|
391
|
+
|
|
392
|
+
for ring_idx, n_elem in enumerate(elements_per_ring[:n_rings]):
|
|
393
|
+
radius = (ring_idx + 1) * ring_spacing * wavelength
|
|
394
|
+
angles = np.linspace(0, 2*np.pi, n_elem, endpoint=False)
|
|
395
|
+
|
|
396
|
+
for angle in angles:
|
|
397
|
+
x_list.append(radius * np.cos(angle))
|
|
398
|
+
y_list.append(radius * np.sin(angle))
|
|
399
|
+
nx_list.append(np.cos(angle))
|
|
400
|
+
ny_list.append(np.sin(angle))
|
|
401
|
+
|
|
402
|
+
x = np.array(x_list)
|
|
403
|
+
y = np.array(y_list)
|
|
404
|
+
|
|
405
|
+
return ArrayGeometry(
|
|
406
|
+
x=x, y=y,
|
|
407
|
+
z=np.zeros_like(x),
|
|
408
|
+
nx=np.array(nx_list),
|
|
409
|
+
ny=np.array(ny_list),
|
|
410
|
+
nz=np.zeros_like(x),
|
|
411
|
+
element_indices=np.arange(len(x))
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def create_cylindrical_array(
|
|
416
|
+
n_azimuth: int,
|
|
417
|
+
n_vertical: int,
|
|
418
|
+
radius: float,
|
|
419
|
+
height: float,
|
|
420
|
+
wavelength: float = 1.0
|
|
421
|
+
) -> ArrayGeometry:
|
|
422
|
+
"""
|
|
423
|
+
Create a cylindrical conformal array.
|
|
424
|
+
|
|
425
|
+
Elements are distributed on a cylinder surface with normals
|
|
426
|
+
pointing radially outward.
|
|
427
|
+
|
|
428
|
+
Parameters
|
|
429
|
+
----------
|
|
430
|
+
n_azimuth : int
|
|
431
|
+
Number of elements around circumference
|
|
432
|
+
n_vertical : int
|
|
433
|
+
Number of vertical rings
|
|
434
|
+
radius : float
|
|
435
|
+
Cylinder radius in wavelengths
|
|
436
|
+
height : float
|
|
437
|
+
Cylinder height in wavelengths
|
|
438
|
+
wavelength : float
|
|
439
|
+
Wavelength in meters
|
|
440
|
+
|
|
441
|
+
Returns
|
|
442
|
+
-------
|
|
443
|
+
geometry : ArrayGeometry
|
|
444
|
+
"""
|
|
445
|
+
r_m = radius * wavelength
|
|
446
|
+
h_m = height * wavelength
|
|
447
|
+
|
|
448
|
+
phi = np.linspace(0, 2*np.pi, n_azimuth, endpoint=False)
|
|
449
|
+
z_vals = np.linspace(-h_m/2, h_m/2, n_vertical)
|
|
450
|
+
|
|
451
|
+
phi_grid, z_grid = np.meshgrid(phi, z_vals, indexing='ij')
|
|
452
|
+
phi_flat = phi_grid.ravel()
|
|
453
|
+
z_flat = z_grid.ravel()
|
|
454
|
+
|
|
455
|
+
x = r_m * np.cos(phi_flat)
|
|
456
|
+
y = r_m * np.sin(phi_flat)
|
|
457
|
+
z = z_flat
|
|
458
|
+
|
|
459
|
+
# Normals point radially outward
|
|
460
|
+
nx = np.cos(phi_flat)
|
|
461
|
+
ny = np.sin(phi_flat)
|
|
462
|
+
nz = np.zeros_like(x)
|
|
463
|
+
|
|
464
|
+
return ArrayGeometry(
|
|
465
|
+
x=x, y=y, z=z,
|
|
466
|
+
nx=nx, ny=ny, nz=nz,
|
|
467
|
+
element_indices=np.arange(len(x))
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def create_spherical_array(
|
|
472
|
+
n_theta: int,
|
|
473
|
+
n_phi: int,
|
|
474
|
+
radius: float,
|
|
475
|
+
theta_min: float = 0.0,
|
|
476
|
+
theta_max: float = np.pi/2,
|
|
477
|
+
wavelength: float = 1.0
|
|
478
|
+
) -> ArrayGeometry:
|
|
479
|
+
"""
|
|
480
|
+
Create a spherical conformal array.
|
|
481
|
+
|
|
482
|
+
Parameters
|
|
483
|
+
----------
|
|
484
|
+
n_theta : int
|
|
485
|
+
Number of elements in theta (elevation)
|
|
486
|
+
n_phi : int
|
|
487
|
+
Number of elements in phi (azimuth)
|
|
488
|
+
radius : float
|
|
489
|
+
Sphere radius in wavelengths
|
|
490
|
+
theta_min : float
|
|
491
|
+
Minimum theta in radians (0 = north pole)
|
|
492
|
+
theta_max : float
|
|
493
|
+
Maximum theta in radians
|
|
494
|
+
wavelength : float
|
|
495
|
+
Wavelength in meters
|
|
496
|
+
|
|
497
|
+
Returns
|
|
498
|
+
-------
|
|
499
|
+
geometry : ArrayGeometry
|
|
500
|
+
"""
|
|
501
|
+
r_m = radius * wavelength
|
|
502
|
+
|
|
503
|
+
theta = np.linspace(theta_min, theta_max, n_theta)
|
|
504
|
+
phi = np.linspace(0, 2*np.pi, n_phi, endpoint=False)
|
|
505
|
+
|
|
506
|
+
theta_grid, phi_grid = np.meshgrid(theta, phi, indexing='ij')
|
|
507
|
+
theta_flat = theta_grid.ravel()
|
|
508
|
+
phi_flat = phi_grid.ravel()
|
|
509
|
+
|
|
510
|
+
# Spherical to Cartesian
|
|
511
|
+
x = r_m * np.sin(theta_flat) * np.cos(phi_flat)
|
|
512
|
+
y = r_m * np.sin(theta_flat) * np.sin(phi_flat)
|
|
513
|
+
z = r_m * np.cos(theta_flat)
|
|
514
|
+
|
|
515
|
+
# Normals point radially outward
|
|
516
|
+
nx = np.sin(theta_flat) * np.cos(phi_flat)
|
|
517
|
+
ny = np.sin(theta_flat) * np.sin(phi_flat)
|
|
518
|
+
nz = np.cos(theta_flat)
|
|
519
|
+
|
|
520
|
+
return ArrayGeometry(
|
|
521
|
+
x=x, y=y, z=z,
|
|
522
|
+
nx=nx, ny=ny, nz=nz,
|
|
523
|
+
element_indices=np.arange(len(x))
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ============== Sparse/Thinned Arrays ==============
|
|
528
|
+
|
|
529
|
+
def thin_array_random(
|
|
530
|
+
geometry: ArrayGeometry,
|
|
531
|
+
thinning_factor: float,
|
|
532
|
+
seed: Optional[int] = None
|
|
533
|
+
) -> ArrayGeometry:
|
|
534
|
+
"""
|
|
535
|
+
Randomly thin an array by removing elements.
|
|
536
|
+
|
|
537
|
+
Parameters
|
|
538
|
+
----------
|
|
539
|
+
geometry : ArrayGeometry
|
|
540
|
+
Original array geometry
|
|
541
|
+
thinning_factor : float
|
|
542
|
+
Fraction of elements to keep (0 to 1)
|
|
543
|
+
seed : int, optional
|
|
544
|
+
Random seed for reproducibility
|
|
545
|
+
|
|
546
|
+
Returns
|
|
547
|
+
-------
|
|
548
|
+
geometry : ArrayGeometry
|
|
549
|
+
Thinned array geometry
|
|
550
|
+
"""
|
|
551
|
+
if seed is not None:
|
|
552
|
+
np.random.seed(seed)
|
|
553
|
+
|
|
554
|
+
n_keep = int(geometry.n_elements * thinning_factor)
|
|
555
|
+
keep_indices = np.random.choice(geometry.n_elements, n_keep, replace=False)
|
|
556
|
+
keep_indices = np.sort(keep_indices)
|
|
557
|
+
|
|
558
|
+
return ArrayGeometry(
|
|
559
|
+
x=geometry.x[keep_indices],
|
|
560
|
+
y=geometry.y[keep_indices],
|
|
561
|
+
z=geometry.z[keep_indices] if geometry.z is not None else None,
|
|
562
|
+
nx=geometry.nx[keep_indices] if geometry.nx is not None else None,
|
|
563
|
+
ny=geometry.ny[keep_indices] if geometry.ny is not None else None,
|
|
564
|
+
nz=geometry.nz[keep_indices] if geometry.nz is not None else None,
|
|
565
|
+
element_indices=keep_indices
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def thin_array_density_tapered(
|
|
570
|
+
geometry: ArrayGeometry,
|
|
571
|
+
density_func: Callable[[np.ndarray, np.ndarray], np.ndarray],
|
|
572
|
+
seed: Optional[int] = None
|
|
573
|
+
) -> ArrayGeometry:
|
|
574
|
+
"""
|
|
575
|
+
Thin array with position-dependent density (statistical thinning).
|
|
576
|
+
|
|
577
|
+
Parameters
|
|
578
|
+
----------
|
|
579
|
+
geometry : ArrayGeometry
|
|
580
|
+
Original array geometry
|
|
581
|
+
density_func : callable
|
|
582
|
+
Function(x, y) -> probability of keeping each element (0 to 1)
|
|
583
|
+
seed : int, optional
|
|
584
|
+
Random seed
|
|
585
|
+
|
|
586
|
+
Returns
|
|
587
|
+
-------
|
|
588
|
+
geometry : ArrayGeometry
|
|
589
|
+
Thinned geometry
|
|
590
|
+
"""
|
|
591
|
+
if seed is not None:
|
|
592
|
+
np.random.seed(seed)
|
|
593
|
+
|
|
594
|
+
# Compute keep probability for each element
|
|
595
|
+
keep_prob = density_func(geometry.x, geometry.y)
|
|
596
|
+
keep_prob = np.clip(keep_prob, 0, 1)
|
|
597
|
+
|
|
598
|
+
# Random selection based on probability
|
|
599
|
+
keep_mask = np.random.random(geometry.n_elements) < keep_prob
|
|
600
|
+
keep_indices = np.where(keep_mask)[0]
|
|
601
|
+
|
|
602
|
+
return ArrayGeometry(
|
|
603
|
+
x=geometry.x[keep_indices],
|
|
604
|
+
y=geometry.y[keep_indices],
|
|
605
|
+
z=geometry.z[keep_indices] if geometry.z is not None else None,
|
|
606
|
+
nx=geometry.nx[keep_indices] if geometry.nx is not None else None,
|
|
607
|
+
ny=geometry.ny[keep_indices] if geometry.ny is not None else None,
|
|
608
|
+
nz=geometry.nz[keep_indices] if geometry.nz is not None else None,
|
|
609
|
+
element_indices=keep_indices
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def thin_array_genetic_algorithm(
|
|
614
|
+
geometry: ArrayGeometry,
|
|
615
|
+
n_target: int,
|
|
616
|
+
objective_func: Callable[[ArrayGeometry], float],
|
|
617
|
+
population_size: int = 50,
|
|
618
|
+
n_generations: int = 100,
|
|
619
|
+
mutation_rate: float = 0.1,
|
|
620
|
+
seed: Optional[int] = None
|
|
621
|
+
) -> ArrayGeometry:
|
|
622
|
+
"""
|
|
623
|
+
Optimize element selection using a genetic algorithm.
|
|
624
|
+
|
|
625
|
+
Parameters
|
|
626
|
+
----------
|
|
627
|
+
geometry : ArrayGeometry
|
|
628
|
+
Original full array geometry
|
|
629
|
+
n_target : int
|
|
630
|
+
Target number of elements to keep
|
|
631
|
+
objective_func : callable
|
|
632
|
+
Function(ArrayGeometry) -> float to minimize
|
|
633
|
+
population_size : int
|
|
634
|
+
GA population size
|
|
635
|
+
n_generations : int
|
|
636
|
+
Number of generations
|
|
637
|
+
mutation_rate : float
|
|
638
|
+
Probability of mutation per gene
|
|
639
|
+
seed : int, optional
|
|
640
|
+
Random seed
|
|
641
|
+
|
|
642
|
+
Returns
|
|
643
|
+
-------
|
|
644
|
+
geometry : ArrayGeometry
|
|
645
|
+
Optimized thinned geometry
|
|
646
|
+
"""
|
|
647
|
+
if seed is not None:
|
|
648
|
+
np.random.seed(seed)
|
|
649
|
+
|
|
650
|
+
n_elements = geometry.n_elements
|
|
651
|
+
|
|
652
|
+
def create_individual():
|
|
653
|
+
"""Create a random selection of n_target elements."""
|
|
654
|
+
return np.random.choice(n_elements, n_target, replace=False)
|
|
655
|
+
|
|
656
|
+
def evaluate(individual):
|
|
657
|
+
"""Evaluate fitness of an individual."""
|
|
658
|
+
geom = ArrayGeometry(
|
|
659
|
+
x=geometry.x[individual],
|
|
660
|
+
y=geometry.y[individual],
|
|
661
|
+
z=geometry.z[individual] if geometry.z is not None else None,
|
|
662
|
+
nx=geometry.nx[individual] if geometry.nx is not None else None,
|
|
663
|
+
ny=geometry.ny[individual] if geometry.ny is not None else None,
|
|
664
|
+
nz=geometry.nz[individual] if geometry.nz is not None else None,
|
|
665
|
+
element_indices=individual
|
|
666
|
+
)
|
|
667
|
+
return objective_func(geom)
|
|
668
|
+
|
|
669
|
+
def crossover(parent1, parent2):
|
|
670
|
+
"""Single-point crossover maintaining n_target elements."""
|
|
671
|
+
# Combine unique elements from both parents
|
|
672
|
+
combined = np.unique(np.concatenate([parent1, parent2]))
|
|
673
|
+
if len(combined) >= n_target:
|
|
674
|
+
return np.random.choice(combined, n_target, replace=False)
|
|
675
|
+
else:
|
|
676
|
+
# Fill with random elements if needed
|
|
677
|
+
remaining = np.setdiff1d(np.arange(n_elements), combined)
|
|
678
|
+
additional = np.random.choice(remaining, n_target - len(combined), replace=False)
|
|
679
|
+
return np.concatenate([combined, additional])
|
|
680
|
+
|
|
681
|
+
def mutate(individual):
|
|
682
|
+
"""Mutate by swapping elements."""
|
|
683
|
+
individual = individual.copy()
|
|
684
|
+
for i in range(len(individual)):
|
|
685
|
+
if np.random.random() < mutation_rate:
|
|
686
|
+
# Swap with a random element not in the array
|
|
687
|
+
available = np.setdiff1d(np.arange(n_elements), individual)
|
|
688
|
+
if len(available) > 0:
|
|
689
|
+
individual[i] = np.random.choice(available)
|
|
690
|
+
return individual
|
|
691
|
+
|
|
692
|
+
# Initialize population
|
|
693
|
+
population = [create_individual() for _ in range(population_size)]
|
|
694
|
+
fitness = [evaluate(ind) for ind in population]
|
|
695
|
+
|
|
696
|
+
for gen in range(n_generations):
|
|
697
|
+
# Selection (tournament)
|
|
698
|
+
new_population = []
|
|
699
|
+
elite_idx = np.argmin(fitness)
|
|
700
|
+
new_population.append(population[elite_idx]) # Elitism
|
|
701
|
+
|
|
702
|
+
while len(new_population) < population_size:
|
|
703
|
+
# Tournament selection
|
|
704
|
+
i1, i2 = np.random.choice(population_size, 2, replace=False)
|
|
705
|
+
parent1 = population[i1] if fitness[i1] < fitness[i2] else population[i2]
|
|
706
|
+
|
|
707
|
+
i3, i4 = np.random.choice(population_size, 2, replace=False)
|
|
708
|
+
parent2 = population[i3] if fitness[i3] < fitness[i4] else population[i4]
|
|
709
|
+
|
|
710
|
+
child = crossover(parent1, parent2)
|
|
711
|
+
child = mutate(child)
|
|
712
|
+
new_population.append(child)
|
|
713
|
+
|
|
714
|
+
population = new_population
|
|
715
|
+
fitness = [evaluate(ind) for ind in population]
|
|
716
|
+
|
|
717
|
+
# Return best individual
|
|
718
|
+
best_idx = np.argmin(fitness)
|
|
719
|
+
best_individual = population[best_idx]
|
|
720
|
+
|
|
721
|
+
return ArrayGeometry(
|
|
722
|
+
x=geometry.x[best_individual],
|
|
723
|
+
y=geometry.y[best_individual],
|
|
724
|
+
z=geometry.z[best_individual] if geometry.z is not None else None,
|
|
725
|
+
nx=geometry.nx[best_individual] if geometry.nx is not None else None,
|
|
726
|
+
ny=geometry.ny[best_individual] if geometry.ny is not None else None,
|
|
727
|
+
nz=geometry.nz[best_individual] if geometry.nz is not None else None,
|
|
728
|
+
element_indices=best_individual
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# ============== Subarray Architectures ==============
|
|
733
|
+
|
|
734
|
+
def create_rectangular_subarrays(
|
|
735
|
+
Nx_total: int,
|
|
736
|
+
Ny_total: int,
|
|
737
|
+
Nx_sub: int,
|
|
738
|
+
Ny_sub: int,
|
|
739
|
+
dx: float,
|
|
740
|
+
dy: float,
|
|
741
|
+
wavelength: float = 1.0
|
|
742
|
+
) -> SubarrayArchitecture:
|
|
743
|
+
"""
|
|
744
|
+
Create rectangular subarrays from a rectangular array.
|
|
745
|
+
|
|
746
|
+
Parameters
|
|
747
|
+
----------
|
|
748
|
+
Nx_total : int
|
|
749
|
+
Total elements in x
|
|
750
|
+
Ny_total : int
|
|
751
|
+
Total elements in y
|
|
752
|
+
Nx_sub : int
|
|
753
|
+
Elements per subarray in x
|
|
754
|
+
Ny_sub : int
|
|
755
|
+
Elements per subarray in y
|
|
756
|
+
dx, dy : float
|
|
757
|
+
Element spacing in wavelengths
|
|
758
|
+
wavelength : float
|
|
759
|
+
Wavelength in meters
|
|
760
|
+
|
|
761
|
+
Returns
|
|
762
|
+
-------
|
|
763
|
+
architecture : SubarrayArchitecture
|
|
764
|
+
"""
|
|
765
|
+
# Create full array
|
|
766
|
+
geometry = create_rectangular_array(Nx_total, Ny_total, dx, dy, wavelength)
|
|
767
|
+
|
|
768
|
+
# Number of subarrays
|
|
769
|
+
n_sub_x = Nx_total // Nx_sub
|
|
770
|
+
n_sub_y = Ny_total // Ny_sub
|
|
771
|
+
n_subarrays = n_sub_x * n_sub_y
|
|
772
|
+
|
|
773
|
+
# Assign elements to subarrays
|
|
774
|
+
assignments = np.zeros(geometry.n_elements, dtype=int)
|
|
775
|
+
subarray_centers = []
|
|
776
|
+
|
|
777
|
+
# Element positions on the original grid
|
|
778
|
+
for sub_idx in range(n_subarrays):
|
|
779
|
+
sub_ix = sub_idx % n_sub_x
|
|
780
|
+
sub_iy = sub_idx // n_sub_x
|
|
781
|
+
|
|
782
|
+
# Elements in this subarray
|
|
783
|
+
x_start = sub_ix * Nx_sub
|
|
784
|
+
y_start = sub_iy * Ny_sub
|
|
785
|
+
|
|
786
|
+
center_x = 0.0
|
|
787
|
+
center_y = 0.0
|
|
788
|
+
count = 0
|
|
789
|
+
|
|
790
|
+
for ix in range(Nx_sub):
|
|
791
|
+
for iy in range(Ny_sub):
|
|
792
|
+
global_ix = x_start + ix
|
|
793
|
+
global_iy = y_start + iy
|
|
794
|
+
if global_ix < Nx_total and global_iy < Ny_total:
|
|
795
|
+
elem_idx = global_ix * Ny_total + global_iy
|
|
796
|
+
if elem_idx < geometry.n_elements:
|
|
797
|
+
assignments[elem_idx] = sub_idx
|
|
798
|
+
center_x += geometry.x[elem_idx]
|
|
799
|
+
center_y += geometry.y[elem_idx]
|
|
800
|
+
count += 1
|
|
801
|
+
|
|
802
|
+
if count > 0:
|
|
803
|
+
subarray_centers.append([center_x / count, center_y / count])
|
|
804
|
+
else:
|
|
805
|
+
subarray_centers.append([0, 0])
|
|
806
|
+
|
|
807
|
+
return SubarrayArchitecture(
|
|
808
|
+
geometry=geometry,
|
|
809
|
+
subarray_assignments=assignments,
|
|
810
|
+
n_subarrays=n_subarrays,
|
|
811
|
+
subarray_centers=np.array(subarray_centers)
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def compute_subarray_weights(
|
|
816
|
+
architecture: SubarrayArchitecture,
|
|
817
|
+
k: float,
|
|
818
|
+
theta0_deg: float,
|
|
819
|
+
phi0_deg: float,
|
|
820
|
+
intra_subarray_weights: Optional[np.ndarray] = None
|
|
821
|
+
) -> np.ndarray:
|
|
822
|
+
"""
|
|
823
|
+
Compute element weights for subarray-level beamforming.
|
|
824
|
+
|
|
825
|
+
Phase is quantized to subarray centers (one phase shifter per subarray).
|
|
826
|
+
|
|
827
|
+
Parameters
|
|
828
|
+
----------
|
|
829
|
+
architecture : SubarrayArchitecture
|
|
830
|
+
Subarray architecture
|
|
831
|
+
k : float
|
|
832
|
+
Wavenumber
|
|
833
|
+
theta0_deg, phi0_deg : float
|
|
834
|
+
Steering direction in degrees
|
|
835
|
+
intra_subarray_weights : ndarray, optional
|
|
836
|
+
Amplitude taper applied within each subarray
|
|
837
|
+
|
|
838
|
+
Returns
|
|
839
|
+
-------
|
|
840
|
+
weights : ndarray
|
|
841
|
+
Complex weights for all elements
|
|
842
|
+
"""
|
|
843
|
+
from .core import steering_vector
|
|
844
|
+
|
|
845
|
+
geom = architecture.geometry
|
|
846
|
+
weights = np.ones(geom.n_elements, dtype=complex)
|
|
847
|
+
|
|
848
|
+
# Apply intra-subarray amplitude weights if provided
|
|
849
|
+
if intra_subarray_weights is not None:
|
|
850
|
+
weights *= intra_subarray_weights
|
|
851
|
+
|
|
852
|
+
# Compute steering phase for each subarray center
|
|
853
|
+
theta0 = np.deg2rad(theta0_deg)
|
|
854
|
+
phi0 = np.deg2rad(phi0_deg)
|
|
855
|
+
u0 = np.sin(theta0) * np.cos(phi0)
|
|
856
|
+
v0 = np.sin(theta0) * np.sin(phi0)
|
|
857
|
+
|
|
858
|
+
for sub_idx in range(architecture.n_subarrays):
|
|
859
|
+
center = architecture.subarray_centers[sub_idx]
|
|
860
|
+
phase = k * (center[0] * u0 + center[1] * v0)
|
|
861
|
+
|
|
862
|
+
# Apply this phase to all elements in the subarray
|
|
863
|
+
mask = architecture.subarray_assignments == sub_idx
|
|
864
|
+
weights[mask] *= np.exp(-1j * phase)
|
|
865
|
+
|
|
866
|
+
return weights
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def array_factor_conformal(
|
|
870
|
+
theta: np.ndarray,
|
|
871
|
+
phi: np.ndarray,
|
|
872
|
+
geometry: ArrayGeometry,
|
|
873
|
+
weights: np.ndarray,
|
|
874
|
+
k: float,
|
|
875
|
+
element_pattern_func: Optional[Callable] = None,
|
|
876
|
+
**element_kwargs
|
|
877
|
+
) -> np.ndarray:
|
|
878
|
+
"""
|
|
879
|
+
Compute array factor for conformal arrays with element orientation.
|
|
880
|
+
|
|
881
|
+
Each element's contribution is weighted by its projected pattern
|
|
882
|
+
in the observation direction.
|
|
883
|
+
|
|
884
|
+
Parameters
|
|
885
|
+
----------
|
|
886
|
+
theta : ndarray
|
|
887
|
+
Observation theta angles in radians
|
|
888
|
+
phi : ndarray
|
|
889
|
+
Observation phi angles in radians
|
|
890
|
+
geometry : ArrayGeometry
|
|
891
|
+
Array geometry with element normals
|
|
892
|
+
weights : ndarray
|
|
893
|
+
Complex element weights
|
|
894
|
+
k : float
|
|
895
|
+
Wavenumber
|
|
896
|
+
element_pattern_func : callable, optional
|
|
897
|
+
Element pattern function
|
|
898
|
+
**element_kwargs
|
|
899
|
+
Arguments for element pattern function
|
|
900
|
+
|
|
901
|
+
Returns
|
|
902
|
+
-------
|
|
903
|
+
AF : ndarray
|
|
904
|
+
Array factor (same shape as theta)
|
|
905
|
+
"""
|
|
906
|
+
original_shape = theta.shape
|
|
907
|
+
theta_flat = theta.ravel()
|
|
908
|
+
phi_flat = phi.ravel()
|
|
909
|
+
n_angles = len(theta_flat)
|
|
910
|
+
|
|
911
|
+
# Observation direction unit vectors
|
|
912
|
+
obs_x = np.sin(theta_flat) * np.cos(phi_flat)
|
|
913
|
+
obs_y = np.sin(theta_flat) * np.sin(phi_flat)
|
|
914
|
+
obs_z = np.cos(theta_flat)
|
|
915
|
+
|
|
916
|
+
AF = np.zeros(n_angles, dtype=complex)
|
|
917
|
+
|
|
918
|
+
for i in range(geometry.n_elements):
|
|
919
|
+
# Phase from element position
|
|
920
|
+
phase = k * (geometry.x[i] * obs_x + geometry.y[i] * obs_y)
|
|
921
|
+
if geometry.z is not None:
|
|
922
|
+
phase += k * geometry.z[i] * obs_z
|
|
923
|
+
|
|
924
|
+
# Element pattern correction for orientation
|
|
925
|
+
if geometry.nx is not None and geometry.ny is not None and geometry.nz is not None:
|
|
926
|
+
# Cosine of angle between observation direction and element normal
|
|
927
|
+
cos_angle = (geometry.nx[i] * obs_x +
|
|
928
|
+
geometry.ny[i] * obs_y +
|
|
929
|
+
geometry.nz[i] * obs_z)
|
|
930
|
+
cos_angle = np.maximum(cos_angle, 0) # Only forward hemisphere
|
|
931
|
+
|
|
932
|
+
if element_pattern_func is not None:
|
|
933
|
+
# Local theta for this element (angle from its normal)
|
|
934
|
+
local_theta = np.arccos(np.clip(cos_angle, -1, 1))
|
|
935
|
+
element_gain = element_pattern_func(local_theta, phi_flat, **element_kwargs)
|
|
936
|
+
else:
|
|
937
|
+
element_gain = cos_angle # Simple cosine pattern
|
|
938
|
+
else:
|
|
939
|
+
element_gain = 1.0
|
|
940
|
+
|
|
941
|
+
AF += weights[i] * element_gain * np.exp(1j * phase)
|
|
942
|
+
|
|
943
|
+
return AF.reshape(original_shape)
|