sistemato salvataggio dati simulazione, e meccanizzazione processi di selezione dello scenario

This commit is contained in:
VALLONGOL 2025-11-03 11:00:34 +01:00
parent 1a223215c4
commit 8a4f621f0c
12 changed files with 382 additions and 141 deletions

View File

@ -3,7 +3,7 @@
"scan_limit": 60, "scan_limit": 60,
"max_range": 100, "max_range": 100,
"geometry": "1599x1089+587+179", "geometry": "1599x1089+587+179",
"last_selected_scenario": "corto", "last_selected_scenario": "scenario1",
"connection": { "connection": {
"target": { "target": {
"type": "sfp", "type": "sfp",

View File

@ -17,6 +17,7 @@ DEFAULT_VERSION = "0.0.0+unknown"
DEFAULT_COMMIT = "Unknown" DEFAULT_COMMIT = "Unknown"
DEFAULT_BRANCH = "Unknown" DEFAULT_BRANCH = "Unknown"
# --- Helper Function --- # --- Helper Function ---
def get_version_string(format_string=None): def get_version_string(format_string=None):
""" """
@ -44,28 +45,38 @@ def get_version_string(format_string=None):
replacements = {} replacements = {}
try: try:
replacements['version'] = __version__ if __version__ else DEFAULT_VERSION replacements["version"] = __version__ if __version__ else DEFAULT_VERSION
replacements['commit'] = GIT_COMMIT_HASH if GIT_COMMIT_HASH else DEFAULT_COMMIT replacements["commit"] = GIT_COMMIT_HASH if GIT_COMMIT_HASH else DEFAULT_COMMIT
replacements['commit_short'] = GIT_COMMIT_HASH[:7] if GIT_COMMIT_HASH and len(GIT_COMMIT_HASH) >= 7 else DEFAULT_COMMIT replacements["commit_short"] = (
replacements['branch'] = GIT_BRANCH if GIT_BRANCH else DEFAULT_BRANCH GIT_COMMIT_HASH[:7]
replacements['timestamp'] = BUILD_TIMESTAMP if BUILD_TIMESTAMP else "Unknown" if GIT_COMMIT_HASH and len(GIT_COMMIT_HASH) >= 7
replacements['timestamp_short'] = BUILD_TIMESTAMP.split('T')[0] if BUILD_TIMESTAMP and 'T' in BUILD_TIMESTAMP else "Unknown" else DEFAULT_COMMIT
replacements['is_git'] = "Git" if IS_GIT_REPO else "Unknown" )
replacements['dirty'] = "-dirty" if __version__ and __version__.endswith('-dirty') else "" replacements["branch"] = GIT_BRANCH if GIT_BRANCH else DEFAULT_BRANCH
replacements["timestamp"] = BUILD_TIMESTAMP if BUILD_TIMESTAMP else "Unknown"
replacements["timestamp_short"] = (
BUILD_TIMESTAMP.split("T")[0]
if BUILD_TIMESTAMP and "T" in BUILD_TIMESTAMP
else "Unknown"
)
replacements["is_git"] = "Git" if IS_GIT_REPO else "Unknown"
replacements["dirty"] = (
"-dirty" if __version__ and __version__.endswith("-dirty") else ""
)
tag = DEFAULT_VERSION tag = DEFAULT_VERSION
if __version__ and IS_GIT_REPO: if __version__ and IS_GIT_REPO:
match = re.match(r'^(v?([0-9]+(?:\.[0-9]+)*))', __version__) match = re.match(r"^(v?([0-9]+(?:\.[0-9]+)*))", __version__)
if match: if match:
tag = match.group(1) tag = match.group(1)
replacements['tag'] = tag replacements["tag"] = tag
output_string = format_string output_string = format_string
for placeholder, value in replacements.items(): for placeholder, value in replacements.items():
pattern = re.compile(r'{{\s*' + re.escape(placeholder) + r'\s*}}') pattern = re.compile(r"{{\s*" + re.escape(placeholder) + r"\s*}}")
output_string = pattern.sub(str(value), output_string) output_string = pattern.sub(str(value), output_string)
if re.search(r'{\s*\w+\s*}', output_string): if re.search(r"{\s*\w+\s*}", output_string):
pass # Or log a warning: print(f"Warning: Unreplaced placeholders found: {output_string}") pass # Or log a warning: print(f"Warning: Unreplaced placeholders found: {output_string}")
return output_string return output_string

View File

@ -16,6 +16,7 @@ class SimulationArchive:
""" """
Gestisce la raccolta dei dati per una singola esecuzione di simulazione e la salva su file. Gestisce la raccolta dei dati per una singola esecuzione di simulazione e la salva su file.
""" """
ARCHIVE_FOLDER = "archive_simulations" ARCHIVE_FOLDER = "archive_simulations"
def __init__(self, scenario: Scenario): def __init__(self, scenario: Scenario):
@ -41,7 +42,9 @@ class SimulationArchive:
except OSError as e: except OSError as e:
print(f"Errore nella creazione della directory di archivio: {e}") print(f"Errore nella creazione della directory di archivio: {e}")
def add_simulated_state(self, target_id: int, timestamp: float, state: Tuple[float, ...]): def add_simulated_state(
self, target_id: int, timestamp: float, state: Tuple[float, ...]
):
"""Aggiunge uno stato simulato all'archivio.""" """Aggiunge uno stato simulato all'archivio."""
if target_id not in self.recorded_data: if target_id not in self.recorded_data:
self.recorded_data[target_id] = {"simulated": [], "real": []} self.recorded_data[target_id] = {"simulated": [], "real": []}
@ -49,7 +52,9 @@ class SimulationArchive:
full_state: RecordedState = (timestamp, state[0], state[1], state[2]) full_state: RecordedState = (timestamp, state[0], state[1], state[2])
self.recorded_data[target_id]["simulated"].append(full_state) self.recorded_data[target_id]["simulated"].append(full_state)
def add_real_state(self, target_id: int, timestamp: float, state: Tuple[float, ...]): def add_real_state(
self, target_id: int, timestamp: float, state: Tuple[float, ...]
):
"""Aggiunge uno stato reale (dal server) all'archivio.""" """Aggiunge uno stato reale (dal server) all'archivio."""
if target_id not in self.recorded_data: if target_id not in self.recorded_data:
self.recorded_data[target_id] = {"simulated": [], "real": []} self.recorded_data[target_id] = {"simulated": [], "real": []}
@ -78,12 +83,14 @@ class SimulationArchive:
} }
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S") ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_scenario_name = "".join(c for c in self.scenario_name if c.isalnum() or c in (' ', '_')).rstrip() safe_scenario_name = "".join(
c for c in self.scenario_name if c.isalnum() or c in (" ", "_")
).rstrip()
filename = f"{ts_str}_{safe_scenario_name}.json" filename = f"{ts_str}_{safe_scenario_name}.json"
filepath = os.path.join(self.ARCHIVE_FOLDER, filename) filepath = os.path.join(self.ARCHIVE_FOLDER, filename)
try: try:
with open(filepath, 'w', encoding='utf-8') as f: with open(filepath, "w", encoding="utf-8") as f:
json.dump(archive_content, f, indent=4) json.dump(archive_content, f, indent=4)
print(f"Archivio di simulazione salvato in: {filepath}") print(f"Archivio di simulazione salvato in: {filepath}")
return filepath return filepath

View File

@ -395,3 +395,22 @@ class SimulationStateHub:
except Exception: except Exception:
# Silently ignore errors (e.g., invalid target_id type) # Silently ignore errors (e.g., invalid target_id type)
pass pass
def clear_simulated_data(self, target_id: Optional[int] = None):
"""
Clears simulated data history for a specific target or for all targets
if target_id is None. This preserves any real data so analysis of
real trajectories is not affected when replacing simulations.
"""
with self._lock:
try:
if target_id is None:
for tid, info in self._target_data.items():
info.get("simulated", collections.deque()).clear()
else:
tid = int(target_id)
if tid in self._target_data:
self._target_data[tid]["simulated"].clear()
except Exception:
# Silently ignore errors to preserve hub stability
pass

View File

@ -51,9 +51,7 @@ class SFPCommunicator(CommunicatorInterface):
) )
# Unified payload router # Unified payload router
self.payload_router = DebugPayloadRouter( self.payload_router = DebugPayloadRouter(simulation_hub=simulation_hub)
simulation_hub=simulation_hub
)
def _save_json_payload_to_temp(self, content: str, prefix: str): def _save_json_payload_to_temp(self, content: str, prefix: str):
"""Saves the JSON payload to a timestamped file in the Temp folder if debug is enabled.""" """Saves the JSON payload to a timestamped file in the Temp folder if debug is enabled."""

