332 lines
15 KiB
Python
332 lines
15 KiB
Python
# target_simulator/gui/ppi_display.py
|
|
|
|
"""
|
|
A reusable Tkinter widget that displays a Plan Position Indicator (PPI)
|
|
using Matplotlib, capable of showing both live targets and trajectory previews,
|
|
and comparing simulated vs. real-time data.
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
import math
|
|
import numpy as np
|
|
import collections
|
|
from matplotlib.figure import Figure
|
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
|
from typing import List, Dict, Union
|
|
|
|
# Use absolute imports
|
|
from target_simulator.core.models import Target, Waypoint, ManeuverType, NM_TO_FT
|
|
|
|
|
|
class PPIDisplay(ttk.Frame):
|
|
"""
|
|
A custom widget for the PPI radar display, capable of showing simulated
|
|
data, real-time feedback, and trajectory previews.
|
|
"""
|
|
TRAIL_LENGTH = 100 # Number of historical points to show in a target's trail
|
|
|
|
def __init__(self, master, max_range_nm: int = 100, scan_limit_deg: int = 60, trail_length: int = None):
|
|
super().__init__(master)
|
|
self.max_range = max_range_nm
|
|
self.scan_limit_deg = scan_limit_deg
|
|
|
|
# Artists for dynamic target display
|
|
self.sim_target_artists = []
|
|
self.real_target_artists = []
|
|
self.sim_trail_artists = []
|
|
self.real_trail_artists = []
|
|
self.target_label_artists = []
|
|
|
|
# Data store for target trails. Use a bounded deque per-target whose
|
|
# max length is configurable (via constructor). The widget no longer
|
|
# supports an unbounded "full trail" mode — use a sufficiently large
|
|
# trail_length if you need a longer history.
|
|
self.trail_length = trail_length or self.TRAIL_LENGTH
|
|
self._trails = {
|
|
"simulated": collections.defaultdict(lambda: collections.deque(maxlen=self.trail_length)),
|
|
"real": collections.defaultdict(lambda: collections.deque(maxlen=self.trail_length)),
|
|
}
|
|
|
|
# Artists for trajectory preview
|
|
self.preview_artists = []
|
|
|
|
# --- UI Visibility Controls ---
|
|
self.show_sim_points_var = tk.BooleanVar(value=True)
|
|
self.show_real_points_var = tk.BooleanVar(value=True)
|
|
self.show_sim_trail_var = tk.BooleanVar(value=False)
|
|
self.show_real_trail_var = tk.BooleanVar(value=True)
|
|
|
|
self._create_controls()
|
|
self._create_plot()
|
|
|
|
def _create_controls(self):
|
|
"""Creates the control widgets for the PPI display."""
|
|
top_frame = ttk.Frame(self)
|
|
top_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
|
|
|
|
self.controls_frame = ttk.Frame(top_frame)
|
|
self.controls_frame.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
|
|
|
ttk.Label(self.controls_frame, text="Range (NM):").pack(side=tk.LEFT)
|
|
|
|
all_steps = [10, 20, 40, 80, 100, 160, 240, 320]
|
|
valid_steps = sorted([s for s in all_steps if s <= self.max_range])
|
|
if not valid_steps or self.max_range not in valid_steps:
|
|
valid_steps.append(self.max_range)
|
|
valid_steps.sort()
|
|
|
|
self.range_var = tk.IntVar(value=self.max_range)
|
|
self.range_selector = ttk.Combobox(
|
|
self.controls_frame, textvariable=self.range_var,
|
|
values=valid_steps, state="readonly", width=5
|
|
)
|
|
self.range_selector.pack(side=tk.LEFT, padx=5)
|
|
self.range_selector.bind("<<ComboboxSelected>>", self._on_range_selected)
|
|
|
|
# --- Display Options ---
|
|
options_frame = ttk.LabelFrame(top_frame, text="Display Options")
|
|
options_frame.pack(side=tk.RIGHT, padx=(10, 0))
|
|
|
|
cb_sim_points = ttk.Checkbutton(options_frame, text="Sim Points", variable=self.show_sim_points_var, command=lambda: self.canvas.draw_idle())
|
|
cb_sim_points.grid(row=0, column=0, sticky='w', padx=5)
|
|
cb_real_points = ttk.Checkbutton(options_frame, text="Real Points", variable=self.show_real_points_var, command=lambda: self.canvas.draw_idle())
|
|
cb_real_points.grid(row=0, column=1, sticky='w', padx=5)
|
|
cb_sim_trail = ttk.Checkbutton(options_frame, text="Sim Trail", variable=self.show_sim_trail_var, command=lambda: self.canvas.draw_idle())
|
|
cb_sim_trail.grid(row=1, column=0, sticky='w', padx=5)
|
|
cb_real_trail = ttk.Checkbutton(options_frame, text="Real Trail", variable=self.show_real_trail_var, command=lambda: self.canvas.draw_idle())
|
|
cb_real_trail.grid(row=1, column=1, sticky='w', padx=5)
|
|
# --- Legend ---
|
|
legend_frame = ttk.Frame(top_frame)
|
|
legend_frame.pack(side=tk.RIGHT, padx=(10, 5))
|
|
|
|
# Small colored swatches for simulated/real
|
|
sim_sw = tk.Canvas(legend_frame, width=16, height=12, highlightthickness=0)
|
|
sim_sw.create_rectangle(0, 0, 16, 12, fill='green', outline='black')
|
|
sim_sw.pack(side=tk.LEFT, padx=(0, 4))
|
|
ttk.Label(legend_frame, text="Simulated").pack(side=tk.LEFT, padx=(0, 8))
|
|
|
|
real_sw = tk.Canvas(legend_frame, width=16, height=12, highlightthickness=0)
|
|
real_sw.create_rectangle(0, 0, 16, 12, fill='red', outline='black')
|
|
real_sw.pack(side=tk.LEFT, padx=(2, 4))
|
|
ttk.Label(legend_frame, text="Real").pack(side=tk.LEFT)
|
|
|
|
def _create_plot(self):
|
|
"""Initializes the Matplotlib polar plot."""
|
|
fig = Figure(figsize=(5, 5), dpi=100, facecolor="#3E3E3E")
|
|
fig.subplots_adjust(left=0.05, right=0.95, top=0.9, bottom=0.05)
|
|
|
|
self.ax = fig.add_subplot(111, projection="polar", facecolor="#2E2E2E")
|
|
|
|
# Set zero at North (top) and theta direction to counter-clockwise (standard)
|
|
self.ax.set_theta_zero_location("N")
|
|
self.ax.set_theta_direction(1) # POSITIVO = ANTI-ORARIO (CCW)
|
|
|
|
self.ax.set_rlabel_position(90)
|
|
self.ax.set_ylim(0, self.range_var.get())
|
|
|
|
# Set angular grid labels to be [-180, 180] with North at 0
|
|
angles_deg = np.arange(0, 360, 30)
|
|
labels = []
|
|
for angle in angles_deg:
|
|
# Convert angle from CCW-from-East to CW-from-North for display label
|
|
display_angle = (450 - angle) % 360
|
|
if display_angle > 180:
|
|
display_angle -= 360
|
|
labels.append(f'{-display_angle}°') # Invert sign to match CW convention
|
|
|
|
self.ax.set_thetagrids(angles_deg, labels)
|
|
|
|
|
|
self.ax.tick_params(axis="x", colors="white", labelsize=8)
|
|
self.ax.tick_params(axis="y", colors="white", labelsize=8)
|
|
self.ax.grid(color="white", linestyle="--", linewidth=0.5, alpha=0.5)
|
|
self.ax.spines["polar"].set_color("white")
|
|
self.ax.set_title("PPI Display", color="white")
|
|
|
|
# ... (il resto del metodo è corretto) ...
|
|
|
|
(self._path_plot,) = self.ax.plot([], [], "g--", linewidth=1.5, label="Path")
|
|
(self._start_plot,) = self.ax.plot([], [], "go", markersize=8, label="Start")
|
|
(self._waypoints_plot,) = self.ax.plot([], [], "y+", markersize=10, mew=2, label="Waypoints")
|
|
self.preview_artists = [self._path_plot, self._start_plot, self._waypoints_plot]
|
|
|
|
limit_rad = np.deg2rad(self.scan_limit_deg)
|
|
(self._scan_line_1,) = self.ax.plot([limit_rad, limit_rad], [0, self.max_range], color="yellow", linestyle="--", linewidth=1)
|
|
(self._scan_line_2,) = self.ax.plot([-limit_rad, -limit_rad], [0, self.max_range], color="yellow", linestyle="--", linewidth=1)
|
|
|
|
self.canvas = FigureCanvasTkAgg(fig, master=self)
|
|
self.canvas.draw()
|
|
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
|
self._update_scan_lines()
|
|
|
|
def _init_trails(self):
|
|
"""(Removed) kept for historical reasons but no longer used."""
|
|
# This method is intentionally left as a no-op. Trails are initialized
|
|
# during construction and are always bounded by `trail_length`.
|
|
return
|
|
|
|
def _on_full_trail_toggled(self):
|
|
"""Toggle between bounded trail and full trail while preserving data.
|
|
|
|
When switching modes we transfer existing points into the new structure
|
|
to avoid losing history unexpectedly.
|
|
"""
|
|
# Full-trail toggling has been removed. Keep the method present so
|
|
# callers won't fail if they still reference it, but do nothing.
|
|
return
|
|
|
|
def update_targets(self, targets_data: Union[List[Target], Dict[str, List[Target]]]):
|
|
"""
|
|
Updates the display with the current state of targets.
|
|
This method is backward compatible. It can accept:
|
|
1. A simple List[Target] (for previews and simple display).
|
|
2. A Dict{'simulated': [...], 'real': [...]} for comparative display.
|
|
"""
|
|
sim_targets: List[Target] = []
|
|
real_targets: List[Target] = []
|
|
|
|
if isinstance(targets_data, list):
|
|
sim_targets = [t for t in targets_data if t.active]
|
|
elif isinstance(targets_data, dict):
|
|
sim_targets = [t for t in targets_data.get("simulated", []) if t.active]
|
|
real_targets = [t for t in targets_data.get("real", []) if t.active]
|
|
|
|
# --- Clear all previous dynamic artists ---
|
|
for artist_list in [self.sim_target_artists, self.real_target_artists,
|
|
self.sim_trail_artists, self.real_trail_artists,
|
|
self.target_label_artists]:
|
|
for artist in artist_list:
|
|
artist.remove()
|
|
artist_list.clear()
|
|
|
|
# --- Update trail history ---
|
|
for target in sim_targets:
|
|
pos = (np.deg2rad(target.current_azimuth_deg), target.current_range_nm)
|
|
self._trails["simulated"][target.target_id].append(pos)
|
|
|
|
for target in real_targets:
|
|
pos = (np.deg2rad(target.current_azimuth_deg), target.current_range_nm)
|
|
self._trails["real"][target.target_id].append(pos)
|
|
|
|
# --- Redraw artists based on visibility settings ---
|
|
if self.show_sim_points_var.get():
|
|
self._draw_target_visuals(sim_targets, 'green', self.sim_target_artists)
|
|
|
|
if self.show_real_points_var.get():
|
|
self._draw_target_visuals(real_targets, 'red', self.real_target_artists)
|
|
|
|
if self.show_sim_trail_var.get():
|
|
self._draw_trails(self._trails["simulated"], 'limegreen', self.sim_trail_artists)
|
|
|
|
if self.show_real_trail_var.get():
|
|
self._draw_trails(self._trails["real"], 'tomato', self.real_trail_artists)
|
|
|
|
self.canvas.draw_idle()
|
|
|
|
def _draw_target_visuals(self, targets: List[Target], color: str, artist_list: List):
|
|
"""Helper to draw dots and vectors for a list of targets."""
|
|
vector_len_nm = self.range_var.get() / 20.0
|
|
|
|
for target in targets:
|
|
r_nm = target.current_range_nm
|
|
theta_rad = np.deg2rad(target.current_azimuth_deg)
|
|
|
|
(dot,) = self.ax.plot(theta_rad, r_nm, "o", markersize=6, color=color)
|
|
artist_list.append(dot)
|
|
|
|
heading_rad = np.deg2rad(target.current_heading_deg)
|
|
x_nm = r_nm * np.sin(theta_rad)
|
|
y_nm = r_nm * np.cos(theta_rad)
|
|
dx_nm = vector_len_nm * np.sin(heading_rad)
|
|
dy_nm = vector_len_nm * np.cos(heading_rad)
|
|
r2_nm = math.sqrt((x_nm + dx_nm)**2 + (y_nm + dy_nm)**2)
|
|
theta2_rad = math.atan2(x_nm + dx_nm, y_nm + dy_nm)
|
|
|
|
(line,) = self.ax.plot([theta_rad, theta2_rad], [r_nm, r2_nm], color=color, linewidth=1.2)
|
|
artist_list.append(line)
|
|
|
|
label_r = r_nm + (vector_len_nm * 0.5)
|
|
txt = self.ax.text(theta_rad, label_r, str(target.target_id), color="white", fontsize=8, ha="center", va="bottom")
|
|
self.target_label_artists.append(txt)
|
|
|
|
def _draw_trails(self, trail_data: Dict, color: str, artist_list: List):
|
|
"""Helper to draw historical trails for targets."""
|
|
for target_id in trail_data:
|
|
trail = trail_data[target_id]
|
|
if len(trail) > 1:
|
|
thetas, rs = zip(*trail)
|
|
(line,) = self.ax.plot(thetas, rs, color=color, linestyle='-', linewidth=0.8, alpha=0.7)
|
|
artist_list.append(line)
|
|
|
|
def clear_trails(self):
|
|
"""Clears the historical data for all target trails."""
|
|
self._trails["simulated"].clear()
|
|
self._trails["real"].clear()
|
|
self.update_targets({}) # Trigger a redraw to clear visuals
|
|
|
|
# --- Other methods remain unchanged ---
|
|
def _update_scan_lines(self):
|
|
current_range_max = self.ax.get_ylim()[1]
|
|
limit_rad = np.deg2rad(self.scan_limit_deg)
|
|
self._scan_line_1.set_data([limit_rad, limit_rad], [0, current_range_max])
|
|
self._scan_line_2.set_data([-limit_rad, -limit_rad], [0, current_range_max])
|
|
|
|
def _on_range_selected(self, event=None):
|
|
new_range = self.range_var.get()
|
|
self.ax.set_ylim(0, new_range)
|
|
self._update_scan_lines()
|
|
self.canvas.draw_idle()
|
|
|
|
def clear_previews(self):
|
|
for artist in self.preview_artists:
|
|
artist.set_data([], [])
|
|
self.canvas.draw_idle()
|
|
|
|
def draw_trajectory_preview(self, waypoints: List[Waypoint], use_spline: bool):
|
|
self.clear_previews()
|
|
self.clear_trails()
|
|
if not waypoints or waypoints[0].maneuver_type != ManeuverType.FLY_TO_POINT:
|
|
return
|
|
path, _ = Target.generate_path_from_waypoints(waypoints, use_spline)
|
|
if not path:
|
|
return
|
|
path_thetas, path_rs = [], []
|
|
for point in path:
|
|
_time, x_ft, y_ft, _z_ft = point
|
|
r_ft = math.sqrt(x_ft**2 + y_ft**2)
|
|
theta_rad = math.atan2(x_ft, y_ft)
|
|
path_rs.append(r_ft / NM_TO_FT)
|
|
path_thetas.append(theta_rad)
|
|
self._path_plot.set_data(path_thetas, path_rs)
|
|
|
|
wp_thetas, wp_rs = [], []
|
|
for wp in waypoints:
|
|
if wp.maneuver_type == ManeuverType.FLY_TO_POINT:
|
|
r_nm = wp.target_range_nm or 0.0
|
|
theta_rad = math.radians(wp.target_azimuth_deg or 0.0)
|
|
wp_rs.append(r_nm)
|
|
wp_thetas.append(theta_rad)
|
|
self._waypoints_plot.set_data(wp_thetas, wp_rs)
|
|
|
|
start_wp = waypoints[0]
|
|
start_r = start_wp.target_range_nm or 0.0
|
|
start_theta = math.radians(start_wp.target_azimuth_deg or 0.0)
|
|
self._start_plot.set_data([start_theta], [start_r])
|
|
self.canvas.draw_idle()
|
|
|
|
def reconfigure_radar(self, max_range_nm: int, scan_limit_deg: int):
|
|
self.max_range = max_range_nm
|
|
self.scan_limit_deg = scan_limit_deg
|
|
steps = [10, 20, 40, 80, 100, 160, 240, 320]
|
|
valid_steps = sorted([s for s in steps if s <= self.max_range])
|
|
if not valid_steps or self.max_range not in valid_steps:
|
|
valid_steps.append(self.max_range)
|
|
valid_steps.sort()
|
|
current_range = self.range_var.get()
|
|
self.range_selector["values"] = valid_steps
|
|
if current_range in valid_steps:
|
|
self.range_var.set(current_range)
|
|
else:
|
|
self.range_var.set(self.max_range)
|
|
self._on_range_selected() |