802 lines
43 KiB
Python
802 lines
43 KiB
Python
"""
|
|
Main GUI module for the Radar Scenario Simulator (Tkinter version).
|
|
|
|
This file contains the implementation of the main application window using Tkinter,
|
|
organized with a Notebook widget for clarity and featuring proactive guidance
|
|
and interactive antenna simulation.
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, simpledialog
|
|
import numpy as np
|
|
from scipy.constants import c
|
|
|
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
|
from matplotlib.figure import Figure
|
|
import matplotlib.animation as animation
|
|
|
|
# Import core simulation engine and utility functions
|
|
from ..core.simulation_engine import RadarConfig, AntennaConfig, ScanConfig, Target, generate_iq_data
|
|
from ..utils import radar_math, config_manager
|
|
|
|
# --- Helper Dialog for Adding/Editing Targets ---
|
|
class AddTargetDialog(tk.Toplevel):
|
|
"""Dialog window for adding or editing target parameters."""
|
|
def __init__(self, parent, target_data=None):
|
|
super().__init__(parent)
|
|
self.title("Add New Target" if target_data is None else "Edit Target")
|
|
self.transient(parent) # Set to be on top of the parent window
|
|
self.grab_set() # Disable events in other windows
|
|
self.result = None
|
|
|
|
frame = ttk.Frame(self, padding="10")
|
|
frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
# Initialize vars with default or existing data
|
|
self.vars = {
|
|
"pos_x": tk.DoubleVar(value=target_data["pos_x"] if target_data else 5000.0),
|
|
"pos_y": tk.DoubleVar(value=target_data["pos_y"] if target_data else 0.0),
|
|
"pos_z": tk.DoubleVar(value=target_data["pos_z"] if target_data else 0.0),
|
|
"vel_x": tk.DoubleVar(value=target_data["vel_x"] if target_data else -150.0),
|
|
"vel_y": tk.DoubleVar(value=target_data["vel_y"] if target_data else 0.0),
|
|
"vel_z": tk.DoubleVar(value=target_data["vel_z"] if target_data else 0.0),
|
|
"rcs": tk.DoubleVar(value=target_data["rcs"] if target_data else 1.0)
|
|
}
|
|
|
|
# Create input fields
|
|
labels = ["Initial Position X (m):", "Initial Position Y (m):", "Initial Position Z (m):",
|
|
"Velocity X (m/s):", "Velocity Y (m/s):", "Velocity Z (m/s):", "RCS (m^2):"]
|
|
keys = ["pos_x", "pos_y", "pos_z", "vel_x", "vel_y", "vel_z", "rcs"]
|
|
|
|
for i, (label_text, key) in enumerate(zip(labels, keys)):
|
|
ttk.Label(frame, text=label_text).grid(row=i, column=0, sticky=tk.W, pady=2)
|
|
spinbox = ttk.Spinbox(frame, from_=-1e6, to=1e6, textvariable=self.vars[key])
|
|
spinbox.grid(row=i, column=1, sticky=(tk.W, tk.E), pady=2)
|
|
if key == "rcs": # RCS should not be negative
|
|
spinbox.config(from_=0.01)
|
|
|
|
# Buttons
|
|
button_frame = ttk.Frame(frame)
|
|
button_frame.grid(row=len(labels), 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)
|
|
|
|
def on_ok(self):
|
|
"""Called when the OK button is pressed."""
|
|
self.result = {key: var.get() for key, var in self.vars.items()}
|
|
self.destroy()
|
|
|
|
def show(self):
|
|
"""Displays the dialog and waits for it to close."""
|
|
self.wait_window()
|
|
return self.result
|
|
|
|
# --- 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")
|
|
|
|
# Start fullscreen
|
|
self.attributes('-fullscreen', True)
|
|
self.bind("<Escape>", lambda event: self.attributes('-fullscreen', False))
|
|
|
|
# --- Paned Window for Layout ---
|
|
paned_window = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
|
|
paned_window.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Left Frame: Controls
|
|
left_frame = ttk.Frame(paned_window, width=550) # Increased width
|
|
paned_window.add(left_frame, weight=1)
|
|
|
|
# Right Frame: Plots
|
|
right_frame = ttk.Frame(paned_window)
|
|
paned_window.add(right_frame, weight=3)
|
|
|
|
# --- Tkinter Variables for UI Elements ---
|
|
self.vars = {
|
|
# Radar Config
|
|
"carrier_frequency": tk.DoubleVar(value=9.5e9),
|
|
"prf": tk.DoubleVar(value=2000.0),
|
|
"duty_cycle": tk.DoubleVar(value=10.0), # in percent
|
|
"sample_rate": tk.DoubleVar(value=5e6),
|
|
|
|
# Antenna Config
|
|
"beamwidth_az_deg": tk.DoubleVar(value=3.0),
|
|
"beamwidth_el_deg": tk.DoubleVar(value=3.0), # For future use
|
|
|
|
# Scan Config
|
|
"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),
|
|
|
|
# Simulation & Plotting Control
|
|
"num_pulses_cpi": tk.IntVar(value=256), # Number of pulses per Coherent Processing Interval
|
|
"simulation_duration_s": tk.DoubleVar(value=1.0), # Total duration of the simulation in seconds
|
|
"min_db": tk.DoubleVar(value=-60.0),
|
|
"max_db": tk.DoubleVar(value=0.0),
|
|
"auto_scale": tk.BooleanVar(value=True),
|
|
|
|
# Derived Parameters
|
|
"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()
|
|
}
|
|
|
|
# Load profiles and set up combobox
|
|
self.profiles = config_manager.load_profiles()
|
|
self.selected_profile = tk.StringVar()
|
|
|
|
# --- Traces for Real-time Parameter Updates ---
|
|
# Any change in these variables triggers an update of derived parameters
|
|
self.vars["prf"].trace_add("write", self.update_derived_parameters)
|
|
self.vars["carrier_frequency"].trace_add("write", self.update_derived_parameters)
|
|
self.vars["duty_cycle"].trace_add("write", self.update_derived_parameters)
|
|
self.vars["beamwidth_az_deg"].trace_add("write", self.update_derived_parameters)
|
|
self.vars["scan_mode"].trace_add("write", self.update_derived_parameters)
|
|
self.vars["scan_speed_deg_s"].trace_add("write", self.update_derived_parameters)
|
|
|
|
# --- Notebook (Tabs) for Left Frame ---
|
|
notebook = ttk.Notebook(left_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") # Renamed tab
|
|
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)
|
|
|
|
# --- Plotting Area for Right Frame ---
|
|
self.figure = Figure(figsize=(10, 8), dpi=100) # Larger figure
|
|
self.figure.set_facecolor('#3a3a3a') # Dark background for plots
|
|
self.canvas = FigureCanvasTkAgg(self.figure, master=right_frame)
|
|
self.canvas_widget = self.canvas.get_tk_widget()
|
|
self.canvas_widget.pack(fill=tk.BOTH, expand=True)
|
|
|
|
self.ani = None # For animation object
|
|
self.current_simulation_generator = None
|
|
self.targets_in_simulation = [] # Actual Target objects for simulation
|
|
|
|
self.toggle_amplitude_controls() # Set initial state of amplitude controls
|
|
self.update_derived_parameters() # Initial call to update all calculated values
|
|
|
|
# --- Widget Creation Helper ---
|
|
def _create_labeled_spinbox(self, parent, text, var, from_, to, increment=1.0, is_db=False, scientific=False, command=None):
|
|
"""Helper to create a labeled Spinbox."""
|
|
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
|
|
|
|
# --- Tab Population Methods ---
|
|
def _populate_config_tab(self, tab):
|
|
# Profile Management
|
|
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 Configuration
|
|
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)
|
|
|
|
# Antenna Configuration
|
|
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)
|
|
# self._create_labeled_spinbox(antenna_group, "Elevation Beamwidth (deg):", self.vars["beamwidth_el_deg"], 0.1, 90.0, increment=0.1) # Future use
|
|
|
|
# Scan Strategy
|
|
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)
|
|
# ttk.Radiobutton(mode_frame, text="Circular", variable=self.vars["scan_mode"], value="circular", command=self.update_scan_mode_controls).pack(side=tk.LEFT) # Future use
|
|
|
|
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() # Set initial state of scan controls
|
|
|
|
# Derived & Calculated Values
|
|
derived_group = ttk.LabelFrame(tab, text="Derived & Calculated Values", padding=10)
|
|
derived_group.pack(fill=tk.X, padx=5, pady=5)
|
|
ttk.Label(derived_group, textvariable=self.vars["pulse_width_text"]).pack(anchor=tk.W)
|
|
ttk.Label(derived_group, textvariable=self.vars["listening_time_text"]).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)
|
|
ttk.Label(derived_group, textvariable=self.vars["dwell_time_text"]).pack(anchor=tk.W)
|
|
ttk.Label(derived_group, textvariable=self.vars["pulses_on_target_text"]).pack(anchor=tk.W)
|
|
|
|
def _populate_target_tab(self, tab):
|
|
target_group = ttk.LabelFrame(tab, text="Target Management", 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)
|
|
self.analysis_text_scroll = ttk.Scrollbar(self.analysis_frame, command=self.analysis_text.yview)
|
|
self.analysis_text_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
|
self.analysis_text.config(yscrollcommand=self.analysis_text_scroll.set)
|
|
|
|
def _create_target_table(self, parent):
|
|
frame = ttk.Frame(parent)
|
|
frame.pack(fill=tk.BOTH, expand=True)
|
|
cols = ("Pos X", "Pos Y", "Pos Z", "Vel X", "Vel Y", "Vel Z", "RCS")
|
|
self.target_table = ttk.Treeview(frame, columns=cols, show="headings")
|
|
self.target_table.column("#0", width=0, stretch=tk.NO) # Hide default first column
|
|
for col in cols:
|
|
self.target_table.heading(col, text=col)
|
|
self.target_table.column(col, width=60, anchor=tk.CENTER) # Smaller width
|
|
|
|
self.target_table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
# Add scrollbar
|
|
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)
|
|
|
|
# Bind double-click for editing
|
|
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_animation)
|
|
self.generate_button.pack(pady=5)
|
|
self.stop_button = ttk.Button(parent, text="Stop Simulation", command=self.stop_simulation_animation, state=tk.DISABLED)
|
|
self.stop_button.pack(pady=5)
|
|
|
|
# --- UI Logic / Callbacks ---
|
|
def toggle_amplitude_controls(self):
|
|
"""Enables/disables min/max amplitude spinboxes based on auto-scale checkbox."""
|
|
state = tk.DISABLED if self.vars["auto_scale"].get() else tk.NORMAL
|
|
self.min_db_spinbox.config(state=state)
|
|
self.max_db_spinbox.config(state=state)
|
|
|
|
def update_scan_mode_controls(self, *args):
|
|
"""Enables/disables scan mode controls based on selected mode."""
|
|
mode = self.vars["scan_mode"].get()
|
|
if mode == 'staring':
|
|
state = tk.DISABLED
|
|
self.vars["scan_speed_deg_s"].set(0.0001) # Set to a very small non-zero to avoid division by zero, but functionally 'staring'
|
|
else: # sector, circular
|
|
state = tk.NORMAL
|
|
if self.vars["scan_speed_deg_s"].get() <= 0.0001:
|
|
self.vars["scan_speed_deg_s"].set(20.0) # Reset to default if it was 0
|
|
self.min_az_spinbox.config(state=state)
|
|
self.max_az_spinbox.config(state=state)
|
|
self.scan_speed_spinbox.config(state=state)
|
|
self.update_derived_parameters() # Recalculate based on new scan mode
|
|
|
|
def update_derived_parameters(self, *args):
|
|
"""Calculates and displays derived radar parameters in real-time."""
|
|
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()
|
|
|
|
# Pulse and Listening Time
|
|
pri = 1.0 / prf
|
|
pulse_width = pri * (duty_cycle / 100.0)
|
|
listening_time = pri # Total time to listen for echoes (full PRI)
|
|
self.vars["pulse_width_text"].set(f"Pulse Width: {pulse_width * 1e6:,.2f} µs")
|
|
self.vars["listening_time_text"].set(f"Listening Window: {listening_time * 1e6:,.2f} µs (Max Range Time)")
|
|
|
|
# Max Unambiguous Range & Velocity
|
|
max_range = radar_math.calculate_max_unambiguous_range(prf)
|
|
max_vel = radar_math.calculate_max_unambiguous_velocity(carrier_freq, prf)
|
|
self.vars["max_range_text"].set(f"Max Unambiguous Range: {max_range:,.0f} m")
|
|
self.vars["max_velocity_text"].set(f"Max Unambiguous Velocity: \u00b1{max_vel:,.1f} m/s")
|
|
|
|
# Dwell Time & Pulses on Target (for scanning modes)
|
|
if scan_mode == 'staring':
|
|
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)
|
|
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()
|
|
except (tk.TclError, ValueError, ZeroDivisionError):
|
|
# Ignore errors during startup or when entry is invalid
|
|
self.vars["pulse_width_text"].set("Pulse Width: N/A")
|
|
self.vars["listening_time_text"].set("Listening Window: N/A")
|
|
self.vars["max_range_text"].set("Max Unambiguous Range: N/A")
|
|
self.vars["max_velocity_text"].set("Max Unambiguous Velocity: N/A")
|
|
self.vars["dwell_time_text"].set("Dwell Time: N/A")
|
|
self.vars["pulses_on_target_text"].set("Pulses on Target: N/A")
|
|
pass
|
|
|
|
def check_target_warnings(self):
|
|
"""Highlights derived parameters if targets violate unambiguous limits."""
|
|
# Guard against calls before UI is fully initialized
|
|
if not hasattr(self, 'target_table') or not hasattr(self, 'max_range_label'):
|
|
return
|
|
try:
|
|
prf = self.vars["prf"].get()
|
|
carrier_freq = self.vars["carrier_frequency"].get()
|
|
max_range = radar_math.calculate_max_unambiguous_range(prf)
|
|
max_vel = 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 = np.linalg.norm(values[0:3])
|
|
target_radial_vel = np.dot(values[3:6], values[0:3] / target_initial_range) if target_initial_range > 0 else 0
|
|
|
|
if target_initial_range > max_range: range_warning = True
|
|
if abs(target_radial_vel) > max_vel: 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):
|
|
# On error (e.g., invalid data in entry), reset colors if labels exist
|
|
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')
|
|
pass
|
|
|
|
def open_add_target_dialog(self):
|
|
"""Opens dialog to add a new target."""
|
|
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 selected_item:
|
|
item_data = self.target_table.item(selected_item[0])['values']
|
|
target_data = {
|
|
"pos_x": float(item_data[0]), "pos_y": float(item_data[1]), "pos_z": float(item_data[2]),
|
|
"vel_x": float(item_data[3]), "vel_y": float(item_data[4]), "vel_z": float(item_data[5]),
|
|
"rcs": float(item_data[6])
|
|
}
|
|
dialog = AddTargetDialog(self, target_data=target_data)
|
|
result = dialog.show()
|
|
if result:
|
|
# Update the existing item
|
|
self.target_table.item(selected_item[0], values=[f"{v:.2f}" for v in result.values()])
|
|
self.check_target_warnings()
|
|
|
|
def add_target_to_table(self, data):
|
|
"""Adds a target to the Treeview table."""
|
|
self.target_table.insert("", tk.END, values=[f"{v:.2f}" for v in data.values()])
|
|
self.check_target_warnings()
|
|
|
|
def remove_selected_target(self):
|
|
"""Removes selected targets from the Treeview table."""
|
|
for i in self.target_table.selection(): self.target_table.delete(i)
|
|
self.check_target_warnings()
|
|
|
|
# --- Profile Management ---
|
|
def on_profile_select(self, event=None):
|
|
"""Loads selected profile parameters into the GUI."""
|
|
profile_name = self.selected_profile.get()
|
|
if profile_name in self.profiles:
|
|
profile_data = self.profiles[profile_name]
|
|
# Radar Config
|
|
self.vars["carrier_frequency"].set(profile_data.get("carrier_frequency", 9.5e9))
|
|
self.vars["prf"].set(profile_data.get("prf", 2000.0))
|
|
self.vars["duty_cycle"].set(profile_data.get("duty_cycle", 10.0))
|
|
self.vars["sample_rate"].set(profile_data.get("sample_rate", 5e6))
|
|
# Antenna Config
|
|
self.vars["beamwidth_az_deg"].set(profile_data.get("beamwidth_az_deg", 3.0))
|
|
self.vars["beamwidth_el_deg"].set(profile_data.get("beamwidth_el_deg", 3.0))
|
|
# Scan Config
|
|
self.vars["scan_mode"].set(profile_data.get("scan_mode", 'staring'))
|
|
self.vars["min_az_deg"].set(profile_data.get("min_az_deg", -30.0))
|
|
self.vars["max_az_deg"].set(profile_data.get("max_az_deg", 30.0))
|
|
self.vars["scan_speed_deg_s"].set(profile_data.get("scan_speed_deg_s", 20.0))
|
|
|
|
self.update_scan_mode_controls() # Ensure correct state for scan controls
|
|
messagebox.showinfo("Profile Loaded", f"Profile '{profile_name}' has been loaded.", parent=self)
|
|
|
|
def save_profile(self):
|
|
"""Saves current radar configuration as a new profile."""
|
|
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 = {
|
|
"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(),
|
|
"beamwidth_az_deg": self.vars["beamwidth_az_deg"].get(),
|
|
"beamwidth_el_deg": self.vars["beamwidth_el_deg"].get(),
|
|
"scan_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()
|
|
}
|
|
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):
|
|
"""Deletes the selected radar profile."""
|
|
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):
|
|
"""Updates the profile combobox with current saved profiles."""
|
|
self.profile_combobox['values'] = sorted(list(self.profiles.keys()))
|
|
self.selected_profile.set('') # Clear selection
|
|
|
|
# --- Simulation and Plotting ---
|
|
def get_radar_config_from_gui(self) -> RadarConfig:
|
|
"""Constructs a RadarConfig object from current GUI values."""
|
|
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 and returns a list of Target objects."""
|
|
targets = []
|
|
for item in self.target_table.get_children():
|
|
values = self.target_table.item(item)['values']
|
|
try:
|
|
float_values = [float(v) for v in values]
|
|
pos = np.array(float_values[0:3])
|
|
vel = np.array(float_values[3:6])
|
|
rcs = float_values[6]
|
|
targets.append(Target(initial_position=pos, velocity=vel, rcs=rcs))
|
|
except (ValueError, IndexError) as e:
|
|
messagebox.showwarning("Invalid Data", f"Skipping invalid target data: {values}. Error: {e}", parent=self)
|
|
return targets
|
|
|
|
def _simulation_generator(self, radar_cfg: RadarConfig, targets: list[Target], total_duration_s: float):
|
|
"""
|
|
Generator function to simulate the radar scan frame by frame (CPI by CPI).
|
|
Yields (current_az_deg, iq_data_cpi, num_frame).
|
|
"""
|
|
prf = radar_cfg.prf
|
|
pri = 1.0 / prf
|
|
num_pulses_cpi = self.vars["num_pulses_cpi"].get()
|
|
|
|
current_az_deg = radar_cfg.scan_config.min_az_deg if radar_cfg.scan_config.mode == 'sector' else 0.0
|
|
current_time_s = 0.0
|
|
frame_num = 0
|
|
|
|
while current_time_s < total_duration_s:
|
|
# Determine antenna pointing for this CPI
|
|
if radar_cfg.scan_config.mode == 'staring':
|
|
pass # current_az_deg remains 0.0 or initial_min_az_deg
|
|
elif radar_cfg.scan_config.mode == 'sector':
|
|
# Simple sweep back and forth
|
|
scan_range = radar_cfg.scan_config.max_az_deg - radar_cfg.scan_config.min_az_deg
|
|
if scan_range <= 0: # Handle invalid range
|
|
current_az_deg = radar_cfg.scan_config.min_az_deg
|
|
else:
|
|
cycle_time = scan_range / radar_cfg.scan_config.scan_speed_deg_s
|
|
# One full sweep (forward and back) takes 2*cycle_time
|
|
time_in_cycle = current_time_s % (2 * cycle_time)
|
|
if time_in_cycle < cycle_time:
|
|
# Sweeping forward
|
|
current_az_deg = radar_cfg.scan_config.min_az_deg + (time_in_cycle * radar_cfg.scan_config.scan_speed_deg_s)
|
|
else:
|
|
# Sweeping backward
|
|
current_az_deg = radar_cfg.scan_config.max_az_deg - ((time_in_cycle - cycle_time) * radar_cfg.scan_config.scan_speed_deg_s)
|
|
# Add 'circular' mode later if needed
|
|
|
|
# Generate IQ data for this CPI at the current antenna angle
|
|
iq_data_cpi = generate_iq_data(radar_cfg, targets, num_pulses_cpi, current_az_deg)
|
|
yield current_az_deg, iq_data_cpi, frame_num
|
|
|
|
current_time_s += num_pulses_cpi * pri
|
|
frame_num += 1
|
|
|
|
def start_simulation_animation(self):
|
|
"""Starts the animated simulation."""
|
|
self.stop_simulation_animation() # Stop any running animation first
|
|
self.generate_button.config(state=tk.DISABLED)
|
|
self.stop_button.config(state=tk.NORMAL)
|
|
self.update_idletasks() # Update GUI immediately
|
|
|
|
radar_cfg = self.get_radar_config_from_gui()
|
|
self.targets_in_simulation = self.get_targets_from_gui()
|
|
total_duration_s = self.vars["simulation_duration_s"].get()
|
|
|
|
if not self.targets_in_simulation:
|
|
messagebox.showwarning("No Targets", "Please add at least one target to simulate.", parent=self)
|
|
self.generate_button.config(state=tk.NORMAL)
|
|
self.stop_button.config(state=tk.DISABLED)
|
|
return
|
|
|
|
# Calculate maximum range for PPI plot based on max target range or unambiguous range
|
|
max_target_range = 0
|
|
if self.targets_in_simulation: max_target_range = max(np.linalg.norm(t.initial_position) for t in self.targets_in_simulation)
|
|
|
|
# Take the larger of the unambiguous range and the max target range, plus some margin
|
|
max_plot_range = max(radar_math.calculate_max_unambiguous_range(radar_cfg.prf), max_target_range) * 1.2
|
|
|
|
# Setup initial plot for animation
|
|
self.figure.clear()
|
|
|
|
# Plot 1: Range-Doppler Map
|
|
self.ax_rd = self.figure.add_subplot(121) # 1 row, 2 cols, 1st plot
|
|
self.ax_rd.set_title('Range-Doppler Map')
|
|
self.ax_rd.set_xlabel('Range (m)')
|
|
self.ax_rd.set_ylabel('Velocity (m/s)')
|
|
# Initialize RD map with dummy data (will be updated)
|
|
self.im_rd = self.ax_rd.imshow(np.zeros((self.vars["num_pulses_cpi"].get(), 100)), aspect='auto', cmap='jet', vmin=self.vars["min_db"].get(), vmax=self.vars["max_db"].get())
|
|
self.cbar_rd = self.figure.colorbar(self.im_rd, ax=self.ax_rd, label='Amplitude (dB)')
|
|
|
|
# Plot 2: PPI (Plan Position Indicator)
|
|
self.ax_ppi = self.figure.add_subplot(122, polar=True) # 1 row, 2 cols, 2nd plot, polar projection
|
|
self.ax_ppi.set_title('Antenna Scan & Targets (PPI)')
|
|
self.ax_ppi.set_theta_zero_location("N") # North at top
|
|
self.ax_ppi.set_theta_direction(-1) # Clockwise rotation
|
|
self.ax_ppi.set_ylim(0, max_plot_range) # Set range limit
|
|
self.ax_ppi.set_rlabel_position(-22.5) # Move radial labels away from spokes
|
|
self.ax_ppi.tick_params(axis='y', colors='lightgray') # Gray out range rings for clarity
|
|
|
|
# Plot targets on PPI
|
|
self.ppi_targets_plot = []
|
|
for target in self.targets_in_simulation:
|
|
x, y, _ = target.initial_position
|
|
r = np.linalg.norm([x, y])
|
|
theta = np.arctan2(y, x)
|
|
target_plot, = self.ax_ppi.plot(theta, r, 'o', color='red', markersize=8, label=f"Target (RCS={target.rcs})")
|
|
self.ppi_targets_plot.append(target_plot)
|
|
|
|
# Plot antenna beam on PPI
|
|
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.figure.tight_layout()
|
|
self.canvas.draw()
|
|
|
|
# Initialize the generator
|
|
self.current_simulation_generator = self._simulation_generator(radar_cfg, self.targets_in_simulation, total_duration_s)
|
|
|
|
# Start animation
|
|
self.ani = animation.FuncAnimation(
|
|
self.figure,
|
|
self._update_plots,
|
|
frames=self.current_simulation_generator, # Pass the generator as frames
|
|
interval=100, # ms between frames (adjust for desired speed)
|
|
blit=False,
|
|
repeat=False, # Don't repeat the animation
|
|
cache_frame_data=False # Important for generators
|
|
)
|
|
self.canvas.draw()
|
|
|
|
def _update_plots(self, frame_data):
|
|
"""
|
|
Update function for matplotlib animation.
|
|
Receives frame_data (current_az_deg, iq_data_cpi, frame_num) from the generator.
|
|
"""
|
|
current_az_deg, iq_data_cpi, frame_num = frame_data
|
|
|
|
radar_cfg = self.get_radar_config_from_gui() # Re-fetch config to get latest values (e.g., sample_rate)
|
|
|
|
# --- Update Range-Doppler Map ---
|
|
if iq_data_cpi.size == 0:
|
|
self.im_rd.set_data(np.zeros((self.vars["num_pulses_cpi"].get(), 100))) # Update with empty data
|
|
self.ax_rd.set_title(f'Range-Doppler Map (Frame {frame_num}) - No Data')
|
|
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)
|
|
|
|
# Auto-scaling or fixed limits
|
|
vmin, vmax = None, None
|
|
if self.vars["auto_scale"].get():
|
|
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)
|
|
self.vars["min_db"].set(round(vmin, 2))
|
|
self.vars["max_db"].set(round(vmax, 2))
|
|
else:
|
|
vmin, vmax = -100, 0
|
|
else:
|
|
vmin, vmax = self.vars["min_db"].get(), self.vars["max_db"].get()
|
|
|
|
self.im_rd.set_data(range_doppler_map_db)
|
|
self.im_rd.set_clim(vmin=vmin, vmax=vmax) # Update color limits
|
|
|
|
# Update extents for axes if necessary (e.g., if sample_rate changed mid-sim)
|
|
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'Range-Doppler Map (Frame {frame_num})')
|
|
|
|
# --- Update PPI Plot ---
|
|
current_az_rad = np.deg2rad(current_az_deg)
|
|
beamwidth_rad = np.deg2rad(radar_cfg.antenna_config.beamwidth_az_deg)
|
|
|
|
# Update beam patch
|
|
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] # Get current max range of PPI plot
|
|
self.beam_patch.remove() # Remove old patch
|
|
self.beam_patch = self.ax_ppi.fill_between(theta_beam, 0, max_plot_range, color='cyan', alpha=0.2, linewidth=0)
|
|
|
|
# Update beam centerline
|
|
self.beam_line.set_xdata([current_az_rad, current_az_rad])
|
|
|
|
# --- Update Analysis Text ---
|
|
self._update_analysis_text(radar_cfg, self.targets_in_simulation, current_az_deg, iq_data_cpi)
|
|
|
|
return self.im_rd, self.beam_patch, self.beam_line # Return artists that were modified
|
|
|
|
def _update_analysis_text(self, radar_cfg: RadarConfig, targets: list[Target], current_az_deg: float, iq_data_cpi: np.ndarray):
|
|
"""Generates and updates the simulation analysis text."""
|
|
report = []
|
|
|
|
# Radar Limits
|
|
max_range = radar_math.calculate_max_unambiguous_range(radar_cfg.prf)
|
|
max_vel = radar_math.calculate_max_unambiguous_velocity(radar_cfg.carrier_frequency, radar_cfg.prf)
|
|
|
|
report.append(f"--- Radar Configuration Analysis ---")
|
|
report.append(f"Max Unambiguous Range: {max_range:,.0f} m")
|
|
report.append(f"Max Unambiguous Velocity: \u00b1{max_vel:,.1f} m/s")
|
|
report.append(f"Current Antenna Azimuth: {current_az_deg:,.1f} deg")
|
|
|
|
# Dwell Time & Pulses on Target
|
|
if radar_cfg.scan_config.mode == 'staring' or radar_cfg.scan_config.scan_speed_deg_s <= 0:
|
|
report.append("Dwell Time: N/A (Staring Mode)")
|
|
report.append("Pulses on Target: N/A (Staring Mode)")
|
|
else:
|
|
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 (Potential Doppler Resolution Loss!)")
|
|
|
|
|
|
report.append(f"\n--- Target Analysis (relative to current antenna pointing) ---")
|
|
if not targets:
|
|
report.append("No targets defined.")
|
|
else:
|
|
for i, target in enumerate(targets):
|
|
x, y, z = target.initial_position
|
|
range_to_target = np.linalg.norm(target.initial_position) # Initial range
|
|
target_az_deg = np.rad2deg(np.arctan2(y, x))
|
|
az_error = target_az_deg - current_az_deg
|
|
|
|
report.append(f"\nTarget {i+1} (RCS={target.rcs:.1f} m^2):")
|
|
report.append(f" Initial Range: {range_to_target:,.0f} m")
|
|
report.append(f" Initial Radial Velocity: {np.dot(target.velocity, target.initial_position / range_to_target):.1f} m/s")
|
|
report.append(f" Angle from Boresight (Az): {az_error:,.1f} deg")
|
|
|
|
gain_factor = radar_math.calculate_gaussian_gain(az_error, radar_cfg.antenna_config.beamwidth_az_deg)
|
|
report.append(f" Antenna Gain Factor: {gain_factor:.2f}")
|
|
|
|
if range_to_target > max_range:
|
|
report.append(f" WARNING: Target range {range_to_target:,.0f} m > Max Unambiguous Range {max_range:,.0f} m (Range Ambiguity!)")
|
|
if abs(np.dot(target.velocity, target.initial_position / range_to_target)) > max_vel:
|
|
report.append(f" WARNING: Target radial velocity > Max Unambiguous Velocity {max_vel:,.1f} m/s (Doppler Ambiguity!)")
|
|
if gain_factor < 0.1: # Arbitrary threshold for "weak" detection
|
|
report.append(f" WARNING: Target is far from beam center (Gain Factor {gain_factor:.2f}) - may be weakly detected or missed.")
|
|
|
|
self.analysis_text.config(state=tk.NORMAL) # Enable editing
|
|
self.analysis_text.delete(1.0, tk.END) # Clear previous content
|
|
self.analysis_text.insert(tk.END, "\n".join(report))
|
|
self.analysis_text.config(state=tk.DISABLED) # Disable editing
|
|
self.analysis_text.see(tk.END) # Scroll to bottom
|
|
|
|
def stop_simulation_animation(self):
|
|
"""Stops the currently running simulation animation."""
|
|
if self.ani and self.ani.event_source is not None:
|
|
self.ani.event_source.stop()
|
|
self.ani = None # Set to None regardless
|
|
self.current_simulation_generator = None
|
|
self.generate_button.config(state=tk.NORMAL)
|
|
self.stop_button.config(state=tk.DISABLED)
|
|
# Clear plots after stopping
|
|
self.figure.clear()
|
|
self.canvas.draw()
|
|
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 start_gui():
|
|
"""Entry point to launch the Tkinter GUI application."""
|
|
app = App()
|
|
app.mainloop() |