diff --git a/target_simulator/core/sfp_communicator.py b/target_simulator/core/sfp_communicator.py index f0a968a..b0bce72 100644 --- a/target_simulator/core/sfp_communicator.py +++ b/target_simulator/core/sfp_communicator.py @@ -52,7 +52,7 @@ class SFPCommunicator(CommunicatorInterface): # Unified payload router self.payload_router = DebugPayloadRouter( - simulation_hub=simulation_hub, update_queue=update_queue + simulation_hub=simulation_hub ) def _save_json_payload_to_temp(self, content: str, prefix: str): @@ -188,15 +188,6 @@ class SFPCommunicator(CommunicatorInterface): self.logger.exception("Error while shutting down transport") self.transport = None - # Clear the update queue to prevent processing of stale data. - if self.update_queue: - self.logger.info("Clearing GUI update queue of stale messages.") - while not self.update_queue.empty(): - try: - self.update_queue.get_nowait() - except Exception: # Can be Empty, but catch all to be safe - break - self.config = None self._destination = None self._notify_connection_state_changed() diff --git a/target_simulator/core/simulation_engine.py b/target_simulator/core/simulation_engine.py index a9adeef..6a7b9ea 100644 --- a/target_simulator/core/simulation_engine.py +++ b/target_simulator/core/simulation_engine.py @@ -26,14 +26,12 @@ class SimulationEngine(threading.Thread): def __init__( self, communicator: Optional[CommunicatorInterface], - update_queue: Optional[Queue], simulation_hub: Optional[SimulationStateHub] = None, ): super().__init__(daemon=True, name="SimulationEngineThread") self.logger = get_logger(__name__) self.communicator = communicator - self.update_queue = update_queue self.simulation_hub = simulation_hub # Hub for data analysis self.time_multiplier = 1.0 self.update_interval_s = 1.0 @@ -111,37 +109,30 @@ class SimulationEngine(threading.Thread): simulated_delta_time = delta_time * self.time_multiplier self.scenario.update_state(simulated_delta_time) - updated_targets = self.scenario.get_all_targets() + + # --- High-Frequency State Logging --- + tick_timestamp = time.monotonic() + active_targets = [t for t in self.scenario.get_all_targets() if t.active] + + if self.simulation_hub: + for target in active_targets: + state_tuple = ( + getattr(target, "_pos_x_ft", 0.0), + getattr(target, "_pos_y_ft", 0.0), + getattr(target, "_pos_z_ft", 0.0), + ) + self.simulation_hub.add_simulated_state( + target.target_id, tick_timestamp, state_tuple + ) if self.scenario.is_finished(): self.logger.info("Scenario finished. Stopping engine.") - if self.update_queue: - self.update_queue.put_nowait(updated_targets) - self.update_queue.put_nowait("SIMULATION_FINISHED") break - # --- Communication, Data Hub, and GUI Update Step --- + # --- Throttled Communication Step --- if (current_time - self._last_update_time) >= self.update_interval_s: self._last_update_time = current_time - # Prepare batch timestamp and active targets once per update - timestamp_for_batch = time.monotonic() - active_targets = [t for t in updated_targets if t.active] - - # Always log simulated state for all active targets to the hub for analysis - # This should happen regardless of whether a communicator is present - # so that offline analysis (GUI only) still has simulated data. - if self.simulation_hub: - for target in active_targets: - state_tuple = ( - getattr(target, "_pos_x_ft", 0.0), - getattr(target, "_pos_y_ft", 0.0), - getattr(target, "_pos_z_ft", 0.0), - ) - self.simulation_hub.add_simulated_state( - target.target_id, timestamp_for_batch, state_tuple - ) - if self.communicator and self.communicator.is_open: commands_to_send = [] @@ -162,7 +153,7 @@ class SimulationEngine(threading.Thread): getattr(target, "_pos_z_ft", 0.0), ) append_sent_position( - timestamp_for_batch, + tick_timestamp, target.target_id, state_tuple[0], state_tuple[1], @@ -182,7 +173,7 @@ class SimulationEngine(threading.Thread): getattr(target, "_pos_z_ft", 0.0), ) append_sent_position( - timestamp_for_batch, + tick_timestamp, target.target_id, state_tuple[0], state_tuple[1], @@ -194,15 +185,6 @@ class SimulationEngine(threading.Thread): if commands_to_send: self.communicator.send_commands(commands_to_send) - # Update the GUI queue - if self.update_queue: - try: - self.update_queue.put_nowait(updated_targets) - except Queue.Full: - self.logger.warning( - "GUI update queue is full. A frame was skipped." - ) - time.sleep(TICK_INTERVAL_S) self._is_running_event.clear() diff --git a/target_simulator/gui/main_view.py b/target_simulator/gui/main_view.py index 8ec2753..859a0fe 100644 --- a/target_simulator/gui/main_view.py +++ b/target_simulator/gui/main_view.py @@ -35,6 +35,7 @@ from target_simulator.core import command_builder GUI_QUEUE_POLL_INTERVAL_MS = 100 +GUI_REFRESH_RATE_MS = 40 class MainView(tk.Tk): @@ -68,7 +69,6 @@ class MainView(tk.Tk): # --- Simulation Engine --- self.simulation_engine: Optional[SimulationEngine] = None - self.gui_update_queue = Queue() self.is_simulation_running = tk.BooleanVar(value=False) self.time_multiplier = 1.0 self.update_time = tk.DoubleVar(value=1.0) @@ -99,13 +99,10 @@ class MainView(tk.Tk): self._update_window_title() self.protocol("WM_DELETE_WINDOW", self._on_closing) self.logger.info("MainView initialized successfully.") + + # Start the new rendering loop + self.after(GUI_REFRESH_RATE_MS, self._gui_refresh_loop) - # Always poll the GUI update queue so the main PPI receives real-time - # hub updates even when the live simulation engine is not running. - try: - self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue) - except Exception: - self.logger.exception("Failed to schedule GUI queue polling") # Schedule periodic rate status updates (shows events/sec for real inputs and PPI updates) try: # Start after one second to allow initial state to settle @@ -594,9 +591,9 @@ class MainView(tk.Tk): communicator = TFTPCommunicator() config_data = config.get("tftp", {}) elif comm_type == "sfp": - # --- MODIFICATION: Pass the hub and GUI update queue to the communicator --- + # --- MODIFICATION: Do not pass update_queue --- communicator = SFPCommunicator( - simulation_hub=self.simulation_hub, update_queue=self.gui_update_queue + simulation_hub=self.simulation_hub ) communicator.add_connection_state_callback(self._on_connection_state_change) config_data = config.get("sfp", {}) @@ -832,8 +829,6 @@ class MainView(tk.Tk): self.logger.error("Aborting simulation start due to radar reset failure.") return - # MODIFICATION: Add a short delay to allow the server to process the reset - # before it receives the new scenario initialization commands. time.sleep(1) # 1 second delay self.logger.info( @@ -851,14 +846,11 @@ class MainView(tk.Tk): self.logger.info("Initial scenario state sent successfully.") self.logger.info("Starting live simulation...") - self.is_simulation_running.set(True) - self._update_button_states() self.scenario.reset_simulation() self.simulation_engine = SimulationEngine( communicator=self.target_communicator, - update_queue=self.gui_update_queue, simulation_hub=self.simulation_hub, ) @@ -886,7 +878,9 @@ class MainView(tk.Tk): self.simulation_engine.start() - self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue) + # Set running state and update buttons AFTER starting the thread + self.is_simulation_running.set(True) + self._update_button_states() def _on_stop_simulation(self): if not self.is_simulation_running.get() or not self.simulation_engine: @@ -918,29 +912,19 @@ class MainView(tk.Tk): return def _on_simulation_finished(self): - """Handle the natural end-of-simulation event coming from the engine. - - This should update the UI and inform the user, but must NOT disconnect the - communicator — the connection stays active until the user explicitly disconnects. - """ + """Handle the natural end-of-simulation event.""" self.logger.info("Handling simulation finished (engine signalled completion).") - # If engine still exists, stop it cleanly - try: - if self.simulation_engine and getattr(self.simulation_engine, "is_running", False): - try: - self.simulation_engine.stop() - except Exception: - self.logger.exception("Error while stopping finished simulation engine") - self.simulation_engine = None - except Exception: - self.logger.exception("Error while handling simulation finished cleanup") + if self.simulation_engine and self.simulation_engine.is_running(): + try: + self.simulation_engine.stop() + except Exception: + self.logger.exception("Error while stopping finished simulation engine") + self.simulation_engine = None - # Mark as not running and update UI (but keep connection state as-is) self.is_simulation_running.set(False) self._update_button_states() - # Notify the user in English that the simulation finished try: messagebox.showinfo("Simulation Finished", "The live simulation has completed.") except Exception: @@ -955,121 +939,88 @@ class MainView(tk.Tk): self._update_all_views() def _process_gui_queue(self): + """ + Processes a batch of updates from the GUI queue to keep the UI responsive + without getting stuck in an infinite loop. + """ + MAX_UPDATES_PER_CYCLE = 100 # Process up to 100 messages per call try: - # We process one update at a time to keep the GUI responsive - update = self.gui_update_queue.get_nowait() - # try: - # self.logger.debug(f"MainView: dequeued GUI update (type={type(update)}) from queue id={id(self.gui_update_queue)}") - # except Exception: - # pass - - if update == "SIMULATION_FINISHED": - self.logger.info("Simulation finished signal received.") - # Ensure engine is stopped and UI reset (do not disconnect communicator) - self._on_simulation_finished() - # Reset progress UI to final state + for _ in range(MAX_UPDATES_PER_CYCLE): try: - self.sim_elapsed_time = self.total_sim_time - self.sim_slider_var.set(1.0 if self.total_sim_time > 0 else 0.0) - except Exception: - pass - self._update_simulation_progress_display() + update = self.gui_update_queue.get_nowait() - elif isinstance(update, list): - # 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: - # Hub refresh notification (real data arrived). - # Only update the 'real' targets on the PPI display. - # self.logger.debug("MainView: received hub refresh. Updating real targets.") - display_data = self._build_display_data_from_hub() - self.ppi_widget.update_real_targets(display_data.get("real", [])) - # Also propagate platform/antenna azimuth (if available) so the PPI - # can render the antenna orientation. The hub stores a (az_deg, ts) - # tuple via set_platform_azimuth(). - try: - if ( - hasattr(self, "simulation_hub") - and self.simulation_hub is not None - and hasattr(self.ppi_widget, "update_antenna_azimuth") - ): + if update == "SIMULATION_FINISHED": + self.logger.info("Simulation finished signal received.") + self._on_simulation_finished() + try: + self.sim_elapsed_time = self.total_sim_time + self.sim_slider_var.set(1.0 if self.total_sim_time > 0 else 0.0) + except Exception: + pass + self._update_simulation_progress_display() + + elif isinstance(update, list): + if len(update) == 0: + # Hub refresh notification (real data arrived). + display_data = self._build_display_data_from_hub() + self.ppi_widget.update_real_targets(display_data.get("real", [])) try: - # Prefer the new API name, fall back to the legacy - if hasattr(self.simulation_hub, "get_antenna_azimuth"): - az_deg, az_ts = ( - self.simulation_hub.get_antenna_azimuth() - ) - else: - az_deg, az_ts = ( - self.simulation_hub.get_platform_azimuth() - ) - - if az_deg is not None: - # pass the hub-provided timestamp if available - self.ppi_widget.update_antenna_azimuth( - az_deg, timestamp=az_ts - ) + if ( + hasattr(self, "simulation_hub") + and self.simulation_hub is not None + and hasattr(self.ppi_widget, "update_antenna_azimuth") + ): + if hasattr(self.simulation_hub, "get_antenna_azimuth"): + az_deg, az_ts = self.simulation_hub.get_antenna_azimuth() + else: + az_deg, az_ts = self.simulation_hub.get_platform_azimuth() + + if az_deg is not None: + self.ppi_widget.update_antenna_azimuth( + az_deg, timestamp=az_ts + ) except Exception: - # don't allow GUI update failures to interrupt queue processing self.logger.debug( "Failed to propagate antenna azimuth to PPI", exc_info=True, ) - except Exception: - pass - - else: - # This is an update with simulated targets from the engine. - # Only update the 'simulated' targets on the PPI and the target list. - simulated_targets: List[Target] = update - # self.logger.debug(f"MainView: received simulation update for {len(simulated_targets)} targets.") - - # Update the target list view with detailed simulated data - self.target_list.update_target_list(simulated_targets) - - # Update only the simulated targets on the PPI - self.ppi_widget.update_simulated_targets(simulated_targets) - - # Update simulation progress bar - try: - # Use the engine's scenario simulated time as elapsed if available - if self.simulation_engine and self.simulation_engine.scenario: - # Derive elapsed as the max of target sim times - times = [ - getattr(t, "_sim_time_s", 0.0) - for t in self.simulation_engine.scenario.get_all_targets() - ] - self.sim_elapsed_time = max(times) if times else 0.0 else: - self.sim_elapsed_time += 0.0 + # This is an update with simulated targets from the engine. + simulated_targets: List[Target] = update + self.target_list.update_target_list(simulated_targets) + self.ppi_widget.update_simulated_targets(simulated_targets) - # Update slider only if user is not interacting with it - if self.total_sim_time > 0 and not getattr( - self, "_slider_is_dragging", False - ): - progress_frac = min( - 1.0, - max(0.0, self.sim_elapsed_time / self.total_sim_time), - ) - self.sim_slider_var.set(progress_frac) + # Update simulation progress bar + try: + if self.simulation_engine and self.simulation_engine.scenario: + times = [ + getattr(t, "_sim_time_s", 0.0) + for t in self.simulation_engine.scenario.get_all_targets() + ] + self.sim_elapsed_time = max(times) if times else 0.0 - self._update_simulation_progress_display() - except Exception: - # Do not allow progress UI failures to interrupt GUI updates - self.logger.debug("Progress UI update failed", exc_info=True) + if self.total_sim_time > 0 and not getattr( + self, "_slider_is_dragging", False + ): + progress_frac = min( + 1.0, + max(0.0, self.sim_elapsed_time / self.total_sim_time), + ) + self.sim_slider_var.set(progress_frac) - except Empty: - # If the queue is empty, we don't need to do anything - pass + self._update_simulation_progress_display() + except Exception: + self.logger.debug("Progress UI update failed", exc_info=True) + + except Empty: + # Queue is empty, we can stop processing for this cycle. + break finally: - # Always continue polling the GUI update queue so we can show - # real-time server updates on the PPI even when the live - # simulation engine is not running. + # Always reschedule the next poll. try: self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue) except Exception: + # This can happen on shutdown, just ignore. pass def _update_button_states(self): @@ -1643,3 +1594,52 @@ class MainView(tk.Tk): self.analysis_window = AnalysisWindow( self, analyzer=self.performance_analyzer, hub=self.simulation_hub ) + + def _gui_refresh_loop(self): + """ + Main GUI refresh loop. Runs at a fixed rate, pulls the latest data + from the hub, and updates the PPI display. + """ + # Check if the simulation has finished + sim_was_running = self.is_simulation_running.get() + sim_is_running_now = ( + self.simulation_engine is not None and self.simulation_engine.is_running() + ) + + if sim_was_running and not sim_is_running_now: + self._on_simulation_finished() + + # Update PPI with the latest data from the hub + display_data = self._build_display_data_from_hub() + self.ppi_widget.update_simulated_targets(display_data.get("simulated", [])) + self.ppi_widget.update_real_targets(display_data.get("real", [])) + + # Update antenna azimuth + try: + if self.simulation_hub and hasattr(self.simulation_hub, "get_antenna_azimuth"): + az_deg, az_ts = self.simulation_hub.get_antenna_azimuth() + if az_deg is not None: + self.ppi_widget.update_antenna_azimuth(az_deg, timestamp=az_ts) + except Exception: + pass + + # Update progress bar if the simulation is running + if sim_is_running_now: + try: + if self.simulation_engine and self.simulation_engine.scenario: + times = [ + getattr(t, "_sim_time_s", 0.0) + for t in self.simulation_engine.scenario.get_all_targets() + ] + self.sim_elapsed_time = max(times) if times else 0.0 + + if self.total_sim_time > 0 and not self._slider_is_dragging: + progress = min(1.0, self.sim_elapsed_time / self.total_sim_time) + self.sim_slider_var.set(progress) + + self._update_simulation_progress_display() + except Exception: + self.logger.debug("Progress UI update failed", exc_info=True) + + # Reschedule the next refresh cycle + self.after(GUI_REFRESH_RATE_MS, self._gui_refresh_loop) diff --git a/target_simulator/gui/payload_router.py b/target_simulator/gui/payload_router.py index fb1e79c..b458e2b 100644 --- a/target_simulator/gui/payload_router.py +++ b/target_simulator/gui/payload_router.py @@ -39,7 +39,6 @@ class DebugPayloadRouter: def __init__( self, simulation_hub: Optional[SimulationStateHub] = None, - update_queue: Optional[Queue] = None, ): self._log_prefix = "[DebugPayloadRouter]" self._lock = threading.Lock() @@ -50,7 +49,6 @@ class DebugPayloadRouter: self._persist = False self._hub = simulation_hub - self._update_queue = update_queue # Listeners for real-time target data broadcasts self._ris_target_listeners: List[TargetListListener] = [] @@ -73,7 +71,7 @@ class DebugPayloadRouter: ord("r"): self._handle_ris_status, } logger.info( - f"{self._log_prefix} Initialized (Hub: {self._hub is not None}, Queue: {self._update_queue is not None})." + f"{self._log_prefix} Initialized (Hub: {self._hub is not None})." ) self._logger = logger @@ -216,13 +214,13 @@ class DebugPayloadRouter: "Failed to propagate heading to hub", exc_info=True ) - if self._update_queue: - try: - self._update_queue.put_nowait([]) - except Full: - self._logger.warning( - f"{self._log_prefix} GUI update queue is full; dropped notification." - ) + #if self._update_queue: + # try: + # self._update_queue.put_nowait([]) + # except Full: + # self._logger.warning( + # f"{self._log_prefix} GUI update queue is full; dropped notification." + # ) except Exception: self._logger.exception( "DebugPayloadRouter: Failed to process RIS for Hub."