Source code for phased_array.visualization

"""
Visualization functions for phased array patterns.

Includes 3D Plotly plots, UV-space representation, and array geometry plots.
"""

import warnings
from typing import Any, Dict, List, Optional, Tuple

import numpy as np

try:
    import plotly.graph_objects as go
    from plotly.subplots import make_subplots
    PLOTLY_AVAILABLE = True
except ImportError:
    PLOTLY_AVAILABLE = False

try:
    import matplotlib.pyplot as plt
    from matplotlib import cm
    from mpl_toolkits.mplot3d import Axes3D
    MATPLOTLIB_AVAILABLE = True
except ImportError:
    MATPLOTLIB_AVAILABLE = False

from .geometry import ArrayGeometry
from .utils import (is_visible_region, linear_to_db, theta_phi_to_uv,
                    uv_to_theta_phi)

# ============== Matplotlib Plots (2D) ==============

[docs] def plot_pattern_2d( angles_deg: np.ndarray, pattern_dB: np.ndarray, title: str = "Radiation Pattern", xlabel: str = "Angle (degrees)", ylabel: str = "Normalized Gain (dB)", min_dB: float = -50.0, figsize: Tuple[int, int] = (10, 6), ax: Optional[Any] = None, label: Optional[str] = None, **plot_kwargs ) -> Any: """ Plot a 1D pattern cut. Parameters ---------- angles_deg : ndarray Angle values in degrees pattern_dB : ndarray Pattern values in dB title : str Plot title xlabel, ylabel : str Axis labels min_dB : float Minimum dB level to display figsize : tuple Figure size (width, height) ax : matplotlib axis, optional Existing axis to plot on label : str, optional Legend label **plot_kwargs Additional arguments for plt.plot() Returns ------- ax : matplotlib axis """ if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for this function") if ax is None: fig, ax = plt.subplots(figsize=figsize) pattern_clipped = np.clip(pattern_dB, min_dB, 0) ax.plot(angles_deg, pattern_clipped, label=label, **plot_kwargs) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) ax.set_title(title) ax.set_ylim([min_dB, 5]) ax.grid(True, alpha=0.3) if label is not None: ax.legend() return ax
[docs] def plot_pattern_polar( angles_deg: np.ndarray, pattern_dB: np.ndarray, title: str = "Radiation Pattern", min_dB: float = -40.0, figsize: Tuple[int, int] = (8, 8), ax: Optional[Any] = None, **plot_kwargs ) -> Any: """ Plot a 1D pattern cut in polar coordinates. Parameters ---------- angles_deg : ndarray Angle values in degrees pattern_dB : ndarray Pattern values in dB (normalized) title : str Plot title min_dB : float Minimum dB level (becomes r=0) figsize : tuple Figure size ax : matplotlib polar axis, optional Existing axis Returns ------- ax : matplotlib axis """ if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for this function") if ax is None: fig, ax = plt.subplots(figsize=figsize, subplot_kw={'projection': 'polar'}) # Convert dB to radius (shift so min_dB = 0) r = pattern_dB - min_dB r = np.clip(r, 0, None) ax.plot(np.deg2rad(angles_deg), r, **plot_kwargs) ax.set_title(title) ax.set_theta_zero_location('N') # 0 degrees at top ax.set_theta_direction(-1) # Clockwise return ax
[docs] def plot_pattern_contour( theta_deg: np.ndarray, phi_deg: np.ndarray, pattern_dB: np.ndarray, title: str = "Radiation Pattern", min_dB: float = -40.0, levels: int = 20, figsize: Tuple[int, int] = (10, 8), cmap: str = 'jet', ax: Optional[Any] = None ) -> Any: """ Plot 2D pattern as contour plot. Parameters ---------- theta_deg : ndarray Theta values (1D or 2D grid) phi_deg : ndarray Phi values (1D or 2D grid) pattern_dB : ndarray Pattern in dB (2D) title : str Plot title min_dB : float Minimum dB level levels : int Number of contour levels figsize : tuple Figure size cmap : str Colormap name ax : matplotlib axis, optional Existing axis Returns ------- ax : matplotlib axis """ if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for this function") if ax is None: fig, ax = plt.subplots(figsize=figsize) # Create meshgrid if 1D inputs if theta_deg.ndim == 1 and phi_deg.ndim == 1: theta_grid, phi_grid = np.meshgrid(theta_deg, phi_deg, indexing='ij') else: theta_grid, phi_grid = theta_deg, phi_deg pattern_clipped = np.clip(pattern_dB, min_dB, 0) cf = ax.contourf(phi_grid, theta_grid, pattern_clipped, levels=levels, cmap=cmap) plt.colorbar(cf, ax=ax, label='Gain (dB)') ax.set_xlabel('Phi (degrees)') ax.set_ylabel('Theta (degrees)') ax.set_title(title) return ax
[docs] def plot_array_geometry( geometry: ArrayGeometry, weights: Optional[np.ndarray] = None, title: str = "Array Geometry", show_indices: bool = False, figsize: Tuple[int, int] = (8, 8), ax: Optional[Any] = None ) -> Any: """ Plot array element positions (2D view). Parameters ---------- geometry : ArrayGeometry Array geometry weights : ndarray, optional Element weights (color by magnitude) title : str Plot title show_indices : bool Show element index numbers figsize : tuple Figure size ax : matplotlib axis, optional Existing axis Returns ------- ax : matplotlib axis """ if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for this function") if ax is None: fig, ax = plt.subplots(figsize=figsize) if weights is not None: colors = np.abs(weights) scatter = ax.scatter(geometry.x, geometry.y, c=colors, cmap='viridis', s=50) plt.colorbar(scatter, ax=ax, label='|Weight|') else: ax.scatter(geometry.x, geometry.y, s=50) if show_indices: for i, (x, y) in enumerate(zip(geometry.x, geometry.y)): ax.annotate(str(i), (x, y), fontsize=8) ax.set_xlabel('X (m)') ax.set_ylabel('Y (m)') ax.set_title(title) ax.set_aspect('equal') ax.grid(True, alpha=0.3) return ax
# ============== UV-Space Visualization ==============
[docs] def compute_pattern_uv_space( geometry: ArrayGeometry, weights: np.ndarray, k: float, n_u: int = 201, n_v: int = 201, u_range: Tuple[float, float] = (-1, 1), v_range: Tuple[float, float] = (-1, 1) ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """ Compute pattern directly in UV-space. Parameters ---------- geometry : ArrayGeometry Array geometry weights : ndarray Element weights k : float Wavenumber n_u, n_v : int Number of points in u and v u_range, v_range : tuple Range for u and v Returns ------- u : ndarray U values (1D) v : ndarray V values (1D) pattern_dB : ndarray Pattern in dB (2D: n_u x n_v) """ from .core import array_factor_uv u = np.linspace(u_range[0], u_range[1], n_u) v = np.linspace(v_range[0], v_range[1], n_v) u_grid, v_grid = np.meshgrid(u, v, indexing='ij') AF = array_factor_uv(u_grid, v_grid, geometry.x, geometry.y, weights, k) pattern_dB = linear_to_db(np.abs(AF)**2) pattern_dB -= np.max(pattern_dB) return u, v, pattern_dB
[docs] def plot_pattern_uv_space( u: np.ndarray, v: np.ndarray, pattern_dB: np.ndarray, title: str = "UV-Space Pattern", min_dB: float = -40.0, show_visible_region: bool = True, show_grating_circles: bool = False, dx_wavelengths: Optional[float] = None, dy_wavelengths: Optional[float] = None, figsize: Tuple[int, int] = (10, 8), cmap: str = 'jet', ax: Optional[Any] = None ) -> Any: """ Plot pattern in UV-space with optional visible region and grating lobe circles. Parameters ---------- u, v : ndarray Direction cosine values (1D) pattern_dB : ndarray Pattern in dB (2D) title : str Plot title min_dB : float Minimum dB level show_visible_region : bool Show unit circle (visible space boundary) show_grating_circles : bool Show grating lobe circles dx_wavelengths, dy_wavelengths : float, optional Element spacing for grating lobe calculation figsize : tuple Figure size cmap : str Colormap ax : matplotlib axis, optional Existing axis Returns ------- ax : matplotlib axis """ if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for this function") if ax is None: fig, ax = plt.subplots(figsize=figsize) u_grid, v_grid = np.meshgrid(u, v, indexing='ij') pattern_clipped = np.clip(pattern_dB, min_dB, 0) cf = ax.contourf(u_grid, v_grid, pattern_clipped, levels=20, cmap=cmap) plt.colorbar(cf, ax=ax, label='Gain (dB)') if show_visible_region: theta_circle = np.linspace(0, 2*np.pi, 100) ax.plot(np.cos(theta_circle), np.sin(theta_circle), 'w--', linewidth=2, label='Visible region') if show_grating_circles and dx_wavelengths is not None: # Grating lobe positions: u = u0 + m/dx, v = v0 + n/dy for m in [-1, 1]: u_grating = m / dx_wavelengths ax.plot(np.cos(theta_circle) + u_grating, np.sin(theta_circle), 'r--', linewidth=1, alpha=0.7) if dy_wavelengths is not None: for n in [-1, 1]: v_grating = n / dy_wavelengths ax.plot(np.cos(theta_circle), np.sin(theta_circle) + v_grating, 'r--', linewidth=1, alpha=0.7) ax.set_xlabel('u = sin(θ)cos(φ)') ax.set_ylabel('v = sin(θ)sin(φ)') ax.set_title(title) ax.set_aspect('equal') ax.set_xlim([u.min(), u.max()]) ax.set_ylim([v.min(), v.max()]) return ax
# ============== 3D Plotly Plots ==============
[docs] def plot_pattern_3d_plotly( theta: np.ndarray, phi: np.ndarray, pattern_dB: np.ndarray, title: str = "3D Radiation Pattern", min_dB: float = -40.0, colorscale: str = 'Jet', surface_type: str = 'spherical' ) -> Any: """ Create interactive 3D pattern plot using Plotly. Parameters ---------- theta : ndarray Theta values in radians (1D) phi : ndarray Phi values in radians (1D) pattern_dB : ndarray Pattern in dB (2D: n_theta x n_phi) title : str Plot title min_dB : float Minimum dB level colorscale : str Plotly colorscale name surface_type : str 'spherical' - radius proportional to gain 'cartesian' - theta/phi/gain surface Returns ------- fig : plotly.graph_objects.Figure Examples -------- Create an interactive 3D pattern plot: >>> import numpy as np >>> import phased_array as pa >>> geom = pa.create_rectangular_array(16, 16, dx=0.5, dy=0.5) >>> k = pa.wavelength_to_k(1.0) >>> weights = pa.steering_vector(k, geom.x, geom.y, theta0_deg=20, phi0_deg=45) >>> weights *= pa.taylor_taper_2d(16, 16, sidelobe_dB=-30) >>> theta, phi, pattern_dB = pa.compute_full_pattern( ... geom.x, geom.y, weights, k ... ) >>> fig = pa.plot_pattern_3d_plotly( ... theta, phi, pattern_dB, ... title="16x16 Array - Steered to 20 deg", ... min_dB=-40 ... ) >>> # fig.show() # Display in browser Cartesian projection (theta/phi/gain axes): >>> fig = pa.plot_pattern_3d_plotly( ... theta, phi, pattern_dB, ... surface_type='cartesian' ... ) """ if not PLOTLY_AVAILABLE: raise ImportError("plotly is required for this function. Install with: pip install plotly") # Create meshgrid theta_grid, phi_grid = np.meshgrid(theta, phi, indexing='ij') pattern_clipped = np.clip(pattern_dB, min_dB, 0) if surface_type == 'spherical': # Map gain to radius (linear scale for better visualization) r = (pattern_clipped - min_dB) / (-min_dB) # 0 to 1 r = np.clip(r, 0.1, 1) # Minimum radius for visibility # Spherical to Cartesian x = r * np.sin(theta_grid) * np.cos(phi_grid) y = r * np.sin(theta_grid) * np.sin(phi_grid) z = r * np.cos(theta_grid) fig = go.Figure(data=[go.Surface( x=x, y=y, z=z, surfacecolor=pattern_clipped, colorscale=colorscale, colorbar=dict(title='Gain (dB)'), showscale=True )]) fig.update_layout( title=title, scene=dict( xaxis_title='X', yaxis_title='Y', zaxis_title='Z', aspectmode='data' ) ) else: # cartesian fig = go.Figure(data=[go.Surface( x=np.rad2deg(theta_grid), y=np.rad2deg(phi_grid), z=pattern_clipped, colorscale=colorscale, colorbar=dict(title='Gain (dB)') )]) fig.update_layout( title=title, scene=dict( xaxis_title='Theta (deg)', yaxis_title='Phi (deg)', zaxis_title='Gain (dB)', ) ) return fig
[docs] def plot_pattern_3d_cartesian_plotly( theta_deg: np.ndarray, phi_deg: np.ndarray, pattern_dB: np.ndarray, title: str = "3D Radiation Pattern", min_dB: float = -40.0, colorscale: str = 'Jet' ) -> Any: """ Create 3D surface plot with theta/phi/gain axes. Parameters ---------- theta_deg : ndarray Theta values in degrees (1D) phi_deg : ndarray Phi values in degrees (1D) pattern_dB : ndarray Pattern in dB (2D) title : str Plot title min_dB : float Minimum dB colorscale : str Colorscale name Returns ------- fig : plotly Figure """ if not PLOTLY_AVAILABLE: raise ImportError("plotly is required for this function") theta_grid, phi_grid = np.meshgrid(theta_deg, phi_deg, indexing='ij') pattern_clipped = np.clip(pattern_dB, min_dB, 0) fig = go.Figure(data=[go.Surface( x=theta_grid, y=phi_grid, z=pattern_clipped, colorscale=colorscale, colorbar=dict(title='Gain (dB)') )]) fig.update_layout( title=title, scene=dict( xaxis_title='Theta (deg)', yaxis_title='Phi (deg)', zaxis_title='Gain (dB)', ) ) return fig
[docs] def plot_array_geometry_3d_plotly( geometry: ArrayGeometry, weights: Optional[np.ndarray] = None, title: str = "Array Geometry", show_normals: bool = True, normal_scale: float = 0.1 ) -> Any: """ Create interactive 3D array geometry plot using Plotly. Parameters ---------- geometry : ArrayGeometry Array geometry with positions and optional normals weights : ndarray, optional Element weights for coloring title : str Plot title show_normals : bool Show element normal vectors normal_scale : float Scale factor for normal vectors Returns ------- fig : plotly Figure """ if not PLOTLY_AVAILABLE: raise ImportError("plotly is required for this function") z = geometry.z if geometry.z is not None else np.zeros_like(geometry.x) # Element colors if weights is not None: colors = np.abs(weights) color_label = '|Weight|' else: colors = np.arange(len(geometry.x)) color_label = 'Element Index' # Element positions scatter = go.Scatter3d( x=geometry.x, y=geometry.y, z=z, mode='markers', marker=dict( size=8, color=colors, colorscale='Viridis', colorbar=dict(title=color_label), showscale=True ), name='Elements' ) data = [scatter] # Element normals if show_normals and geometry.nx is not None and geometry.ny is not None: nz = geometry.nz if geometry.nz is not None else np.zeros_like(geometry.nx) for i in range(len(geometry.x)): data.append(go.Scatter3d( x=[geometry.x[i], geometry.x[i] + normal_scale * geometry.nx[i]], y=[geometry.y[i], geometry.y[i] + normal_scale * geometry.ny[i]], z=[z[i], z[i] + normal_scale * nz[i]], mode='lines', line=dict(color='red', width=2), showlegend=False )) fig = go.Figure(data=data) fig.update_layout( title=title, scene=dict( xaxis_title='X (m)', yaxis_title='Y (m)', zaxis_title='Z (m)', aspectmode='data' ) ) return fig
[docs] def plot_pattern_uv_plotly( u: np.ndarray, v: np.ndarray, pattern_dB: np.ndarray, title: str = "UV-Space Pattern", min_dB: float = -40.0, colorscale: str = 'Jet', show_visible_circle: bool = True ) -> Any: """ Interactive UV-space pattern plot using Plotly. Parameters ---------- u, v : ndarray Direction cosines (1D) pattern_dB : ndarray Pattern in dB (2D) title : str Plot title min_dB : float Minimum dB colorscale : str Colorscale show_visible_circle : bool Show visible region boundary Returns ------- fig : plotly Figure """ if not PLOTLY_AVAILABLE: raise ImportError("plotly is required for this function") u_grid, v_grid = np.meshgrid(u, v, indexing='ij') pattern_clipped = np.clip(pattern_dB, min_dB, 0) fig = go.Figure() # Heatmap of pattern fig.add_trace(go.Heatmap( x=u, y=v, z=pattern_clipped.T, colorscale=colorscale, colorbar=dict(title='Gain (dB)'), zmin=min_dB, zmax=0 )) # Visible region circle if show_visible_circle: theta = np.linspace(0, 2*np.pi, 100) fig.add_trace(go.Scatter( x=np.cos(theta), y=np.sin(theta), mode='lines', line=dict(color='white', width=2, dash='dash'), name='Visible Region' )) fig.update_layout( title=title, xaxis_title='u = sin(θ)cos(φ)', yaxis_title='v = sin(θ)sin(φ)', xaxis=dict(scaleanchor='y', scaleratio=1), yaxis=dict(constrain='domain') ) return fig
[docs] def plot_comparison_patterns( angles_deg: np.ndarray, patterns_dB: Dict[str, np.ndarray], title: str = "Pattern Comparison", min_dB: float = -50.0, figsize: Tuple[int, int] = (12, 6) ) -> Any: """ Plot multiple patterns for comparison. Parameters ---------- angles_deg : ndarray Angle values patterns_dB : dict Dictionary of {label: pattern_dB} title : str Plot title min_dB : float Minimum dB figsize : tuple Figure size Returns ------- ax : matplotlib axis """ if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for this function") fig, ax = plt.subplots(figsize=figsize) for label, pattern in patterns_dB.items(): pattern_clipped = np.clip(pattern, min_dB, 0) ax.plot(angles_deg, pattern_clipped, label=label, linewidth=1.5) ax.set_xlabel('Angle (degrees)') ax.set_ylabel('Normalized Gain (dB)') ax.set_title(title) ax.set_ylim([min_dB, 5]) ax.grid(True, alpha=0.3) ax.legend() return ax
[docs] def create_pattern_animation_plotly( theta: np.ndarray, phi: np.ndarray, patterns_dB: List[np.ndarray], frame_labels: List[str], title: str = "Pattern Animation", min_dB: float = -40.0, colorscale: str = 'Jet' ) -> Any: """ Create animated pattern plot (e.g., beam scanning). Parameters ---------- theta : ndarray Theta values (1D) phi : ndarray Phi values (1D) patterns_dB : list of ndarray List of 2D patterns for each frame frame_labels : list of str Label for each frame title : str Plot title min_dB : float Minimum dB colorscale : str Colorscale Returns ------- fig : plotly Figure with animation """ if not PLOTLY_AVAILABLE: raise ImportError("plotly is required for this function") theta_grid, phi_grid = np.meshgrid(theta, phi, indexing='ij') # First frame fig = go.Figure( data=[go.Surface( x=np.rad2deg(theta_grid), y=np.rad2deg(phi_grid), z=np.clip(patterns_dB[0], min_dB, 0), colorscale=colorscale, cmin=min_dB, cmax=0 )], layout=go.Layout( title=title, scene=dict( xaxis_title='Theta (deg)', yaxis_title='Phi (deg)', zaxis_title='Gain (dB)', zaxis=dict(range=[min_dB, 0]) ), updatemenus=[dict( type='buttons', showactive=False, buttons=[ dict(label='Play', method='animate', args=[None, {'frame': {'duration': 500, 'redraw': True}, 'fromcurrent': True}]), dict(label='Pause', method='animate', args=[[None], {'frame': {'duration': 0, 'redraw': False}, 'mode': 'immediate'}]) ] )] ), frames=[go.Frame( data=[go.Surface( x=np.rad2deg(theta_grid), y=np.rad2deg(phi_grid), z=np.clip(p, min_dB, 0), colorscale=colorscale, cmin=min_dB, cmax=0 )], name=label ) for p, label in zip(patterns_dB, frame_labels)] ) return fig
# ============== Wideband / Beam Squint Plots ==============
[docs] def plot_beam_squint( frequencies: np.ndarray, squint_data: Dict[str, np.ndarray], center_frequency: float, title: str = "Beam Squint vs Frequency", figsize: Tuple[int, int] = (10, 6) ) -> Any: """ Plot beam squint comparison for different steering modes. Parameters ---------- frequencies : ndarray Frequency values in Hz squint_data : dict Dictionary of {mode_name: squint_array} in degrees center_frequency : float Center frequency in Hz (for normalization) title : str Plot title figsize : tuple Figure size Returns ------- ax : matplotlib axis """ if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for this function") fig, ax = plt.subplots(figsize=figsize) # Normalize frequency to percentage of center freq_percent = (frequencies - center_frequency) / center_frequency * 100 colors = {'phase': 'red', 'hybrid': 'blue', 'ttd': 'green'} labels = {'phase': 'Phase-only', 'hybrid': 'Hybrid (TTD + Phase)', 'ttd': 'True-Time Delay'} for mode, squint in squint_data.items(): color = colors.get(mode, None) label = labels.get(mode, mode) ax.plot(freq_percent, squint, 'o-', label=label, color=color, linewidth=2, markersize=6) ax.axhline(0, color='gray', linestyle='--', alpha=0.5) ax.axvline(0, color='gray', linestyle='--', alpha=0.5) ax.set_xlabel('Frequency Offset (%)') ax.set_ylabel('Beam Squint (degrees)') ax.set_title(title) ax.grid(True, alpha=0.3) ax.legend() return ax
[docs] def plot_pattern_vs_frequency( angles: np.ndarray, frequencies: np.ndarray, patterns: np.ndarray, center_frequency: float, title: str = "Pattern vs Frequency", min_dB: float = -40.0, figsize: Tuple[int, int] = (12, 8) ) -> Any: """ Plot radiation patterns at multiple frequencies as a waterfall/heatmap. Parameters ---------- angles : ndarray Angle values in degrees frequencies : ndarray Frequency values in Hz patterns : ndarray 2D array (n_freq x n_angles) of patterns in dB center_frequency : float Center frequency for labeling title : str Plot title min_dB : float Minimum dB for colormap figsize : tuple Figure size Returns ------- ax : matplotlib axis """ if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for this function") fig, ax = plt.subplots(figsize=figsize) # Normalize frequency to percentage freq_percent = (frequencies - center_frequency) / center_frequency * 100 patterns_clipped = np.clip(patterns, min_dB, 0) im = ax.pcolormesh(angles, freq_percent, patterns_clipped, cmap='jet', shading='auto') plt.colorbar(im, ax=ax, label='Gain (dB)') ax.set_xlabel('Angle (degrees)') ax.set_ylabel('Frequency Offset (%)') ax.set_title(title) return ax
[docs] def plot_pattern_vs_frequency_plotly( angles: np.ndarray, frequencies: np.ndarray, patterns: np.ndarray, center_frequency: float, title: str = "Pattern vs Frequency", min_dB: float = -40.0 ) -> Any: """ Interactive Plotly plot of patterns vs frequency. Parameters ---------- angles : ndarray Angle values in degrees frequencies : ndarray Frequency values in Hz patterns : ndarray 2D array (n_freq x n_angles) in dB center_frequency : float Center frequency title : str Plot title min_dB : float Minimum dB Returns ------- fig : plotly Figure """ if not PLOTLY_AVAILABLE: raise ImportError("plotly is required for this function") freq_percent = (frequencies - center_frequency) / center_frequency * 100 patterns_clipped = np.clip(patterns, min_dB, 0) fig = go.Figure(data=go.Heatmap( x=angles, y=freq_percent, z=patterns_clipped, colorscale='Jet', zmin=min_dB, zmax=0, colorbar=dict(title='Gain (dB)') )) fig.update_layout( title=title, xaxis_title='Angle (degrees)', yaxis_title='Frequency Offset (%)' ) return fig
[docs] def plot_subarray_delays( architecture, delays: np.ndarray, title: str = "Subarray Time Delays", figsize: Tuple[int, int] = (10, 8) ) -> Any: """ Visualize TTD values across subarrays. Parameters ---------- architecture : SubarrayArchitecture Subarray architecture with centers delays : ndarray Time delay for each subarray in seconds title : str Plot title figsize : tuple Figure size Returns ------- ax : matplotlib axis """ if not MATPLOTLIB_AVAILABLE: raise ImportError("matplotlib is required for this function") fig, ax = plt.subplots(figsize=figsize) centers = architecture.subarray_centers delays_ns = delays * 1e9 # Convert to nanoseconds scatter = ax.scatter(centers[:, 0], centers[:, 1], c=delays_ns, cmap='viridis', s=200, edgecolors='black') plt.colorbar(scatter, ax=ax, label='Delay (ns)') # Add labels for i, (x, y) in enumerate(centers): ax.annotate(f'SA{i}', (x, y), ha='center', va='center', fontsize=8) ax.set_xlabel('X (m)') ax.set_ylabel('Y (m)') ax.set_title(title) ax.set_aspect('equal') ax.grid(True, alpha=0.3) return ax