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.
@@ -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)