729 lines
27 KiB
Python
729 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
|
||
import warnings
|
||
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.patches.Polygon] = None
|
||
self.ownship_heading_deg = 0.0
|
||
self._antenna_line_artist: Optional[mpl.lines.Line2D] = None
|
||
|
||
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
|
||
|
||
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."""
|
||
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")
|
||
self.ax.set_theta_direction(1)
|
||
self.ax.set_rlabel_position(90)
|
||
self.ax.set_ylim(0, self.range_var.get())
|
||
|
||
angles_deg = np.arange(0, 360, 30)
|
||
labels = [f"{(a if a <= 180 else a - 360)}°" 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 = mpl.patches.Polygon(
|
||
[[-1, -1]], closed=True, facecolor="cyan", edgecolor="black", zorder=10
|
||
)
|
||
self.ax.add_patch(self._ownship_artist)
|
||
|
||
(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]
|
||
|
||
(self._scan_line_1,) = self.ax.plot([], [], "y--", linewidth=1, zorder=1)
|
||
(self._scan_line_2,) = self.ax.plot([], [], "y--", linewidth=1, zorder=1)
|
||
|
||
(self._antenna_line_artist,) = self.ax.plot(
|
||
[],
|
||
[],
|
||
color="lightgray",
|
||
linestyle="--",
|
||
linewidth=1.2,
|
||
alpha=0.85,
|
||
zorder=2,
|
||
)
|
||
logger.debug(f"Antenna artist created: {self._antenna_line_artist}")
|
||
|
||
self.canvas = FigureCanvasTkAgg(fig, master=self)
|
||
|
||
# Suppress the UserWarning from tight_layout with polar axes
|
||
with warnings.catch_warnings():
|
||
warnings.simplefilter("ignore", UserWarning)
|
||
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_plot_orientation() # Initial draw
|
||
|
||
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()
|
||
# Convention: Azimuth/Heading is 0=N, positive=CCW (Left)
|
||
# Matplotlib theta is 0=East, positive=CCW.
|
||
# With zero_location="N", theta becomes 0=North, positive=CCW.
|
||
heading_rad = np.deg2rad(self.ownship_heading_deg)
|
||
max_r = self.ax.get_ylim()[1]
|
||
|
||
# Define ownship triangle shape in polar coordinates (theta, r)
|
||
r_scale = max_r * 0.04
|
||
nose = (0, r_scale)
|
||
wing_angle = np.deg2rad(140)
|
||
left_wing = (wing_angle, r_scale * 0.8)
|
||
right_wing = (-wing_angle, r_scale * 0.8)
|
||
|
||
base_verts_polar = np.array([nose, left_wing, right_wing])
|
||
|
||
if mode == "Heading-Up":
|
||
# Rotate the entire grid by the heading angle
|
||
self.ax.set_theta_offset(np.pi / 2 - heading_rad)
|
||
|
||
# To make ownship and scan lines appear fixed, we must "counter-rotate"
|
||
# them by drawing them at an angle equal to the heading.
|
||
verts_polar = base_verts_polar.copy()
|
||
verts_polar[:, 0] += heading_rad
|
||
self._ownship_artist.set_xy(verts_polar)
|
||
|
||
limit_rad = np.deg2rad(self.scan_limit_deg)
|
||
self._scan_line_1.set_data(
|
||
[heading_rad + limit_rad, heading_rad + limit_rad], [0, max_r]
|
||
)
|
||
self._scan_line_2.set_data(
|
||
[heading_rad - limit_rad, heading_rad - limit_rad], [0, max_r]
|
||
)
|
||
|
||
else: # North-Up
|
||
# Keep grid fixed with North up
|
||
self.ax.set_theta_offset(np.pi / 2)
|
||
# Rotate ownship vertices by adding heading to theta
|
||
verts_polar = base_verts_polar.copy()
|
||
verts_polar[:, 0] += heading_rad
|
||
self._ownship_artist.set_xy(verts_polar)
|
||
# Rotate scan lines by adding heading to theta
|
||
limit_rad = np.deg2rad(self.scan_limit_deg)
|
||
self._scan_line_1.set_data(
|
||
[heading_rad + limit_rad, heading_rad + limit_rad], [0, max_r]
|
||
)
|
||
self._scan_line_2.set_data(
|
||
[heading_rad - limit_rad, heading_rad - limit_rad], [0, max_r]
|
||
)
|
||
|
||
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:
|
||
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."""
|
||
self._update_plot_orientation()
|
||
|
||
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_plot_orientation()
|
||
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."""
|
||
try:
|
||
if self._antenna_line_artist is None:
|
||
logger.warning("Antenna artist is not initialized. Cannot render.")
|
||
return
|
||
|
||
if az_deg is None or not self.animate_antenna_var.get():
|
||
self._antenna_line_artist.set_visible(False)
|
||
else:
|
||
az_float = float(az_deg)
|
||
|
||
# WORKING SOLUTION confirmed by user:
|
||
# North-Up with heading=+10°:
|
||
# - Server sends ant_nav_az from +50° to -70°
|
||
# - Add 2×heading: result is +70° to -50° ✓ CORRECT
|
||
#
|
||
# Heading-Up with heading=+10°:
|
||
# - Grid is rotated by -10° (via set_theta_offset)
|
||
# - Scan lines are drawn at heading ± limit
|
||
# - Antenna needs same transformation as scan lines
|
||
#
|
||
# Solution: use SAME formula for both modes
|
||
# - North-Up: add 2×heading
|
||
# - Heading-Up: add 2×heading (counter-rotates for the rotated grid)
|
||
|
||
final_az_for_plot = az_float + (2.0 * self.ownship_heading_deg)
|
||
|
||
# Normalize to [-180, 180] range
|
||
while final_az_for_plot > 180:
|
||
final_az_for_plot -= 360
|
||
while final_az_for_plot < -180:
|
||
final_az_for_plot += 360
|
||
|
||
# Convert final angle to theta for Matplotlib (0=N, positive=CCW)
|
||
theta = np.deg2rad(final_az_for_plot)
|
||
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)
|
||
|
||
if self.canvas:
|
||
self.canvas.draw_idle()
|
||
except Exception:
|
||
logger.exception("Error rendering antenna line")
|