300 lines
13 KiB
Python
300 lines
13 KiB
Python
# 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)
|