View File

@ -27,12 +27,20 @@ class SimulationEngine(threading.Thread):
self, self,
communicator: Optional[CommunicatorInterface], communicator: Optional[CommunicatorInterface],
simulation_hub: Optional[SimulationStateHub] = None, simulation_hub: Optional[SimulationStateHub] = None,
archive: Optional[str] = None archive: Optional[str] = None,
): ):
super().__init__(daemon=True, name="SimulationEngineThread") super().__init__(daemon=True, name="SimulationEngineThread")
self.logger = get_logger(__name__) self.logger = get_logger(__name__)
self.communicator = communicator self.communicator = communicator
# Backwards-compat: older callers passed an update_queue as the
# second positional argument. Detect a Queue and treat it as the
# update queue while leaving simulation_hub unset.
self.update_queue: Optional[Queue] = None
if isinstance(simulation_hub, Queue):
self.update_queue = simulation_hub
self.simulation_hub = None
else:
self.simulation_hub = simulation_hub # Hub for data analysis self.simulation_hub = simulation_hub # Hub for data analysis
self.archive = archive # Archive path if needed self.archive = archive # Archive path if needed
self.time_multiplier = 1.0 self.time_multiplier = 1.0

View File

@ -49,10 +49,13 @@ class AnalysisWindow(tk.Toplevel):
def _load_data_and_setup(self, filepath: str): def _load_data_and_setup(self, filepath: str):
try: try:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, "r", encoding="utf-8") as f:
archive_data = json.load(f) archive_data = json.load(f)
except Exception as e: except Exception as e:
messagebox.showerror("Errore di Caricamento", f"Impossibile caricare il file di archivio.\n{e}") messagebox.showerror(
"Errore di Caricamento",
f"Impossibile caricare il file di archivio.\n{e}",
)
self.destroy() self.destroy()
return return
@ -140,7 +143,9 @@ class AnalysisWindow(tk.Toplevel):
self.stats_tree.pack(fill=tk.BOTH, expand=True) self.stats_tree.pack(fill=tk.BOTH, expand=True)
# Right side: explanatory legend box (compact) # Right side: explanatory legend box (compact)
legend_title = ttk.Label(right, text="How to read these results:", font=(None, 9, "bold")) legend_title = ttk.Label(
right, text="How to read these results:", font=(None, 9, "bold")
)
legend_title.pack(anchor=tk.NW, padx=(6, 6), pady=(4, 2)) legend_title.pack(anchor=tk.NW, padx=(6, 6), pady=(4, 2))
legend_text = ( legend_text = (
@ -149,9 +154,13 @@ class AnalysisWindow(tk.Toplevel):
"> 0 => real is ahead of simulated.\n< 0 => real lags simulated.\n\nUnits: feet" "> 0 => real is ahead of simulated.\n< 0 => real lags simulated.\n\nUnits: feet"
) )
try: try:
ttk.Label(right, text=legend_text, foreground="gray", justify=tk.LEFT, wraplength=260).pack( ttk.Label(
anchor=tk.NW, padx=(6, 6) right,
) text=legend_text,
foreground="gray",
justify=tk.LEFT,
wraplength=260,
).pack(anchor=tk.NW, padx=(6, 6))
except Exception: except Exception:
pass pass
@ -181,7 +190,6 @@ class AnalysisWindow(tk.Toplevel):
self.canvas.draw() self.canvas.draw()
self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
def _update_target_selector(self): def _update_target_selector(self):
# Only update the combobox values when the hub reports target ids. # Only update the combobox values when the hub reports target ids.
# This prevents the selector from being emptied when the hub is cleared # This prevents the selector from being emptied when the hub is cleared

