""" Manages all matplotlib plotting operations for the Radar Scenario Simulator GUI. This module encapsulates the logic for initializing, clearing, and updating the Range-Doppler (RD) and Plan Position Indicator (PPI) plots. """ import numpy as np from scipy.constants import c from ..utils import radar_math class PlotManager: """Handles the state and drawing of matplotlib plots.""" def __init__(self, rd_figure, rd_canvas, ppi_figure, ppi_canvas, ppi_range_var): self.rd_figure = rd_figure self.rd_canvas = rd_canvas self.ppi_figure = ppi_figure self.ppi_canvas = ppi_canvas self.ppi_range_var = ppi_range_var # Artists that will be updated self.ax_rd = None self.im_rd = None self.cbar_rd = None self.ax_ppi = None self.ppi_targets_plot = [] self.beam_patch = None self.beam_line = None self._init_base_plots() def _init_base_plots(self): """Initializes the plots with titles and labels, called once.""" self.init_ppi_plot() self.clear_rd_plot() def init_ppi_plot(self): """Clears and initializes the polar axes for the PPI plot.""" self.ppi_figure.clear() self.ax_ppi = self.ppi_figure.add_subplot(111, polar=True) self.ax_ppi.set_title('PPI Display', color='white', fontsize=10) self.ax_ppi.set_facecolor('#3a3a3a') self.ax_ppi.set_theta_zero_location("N") self.ax_ppi.set_theta_direction(-1) self.ax_ppi.set_rlabel_position(-22.5) self.ax_ppi.tick_params(axis='x', colors='white', labelsize=8) self.ax_ppi.tick_params(axis='y', colors='lightgray', labelsize=8) self.ax_ppi.grid(color='gray', linestyle='--') self.ppi_figure.tight_layout() if hasattr(self, 'ppi_targets_plot'): self.ppi_targets_plot.clear() self.ppi_canvas.draw() def clear_rd_plot(self): """Clears the Range-Doppler plot.""" self.rd_figure.clear() self.ax_rd = self.rd_figure.add_subplot(111) self.ax_rd.set_title('Range-Doppler Map', color='white', fontsize=10) self.ax_rd.set_xlabel('Range (m)', color='white', fontsize=8) self.ax_rd.set_ylabel('Velocity (m/s)', color='white', fontsize=8) self.ax_rd.tick_params(axis='x', colors='white', labelsize=8) self.ax_rd.tick_params(axis='y', colors='white', labelsize=8) self.ax_rd.set_facecolor('#3a3a3a') self.rd_figure.tight_layout() self.rd_canvas.draw() def setup_plots_for_simulation(self, num_pulses_cpi, min_db, max_db, targets, radar_cfg): """Prepares the plots at the start of a simulation.""" self.clear_rd_plot() self.im_rd = self.ax_rd.imshow(np.zeros((num_pulses_cpi, 100)), aspect='auto', cmap='jet', vmin=min_db, vmax=max_db) self.cbar_rd = self.rd_figure.colorbar(self.im_rd, ax=self.ax_rd) self.cbar_rd.ax.tick_params(colors='white', labelsize=8) self.cbar_rd.set_label('Amplitude (dB)', color='white', fontsize=8) self.rd_figure.tight_layout() self.init_ppi_plot() self.redraw_ppi_targets(targets) max_target_range = max(np.linalg.norm(t.initial_position) for t in targets) max_unamb_range = radar_math.calculate_max_unambiguous_range(radar_cfg.prf) max_plot_range = max(max_unamb_range, max_target_range) * 1.2 self.ax_ppi.set_ylim(0, max_plot_range) self.ppi_range_var.set(max_plot_range) self.beam_patch = self.ax_ppi.fill_between( np.radians([-0.5 * radar_cfg.antenna_config.beamwidth_az_deg, 0.5 * radar_cfg.antenna_config.beamwidth_az_deg]), 0, max_plot_range, color='cyan', alpha=0.2, linewidth=0 ) self.beam_line, = self.ax_ppi.plot([0, 0], [0, max_plot_range], color='cyan', linewidth=2) self.ppi_canvas.draw() self.rd_canvas.draw() def update_plots(self, frame_data, radar_cfg, auto_scale, min_db, max_db): """Update plots with new frame data from the simulation.""" current_az_deg, iq_data_cpi, frame_num = frame_data # --- Update Range-Doppler Map --- if iq_data_cpi.size == 0: self.im_rd.set_data(np.zeros((iq_data_cpi.shape[0], 100))) self.ax_rd.set_title(f'RD Map (Frame {frame_num}) - No Data', color='white', fontsize=10) else: window = np.hanning(iq_data_cpi.shape[0])[:, np.newaxis] iq_data_windowed = iq_data_cpi * window range_doppler_map = np.fft.fftshift(np.fft.fft(iq_data_windowed, axis=0), axes=0) range_doppler_map = np.fft.fftshift(np.fft.fft(range_doppler_map, axis=1), axes=1) epsilon = 1e-10 range_doppler_map_db = 20 * np.log10(np.abs(range_doppler_map) + epsilon) vmin, vmax = min_db, max_db if auto_scale: if np.any(np.isfinite(range_doppler_map_db)): vmin = np.nanmin(range_doppler_map_db[np.isfinite(range_doppler_map_db)]) vmax = np.nanmax(range_doppler_map_db) else: vmin, vmax = -100, 0 self.im_rd.set_data(range_doppler_map_db) self.im_rd.set_clim(vmin=vmin, vmax=vmax) doppler_freq_axis = np.fft.fftshift(np.fft.fftfreq(iq_data_cpi.shape[0], d=1.0/radar_cfg.prf)) velocity_axis = doppler_freq_axis * (c / radar_cfg.carrier_frequency) / 2 range_axis_samples = iq_data_cpi.shape[1] range_axis_m = np.arange(range_axis_samples) * c / (2 * radar_cfg.sample_rate) self.im_rd.set_extent([range_axis_m[0], range_axis_m[-1], velocity_axis[0], velocity_axis[-1]]) self.ax_rd.set_title(f'RD Map (Frame {frame_num})', color='white', fontsize=10) # --- Update PPI Plot --- current_az_rad = np.deg2rad(current_az_deg) beamwidth_rad = np.deg2rad(radar_cfg.antenna_config.beamwidth_az_deg) theta_beam = np.linspace(current_az_rad - beamwidth_rad / 2, current_az_rad + beamwidth_rad / 2, 50) max_plot_range = self.ax_ppi.get_ylim()[1] self.beam_patch.remove() self.beam_patch = self.ax_ppi.fill_between(theta_beam, 0, max_plot_range, color='cyan', alpha=0.2, linewidth=0) self.beam_line.set_xdata([current_az_rad, current_az_rad]) self.rd_canvas.draw_idle() self.ppi_canvas.draw_idle() def redraw_ppi_targets(self, targets): """Clears and redraws all targets on the PPI plot.""" if not hasattr(self, 'ax_ppi') or not self.ax_ppi: return for plot in self.ppi_targets_plot: plot.remove() self.ppi_targets_plot.clear() for target in targets: try: x, y = target.initial_position[0], target.initial_position[1] r = np.linalg.norm([x, y]) theta = np.arctan2(y, x) target_plot, = self.ax_ppi.plot(theta, r, 'o', color='red', markersize=6) self.ppi_targets_plot.append(target_plot) except (ValueError, IndexError): continue self.ppi_canvas.draw() def update_ppi_range(self): """Callback to update the PPI plot's range limit from the tk var.""" if not hasattr(self, 'ax_ppi'): return try: new_range = self.ppi_range_var.get() if new_range > 0: self.ax_ppi.set_ylim(0, new_range) self.ppi_canvas.draw() except Exception: pass # Ignore errors if entry is invalid