SXXXXXXX_ScenarioSimulator/scenario_simulator/gui/gui.py
2025-09-30 15:10:50 +02:00

904 lines
48 KiB
Python

"""
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('<<ComboboxSelected>>', 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('<<ComboboxSelected>>', 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("<Double-1>", 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('<<ComboboxSelected>>', 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()