View File

@ -427,6 +427,7 @@ class MainView(tk.Tk):
# idle tasks have run (so sizes are realistic) and then move the sash # idle tasks have run (so sizes are realistic) and then move the sash
# so the Logs pane has a small fixed minimum height (~80px) or ~18%. # so the Logs pane has a small fixed minimum height (~80px) or ~18%.
try: try:
def _shrink_log_pane_once(event=None): def _shrink_log_pane_once(event=None):
# Run only once # Run only once
if getattr(self, "_log_pane_shrunk", False): if getattr(self, "_log_pane_shrunk", False):
@ -441,6 +442,18 @@ class MainView(tk.Tk):
# Prefer the paned window's current height if available # Prefer the paned window's current height if available
total_h = v_pane.winfo_height() or self.winfo_height() or 800 total_h = v_pane.winfo_height() or self.winfo_height() or 800
# If geometry isn't yet realized (very small), try again shortly
# instead of forcing a potentially incorrect sash position that
# could collapse the top pane. A small threshold avoids acting
# on transient values produced during window manager setup.
if total_h < 200:
try:
# Schedule a retry and do not mark as shrunk yet
self.after(300, _shrink_log_pane_once)
except Exception:
pass
return
# Determine desired log pane height (min 60px, or ~18% of window) # Determine desired log pane height (min 60px, or ~18% of window)
desired_log_h = max(60, int(total_h * 0.18)) desired_log_h = max(60, int(total_h * 0.18))
@ -460,13 +473,21 @@ class MainView(tk.Tk):
# Apply sash position (index 0 for the only sash in vertical pane) # Apply sash position (index 0 for the only sash in vertical pane)
try: try:
v_pane.sashpos(0, desired_top_h) v_pane.sashpos(0, desired_top_h)
# Only mark successful shrink once sashpos applied without
# raising an exception. Some platforms may not support
# sashpos until fully realized, so if sashpos raises we
# leave the flag unset and allow the Configure event to
# retry.
setattr(self, "_log_pane_shrunk", True)
except Exception: except Exception:
# Some platforms may not support sashpos until fully realized; # Some platforms may not support sashpos until fully realized;
# ignore and rely on later Configure event. # ignore and rely on later Configure event to attempt again.
try:
# Retry shortly instead of marking as done.
self.after(300, _shrink_log_pane_once)
except Exception:
pass pass
return
# Mark as done so we don't repeatedly adjust
setattr(self, "_log_pane_shrunk", True)
except Exception: except Exception:
pass pass
@ -474,6 +495,7 @@ class MainView(tk.Tk):
# slightly longer delay to allow theme/layout to stabilise (some # slightly longer delay to allow theme/layout to stabilise (some
# nested widgets can delay final geometry on certain platforms). # nested widgets can delay final geometry on certain platforms).
self.after(500, _shrink_log_pane_once) self.after(500, _shrink_log_pane_once)
# Also bind to a single Configure event in case geometry wasn't ready # Also bind to a single Configure event in case geometry wasn't ready
def _on_config_once(ev): def _on_config_once(ev):
_shrink_log_pane_once() _shrink_log_pane_once()
@ -487,7 +509,9 @@ class MainView(tk.Tk):
pass pass
def _create_analysis_tab_widgets(self, parent): def _create_analysis_tab_widgets(self, parent):
self.analysis_tree = ttk.Treeview(parent, columns=("datetime", "scenario", "duration"), show="headings") self.analysis_tree = ttk.Treeview(
parent, columns=("datetime", "scenario", "duration"), show="headings"
)
self.analysis_tree.heading("datetime", text="Date/Time") self.analysis_tree.heading("datetime", text="Date/Time")
self.analysis_tree.heading("scenario", text="Scenario Name") self.analysis_tree.heading("scenario", text="Scenario Name")
self.analysis_tree.heading("duration", text="Duration (s)") self.analysis_tree.heading("duration", text="Duration (s)")
@ -498,8 +522,12 @@ class MainView(tk.Tk):
btn_frame = ttk.Frame(parent) btn_frame = ttk.Frame(parent)
btn_frame.pack(fill=tk.X, padx=5, pady=5) btn_frame.pack(fill=tk.X, padx=5, pady=5)
ttk.Button(btn_frame, text="Refresh List", command=self._refresh_analysis_list).pack(side=tk.LEFT) ttk.Button(
ttk.Button(btn_frame, text="Analyze Selected", command=self._on_analyze_run).pack(side=tk.RIGHT) btn_frame, text="Refresh List", command=self._refresh_analysis_list
).pack(side=tk.LEFT)
ttk.Button(
btn_frame, text="Analyze Selected", command=self._on_analyze_run
).pack(side=tk.RIGHT)
# Popola la lista all'avvio # Popola la lista all'avvio
self.after(100, self._refresh_analysis_list) self.after(100, self._refresh_analysis_list)
@ -712,9 +740,7 @@ class MainView(tk.Tk):
config_data = config.get("tftp", {}) config_data = config.get("tftp", {})
elif comm_type == "sfp": elif comm_type == "sfp":
# --- MODIFICATION: Do not pass update_queue --- # --- MODIFICATION: Do not pass update_queue ---
communicator = SFPCommunicator( communicator = SFPCommunicator(simulation_hub=self.simulation_hub)
simulation_hub=self.simulation_hub
)
communicator.add_connection_state_callback(self._on_connection_state_change) communicator.add_connection_state_callback(self._on_connection_state_change)
config_data = config.get("sfp", {}) config_data = config.get("sfp", {})
if self.defer_sfp_connection: if self.defer_sfp_connection:
@ -839,21 +865,28 @@ class MainView(tk.Tk):
use_json = False use_json = False
if use_json: if use_json:
# Use the same Reset IDs JSON payload sequence as provided by the # Send both the per-target zeroing payloads (if available) followed
# SFP debug window: build one-or-more compact JSON payloads that # by a minimal {'CMD':'reset'} line. The per-target payloads clear
# explicitly disable all target IDs and send them in order. # active flags on individual IDs; the simple reset is a noop on
# servers that don't implement it but is harmless when supported.
try: try:
json_payloads = command_builder.build_json_reset_ids() json_payloads = command_builder.build_json_reset_ids()
# Ensure all payloads are newline-terminated and then append
# the simple reset as a final step so servers that process
# the reset command can react accordingly.
final_reset = '{"CMD":"reset"}\n'
commands_to_send = [
p if p.endswith("\n") else p + "\n" for p in json_payloads
] + [final_reset]
self.logger.info( self.logger.info(
"Using JSON Reset IDs payloads for radar reset (parts=%d).", "Using JSON Reset IDs payloads for radar reset (parts=%d + reset).",
len(json_payloads), len(json_payloads),
) )
except Exception: except Exception:
self.logger.exception( self.logger.exception(
"Failed to build Reset IDs JSON payloads; falling back to simple JSON reset." "Failed to build Reset IDs JSON payloads; falling back to simple JSON reset."
) )
json_payloads = ['{"CMD":"reset"}\n'] commands_to_send = ['{"CMD":"reset"}\n']
commands_to_send = json_payloads
else: else:
# Legacy textual reset sequence (no leading $; newline-terminated strings) # Legacy textual reset sequence (no leading $; newline-terminated strings)
commands_to_send = [prep_command, reset_command + "\n"] commands_to_send = [prep_command, reset_command + "\n"]
@ -878,7 +911,9 @@ class MainView(tk.Tk):
return False return False
else: else:
if not self.target_communicator.send_commands(commands_to_send): if not self.target_communicator.send_commands(commands_to_send):
self.logger.error("Failed to send preparatory/reset commands to the radar.") self.logger.error(
"Failed to send preparatory/reset commands to the radar."
)
messagebox.showerror( messagebox.showerror(
"Reset Error", "Failed to send reset command to the radar." "Reset Error", "Failed to send reset command to the radar."
) )
@ -914,7 +949,10 @@ class MainView(tk.Tk):
self.logger.info("Simulation is already running.") self.logger.info("Simulation is already running.")
return return
# Require explicit connection before starting live. Do NOT auto-connect. # Require explicit connection before starting live. Do NOT auto-connect.
if not (self.target_communicator and getattr(self.target_communicator, "is_open", False)): if not (
self.target_communicator
and getattr(self.target_communicator, "is_open", False)
):
# Friendly English reminder to connect first # Friendly English reminder to connect first
messagebox.showwarning( messagebox.showwarning(
"Not Connected", "Not Connected",
@ -1091,7 +1129,9 @@ class MainView(tk.Tk):
) )
else: else:
# As a fallback, log the info (no blocking GUI popup). # As a fallback, log the info (no blocking GUI popup).
self.logger.info("Live simulation completed (notice widget unavailable).") self.logger.info(
"Live simulation completed (notice widget unavailable)."
)
except Exception: except Exception:
# Ensure we never raise from UI-notice handling # Ensure we never raise from UI-notice handling
try: try:
@ -1123,7 +1163,9 @@ class MainView(tk.Tk):
self._on_simulation_finished() self._on_simulation_finished()
try: try:
self.sim_elapsed_time = self.total_sim_time self.sim_elapsed_time = self.total_sim_time
self.sim_slider_var.set(1.0 if self.total_sim_time > 0 else 0.0) self.sim_slider_var.set(
1.0 if self.total_sim_time > 0 else 0.0
)
except Exception: except Exception:
pass pass
self._update_simulation_progress_display() self._update_simulation_progress_display()
@ -1132,17 +1174,27 @@ class MainView(tk.Tk):
if len(update) == 0: if len(update) == 0:
# Hub refresh notification (real data arrived). # Hub refresh notification (real data arrived).
display_data = self._build_display_data_from_hub() display_data = self._build_display_data_from_hub()
self.ppi_widget.update_real_targets(display_data.get("real", [])) self.ppi_widget.update_real_targets(
display_data.get("real", [])
)
try: try:
if ( if (
hasattr(self, "simulation_hub") hasattr(self, "simulation_hub")
and self.simulation_hub is not None and self.simulation_hub is not None
and hasattr(self.ppi_widget, "update_antenna_azimuth") and hasattr(
self.ppi_widget, "update_antenna_azimuth"
)
): ):
if hasattr(self.simulation_hub, "get_antenna_azimuth"): if hasattr(
az_deg, az_ts = self.simulation_hub.get_antenna_azimuth() self.simulation_hub, "get_antenna_azimuth"
):
az_deg, az_ts = (
self.simulation_hub.get_antenna_azimuth()
)
else: else:
az_deg, az_ts = self.simulation_hub.get_platform_azimuth() az_deg, az_ts = (
self.simulation_hub.get_platform_azimuth()
)
if az_deg is not None: if az_deg is not None:
self.ppi_widget.update_antenna_azimuth( self.ppi_widget.update_antenna_azimuth(
@ -1161,7 +1213,10 @@ class MainView(tk.Tk):
# Update simulation progress bar # Update simulation progress bar
try: try:
if self.simulation_engine and self.simulation_engine.scenario: if (
self.simulation_engine
and self.simulation_engine.scenario
):
times = [ times = [
getattr(t, "_sim_time_s", 0.0) getattr(t, "_sim_time_s", 0.0)
for t in self.simulation_engine.scenario.get_all_targets() for t in self.simulation_engine.scenario.get_all_targets()
@ -1173,13 +1228,18 @@ class MainView(tk.Tk):
): ):
progress_frac = min( progress_frac = min(
1.0, 1.0,
max(0.0, self.sim_elapsed_time / self.total_sim_time), max(
0.0,
self.sim_elapsed_time / self.total_sim_time,
),
) )
self.sim_slider_var.set(progress_frac) self.sim_slider_var.set(progress_frac)
self._update_simulation_progress_display() self._update_simulation_progress_display()
except Exception: except Exception:
self.logger.debug("Progress UI update failed", exc_info=True) self.logger.debug(
"Progress UI update failed", exc_info=True
)
except Empty: except Empty:
# Queue is empty, we can stop processing for this cycle. # Queue is empty, we can stop processing for this cycle.
@ -1197,12 +1257,16 @@ class MainView(tk.Tk):
# Determine if analysis data exists. Ensure boolean type. # Determine if analysis data exists. Ensure boolean type.
has_data_to_analyze = ( has_data_to_analyze = (
bool(self.simulation_hub.get_all_target_ids()) if self.simulation_hub else False bool(self.simulation_hub.get_all_target_ids())
if self.simulation_hub
else False
) )
# Enable Analysis only when simulation is NOT running and there is data # Enable Analysis only when simulation is NOT running and there is data
# to analyze (i.e., after a completed run or after receiving real data). # to analyze (i.e., after a completed run or after receiving real data).
analysis_state = tk.NORMAL if (not is_running and has_data_to_analyze) else tk.DISABLED analysis_state = (
tk.NORMAL if (not is_running and has_data_to_analyze) else tk.DISABLED
)
state = tk.DISABLED if is_running else tk.NORMAL state = tk.DISABLED if is_running else tk.NORMAL
@ -1386,6 +1450,24 @@ class MainView(tk.Tk):
scenario_data = self.config_manager.get_scenario(scenario_name) scenario_data = self.config_manager.get_scenario(scenario_name)
if scenario_data: if scenario_data:
try: try:
# Clear any previously simulated-only data so the new scenario's
# simulated targets are shown cleanly. We intentionally preserve
# 'real' target data if present (do not call hub.reset()).
try:
if hasattr(self, "simulation_hub") and self.simulation_hub:
self.simulation_hub.clear_simulated_data()
except Exception:
pass
# Also clear PPI trails and simulated target visual state before
# loading the new scenario to avoid leaving stale markers.
try:
if hasattr(self, "ppi_widget") and self.ppi_widget:
self.ppi_widget.clear_trails()
self.ppi_widget.update_simulated_targets([])
except Exception:
pass
self.scenario = Scenario.from_dict(scenario_data) self.scenario = Scenario.from_dict(scenario_data)
self.current_scenario_name = scenario_name self.current_scenario_name = scenario_name
# Update target list UI with loaded scenario's targets # Update target list UI with loaded scenario's targets
@ -1397,7 +1479,8 @@ class MainView(tk.Tk):
# and planned paths before starting the simulation. # and planned paths before starting the simulation.
try: try:
connected = bool( connected = bool(
self.target_communicator and getattr(self.target_communicator, "is_open", False) self.target_communicator
and getattr(self.target_communicator, "is_open", False)
) )
except Exception: except Exception:
connected = False connected = False
@ -1469,7 +1552,9 @@ class MainView(tk.Tk):
# Update scenario list UI and select the new scenario # Update scenario list UI and select the new scenario
try: try:
self.scenario_controls.update_scenario_list(names, select_scenario=scenario_name) self.scenario_controls.update_scenario_list(
names, select_scenario=scenario_name
)
except Exception: except Exception:
# Fallback for older UI: directly set combobox values # Fallback for older UI: directly set combobox values
try: try:
@ -1840,7 +1925,9 @@ class MainView(tk.Tk):
# Update antenna azimuth # Update antenna azimuth
try: try:
if self.simulation_hub and hasattr(self.simulation_hub, "get_antenna_azimuth"): if self.simulation_hub and hasattr(
self.simulation_hub, "get_antenna_azimuth"
):
az_deg, az_ts = self.simulation_hub.get_antenna_azimuth() az_deg, az_ts = self.simulation_hub.get_antenna_azimuth()
if az_deg is not None: if az_deg is not None:
self.ppi_widget.update_antenna_azimuth(az_deg, timestamp=az_ts) self.ppi_widget.update_antenna_azimuth(az_deg, timestamp=az_ts)
@ -1880,29 +1967,42 @@ class MainView(tk.Tk):
filepath = os.path.join(archive_folder, filename) filepath = os.path.join(archive_folder, filename)
try: try:
# Leggiamo solo i metadati per non caricare tutto il file # Leggiamo solo i metadati per non caricare tutto il file
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
# Usiamo il timestamp del nome del file per l'ordinamento # Usiamo il timestamp del nome del file per l'ordinamento
dt_str = filename.split('_')[0] dt_str = filename.split("_")[0]
run_info = { run_info = {
"datetime": datetime.strptime(dt_str, "%Y%m%d").strftime("%Y-%m-%d") + " " + filename.split('_')[1].replace(".json",""), "datetime": datetime.strptime(dt_str, "%Y%m%d").strftime(
"%Y-%m-%d"
)
+ " "
+ filename.split("_")[1].replace(".json", ""),
"scenario": metadata.get("scenario_name", "N/A"), "scenario": metadata.get("scenario_name", "N/A"),
"duration": f"{metadata.get('duration_seconds', 0):.1f}", "duration": f"{metadata.get('duration_seconds', 0):.1f}",
"filepath": filepath "filepath": filepath,
} }
runs.append(run_info) runs.append(run_info)
except Exception as e: except Exception as e:
self.logger.warning(f"Impossibile leggere l'archivio {filename}: {e}") self.logger.warning(
f"Impossibile leggere l'archivio {filename}: {e}"
)
# Ordina dal più recente al più vecchio # Ordina dal più recente al più vecchio
for run in sorted(runs, key=lambda r: r['datetime'], reverse=True): for run in sorted(runs, key=lambda r: r["datetime"], reverse=True):
self.analysis_tree.insert("", tk.END, values=(run['datetime'], run['scenario'], run['duration']), iid=run['filepath']) self.analysis_tree.insert(
"",
tk.END,
values=(run["datetime"], run["scenario"], run["duration"]),
iid=run["filepath"],
)
def _on_analyze_run(self): def _on_analyze_run(self):
selected_item = self.analysis_tree.focus() selected_item = self.analysis_tree.focus()
if not selected_item: if not selected_item:
messagebox.showinfo("Nessuna Selezione", "Seleziona una simulazione da analizzare.") messagebox.showinfo(
"Nessuna Selezione", "Seleziona una simulazione da analizzare."
)
return return
archive_filepath = selected_item # L'IID è il filepath archive_filepath = selected_item # L'IID è il filepath

View File

@ -71,9 +71,7 @@ class DebugPayloadRouter:
ord("R"): self._handle_ris_status, ord("R"): self._handle_ris_status,
ord("r"): self._handle_ris_status, ord("r"): self._handle_ris_status,
} }
logger.info( logger.info(f"{self._log_prefix} Initialized (Hub: {self._hub is not None}).")
f"{self._log_prefix} Initialized (Hub: {self._hub is not None})."
)
self._logger = logger self._logger = logger
def set_archive(self, archive): def set_archive(self, archive):
@ -220,7 +218,7 @@ class DebugPayloadRouter:
"Failed to propagate heading to hub", exc_info=True "Failed to propagate heading to hub", exc_info=True
) )
#if self._update_queue: # if self._update_queue:
# try: # try:
# self._update_queue.put_nowait([]) # self._update_queue.put_nowait([])
# except Full: # except Full:
@ -246,7 +244,7 @@ class DebugPayloadRouter:
archive.add_real_state( archive.add_real_state(
target_id=target.target_id, target_id=target.target_id,
timestamp=reception_timestamp, timestamp=reception_timestamp,
state=state_tuple state=state_tuple,
) )
# --- BROADCAST to all registered listeners --- # --- BROADCAST to all registered listeners ---

