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,719 @@
1
+ """
2
+ Realistic impairment models for phased arrays.
3
+
4
+ Includes mutual coupling, phase quantization, element failures, and scan blindness.
5
+ """
6
+
7
+ import numpy as np
8
+ from typing import Tuple, Optional, List, Dict, Union
9
+ from .geometry import ArrayGeometry
10
+
11
+
12
+ # ============== Mutual Coupling ==============
13
+
14
+ def mutual_coupling_matrix_theoretical(
15
+ geometry: ArrayGeometry,
16
+ k: float,
17
+ coupling_model: str = 'sinc',
18
+ coupling_coeff: float = 0.3
19
+ ) -> np.ndarray:
20
+ """
21
+ Compute theoretical mutual coupling matrix.
22
+
23
+ Parameters
24
+ ----------
25
+ geometry : ArrayGeometry
26
+ Array geometry
27
+ k : float
28
+ Wavenumber (2*pi/wavelength)
29
+ coupling_model : str
30
+ 'sinc' - sinc function model (good for dipoles)
31
+ 'exponential' - exponential decay model
32
+ coupling_coeff : float
33
+ Coupling coefficient (typical: 0.1-0.5)
34
+
35
+ Returns
36
+ -------
37
+ C : ndarray
38
+ N x N complex mutual coupling matrix
39
+ """
40
+ n = geometry.n_elements
41
+ C = np.eye(n, dtype=complex)
42
+
43
+ for i in range(n):
44
+ for j in range(n):
45
+ if i != j:
46
+ # Distance between elements
47
+ dx = geometry.x[i] - geometry.x[j]
48
+ dy = geometry.y[i] - geometry.y[j]
49
+ dz = 0
50
+ if geometry.z is not None:
51
+ dz = geometry.z[i] - geometry.z[j]
52
+
53
+ r = np.sqrt(dx**2 + dy**2 + dz**2)
54
+ kr = k * r
55
+
56
+ if coupling_model == 'sinc':
57
+ # Sinc model: approximates dipole coupling
58
+ if kr > 1e-10:
59
+ coupling = coupling_coeff * np.sin(kr) / kr
60
+ else:
61
+ coupling = coupling_coeff
62
+ elif coupling_model == 'exponential':
63
+ # Exponential decay
64
+ coupling = coupling_coeff * np.exp(-kr / 2)
65
+ else:
66
+ coupling = 0
67
+
68
+ # Add phase based on distance
69
+ C[i, j] = coupling * np.exp(-1j * kr)
70
+
71
+ return C
72
+
73
+
74
+ def mutual_coupling_matrix_measured(
75
+ s_parameters: np.ndarray
76
+ ) -> np.ndarray:
77
+ """
78
+ Convert measured S-parameters to coupling matrix.
79
+
80
+ The coupling matrix relates actual element currents to excitation
81
+ voltages: I = C^(-1) @ V
82
+
83
+ Parameters
84
+ ----------
85
+ s_parameters : ndarray
86
+ N x N S-parameter matrix (complex)
87
+
88
+ Returns
89
+ -------
90
+ C : ndarray
91
+ Mutual coupling matrix
92
+ """
93
+ n = s_parameters.shape[0]
94
+ # C = (I + S)(I - S)^(-1) for impedance normalization
95
+ # Or simply use S directly for voltage coupling
96
+ C = np.eye(n, dtype=complex) + s_parameters
97
+ return C
98
+
99
+
100
+ def apply_mutual_coupling(
101
+ weights: np.ndarray,
102
+ C: np.ndarray,
103
+ mode: str = 'transmit'
104
+ ) -> np.ndarray:
105
+ """
106
+ Apply mutual coupling to element weights.
107
+
108
+ Parameters
109
+ ----------
110
+ weights : ndarray
111
+ Ideal element weights (N,)
112
+ C : ndarray
113
+ Mutual coupling matrix (N x N)
114
+ mode : str
115
+ 'transmit' - coupling affects radiated field
116
+ 'receive' - coupling affects received signal
117
+ 'compensate' - pre-distort to compensate coupling
118
+
119
+ Returns
120
+ -------
121
+ effective_weights : ndarray
122
+ Weights after coupling effects
123
+ """
124
+ if mode == 'transmit':
125
+ # Actual element excitations given desired weights
126
+ # Radiated field is C @ weights
127
+ return C @ weights
128
+ elif mode == 'receive':
129
+ # Coupled signal at element ports
130
+ return C.T @ weights
131
+ elif mode == 'compensate':
132
+ # Pre-distort to achieve desired radiation
133
+ # Want C @ w_comp = w_desired, so w_comp = C^(-1) @ w_desired
134
+ try:
135
+ return np.linalg.solve(C, weights)
136
+ except np.linalg.LinAlgError:
137
+ return np.linalg.lstsq(C, weights, rcond=None)[0]
138
+ else:
139
+ raise ValueError(f"Unknown coupling mode: {mode}")
140
+
141
+
142
+ def active_element_pattern(
143
+ theta: np.ndarray,
144
+ phi: np.ndarray,
145
+ geometry: ArrayGeometry,
146
+ element_idx: int,
147
+ C: np.ndarray,
148
+ k: float,
149
+ isolated_element_pattern: Optional[callable] = None
150
+ ) -> np.ndarray:
151
+ """
152
+ Compute active element pattern including mutual coupling.
153
+
154
+ The active element pattern is the pattern of a single element
155
+ when all other elements are terminated in matched loads.
156
+
157
+ Parameters
158
+ ----------
159
+ theta : ndarray
160
+ Observation theta angles
161
+ phi : ndarray
162
+ Observation phi angles
163
+ geometry : ArrayGeometry
164
+ Array geometry
165
+ element_idx : int
166
+ Index of the element to compute pattern for
167
+ C : ndarray
168
+ Mutual coupling matrix
169
+ k : float
170
+ Wavenumber
171
+ isolated_element_pattern : callable, optional
172
+ Pattern function for isolated element
173
+
174
+ Returns
175
+ -------
176
+ pattern : ndarray
177
+ Active element pattern (complex)
178
+ """
179
+ from .core import array_factor_vectorized
180
+
181
+ n = geometry.n_elements
182
+
183
+ # Excite only the element of interest
184
+ excitation = np.zeros(n, dtype=complex)
185
+ excitation[element_idx] = 1.0
186
+
187
+ # Apply coupling
188
+ effective_excitation = C @ excitation
189
+
190
+ # Compute pattern (AF with coupled excitations)
191
+ AF = array_factor_vectorized(
192
+ theta, phi,
193
+ geometry.x, geometry.y,
194
+ effective_excitation, k,
195
+ geometry.z
196
+ )
197
+
198
+ # Apply isolated element pattern if provided
199
+ if isolated_element_pattern is not None:
200
+ EP = isolated_element_pattern(theta, phi)
201
+ return AF * EP
202
+
203
+ return AF
204
+
205
+
206
+ # ============== Phase Quantization ==============
207
+
208
+ def quantize_phase(
209
+ weights: np.ndarray,
210
+ n_bits: int
211
+ ) -> np.ndarray:
212
+ """
213
+ Quantize phase shifter settings to discrete levels.
214
+
215
+ Parameters
216
+ ----------
217
+ weights : ndarray
218
+ Complex weights (phase will be quantized)
219
+ n_bits : int
220
+ Number of bits for phase quantization (e.g., 3 bits = 8 levels)
221
+
222
+ Returns
223
+ -------
224
+ quantized_weights : ndarray
225
+ Weights with quantized phases
226
+ """
227
+ n_levels = 2 ** n_bits
228
+ phase_step = 2 * np.pi / n_levels
229
+
230
+ # Extract amplitude and phase
231
+ amplitude = np.abs(weights)
232
+ phase = np.angle(weights)
233
+
234
+ # Quantize phase to nearest level
235
+ quantized_phase = np.round(phase / phase_step) * phase_step
236
+
237
+ return amplitude * np.exp(1j * quantized_phase)
238
+
239
+
240
+ def quantization_rms_error(n_bits: int) -> float:
241
+ """
242
+ Compute theoretical RMS phase error for quantization.
243
+
244
+ Parameters
245
+ ----------
246
+ n_bits : int
247
+ Number of phase quantization bits
248
+
249
+ Returns
250
+ -------
251
+ rms_error_deg : float
252
+ RMS phase error in degrees
253
+ """
254
+ # Uniform quantization: RMS error = step / sqrt(12)
255
+ n_levels = 2 ** n_bits
256
+ step_deg = 360.0 / n_levels
257
+ return step_deg / np.sqrt(12)
258
+
259
+
260
+ def quantization_sidelobe_increase(n_bits: int) -> float:
261
+ """
262
+ Estimate sidelobe level increase due to phase quantization.
263
+
264
+ Parameters
265
+ ----------
266
+ n_bits : int
267
+ Number of phase quantization bits
268
+
269
+ Returns
270
+ -------
271
+ increase_dB : float
272
+ Expected sidelobe increase in dB
273
+ """
274
+ # Approximate formula: sidelobe ratio ~ -6*n_bits dB
275
+ # for uniformly distributed phase errors
276
+ rms_error_rad = np.deg2rad(quantization_rms_error(n_bits))
277
+ # Peak sidelobe from quantization noise ~ 2*sigma
278
+ return 20 * np.log10(2 * rms_error_rad + 1e-10)
279
+
280
+
281
+ def analyze_quantization_effect(
282
+ weights: np.ndarray,
283
+ geometry: ArrayGeometry,
284
+ k: float,
285
+ n_bits: int,
286
+ theta_range: Tuple[float, float] = (0, np.pi/2),
287
+ n_points: int = 361
288
+ ) -> Dict[str, np.ndarray]:
289
+ """
290
+ Analyze effect of phase quantization on the pattern.
291
+
292
+ Parameters
293
+ ----------
294
+ weights : ndarray
295
+ Ideal complex weights
296
+ geometry : ArrayGeometry
297
+ Array geometry
298
+ k : float
299
+ Wavenumber
300
+ n_bits : int
301
+ Quantization bits
302
+ theta_range : tuple
303
+ Range for pattern computation
304
+ n_points : int
305
+ Number of angle points
306
+
307
+ Returns
308
+ -------
309
+ results : dict
310
+ 'theta_deg': angle array
311
+ 'pattern_ideal_dB': ideal pattern
312
+ 'pattern_quantized_dB': quantized pattern
313
+ 'difference_dB': pattern difference
314
+ """
315
+ from .core import array_factor_vectorized
316
+ from .utils import linear_to_db
317
+
318
+ # Quantize weights
319
+ weights_q = quantize_phase(weights, n_bits)
320
+
321
+ # Compute patterns
322
+ theta = np.linspace(theta_range[0], theta_range[1], n_points)
323
+ phi = np.zeros_like(theta)
324
+
325
+ theta_grid = theta.reshape(-1, 1)
326
+ phi_grid = phi.reshape(-1, 1)
327
+
328
+ AF_ideal = array_factor_vectorized(
329
+ theta_grid, phi_grid,
330
+ geometry.x, geometry.y, weights, k
331
+ ).ravel()
332
+
333
+ AF_quant = array_factor_vectorized(
334
+ theta_grid, phi_grid,
335
+ geometry.x, geometry.y, weights_q, k
336
+ ).ravel()
337
+
338
+ # Convert to dB
339
+ pattern_ideal = linear_to_db(np.abs(AF_ideal)**2)
340
+ pattern_quant = linear_to_db(np.abs(AF_quant)**2)
341
+
342
+ # Normalize
343
+ pattern_ideal -= np.max(pattern_ideal)
344
+ pattern_quant -= np.max(pattern_quant)
345
+
346
+ return {
347
+ 'theta_deg': np.rad2deg(theta),
348
+ 'pattern_ideal_dB': pattern_ideal,
349
+ 'pattern_quantized_dB': pattern_quant,
350
+ 'difference_dB': pattern_quant - pattern_ideal,
351
+ 'rms_error_deg': quantization_rms_error(n_bits)
352
+ }
353
+
354
+
355
+ # ============== Element Failures ==============
356
+
357
+ def simulate_element_failures(
358
+ weights: np.ndarray,
359
+ failure_rate: float,
360
+ mode: str = 'off',
361
+ seed: Optional[int] = None
362
+ ) -> Tuple[np.ndarray, np.ndarray]:
363
+ """
364
+ Simulate random element failures.
365
+
366
+ Parameters
367
+ ----------
368
+ weights : ndarray
369
+ Nominal element weights
370
+ failure_rate : float
371
+ Probability of failure per element (0 to 1)
372
+ mode : str
373
+ 'off' - failed elements have zero output
374
+ 'stuck' - failed elements stuck at nominal magnitude, random phase
375
+ 'full' - failed elements at full power, random phase
376
+ seed : int, optional
377
+ Random seed
378
+
379
+ Returns
380
+ -------
381
+ degraded_weights : ndarray
382
+ Weights with failures applied
383
+ failure_mask : ndarray
384
+ Boolean array, True for failed elements
385
+ """
386
+ if seed is not None:
387
+ np.random.seed(seed)
388
+
389
+ n = len(weights)
390
+ failure_mask = np.random.random(n) < failure_rate
391
+ degraded_weights = weights.copy()
392
+
393
+ if mode == 'off':
394
+ degraded_weights[failure_mask] = 0
395
+ elif mode == 'stuck':
396
+ # Random phase, same magnitude
397
+ random_phase = np.random.uniform(0, 2*np.pi, np.sum(failure_mask))
398
+ degraded_weights[failure_mask] = (
399
+ np.abs(degraded_weights[failure_mask]) * np.exp(1j * random_phase)
400
+ )
401
+ elif mode == 'full':
402
+ # Full power, random phase
403
+ random_phase = np.random.uniform(0, 2*np.pi, np.sum(failure_mask))
404
+ degraded_weights[failure_mask] = np.exp(1j * random_phase)
405
+ else:
406
+ raise ValueError(f"Unknown failure mode: {mode}")
407
+
408
+ return degraded_weights, failure_mask
409
+
410
+
411
+ def analyze_graceful_degradation(
412
+ weights: np.ndarray,
413
+ geometry: ArrayGeometry,
414
+ k: float,
415
+ failure_rates: List[float],
416
+ n_trials: int = 100,
417
+ mode: str = 'off'
418
+ ) -> Dict[str, np.ndarray]:
419
+ """
420
+ Monte Carlo analysis of graceful degradation vs failure rate.
421
+
422
+ Parameters
423
+ ----------
424
+ weights : ndarray
425
+ Nominal weights
426
+ geometry : ArrayGeometry
427
+ Array geometry
428
+ k : float
429
+ Wavenumber
430
+ failure_rates : list
431
+ Failure rates to test
432
+ n_trials : int
433
+ Number of Monte Carlo trials per rate
434
+ mode : str
435
+ Failure mode
436
+
437
+ Returns
438
+ -------
439
+ results : dict
440
+ 'failure_rates': input rates
441
+ 'gain_loss_mean_dB': mean gain loss
442
+ 'gain_loss_std_dB': std of gain loss
443
+ 'sidelobe_increase_mean_dB': mean sidelobe increase
444
+ """
445
+ from .core import array_factor_vectorized, compute_half_power_beamwidth
446
+ from .utils import linear_to_db
447
+
448
+ # Reference pattern (no failures)
449
+ theta = np.linspace(0, np.pi/2, 181)
450
+ phi = np.zeros_like(theta)
451
+
452
+ AF_ref = array_factor_vectorized(
453
+ theta.reshape(-1, 1), phi.reshape(-1, 1),
454
+ geometry.x, geometry.y, weights, k
455
+ ).ravel()
456
+ pattern_ref_dB = linear_to_db(np.abs(AF_ref)**2)
457
+ pattern_ref_dB -= np.max(pattern_ref_dB)
458
+
459
+ peak_ref = 0 # Normalized
460
+ # Find first sidelobe
461
+ main_beam_end = np.argmax(pattern_ref_dB < -3)
462
+ sidelobe_ref = np.max(pattern_ref_dB[main_beam_end:]) if main_beam_end > 0 else -20
463
+
464
+ gain_loss_mean = []
465
+ gain_loss_std = []
466
+ sidelobe_increase_mean = []
467
+
468
+ for rate in failure_rates:
469
+ gain_losses = []
470
+ sidelobe_increases = []
471
+
472
+ for trial in range(n_trials):
473
+ degraded, _ = simulate_element_failures(
474
+ weights, rate, mode, seed=None
475
+ )
476
+
477
+ AF = array_factor_vectorized(
478
+ theta.reshape(-1, 1), phi.reshape(-1, 1),
479
+ geometry.x, geometry.y, degraded, k
480
+ ).ravel()
481
+
482
+ pattern_dB = linear_to_db(np.abs(AF)**2)
483
+ peak_degraded = np.max(pattern_dB)
484
+ pattern_dB -= peak_degraded
485
+
486
+ # Gain loss
487
+ gain_loss = peak_ref - (peak_degraded - np.max(linear_to_db(np.abs(AF_ref)**2)))
488
+ gain_losses.append(gain_loss)
489
+
490
+ # Sidelobe increase
491
+ sidelobe_degraded = np.max(pattern_dB[main_beam_end:]) if main_beam_end > 0 else -20
492
+ sidelobe_increases.append(sidelobe_degraded - sidelobe_ref)
493
+
494
+ gain_loss_mean.append(np.mean(gain_losses))
495
+ gain_loss_std.append(np.std(gain_losses))
496
+ sidelobe_increase_mean.append(np.mean(sidelobe_increases))
497
+
498
+ return {
499
+ 'failure_rates': np.array(failure_rates),
500
+ 'gain_loss_mean_dB': np.array(gain_loss_mean),
501
+ 'gain_loss_std_dB': np.array(gain_loss_std),
502
+ 'sidelobe_increase_mean_dB': np.array(sidelobe_increase_mean)
503
+ }
504
+
505
+
506
+ # ============== Scan Blindness ==============
507
+
508
+ def surface_wave_scan_angle(
509
+ dx: float,
510
+ dy: float,
511
+ substrate_er: float = 4.0,
512
+ substrate_h: float = 0.1
513
+ ) -> Tuple[float, float]:
514
+ """
515
+ Estimate scan blindness angles due to surface wave excitation.
516
+
517
+ Parameters
518
+ ----------
519
+ dx : float
520
+ Element spacing in x (wavelengths)
521
+ dy : float
522
+ Element spacing in y (wavelengths)
523
+ substrate_er : float
524
+ Substrate relative permittivity
525
+ substrate_h : float
526
+ Substrate height (wavelengths)
527
+
528
+ Returns
529
+ -------
530
+ theta_blind_E : float
531
+ Blind angle in E-plane (degrees)
532
+ theta_blind_H : float
533
+ Blind angle in H-plane (degrees)
534
+ """
535
+ # Surface wave propagation constant (approximate)
536
+ # For thin substrates: beta_sw ~ k0 * sqrt(er) * (1 + some correction)
537
+ # Simplified model
538
+ n_eff = np.sqrt(substrate_er) * (1 + 0.5 * substrate_h * np.sqrt(substrate_er - 1))
539
+ n_eff = min(n_eff, np.sqrt(substrate_er))
540
+
541
+ # Blind angle occurs when grating lobe enters surface wave
542
+ # sin(theta_blind) = n_eff - 1/d
543
+ sin_theta_E = n_eff - 1 / dx
544
+ sin_theta_H = n_eff - 1 / dy
545
+
546
+ # Clamp to valid range
547
+ sin_theta_E = np.clip(sin_theta_E, -1, 1)
548
+ sin_theta_H = np.clip(sin_theta_H, -1, 1)
549
+
550
+ theta_blind_E = np.rad2deg(np.arcsin(sin_theta_E)) if abs(sin_theta_E) <= 1 else 90
551
+ theta_blind_H = np.rad2deg(np.arcsin(sin_theta_H)) if abs(sin_theta_H) <= 1 else 90
552
+
553
+ return abs(theta_blind_E), abs(theta_blind_H)
554
+
555
+
556
+ def scan_blindness_model(
557
+ theta: np.ndarray,
558
+ phi: np.ndarray,
559
+ theta_blind: float,
560
+ phi_blind: Optional[float] = None,
561
+ null_width_deg: float = 5.0,
562
+ null_depth_dB: float = -30.0
563
+ ) -> np.ndarray:
564
+ """
565
+ Model scan blindness as a Gaussian null at the blind angle.
566
+
567
+ Parameters
568
+ ----------
569
+ theta : ndarray
570
+ Observation theta angles (radians)
571
+ phi : ndarray
572
+ Observation phi angles (radians)
573
+ theta_blind : float
574
+ Blind angle theta (degrees)
575
+ phi_blind : float, optional
576
+ Blind angle phi (degrees). If None, blindness is phi-independent
577
+ null_width_deg : float
578
+ Width of the null (degrees, 1-sigma)
579
+ null_depth_dB : float
580
+ Depth of null in dB (negative)
581
+
582
+ Returns
583
+ -------
584
+ factor : ndarray
585
+ Multiplicative factor (0 to 1)
586
+ """
587
+ theta_deg = np.rad2deg(theta)
588
+
589
+ if phi_blind is None:
590
+ # Phi-independent blindness (conical null)
591
+ angular_distance = np.abs(theta_deg - theta_blind)
592
+ else:
593
+ # Point null at specific direction
594
+ phi_deg = np.rad2deg(phi)
595
+ phi_blind_rad = np.deg2rad(phi_blind)
596
+ theta_blind_rad = np.deg2rad(theta_blind)
597
+
598
+ # Angular distance on sphere
599
+ cos_dist = (np.cos(theta) * np.cos(theta_blind_rad) +
600
+ np.sin(theta) * np.sin(theta_blind_rad) *
601
+ np.cos(phi - phi_blind_rad))
602
+ angular_distance = np.rad2deg(np.arccos(np.clip(cos_dist, -1, 1)))
603
+
604
+ # Gaussian null
605
+ null_depth_linear = 10 ** (null_depth_dB / 10)
606
+ factor = 1 - (1 - null_depth_linear) * np.exp(
607
+ -0.5 * (angular_distance / null_width_deg) ** 2
608
+ )
609
+
610
+ return factor
611
+
612
+
613
+ def apply_scan_blindness(
614
+ pattern: np.ndarray,
615
+ theta: np.ndarray,
616
+ phi: np.ndarray,
617
+ theta_blind_list: List[float],
618
+ phi_blind_list: Optional[List[float]] = None,
619
+ null_width_deg: float = 5.0,
620
+ null_depth_dB: float = -30.0
621
+ ) -> np.ndarray:
622
+ """
623
+ Apply scan blindness model to a computed pattern.
624
+
625
+ Parameters
626
+ ----------
627
+ pattern : ndarray
628
+ Complex or magnitude pattern
629
+ theta : ndarray
630
+ Theta angles (radians)
631
+ phi : ndarray
632
+ Phi angles (radians)
633
+ theta_blind_list : list
634
+ List of blind angles (degrees)
635
+ phi_blind_list : list, optional
636
+ List of blind phi angles
637
+ null_width_deg : float
638
+ Null width
639
+ null_depth_dB : float
640
+ Null depth
641
+
642
+ Returns
643
+ -------
644
+ modified_pattern : ndarray
645
+ Pattern with scan blindness applied
646
+ """
647
+ modified = pattern.copy()
648
+
649
+ if phi_blind_list is None:
650
+ phi_blind_list = [None] * len(theta_blind_list)
651
+
652
+ for theta_blind, phi_blind in zip(theta_blind_list, phi_blind_list):
653
+ factor = scan_blindness_model(
654
+ theta, phi, theta_blind, phi_blind,
655
+ null_width_deg, null_depth_dB
656
+ )
657
+ modified = modified * np.sqrt(factor) # sqrt for voltage pattern
658
+
659
+ return modified
660
+
661
+
662
+ def compute_scan_loss(
663
+ geometry: ArrayGeometry,
664
+ weights: np.ndarray,
665
+ k: float,
666
+ theta_scan_deg: float,
667
+ phi_scan_deg: float,
668
+ element_pattern_func: Optional[callable] = None
669
+ ) -> float:
670
+ """
671
+ Compute scan loss (reduction in peak gain at scan angle).
672
+
673
+ Parameters
674
+ ----------
675
+ geometry : ArrayGeometry
676
+ Array geometry
677
+ weights : ndarray
678
+ Element weights
679
+ k : float
680
+ Wavenumber
681
+ theta_scan_deg : float
682
+ Scan angle theta
683
+ phi_scan_deg : float
684
+ Scan angle phi
685
+ element_pattern_func : callable, optional
686
+ Element pattern function
687
+
688
+ Returns
689
+ -------
690
+ scan_loss_dB : float
691
+ Reduction in gain relative to broadside (negative or zero)
692
+ """
693
+ from .core import total_pattern
694
+
695
+ # Compute gain at broadside
696
+ theta_bs = np.array([[0.0]])
697
+ phi_bs = np.array([[0.0]])
698
+ weights_bs = np.ones_like(weights)
699
+
700
+ pattern_bs = total_pattern(
701
+ theta_bs, phi_bs, geometry.x, geometry.y,
702
+ weights_bs, k, element_pattern_func
703
+ )
704
+ gain_bs = np.abs(pattern_bs.item()) ** 2
705
+
706
+ # Compute gain at scan angle
707
+ theta_scan = np.array([[np.deg2rad(theta_scan_deg)]])
708
+ phi_scan = np.array([[np.deg2rad(phi_scan_deg)]])
709
+
710
+ pattern_scan = total_pattern(
711
+ theta_scan, phi_scan, geometry.x, geometry.y,
712
+ weights, k, element_pattern_func
713
+ )
714
+ gain_scan = np.abs(pattern_scan.item()) ** 2
715
+
716
+ if gain_bs > 0 and gain_scan > 0:
717
+ return 10 * np.log10(gain_scan / gain_bs)
718
+ else:
719
+ return -100.0