S1005403_RisCC/target_simulator/gui/ppi_display.py

312 lines
12 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.
"""
import tkinter as tk
from tkinter import ttk
import math
import numpy as np
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
# Use absolute imports
from target_simulator.core.models import Target, Waypoint, ManeuverType, NM_TO_FT
from typing import List
class PPIDisplay(ttk.Frame):
"""A custom, reusable widget for the PPI radar display."""
def __init__(self, master, max_range_nm: int = 100, scan_limit_deg: int = 60):
super().__init__(master)
self.max_range = max_range_nm
self.scan_limit_deg = scan_limit_deg
# Artists for dynamic target display (dots, lines)
self.target_artists = []
# Artists for numeric labels next to targets
self.target_label_artists = []
self.active_targets: List[Target] = []
# Artists for trajectory preview display
self.preview_artists = []
self._create_controls()
self._create_plot()
def _create_controls(self):
"""Creates the control widgets for the PPI display."""
self.controls_frame = ttk.Frame(self)
self.controls_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
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)
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 and make theta increase counter-clockwise so
# positive azimuths are to the left of 0° (CCW positive)
self.ax.set_theta_zero_location("N")
self.ax.set_theta_direction(1)
self.ax.set_rlabel_position(90)
self.ax.set_ylim(0, self.range_var.get())
# Create labels so that positive azimuths appear to the LEFT of 0°.
# We map internal angles (0..360) to displayed labels where angles
# increasing CCW from 0 are shown as +, and the right side is negative.
angles = np.arange(0, 360, 30)
labels = []
for angle in angles:
# Convert internal angle to centered signed value in [-180,180]
a = ((angle + 180) % 360) - 180
# With CCW positive, a is already positive on the left
disp = a
if disp == 0:
labels.append("")
elif abs(disp) == 180:
labels.append("±180°")
elif disp > 0:
labels.append(f"+{int(disp)}°")
else:
labels.append(f"{int(disp)}°")
self.ax.set_thetagrids(angles, 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")
# --- Artists for drawing previews ---
(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]
# --- Artists for scan lines ---
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 _update_scan_lines(self):
"""Updates the length and position of the scan sector lines."""
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):
"""Handles the selection of a new range."""
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):
"""Clears all preview-related artists from the plot."""
for artist in self.preview_artists:
artist.set_data([], [])
self.canvas.draw_idle()
def update_targets(self, targets: List[Target]):
"""Updates the display with the current state of active targets."""
# Keep only active targets
self.active_targets = [t for t in targets if t.active]
# Clear previous target artists
for artist in self.target_artists:
artist.remove()
self.target_artists.clear()
# Clear previous numeric label artists (avoid label accumulation)
for l in getattr(self, "target_label_artists", []):
try:
l.remove()
except Exception:
pass
self.target_label_artists.clear()
vector_len_nm = max(1.0, self.range_var.get() / 20.0) # Length of heading vector
# Ensure plot radial limit matches selected range
try:
current_range = self.range_var.get()
self.ax.set_ylim(0, current_range)
self._update_scan_lines()
except Exception:
pass
# Diagnostic logging (kept lightweight)
try:
import logging
logger = logging.getLogger(__name__)
logger.debug("PPIDisplay.update_targets: received %d active target(s)", len(self.active_targets))
for t in self.active_targets:
try:
logger.debug("PPIDisplay target id=%s r_nm=%.2f az=%.2f hdg=%.2f", getattr(t, 'target_id', None), getattr(t, 'current_range_nm', 0.0), getattr(t, 'current_azimuth_deg', 0.0), getattr(t, 'current_heading_deg', 0.0))
except Exception:
pass
except Exception:
pass
for target in self.active_targets:
# Target's polar coordinates
r_nm = target.current_range_nm
theta_rad = np.deg2rad(target.current_azimuth_deg)
# Draw the target dot (slightly larger for visibility)
(dot,) = self.ax.plot(theta_rad, r_nm, "o", markersize=8, color="red")
self.target_artists.append(dot)
# Draw the heading vector
heading_rad = np.deg2rad(target.current_heading_deg)
# Convert to Cartesian to calculate endpoint, then back to polar
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="red", linewidth=1.2
)
self.target_artists.append(line)
# Add numeric label near the dot showing target id (if available)
try:
tid = getattr(target, "target_id", None)
if tid is not None:
# place label slightly outward from the dot
label_r = r_nm + (vector_len_nm * 0.7)
txt = self.ax.text(theta_rad, label_r, f"{tid}", color="white", fontsize=8, ha="center", va="center")
self.target_label_artists.append(txt)
except Exception:
pass
# Force immediate redraw to make sure UI shows the changes
try:
self.canvas.draw()
except Exception:
try:
self.canvas.draw_idle()
except Exception:
pass
def draw_trajectory_preview(self, waypoints: List[Waypoint], use_spline: bool):
"""
Simulates and draws a trajectory preview by leveraging the static path generator.
"""
self.clear_previews()
if not waypoints or waypoints[0].maneuver_type != ManeuverType.FLY_TO_POINT:
return
# Use the static method to get the path without creating a Target instance
path, _ = Target.generate_path_from_waypoints(waypoints, use_spline)
if not path:
return
# --- Draw the main path (splined or polygonal) ---
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)
# --- Draw waypoint markers (only for Fly to Point) ---
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)
# --- Draw the start point ---
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):
"""
Updates the radar parameters (range, scan limit) of an existing PPI display.
"""
self.max_range = max_range_nm
self.scan_limit_deg = scan_limit_deg
# Update the range combobox values
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 the current range is still valid, keep it. Otherwise, reset to max.
if current_range in valid_steps:
self.range_var.set(current_range)
else:
self.range_var.set(self.max_range)
# Apply the new range and redraw everything
self._on_range_selected()