View File

@ -327,16 +327,26 @@ class PPIDisplay(ttk.Frame):
# Draw active targets as before # Draw active targets as before
if active_targets: if active_targets:
self._draw_target_visuals(active_targets, color, target_artists, label_artists) self._draw_target_visuals(
active_targets, color, target_artists, label_artists
)
# Draw inactive simulated targets with a yellow 'X' marker overlay # Draw inactive simulated targets with a yellow 'X' marker overlay
if inactive_targets and category == "simulated": if inactive_targets and category == "simulated":
self._draw_inactive_markers(inactive_targets, color, target_artists, label_artists) self._draw_inactive_markers(
inactive_targets, color, target_artists, label_artists
)
if show_trail: if show_trail:
self._draw_trails(trail_data, trail_color, trail_artists) self._draw_trails(trail_data, trail_color, trail_artists)
def _draw_inactive_markers(self, targets: List[Target], color: str, artist_list: List, label_artist_list: List): def _draw_inactive_markers(
self,
targets: List[Target],
color: str,
artist_list: List,
label_artist_list: List,
):
"""Draw a small stationary marker for targets that are no longer simulated and """Draw a small stationary marker for targets that are no longer simulated and
overlay a yellow 'X' to indicate the target is not being updated by the simulator. overlay a yellow 'X' to indicate the target is not being updated by the simulator.
""" """
@ -345,7 +355,9 @@ class PPIDisplay(ttk.Frame):
r_nm = target.current_range_nm r_nm = target.current_range_nm
theta_rad_plot = np.deg2rad(target.current_azimuth_deg) theta_rad_plot = np.deg2rad(target.current_azimuth_deg)
# plot a subdued point # plot a subdued point
(dot,) = self.ax.plot(theta_rad_plot, r_nm, "o", markersize=6, color=color, alpha=0.6) (dot,) = self.ax.plot(
theta_rad_plot, r_nm, "o", markersize=6, color=color, alpha=0.6
)
artist_list.append(dot) artist_list.append(dot)
# overlay a yellow X at the same position # overlay a yellow X at the same position
@ -545,14 +557,16 @@ class PPIDisplay(ttk.Frame):
waypoints = target.trajectory waypoints = target.trajectory
if not waypoints: if not waypoints:
continue continue
path, _ = Target.generate_path_from_waypoints(waypoints, target.use_spline) path, _ = Target.generate_path_from_waypoints(
waypoints, target.use_spline
)
if not path: if not path:
continue continue
path_thetas, path_rs = [], [] path_thetas, path_rs = [], []
for point in path: for point in path:
x_ft, y_ft = point[1], point[2] x_ft, y_ft = point[1], point[2]
r_ft = math.sqrt(x_ft ** 2 + y_ft ** 2) r_ft = math.sqrt(x_ft**2 + y_ft**2)
az_rad_plot = math.atan2(x_ft, y_ft) az_rad_plot = math.atan2(x_ft, y_ft)
path_rs.append(r_ft / NM_TO_FT) path_rs.append(r_ft / NM_TO_FT)
path_thetas.append(az_rad_plot) path_thetas.append(az_rad_plot)
@ -572,13 +586,18 @@ class PPIDisplay(ttk.Frame):
start_r = path_rs[0] if path_rs else None start_r = path_rs[0] if path_rs else None
start_art = None start_art = None
if start_theta is not None: if start_theta is not None:
(start_art,) = self.ax.plot([start_theta], [start_r], "go", markersize=6) (start_art,) = self.ax.plot(
[start_theta], [start_r], "go", markersize=6
)
arts = [a for a in (line_art, start_art) if a is not None] arts = [a for a in (line_art, start_art) if a is not None]
if arts: if arts:
self.preview_path_artists[target.target_id] = arts self.preview_path_artists[target.target_id] = arts
except Exception: except Exception:
logger.exception("Failed to draw preview for target %s", getattr(target, 'target_id', '?')) logger.exception(
"Failed to draw preview for target %s",
getattr(target, "target_id", "?"),
)
if self.canvas: if self.canvas:
self.canvas.draw() self.canvas.draw()

