# target_simulator/gui/analysis_window.py """ A Toplevel window for displaying performance analysis by processing an efficient trail file. """ import tkinter as tk from tkinter import ttk, messagebox import json import os import csv import math import statistics import warnings from typing import Optional, Dict, List, Any, Tuple from target_simulator.gui.performance_analysis_window import PerformanceAnalysisWindow try: import numpy as np from matplotlib.figure import Figure from matplotlib.backends.backend_tkagg import ( FigureCanvasTkAgg, NavigationToolbar2Tk, ) MATPLOTLIB_AVAILABLE = True except ImportError: np = None MATPLOTLIB_AVAILABLE = False # Constants for analysis DOWNSAMPLE_THRESHOLD = 4000 # Number of points before downsampling is applied class AnalysisWindow(tk.Toplevel): """ A window that displays tracking performance analysis by loading data from an archive's main JSON file and its associated `.trail.csv`. """ def __init__(self, master, archive_filepath: str): super().__init__(master) self.title(f"Analysis for: {os.path.basename(archive_filepath)}") self.geometry("1100x800") self.archive_filepath = archive_filepath self.trail_filepath: Optional[str] = None self.performance_data_path: Optional[str] = None self.scenario_name = "Unknown" self.target_ids: List[int] = [] self.selected_target_id = tk.IntVar() self._show_loading_window(archive_filepath) def _load_data_and_setup(self, filepath: str): """Loads metadata from the main archive and finds associated data files.""" try: with open(filepath, "r", encoding="utf-8") as f: archive_data = json.load(f) except Exception as e: raise IOError(f"Could not load archive file: {e}") metadata = archive_data.get("metadata", {}) self.scenario_name = metadata.get("scenario_name", "Unknown") self.title(f"Analysis - {self.scenario_name}") # Find the associated trail, latency, and performance files base_path, _ = os.path.splitext(filepath) self.trail_filepath = f"{base_path}.trail.csv" self.latency_filepath = f"{base_path}.latency.csv" self.performance_data_path = f"{base_path}.perf.csv" if not os.path.exists(self.trail_filepath): raise FileNotFoundError( f"Required trail file not found: {self.trail_filepath}" ) # Get available target IDs from the trail file header with open(self.trail_filepath, "r", encoding="utf-8") as f: reader = csv.reader(f) headers = next(reader, []) if "target_id" not in headers: raise ValueError("Trail file missing 'target_id' column.") target_id_index = headers.index("target_id") ids = set() for row in reader: if row and not row[0].startswith("#"): try: ids.add(int(row[target_id_index])) except (ValueError, IndexError): continue self.target_ids = sorted(list(ids)) def _show_loading_window(self, archive_filepath: str): """Shows a loading dialog and loads data in the background.""" loading_dialog = tk.Toplevel(self) # ... (loading dialog implementation is unchanged) loading_dialog.title("Loading Analysis") loading_dialog.geometry("400x150") loading_dialog.transient(self) loading_dialog.grab_set() loading_dialog.update_idletasks() x = ( self.winfo_x() + (self.winfo_width() // 2) - (loading_dialog.winfo_width() // 2) ) y = ( self.winfo_y() + (self.winfo_height() // 2) - (loading_dialog.winfo_height() // 2) ) loading_dialog.geometry(f"+{x}+{y}") ttk.Label( loading_dialog, text="Loading simulation data...", font=("Segoe UI", 11) ).pack(pady=(20, 10)) progress_label = ttk.Label( loading_dialog, text="Please wait", font=("Segoe UI", 9) ) progress_label.pack(pady=5) progress = ttk.Progressbar(loading_dialog, mode="indeterminate", length=300) progress.pack(pady=10) progress.start(10) def load_and_display(): try: progress_label.config(text="Locating data files...") self.update() self._load_data_and_setup(archive_filepath) progress_label.config(text="Creating widgets...") self.update() self._create_widgets() progress_label.config(text="Ready.") self.update() loading_dialog.destroy() # Trigger initial analysis self._on_target_select() except Exception as e: loading_dialog.destroy() messagebox.showerror( "Analysis Error", f"Failed to load analysis:\n{e}", parent=self ) self.destroy() self.after(100, load_and_display) def _create_widgets(self): main_pane = ttk.PanedWindow(self, orient=tk.VERTICAL) main_pane.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) stats_frame = ttk.LabelFrame(main_pane, text="Error Statistics (feet)") main_pane.add(stats_frame, weight=1) self._create_stats_widgets(stats_frame) plot_frame = ttk.LabelFrame(main_pane, text="Error Over Time (feet)") main_pane.add(plot_frame, weight=4) self._create_plot_widgets(plot_frame) def _create_stats_widgets(self, parent): # Configure grid per il layout parent.rowconfigure(0, weight=0) # Top bar parent.rowconfigure(1, weight=1) # Content area parent.columnconfigure(0, weight=1) # Top bar con combobox e pulsante top_bar = ttk.Frame(parent, padding=5) top_bar.grid(row=0, column=0, sticky="ew") ttk.Label(top_bar, text="Select Target ID:").pack(side=tk.LEFT) self.target_selector = ttk.Combobox( top_bar, textvariable=self.selected_target_id, state="readonly", width=5, values=self.target_ids, ) self.target_selector.pack(side=tk.LEFT, padx=5) self.target_selector.bind("<>", self._on_target_select) if self.target_ids: self.selected_target_id.set(self.target_ids[0]) # Performance Analysis button (always visible, disabled if no data) perf_button = ttk.Button( top_bar, text="Open Performance Analysis", command=self._open_performance_window, ) perf_button.pack(side=tk.LEFT, padx=(20, 0)) if not os.path.exists(self.performance_data_path): perf_button.config(state="disabled") # Content container diviso in due colonne content_frame = ttk.Frame(parent) content_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) content_frame.columnconfigure(0, weight=1, uniform="half") content_frame.columnconfigure(1, weight=1, uniform="half") content_frame.rowconfigure(0, weight=1) # Left: Stats table table_container = ttk.Frame(content_frame) table_container.grid(row=0, column=0, sticky="nsew", padx=(0, 2)) columns = ("error_type", "mean", "std_dev", "rmse") self.stats_tree = ttk.Treeview( table_container, columns=columns, show="headings", height=4 ) self.stats_tree.heading("error_type", text="") self.stats_tree.heading("mean", text="Mean (ft)") self.stats_tree.heading("std_dev", text="Std Dev (ft)") self.stats_tree.heading("rmse", text="RMSE (ft)") self.stats_tree.column("error_type", width=100, anchor=tk.W) self.stats_tree.column("mean", anchor=tk.E, width=120) self.stats_tree.column("std_dev", anchor=tk.E, width=120) self.stats_tree.column("rmse", anchor=tk.E, width=120) self.stats_tree.pack(fill=tk.BOTH, expand=True) # Right: Legend frame legend_frame = ttk.Frame(content_frame) legend_frame.grid(row=0, column=1, sticky="nsew", padx=(2, 0)) legend_title = ttk.Label( legend_frame, text="How to Interpret Results:", font=("Segoe UI", 9, "bold") ) legend_title.pack(anchor=tk.NW, pady=(0, 5)) explanation_text = ( "Error = Real - Simulated Position\n\n" "Sign (e.g., X axis):\n" "• Positive: Real target at larger X\n" "• Negative: Real target at smaller X\n\n" "Spike Filtering:\n" "Transients >20x median filtered\n" "from plots and statistics.\n\n" "Latency:\n" "Time from packet generation\n" "(server) to reception (client)." ) ttk.Label( legend_frame, text=explanation_text, justify=tk.LEFT, font=("Segoe UI", 9) ).pack(anchor=tk.NW, fill=tk.BOTH, expand=True) def _create_plot_widgets(self, parent): if not MATPLOTLIB_AVAILABLE: ttk.Label(parent, text="Matplotlib is required for plotting.").pack() return fig = Figure(figsize=(5, 7), dpi=100) # Check if latency file exists to determine subplot layout has_latency = os.path.exists(self.latency_filepath) if has_latency: # Two subplots: errors (top) and latency (bottom) gs = fig.add_gridspec(2, 1, height_ratios=[2, 1], hspace=0.3, top=0.95) self.ax = fig.add_subplot(gs[0, 0]) self.ax_latency = fig.add_subplot(gs[1, 0], sharex=self.ax) else: # Single subplot: just errors self.ax = fig.add_subplot(111) self.ax_latency = None # Error plot self.ax.set_title("Instantaneous Error") self.ax.set_ylabel("Error (ft)") (self.line_x,) = self.ax.plot([], [], lw=1.5, label="Error X", color="#1f77b4") (self.line_y,) = self.ax.plot([], [], lw=1.5, label="Error Y", color="#ff7f0e") (self.line_z,) = self.ax.plot([], [], lw=1.5, label="Error Z", color="#2ca02c") self.ax.grid(True, alpha=0.3) self.ax.axhline(0.0, color="black", lw=1, linestyle="--", alpha=0.5) self.ax.legend(loc="upper right", fontsize=9) if not has_latency: self.ax.set_xlabel("Elapsed Time (s)") # Latency plot (if file exists) if has_latency: self.ax_latency.set_title("Latency Evolution") self.ax_latency.set_xlabel("Elapsed Time (s)") self.ax_latency.set_ylabel("Latency (ms)") (self.line_latency,) = self.ax_latency.plot( [], [], lw=1.5, color="#d62728", label="Latency" ) self.ax_latency.grid(True, alpha=0.3) self.ax_latency.legend(loc="upper right", fontsize=9) else: self.line_latency = None with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) fig.tight_layout() canvas_frame = ttk.Frame(parent) canvas_frame.pack(fill=tk.BOTH, expand=True) toolbar_frame = ttk.Frame(canvas_frame) toolbar_frame.pack(side=tk.TOP, fill=tk.X) self.canvas = FigureCanvasTkAgg(fig, master=canvas_frame) toolbar = NavigationToolbar2Tk(self.canvas, toolbar_frame) toolbar.update() self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True) def _on_target_select(self, event=None): """Initiates analysis for the selected target.""" if not self.trail_filepath: return target_id = self.selected_target_id.get() # Analyze data (fast operation now) timestamps, errors, stats = self._analyze_trail_file(target_id) # Update UI - load latency first so stats table can include it self._update_latency_plot() self._update_stats_table(stats) self._update_plot(timestamps, errors) def _analyze_trail_file( self, target_id: int ) -> Tuple[List[float], Dict[str, List[float]], Dict[str, Dict[str, float]]]: """ Analyzes the trail file for a specific target using an efficient two-pointer algorithm. """ sim_points = [] real_points = [] with open(self.trail_filepath, "r", encoding="utf-8") as f: reader = csv.DictReader(line for line in f if not line.startswith("#")) for row in reader: try: if int(row["target_id"]) == target_id: point = ( float(row["timestamp"]), float(row["x_ft"]), float(row["y_ft"]), float(row["z_ft"]), ) if row["source"] == "simulated": sim_points.append(point) elif row["source"] == "real": real_points.append(point) except (ValueError, KeyError): continue if not sim_points or not real_points: return [], {}, {} # --- Two-Pointer Algorithm for Error Calculation --- timestamps, errors_x, errors_y, errors_z = [], [], [], [] sim_idx = 0 for real_p in real_points: real_ts, real_x, real_y, real_z = real_p # Advance sim_idx to find the bracketing segment for the current real point while ( sim_idx + 1 < len(sim_points) and sim_points[sim_idx + 1][0] < real_ts ): sim_idx += 1 if sim_idx + 1 < len(sim_points): p1 = sim_points[sim_idx] p2 = sim_points[sim_idx + 1] # Check if the real point is within this segment if p1[0] <= real_ts <= p2[0]: # Interpolate ts1, x1, y1, z1 = p1 ts2, x2, y2, z2 = p2 duration = ts2 - ts1 if duration == 0: continue factor = (real_ts - ts1) / duration interp_x = x1 + (x2 - x1) * factor interp_y = y1 + (y2 - y1) * factor interp_z = z1 + (z2 - z1) * factor timestamps.append(real_ts) errors_x.append(real_x - interp_x) errors_y.append(real_y - interp_y) errors_z.append(real_z - interp_z) errors = {"x": errors_x, "y": errors_y, "z": errors_z} # Calculate final statistics on the full (non-downsampled) data stats = {} for axis, err_list in errors.items(): if not err_list: stats[axis] = {"mean": 0, "std_dev": 0, "rmse": 0} continue mean = statistics.mean(err_list) stdev = statistics.stdev(err_list) if len(err_list) > 1 else 0 rmse = math.sqrt(sum(e**2 for e in err_list) / len(err_list)) stats[axis] = {"mean": mean, "std_dev": stdev, "rmse": rmse} return timestamps, errors, stats def _downsample_data(self, timestamps: List, errors: Dict) -> Tuple[List, Dict]: """Reduces the number of points for plotting while preserving shape.""" if len(timestamps) <= DOWNSAMPLE_THRESHOLD: return timestamps, errors # Simple interval-based downsampling step = len(timestamps) // DOWNSAMPLE_THRESHOLD ts_down = timestamps[::step] err_down = { "x": errors["x"][::step], "y": errors["y"][::step], "z": errors["z"][::step], } return ts_down, err_down def _update_stats_table(self, stats: Dict): """Populates the stats Treeview with calculated metrics.""" self.stats_tree.delete(*self.stats_tree.get_children()) for axis, data in stats.items(): self.stats_tree.insert( "", "end", values=( f"Error {axis.upper()}", f"{data['mean']:.3f}", f"{data['std_dev']:.3f}", f"{data['rmse']:.3f}", ), ) # Add latency statistics if available if hasattr(self, "_latency_data") and self._latency_data: lat_mean = statistics.mean(self._latency_data) lat_std = ( statistics.stdev(self._latency_data) if len(self._latency_data) > 1 else 0.0 ) lat_min = min(self._latency_data) lat_max = max(self._latency_data) self.stats_tree.insert( "", "end", values=( "Latency (ms)", f"{lat_mean:.2f}", f"{lat_std:.2f}", f"{lat_min:.2f} - {lat_max:.2f}", ), ) def _update_plot(self, timestamps: List[float], errors: Dict[str, List[float]]): """Updates the matplotlib plot with (potentially downsampled) data.""" # Apply spike filtering filtered_ts, filtered_errors, spike_count, max_spike_error, max_spike_time = ( self._filter_spikes(timestamps, errors) ) # Convert to elapsed time (seconds from start) if filtered_ts: start_time = min(filtered_ts) elapsed_times = [t - start_time for t in filtered_ts] else: elapsed_times = [] # Downsample if needed ts_plot, errors_plot = self._downsample_data(elapsed_times, filtered_errors) self.line_x.set_data(ts_plot, errors_plot["x"]) self.line_y.set_data(ts_plot, errors_plot["y"]) self.line_z.set_data(ts_plot, errors_plot["z"]) # Remove old spike annotations for txt in getattr(self.ax, "_spike_annotations", []): txt.remove() self.ax._spike_annotations = [] # Add spike annotation if any were filtered if spike_count > 0: annotation_text = ( f"⚠ {spike_count} acquisition spike(s) filtered\n" f"(max error: {max_spike_error:.0f} ft at t={max_spike_time:.1f}s)\n" f"Spikes excluded from statistics" ) txt = self.ax.text( 0.02, 0.98, annotation_text, transform=self.ax.transAxes, verticalalignment="top", bbox=dict(boxstyle="round", facecolor="yellow", alpha=0.7), fontsize=8, ) self.ax._spike_annotations.append(txt) self.ax.relim() self.ax.autoscale_view() self.canvas.draw_idle() def _filter_spikes( self, timestamps: List[float], errors: Dict[str, List[float]] ) -> tuple: """Filters acquisition spikes from error data.""" if not timestamps: return timestamps, errors, 0, 0.0, 0.0 # Calculate magnitude for each point magnitudes = [] for i in range(len(timestamps)): mag = math.sqrt( errors["x"][i] ** 2 + errors["y"][i] ** 2 + errors["z"][i] ** 2 ) magnitudes.append(mag) # Sample a window 5-15 seconds into the simulation to compute threshold min_time = min(timestamps) sample_mags = [] for i, t in enumerate(timestamps): if min_time + 5.0 <= t <= min_time + 15.0: sample_mags.append(magnitudes[i]) if not sample_mags: return timestamps, errors, 0, 0.0, 0.0 # Threshold: 20x the median error magnitude in the sample window threshold = max(statistics.median(sample_mags) * 20, 500.0) # Filter out spikes filtered_ts = [] filtered_errors = {"x": [], "y": [], "z": []} spike_count = 0 max_spike_error = 0.0 max_spike_time = 0.0 for i in range(len(timestamps)): if magnitudes[i] > threshold: spike_count += 1 if magnitudes[i] > max_spike_error: max_spike_error = magnitudes[i] max_spike_time = timestamps[i] else: filtered_ts.append(timestamps[i]) filtered_errors["x"].append(errors["x"][i]) filtered_errors["y"].append(errors["y"][i]) filtered_errors["z"].append(errors["z"][i]) return ( filtered_ts, filtered_errors, spike_count, max_spike_error, max_spike_time, ) def _update_latency_plot(self): """Updates the latency subplot with data from the latency CSV file.""" if not self.ax_latency or not self.line_latency: self._latency_data = [] return if not os.path.exists(self.latency_filepath): self.line_latency.set_data([], []) self._latency_data = [] self.ax_latency.relim() self.ax_latency.autoscale_view() self.canvas.draw_idle() return timestamps = [] latencies = [] try: with open(self.latency_filepath, "r", encoding="utf-8") as f: reader = csv.DictReader(line for line in f if not line.startswith("#")) for row in reader: try: timestamps.append(float(row["timestamp"])) latencies.append(float(row["latency_ms"])) except (ValueError, KeyError): continue # Save full data for statistics self._latency_data = latencies # Convert to elapsed time (seconds from start) if timestamps: start_time = min(timestamps) elapsed_times = [t - start_time for t in timestamps] else: elapsed_times = [] # Downsample for plotting if needed ts_plot = elapsed_times lat_plot = latencies if len(elapsed_times) > DOWNSAMPLE_THRESHOLD: step = len(elapsed_times) // DOWNSAMPLE_THRESHOLD ts_plot = elapsed_times[::step] lat_plot = latencies[::step] self.line_latency.set_data(ts_plot, lat_plot) self.ax_latency.relim() self.ax_latency.autoscale_view() self.canvas.draw_idle() except Exception as e: self.line_latency.set_data([], []) self._latency_data = [] print(f"Warning: Failed to load latency data: {e}") def _open_performance_window(self): """Opens the dedicated performance analysis window.""" if not self.performance_data_path or not os.path.exists( self.performance_data_path ): messagebox.showinfo( "No Data", "No performance data file found for this run.", parent=self ) return try: PerformanceAnalysisWindow( parent=self, performance_csv_path=self.performance_data_path ) except Exception as e: messagebox.showerror( "Error", f"Failed to open performance analysis:\n{e}", parent=self )