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,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