View File

@ -62,7 +62,12 @@ class ConfigManager:
) )
self.filepath = os.path.join(application_path, filename) self.filepath = os.path.join(application_path, filename)
self.scenarios_filepath = os.path.join(application_path, scenarios_filename) # Persist scenarios next to the settings file so tests that override
# `filepath` do not accidentally read/write the application's
# scenarios.json in the repo root.
self.scenarios_filepath = os.path.join(
os.path.dirname(self.filepath), scenarios_filename
)
self._settings = self._load_or_initialize_settings() self._settings = self._load_or_initialize_settings()
# Load scenarios from separate file if present, otherwise keep any scenarios # Load scenarios from separate file if present, otherwise keep any scenarios
# found inside settings.json (fallback). # found inside settings.json (fallback).
@ -93,6 +98,11 @@ class ConfigManager:
# `_load_or_initialize_scenarios()`. # `_load_or_initialize_scenarios()`.
if not os.path.exists(self.filepath): if not os.path.exists(self.filepath):
# Ensure in-memory scenarios reflect the (missing) settings location
try:
self._scenarios = self._load_or_initialize_scenarios()
except Exception:
self._scenarios = {}
return {"general": {}, "scenarios": {}} return {"general": {}, "scenarios": {}}
try: try:
with open(self.filepath, "r", encoding="utf-8") as f: with open(self.filepath, "r", encoding="utf-8") as f:
@ -102,8 +112,19 @@ class ConfigManager:
return {"general": settings, "scenarios": {}} return {"general": settings, "scenarios": {}}
# Keep scenarios key if present; actual source of truth for scenarios # Keep scenarios key if present; actual source of truth for scenarios
# will be the separate scenarios file when available. # will be the separate scenarios file when available.
# Refresh in-memory scenarios based on the possibly-updated
# settings filepath so callers that override `filepath` and
# re-run this loader get a consistent view.
try:
self._scenarios = self._load_or_initialize_scenarios()
except Exception:
self._scenarios = {}
return settings return settings
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
try:
self._scenarios = self._load_or_initialize_scenarios()
except Exception:
self._scenarios = {}
return {"general": {}, "scenarios": {}} return {"general": {}, "scenarios": {}}
def _save_settings(self): def _save_settings(self):
@ -126,7 +147,35 @@ class ConfigManager:
Falls back to scenarios inside settings.json when scenarios.json is absent. Falls back to scenarios inside settings.json when scenarios.json is absent.
""" """
# If scenarios file exists, load from it. # Prefer a scenarios file colocated with the settings file (useful
# for tests which set `filepath` to a temp dir). Construct the local
# scenarios path by combining the settings directory with the
# scenarios filename.
try:
settings_dir = os.path.dirname(self.filepath) or "."
local_scenarios_path = os.path.join(
settings_dir, os.path.basename(self.scenarios_filepath)
)
if os.path.exists(local_scenarios_path):
with open(local_scenarios_path, "r", encoding="utf-8") as f:
scenarios = json.load(f)
if isinstance(scenarios, dict):
return scenarios
except Exception:
# If local scenarios file is unreadable, fall through to other
# fallback mechanisms rather than raising.
pass
# Only load the application-level scenarios file when the settings
# filepath resides inside the application directory. Tests often
# override `filepath` to a temporary location; in that case we must
# avoid pulling in global scenarios from the repo.
try:
app_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
settings_abs = os.path.abspath(self.filepath)
if os.path.commonpath([app_root, settings_abs]) == app_root:
if os.path.exists(self.scenarios_filepath): if os.path.exists(self.scenarios_filepath):
try: try:
with open(self.scenarios_filepath, "r", encoding="utf-8") as f: with open(self.scenarios_filepath, "r", encoding="utf-8") as f:
@ -135,6 +184,9 @@ class ConfigManager:
return scenarios return scenarios
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
return {} return {}
except Exception:
# If path computations fail, fall back silently to other options.
pass
# Fallback: try to read scenarios stored inside settings.json # Fallback: try to read scenarios stored inside settings.json
try: try:
@ -201,14 +253,18 @@ class ConfigManager:
shutil.copy2(self.scenarios_filepath, prebackup) shutil.copy2(self.scenarios_filepath, prebackup)
logger.debug("Created pre-write backup: %s", prebackup) logger.debug("Created pre-write backup: %s", prebackup)
except Exception: except Exception:
logger.debug("Pre-write backup failed, continuing", exc_info=True) logger.debug(
"Pre-write backup failed, continuing", exc_info=True
)
except Exception: except Exception:
# Non-fatal # Non-fatal
pass pass
# Write atomically: write to a temp file then validate and replace the target. # Write atomically: write to a temp file then validate and replace the target.
dirpath = os.path.dirname(self.scenarios_filepath) or "." dirpath = os.path.dirname(self.scenarios_filepath) or "."
tmp_path = os.path.join(dirpath, f".{os.path.basename(self.scenarios_filepath)}.tmp") tmp_path = os.path.join(
dirpath, f".{os.path.basename(self.scenarios_filepath)}.tmp"
)
with open(tmp_path, "w", encoding="utf-8") as f: with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(self._scenarios, f, indent=4, cls=EnumEncoder) json.dump(self._scenarios, f, indent=4, cls=EnumEncoder)
@ -217,14 +273,18 @@ class ConfigManager:
with open(tmp_path, "r", encoding="utf-8") as tf: with open(tmp_path, "r", encoding="utf-8") as tf:
validated = json.load(tf) validated = json.load(tf)
if not isinstance(validated, dict): if not isinstance(validated, dict):
logger.error("Temporary scenarios file did not contain a JSON object; aborting write") logger.error(
"Temporary scenarios file did not contain a JSON object; aborting write"
)
try: try:
os.remove(tmp_path) os.remove(tmp_path)
except Exception: except Exception:
pass pass
return return
except Exception as e: except Exception as e:
logger.error("Failed to validate temporary scenarios file: %s", e, exc_info=True) logger.error(
"Failed to validate temporary scenarios file: %s", e, exc_info=True
)
try: try:
os.remove(tmp_path) os.remove(tmp_path)
except Exception: except Exception:
@ -246,7 +306,9 @@ class ConfigManager:
try: try:
new_count = len(validated) if isinstance(validated, dict) else 0 new_count = len(validated) if isinstance(validated, dict) else 0
existing_count = len(existing) if isinstance(existing, dict) else 0 existing_count = (
len(existing) if isinstance(existing, dict) else 0
)
if ( if (
existing_count > 1 existing_count > 1
and new_count <= 1 and new_count <= 1
@ -359,10 +421,18 @@ class ConfigManager:
try: try:
# Basic sanity check: expect a mapping for scenario data # Basic sanity check: expect a mapping for scenario data
if not isinstance(data, dict): if not isinstance(data, dict):
logger.error("Attempted to save scenario '%s' with non-dict data: %r", name, type(data)) logger.error(
"Attempted to save scenario '%s' with non-dict data: %r",
name,
type(data),
)
return return
self._scenarios[name] = data self._scenarios[name] = data
logger.info("Saving scenario '%s' (total scenarios after save: %d)", name, len(self._scenarios)) logger.info(
"Saving scenario '%s' (total scenarios after save: %d)",
name,
len(self._scenarios),
)
logger.debug("Scenario keys: %s", list(self._scenarios.keys())) logger.debug("Scenario keys: %s", list(self._scenarios.keys()))
self._save_scenarios() self._save_scenarios()
except Exception: except Exception:

View File

@ -11,6 +11,9 @@ def _new_cm_with_tmpfile(tmp_path, filename="settings.json"):
"""Create a ConfigManager but point its filepath to a temp file and reload settings.""" """Create a ConfigManager but point its filepath to a temp file and reload settings."""
cm = ConfigManager(filename=filename) cm = ConfigManager(filename=filename)
cm.filepath = str(tmp_path / filename) cm.filepath = str(tmp_path / filename)
# Ensure scenarios are stored next to the temporary settings file so tests
# do not accidentally write the application's scenarios.json in the repo.
cm.scenarios_filepath = os.path.join(os.path.dirname(cm.filepath), "scenarios.json")
cm._settings = cm._load_or_initialize_settings() cm._settings = cm._load_or_initialize_settings()
return cm return cm