From 8a4f621f0c603c8bb88a5d3fbd7a5c0dda94d35d Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Mon, 3 Nov 2025 11:00:34 +0100 Subject: [PATCH] sistemato salvataggio dati simulazione, e meccanizzazione processi di selezione dello scenario --- settings.json | 2 +- target_simulator/_version.py | 39 +-- .../analysis/simulation_archive.py | 25 +- .../analysis/simulation_state_hub.py | 19 ++ target_simulator/core/sfp_communicator.py | 4 +- target_simulator/core/simulation_engine.py | 16 +- target_simulator/gui/analysis_window.py | 36 +-- target_simulator/gui/main_view.py | 232 +++++++++++++----- target_simulator/gui/payload_router.py | 14 +- target_simulator/gui/ppi_display.py | 35 ++- target_simulator/utils/config_manager.py | 98 ++++++-- tests/utils/test_config_manager.py | 3 + 12 files changed, 382 insertions(+), 141 deletions(-) diff --git a/settings.json b/settings.json index 5571ff2..fbb94c9 100644 --- a/settings.json +++ b/settings.json @@ -3,7 +3,7 @@ "scan_limit": 60, "max_range": 100, "geometry": "1599x1089+587+179", - "last_selected_scenario": "corto", + "last_selected_scenario": "scenario1", "connection": { "target": { "type": "sfp", diff --git a/target_simulator/_version.py b/target_simulator/_version.py index 1f134a8..3d3ef50 100644 --- a/target_simulator/_version.py +++ b/target_simulator/_version.py @@ -17,6 +17,7 @@ DEFAULT_VERSION = "0.0.0+unknown" DEFAULT_COMMIT = "Unknown" DEFAULT_BRANCH = "Unknown" + # --- Helper Function --- def get_version_string(format_string=None): """ @@ -44,29 +45,39 @@ def get_version_string(format_string=None): replacements = {} try: - replacements['version'] = __version__ if __version__ else DEFAULT_VERSION - 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['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 "" + replacements["version"] = __version__ if __version__ else DEFAULT_VERSION + 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["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 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: tag = match.group(1) - replacements['tag'] = tag + replacements["tag"] = tag output_string = format_string for placeholder, value in replacements.items(): - pattern = re.compile(r'{{\s*' + re.escape(placeholder) + r'\s*}}') - output_string = pattern.sub(str(value), output_string) + pattern = re.compile(r"{{\s*" + re.escape(placeholder) + r"\s*}}") + output_string = pattern.sub(str(value), output_string) - if re.search(r'{\s*\w+\s*}', output_string): - pass # Or log a warning: print(f"Warning: Unreplaced placeholders found: {output_string}") + if re.search(r"{\s*\w+\s*}", output_string): + pass # Or log a warning: print(f"Warning: Unreplaced placeholders found: {output_string}") return output_string diff --git a/target_simulator/analysis/simulation_archive.py b/target_simulator/analysis/simulation_archive.py index 7456bf7..6c64a3c 100644 --- a/target_simulator/analysis/simulation_archive.py +++ b/target_simulator/analysis/simulation_archive.py @@ -16,6 +16,7 @@ class SimulationArchive: """ Gestisce la raccolta dei dati per una singola esecuzione di simulazione e la salva su file. """ + ARCHIVE_FOLDER = "archive_simulations" def __init__(self, scenario: Scenario): @@ -25,7 +26,7 @@ class SimulationArchive: self.start_time = time.monotonic() self.scenario_name = scenario.name self.scenario_data = scenario.to_dict() - + # Struttura dati per contenere gli eventi registrati, indicizzati per target_id # self.recorded_data[target_id]['simulated'] = [(ts, x, y, z), ...] # self.recorded_data[target_id]['real'] = [(ts, x, y, z), ...] @@ -41,19 +42,23 @@ class SimulationArchive: except OSError as 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.""" if target_id not in self.recorded_data: self.recorded_data[target_id] = {"simulated": [], "real": []} - + full_state: RecordedState = (timestamp, state[0], state[1], state[2]) 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.""" if target_id not in self.recorded_data: self.recorded_data[target_id] = {"simulated": [], "real": []} - + full_state: RecordedState = (timestamp, state[0], state[1], state[2]) self.recorded_data[target_id]["real"].append(full_state) @@ -66,7 +71,7 @@ class SimulationArchive: Il percorso del file salvato. """ end_time = time.monotonic() - + archive_content = { "metadata": { "scenario_name": self.scenario_name, @@ -78,15 +83,17 @@ class SimulationArchive: } 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" filepath = os.path.join(self.ARCHIVE_FOLDER, filename) 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) print(f"Archivio di simulazione salvato in: {filepath}") return filepath except IOError as e: print(f"Errore durante il salvataggio dell'archivio di simulazione: {e}") - return "" \ No newline at end of file + return "" diff --git a/target_simulator/analysis/simulation_state_hub.py b/target_simulator/analysis/simulation_state_hub.py index eddd106..8d75d2d 100644 --- a/target_simulator/analysis/simulation_state_hub.py +++ b/target_simulator/analysis/simulation_state_hub.py @@ -395,3 +395,22 @@ class SimulationStateHub: except Exception: # Silently ignore errors (e.g., invalid target_id type) 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 diff --git a/target_simulator/core/sfp_communicator.py b/target_simulator/core/sfp_communicator.py index b0bce72..99bdc36 100644 --- a/target_simulator/core/sfp_communicator.py +++ b/target_simulator/core/sfp_communicator.py @@ -51,9 +51,7 @@ class SFPCommunicator(CommunicatorInterface): ) # Unified payload router - self.payload_router = DebugPayloadRouter( - simulation_hub=simulation_hub - ) + self.payload_router = DebugPayloadRouter(simulation_hub=simulation_hub) 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.""" diff --git a/target_simulator/core/simulation_engine.py b/target_simulator/core/simulation_engine.py index 23ec07e..bea223f 100644 --- a/target_simulator/core/simulation_engine.py +++ b/target_simulator/core/simulation_engine.py @@ -27,13 +27,21 @@ class SimulationEngine(threading.Thread): self, communicator: Optional[CommunicatorInterface], simulation_hub: Optional[SimulationStateHub] = None, - archive: Optional[str] = None + archive: Optional[str] = None, ): super().__init__(daemon=True, name="SimulationEngineThread") self.logger = get_logger(__name__) self.communicator = communicator - self.simulation_hub = simulation_hub # Hub for data analysis + # 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.archive = archive # Archive path if needed self.time_multiplier = 1.0 self.update_interval_s = 1.0 @@ -123,11 +131,11 @@ class SimulationEngine(threading.Thread): self.archive.add_simulated_state( target.target_id, log_timestamp, state_tuple ) - + # --- 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 = ( diff --git a/target_simulator/gui/analysis_window.py b/target_simulator/gui/analysis_window.py index 52a2ba4..1bff084 100644 --- a/target_simulator/gui/analysis_window.py +++ b/target_simulator/gui/analysis_window.py @@ -43,16 +43,19 @@ class AnalysisWindow(tk.Toplevel): # ... il resto del codice di creazione widget rimane simile ... self._create_widgets() - + # Non c'è più un loop, ma un singolo aggiornamento self._populate_analysis() - + def _load_data_and_setup(self, filepath: str): try: - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, "r", encoding="utf-8") as f: archive_data = json.load(f) 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() return @@ -65,19 +68,19 @@ class AnalysisWindow(tk.Toplevel): self._hub.add_simulated_state(target_id, state[0], tuple(state[1:])) for state in data.get("real", []): self._hub.add_real_state(target_id, state[0], tuple(state[1:])) - + # Crea l'analizzatore con l'hub popolato self._analyzer = PerformanceAnalyzer(self._hub) - + def _populate_analysis(self): """Esegue l'analisi e popola i widget una sola volta.""" - self._update_target_selector() # Ora usa l'hub locale - + self._update_target_selector() # Ora usa l'hub locale + # Seleziona il primo target di default target_ids = self.target_selector["values"] if target_ids: self.selected_target_id.set(target_ids[0]) - + analysis_results = self._analyzer.analyze() sel_id = self.selected_target_id.get() @@ -140,7 +143,9 @@ class AnalysisWindow(tk.Toplevel): self.stats_tree.pack(fill=tk.BOTH, expand=True) # 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_text = ( @@ -149,9 +154,13 @@ class AnalysisWindow(tk.Toplevel): "> 0 => real is ahead of simulated.\n< 0 => real lags simulated.\n\nUnits: feet" ) try: - ttk.Label(right, text=legend_text, foreground="gray", justify=tk.LEFT, wraplength=260).pack( - anchor=tk.NW, padx=(6, 6) - ) + ttk.Label( + right, + text=legend_text, + foreground="gray", + justify=tk.LEFT, + wraplength=260, + ).pack(anchor=tk.NW, padx=(6, 6)) except Exception: pass @@ -181,7 +190,6 @@ class AnalysisWindow(tk.Toplevel): self.canvas.draw() self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) - def _update_target_selector(self): # Only update the combobox values when the hub reports target ids. # This prevents the selector from being emptied when the hub is cleared diff --git a/target_simulator/gui/main_view.py b/target_simulator/gui/main_view.py index e5c0937..032f1db 100644 --- a/target_simulator/gui/main_view.py +++ b/target_simulator/gui/main_view.py @@ -49,7 +49,7 @@ class MainView(tk.Tk): super().__init__() self.logger = get_logger(__name__) self.config_manager = ConfigManager() - + self.current_archive: Optional[SimulationArchive] = None # --- Load Settings --- @@ -105,7 +105,7 @@ 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) @@ -409,11 +409,11 @@ class MainView(tk.Tk): lru_action_frame, text="Send LRU Status", command=self._on_send_lru_status ) send_lru_button.pack(side=tk.RIGHT) - + # --- TAB 4: Analysis --- analysis_tab = ttk.Frame(left_notebook) left_notebook.add(analysis_tab, text="Analysis") - self._create_analysis_tab_widgets(analysis_tab) # Nuovo metodo + self._create_analysis_tab_widgets(analysis_tab) # Nuovo metodo # --- Bottom Pane (Logs) --- log_frame_container = ttk.LabelFrame(v_pane, text="Logs") @@ -427,6 +427,7 @@ class MainView(tk.Tk): # 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%. try: + def _shrink_log_pane_once(event=None): # Run only once if getattr(self, "_log_pane_shrunk", False): @@ -441,6 +442,18 @@ class MainView(tk.Tk): # Prefer the paned window's current height if available 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) 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) try: 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: # Some platforms may not support sashpos until fully realized; - # ignore and rely on later Configure event. - pass - - # Mark as done so we don't repeatedly adjust - setattr(self, "_log_pane_shrunk", True) + # 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 + return except Exception: pass @@ -474,6 +495,7 @@ class MainView(tk.Tk): # slightly longer delay to allow theme/layout to stabilise (some # nested widgets can delay final geometry on certain platforms). self.after(500, _shrink_log_pane_once) + # Also bind to a single Configure event in case geometry wasn't ready def _on_config_once(ev): _shrink_log_pane_once() @@ -485,9 +507,11 @@ class MainView(tk.Tk): onconf_id = v_pane.bind("", _on_config_once) except Exception: pass - + 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("scenario", text="Scenario Name") self.analysis_tree.heading("duration", text="Duration (s)") @@ -498,9 +522,13 @@ class MainView(tk.Tk): btn_frame = ttk.Frame(parent) 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(btn_frame, text="Analyze Selected", command=self._on_analyze_run).pack(side=tk.RIGHT) - + ttk.Button( + 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 self.after(100, self._refresh_analysis_list) @@ -712,9 +740,7 @@ class MainView(tk.Tk): config_data = config.get("tftp", {}) elif comm_type == "sfp": # --- MODIFICATION: Do not pass update_queue --- - communicator = SFPCommunicator( - simulation_hub=self.simulation_hub - ) + communicator = SFPCommunicator(simulation_hub=self.simulation_hub) communicator.add_connection_state_callback(self._on_connection_state_change) config_data = config.get("sfp", {}) if self.defer_sfp_connection: @@ -839,21 +865,28 @@ class MainView(tk.Tk): use_json = False if use_json: - # Use the same Reset IDs JSON payload sequence as provided by the - # SFP debug window: build one-or-more compact JSON payloads that - # explicitly disable all target IDs and send them in order. + # Send both the per-target zeroing payloads (if available) followed + # by a minimal {'CMD':'reset'} line. The per-target payloads clear + # active flags on individual IDs; the simple reset is a noop on + # servers that don't implement it but is harmless when supported. try: 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( - "Using JSON Reset IDs payloads for radar reset (parts=%d).", + "Using JSON Reset IDs payloads for radar reset (parts=%d + reset).", len(json_payloads), ) except Exception: self.logger.exception( "Failed to build Reset IDs JSON payloads; falling back to simple JSON reset." ) - json_payloads = ['{"CMD":"reset"}\n'] - commands_to_send = json_payloads + commands_to_send = ['{"CMD":"reset"}\n'] else: # Legacy textual reset sequence (no leading $; newline-terminated strings) commands_to_send = [prep_command, reset_command + "\n"] @@ -878,7 +911,9 @@ class MainView(tk.Tk): return False else: 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( "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.") return # 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 messagebox.showwarning( "Not Connected", @@ -995,7 +1033,7 @@ class MainView(tk.Tk): except Exception: pass self._update_simulation_progress_display() - + self.current_archive = SimulationArchive(self.scenario) self.simulation_engine.archive = self.current_archive if self.target_communicator and hasattr(self.target_communicator, "router"): @@ -1020,15 +1058,15 @@ class MainView(tk.Tk): def _on_stop_simulation(self): if not self.is_simulation_running.get() or not self.simulation_engine: return - + if self.current_archive: self.current_archive.save() self.current_archive = None if self.target_communicator and hasattr(self.target_communicator, "router"): router = self.target_communicator.router() if router: - router.set_archive(None) # Disattiva l'archiviazione - self._refresh_analysis_list() # Aggiorna subito la lista + router.set_archive(None) # Disattiva l'archiviazione + self._refresh_analysis_list() # Aggiorna subito la lista self.logger.info("Stopping live simulation (user request)...") try: @@ -1058,15 +1096,15 @@ class MainView(tk.Tk): def _on_simulation_finished(self): """Handle the natural end-of-simulation event.""" self.logger.info("Handling simulation finished (engine signalled completion).") - + if self.current_archive: self.current_archive.save() self.current_archive = None if self.target_communicator and hasattr(self.target_communicator, "router"): router = self.target_communicator.router() if router: - router.set_archive(None) # Disattiva l'archiviazione - self._refresh_analysis_list() # Aggiorna subito la lista + router.set_archive(None) # Disattiva l'archiviazione + self._refresh_analysis_list() # Aggiorna subito la lista if self.simulation_engine and self.simulation_engine.is_running(): try: @@ -1091,7 +1129,9 @@ class MainView(tk.Tk): ) else: # 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: # Ensure we never raise from UI-notice handling try: @@ -1123,7 +1163,9 @@ class MainView(tk.Tk): 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) + self.sim_slider_var.set( + 1.0 if self.total_sim_time > 0 else 0.0 + ) except Exception: pass self._update_simulation_progress_display() @@ -1132,18 +1174,28 @@ class MainView(tk.Tk): 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", [])) + self.ppi_widget.update_real_targets( + display_data.get("real", []) + ) try: if ( hasattr(self, "simulation_hub") 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"): - az_deg, az_ts = self.simulation_hub.get_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() - + 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 @@ -1161,7 +1213,10 @@ class MainView(tk.Tk): # Update simulation progress bar try: - if self.simulation_engine and self.simulation_engine.scenario: + 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() @@ -1173,14 +1228,19 @@ class MainView(tk.Tk): ): progress_frac = min( 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._update_simulation_progress_display() except Exception: - self.logger.debug("Progress UI update failed", exc_info=True) - + self.logger.debug( + "Progress UI update failed", exc_info=True + ) + except Empty: # Queue is empty, we can stop processing for this cycle. break @@ -1197,12 +1257,16 @@ class MainView(tk.Tk): # Determine if analysis data exists. Ensure boolean type. 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 # 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 @@ -1386,6 +1450,24 @@ class MainView(tk.Tk): scenario_data = self.config_manager.get_scenario(scenario_name) if scenario_data: 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.current_scenario_name = scenario_name # Update target list UI with loaded scenario's targets @@ -1397,7 +1479,8 @@ class MainView(tk.Tk): # and planned paths before starting the simulation. try: 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: connected = False @@ -1469,7 +1552,9 @@ class MainView(tk.Tk): # Update scenario list UI and select the new scenario 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: # Fallback for older UI: directly set combobox values try: @@ -1818,7 +1903,7 @@ 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 @@ -1837,16 +1922,18 @@ class MainView(tk.Tk): 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"): + 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: @@ -1864,7 +1951,7 @@ class MainView(tk.Tk): 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) @@ -1880,33 +1967,46 @@ class MainView(tk.Tk): filepath = os.path.join(archive_folder, filename) try: # 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) metadata = data.get("metadata", {}) # Usiamo il timestamp del nome del file per l'ordinamento - dt_str = filename.split('_')[0] + dt_str = filename.split("_")[0] 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"), "duration": f"{metadata.get('duration_seconds', 0):.1f}", - "filepath": filepath + "filepath": filepath, } runs.append(run_info) 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 - 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']) + 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"], + ) def _on_analyze_run(self): selected_item = self.analysis_tree.focus() if not selected_item: - messagebox.showinfo("Nessuna Selezione", "Seleziona una simulazione da analizzare.") + messagebox.showinfo( + "Nessuna Selezione", "Seleziona una simulazione da analizzare." + ) return - - archive_filepath = selected_item # L'IID è il filepath - + + archive_filepath = selected_item # L'IID è il filepath + # Apri la finestra di analisi passando il percorso del file # (dovremo modificare AnalysisWindow per accettarlo) - AnalysisWindow(self, archive_filepath=archive_filepath) \ No newline at end of file + AnalysisWindow(self, archive_filepath=archive_filepath) diff --git a/target_simulator/gui/payload_router.py b/target_simulator/gui/payload_router.py index 39fadae..8d0d133 100644 --- a/target_simulator/gui/payload_router.py +++ b/target_simulator/gui/payload_router.py @@ -71,11 +71,9 @@ class DebugPayloadRouter: ord("R"): self._handle_ris_status, ord("r"): self._handle_ris_status, } - logger.info( - f"{self._log_prefix} Initialized (Hub: {self._hub is not None})." - ) + logger.info(f"{self._log_prefix} Initialized (Hub: {self._hub is not None}).") self._logger = logger - + def set_archive(self, archive): """Imposta la sessione di archivio corrente per la registrazione.""" with self._lock: @@ -220,7 +218,7 @@ class DebugPayloadRouter: "Failed to propagate heading to hub", exc_info=True ) - #if self._update_queue: + # if self._update_queue: # try: # self._update_queue.put_nowait([]) # except Full: @@ -231,10 +229,10 @@ class DebugPayloadRouter: self._logger.exception( "DebugPayloadRouter: Failed to process RIS for Hub." ) - + with self._lock: archive = self.active_archive - + if archive: reception_timestamp = time.monotonic() for target in real_targets: @@ -246,7 +244,7 @@ class DebugPayloadRouter: archive.add_real_state( target_id=target.target_id, timestamp=reception_timestamp, - state=state_tuple + state=state_tuple, ) # --- BROADCAST to all registered listeners --- diff --git a/target_simulator/gui/ppi_display.py b/target_simulator/gui/ppi_display.py index 1156e19..ad67521 100644 --- a/target_simulator/gui/ppi_display.py +++ b/target_simulator/gui/ppi_display.py @@ -327,16 +327,26 @@ class PPIDisplay(ttk.Frame): # Draw active targets as before 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 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: 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 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 theta_rad_plot = np.deg2rad(target.current_azimuth_deg) # 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) # overlay a yellow X at the same position @@ -545,14 +557,16 @@ class PPIDisplay(ttk.Frame): waypoints = target.trajectory if not waypoints: continue - path, _ = Target.generate_path_from_waypoints(waypoints, target.use_spline) + path, _ = Target.generate_path_from_waypoints( + waypoints, target.use_spline + ) if not path: continue path_thetas, path_rs = [], [] for point in path: 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) path_rs.append(r_ft / NM_TO_FT) 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_art = 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] if arts: self.preview_path_artists[target.target_id] = arts 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: self.canvas.draw() diff --git a/target_simulator/utils/config_manager.py b/target_simulator/utils/config_manager.py index fcfb79e..3e55420 100644 --- a/target_simulator/utils/config_manager.py +++ b/target_simulator/utils/config_manager.py @@ -62,7 +62,12 @@ class ConfigManager: ) 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() # Load scenarios from separate file if present, otherwise keep any scenarios # found inside settings.json (fallback). @@ -93,6 +98,11 @@ class ConfigManager: # `_load_or_initialize_scenarios()`. 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": {}} try: with open(self.filepath, "r", encoding="utf-8") as f: @@ -102,8 +112,19 @@ class ConfigManager: return {"general": settings, "scenarios": {}} # Keep scenarios key if present; actual source of truth for scenarios # 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 except (json.JSONDecodeError, IOError): + try: + self._scenarios = self._load_or_initialize_scenarios() + except Exception: + self._scenarios = {} return {"general": {}, "scenarios": {}} def _save_settings(self): @@ -126,15 +147,46 @@ class ConfigManager: Falls back to scenarios inside settings.json when scenarios.json is absent. """ - # If scenarios file exists, load from it. - if os.path.exists(self.scenarios_filepath): - try: - with open(self.scenarios_filepath, "r", encoding="utf-8") as f: + # 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 (json.JSONDecodeError, IOError): - return {} + 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): + try: + with open(self.scenarios_filepath, "r", encoding="utf-8") as f: + scenarios = json.load(f) + if isinstance(scenarios, dict): + return scenarios + except (json.JSONDecodeError, IOError): + return {} + except Exception: + # If path computations fail, fall back silently to other options. + pass # Fallback: try to read scenarios stored inside settings.json try: @@ -201,14 +253,18 @@ class ConfigManager: shutil.copy2(self.scenarios_filepath, prebackup) logger.debug("Created pre-write backup: %s", prebackup) except Exception: - logger.debug("Pre-write backup failed, continuing", exc_info=True) + logger.debug( + "Pre-write backup failed, continuing", exc_info=True + ) except Exception: # Non-fatal pass # Write atomically: write to a temp file then validate and replace the target. 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: 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: validated = json.load(tf) 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: os.remove(tmp_path) except Exception: pass return 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: os.remove(tmp_path) except Exception: @@ -246,7 +306,9 @@ class ConfigManager: try: 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 ( existing_count > 1 and new_count <= 1 @@ -359,10 +421,18 @@ class ConfigManager: try: # Basic sanity check: expect a mapping for scenario data 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 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())) self._save_scenarios() except Exception: diff --git a/tests/utils/test_config_manager.py b/tests/utils/test_config_manager.py index a3a8e45..db62984 100644 --- a/tests/utils/test_config_manager.py +++ b/tests/utils/test_config_manager.py @@ -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.""" cm = ConfigManager(filename=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() return cm