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,824 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visualization functions for phased array patterns.
|
|
3
|
+
|
|
4
|
+
Includes 3D Plotly plots, UV-space representation, and array geometry plots.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from typing import Tuple, Optional, Dict, Any, List
|
|
9
|
+
import warnings
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import plotly.graph_objects as go
|
|
13
|
+
from plotly.subplots import make_subplots
|
|
14
|
+
PLOTLY_AVAILABLE = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
PLOTLY_AVAILABLE = False
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import matplotlib.pyplot as plt
|
|
20
|
+
from matplotlib import cm
|
|
21
|
+
from mpl_toolkits.mplot3d import Axes3D
|
|
22
|
+
MATPLOTLIB_AVAILABLE = True
|
|
23
|
+
except ImportError:
|
|
24
|
+
MATPLOTLIB_AVAILABLE = False
|
|
25
|
+
|
|
26
|
+
from .geometry import ArrayGeometry
|
|
27
|
+
from .utils import theta_phi_to_uv, uv_to_theta_phi, is_visible_region, linear_to_db
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ============== Matplotlib Plots (2D) ==============
|
|
31
|
+
|
|
32
|
+
def plot_pattern_2d(
|
|
33
|
+
angles_deg: np.ndarray,
|
|
34
|
+
pattern_dB: np.ndarray,
|
|
35
|
+
title: str = "Radiation Pattern",
|
|
36
|
+
xlabel: str = "Angle (degrees)",
|
|
37
|
+
ylabel: str = "Normalized Gain (dB)",
|
|
38
|
+
min_dB: float = -50.0,
|
|
39
|
+
figsize: Tuple[int, int] = (10, 6),
|
|
40
|
+
ax: Optional[Any] = None,
|
|
41
|
+
label: Optional[str] = None,
|
|
42
|
+
**plot_kwargs
|
|
43
|
+
) -> Any:
|
|
44
|
+
"""
|
|
45
|
+
Plot a 1D pattern cut.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
angles_deg : ndarray
|
|
50
|
+
Angle values in degrees
|
|
51
|
+
pattern_dB : ndarray
|
|
52
|
+
Pattern values in dB
|
|
53
|
+
title : str
|
|
54
|
+
Plot title
|
|
55
|
+
xlabel, ylabel : str
|
|
56
|
+
Axis labels
|
|
57
|
+
min_dB : float
|
|
58
|
+
Minimum dB level to display
|
|
59
|
+
figsize : tuple
|
|
60
|
+
Figure size (width, height)
|
|
61
|
+
ax : matplotlib axis, optional
|
|
62
|
+
Existing axis to plot on
|
|
63
|
+
label : str, optional
|
|
64
|
+
Legend label
|
|
65
|
+
**plot_kwargs
|
|
66
|
+
Additional arguments for plt.plot()
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
ax : matplotlib axis
|
|
71
|
+
"""
|
|
72
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
73
|
+
raise ImportError("matplotlib is required for this function")
|
|
74
|
+
|
|
75
|
+
if ax is None:
|
|
76
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
77
|
+
|
|
78
|
+
pattern_clipped = np.clip(pattern_dB, min_dB, 0)
|
|
79
|
+
ax.plot(angles_deg, pattern_clipped, label=label, **plot_kwargs)
|
|
80
|
+
|
|
81
|
+
ax.set_xlabel(xlabel)
|
|
82
|
+
ax.set_ylabel(ylabel)
|
|
83
|
+
ax.set_title(title)
|
|
84
|
+
ax.set_ylim([min_dB, 5])
|
|
85
|
+
ax.grid(True, alpha=0.3)
|
|
86
|
+
|
|
87
|
+
if label is not None:
|
|
88
|
+
ax.legend()
|
|
89
|
+
|
|
90
|
+
return ax
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def plot_pattern_polar(
|
|
94
|
+
angles_deg: np.ndarray,
|
|
95
|
+
pattern_dB: np.ndarray,
|
|
96
|
+
title: str = "Radiation Pattern",
|
|
97
|
+
min_dB: float = -40.0,
|
|
98
|
+
figsize: Tuple[int, int] = (8, 8),
|
|
99
|
+
ax: Optional[Any] = None,
|
|
100
|
+
**plot_kwargs
|
|
101
|
+
) -> Any:
|
|
102
|
+
"""
|
|
103
|
+
Plot a 1D pattern cut in polar coordinates.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
angles_deg : ndarray
|
|
108
|
+
Angle values in degrees
|
|
109
|
+
pattern_dB : ndarray
|
|
110
|
+
Pattern values in dB (normalized)
|
|
111
|
+
title : str
|
|
112
|
+
Plot title
|
|
113
|
+
min_dB : float
|
|
114
|
+
Minimum dB level (becomes r=0)
|
|
115
|
+
figsize : tuple
|
|
116
|
+
Figure size
|
|
117
|
+
ax : matplotlib polar axis, optional
|
|
118
|
+
Existing axis
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
ax : matplotlib axis
|
|
123
|
+
"""
|
|
124
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
125
|
+
raise ImportError("matplotlib is required for this function")
|
|
126
|
+
|
|
127
|
+
if ax is None:
|
|
128
|
+
fig, ax = plt.subplots(figsize=figsize, subplot_kw={'projection': 'polar'})
|
|
129
|
+
|
|
130
|
+
# Convert dB to radius (shift so min_dB = 0)
|
|
131
|
+
r = pattern_dB - min_dB
|
|
132
|
+
r = np.clip(r, 0, None)
|
|
133
|
+
|
|
134
|
+
ax.plot(np.deg2rad(angles_deg), r, **plot_kwargs)
|
|
135
|
+
ax.set_title(title)
|
|
136
|
+
ax.set_theta_zero_location('N') # 0 degrees at top
|
|
137
|
+
ax.set_theta_direction(-1) # Clockwise
|
|
138
|
+
|
|
139
|
+
return ax
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def plot_pattern_contour(
|
|
143
|
+
theta_deg: np.ndarray,
|
|
144
|
+
phi_deg: np.ndarray,
|
|
145
|
+
pattern_dB: np.ndarray,
|
|
146
|
+
title: str = "Radiation Pattern",
|
|
147
|
+
min_dB: float = -40.0,
|
|
148
|
+
levels: int = 20,
|
|
149
|
+
figsize: Tuple[int, int] = (10, 8),
|
|
150
|
+
cmap: str = 'jet',
|
|
151
|
+
ax: Optional[Any] = None
|
|
152
|
+
) -> Any:
|
|
153
|
+
"""
|
|
154
|
+
Plot 2D pattern as contour plot.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
theta_deg : ndarray
|
|
159
|
+
Theta values (1D or 2D grid)
|
|
160
|
+
phi_deg : ndarray
|
|
161
|
+
Phi values (1D or 2D grid)
|
|
162
|
+
pattern_dB : ndarray
|
|
163
|
+
Pattern in dB (2D)
|
|
164
|
+
title : str
|
|
165
|
+
Plot title
|
|
166
|
+
min_dB : float
|
|
167
|
+
Minimum dB level
|
|
168
|
+
levels : int
|
|
169
|
+
Number of contour levels
|
|
170
|
+
figsize : tuple
|
|
171
|
+
Figure size
|
|
172
|
+
cmap : str
|
|
173
|
+
Colormap name
|
|
174
|
+
ax : matplotlib axis, optional
|
|
175
|
+
Existing axis
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
ax : matplotlib axis
|
|
180
|
+
"""
|
|
181
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
182
|
+
raise ImportError("matplotlib is required for this function")
|
|
183
|
+
|
|
184
|
+
if ax is None:
|
|
185
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
186
|
+
|
|
187
|
+
# Create meshgrid if 1D inputs
|
|
188
|
+
if theta_deg.ndim == 1 and phi_deg.ndim == 1:
|
|
189
|
+
theta_grid, phi_grid = np.meshgrid(theta_deg, phi_deg, indexing='ij')
|
|
190
|
+
else:
|
|
191
|
+
theta_grid, phi_grid = theta_deg, phi_deg
|
|
192
|
+
|
|
193
|
+
pattern_clipped = np.clip(pattern_dB, min_dB, 0)
|
|
194
|
+
|
|
195
|
+
cf = ax.contourf(phi_grid, theta_grid, pattern_clipped, levels=levels, cmap=cmap)
|
|
196
|
+
plt.colorbar(cf, ax=ax, label='Gain (dB)')
|
|
197
|
+
|
|
198
|
+
ax.set_xlabel('Phi (degrees)')
|
|
199
|
+
ax.set_ylabel('Theta (degrees)')
|
|
200
|
+
ax.set_title(title)
|
|
201
|
+
|
|
202
|
+
return ax
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def plot_array_geometry(
|
|
206
|
+
geometry: ArrayGeometry,
|
|
207
|
+
weights: Optional[np.ndarray] = None,
|
|
208
|
+
title: str = "Array Geometry",
|
|
209
|
+
show_indices: bool = False,
|
|
210
|
+
figsize: Tuple[int, int] = (8, 8),
|
|
211
|
+
ax: Optional[Any] = None
|
|
212
|
+
) -> Any:
|
|
213
|
+
"""
|
|
214
|
+
Plot array element positions (2D view).
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
geometry : ArrayGeometry
|
|
219
|
+
Array geometry
|
|
220
|
+
weights : ndarray, optional
|
|
221
|
+
Element weights (color by magnitude)
|
|
222
|
+
title : str
|
|
223
|
+
Plot title
|
|
224
|
+
show_indices : bool
|
|
225
|
+
Show element index numbers
|
|
226
|
+
figsize : tuple
|
|
227
|
+
Figure size
|
|
228
|
+
ax : matplotlib axis, optional
|
|
229
|
+
Existing axis
|
|
230
|
+
|
|
231
|
+
Returns
|
|
232
|
+
-------
|
|
233
|
+
ax : matplotlib axis
|
|
234
|
+
"""
|
|
235
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
236
|
+
raise ImportError("matplotlib is required for this function")
|
|
237
|
+
|
|
238
|
+
if ax is None:
|
|
239
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
240
|
+
|
|
241
|
+
if weights is not None:
|
|
242
|
+
colors = np.abs(weights)
|
|
243
|
+
scatter = ax.scatter(geometry.x, geometry.y, c=colors, cmap='viridis', s=50)
|
|
244
|
+
plt.colorbar(scatter, ax=ax, label='|Weight|')
|
|
245
|
+
else:
|
|
246
|
+
ax.scatter(geometry.x, geometry.y, s=50)
|
|
247
|
+
|
|
248
|
+
if show_indices:
|
|
249
|
+
for i, (x, y) in enumerate(zip(geometry.x, geometry.y)):
|
|
250
|
+
ax.annotate(str(i), (x, y), fontsize=8)
|
|
251
|
+
|
|
252
|
+
ax.set_xlabel('X (m)')
|
|
253
|
+
ax.set_ylabel('Y (m)')
|
|
254
|
+
ax.set_title(title)
|
|
255
|
+
ax.set_aspect('equal')
|
|
256
|
+
ax.grid(True, alpha=0.3)
|
|
257
|
+
|
|
258
|
+
return ax
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ============== UV-Space Visualization ==============
|
|
262
|
+
|
|
263
|
+
def compute_pattern_uv_space(
|
|
264
|
+
geometry: ArrayGeometry,
|
|
265
|
+
weights: np.ndarray,
|
|
266
|
+
k: float,
|
|
267
|
+
n_u: int = 201,
|
|
268
|
+
n_v: int = 201,
|
|
269
|
+
u_range: Tuple[float, float] = (-1, 1),
|
|
270
|
+
v_range: Tuple[float, float] = (-1, 1)
|
|
271
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
272
|
+
"""
|
|
273
|
+
Compute pattern directly in UV-space.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
geometry : ArrayGeometry
|
|
278
|
+
Array geometry
|
|
279
|
+
weights : ndarray
|
|
280
|
+
Element weights
|
|
281
|
+
k : float
|
|
282
|
+
Wavenumber
|
|
283
|
+
n_u, n_v : int
|
|
284
|
+
Number of points in u and v
|
|
285
|
+
u_range, v_range : tuple
|
|
286
|
+
Range for u and v
|
|
287
|
+
|
|
288
|
+
Returns
|
|
289
|
+
-------
|
|
290
|
+
u : ndarray
|
|
291
|
+
U values (1D)
|
|
292
|
+
v : ndarray
|
|
293
|
+
V values (1D)
|
|
294
|
+
pattern_dB : ndarray
|
|
295
|
+
Pattern in dB (2D: n_u x n_v)
|
|
296
|
+
"""
|
|
297
|
+
from .core import array_factor_uv
|
|
298
|
+
|
|
299
|
+
u = np.linspace(u_range[0], u_range[1], n_u)
|
|
300
|
+
v = np.linspace(v_range[0], v_range[1], n_v)
|
|
301
|
+
u_grid, v_grid = np.meshgrid(u, v, indexing='ij')
|
|
302
|
+
|
|
303
|
+
AF = array_factor_uv(u_grid, v_grid, geometry.x, geometry.y, weights, k)
|
|
304
|
+
|
|
305
|
+
pattern_dB = linear_to_db(np.abs(AF)**2)
|
|
306
|
+
pattern_dB -= np.max(pattern_dB)
|
|
307
|
+
|
|
308
|
+
return u, v, pattern_dB
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def plot_pattern_uv_space(
|
|
312
|
+
u: np.ndarray,
|
|
313
|
+
v: np.ndarray,
|
|
314
|
+
pattern_dB: np.ndarray,
|
|
315
|
+
title: str = "UV-Space Pattern",
|
|
316
|
+
min_dB: float = -40.0,
|
|
317
|
+
show_visible_region: bool = True,
|
|
318
|
+
show_grating_circles: bool = False,
|
|
319
|
+
dx_wavelengths: Optional[float] = None,
|
|
320
|
+
dy_wavelengths: Optional[float] = None,
|
|
321
|
+
figsize: Tuple[int, int] = (10, 8),
|
|
322
|
+
cmap: str = 'jet',
|
|
323
|
+
ax: Optional[Any] = None
|
|
324
|
+
) -> Any:
|
|
325
|
+
"""
|
|
326
|
+
Plot pattern in UV-space with optional visible region and grating lobe circles.
|
|
327
|
+
|
|
328
|
+
Parameters
|
|
329
|
+
----------
|
|
330
|
+
u, v : ndarray
|
|
331
|
+
Direction cosine values (1D)
|
|
332
|
+
pattern_dB : ndarray
|
|
333
|
+
Pattern in dB (2D)
|
|
334
|
+
title : str
|
|
335
|
+
Plot title
|
|
336
|
+
min_dB : float
|
|
337
|
+
Minimum dB level
|
|
338
|
+
show_visible_region : bool
|
|
339
|
+
Show unit circle (visible space boundary)
|
|
340
|
+
show_grating_circles : bool
|
|
341
|
+
Show grating lobe circles
|
|
342
|
+
dx_wavelengths, dy_wavelengths : float, optional
|
|
343
|
+
Element spacing for grating lobe calculation
|
|
344
|
+
figsize : tuple
|
|
345
|
+
Figure size
|
|
346
|
+
cmap : str
|
|
347
|
+
Colormap
|
|
348
|
+
ax : matplotlib axis, optional
|
|
349
|
+
Existing axis
|
|
350
|
+
|
|
351
|
+
Returns
|
|
352
|
+
-------
|
|
353
|
+
ax : matplotlib axis
|
|
354
|
+
"""
|
|
355
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
356
|
+
raise ImportError("matplotlib is required for this function")
|
|
357
|
+
|
|
358
|
+
if ax is None:
|
|
359
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
360
|
+
|
|
361
|
+
u_grid, v_grid = np.meshgrid(u, v, indexing='ij')
|
|
362
|
+
pattern_clipped = np.clip(pattern_dB, min_dB, 0)
|
|
363
|
+
|
|
364
|
+
cf = ax.contourf(u_grid, v_grid, pattern_clipped, levels=20, cmap=cmap)
|
|
365
|
+
plt.colorbar(cf, ax=ax, label='Gain (dB)')
|
|
366
|
+
|
|
367
|
+
if show_visible_region:
|
|
368
|
+
theta_circle = np.linspace(0, 2*np.pi, 100)
|
|
369
|
+
ax.plot(np.cos(theta_circle), np.sin(theta_circle), 'w--', linewidth=2,
|
|
370
|
+
label='Visible region')
|
|
371
|
+
|
|
372
|
+
if show_grating_circles and dx_wavelengths is not None:
|
|
373
|
+
# Grating lobe positions: u = u0 + m/dx, v = v0 + n/dy
|
|
374
|
+
for m in [-1, 1]:
|
|
375
|
+
u_grating = m / dx_wavelengths
|
|
376
|
+
ax.plot(np.cos(theta_circle) + u_grating, np.sin(theta_circle),
|
|
377
|
+
'r--', linewidth=1, alpha=0.7)
|
|
378
|
+
if dy_wavelengths is not None:
|
|
379
|
+
for n in [-1, 1]:
|
|
380
|
+
v_grating = n / dy_wavelengths
|
|
381
|
+
ax.plot(np.cos(theta_circle), np.sin(theta_circle) + v_grating,
|
|
382
|
+
'r--', linewidth=1, alpha=0.7)
|
|
383
|
+
|
|
384
|
+
ax.set_xlabel('u = sin(θ)cos(φ)')
|
|
385
|
+
ax.set_ylabel('v = sin(θ)sin(φ)')
|
|
386
|
+
ax.set_title(title)
|
|
387
|
+
ax.set_aspect('equal')
|
|
388
|
+
ax.set_xlim([u.min(), u.max()])
|
|
389
|
+
ax.set_ylim([v.min(), v.max()])
|
|
390
|
+
|
|
391
|
+
return ax
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ============== 3D Plotly Plots ==============
|
|
395
|
+
|
|
396
|
+
def plot_pattern_3d_plotly(
|
|
397
|
+
theta: np.ndarray,
|
|
398
|
+
phi: np.ndarray,
|
|
399
|
+
pattern_dB: np.ndarray,
|
|
400
|
+
title: str = "3D Radiation Pattern",
|
|
401
|
+
min_dB: float = -40.0,
|
|
402
|
+
colorscale: str = 'Jet',
|
|
403
|
+
surface_type: str = 'spherical'
|
|
404
|
+
) -> Any:
|
|
405
|
+
"""
|
|
406
|
+
Create interactive 3D pattern plot using Plotly.
|
|
407
|
+
|
|
408
|
+
Parameters
|
|
409
|
+
----------
|
|
410
|
+
theta : ndarray
|
|
411
|
+
Theta values in radians (1D)
|
|
412
|
+
phi : ndarray
|
|
413
|
+
Phi values in radians (1D)
|
|
414
|
+
pattern_dB : ndarray
|
|
415
|
+
Pattern in dB (2D: n_theta x n_phi)
|
|
416
|
+
title : str
|
|
417
|
+
Plot title
|
|
418
|
+
min_dB : float
|
|
419
|
+
Minimum dB level
|
|
420
|
+
colorscale : str
|
|
421
|
+
Plotly colorscale name
|
|
422
|
+
surface_type : str
|
|
423
|
+
'spherical' - radius proportional to gain
|
|
424
|
+
'cartesian' - theta/phi/gain surface
|
|
425
|
+
|
|
426
|
+
Returns
|
|
427
|
+
-------
|
|
428
|
+
fig : plotly.graph_objects.Figure
|
|
429
|
+
"""
|
|
430
|
+
if not PLOTLY_AVAILABLE:
|
|
431
|
+
raise ImportError("plotly is required for this function. Install with: pip install plotly")
|
|
432
|
+
|
|
433
|
+
# Create meshgrid
|
|
434
|
+
theta_grid, phi_grid = np.meshgrid(theta, phi, indexing='ij')
|
|
435
|
+
pattern_clipped = np.clip(pattern_dB, min_dB, 0)
|
|
436
|
+
|
|
437
|
+
if surface_type == 'spherical':
|
|
438
|
+
# Map gain to radius (linear scale for better visualization)
|
|
439
|
+
r = (pattern_clipped - min_dB) / (-min_dB) # 0 to 1
|
|
440
|
+
r = np.clip(r, 0.1, 1) # Minimum radius for visibility
|
|
441
|
+
|
|
442
|
+
# Spherical to Cartesian
|
|
443
|
+
x = r * np.sin(theta_grid) * np.cos(phi_grid)
|
|
444
|
+
y = r * np.sin(theta_grid) * np.sin(phi_grid)
|
|
445
|
+
z = r * np.cos(theta_grid)
|
|
446
|
+
|
|
447
|
+
fig = go.Figure(data=[go.Surface(
|
|
448
|
+
x=x, y=y, z=z,
|
|
449
|
+
surfacecolor=pattern_clipped,
|
|
450
|
+
colorscale=colorscale,
|
|
451
|
+
colorbar=dict(title='Gain (dB)'),
|
|
452
|
+
showscale=True
|
|
453
|
+
)])
|
|
454
|
+
|
|
455
|
+
fig.update_layout(
|
|
456
|
+
title=title,
|
|
457
|
+
scene=dict(
|
|
458
|
+
xaxis_title='X',
|
|
459
|
+
yaxis_title='Y',
|
|
460
|
+
zaxis_title='Z',
|
|
461
|
+
aspectmode='data'
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
else: # cartesian
|
|
466
|
+
fig = go.Figure(data=[go.Surface(
|
|
467
|
+
x=np.rad2deg(theta_grid),
|
|
468
|
+
y=np.rad2deg(phi_grid),
|
|
469
|
+
z=pattern_clipped,
|
|
470
|
+
colorscale=colorscale,
|
|
471
|
+
colorbar=dict(title='Gain (dB)')
|
|
472
|
+
)])
|
|
473
|
+
|
|
474
|
+
fig.update_layout(
|
|
475
|
+
title=title,
|
|
476
|
+
scene=dict(
|
|
477
|
+
xaxis_title='Theta (deg)',
|
|
478
|
+
yaxis_title='Phi (deg)',
|
|
479
|
+
zaxis_title='Gain (dB)',
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
return fig
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def plot_pattern_3d_cartesian_plotly(
|
|
487
|
+
theta_deg: np.ndarray,
|
|
488
|
+
phi_deg: np.ndarray,
|
|
489
|
+
pattern_dB: np.ndarray,
|
|
490
|
+
title: str = "3D Radiation Pattern",
|
|
491
|
+
min_dB: float = -40.0,
|
|
492
|
+
colorscale: str = 'Jet'
|
|
493
|
+
) -> Any:
|
|
494
|
+
"""
|
|
495
|
+
Create 3D surface plot with theta/phi/gain axes.
|
|
496
|
+
|
|
497
|
+
Parameters
|
|
498
|
+
----------
|
|
499
|
+
theta_deg : ndarray
|
|
500
|
+
Theta values in degrees (1D)
|
|
501
|
+
phi_deg : ndarray
|
|
502
|
+
Phi values in degrees (1D)
|
|
503
|
+
pattern_dB : ndarray
|
|
504
|
+
Pattern in dB (2D)
|
|
505
|
+
title : str
|
|
506
|
+
Plot title
|
|
507
|
+
min_dB : float
|
|
508
|
+
Minimum dB
|
|
509
|
+
colorscale : str
|
|
510
|
+
Colorscale name
|
|
511
|
+
|
|
512
|
+
Returns
|
|
513
|
+
-------
|
|
514
|
+
fig : plotly Figure
|
|
515
|
+
"""
|
|
516
|
+
if not PLOTLY_AVAILABLE:
|
|
517
|
+
raise ImportError("plotly is required for this function")
|
|
518
|
+
|
|
519
|
+
theta_grid, phi_grid = np.meshgrid(theta_deg, phi_deg, indexing='ij')
|
|
520
|
+
pattern_clipped = np.clip(pattern_dB, min_dB, 0)
|
|
521
|
+
|
|
522
|
+
fig = go.Figure(data=[go.Surface(
|
|
523
|
+
x=theta_grid,
|
|
524
|
+
y=phi_grid,
|
|
525
|
+
z=pattern_clipped,
|
|
526
|
+
colorscale=colorscale,
|
|
527
|
+
colorbar=dict(title='Gain (dB)')
|
|
528
|
+
)])
|
|
529
|
+
|
|
530
|
+
fig.update_layout(
|
|
531
|
+
title=title,
|
|
532
|
+
scene=dict(
|
|
533
|
+
xaxis_title='Theta (deg)',
|
|
534
|
+
yaxis_title='Phi (deg)',
|
|
535
|
+
zaxis_title='Gain (dB)',
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return fig
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def plot_array_geometry_3d_plotly(
|
|
543
|
+
geometry: ArrayGeometry,
|
|
544
|
+
weights: Optional[np.ndarray] = None,
|
|
545
|
+
title: str = "Array Geometry",
|
|
546
|
+
show_normals: bool = True,
|
|
547
|
+
normal_scale: float = 0.1
|
|
548
|
+
) -> Any:
|
|
549
|
+
"""
|
|
550
|
+
Create interactive 3D array geometry plot using Plotly.
|
|
551
|
+
|
|
552
|
+
Parameters
|
|
553
|
+
----------
|
|
554
|
+
geometry : ArrayGeometry
|
|
555
|
+
Array geometry with positions and optional normals
|
|
556
|
+
weights : ndarray, optional
|
|
557
|
+
Element weights for coloring
|
|
558
|
+
title : str
|
|
559
|
+
Plot title
|
|
560
|
+
show_normals : bool
|
|
561
|
+
Show element normal vectors
|
|
562
|
+
normal_scale : float
|
|
563
|
+
Scale factor for normal vectors
|
|
564
|
+
|
|
565
|
+
Returns
|
|
566
|
+
-------
|
|
567
|
+
fig : plotly Figure
|
|
568
|
+
"""
|
|
569
|
+
if not PLOTLY_AVAILABLE:
|
|
570
|
+
raise ImportError("plotly is required for this function")
|
|
571
|
+
|
|
572
|
+
z = geometry.z if geometry.z is not None else np.zeros_like(geometry.x)
|
|
573
|
+
|
|
574
|
+
# Element colors
|
|
575
|
+
if weights is not None:
|
|
576
|
+
colors = np.abs(weights)
|
|
577
|
+
color_label = '|Weight|'
|
|
578
|
+
else:
|
|
579
|
+
colors = np.arange(len(geometry.x))
|
|
580
|
+
color_label = 'Element Index'
|
|
581
|
+
|
|
582
|
+
# Element positions
|
|
583
|
+
scatter = go.Scatter3d(
|
|
584
|
+
x=geometry.x, y=geometry.y, z=z,
|
|
585
|
+
mode='markers',
|
|
586
|
+
marker=dict(
|
|
587
|
+
size=8,
|
|
588
|
+
color=colors,
|
|
589
|
+
colorscale='Viridis',
|
|
590
|
+
colorbar=dict(title=color_label),
|
|
591
|
+
showscale=True
|
|
592
|
+
),
|
|
593
|
+
name='Elements'
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
data = [scatter]
|
|
597
|
+
|
|
598
|
+
# Element normals
|
|
599
|
+
if show_normals and geometry.nx is not None and geometry.ny is not None:
|
|
600
|
+
nz = geometry.nz if geometry.nz is not None else np.zeros_like(geometry.nx)
|
|
601
|
+
|
|
602
|
+
for i in range(len(geometry.x)):
|
|
603
|
+
data.append(go.Scatter3d(
|
|
604
|
+
x=[geometry.x[i], geometry.x[i] + normal_scale * geometry.nx[i]],
|
|
605
|
+
y=[geometry.y[i], geometry.y[i] + normal_scale * geometry.ny[i]],
|
|
606
|
+
z=[z[i], z[i] + normal_scale * nz[i]],
|
|
607
|
+
mode='lines',
|
|
608
|
+
line=dict(color='red', width=2),
|
|
609
|
+
showlegend=False
|
|
610
|
+
))
|
|
611
|
+
|
|
612
|
+
fig = go.Figure(data=data)
|
|
613
|
+
fig.update_layout(
|
|
614
|
+
title=title,
|
|
615
|
+
scene=dict(
|
|
616
|
+
xaxis_title='X (m)',
|
|
617
|
+
yaxis_title='Y (m)',
|
|
618
|
+
zaxis_title='Z (m)',
|
|
619
|
+
aspectmode='data'
|
|
620
|
+
)
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
return fig
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def plot_pattern_uv_plotly(
|
|
627
|
+
u: np.ndarray,
|
|
628
|
+
v: np.ndarray,
|
|
629
|
+
pattern_dB: np.ndarray,
|
|
630
|
+
title: str = "UV-Space Pattern",
|
|
631
|
+
min_dB: float = -40.0,
|
|
632
|
+
colorscale: str = 'Jet',
|
|
633
|
+
show_visible_circle: bool = True
|
|
634
|
+
) -> Any:
|
|
635
|
+
"""
|
|
636
|
+
Interactive UV-space pattern plot using Plotly.
|
|
637
|
+
|
|
638
|
+
Parameters
|
|
639
|
+
----------
|
|
640
|
+
u, v : ndarray
|
|
641
|
+
Direction cosines (1D)
|
|
642
|
+
pattern_dB : ndarray
|
|
643
|
+
Pattern in dB (2D)
|
|
644
|
+
title : str
|
|
645
|
+
Plot title
|
|
646
|
+
min_dB : float
|
|
647
|
+
Minimum dB
|
|
648
|
+
colorscale : str
|
|
649
|
+
Colorscale
|
|
650
|
+
show_visible_circle : bool
|
|
651
|
+
Show visible region boundary
|
|
652
|
+
|
|
653
|
+
Returns
|
|
654
|
+
-------
|
|
655
|
+
fig : plotly Figure
|
|
656
|
+
"""
|
|
657
|
+
if not PLOTLY_AVAILABLE:
|
|
658
|
+
raise ImportError("plotly is required for this function")
|
|
659
|
+
|
|
660
|
+
u_grid, v_grid = np.meshgrid(u, v, indexing='ij')
|
|
661
|
+
pattern_clipped = np.clip(pattern_dB, min_dB, 0)
|
|
662
|
+
|
|
663
|
+
fig = go.Figure()
|
|
664
|
+
|
|
665
|
+
# Heatmap of pattern
|
|
666
|
+
fig.add_trace(go.Heatmap(
|
|
667
|
+
x=u, y=v, z=pattern_clipped.T,
|
|
668
|
+
colorscale=colorscale,
|
|
669
|
+
colorbar=dict(title='Gain (dB)'),
|
|
670
|
+
zmin=min_dB, zmax=0
|
|
671
|
+
))
|
|
672
|
+
|
|
673
|
+
# Visible region circle
|
|
674
|
+
if show_visible_circle:
|
|
675
|
+
theta = np.linspace(0, 2*np.pi, 100)
|
|
676
|
+
fig.add_trace(go.Scatter(
|
|
677
|
+
x=np.cos(theta), y=np.sin(theta),
|
|
678
|
+
mode='lines',
|
|
679
|
+
line=dict(color='white', width=2, dash='dash'),
|
|
680
|
+
name='Visible Region'
|
|
681
|
+
))
|
|
682
|
+
|
|
683
|
+
fig.update_layout(
|
|
684
|
+
title=title,
|
|
685
|
+
xaxis_title='u = sin(θ)cos(φ)',
|
|
686
|
+
yaxis_title='v = sin(θ)sin(φ)',
|
|
687
|
+
xaxis=dict(scaleanchor='y', scaleratio=1),
|
|
688
|
+
yaxis=dict(constrain='domain')
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
return fig
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def plot_comparison_patterns(
|
|
695
|
+
angles_deg: np.ndarray,
|
|
696
|
+
patterns_dB: Dict[str, np.ndarray],
|
|
697
|
+
title: str = "Pattern Comparison",
|
|
698
|
+
min_dB: float = -50.0,
|
|
699
|
+
figsize: Tuple[int, int] = (12, 6)
|
|
700
|
+
) -> Any:
|
|
701
|
+
"""
|
|
702
|
+
Plot multiple patterns for comparison.
|
|
703
|
+
|
|
704
|
+
Parameters
|
|
705
|
+
----------
|
|
706
|
+
angles_deg : ndarray
|
|
707
|
+
Angle values
|
|
708
|
+
patterns_dB : dict
|
|
709
|
+
Dictionary of {label: pattern_dB}
|
|
710
|
+
title : str
|
|
711
|
+
Plot title
|
|
712
|
+
min_dB : float
|
|
713
|
+
Minimum dB
|
|
714
|
+
figsize : tuple
|
|
715
|
+
Figure size
|
|
716
|
+
|
|
717
|
+
Returns
|
|
718
|
+
-------
|
|
719
|
+
ax : matplotlib axis
|
|
720
|
+
"""
|
|
721
|
+
if not MATPLOTLIB_AVAILABLE:
|
|
722
|
+
raise ImportError("matplotlib is required for this function")
|
|
723
|
+
|
|
724
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
725
|
+
|
|
726
|
+
for label, pattern in patterns_dB.items():
|
|
727
|
+
pattern_clipped = np.clip(pattern, min_dB, 0)
|
|
728
|
+
ax.plot(angles_deg, pattern_clipped, label=label, linewidth=1.5)
|
|
729
|
+
|
|
730
|
+
ax.set_xlabel('Angle (degrees)')
|
|
731
|
+
ax.set_ylabel('Normalized Gain (dB)')
|
|
732
|
+
ax.set_title(title)
|
|
733
|
+
ax.set_ylim([min_dB, 5])
|
|
734
|
+
ax.grid(True, alpha=0.3)
|
|
735
|
+
ax.legend()
|
|
736
|
+
|
|
737
|
+
return ax
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def create_pattern_animation_plotly(
|
|
741
|
+
theta: np.ndarray,
|
|
742
|
+
phi: np.ndarray,
|
|
743
|
+
patterns_dB: List[np.ndarray],
|
|
744
|
+
frame_labels: List[str],
|
|
745
|
+
title: str = "Pattern Animation",
|
|
746
|
+
min_dB: float = -40.0,
|
|
747
|
+
colorscale: str = 'Jet'
|
|
748
|
+
) -> Any:
|
|
749
|
+
"""
|
|
750
|
+
Create animated pattern plot (e.g., beam scanning).
|
|
751
|
+
|
|
752
|
+
Parameters
|
|
753
|
+
----------
|
|
754
|
+
theta : ndarray
|
|
755
|
+
Theta values (1D)
|
|
756
|
+
phi : ndarray
|
|
757
|
+
Phi values (1D)
|
|
758
|
+
patterns_dB : list of ndarray
|
|
759
|
+
List of 2D patterns for each frame
|
|
760
|
+
frame_labels : list of str
|
|
761
|
+
Label for each frame
|
|
762
|
+
title : str
|
|
763
|
+
Plot title
|
|
764
|
+
min_dB : float
|
|
765
|
+
Minimum dB
|
|
766
|
+
colorscale : str
|
|
767
|
+
Colorscale
|
|
768
|
+
|
|
769
|
+
Returns
|
|
770
|
+
-------
|
|
771
|
+
fig : plotly Figure with animation
|
|
772
|
+
"""
|
|
773
|
+
if not PLOTLY_AVAILABLE:
|
|
774
|
+
raise ImportError("plotly is required for this function")
|
|
775
|
+
|
|
776
|
+
theta_grid, phi_grid = np.meshgrid(theta, phi, indexing='ij')
|
|
777
|
+
|
|
778
|
+
# First frame
|
|
779
|
+
fig = go.Figure(
|
|
780
|
+
data=[go.Surface(
|
|
781
|
+
x=np.rad2deg(theta_grid),
|
|
782
|
+
y=np.rad2deg(phi_grid),
|
|
783
|
+
z=np.clip(patterns_dB[0], min_dB, 0),
|
|
784
|
+
colorscale=colorscale,
|
|
785
|
+
cmin=min_dB,
|
|
786
|
+
cmax=0
|
|
787
|
+
)],
|
|
788
|
+
layout=go.Layout(
|
|
789
|
+
title=title,
|
|
790
|
+
scene=dict(
|
|
791
|
+
xaxis_title='Theta (deg)',
|
|
792
|
+
yaxis_title='Phi (deg)',
|
|
793
|
+
zaxis_title='Gain (dB)',
|
|
794
|
+
zaxis=dict(range=[min_dB, 0])
|
|
795
|
+
),
|
|
796
|
+
updatemenus=[dict(
|
|
797
|
+
type='buttons',
|
|
798
|
+
showactive=False,
|
|
799
|
+
buttons=[
|
|
800
|
+
dict(label='Play',
|
|
801
|
+
method='animate',
|
|
802
|
+
args=[None, {'frame': {'duration': 500, 'redraw': True},
|
|
803
|
+
'fromcurrent': True}]),
|
|
804
|
+
dict(label='Pause',
|
|
805
|
+
method='animate',
|
|
806
|
+
args=[[None], {'frame': {'duration': 0, 'redraw': False},
|
|
807
|
+
'mode': 'immediate'}])
|
|
808
|
+
]
|
|
809
|
+
)]
|
|
810
|
+
),
|
|
811
|
+
frames=[go.Frame(
|
|
812
|
+
data=[go.Surface(
|
|
813
|
+
x=np.rad2deg(theta_grid),
|
|
814
|
+
y=np.rad2deg(phi_grid),
|
|
815
|
+
z=np.clip(p, min_dB, 0),
|
|
816
|
+
colorscale=colorscale,
|
|
817
|
+
cmin=min_dB,
|
|
818
|
+
cmax=0
|
|
819
|
+
)],
|
|
820
|
+
name=label
|
|
821
|
+
) for p, label in zip(patterns_dB, frame_labels)]
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
return fig
|