# target_simulator/simulation/simulation_controller.py import time import threading from typing import Optional from tkinter import messagebox from target_simulator.core.simulation_engine import SimulationEngine from target_simulator.analysis.simulation_archive import SimulationArchive from target_simulator.core import command_builder class SimulationController: """Orchestrates simulation start/stop/reset logic extracted from MainView.""" def __init__(self, communicator_manager, simulation_hub, config_manager, logger): self.communicator_manager = communicator_manager self.simulation_hub = simulation_hub self.config_manager = config_manager self.logger = logger self.simulation_engine: Optional[SimulationEngine] = None self.current_archive: Optional[SimulationArchive] = None def _reset_radar_state_no_ui(self, main_view) -> bool: """Runs the reset sequence without showing UI popups. Safe for threads.""" target_comm = getattr(self.communicator_manager, "target_communicator", None) if not target_comm or not getattr(target_comm, "is_open", False): self.logger.error("Cannot reset radar: communicator is not connected.") return False use_json = getattr(target_comm, "_use_json_protocol", False) def _wait_for_clear(timeout_s: float) -> bool: start_time = time.monotonic() while time.monotonic() - start_time < timeout_s: if not self.simulation_hub.has_active_real_targets(): return True time.sleep(0.2) return False if not use_json: self.logger.info("Sending legacy reset command...") if not target_comm.send_commands(["tgtset /-s\n"]): self.logger.error("Failed to send legacy reset command.") return False if not _wait_for_clear(3.0): self.logger.error("Legacy reset failed: server did not clear targets.") return False return True # JSON path with fallback self.logger.info("Sending primary JSON reset command...") if target_comm.send_commands(['{"CMD":"reset"}\n']): if _wait_for_clear(1.0): self.logger.info("Primary JSON reset successful.") return True self.logger.warning("Primary JSON reset timed out. Trying fallback...") else: self.logger.warning("Primary JSON reset command failed. Trying fallback...") self.logger.info("Sending fallback per-ID JSON reset payloads...") try: json_payloads = command_builder.build_json_reset_ids() except Exception as e: self.logger.exception(f"Failed to build fallback payloads: {e}") return False for i, payload in enumerate(json_payloads): if not target_comm.send_commands([payload]): self.logger.error(f"Failed to send fallback payload part {i+1}.") return False if not _wait_for_clear(3.0): self.logger.error("Fallback per-ID JSON reset failed to clear targets.") return False self.logger.info("Fallback per-ID JSON reset successful.") return True def start_simulation(self, main_view): """Start live simulation asynchronously.""" target_comm = getattr(self.communicator_manager, "target_communicator", None) if not (target_comm and getattr(target_comm, "is_open", False)): messagebox.showwarning("Not Connected", "Please connect before starting.") main_view._start_in_progress_main = False # Release the guard return if not main_view.scenario or not main_view.scenario.get_all_targets(): messagebox.showinfo("Empty Scenario", "Cannot start an empty scenario.") main_view._start_in_progress_main = False # Release the guard return def _background_start(): self.logger.info("Background start process initiated.") reset_ok = self._reset_radar_state_no_ui(main_view) if not reset_ok: self.logger.error("Radar reset failed. Aborting start.") main_view.after(0, lambda: messagebox.showerror( "Start Failed", "Could not reset radar state. Simulation aborted." )) main_view.after(0, self._finalize_start_failure, main_view) return self.logger.info("Sending initial scenario state...") if not target_comm.send_scenario(main_view.scenario): self.logger.error("Failed to send scenario. Aborting start.") main_view.after(0, lambda: messagebox.showerror( "Start Failed", "Could not send scenario. Aborted." )) main_view.after(0, self._finalize_start_failure, main_view) return try: self.logger.info("Initializing SimulationEngine.") engine = SimulationEngine(communicator=target_comm, simulation_hub=self.simulation_hub) engine.set_update_interval(main_view.update_time.get()) engine.load_scenario(main_view.scenario) main_view.simulation_engine = self.simulation_engine = engine durations = [getattr(t, "_total_duration_s", 0.0) for t in main_view.scenario.get_all_targets()] total_time = max(durations) if durations else 0.0 archive = SimulationArchive(main_view.scenario) engine.archive = archive if hasattr(target_comm, "router"): router = target_comm.router() if router: router.set_archive(archive) self.logger.info("Starting SimulationEngine thread.") engine.start() main_view.after(0, self._finalize_start_success, main_view, archive, total_time) except Exception as e: self.logger.exception(f"Failed to start SimulationEngine: {e}") main_view.after(0, lambda: messagebox.showerror("Start Failed", f"Error: {e}")) main_view.after(0, self._finalize_start_failure, main_view) # UI updates on main thread main_view.show_status_message("Starting simulation...", timeout_ms=None) main_view._update_button_states() self.simulation_hub.reset() if hasattr(main_view, "ppi_widget"): main_view.ppi_widget.clear_trails() threading.Thread(target=_background_start, daemon=True).start() def _finalize_start_success(self, main_view, archive, total_time): """Callback on main thread after a successful background start.""" main_view.current_archive = self.current_archive = archive main_view.total_sim_time = total_time main_view.sim_elapsed_time = 0.0 main_view.simulation_controls.sim_slider_var.set(0.0) main_view._update_simulation_progress_display() if hasattr(main_view, "ppi_widget"): main_view.ppi_widget.clear_previews() main_view.is_simulation_running.set(True) main_view._start_in_progress_main = False main_view._update_button_states() main_view.show_status_message("Simulation running", timeout_ms=None) self.logger.info("Simulation started successfully.") def _finalize_start_failure(self, main_view): """Callback on main thread after a failed background start.""" main_view.is_simulation_running.set(False) main_view._start_in_progress_main = False main_view._update_button_states() main_view.clear_status_message() self.logger.warning("Simulation start process failed and was finalized.") def _stop_or_finish_simulation(self, main_view, was_stopped_by_user: bool): """Unified logic for handling simulation end, either by user or naturally.""" if self.current_archive: # --- NUOVA AGGIUNTA INIZIO --- # Retrieve estimated latency before saving the archive estimated_latency_s = 0.0 extra_metadata = {} try: target_comm = getattr(self.communicator_manager, "target_communicator", None) if target_comm and hasattr(target_comm, 'router'): router = target_comm.router() if router and hasattr(router, 'get_estimated_latency_s'): estimated_latency_s = router.get_estimated_latency_s() if estimated_latency_s > 0: extra_metadata['estimated_latency_ms'] = round(estimated_latency_s * 1000, 2) # Retrieve prediction offset from config conn_settings = self.config_manager.get_connection_settings() target_sfp_cfg = conn_settings.get("target", {}).get("sfp", {}) offset_ms = target_sfp_cfg.get("prediction_offset_ms", 0.0) if offset_ms > 0: extra_metadata['prediction_offset_ms'] = offset_ms except Exception as e: self.logger.warning(f"Could not retrieve estimated latency for archive: {e}") # Also attempt to include latency statistics and recent samples try: if target_comm and hasattr(target_comm, 'router'): router = target_comm.router() if router and hasattr(router, 'get_latency_stats'): stats = router.get_latency_stats(sample_limit=500) if stats and stats.get('count', 0) > 0: extra_metadata['latency_summary'] = stats if router and hasattr(router, 'get_latency_samples'): samples = router.get_latency_samples(limit=200) if samples: # convert to milliseconds and round samples_ms = [round(s * 1000.0, 3) for s in samples[-200:]] extra_metadata['latency_samples_ms'] = samples_ms except Exception as e: self.logger.warning(f"Could not collect latency samples for archive: {e}") # Add simulation parameters (client update interval / send rate) try: update_interval_s = None if hasattr(main_view, "update_time"): try: # update_time is a Tk variable (DoubleVar) in the UI update_interval_s = float(main_view.update_time.get()) except Exception: update_interval_s = None if update_interval_s is not None: extra_metadata['client_update_interval_s'] = round(update_interval_s, 6) if update_interval_s > 0: extra_metadata['client_update_rate_hz'] = round(1.0 / update_interval_s, 3) except Exception as e: self.logger.warning(f"Could not read client update interval for archive: {e}") # --- NUOVA AGGIUNTA FINE --- self.current_archive.save(extra_metadata=extra_metadata) self.current_archive = main_view.current_archive = None target_comm = getattr(self.communicator_manager, "target_communicator", None) if target_comm and hasattr(target_comm, "router"): router = target_comm.router() if router: router.set_archive(None) main_view._refresh_analysis_list() if self.simulation_engine: self.simulation_engine.stop() self.simulation_engine = main_view.simulation_engine = None main_view.is_simulation_running.set(False) main_view._update_button_states() if was_stopped_by_user: main_view.show_status_message("Simulation stopped.", timeout_ms=5000) else: main_view.show_status_message("Simulation finished.", timeout_ms=5000) main_view.simulation_controls.show_notice("Simulation has finished.") def stop_simulation(self, main_view): """Handles user request to stop the simulation.""" if not main_view.is_simulation_running.get(): return self.logger.info("Stopping live simulation (user request)...") self._stop_or_finish_simulation(main_view, was_stopped_by_user=True) def on_simulation_finished(self, main_view): """Handles the natural end of a simulation.""" if not main_view.is_simulation_running.get(): return self.logger.info("Simulation engine finished execution.") self._stop_or_finish_simulation(main_view, was_stopped_by_user=False)