S1005403_RisCC/target_simulator/gui/ppi_display.py

724 lines
27 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 time
import logging
import numpy as np
import collections
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import matplotlib as mpl
from typing import List, Dict, Optional
from target_simulator.core.models import Target, Waypoint, ManeuverType, NM_TO_FT
# Module-level logger
logger = logging.getLogger(__name__)
class PPIDisplay(ttk.Frame):
"""
A custom widget for the PPI radar display.
"""
TRAIL_LENGTH = 100
def __init__(
self,
master,
max_range_nm: int = 100,
scan_limit_deg: int = 60,
trail_length: int = None,
):
"""
Inizializza il widget PPI.
Ingressi: master (Tk parent), max_range_nm (int), scan_limit_deg (int), trail_length (int|None)
Uscite: nessuna (side-effect: costruisce widgets e canvas)
Commento: mantiene gli artist Matplotlib per targets, trails e antenna.
"""
super().__init__(master)
self.max_range = max_range_nm
self.scan_limit_deg = scan_limit_deg
self.sim_target_artists: List[mpl.artist.Artist] = []
self.real_target_artists: List[mpl.artist.Artist] = []
self.sim_trail_artists: List[mpl.artist.Artist] = []
self.real_trail_artists: List[mpl.artist.Artist] = []
self.sim_label_artists: List[mpl.artist.Artist] = []
self.real_label_artists: List[mpl.artist.Artist] = []
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)
),
}
self.preview_artists: List[mpl.artist.Artist] = []
self.preview_path_artists: Dict[int, List[mpl.artist.Artist]] = {}
# Display options
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=False)
self.animate_antenna_var = tk.BooleanVar(value=True)
self.display_mode_var = tk.StringVar(value="North-Up")
self.canvas = None
self._ownship_artist: Optional[mpl.lines.Line2D] = None
self.ownship_heading_deg = 0.0
self._create_controls()
self._create_plot()
self._real_update_timestamps = collections.deque(maxlen=10000)
self._last_update_summary_time = time.monotonic()
self._update_summary_interval_s = 1.0
self._antenna_line_artist: Optional[mpl.lines.Line2D] = None
def _on_display_options_changed(self, *args):
"""Handler invoked when display options (points/trails) change.
Clears current target artists and triggers a redraw so the new
display options are applied immediately.
"""
self.clear_all_targets()
if self.canvas:
self.canvas.draw()
def _on_display_mode_changed(self, *args):
"""Callback when the display mode (North-Up/Heading-Up) changes."""
self._update_plot_orientation()
if self.canvas:
self.canvas.draw_idle()
def _create_controls(self):
"""Create the top control panel containing radar controls and legend.
The panel includes 4 logical sections: Radar, Display Mode, Display
Options and Legend. Widgets are created and wired to the corresponding
callbacks and Tk variables.
"""
top_frame = ttk.Frame(self)
top_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5, 2))
# Section 1: Radar Controls
radar_frame = ttk.LabelFrame(top_frame, text="Radar", padding=5)
radar_frame.pack(side=tk.LEFT, padx=(0, 5), fill=tk.Y)
range_subframe = ttk.Frame(radar_frame)
range_subframe.pack(anchor="w")
ttk.Label(range_subframe, 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]
+ ([self.max_range] if self.max_range not in all_steps else [])
)
self.range_var = tk.IntVar(value=self.max_range)
self.range_selector = ttk.Combobox(
range_subframe,
textvariable=self.range_var,
values=valid_steps,
state="readonly",
width=5,
)
self.range_selector.pack(side=tk.LEFT, padx=5)
ttk.Checkbutton(
radar_frame,
text="Animate Antenna",
variable=self.animate_antenna_var,
command=self._force_redraw,
).pack(anchor="w", pady=(4, 0))
# Section 2: Display Mode
mode_frame = ttk.LabelFrame(top_frame, text="Display Mode", padding=5)
mode_frame.pack(side=tk.LEFT, padx=5, fill=tk.Y)
ttk.Radiobutton(
mode_frame,
text="North-Up",
variable=self.display_mode_var,
value="North-Up",
command=self._on_display_mode_changed,
).pack(anchor="w")
ttk.Radiobutton(
mode_frame,
text="Heading-Up",
variable=self.display_mode_var,
value="Heading-Up",
command=self._on_display_mode_changed,
).pack(anchor="w")
# Section 3: Display Options
options_frame = ttk.LabelFrame(top_frame, text="Display Options", padding=5)
options_frame.pack(side=tk.LEFT, padx=5, fill=tk.Y)
ttk.Checkbutton(
options_frame,
text="Sim Points",
variable=self.show_sim_points_var,
command=self._on_display_options_changed,
).grid(row=0, column=0, sticky="w", padx=5)
ttk.Checkbutton(
options_frame,
text="Real Points",
variable=self.show_real_points_var,
command=self._on_display_options_changed,
).grid(row=0, column=1, sticky="w", padx=5)
ttk.Checkbutton(
options_frame,
text="Sim Trail",
variable=self.show_sim_trail_var,
command=self._on_display_options_changed,
).grid(row=1, column=0, sticky="w", padx=5)
ttk.Checkbutton(
options_frame,
text="Real Trail",
variable=self.show_real_trail_var,
command=self._on_display_options_changed,
).grid(row=1, column=1, sticky="w", padx=5)
# Spacer to push the legend to the right
spacer = ttk.Frame(top_frame)
spacer.pack(side=tk.LEFT, expand=True, fill=tk.X)
# Section 4: Legend
legend_frame = ttk.LabelFrame(top_frame, text="Legend", padding=5)
legend_frame.pack(side=tk.LEFT, padx=5, fill=tk.Y)
# Ownship
own_sw = tk.Canvas(legend_frame, width=16, height=12, highlightthickness=0)
own_sw.create_rectangle(0, 0, 16, 12, fill="cyan", outline="black")
own_sw.grid(row=0, column=0, padx=(0, 4), pady=(0, 2))
ttk.Label(legend_frame, text="Ownship").grid(row=0, column=1, sticky="w")
# Simulated
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.grid(row=1, column=0, padx=(0, 4), pady=(0, 2))
ttk.Label(legend_frame, text="Simulated").grid(row=1, column=1, sticky="w")
# Real
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.grid(row=2, column=0, padx=(0, 4))
ttk.Label(legend_frame, text="Real").grid(row=2, column=1, sticky="w")
def _force_redraw(self):
"""Request an immediate (idle) redraw of the Matplotlib canvas.
This is used by UI controls that change visual options to avoid
blocking synchronous draws.
"""
if self.canvas:
self.canvas.draw_idle()
def _create_plot(self):
"""Create the Matplotlib polar Figure and initial plot artists.
This sets up axes, gridlines, preview artists, ownship marker and
antenna/scan line artists used by the rest of the widget.
"""
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")
self.ax.set_theta_zero_location("N")
# Default direction is CCW (antiorario), which is correct for our model
# self.ax.set_theta_direction(-1) # This was the old incorrect way
self.ax.set_rlabel_position(90)
self.ax.set_ylim(0, self.range_var.get())
# Correct labels for CCW positive convention
angles_deg = np.arange(0, 360, 30)
labels = [f"{(a - 360) if a > 180 else a}°" for a in angles_deg]
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")
(self._ownship_artist,) = self.ax.plot(
[0],
[0],
marker="^",
markersize=12,
color="cyan",
markeredgecolor="black",
zorder=10,
transform=self.ax.transData,
)
self._ownship_artist.set_visible(True)
(self._path_plot,) = self.ax.plot([], [], "g--", linewidth=1.5, zorder=3)
(self._start_plot,) = self.ax.plot([], [], "go", markersize=8, zorder=3)
(self._waypoints_plot,) = self.ax.plot(
[], [], "y+", markersize=10, mew=2, zorder=3
)
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], "y--", linewidth=1, zorder=1
)
(self._scan_line_2,) = self.ax.plot(
[-limit_rad, -limit_rad], [0, self.max_range], "y--", linewidth=1, zorder=1
)
(self._antenna_line_artist,) = self.ax.plot(
[],
[],
color="lightgray",
linestyle="--",
linewidth=1.2,
alpha=0.85,
zorder=2,
)
self.canvas = FigureCanvasTkAgg(fig, master=self)
self.canvas.draw()
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self.range_selector.bind("<<ComboboxSelected>>", self._on_range_selected)
self._update_scan_lines()
def update_ownship_state(self, heading_deg: float):
"""Updates the ownship's visual representation on the PPI."""
if self.ownship_heading_deg != heading_deg:
self.ownship_heading_deg = heading_deg
self._update_plot_orientation()
def _update_plot_orientation(self):
"""Applies rotation to the plot or ownship icon based on display mode."""
if not self.ax or not self._ownship_artist:
return
mode = self.display_mode_var.get()
heading_rad = np.deg2rad(self.ownship_heading_deg)
if mode == "Heading-Up":
self.ax.set_theta_offset(math.pi / 2 - heading_rad)
transform = mpl.transforms.IdentityTransform()
self._ownship_artist.set_transform(transform)
else: # North-Up
self.ax.set_theta_offset(math.pi / 2)
transform = (
mpl.transforms.Affine2D().rotate(-heading_rad) + self.ax.transData
)
self._ownship_artist.set_transform(transform)
if self.canvas:
self.canvas.draw_idle()
def clear_all_targets(self):
"""Clears all target artists from the display."""
all_artists = (
self.sim_target_artists
+ self.real_target_artists
+ self.sim_trail_artists
+ self.real_trail_artists
+ self.sim_label_artists
+ self.real_label_artists
)
for artist in all_artists:
artist.remove()
self.sim_target_artists.clear()
self.real_target_artists.clear()
self.sim_trail_artists.clear()
self.real_trail_artists.clear()
self.sim_label_artists.clear()
self.real_label_artists.clear()
def update_simulated_targets(self, targets: List[Target]):
"""Updates and redraws only the simulated targets."""
self._update_target_category(targets, "simulated")
if self.canvas:
self.canvas.draw()
def update_real_targets(self, targets: List[Target]):
"""Updates and redraws only the real targets."""
try:
now = time.monotonic()
self._real_update_timestamps.append(now)
if (
now - self._last_update_summary_time
) >= self._update_summary_interval_s:
self._last_update_summary_time = now
except Exception:
pass
self._update_target_category(targets, "real")
if self.canvas:
self.canvas.draw()
def get_real_update_rate(self, window_seconds: float = 1.0) -> float:
"""
Returns approximate PPI "real targets" update rate (updates/sec).
"""
try:
now = time.monotonic()
cutoff = now - float(window_seconds)
count = sum(1 for ts in self._real_update_timestamps if ts >= cutoff)
return count / float(window_seconds) if window_seconds > 0 else float(count)
except Exception:
return 0.0
def _update_target_category(self, new_data: List[Target], category: str):
"""
Generic helper to update targets for a specific category ('simulated' or 'real').
"""
if category == "simulated":
target_artists, trail_artists, label_artists = (
self.sim_target_artists,
self.sim_trail_artists,
self.sim_label_artists,
)
trail_data, show_points, show_trail = (
self._trails["simulated"],
self.show_sim_points_var.get(),
self.show_sim_trail_var.get(),
)
color, trail_color = "green", "limegreen"
else:
target_artists, trail_artists, label_artists = (
self.real_target_artists,
self.real_trail_artists,
self.real_label_artists,
)
trail_data, show_points, show_trail = (
self._trails["real"],
self.show_real_points_var.get(),
self.show_real_trail_var.get(),
)
color, trail_color = "red", "tomato"
for artist in target_artists + trail_artists + label_artists:
artist.remove()
target_artists.clear()
trail_artists.clear()
label_artists.clear()
if show_points or show_trail:
for t in new_data:
if t.active:
# No negation needed anymore
pos = (np.deg2rad(t.current_azimuth_deg), t.current_range_nm)
trail_data[t.target_id].append(pos)
if show_points:
active_targets = [t for t in new_data if t.active]
if active_targets:
self._draw_target_visuals(
active_targets, color, target_artists, label_artists
)
inactive_targets = [t for t in new_data if not t.active]
if inactive_targets and category == "simulated":
self._draw_inactive_markers(
inactive_targets, color, target_artists, label_artists
)
if show_trail:
self._draw_trails(trail_data, trail_color, trail_artists)
def _draw_inactive_markers(
self,
targets: List[Target],
color: str,
artist_list: List,
label_artist_list: List,
):
for target in targets:
try:
r_nm = target.current_range_nm
theta_rad_plot = np.deg2rad(target.current_azimuth_deg)
(dot,) = self.ax.plot(
theta_rad_plot,
r_nm,
"o",
markersize=6,
color=color,
alpha=0.6,
zorder=5,
)
artist_list.append(dot)
(x_mark,) = self.ax.plot(
theta_rad_plot,
r_nm,
marker="x",
color="yellow",
markersize=8,
markeredgewidth=0.9,
linestyle="",
zorder=6,
)
label_artist_list.append(x_mark)
except Exception:
pass
def _draw_target_visuals(
self,
targets: List[Target],
color: str,
artist_list: List,
label_artist_list: List,
):
vector_len_nm = self.range_var.get() / 20.0
marker_size = 8 if color == "green" else 6
for target in targets:
r_nm = target.current_range_nm
theta_rad_plot = np.deg2rad(target.current_azimuth_deg)
(dot,) = self.ax.plot(
theta_rad_plot, r_nm, "o", markersize=marker_size, color=color, zorder=5
)
artist_list.append(dot)
heading_relative_to_north_rad = np.deg2rad(target.current_heading_deg)
x_start_rel_nm = r_nm * math.sin(theta_rad_plot)
y_start_rel_nm = r_nm * math.cos(theta_rad_plot)
dx_nm = vector_len_nm * math.sin(heading_relative_to_north_rad)
dy_nm = vector_len_nm * math.cos(heading_relative_to_north_rad)
x_end_rel_nm = x_start_rel_nm + dx_nm
y_end_rel_nm = y_start_rel_nm + dy_nm
r_end_nm = math.hypot(x_end_rel_nm, y_end_rel_nm)
theta_end_rad_plot = math.atan2(x_end_rel_nm, y_end_rel_nm)
(line,) = self.ax.plot(
[theta_rad_plot, theta_end_rad_plot],
[r_nm, r_end_nm],
color=color,
linewidth=1.2,
zorder=4,
)
artist_list.append(line)
txt = self.ax.text(
theta_rad_plot,
r_nm + (vector_len_nm * 0.5),
str(target.target_id),
color="white",
fontsize=8,
ha="center",
va="bottom",
zorder=7,
)
label_artist_list.append(txt)
def _draw_trails(self, trail_data: Dict, color: str, artist_list: List):
for trail in trail_data.values():
if len(trail) > 1:
thetas, rs = zip(*trail)
(line,) = self.ax.plot(
thetas,
rs,
color=color,
linestyle="-",
linewidth=0.8,
alpha=0.7,
zorder=3,
)
artist_list.append(line)
def clear_trails(self):
self._trails["simulated"].clear()
self._trails["real"].clear()
self.clear_all_targets()
if self.canvas:
self.canvas.draw()
def _update_scan_lines(self):
"""Update scan/sector indicator lines and keep antenna line length in sync.
Reads the current plot radial limit and updates the scan sector marker
lines as well as the antenna artist so they span the visible radius.
"""
max_r = self.ax.get_ylim()[1]
limit_rad = np.deg2rad(self.scan_limit_deg)
self._scan_line_1.set_data([limit_rad, limit_rad], [0, max_r])
self._scan_line_2.set_data([-limit_rad, -limit_rad], [0, max_r])
if self._antenna_line_artist:
data = self._antenna_line_artist.get_data()
if data and len(data) == 2 and len(data[0]) >= 1:
theta = data[0][0]
self._antenna_line_artist.set_data([theta, theta], [0, max_r])
def _on_range_selected(self, event=None):
"""Adjust plot limits when the range selector value changes.
Ensures scan lines and the canvas are updated to match the new range.
"""
self.ax.set_ylim(0, self.range_var.get())
self._update_scan_lines()
if self.canvas:
self.canvas.draw()
def clear_previews(self):
"""Clear any scenario or trajectory preview artists from the plot."""
for artist in self.preview_artists:
artist.set_data([], [])
for arts in self.preview_path_artists.values():
for a in arts:
a.remove()
self.preview_path_artists.clear()
if self.canvas:
self.canvas.draw()
def draw_scenario_preview(self, scenario):
self.clear_previews()
if scenario is None:
return
for target in scenario.get_all_targets():
try:
path, _ = Target.generate_path_from_waypoints(
target.trajectory, target.use_spline
)
if not path:
continue
path_thetas, path_rs = [], []
for point in path:
x_ft, y_ft = point[1], point[2]
r_ft = math.hypot(x_ft, y_ft)
az_rad_plot = math.atan2(x_ft, y_ft)
path_rs.append(r_ft / NM_TO_FT)
path_thetas.append(az_rad_plot)
(line_art,) = self.ax.plot(
path_thetas,
path_rs,
color="limegreen",
linestyle="--",
linewidth=1.2,
alpha=0.9,
)
(start_art,) = self.ax.plot(
[path_thetas[0]], [path_rs[0]], "go", markersize=6
)
self.preview_path_artists[target.target_id] = [line_art, start_art]
except Exception:
logger.exception(
"Failed to draw preview for target %s",
getattr(target, "target_id", "?"),
)
if self.canvas:
self.canvas.draw()
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:
x_ft, y_ft = point[1], point[2]
r_ft = math.hypot(x_ft, y_ft)
az_rad_plot = math.atan2(x_ft, y_ft)
path_rs.append(r_ft / NM_TO_FT)
path_thetas.append(az_rad_plot)
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
az_rad_plot = np.deg2rad(wp.target_azimuth_deg or 0.0)
wp_rs.append(r_nm)
wp_thetas.append(az_rad_plot)
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 = np.deg2rad(start_wp.target_azimuth_deg or 0.0)
self._start_plot.set_data([start_theta], [start_r])
if self.canvas:
self.canvas.draw()
def reconfigure_radar(self, max_range_nm: int, scan_limit_deg: int):
self.max_range, self.scan_limit_deg = max_range_nm, scan_limit_deg
steps = [10, 20, 40, 80, 100, 160, 240, 320]
valid_steps = sorted(
[s for s in steps if s <= max_range_nm]
+ ([max_range_nm] if max_range_nm not in steps else [])
)
self.range_selector["values"] = valid_steps
if self.range_var.get() not in valid_steps:
self.range_var.set(max_range_nm)
self._on_range_selected()
def render_antenna_line(self, az_deg: Optional[float]):
"""Directly renders the antenna line at a given absolute azimuth."""
# Aggiunto log di debug per tracciare la chiamata
# logger.debug(f"PPIDisplay.render_antenna_line called with az_deg={az_deg}")
try:
# If the artist was not created (unexpected), create it lazily so
# the antenna can still be rendered. This avoids silent failures
# when the initial plot setup didn't complete for some reason.
created_artist = False
if self._antenna_line_artist is None:
try:
(self._antenna_line_artist,) = self.ax.plot(
[],
[],
color="lightgray",
linestyle="--",
linewidth=1.2,
alpha=0.85,
zorder=2,
)
created_artist = True
logger.debug(
"PPIDisplay: lazily created _antenna_line_artist for widget id=%s",
id(self),
)
except Exception:
# If creation fails, give up gracefully
return
if az_deg is None or not self.animate_antenna_var.get():
self._antenna_line_artist.set_visible(False)
else:
# Assicuriamoci che az_deg sia un float valido
az_float = float(az_deg)
theta = np.deg2rad(az_float % 360)
max_r = self.ax.get_ylim()[1]
self._antenna_line_artist.set_data([theta, theta], [0, max_r])
self._antenna_line_artist.set_visible(True)
# logger.debug(f"Antenna line drawn at theta={theta}")
# If we created the artist now, or we made it visible, schedule a
# canvas redraw. Use draw_idle to avoid immediate blocking draws.
try:
if (
created_artist
or (az_deg is not None and self.animate_antenna_var.get())
) and self.canvas:
self.canvas.draw_idle()
except Exception:
pass
except Exception:
# Silently fail to prevent logging floods
pass