S1005403_RisCC/target_simulator/simulation/simulation_controller.py
2025-11-13 10:57:10 +01:00

318 lines
14 KiB
Python

# target_simulator/simulation/simulation_controller.py
"""
SimulationController orchestration.
Cosa fa: coordina avvio, stop e salvataggio di simulazioni; reset radar e invio scenari.
Principali: SimulationController
Ingressi/Uscite: interagisce con MainView e CommunicatorManager (side-effecting).
"""
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
# Capture the current ownship state as the simulation's fixed origin
self.logger.info("Capturing current ownship state as simulation origin.")
initial_ownship_state = self.simulation_hub.get_ownship_state()
self.simulation_hub.set_simulation_origin(initial_ownship_state)
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=None
) # Get all available samples
if samples:
# Convert to [timestamp, latency_ms] format
samples_with_time = [
[round(ts, 3), round(lat * 1000.0, 3)]
for ts, lat in samples
]
extra_metadata["latency_samples"] = samples_with_time
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)