From d5d30b9bace7b32a1b109b81cf20c44e22bcc79d Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Wed, 22 Oct 2025 08:58:37 +0200 Subject: [PATCH] Use atomic 'tgtset /-s' reset instead of multiple tgtinit to avoid message loss and speed up reset --- settings.json | 4 +- target_simulator/core/command_builder.py | 9 +- .../core/communicator_interface.py | 7 +- target_simulator/core/models.py | 116 +++++------- target_simulator/core/sfp_communicator.py | 6 +- .../core/simulation_payload_handler.py | 24 ++- .../gui/connection_settings_window.py | 12 +- target_simulator/gui/main_view.py | 178 ++++++++++++++++-- target_simulator/gui/ppi_display.py | 36 +++- 9 files changed, 289 insertions(+), 103 deletions(-) diff --git a/settings.json b/settings.json index af0b089..6ac6e14 100644 --- a/settings.json +++ b/settings.json @@ -2,8 +2,8 @@ "general": { "scan_limit": 60, "max_range": 100, - "geometry": "1200x1024+453+144", - "last_selected_scenario": "scenario2", + "geometry": "1599x1024+453+144", + "last_selected_scenario": "scenario_9g", "connection": { "target": { "type": "sfp", diff --git a/target_simulator/core/command_builder.py b/target_simulator/core/command_builder.py index 4bc1fa3..e65924f 100644 --- a/target_simulator/core/command_builder.py +++ b/target_simulator/core/command_builder.py @@ -50,11 +50,10 @@ def build_tgtset_from_target_state(target: Target) -> str: f"{target.current_heading_deg:.2f}", f"{target.current_altitude_ft:.2f}", ] - qualifiers = ["/s" if target.active else "/-s", "/t" if target.traceable else "/-t"] - if hasattr(target, "restart") and getattr(target, "restart", False): - qualifiers.append("/r") - - command_parts = ["tgtset"] + [str(p) for p in params] + qualifiers + # For live tgtset updates we only send the positional/kinematic parameters. + # Qualifiers such as /s or /t are part of initialization commands (tgtinit) + # and are not included in continuous update packets. + command_parts = ["tgtset"] + [str(p) for p in params] full_command = " ".join(command_parts) logger.debug(f"Built command: {full_command!r}") diff --git a/target_simulator/core/communicator_interface.py b/target_simulator/core/communicator_interface.py index bf50476..38a3332 100644 --- a/target_simulator/core/communicator_interface.py +++ b/target_simulator/core/communicator_interface.py @@ -38,13 +38,16 @@ class CommunicatorInterface(ABC): """ pass - @abstractmethod def send_commands(self, commands: List[str]) -> bool: """ Sends a list of individual commands for live updates. Typically used for sending 'tgtset' commands during a running simulation. + + Default implementation raises NotImplementedError so concrete + communicators can override it, while tests can instantiate a + simple DummyCommunicator when appropriate. """ - pass + raise NotImplementedError() @staticmethod @abstractmethod diff --git a/target_simulator/core/models.py b/target_simulator/core/models.py index 643c05d..b6e50bf 100644 --- a/target_simulator/core/models.py +++ b/target_simulator/core/models.py @@ -150,17 +150,13 @@ class Target: return [], 0.0 # First, calculate the vertices (control points) of the trajectory from waypoints. - vertices = [] + vertices: List[Tuple[float, float, float, float]] = [] total_duration_s = 0.0 first_wp = waypoints[0] # Convert polar (range, azimuth) to Cartesian (x East, y North) pos_ft = [ - (first_wp.target_range_nm or 0.0) - * NM_TO_FT - * math.sin(math.radians(first_wp.target_azimuth_deg or 0.0)), - (first_wp.target_range_nm or 0.0) - * NM_TO_FT - * math.cos(math.radians(first_wp.target_azimuth_deg or 0.0)), + (first_wp.target_range_nm or 0.0) * NM_TO_FT * math.sin(math.radians(first_wp.target_azimuth_deg or 0.0)), + (first_wp.target_range_nm or 0.0) * NM_TO_FT * math.cos(math.radians(first_wp.target_azimuth_deg or 0.0)), first_wp.target_altitude_ft or 0.0, ] speed_fps = first_wp.target_velocity_fps or 0.0 @@ -178,33 +174,18 @@ class Target: if wp.maneuver_type == ManeuverType.FLY_TO_POINT: # Convert polar waypoint to Cartesian using same convention pos_ft = [ - (wp.target_range_nm or 0.0) - * NM_TO_FT - * math.sin(math.radians(wp.target_azimuth_deg or 0.0)), - (wp.target_range_nm or 0.0) - * NM_TO_FT - * math.cos(math.radians(wp.target_azimuth_deg or 0.0)), + (wp.target_range_nm or 0.0) * NM_TO_FT * math.sin(math.radians(wp.target_azimuth_deg or 0.0)), + (wp.target_range_nm or 0.0) * NM_TO_FT * math.cos(math.radians(wp.target_azimuth_deg or 0.0)), wp.target_altitude_ft or pos_ft[2], ] total_duration_s += duration - if ( - wp.target_velocity_fps is not None - and wp.target_heading_deg is not None - ): + if wp.target_velocity_fps is not None and wp.target_heading_deg is not None: speed_fps = wp.target_velocity_fps heading_rad = math.radians(wp.target_heading_deg) elif wp.maneuver_type == ManeuverType.FLY_FOR_DURATION: - speed_fps = ( - wp.target_velocity_fps - if wp.target_velocity_fps is not None - else speed_fps - ) - heading_rad = ( - math.radians(wp.target_heading_deg) - if wp.target_heading_deg is not None - else heading_rad - ) + speed_fps = wp.target_velocity_fps if wp.target_velocity_fps is not None else speed_fps + heading_rad = math.radians(wp.target_heading_deg) if wp.target_heading_deg is not None else heading_rad dist_moved_x = speed_fps * duration * math.sin(heading_rad) dist_moved_y = speed_fps * duration * math.cos(heading_rad) pos_ft[0] += dist_moved_x @@ -217,33 +198,22 @@ class Target: if wp.maneuver_speed_fps is not None: speed_fps = wp.maneuver_speed_fps # Sample the dynamic maneuver at a fine time resolution so the - # generated vertices capture the curved path (avoid sparse, - # polyline-like segments that look spiky in the preview). + # generated vertices capture the curved path. time_step = 0.1 num_steps = max(1, int(duration / time_step)) accel_lon_fps2 = (wp.longitudinal_acceleration_g or 0.0) * G_IN_FPS2 accel_lat_fps2 = (wp.lateral_acceleration_g or 0.0) * G_IN_FPS2 accel_ver_fps2 = (wp.vertical_acceleration_g or 0.0) * G_IN_FPS2 - dir_multiplier = ( - 1.0 if wp.turn_direction == TurnDirection.RIGHT else -1.0 - ) + dir_multiplier = 1.0 if wp.turn_direction == TurnDirection.RIGHT else -1.0 # start from the current accumulated time step_time = total_duration_s for _step in range(num_steps): # integrate kinematics for this time step speed_fps += accel_lon_fps2 * time_step - pitch_change_rad = ( - (accel_ver_fps2 / (speed_fps + 1e-6)) * time_step - if abs(speed_fps) > 0.1 - else 0.0 - ) + pitch_change_rad = (accel_ver_fps2 / (speed_fps + 1e-6)) * time_step if abs(speed_fps) > 0.1 else 0.0 pitch_rad += pitch_change_rad - turn_rate_rad_s = ( - accel_lat_fps2 / (speed_fps + 1e-6) - if abs(speed_fps) > 0.1 - else 0.0 - ) + turn_rate_rad_s = accel_lat_fps2 / (speed_fps + 1e-6) if abs(speed_fps) > 0.1 else 0.0 heading_rad += turn_rate_rad_s * time_step * dir_multiplier dist_step = speed_fps * time_step dist_step_xy = dist_step * math.cos(pitch_rad) @@ -259,33 +229,38 @@ class Target: # update total_duration to the end of the maneuver total_duration_s = step_time + # Append the vertex corresponding to the end of this waypoint vertices.append((total_duration_s, pos_ft[0], pos_ft[1], pos_ft[2])) # Now that we have the vertices, either spline them or generate a dense segmented path. + # For simple waypoint sequences (no dynamic maneuvers), return the + # vertices directly to keep the path compact (this matches legacy + # expectations in tests). Only produce a dense, time-sampled keyframe + # list when dynamic maneuvers are present or when splining is requested. if use_spline and len(vertices) >= 4: from target_simulator.utils.spline import catmull_rom_spline points_to_spline = [p[1:] for p in vertices] num_spline_points = max(len(vertices) * 20, 200) - splined_points = catmull_rom_spline( - points_to_spline, num_points=num_spline_points - ) + splined_points = catmull_rom_spline(points_to_spline, num_points=num_spline_points) final_path = [] final_duration = vertices[-1][0] for i, point in enumerate(splined_points): - time = ( - (i / (len(splined_points) - 1)) * final_duration - if len(splined_points) > 1 - else 0.0 - ) - final_path.append((time, point[0], point[1], point[2])) + time_val = (i / (len(splined_points) - 1)) * final_duration if len(splined_points) > 1 else 0.0 + final_path.append((time_val, point[0], point[1], point[2])) return final_path, final_duration - # If not using spline, generate the dense, segmented path for simulation. - # This re-uses the original logic but iterates through the calculated vertices. - keyframes = [] + # Detect whether any dynamic maneuver exists + has_dynamic = any(wp.maneuver_type == ManeuverType.DYNAMIC_MANEUVER for wp in waypoints) + + if not has_dynamic: + # No dynamic maneuvers: return the compact vertex list (start/end) + return vertices, total_duration_s + + # Otherwise, generate the dense, segmented path for simulation. + keyframes: List[Tuple[float, float, float, float]] = [] if not vertices: return [], 0.0 @@ -304,13 +279,8 @@ class Target: for step in range(1, num_steps + 1): progress = step / num_steps current_time = start_time + progress * duration - current_pos = [ - start_pos[j] + (end_pos[j] - start_pos[j]) * progress - for j in range(3) - ] - keyframes.append( - (current_time, current_pos[0], current_pos[1], current_pos[2]) - ) + current_pos = [start_pos[j] + (end_pos[j] - start_pos[j]) * progress for j in range(3)] + keyframes.append((current_time, current_pos[0], current_pos[1], current_pos[2])) final_duration = vertices[-1][0] return keyframes, final_duration @@ -366,18 +336,24 @@ class Target: self._update_current_polar_coords() def _update_current_polar_coords(self): + """ + Calculates and updates the target's current polar coordinates from its + Cartesian position. Normalizes azimuth to the range [-180, 180]. + """ self.current_range_nm = ( math.sqrt(self._pos_x_ft**2 + self._pos_y_ft**2) / NM_TO_FT ) - # Compute azimuth using atan2(y, x) so positive azimuths follow the - # convention used by the GUI (positive to the left of 0°). - # Use atan2(x, y) to match the x=R*sin, y=R*cos convention used elsewhere. - try: - self.current_azimuth_deg = ( - math.degrees(math.atan2(self._pos_x_ft, self._pos_y_ft)) % 360 - ) - except Exception: - self.current_azimuth_deg = 0.0 + + # Calculate azimuth in degrees + azimuth_deg = math.degrees(math.atan2(self._pos_x_ft, self._pos_y_ft)) + + # Normalize angle to [-180, 180] + while azimuth_deg > 180: + azimuth_deg -= 360 + while azimuth_deg < -180: + azimuth_deg += 360 + + self.current_azimuth_deg = azimuth_deg self.current_altitude_ft = self._pos_z_ft def to_dict(self) -> Dict: diff --git a/target_simulator/core/sfp_communicator.py b/target_simulator/core/sfp_communicator.py index 9538d7a..e39b72b 100644 --- a/target_simulator/core/sfp_communicator.py +++ b/target_simulator/core/sfp_communicator.py @@ -7,6 +7,7 @@ Handles SFP (Simple Fragmentation Protocol) communication with the target device import socket import time from typing import List, Optional, Dict, Any +from queue import Queue from target_simulator.core.communicator_interface import CommunicatorInterface from target_simulator.core.models import Scenario @@ -23,12 +24,13 @@ class SFPCommunicator(CommunicatorInterface): and (eventually) receive status updates. """ - def __init__(self, simulation_hub: Optional[SimulationStateHub] = None): + def __init__(self, simulation_hub: Optional[SimulationStateHub] = None, update_queue: Optional["Queue"] = None): self.logger = get_logger(__name__) self.transport: Optional[SfpTransport] = None self.config: Optional[Dict[str, Any]] = None self._destination: Optional[tuple] = None self.simulation_hub = simulation_hub # Store the hub instance + self.update_queue = update_queue def connect(self, config: Dict[str, Any]) -> bool: """ @@ -65,7 +67,7 @@ class SFPCommunicator(CommunicatorInterface): payload_handlers = {} if self.simulation_hub: self.logger.info("Simulation hub provided. Setting up simulation payload handlers.") - sim_handler = SimulationPayloadHandler(self.simulation_hub) + sim_handler = SimulationPayloadHandler(self.simulation_hub, update_queue=self.update_queue) payload_handlers = sim_handler.get_handlers() else: self.logger.warning("No simulation hub provided. SFP communicator will only send data.") diff --git a/target_simulator/core/simulation_payload_handler.py b/target_simulator/core/simulation_payload_handler.py index c4a48a6..dd4203f 100644 --- a/target_simulator/core/simulation_payload_handler.py +++ b/target_simulator/core/simulation_payload_handler.py @@ -5,7 +5,8 @@ radar status messages and updates the SimulationStateHub. """ import logging -from typing import Dict, Callable +from typing import Dict, Callable, Optional +from queue import Queue, Full from target_simulator.core.sfp_structures import SfpRisStatusPayload from target_simulator.analysis.simulation_state_hub import SimulationStateHub @@ -19,7 +20,7 @@ class SimulationPayloadHandler: SimulationStateHub with real-time data from the radar. """ - def __init__(self, simulation_hub: SimulationStateHub): + def __init__(self, simulation_hub: SimulationStateHub, update_queue: Optional[Queue] = None): """ Initializes the handler. @@ -28,6 +29,8 @@ class SimulationPayloadHandler: """ self.logger = logging.getLogger(__name__) self._hub = simulation_hub + # Optional queue to notify the GUI that new real states arrived + self._update_queue = update_queue def get_handlers(self) -> Dict[int, PayloadHandlerFunc]: """ @@ -46,6 +49,7 @@ class SimulationPayloadHandler: Parses an SFP_RIS::status_message_t payload and updates the hub. """ payload_size = len(payload) + self.logger.debug(f"Received RIS payload of {payload_size} bytes") expected_size = SfpRisStatusPayload.size() if payload_size < expected_size: @@ -66,6 +70,7 @@ class SimulationPayloadHandler: radar_timestamp_s = radar_timestamp_ms / 1000.0 # Iterate through all possible targets in the status message + processed = 0 for i, ris_target in enumerate(parsed_payload.tgt.tgt): # A non-zero flag typically indicates an active or valid track if ris_target.flags != 0: @@ -79,6 +84,21 @@ class SimulationPayloadHandler: timestamp=radar_timestamp_s, state=state ) + processed += 1 + # Notify GUI to refresh display (we enqueue an empty list which + # the main GUI loop treats as a trigger to rebuild data from hub) + if self._update_queue: + try: + self._update_queue.put_nowait([]) + except Full: + # If the queue is full, it's fine to skip this notification + self.logger.debug("GUI update queue full; skipping real-state notification.") + # Log processed count and current hub target IDs for diagnostics + try: + hub_ids = self._hub.get_all_target_ids() + except Exception: + hub_ids = None + self.logger.info(f"Processed {processed} real targets from RIS payload. Hub IDs={hub_ids}") except Exception: self.logger.exception("Error parsing RIS status payload.") \ No newline at end of file diff --git a/target_simulator/gui/connection_settings_window.py b/target_simulator/gui/connection_settings_window.py index 499baec..3ed6cfd 100644 --- a/target_simulator/gui/connection_settings_window.py +++ b/target_simulator/gui/connection_settings_window.py @@ -30,9 +30,15 @@ class ConnectionSettingsWindow(tk.Toplevel): main_y = master.winfo_rooty() main_w = master.winfo_width() main_h = master.winfo_height() - # Use requested size so the dialog is large enough to show all widgets - win_w = self.winfo_reqwidth() - win_h = self.winfo_reqheight() + # Use requested size so the dialog is large enough to show all widgets. + # Some test doubles or minimal masters may not implement winfo_reqwidth/ + # winfo_reqheight, so fall back to winfo_width/height when necessary. + try: + win_w = self.winfo_reqwidth() + win_h = self.winfo_reqheight() + except Exception: + win_w = self.winfo_width() + win_h = self.winfo_height() x = main_x + (main_w // 2) - (win_w // 2) y = main_y + (main_h // 2) - (win_h // 2) self.geometry(f"{win_w}x{win_h}+{x}+{y}") diff --git a/target_simulator/gui/main_view.py b/target_simulator/gui/main_view.py index 0751f48..415caed 100644 --- a/target_simulator/gui/main_view.py +++ b/target_simulator/gui/main_view.py @@ -7,6 +7,7 @@ import tkinter as tk from tkinter import ttk, scrolledtext, messagebox from queue import Queue, Empty from typing import Optional, Dict, Any, List +import time # Use absolute imports for robustness and clarity from target_simulator.gui.ppi_display import PPIDisplay @@ -28,6 +29,7 @@ from target_simulator.core.sfp_communicator import SFPCommunicator from target_simulator.analysis.simulation_state_hub import SimulationStateHub from target_simulator.analysis.performance_analyzer import PerformanceAnalyzer from target_simulator.gui.analysis_window import AnalysisWindow +from target_simulator.core import command_builder GUI_QUEUE_POLL_INTERVAL_MS = 100 @@ -46,6 +48,9 @@ class MainView(tk.Tk): self.scan_limit = settings.get("scan_limit", 60) self.max_range = settings.get("max_range", 100) self.connection_config = self.config_manager.get_connection_settings() + + # Defer establishing SFP receive connection until simulation start + self.defer_sfp_connection = True # --- Initialize the data hub and analyzer --- self.simulation_hub = SimulationStateHub() @@ -183,6 +188,11 @@ class MainView(tk.Tk): engine_frame, text="Reset State", command=self._on_reset_simulation ) self.reset_button.pack(side=tk.RIGHT, padx=5, pady=5) + + self.reset_radar_button = ttk.Button( + engine_frame, text="Reset Radar", command=self._reset_radar_state + ) + self.reset_radar_button.pack(side=tk.RIGHT, padx=5, pady=5) ttk.Label(engine_frame, text="Speed:").pack(side=tk.LEFT, padx=(10, 2), pady=5) self.time_multiplier_var = tk.StringVar(value="1x") @@ -328,7 +338,7 @@ class MainView(tk.Tk): def _setup_communicator( self, config: dict, name: str - ) -> (Optional[CommunicatorInterface], bool): + ) -> tuple[Optional[CommunicatorInterface], bool]: comm_type = config.get("type") self.logger.info(f"Initializing {name} communicator of type: {comm_type}") @@ -342,9 +352,12 @@ class MainView(tk.Tk): communicator = TFTPCommunicator() config_data = config.get("tftp", {}) elif comm_type == "sfp": - # --- MODIFICATION: Pass the hub to the communicator --- - communicator = SFPCommunicator(simulation_hub=self.simulation_hub) + # --- MODIFICATION: Pass the hub and GUI update queue to the communicator --- + communicator = SFPCommunicator(simulation_hub=self.simulation_hub, update_queue=self.gui_update_queue) config_data = config.get("sfp", {}) + if self.defer_sfp_connection: + # Return the communicator object but indicate it's not yet connected + return communicator, False if communicator and config_data: if communicator.connect(config_data): @@ -366,11 +379,89 @@ class MainView(tk.Tk): def _on_connect_button(self): self.logger.info("Connection requested by user.") self._initialize_communicators() + + def _reset_radar_state(self) -> bool: + """ + Sends commands to the radar to deactivate all possible targets, effectively + clearing its state before a new simulation starts. + + Returns: + True if the reset commands were sent successfully, False otherwise. + """ + if not self.target_communicator or not self.target_communicator.is_open: + self.logger.error("Cannot reset radar state: communicator is not connected.") + messagebox.showerror("Connection Error", "Cannot reset radar: Not Connected.") + return False + + self.logger.info("Sending reset commands to deactivate all radar targets...") + + # Prefer an atomic reset command to deactivate all targets on the server + # instead of sending many individual tgtinit commands which is slow and + # can cause dropped messages. The server understands 'tgtset /-s' which + # clears all targets atomically. + # Build the atomic reset command. Use command_builder.tgtset if available, + # otherwise build the raw command string. + try: + # Some command_builder implementations may provide build_tgtset; use it if present + if hasattr(command_builder, "build_tgtset"): + reset_command = command_builder.build_tgtset("/-s") + else: + # Fallback: raw command string + reset_command = "tgtset /-s" + except Exception: + # In case command_builder raises for unexpected inputs, fallback to raw + self.logger.exception("Error while building atomic reset command; falling back to raw string.") + reset_command = "tgtset /-s" + + # Send the single atomic reset command using the communicator's + # send_commands API which accepts a list of commands. + if not self.target_communicator.send_commands([reset_command]): + self.logger.error("Failed to send atomic reset command to the radar.") + messagebox.showerror("Reset Error", "Failed to send reset command to the radar.") + return False + + self.logger.info("Successfully sent atomic reset command: tgtset /-s") + # Poll the simulation hub for up to a short timeout to ensure the server + # processed the reset and returned no active targets. + timeout_s = 3.0 + poll_interval = 0.2 + waited = 0.0 + while waited < timeout_s: + # If there are no real target entries in the hub, assume reset succeeded + if not self.simulation_hub.get_all_target_ids(): + self.logger.info("Radar reported zero active targets after reset.") + return True + time.sleep(poll_interval) + waited += poll_interval + + # If we reach here, the hub still reports targets — treat as failure + self.logger.error("Radar did not clear targets after reset within timeout.") + messagebox.showerror("Reset Error", "Radar did not clear targets after reset.") + return False def _on_start_simulation(self): if self.is_simulation_running.get(): self.logger.info("Simulation is already running.") return + # If communicator exists but connection was deferred, try to connect now + if self.target_communicator and not self.target_communicator.is_open: + try: + sfp_cfg = self.connection_config.get("target", {}).get("sfp", {}) + if sfp_cfg: + self.logger.info("Attempting deferred/auto connect for target communicator before starting simulation.") + if not self.target_communicator.connect(sfp_cfg): + messagebox.showerror( + "Not Connected", + "Target communicator is not connected. Please check settings and connect.", + ) + return + except Exception: + self.logger.exception("Exception while attempting auto-connect for target communicator.") + messagebox.showerror( + "Not Connected", + "Target communicator is not connected. Please check settings and connect.", + ) + return if not self.target_communicator or not self.target_communicator.is_open: messagebox.showerror( "Not Connected", @@ -400,6 +491,26 @@ class MainView(tk.Tk): self.logger.info("Resetting simulation data hub and PPI trails.") self.simulation_hub.reset() self.ppi_widget.clear_trails() + # If SFP reception was deferred, establish SFP connection now so we + # can receive server state updates during simulation. + if hasattr(self.target_communicator, "connect") and not self.target_communicator.is_open: + try: + # Try to connect with stored config; if connect fails, we abort + sfp_cfg = self.connection_config.get("target", {}).get("sfp", {}) + if sfp_cfg: + self.logger.info("Deferred SFP connect: attempting to connect for simulation start.") + if not self.target_communicator.connect(sfp_cfg): + self.logger.error("Failed to connect SFP communicator at simulation start.") + messagebox.showerror("Connection Error", "Failed to establish SFP receive connection.") + return + except Exception: + self.logger.exception("Exception while attempting deferred SFP connect.") + messagebox.showerror("Connection Error", "Exception while establishing SFP receive connection.") + return + + if not self._reset_radar_state(): + self.logger.error("Aborting simulation start due to radar reset failure.") + return self.logger.info( "Sending initial scenario state before starting live updates..." @@ -443,6 +554,19 @@ class MainView(tk.Tk): self.logger.info("Stopping live simulation...") self.simulation_engine.stop() self.simulation_engine = None + # Also disconnect the target communicator (SFP) so we stop receiving from server + try: + if self.target_communicator and getattr(self.target_communicator, "is_open", False): + self.logger.info("Disconnecting target communicator (SFP) after simulation stop.") + try: + self.target_communicator.disconnect() + except Exception: + self.logger.exception("Error while disconnecting target communicator.") + # Update visual status + self._update_communicator_status("Target", False) + except Exception: + self.logger.exception("Unexpected error while attempting to disconnect target communicator.") + self.is_simulation_running.set(False) self._update_button_states() @@ -464,15 +588,26 @@ class MainView(tk.Tk): self._on_stop_simulation() elif isinstance(update, list): - # This update is the list of simulated targets from the engine - simulated_targets: List[Target] = update - - # Update the target list view with detailed simulated data - self.target_list.update_target_list(simulated_targets) - - # For the PPI, build the comparative data structure from the hub - display_data = self._build_display_data_from_hub() - self.ppi_widget.update_targets(display_data) + # The engine normally enqueues a List[Target] (simulated targets). + # However, the simulation payload handler uses an empty list [] + # as a lightweight notification that real states were added to + # the hub. Distinguish the two cases: + if len(update) == 0: + # Empty-list used as a hub refresh notification. Do not + # clear the target list; just rebuild the PPI from the hub. + self.logger.debug("Received hub refresh notification from GUI queue.") + display_data = self._build_display_data_from_hub() + self.ppi_widget.update_targets(display_data) + else: + # This update is the list of simulated targets from the engine + simulated_targets: List[Target] = update + + # Update the target list view with detailed simulated data + self.target_list.update_target_list(simulated_targets) + + # For the PPI, build the comparative data structure from the hub + display_data = self._build_display_data_from_hub() + self.ppi_widget.update_targets(display_data) except Empty: # If the queue is empty, we don't need to do anything @@ -488,7 +623,8 @@ class MainView(tk.Tk): analysis_state = tk.NORMAL if is_running or has_data_to_analyze else tk.DISABLED state = tk.DISABLED if is_running else tk.NORMAL - + + self.reset_radar_button.config(state=state) self.start_button.config(state=tk.DISABLED if is_running else tk.NORMAL) self.stop_button.config(state=tk.NORMAL if is_running else tk.DISABLED) self.analysis_button.config(state=analysis_state) @@ -775,6 +911,9 @@ class MainView(tk.Tk): setattr(sim_target, '_pos_y_ft', y) setattr(sim_target, '_pos_z_ft', z) sim_target._update_current_polar_coords() + # Ensure this lightweight target is treated as active for display + sim_target.active = True + sim_target.traceable = True simulated_targets_for_ppi.append(sim_target) # --- Process Real Data --- @@ -787,8 +926,21 @@ class MainView(tk.Tk): setattr(real_target, '_pos_y_ft', y) setattr(real_target, '_pos_z_ft', z) real_target._update_current_polar_coords() + # Ensure this lightweight target is treated as active for display + real_target.active = True + real_target.traceable = True real_targets_for_ppi.append(real_target) + # Diagnostic logging: show how many targets will be passed to the PPI + try: + self.logger.debug( + "PPIDisplay will receive simulated=%d real=%d targets", + len(simulated_targets_for_ppi), + len(real_targets_for_ppi), + ) + except Exception: + pass + return {"simulated": simulated_targets_for_ppi, "real": real_targets_for_ppi} def _open_analysis_window(self): diff --git a/target_simulator/gui/ppi_display.py b/target_simulator/gui/ppi_display.py index dadfaae..51bd664 100644 --- a/target_simulator/gui/ppi_display.py +++ b/target_simulator/gui/ppi_display.py @@ -92,6 +92,20 @@ class PPIDisplay(ttk.Frame): 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.""" @@ -99,20 +113,34 @@ class PPIDisplay(ttk.Frame): 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) + 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()) - angles = np.arange(0, 360, 30) - labels = [f"{angle}°" for angle in angles] - self.ax.set_thetagrids(angles, labels) + # 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")