""" Main GUI module for the Radar Scenario Simulator (Tkinter version). This module is responsible for building the main application window, laying out widgets, and coordinating the simulation and plotting managers. """ import tkinter as tk from tkinter import ttk, messagebox, simpledialog from tkinter.scrolledtext import ScrolledText import queue import logging import numpy as np from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure # Import core simulation engine and utility functions from ..core.simulation_engine import RadarConfig, AntennaConfig, ScanConfig, Target from ..utils import radar_math, config_manager # Import new manager classes and logger from .plot_manager import PlotManager from .simulation_manager import SimulationManager from ..utils import logger # --- Helper Dialog for Adding/Editing Targets --- # Sostituisci l'intera classe AddTargetDialog nel file gui/gui.py con questa class AddTargetDialog(tk.Toplevel): """Dialog window for adding or editing target parameters with dual input modes.""" def __init__(self, parent, target_data_si=None): super().__init__(parent) self.title("Add New Target" if target_data_si is None else "Edit Target") self.transient(parent) self.grab_set() self.result_si = None main_frame = ttk.Frame(self, padding="10") main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) # --- Mode Selection --- self.input_mode = tk.StringVar(value="Cartesian") mode_frame = ttk.Frame(main_frame) mode_frame.grid(row=0, column=0, columnspan=2, pady=(0, 10), sticky=tk.W) ttk.Label(mode_frame, text="Position Input Mode:").pack(side=tk.LEFT, padx=(0, 10)) ttk.Radiobutton(mode_frame, text="Cartesian (X, Y, Z)", variable=self.input_mode, value="Cartesian", command=self._update_input_mode).pack(side=tk.LEFT) ttk.Radiobutton(mode_frame, text="Spherical (R, Az, El)", variable=self.input_mode, value="Spherical", command=self._update_input_mode).pack(side=tk.LEFT) # --- Data Initialization --- self._initialize_vars(target_data_si) # --- Input Frames --- self.cartesian_frame = ttk.Frame(main_frame) self.cartesian_frame.grid(row=1, column=0, columnspan=2, sticky='ew') self.spherical_frame = ttk.Frame(main_frame) self.spherical_frame.grid(row=1, column=0, columnspan=2, sticky='ew') self._populate_cartesian_frame() self._populate_spherical_frame() # --- Common Velocity and RCS Frame --- common_frame = ttk.Frame(main_frame) common_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0), sticky='ew') self._populate_common_frame(common_frame) # --- Buttons --- button_frame = ttk.Frame(main_frame) button_frame.grid(row=3, column=0, columnspan=2, pady=10) ttk.Button(button_frame, text="OK", command=self.on_ok).pack(side=tk.LEFT, padx=5) ttk.Button(button_frame, text="Cancel", command=self.destroy).pack(side=tk.LEFT, padx=5) self._update_input_mode() # Set initial visibility def _initialize_vars(self, target_data_si): """Initialize all tk variables from SI data, calculating both coordinate systems.""" # Default values in SI pos_x_m, pos_y_m, pos_z_m = 5000.0, 0.0, 0.0 vel_x_mps, vel_y_mps, vel_z_mps = -150.0, 0.0, 0.0 rcs = 1.0 if target_data_si: pos_x_m, pos_y_m, pos_z_m = target_data_si["pos_x"], target_data_si["pos_y"], target_data_si["pos_z"] vel_x_mps, vel_y_mps, vel_z_mps = target_data_si["vel_x"], target_data_si["vel_y"], target_data_si["vel_z"] rcs = target_data_si["rcs"] # Calculate spherical coordinates from SI cartesian range_m, az_deg, el_deg = radar_math.cartesian_to_spherical(pos_x_m, pos_y_m, pos_z_m) self.vars = { # Display units "pos_x_nm": tk.DoubleVar(value=radar_math.meters_to_nm(pos_x_m)), "pos_y_nm": tk.DoubleVar(value=radar_math.meters_to_nm(pos_y_m)), "pos_z_nm": tk.DoubleVar(value=radar_math.meters_to_nm(pos_z_m)), "range_nm": tk.DoubleVar(value=radar_math.meters_to_nm(range_m)), "azimuth_deg": tk.DoubleVar(value=az_deg), "elevation_deg": tk.DoubleVar(value=el_deg), "vel_x_knots": tk.DoubleVar(value=radar_math.mps_to_knots(vel_x_mps)), "vel_y_knots": tk.DoubleVar(value=radar_math.mps_to_knots(vel_y_mps)), "vel_z_knots": tk.DoubleVar(value=radar_math.mps_to_knots(vel_z_mps)), "rcs": tk.DoubleVar(value=rcs) } def _create_spinbox(self, parent, label_text, var_key, from_=-1e6, to=1e6): ttk.Label(parent, text=label_text).pack(side=tk.LEFT) spinbox = ttk.Spinbox(parent, from_=from_, to=to, textvariable=self.vars[var_key], width=10) spinbox.pack(side=tk.RIGHT, fill=tk.X, expand=True) def _populate_cartesian_frame(self): labels = ["Position X (NM):", "Position Y (NM):", "Position Z (NM):"] keys = ["pos_x_nm", "pos_y_nm", "pos_z_nm"] for label, key in zip(labels, keys): frame = ttk.Frame(self.cartesian_frame) frame.pack(fill=tk.X, pady=1) self._create_spinbox(frame, label, key) def _populate_spherical_frame(self): frame_r = ttk.Frame(self.spherical_frame); frame_r.pack(fill=tk.X, pady=1) self._create_spinbox(frame_r, "Range (NM):", "range_nm", from_=0.0) frame_az = ttk.Frame(self.spherical_frame); frame_az.pack(fill=tk.X, pady=1) self._create_spinbox(frame_az, "Azimuth (deg):", "azimuth_deg", from_=-180.0, to=180.0) frame_el = ttk.Frame(self.spherical_frame); frame_el.pack(fill=tk.X, pady=1) self._create_spinbox(frame_el, "Elevation (deg):", "elevation_deg", from_=-90.0, to=90.0) def _populate_common_frame(self, parent): ttk.Separator(parent).pack(fill=tk.X, pady=5) labels = ["Velocity X (knots):", "Velocity Y (knots):", "Velocity Z (knots):", "RCS (m^2):"] keys = ["vel_x_knots", "vel_y_knots", "vel_z_knots", "rcs"] for label, key in zip(labels, keys): frame = ttk.Frame(parent) frame.pack(fill=tk.X, pady=1) from_val = 0.01 if key == "rcs" else -1e6 self._create_spinbox(frame, label, key, from_=from_val) def _update_input_mode(self): """Show the correct frame based on the selected radio button.""" if self.input_mode.get() == "Cartesian": self.spherical_frame.grid_remove() self.cartesian_frame.grid() else: # Spherical self.cartesian_frame.grid_remove() self.spherical_frame.grid() def on_ok(self): """Convert data from the active input mode back to SI and store it.""" # Get common values vel_x_mps = radar_math.knots_to_mps(self.vars["vel_x_knots"].get()) vel_y_mps = radar_math.knots_to_mps(self.vars["vel_y_knots"].get()) vel_z_mps = radar_math.knots_to_mps(self.vars["vel_z_knots"].get()) rcs = self.vars["rcs"].get() # Get position based on the active mode if self.input_mode.get() == "Cartesian": pos_x_m = radar_math.nm_to_meters(self.vars["pos_x_nm"].get()) pos_y_m = radar_math.nm_to_meters(self.vars["pos_y_nm"].get()) pos_z_m = radar_math.nm_to_meters(self.vars["pos_z_nm"].get()) else: # Spherical range_nm = self.vars["range_nm"].get() azimuth_deg = self.vars["azimuth_deg"].get() elevation_deg = self.vars["elevation_deg"].get() range_m = radar_math.nm_to_meters(range_nm) pos_x_m, pos_y_m, pos_z_m = radar_math.spherical_to_cartesian(range_m, azimuth_deg, elevation_deg) self.result_si = { "pos_x": pos_x_m, "pos_y": pos_y_m, "pos_z": pos_z_m, "vel_x": vel_x_mps, "vel_y": vel_y_mps, "vel_z": vel_z_mps, "rcs": rcs } self.destroy() def show(self): """Show the dialog and return the result in SI units.""" self.wait_window() return self.result_si # --- Main Application Class --- class App(tk.Tk): """Main application window for the Radar Scenario Simulator.""" def __init__(self): super().__init__() self.title("Radar Scenario Simulator") self.state('zoomed') # Maximize the window without borderless fullscreen # --- Main Layout --- main_paned_window = ttk.PanedWindow(self, orient=tk.HORIZONTAL) main_paned_window.pack(fill=tk.BOTH, expand=True) # --- Left Column (Notebook and Logs) --- left_column_frame = ttk.Frame(main_paned_window, width=600) main_paned_window.add(left_column_frame, weight=1) left_paned_window = ttk.PanedWindow(left_column_frame, orient=tk.VERTICAL) left_paned_window.pack(fill=tk.BOTH, expand=True) notebook_frame = ttk.Frame(left_paned_window, height=500) left_paned_window.add(notebook_frame, weight=2) log_frame = ttk.LabelFrame(left_paned_window, text="Log", height=200) left_paned_window.add(log_frame, weight=1) # --- Right Column (RD and PPI plots) --- right_column_frame = ttk.Frame(main_paned_window) main_paned_window.add(right_column_frame, weight=3) right_paned_window = ttk.PanedWindow(right_column_frame, orient=tk.VERTICAL) right_paned_window.pack(fill=tk.BOTH, expand=True) rd_frame = ttk.LabelFrame(right_paned_window, text="Range-Doppler Map") right_paned_window.add(rd_frame, weight=1) ppi_frame = ttk.Frame(right_paned_window) right_paned_window.add(ppi_frame, weight=1) # --- Initialize UI Variables --- self._init_vars() # --- Create UI Components --- notebook = ttk.Notebook(notebook_frame) notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.config_tab = ttk.Frame(notebook) self.target_tab = ttk.Frame(notebook) self.scenario_tab = ttk.Frame(notebook) notebook.add(self.config_tab, text="Configuration") notebook.add(self.target_tab, text="Target") notebook.add(self.scenario_tab, text="Scenario") self._populate_config_tab(self.config_tab) self._populate_target_tab(self.target_tab) self._populate_scenario_tab(self.scenario_tab) # --- Setup Logging Area --- self.log_widget = ScrolledText(log_frame, state=tk.DISABLED, wrap=tk.WORD, font=("TkDefaultFont", 9)) self.log_widget.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self._init_logging() # --- Setup Plotting Area --- ppi_plot_frame = ttk.LabelFrame(ppi_frame, text="PPI Display") ppi_plot_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 0), pady=5) scan_controls_frame = ttk.LabelFrame(ppi_frame, text="Scan & PPI Control") scan_controls_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(5, 5), pady=5) self._populate_scan_ppi_controls(scan_controls_frame) self.rd_figure = Figure(figsize=(8, 6), dpi=100, facecolor='#3a3a3a') self.rd_canvas = FigureCanvasTkAgg(self.rd_figure, master=rd_frame) self.rd_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) self.ppi_figure = Figure(figsize=(4, 4), dpi=100, facecolor='#3a3a3a') self.ppi_canvas = FigureCanvasTkAgg(self.ppi_figure, master=ppi_plot_frame) self.ppi_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) # --- Initialize Managers --- self.plot_manager = PlotManager(self.rd_figure, self.rd_canvas, self.ppi_figure, self.ppi_canvas, self.vars['ppi_range_nm']) self.simulation_manager = SimulationManager() # --- Finalize Init --- self.plot_manager.update_ppi_range() # Set initial PPI range self.toggle_amplitude_controls() self.update_derived_parameters() self.protocol("WM_DELETE_WINDOW", self.on_closing) self.log.info("Application initialized successfully.") def _init_logging(self): logging_config = { "default_root_level": logging.INFO, "format": "%(asctime)s [%(levelname)-8s] %(name)-20s: %(message)s", "date_format": "%H:%M:%S", "enable_console": True, "colors": { logging.DEBUG: "gray", logging.INFO: "black", logging.WARNING: "orange", logging.ERROR: "red", logging.CRITICAL: "red", } } logger.setup_basic_logging(self, logging_config) logger.add_tkinter_handler(self.log_widget, logging_config) self.log = logger.get_logger(__name__) def on_closing(self): self.log.info("Shutdown sequence initiated.") if self.simulation_manager.is_running(): self.stop_simulation() logger.shutdown_logging_system() self.destroy() def _init_vars(self): self.vars = { "carrier_frequency": tk.DoubleVar(value=9.5e9), "prf": tk.DoubleVar(value=2000.0), "duty_cycle": tk.DoubleVar(value=10.0), "sample_rate": tk.DoubleVar(value=5e6), "beamwidth_az_deg": tk.DoubleVar(value=3.0), "beamwidth_el_deg": tk.DoubleVar(value=3.0), "scan_mode": tk.StringVar(value='staring'), "min_az_deg": tk.DoubleVar(value=-30.0), "max_az_deg": tk.DoubleVar(value=30.0), "scan_speed_deg_s": tk.DoubleVar(value=20.0), "num_pulses_cpi": tk.IntVar(value=256), "simulation_duration_s": tk.DoubleVar(value=10.0), "min_db": tk.DoubleVar(value=-60.0), "max_db": tk.DoubleVar(value=0.0), "auto_scale": tk.BooleanVar(value=True), "pulse_width_text": tk.StringVar(), "listening_time_text": tk.StringVar(), "max_range_text": tk.StringVar(), "max_velocity_text": tk.StringVar(), "dwell_time_text": tk.StringVar(), "pulses_on_target_text": tk.StringVar(), "max_range_nm": tk.DoubleVar(value=100.0), # New "ppi_range_nm": tk.DoubleVar(value=100.0), # Renamed "scan_info_text": tk.StringVar(value="Mode: Staring") } self.profiles = config_manager.load_profiles() self.selected_profile = tk.StringVar() self.scenarios = config_manager.load_scenarios() self.selected_scenario = tk.StringVar() for key in ["prf", "carrier_frequency", "duty_cycle", "beamwidth_az_deg", "scan_mode", "scan_speed_deg_s", "min_az_deg", "max_az_deg"]: self.vars[key].trace_add("write", self.update_derived_parameters) def _create_labeled_spinbox(self, parent, text, var, from_, to, increment=1.0, is_db=False, scientific=False, command=None): frame = ttk.Frame(parent) frame.pack(fill=tk.X, pady=2) ttk.Label(frame, text=text, width=25).pack(side=tk.LEFT) if scientific: spinbox = ttk.Spinbox(frame, from_=from_, to=to, increment=increment, textvariable=var, format="%.2e", command=command) else: spinbox = ttk.Spinbox(frame, from_=from_, to=to, increment=increment, textvariable=var, command=command) spinbox.pack(side=tk.LEFT, fill=tk.X, expand=True) if is_db: if "min" in text.lower(): self.min_db_spinbox = spinbox else: self.max_db_spinbox = spinbox return spinbox def _populate_config_tab(self, tab): profile_group = ttk.LabelFrame(tab, text="Radar Profiles", padding=10) profile_group.pack(fill=tk.X, padx=5, pady=5) profile_frame = ttk.Frame(profile_group) profile_frame.pack(fill=tk.X, pady=2) ttk.Label(profile_frame, text="Profile:").pack(side=tk.LEFT, padx=(0, 5)) self.profile_combobox = ttk.Combobox(profile_frame, textvariable=self.selected_profile, state='readonly') self.profile_combobox.pack(side=tk.LEFT, fill=tk.X, expand=True) self.profile_combobox.bind('<>', self.on_profile_select) btn_frame = ttk.Frame(profile_group) btn_frame.pack(fill=tk.X, pady=5) ttk.Button(btn_frame, text="Save Current...", command=self.save_profile).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame, text="Delete Selected", command=self.delete_profile).pack(side=tk.LEFT, padx=5) self.refresh_profile_list() radar_group = ttk.LabelFrame(tab, text="Radar Configuration", padding=10) radar_group.pack(fill=tk.X, padx=5, pady=5) self._create_labeled_spinbox(radar_group, "Carrier Frequency (Hz):", self.vars["carrier_frequency"], 1e6, 100e9, scientific=True) self._create_labeled_spinbox(radar_group, "PRF (Hz):", self.vars["prf"], 1, 100000) self._create_labeled_spinbox(radar_group, "Duty Cycle (%):", self.vars["duty_cycle"], 0.01, 99.99, increment=0.1) self._create_labeled_spinbox(radar_group, "Sample Rate (Hz):", self.vars["sample_rate"], 1e3, 100e6, scientific=True) self._create_labeled_spinbox(radar_group, "Max Range (NM):", self.vars["max_range_nm"], 10, 1000, increment=10, command=self._on_max_range_config_change) antenna_group = ttk.LabelFrame(tab, text="Antenna Configuration", padding=10) antenna_group.pack(fill=tk.X, padx=5, pady=5) self._create_labeled_spinbox(antenna_group, "Azimuth Beamwidth (deg):", self.vars["beamwidth_az_deg"], 0.1, 90.0, increment=0.1) scan_group = ttk.LabelFrame(tab, text="Scan Strategy", padding=10) scan_group.pack(fill=tk.X, padx=5, pady=5) mode_frame = ttk.Frame(scan_group) mode_frame.pack(fill=tk.X, pady=2) ttk.Label(mode_frame, text="Scan Mode:", width=25).pack(side=tk.LEFT) ttk.Radiobutton(mode_frame, text="Staring", variable=self.vars["scan_mode"], value="staring", command=self.update_scan_mode_controls).pack(side=tk.LEFT) ttk.Radiobutton(mode_frame, text="Sector", variable=self.vars["scan_mode"], value="sector", command=self.update_scan_mode_controls).pack(side=tk.LEFT) self.min_az_spinbox = self._create_labeled_spinbox(scan_group, "Min Azimuth (deg):", self.vars["min_az_deg"], -180, 180, increment=1.0) self.max_az_spinbox = self._create_labeled_spinbox(scan_group, "Max Azimuth (deg):", self.vars["max_az_deg"], -180, 180, increment=1.0) self.scan_speed_spinbox = self._create_labeled_spinbox(scan_group, "Scan Speed (deg/s):", self.vars["scan_speed_deg_s"], 0.1, 1000.0, increment=1.0) self.update_scan_mode_controls() derived_group = ttk.LabelFrame(tab, text="Derived & Calculated Values", padding=10) derived_group.pack(fill=tk.X, padx=5, pady=5) for key in ["pulse_width_text", "listening_time_text"]: ttk.Label(derived_group, textvariable=self.vars[key]).pack(anchor=tk.W) ttk.Separator(derived_group, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5) self.max_range_label = ttk.Label(derived_group, textvariable=self.vars["max_range_text"]) self.max_range_label.pack(anchor=tk.W) self.max_velocity_label = ttk.Label(derived_group, textvariable=self.vars["max_velocity_text"]) self.max_velocity_label.pack(anchor=tk.W) ttk.Separator(derived_group, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5) for key in ["dwell_time_text", "pulses_on_target_text"]: ttk.Label(derived_group, textvariable=self.vars[key]).pack(anchor=tk.W) def _populate_target_tab(self, tab): """Populates the Target tab with scenario management and the target table.""" # --- NUOVO: Scenario Management Group --- scenario_group = ttk.LabelFrame(tab, text="Scenario Management", padding=10) scenario_group.pack(fill=tk.X, padx=5, pady=5) scenario_frame = ttk.Frame(scenario_group) scenario_frame.pack(fill=tk.X, pady=2) ttk.Label(scenario_frame, text="Scenario:").pack(side=tk.LEFT, padx=(0, 5)) self.scenario_combobox = ttk.Combobox(scenario_frame, textvariable=self.selected_scenario, state='readonly') self.scenario_combobox.pack(side=tk.LEFT, fill=tk.X, expand=True) self.scenario_combobox.bind('<>', self.on_scenario_select) btn_frame_scenario = ttk.Frame(scenario_group) btn_frame_scenario.pack(fill=tk.X, pady=5) ttk.Button(btn_frame_scenario, text="Save Current...", command=self.save_scenario).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame_scenario, text="Delete Selected", command=self.delete_scenario).pack(side=tk.LEFT, padx=5) self.refresh_scenario_list() # --- Target Management Group (la tabella esistente) --- target_group = ttk.LabelFrame(tab, text="Target List", padding=10) target_group.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self._create_target_table(target_group) def _populate_scenario_tab(self, tab): sim_group = ttk.LabelFrame(tab, text="Simulation & Plotting Control", padding=10) sim_group.pack(fill=tk.X, padx=5, pady=5) self._create_sim_controls(sim_group) self.analysis_frame = ttk.LabelFrame(tab, text="Simulation Analysis & Warnings", padding=10) self.analysis_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.analysis_text = tk.Text(self.analysis_frame, wrap=tk.WORD, height=10, state=tk.DISABLED, font=('TkDefaultFont', 10)) self.analysis_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) analysis_text_scroll = ttk.Scrollbar(self.analysis_frame, command=self.analysis_text.yview) analysis_text_scroll.pack(side=tk.RIGHT, fill=tk.Y) self.analysis_text.config(yscrollcommand=analysis_text_scroll.set) def _create_target_table(self, parent): """Creates and configures the target Treeview table with display units.""" frame = ttk.Frame(parent) frame.pack(fill=tk.BOTH, expand=True) # --- MODIFICA: Aggiornamento dei nomi delle colonne --- cols = ("Pos X (NM)", "Pos Y (NM)", "Pos Z (NM)", "Vel X (knots)", "Vel Y (knots)", "Vel Z (knots)", "RCS (m^2)") self.target_table = ttk.Treeview(frame, columns=cols, show="headings") self.target_table.column("#0", width=0, stretch=tk.NO) for col in cols: self.target_table.heading(col, text=col) self.target_table.column(col, width=80, anchor=tk.CENTER) # Increased width self.target_table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar = ttk.Scrollbar(frame, orient="vertical", command=self.target_table.yview) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.target_table.configure(yscrollcommand=scrollbar.set) self.target_table.bind("", self.on_target_double_click) btn_frame = ttk.Frame(parent) btn_frame.pack(fill=tk.X, pady=5) ttk.Button(btn_frame, text="Add Target", command=self.open_add_target_dialog).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame, text="Remove Selected", command=self.remove_selected_target).pack(side=tk.LEFT, padx=5) def _create_sim_controls(self, parent): self._create_labeled_spinbox(parent, "Pulses per CPI:", self.vars["num_pulses_cpi"], 1, 8192) self._create_labeled_spinbox(parent, "Simulation Duration (s):", self.vars["simulation_duration_s"], 0.1, 300.0, increment=0.1) auto_scale_check = ttk.Checkbutton(parent, text="Auto-Scale Amplitude", variable=self.vars["auto_scale"], command=self.toggle_amplitude_controls) auto_scale_check.pack(fill=tk.X, pady=5) self._create_labeled_spinbox(parent, "Min Display Amplitude (dB):", self.vars["min_db"], -200, 100, is_db=True) self._create_labeled_spinbox(parent, "Max Display Amplitude (dB):", self.vars["max_db"], -200, 100, is_db=True) self.generate_button = ttk.Button(parent, text="Start Simulation", command=self.start_simulation) self.generate_button.pack(pady=5) self.stop_button = ttk.Button(parent, text="Stop Simulation", command=self.stop_simulation, state=tk.DISABLED) self.stop_button.pack(pady=5) def _populate_scan_ppi_controls(self, parent): frame = ttk.Frame(parent) frame.pack(fill=tk.X, pady=2) ttk.Label(frame, text="PPI Range (NM):", width=25).pack(side=tk.LEFT) self.ppi_range_combobox = ttk.Combobox(frame, textvariable=self.vars['ppi_range_nm'], state='readonly') self.ppi_range_combobox.pack(side=tk.LEFT, fill=tk.X, expand=True) self.ppi_range_combobox.bind('<>', self.on_ppi_range_change) self._update_ppi_range_options() self.vars["max_range_nm"].trace_add("write", self._update_ppi_range_options) ttk.Label(parent, textvariable=self.vars['scan_info_text'], wraplength=180, justify=tk.LEFT).pack(anchor=tk.W, padx=5, pady=5) def _update_ppi_range_options(self, *args): max_range = self.vars["max_range_nm"].get() steps = list(range(int(max_range), 19, -20)) if 10 not in steps: steps.append(10) steps.sort(reverse=True) self.ppi_range_combobox['values'] = steps if self.vars['ppi_range_nm'].get() not in steps: self.vars['ppi_range_nm'].set(steps[0] if steps else 10) def start_simulation(self): if self.simulation_manager.is_running(): return self.log.info("Starting simulation...") self.generate_button.config(state=tk.DISABLED) self.stop_button.config(state=tk.NORMAL) radar_cfg = self.get_radar_config_from_gui() targets = self.get_targets_from_gui() if not targets: self.log.warning("Simulation start requested but no targets are defined.") messagebox.showwarning("No Targets", "Please add at least one target to simulate.", parent=self) self.stop_simulation() return self.plot_manager.setup_plots_for_simulation( self.vars["num_pulses_cpi"].get(), self.vars["min_db"].get(), self.vars["max_db"].get(), targets, radar_cfg ) self.simulation_manager.start( radar_cfg, targets, self.vars["simulation_duration_s"].get(), self.vars["num_pulses_cpi"].get() ) self.after(100, self._check_simulation_queue) def stop_simulation(self): self.log.info("Stopping simulation...") self.simulation_manager.stop() self.generate_button.config(state=tk.NORMAL) self.stop_button.config(state=tk.DISABLED) self.plot_manager.clear_rd_plot() self.analysis_text.config(state=tk.NORMAL) self.analysis_text.delete(1.0, tk.END) self.analysis_text.insert(tk.END, "Simulation stopped or not started.") self.analysis_text.config(state=tk.DISABLED) def _check_simulation_queue(self): try: frame_data = self.simulation_manager.data_queue.get_nowait() if frame_data is None: self.log.info("Simulation thread finished.") self.stop_simulation() return radar_cfg = self.get_radar_config_from_gui() self.plot_manager.update_plots( frame_data, radar_cfg, self.vars["auto_scale"].get(), self.vars["min_db"].get(), self.vars["max_db"].get() ) self._update_analysis_text(radar_cfg, self.get_targets_from_gui(), frame_data[0], frame_data[1]) except queue.Empty: pass finally: if self.simulation_manager.is_running(): self.after(50, self._check_simulation_queue) def on_ppi_range_change(self, *args): if self.plot_manager: self.plot_manager.update_ppi_range() def add_target_to_table(self, data_si): """ Adds a target to the table, converting SI data to display units. Args: data_si (dict): Target data in SI units (meters, m/s). """ # --- MODIFICA: Conversione da SI a unità di visualizzazione --- values_to_display = [ f"{radar_math.meters_to_nm(data_si['pos_x']):.2f}", f"{radar_math.meters_to_nm(data_si['pos_y']):.2f}", f"{radar_math.meters_to_nm(data_si['pos_z']):.2f}", f"{radar_math.mps_to_knots(data_si['vel_x']):.2f}", f"{radar_math.mps_to_knots(data_si['vel_y']):.2f}", f"{radar_math.mps_to_knots(data_si['vel_z']):.2f}", f"{data_si['rcs']:.2f}" ] self.target_table.insert("", tk.END, values=values_to_display) self.check_target_warnings() if self.plot_manager: self.plot_manager.redraw_ppi_targets(self.get_targets_from_gui()) def remove_selected_target(self): selected_items = self.target_table.selection() for i in selected_items: self.target_table.delete(i) self.check_target_warnings() if self.plot_manager: self.plot_manager.redraw_ppi_targets(self.get_targets_from_gui()) def get_radar_config_from_gui(self) -> RadarConfig: antenna_cfg = AntennaConfig(beamwidth_az_deg=self.vars["beamwidth_az_deg"].get(), beamwidth_el_deg=self.vars["beamwidth_el_deg"].get()) scan_cfg = ScanConfig(mode=self.vars["scan_mode"].get(), min_az_deg=self.vars["min_az_deg"].get(), max_az_deg=self.vars["max_az_deg"].get(), scan_speed_deg_s=self.vars["scan_speed_deg_s"].get()) return RadarConfig(carrier_frequency=self.vars["carrier_frequency"].get(), prf=self.vars["prf"].get(), duty_cycle=self.vars["duty_cycle"].get(), sample_rate=self.vars["sample_rate"].get(), antenna_config=antenna_cfg, scan_config=scan_cfg) def get_targets_from_gui(self) -> list[Target]: """ Extracts target data from the Treeview, converts it from display units back to SI, and returns a list of Target objects. """ targets = [] for item in self.target_table.get_children(): values_display_units = self.target_table.item(item)['values'] try: # --- MODIFICA: Conversione da unità di visualizzazione a SI --- pos = np.array([ radar_math.nm_to_meters(float(values_display_units[0])), radar_math.nm_to_meters(float(values_display_units[1])), radar_math.nm_to_meters(float(values_display_units[2])) ]) vel = np.array([ radar_math.knots_to_mps(float(values_display_units[3])), radar_math.knots_to_mps(float(values_display_units[4])), radar_math.knots_to_mps(float(values_display_units[5])) ]) rcs = float(values_display_units[6]) targets.append(Target(initial_position=pos, velocity=vel, rcs=rcs)) except (ValueError, IndexError) as e: self.log.error(f"Skipping invalid target data from table: {values_display_units}. Error: {e}") messagebox.showwarning("Invalid Data", f"Skipping invalid target data: {values_display_units}. Error: {e}", parent=self) return targets def _update_analysis_text(self, radar_cfg, targets, current_az_deg, iq_data_cpi): report = [] max_range_m = radar_math.calculate_max_unambiguous_range(radar_cfg.prf) max_vel_mps = radar_math.calculate_max_unambiguous_velocity(radar_cfg.carrier_frequency, radar_cfg.prf) report.append("--- Radar Configuration Analysis ---") report.append(f"Max Unambiguous Range: {radar_math.meters_to_nm(max_range_m):,.1f} NM ({max_range_m:,.0f} m)") report.append(f"Max Unambiguous Velocity: +/-{radar_math.mps_to_knots(max_vel_mps):,.1f} knots ({max_vel_mps:,.1f} m/s)") report.append(f"Current Antenna Azimuth: {current_az_deg:,.1f} deg") if radar_cfg.scan_config.mode != 'staring' and radar_cfg.scan_config.scan_speed_deg_s > 0: dwell_time = radar_math.calculate_dwell_time(radar_cfg.antenna_config.beamwidth_az_deg, radar_cfg.scan_config.scan_speed_deg_s) pulses_on_target = radar_math.calculate_pulses_on_target(dwell_time, radar_cfg.prf) report.append(f"Calculated Dwell Time: {dwell_time * 1e3:,.2f} ms") report.append(f"Calculated Pulses on Target: {pulses_on_target:,}") if pulses_on_target < self.vars["num_pulses_cpi"].get(): report.append("WARNING: Pulses per CPI > Pulses on Target") report.append("\n--- Target Analysis ---") if not targets: report.append("No targets defined.") else: for i, target in enumerate(targets): report.append(f"\nTarget {i+1} (RCS={target.rcs:.1f} m^2) - Analysis based on initial state.") self.analysis_text.config(state=tk.NORMAL) self.analysis_text.delete(1.0, tk.END) self.analysis_text.insert(tk.END, "\n".join(report)) self.analysis_text.config(state=tk.DISABLED) self.analysis_text.see(tk.END) def update_derived_parameters(self, *args): try: prf = self.vars["prf"].get() carrier_freq = self.vars["carrier_frequency"].get() duty_cycle = self.vars["duty_cycle"].get() beamwidth = self.vars["beamwidth_az_deg"].get() scan_mode = self.vars["scan_mode"].get() scan_speed = self.vars["scan_speed_deg_s"].get() if prf > 0: pri = 1.0 / prf pulse_width = pri * (duty_cycle / 100.0) self.vars["pulse_width_text"].set(f"Pulse Width: {pulse_width * 1e6:,.2f} µs") self.vars["listening_time_text"].set(f"Listening Window: {pri * 1e6:,.2f} µs (Max Range Time)") else: self.vars["pulse_width_text"].set("Pulse Width: N/A (PRF is zero)") self.vars["listening_time_text"].set("Listening Window: N/A (PRF is zero)") max_range_m = radar_math.calculate_max_unambiguous_range(prf) if np.isinf(max_range_m): self.vars["max_range_text"].set("Max Unambiguous Range: Infinite (PRF is zero)") else: max_range_nm = radar_math.meters_to_nm(max_range_m) self.vars["max_range_text"].set(f"Max Unambiguous Range: {max_range_nm:,.1f} NM ({max_range_m:,.0f} m)") max_vel_mps = radar_math.calculate_max_unambiguous_velocity(carrier_freq, prf) if np.isinf(max_vel_mps): self.vars["max_velocity_text"].set("Max Unambiguous Velocity: Infinite (carrier freq is zero)") else: max_vel_knots = radar_math.mps_to_knots(max_vel_mps) self.vars["max_velocity_text"].set(f"Max Unambiguous Velocity: +/-{max_vel_knots:,.1f} knots ({max_vel_mps:,.1f} m/s)") if scan_mode == 'staring' or scan_speed <= 0: self.vars["dwell_time_text"].set("Dwell Time: N/A (Staring)") self.vars["pulses_on_target_text"].set("Pulses on Target: N/A (Staring)") else: dwell_time = radar_math.calculate_dwell_time(beamwidth, scan_speed) pulses_on_target = radar_math.calculate_pulses_on_target(dwell_time, prf) if np.isinf(dwell_time): self.vars["dwell_time_text"].set("Dwell Time: Infinite (Scan Speed is zero)") self.vars["pulses_on_target_text"].set("Pulses on Target: Infinite") else: self.vars["dwell_time_text"].set(f"Dwell Time: {dwell_time * 1e3:,.2f} ms") self.vars["pulses_on_target_text"].set(f"Pulses on Target: {pulses_on_target:,}") self.check_target_warnings() self._update_scan_info_text() # Ensure plot_manager exists before trying to update plots, as this can be called during init if hasattr(self, 'plot_manager') and self.plot_manager: self.plot_manager.update_sector_lines(self.get_radar_config_from_gui()) except (tk.TclError, ValueError): # This can happen when a user is typing in a spinbox and the value is # temporarily invalid (e.g., empty or just '-'). Ignore and wait for a valid value. pass def _on_max_range_config_change(self, *args): self._update_ppi_range_options() new_max_range = self.vars["max_range_nm"].get() self.vars["ppi_range_nm"].set(new_max_range) def _update_scan_info_text(self): mode = self.vars['scan_mode'].get() if mode == 'staring': self.vars['scan_info_text'].set("Mode: Staring") else: min_az, max_az, speed = self.vars['min_az_deg'].get(), self.vars['max_az_deg'].get(), self.vars['scan_speed_deg_s'].get() self.vars['scan_info_text'].set(f"Mode: Sector Scan\nAz: [{min_az}° , {max_az}°] @ {speed}°/s") def check_target_warnings(self): if not hasattr(self, 'target_table'): return try: prf, carrier_freq = self.vars["prf"].get(), self.vars["carrier_frequency"].get() max_range_m = radar_math.calculate_max_unambiguous_range(prf) max_vel_mps = radar_math.calculate_max_unambiguous_velocity(carrier_freq, prf) range_warning, vel_warning = False, False for item in self.target_table.get_children(): values = [float(v) for v in self.target_table.item(item)['values']] target_initial_range_m = np.linalg.norm(values[0:3]) if target_initial_range_m > 0: target_radial_vel_mps = np.dot(values[3:6], values[0:3] / target_initial_range_m) else: target_radial_vel_mps = 0 if not np.isinf(max_range_m) and target_initial_range_m > max_range_m: range_warning = True if not np.isinf(max_vel_mps) and abs(target_radial_vel_mps) > max_vel_mps: vel_warning = True self.max_range_label.config(foreground='orange' if range_warning else 'black') self.max_velocity_label.config(foreground='orange' if vel_warning else 'black') except (tk.TclError, ValueError, ZeroDivisionError): if hasattr(self, 'max_range_label'): self.max_range_label.config(foreground='black') if hasattr(self, 'max_velocity_label'): self.max_velocity_label.config(foreground='black') def open_add_target_dialog(self): dialog = AddTargetDialog(self) result = dialog.show() if result: self.add_target_to_table(result) def on_target_double_click(self, event): """Handles double-click on target table to edit a target.""" selected_item = self.target_table.selection() if not selected_item: return item_data_display_units = self.target_table.item(selected_item[0])['values'] # --- MODIFICA: Conversione da unità di visualizzazione a SI per il dialogo --- try: target_data_si = { "pos_x": radar_math.nm_to_meters(float(item_data_display_units[0])), "pos_y": radar_math.nm_to_meters(float(item_data_display_units[1])), "pos_z": radar_math.nm_to_meters(float(item_data_display_units[2])), "vel_x": radar_math.knots_to_mps(float(item_data_display_units[3])), "vel_y": radar_math.knots_to_mps(float(item_data_display_units[4])), "vel_z": radar_math.knots_to_mps(float(item_data_display_units[5])), "rcs": float(item_data_display_units[6]) } except (ValueError, IndexError): self.log.error("Could not parse target data from table for editing.") return dialog = AddTargetDialog(self, target_data_si=target_data_si) result_si = dialog.show() if result_si: # Result is already in SI. Convert to display units to update the table row. values_to_display = [ f"{radar_math.meters_to_nm(result_si['pos_x']):.2f}", f"{radar_math.meters_to_nm(result_si['pos_y']):.2f}", f"{radar_math.meters_to_nm(result_si['pos_z']):.2f}", f"{radar_math.mps_to_knots(result_si['vel_x']):.2f}", f"{radar_math.mps_to_knots(result_si['vel_y']):.2f}", f"{radar_math.mps_to_knots(result_si['vel_z']):.2f}", f"{result_si['rcs']:.2f}" ] self.target_table.item(selected_item[0], values=values_to_display) self.check_target_warnings() if self.plot_manager: self.plot_manager.redraw_ppi_targets(self.get_targets_from_gui()) def on_profile_select(self, event=None): profile_name = self.selected_profile.get() if profile_name in self.profiles: profile_data = self.profiles[profile_name] for key, value in profile_data.items(): if key in self.vars: self.vars[key].set(value) self.update_scan_mode_controls() messagebox.showinfo("Profile Loaded", f"Profile '{profile_name}' has been loaded.", parent=self) def save_profile(self): profile_name = simpledialog.askstring("Save Profile", "Enter a name for this profile:", parent=self) if not profile_name or not profile_name.strip(): return profile_name = profile_name.strip() if profile_name in self.profiles: if not messagebox.askyesno("Overwrite Profile", f"Profile '{profile_name}' already exists. Overwrite it?", parent=self): return current_config = {key: var.get() for key, var in self.vars.items() if isinstance(var, (tk.DoubleVar, tk.StringVar, tk.IntVar))} self.profiles[profile_name] = current_config if config_manager.save_profiles(self.profiles): self.refresh_profile_list() self.selected_profile.set(profile_name) messagebox.showinfo("Profile Saved", f"Profile '{profile_name}' saved successfully.", parent=self) else: messagebox.showerror("Error", "Could not save profiles to file.", parent=self) def delete_profile(self): profile_name = self.selected_profile.get() if not profile_name: messagebox.showwarning("No Profile Selected", "Please select a profile to delete.", parent=self); return if messagebox.askyesno("Delete Profile", f"Are you sure you want to delete the profile '{profile_name}'?", parent=self): if profile_name in self.profiles: del self.profiles[profile_name] if config_manager.save_profiles(self.profiles): self.refresh_profile_list() messagebox.showinfo("Profile Deleted", f"Profile '{profile_name}' has been deleted.", parent=self) else: messagebox.showerror("Error", "Could not save profiles to file.", parent=self) def refresh_profile_list(self): self.profile_combobox['values'] = sorted(list(self.profiles.keys())) self.selected_profile.set('') def toggle_amplitude_controls(self): state = tk.DISABLED if self.vars["auto_scale"].get() else tk.NORMAL if hasattr(self, 'min_db_spinbox'): self.min_db_spinbox.config(state=state) if hasattr(self, 'max_db_spinbox'): self.max_db_spinbox.config(state=state) def update_scan_mode_controls(self, *args): mode = self.vars["scan_mode"].get() state = tk.NORMAL if mode != 'staring' else tk.DISABLED if mode == 'staring': self.vars["scan_speed_deg_s"].set(0.0001) elif self.vars["scan_speed_deg_s"].get() <= 0.0001: self.vars["scan_speed_deg_s"].set(20.0) if hasattr(self, 'min_az_spinbox'): self.min_az_spinbox.config(state=state) if hasattr(self, 'max_az_spinbox'): self.max_az_spinbox.config(state=state) if hasattr(self, 'scan_speed_spinbox'): self.scan_speed_spinbox.config(state=state) self.update_derived_parameters() def on_scenario_select(self, event=None): """Loads the selected target scenario into the target table, converting for display.""" scenario_name = self.selected_scenario.get() if not scenario_name or scenario_name not in self.scenarios: return self.log.info(f"Loading scenario: '{scenario_name}'") for item in self.target_table.get_children(): self.target_table.delete(item) target_list_si = self.scenarios[scenario_name] for target_data_si in target_list_si: # --- MODIFICA: Conversione da SI a unità di visualizzazione --- values_to_display = [ f"{radar_math.meters_to_nm(target_data_si['initial_position'][0]):.2f}", f"{radar_math.meters_to_nm(target_data_si['initial_position'][1]):.2f}", f"{radar_math.meters_to_nm(target_data_si['initial_position'][2]):.2f}", f"{radar_math.mps_to_knots(target_data_si['velocity'][0]):.2f}", f"{radar_math.mps_to_knots(target_data_si['velocity'][1]):.2f}", f"{radar_math.mps_to_knots(target_data_si['velocity'][2]):.2f}", f"{target_data_si['rcs']:.2f}" ] self.target_table.insert("", tk.END, values=values_to_display) self.check_target_warnings() if self.plot_manager: self.plot_manager.redraw_ppi_targets(self.get_targets_from_gui()) messagebox.showinfo("Scenario Loaded", f"Scenario '{scenario_name}' has been loaded.", parent=self) def save_scenario(self): """Saves the current list of targets as a new scenario.""" scenario_name = simpledialog.askstring("Save Scenario", "Enter a name for this scenario:", parent=self) if not scenario_name or not scenario_name.strip(): return scenario_name = scenario_name.strip() if scenario_name in self.scenarios: if not messagebox.askyesno("Overwrite Scenario", f"Scenario '{scenario_name}' already exists. Overwrite it?", parent=self): return current_targets_data = [] for item in self.target_table.get_children(): values = self.target_table.item(item)['values'] try: float_values = [float(v) for v in values] target_dict = { "initial_position": float_values[0:3], "velocity": float_values[3:6], "rcs": float_values[6] } current_targets_data.append(target_dict) except (ValueError, IndexError): self.log.error(f"Could not parse target data for saving: {values}") continue # Skip invalid rows if not current_targets_data: messagebox.showwarning("No Targets", "Cannot save an empty scenario.", parent=self) return self.scenarios[scenario_name] = current_targets_data if config_manager.save_scenarios(self.scenarios): self.refresh_scenario_list() self.selected_scenario.set(scenario_name) self.log.info(f"Scenario '{scenario_name}' saved successfully.") messagebox.showinfo("Scenario Saved", f"Scenario '{scenario_name}' saved successfully.", parent=self) else: self.log.error("Failed to save scenarios to file.") messagebox.showerror("Error", "Could not save scenarios to file.", parent=self) def delete_scenario(self): """Deletes the selected scenario.""" scenario_name = self.selected_scenario.get() if not scenario_name: messagebox.showwarning("No Scenario Selected", "Please select a scenario to delete.", parent=self) return if messagebox.askyesno("Delete Scenario", f"Are you sure you want to delete the scenario '{scenario_name}'?", parent=self): if scenario_name in self.scenarios: del self.scenarios[scenario_name] if config_manager.save_scenarios(self.scenarios): self.refresh_scenario_list() self.log.info(f"Scenario '{scenario_name}' has been deleted.") messagebox.showinfo("Scenario Deleted", f"Scenario '{scenario_name}' has been deleted.", parent=self) else: self.log.error("Failed to save scenarios to file after deletion.") messagebox.showerror("Error", "Could not save scenarios to file.", parent=self) def refresh_scenario_list(self): """Updates the scenario combobox with current saved scenarios.""" self.scenario_combobox['values'] = sorted(list(self.scenarios.keys())) self.selected_scenario.set('') # Clear selection def start_gui(): """Entry point to launch the Tkinter GUI application.""" app = App() app.mainloop()