phased-array-modeling 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phased_array/__init__.py +263 -0
- phased_array/beamforming.py +705 -0
- phased_array/core.py +598 -0
- phased_array/geometry.py +943 -0
- phased_array/impairments.py +719 -0
- phased_array/utils.py +346 -0
- phased_array/visualization.py +824 -0
- phased_array_modeling-1.0.0.dist-info/METADATA +259 -0
- phased_array_modeling-1.0.0.dist-info/RECORD +12 -0
- phased_array_modeling-1.0.0.dist-info/WHEEL +5 -0
- phased_array_modeling-1.0.0.dist-info/licenses/LICENSE +21 -0
- phased_array_modeling-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|