salvate latenze,posizioni,e performance in file csv

This commit is contained in:
VALLONGOL 2025-11-17 12:41:30 +01:00
parent b3bf64f206
commit 3b9484eaef
12 changed files with 702 additions and 684 deletions

View File

@ -5,5 +5,7 @@
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"todo-tree.tree.showBadges": false,
"todo-tree.tree.showCountsInTree": true
"todo-tree.tree.showCountsInTree": true,
"python.terminal.activateEnvironment": true,
"python.defaultInterpreterPath": "${workspaceFolder}\\.venv\\Scripts\\python.exe"
}

View File

@ -3,7 +3,7 @@
"scan_limit": 60,
"max_range": 100,
"geometry": "1492x992+230+258",
"last_selected_scenario": "scenario_dritto",
"last_selected_scenario": "scenario2",
"connection": {
"target": {
"type": "sfp",
@ -45,6 +45,7 @@
},
"debug": {
"enable_io_trace": true,
"enable_performance_profiling": true,
"temp_folder_name": "Temp",
"io_trace_sent_filename": "sent_positions.csv",
"io_trace_received_filename": "received_positions.csv"

View File

@ -1,148 +0,0 @@
# target_simulator/analysis/performance_analyzer.py
"""
Provides the PerformanceAnalyzer class for calculating error metrics
by comparing simulated data with real-time radar data.
"""
import math
from typing import Dict, List, Optional, Tuple
from target_simulator.analysis.simulation_state_hub import (
SimulationStateHub,
TargetState,
)
# Structure to hold analysis results for a single target
AnalysisResult = Dict[str, Dict[str, float]]
class PerformanceAnalyzer:
"""
Analyzes the performance of the radar tracking by comparing simulated
'ground truth' data against the real data received from the radar.
"""
def __init__(self, hub: SimulationStateHub):
"""
Initializes the analyzer with a reference to the data hub.
Args:
hub: The SimulationStateHub containing the historical data.
"""
self._hub = hub
def analyze(self) -> Dict[int, AnalysisResult]:
"""
Performs a full analysis on all targets currently in the hub.
For each target, it aligns the real and simulated data streams
temporally using linear interpolation and calculates key performance
metrics like Mean Error and Root Mean Square Error (RMSE).
Returns:
A dictionary where keys are target IDs and values are the
analysis results for that target.
"""
results: Dict[int, AnalysisResult] = {}
target_ids = self._hub.get_all_target_ids()
for tid in target_ids:
history = self._hub.get_target_history(tid)
if not history or not history["real"] or len(history["simulated"]) < 2:
# Not enough data to perform analysis
continue
simulated_history = sorted(
history["simulated"]
) # Ensure sorted by timestamp
real_history = history["real"]
errors_x: List[float] = []
errors_y: List[float] = []
errors_z: List[float] = []
for real_state in real_history:
real_ts, real_x, real_y, real_z = real_state
# Find the two simulated points that bracket the real point in time
p1, p2 = self._find_bracketing_points(real_ts, simulated_history)
if p1 and p2:
# We have bracketing points, so we can interpolate
interpolated_state = self._interpolate(real_ts, p1, p2)
_interp_ts, interp_x, interp_y, interp_z = interpolated_state
# Calculate instantaneous error
errors_x.append(real_x - interp_x)
errors_y.append(real_y - interp_y)
errors_z.append(real_z - interp_z)
# If we have collected errors, calculate statistics
if errors_x:
results[tid] = {
"x": self._calculate_stats(errors_x),
"y": self._calculate_stats(errors_y),
"z": self._calculate_stats(errors_z),
}
return results
def _find_bracketing_points(
self, timestamp: float, history: List[TargetState]
) -> Tuple[Optional[TargetState], Optional[TargetState]]:
"""
Finds two points in a time-sorted history that surround a given timestamp.
"""
p1, p2 = None, None
for i in range(len(history) - 1):
if history[i][0] <= timestamp <= history[i + 1][0]:
p1 = history[i]
p2 = history[i + 1]
break
return p1, p2
def _interpolate(
self, timestamp: float, p1: TargetState, p2: TargetState
) -> TargetState:
"""
Performs linear interpolation between two state points (p1 and p2)
to estimate the state at a given timestamp.
"""
ts1, x1, y1, z1 = p1
ts2, x2, y2, z2 = p2
# Avoid division by zero if timestamps are identical
duration = ts2 - ts1
if duration == 0:
return p1
# Calculate interpolation factor (how far timestamp is between ts1 and ts2)
factor = (timestamp - ts1) / duration
# Interpolate each coordinate
interp_x = x1 + (x2 - x1) * factor
interp_y = y1 + (y2 - y1) * factor
interp_z = z1 + (z2 - z1) * factor
return (timestamp, interp_x, interp_y, interp_z)
def _calculate_stats(self, errors: List[float]) -> Dict[str, float]:
"""Calculates mean, variance, and RMSE for a list of errors."""
n = len(errors)
if n == 0:
return {"mean": 0, "variance": 0, "std_dev": 0, "rmse": 0}
mean = sum(errors) / n
# Variance and Standard Deviation
variance = sum((x - mean) ** 2 for x in errors) / n
std_dev = math.sqrt(variance)
# Root Mean Square Error
rmse = math.sqrt(sum(x**2 for x in errors) / n)
return {
"mean": mean,
"variance": variance,
"std_dev": std_dev,
"rmse": rmse,
}

View File

@ -5,18 +5,18 @@ import json
import time
from datetime import datetime
from typing import Dict, List, Any, Tuple, Optional
import math
from target_simulator.core.models import Scenario
import math
from target_simulator.utils.csv_logger import append_row, flush_all_csv_buffers
# Prefer pyproj for accurate geodesic calculations; fall back to a simple
# equirectangular approximation when pyproj is not available.
try:
from pyproj import Geod
_GEOD = Geod(ellps="WGS84")
_HAS_PYPROJ = True
except Exception:
except ImportError:
_GEOD = None
_HAS_PYPROJ = False
@ -27,29 +27,32 @@ RecordedState = Tuple[float, float, float, float] # (timestamp, x_ft, y_ft, z_f
class SimulationArchive:
"""
Manages data collection for a single simulation run and saves it to a file.
Positions are streamed to an efficient CSV trail file for fast analysis.
"""
ARCHIVE_FOLDER = "archive_simulations"
TRAIL_HEADERS = ["timestamp", "source", "target_id", "x_ft", "y_ft", "z_ft"]
LATENCY_HEADERS = ["timestamp", "latency_ms"]
def __init__(self, scenario: Scenario):
"""
Initializes a new archive session for a given scenario.
"""
"""Initializes a new archive session for a given scenario."""
self.start_time = time.monotonic()
self.scenario_name = scenario.name
self.scenario_data = scenario.to_dict()
# Data structure to hold recorded events, indexed by target_id
self.recorded_data: Dict[int, Dict[str, List[RecordedState]]] = {}
# Data structure to hold the ownship's trajectory
self.ownship_trajectory: List[Dict[str, Any]] = []
# Data structure to hold computed georeferenced positions for real targets
# keyed by target_id -> list of {'timestamp': t, 'lat': ..., 'lon': ..., 'alt_ft': ...}
self.recorded_geopos: Dict[int, List[Dict[str, Any]]] = {}
self._ensure_archive_directory()
# Generate a unique temporary trail filename based on precise start time
ts_str_precise = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
self._temp_trail_filename = self._get_trail_filename(ts_str=ts_str_precise)
self._temp_latency_filename = self._get_latency_filename(ts_str=ts_str_precise)
self._cleanup_stale_trail_file(self._temp_trail_filename)
self._cleanup_stale_trail_file(self._temp_latency_filename)
def _ensure_archive_directory(self):
"""Creates the main archive directory if it does not exist."""
if not os.path.exists(self.ARCHIVE_FOLDER):
@ -58,84 +61,90 @@ class SimulationArchive:
except OSError as e:
print(f"Error creating archive directory: {e}")
def add_simulated_state(
self, target_id: int, timestamp: float, state: Tuple[float, ...]
):
"""Adds a simulated state to the archive."""
def _get_trail_filename(self, ts_str: Optional[str] = None) -> str:
"""Generates a filename for the CSV trail file."""
if not ts_str:
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()
return f"{ts_str}_{safe_scenario_name}.trail.csv"
def _get_latency_filename(self, ts_str: Optional[str] = None) -> str:
"""Generates a filename for the CSV latency file."""
if not ts_str:
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()
return f"{ts_str}_{safe_scenario_name}.latency.csv"
def _cleanup_stale_trail_file(self, filename: str):
"""Removes the specified trail file if it exists."""
filepath = os.path.join(self.ARCHIVE_FOLDER, filename)
if os.path.exists(filepath):
try:
os.remove(filepath)
except OSError:
pass # Non-critical if removal fails
def add_simulated_state(self, target_id: int, timestamp: float, state: Tuple[float, ...]):
"""Adds a simulated state to the archive and streams it to the trail file."""
# This part is kept for backward compatibility and direct access if needed
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, ...]
):
"""Adds a real state (from the server) to the archive."""
# Stream to the temporary CSV trail file asynchronously
row = [timestamp, "simulated", target_id, state[0], state[1], state[2]]
append_row(self._temp_trail_filename, row, headers=self.TRAIL_HEADERS)
def add_real_state(self, target_id: int, timestamp: float, state: Tuple[float, ...]):
"""Adds a real state (from the server) to the archive and streams it."""
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)
# Attempt to compute and store geoposition for this real sample.
# Stream to the temporary CSV trail file asynchronously
row = [timestamp, "real", target_id, state[0], state[1], state[2]]
append_row(self._temp_trail_filename, row, headers=self.TRAIL_HEADERS)
try:
self._compute_and_store_geopos(target_id, timestamp, state)
except Exception:
# Non-fatal: if geopositioning fails we simply skip it
pass
def _compute_and_store_geopos(
self, target_id: int, timestamp: float, state: Tuple[float, ...]
):
"""Compute georeferenced lat/lon for a real state and store it in recorded_geopos.
This method is separated for easier testing and clarity.
"""
def _compute_and_store_geopos(self, target_id: int, timestamp: float, state: Tuple[float, ...]):
"""Compute georeferenced lat/lon for a real state and store it."""
if not self.ownship_trajectory:
return
# Find ownship state closest in time
best = min(
self.ownship_trajectory,
key=lambda s: abs(s.get("timestamp", 0.0) - timestamp),
)
own_lat = best.get("latitude")
own_lon = best.get("longitude")
own_pos = best.get("position_xy_ft")
if own_lat is None or own_lon is None or not own_pos:
best_ownship = min(self.ownship_trajectory, key=lambda s: abs(s.get("timestamp", 0.0) - timestamp))
own_lat, own_lon, own_pos = best_ownship.get("latitude"), best_ownship.get("longitude"), best_ownship.get("position_xy_ft")
if any(v is None for v in [own_lat, own_lon, own_pos]):
return
# target and ownship positions are in feet: (x_east_ft, y_north_ft)
target_x_ft = float(state[0])
target_y_ft = float(state[1])
own_x_ft = float(own_pos[0])
own_y_ft = float(own_pos[1])
target_x_ft, target_y_ft = float(state[0]), float(state[1])
own_x_ft, own_y_ft = float(own_pos[0]), float(own_pos[1])
# Compute deltas in meters
delta_east_m = (target_x_ft - own_x_ft) * 0.3048
delta_north_m = (target_y_ft - own_y_ft) * 0.3048
# Use pyproj.Geod when available for accurate forward geodesic
target_lat = None
target_lon = None
if _HAS_PYPROJ and _GEOD is not None:
distance_m = math.hypot(delta_east_m, delta_north_m)
az_rad = math.atan2(delta_east_m, delta_north_m)
az_deg = math.degrees(az_rad)
target_lat, target_lon = None, None
if _HAS_PYPROJ and _GEOD:
try:
lon2, lat2, _ = _GEOD.fwd(
float(own_lon), float(own_lat), az_deg, distance_m
)
target_lat = lat2
target_lon = lon2
distance_m = math.hypot(delta_east_m, delta_north_m)
az_deg = math.degrees(math.atan2(delta_east_m, delta_north_m))
lon2, lat2, _ = _GEOD.fwd(float(own_lon), float(own_lat), az_deg, distance_m)
target_lat, target_lon = lat2, lon2
except Exception:
# fall back to equirectangular below
target_lat = None
target_lon = None
target_lat, target_lon = None, None
if target_lat is None or target_lon is None:
# Convert meters to degrees using a simple equirectangular approximation
R = 6378137.0 # Earth radius in meters (WGS84 sphere approx)
if target_lat is None:
R = 6378137.0
dlat = (delta_north_m / R) * (180.0 / math.pi)
lat_rad = math.radians(float(own_lat))
dlon = (delta_east_m / (R * math.cos(lat_rad))) * (180.0 / math.pi)
@ -144,51 +153,31 @@ class SimulationArchive:
if target_id not in self.recorded_geopos:
self.recorded_geopos[target_id] = []
self.recorded_geopos[target_id].append(
{
"timestamp": timestamp,
"lat": round(target_lat, 7),
"lon": round(target_lon, 7),
self.recorded_geopos[target_id].append({
"timestamp": timestamp, "lat": round(target_lat, 7), "lon": round(target_lon, 7),
"alt_ft": float(state[2]) if len(state) > 2 else None,
}
)
})
def add_ownship_state(self, state: Dict[str, Any]):
"""
Adds an ownship state sample to the archive's trajectory.
Args:
state: A dictionary representing the ownship's state at a point in time.
"""
"""Adds an ownship state sample to the archive's trajectory."""
self.ownship_trajectory.append(state)
def add_latency_sample(self, timestamp: float, latency_ms: float):
"""Adds a latency sample to the latency CSV file."""
row = [timestamp, latency_ms]
append_row(self._temp_latency_filename, row, headers=self.LATENCY_HEADERS)
def save(self, extra_metadata: Optional[Dict[str, Any]] = None) -> str:
"""
Saves the complete simulation archive to a JSON file.
The filename is generated from the timestamp and scenario name.
Performance data is saved to a separate '.perf.csv' file.
Args:
extra_metadata: An optional dictionary of metadata to add or
overwrite in the final archive file.
Returns:
The path of the saved file.
Saves the simulation archive and performance data to separate files,
and finalizes the trail file by renaming it.
"""
end_time = time.monotonic()
metadata = {
"scenario_name": self.scenario_name,
"start_timestamp_utc": datetime.utcnow().isoformat(),
"duration_seconds": end_time - self.start_time,
}
# Merge extra metadata if provided
if extra_metadata:
metadata.update(extra_metadata)
# --- Performance Data Separation ---
performance_samples = metadata.pop("performance_samples", None)
# --- MODIFIED PART START ---
# Force a synchronous flush of all CSV data before proceeding
flush_all_csv_buffers()
# --- MODIFIED PART END ---
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_scenario_name = "".join(
@ -196,36 +185,72 @@ class SimulationArchive:
).rstrip()
base_filename = f"{ts_str}_{safe_scenario_name}"
# Save performance data to a separate CSV file
# --- Finalize Trail File ---
from target_simulator.config import DEBUG_CONFIG
temp_folder = DEBUG_CONFIG.get("temp_folder_name", "Temp")
final_trail_filename = f"{base_filename}.trail.csv"
try:
temp_trail_path = os.path.join(temp_folder, self._temp_trail_filename)
final_trail_path = os.path.join(self.ARCHIVE_FOLDER, final_trail_filename)
if os.path.exists(temp_trail_path):
os.rename(temp_trail_path, final_trail_path)
else:
print(f"Warning: Temporary trail file not found at {temp_trail_path}. No trail file will be saved.")
final_trail_filename = ""
except OSError as e:
print(f"Warning: could not rename trail file: {e}")
final_trail_filename = self._temp_trail_filename
# --- Finalize Latency File ---
final_latency_filename = f"{base_filename}.latency.csv"
try:
temp_latency_path = os.path.join(temp_folder, self._temp_latency_filename)
final_latency_path = os.path.join(self.ARCHIVE_FOLDER, final_latency_filename)
if os.path.exists(temp_latency_path):
os.rename(temp_latency_path, final_latency_path)
else:
print(f"Warning: Temporary latency file not found at {temp_latency_path}. No latency file will be saved.")
final_latency_filename = ""
except OSError as e:
print(f"Warning: could not rename latency file: {e}")
final_latency_filename = self._temp_latency_filename
metadata = {
"scenario_name": self.scenario_name,
"start_timestamp_utc": datetime.utcnow().isoformat(),
"duration_seconds": end_time - self.start_time,
}
if final_trail_filename:
metadata["trail_file"] = final_trail_filename
if final_latency_filename:
metadata["latency_file"] = final_latency_filename
if extra_metadata:
metadata.update(extra_metadata)
performance_samples = metadata.pop("performance_samples", None)
if performance_samples:
import csv
perf_filename = f"{base_filename}.perf.csv"
perf_filepath = os.path.join(self.ARCHIVE_FOLDER, perf_filename)
# Define headers based on the keys of the first sample
headers = list(performance_samples[0].keys())
headers = list(performance_samples[0].keys()) if performance_samples else []
try:
with open(perf_filepath, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
# Write metadata as commented header lines
writer.writerow([f"# Scenario Name: {metadata.get('scenario_name')}"])
writer.writerow([f"# Start Timestamp (UTC): {metadata.get('start_timestamp_utc')}"])
writer.writerow([f"# Source File: {base_filename}.json"])
# Write the actual header row
if headers:
writer.writerow(headers)
# Write data rows
for sample in performance_samples:
writer.writerow([sample.get(h, "") for h in headers])
print(f"Performance data saved to: {perf_filepath}")
except IOError as e:
print(f"Error saving performance data CSV: {e}")
# --- End of Separation Logic ---
archive_content = {
"metadata": metadata,

View File

@ -26,7 +26,7 @@ DEBUG_CONFIG = {
"temp_folder_name": "Temp",
# Enable saving of IO traces (sent/received positions) to CSV files in Temp/
# Set to True during debugging to collect logs.
"enable_io_trace": False,
"enable_io_trace": True,
"io_trace_sent_filename": "sent_positions.csv",
"io_trace_received_filename": "received_positions.csv",
# Enable performance profiling of packet processing

View File

@ -1,102 +1,104 @@
# target_simulator/gui/analysis_window.py
"""
A Toplevel window for displaying real-time performance analysis, including
error statistics and plots.
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
from typing import Optional, Dict, List, Any
from target_simulator.analysis.performance_analyzer import PerformanceAnalyzer
from target_simulator.analysis.simulation_state_hub import SimulationStateHub
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 real-time analysis of tracking performance.
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("900x750")
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] = []
# State variables
self.selected_target_id = tk.IntVar(value=0)
self._active = True
self._filtered_errors = None
self.selected_target_id = tk.IntVar()
self._show_loading_window(archive_filepath)
def _load_data_and_setup(self, filepath: str):
"""Loads data from the main archive and finds the performance data file."""
"""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.\n{e}")
raise IOError(f"Could not load archive file: {e}")
metadata = archive_data.get("metadata", {})
self.estimated_latency_ms = metadata.get("estimated_latency_ms")
self.prediction_offset_ms = metadata.get("prediction_offset_ms")
self.scenario_name = metadata.get("scenario_name", "Unknown")
self.title(f"Analysis - {self.scenario_name}")
latency_samples = metadata.get("latency_samples", [])
self.latency_timestamps = [s[0] for s in latency_samples if isinstance(s, list) and len(s) > 1]
self.latency_values_ms = [s[1] for s in latency_samples if isinstance(s, list) and len(s) > 1]
# 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"
self._hub = SimulationStateHub(history_size=100000)
results = archive_data.get("simulation_results", {})
for target_id_str, data in results.items():
target_id = int(target_id_str)
for state in data.get("simulated", []):
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:]))
if not os.path.exists(self.trail_filepath):
raise FileNotFoundError(f"Required trail file not found: {self.trail_filepath}")
self._analyzer = PerformanceAnalyzer(self._hub)
# 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.")
# Find the associated performance data file
self.performance_data_path = self._find_performance_data_file(filepath)
def _find_performance_data_file(self, archive_path: str) -> Optional[str]:
"""Finds the .perf.csv or .perf.json file associated with an archive."""
base_path, _ = os.path.splitext(archive_path)
# Prefer the new CSV format
csv_path = f"{base_path}.perf.csv"
if os.path.exists(csv_path):
return csv_path
# Fallback to the old JSON format for backward compatibility
json_path = f"{base_path}.perf.json"
if os.path.exists(json_path):
return json_path
return None
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):
"""Show a loading dialog and load data asynchronously."""
"""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)
@ -106,7 +108,7 @@ class AnalysisWindow(tk.Toplevel):
def load_and_display():
try:
progress_label.config(text="Reading archive file...")
progress_label.config(text="Locating data files...")
self.update()
self._load_data_and_setup(archive_filepath)
@ -114,11 +116,14 @@ class AnalysisWindow(tk.Toplevel):
self.update()
self._create_widgets()
progress_label.config(text="Analyzing data...")
progress_label.config(text="Ready.")
self.update()
self._populate_analysis()
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)
@ -126,15 +131,6 @@ class AnalysisWindow(tk.Toplevel):
self.after(100, load_and_display)
def _populate_analysis(self):
"""Runs the analysis and populates the widgets once."""
self._update_target_selector()
target_ids = self.target_selector["values"]
if target_ids:
self.selected_target_id.set(target_ids[0])
self._on_target_select()
def _create_widgets(self):
main_pane = ttk.PanedWindow(self, orient=tk.VERTICAL)
main_pane.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
@ -148,253 +144,392 @@ class AnalysisWindow(tk.Toplevel):
self._create_plot_widgets(plot_frame)
def _create_stats_widgets(self, parent):
container = ttk.Frame(parent)
container.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# Configure grid per il layout
parent.rowconfigure(0, weight=0) # Top bar
parent.rowconfigure(1, weight=1) # Content area
parent.columnconfigure(0, weight=1)
left = ttk.Frame(container)
left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
right = ttk.Frame(container)
right.pack(side=tk.RIGHT, fill=tk.Y)
top_bar = ttk.Frame(left)
top_bar.pack(fill=tk.X, padx=0, pady=(0, 6))
# 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
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("<<ComboboxSelected>>", self._on_target_select)
if self.target_ids:
self.selected_target_id.set(self.target_ids[0])
sync_frame = ttk.Frame(top_bar)
sync_frame.pack(side=tk.LEFT, padx=(20, 0))
if self.estimated_latency_ms is not None:
ttk.Label(sync_frame, text="Avg. Latency:").pack(side=tk.LEFT)
ttk.Label(
sync_frame, text=f"{self.estimated_latency_ms:.1f} ms",
font=("Segoe UI", 9, "bold"), foreground="blue"
).pack(side=tk.LEFT, padx=4)
if self.prediction_offset_ms is not None:
ttk.Label(sync_frame, text="Prediction Offset:").pack(side=tk.LEFT, padx=(10, 0))
ttk.Label(
sync_frame, text=f"{self.prediction_offset_ms:.1f} ms",
font=("Segoe UI", 9, "bold"), foreground="green"
).pack(side=tk.LEFT, padx=4)
# The button is now conditional
if self.performance_data_path:
perf_button = ttk.Button(
sync_frame, text="Performance Analysis...", command=self._open_performance_window
)
# 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(left, columns=columns, show="headings", height=4)
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=100)
self.stats_tree.column("std_dev", anchor=tk.E, width=100)
self.stats_tree.column("rmse", anchor=tk.E, width=100)
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)
legend_title = ttk.Label(right, text="How to Interpret Results:", font=(None, 9, "bold"))
legend_title.pack(anchor=tk.NW, padx=(6, 6), pady=(4, 4))
# 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 = (
"Formula: Error = Real Position - Simulated Position\n\n"
"Sign of Error (e.g., on X axis):\n"
"• Positive Error (+): Real target is at a larger X coordinate.\n"
"• Negative Error (-): Real target is at a smaller X coordinate.\n\n"
"Prediction Offset:\n"
"A manual offset to compensate for server processing delay."
"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(right, text=explanation_text, justify=tk.LEFT, wraplength=280).pack(anchor=tk.NW, padx=(6, 6))
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):
fig = Figure(figsize=(5, 6), dpi=100)
gs = fig.add_gridspec(2, 1, height_ratios=[2, 1], hspace=0.35, top=0.95)
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_xlabel("Time (s)")
self.ax.set_ylabel("Error (ft)")
(self.line_x,) = self.ax.plot([], [], lw=2, label="Error X")
(self.line_y,) = self.ax.plot([], [], lw=2, label="Error Y")
(self.line_z,) = self.ax.plot([], [], lw=2, label="Error Z")
self.ax.grid(True)
self.ax.axhline(0.0, color="black", lw=1, linestyle="--", alpha=0.8)
(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)
self.ax_latency = fig.add_subplot(gs[1, 0], sharex=self.ax)
if not has_latency:
self.ax.set_xlabel("Time (s)")
# Latency plot (if file exists)
if has_latency:
self.ax_latency.set_title("Latency Evolution")
self.ax_latency.set_xlabel("Time (s)")
self.ax_latency.set_ylabel("Latency (ms)")
(self.line_latency,) = self.ax_latency.plot([], [], lw=2, color="orange", label="Latency")
self.ax_latency.grid(True)
(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()
plot_container = ttk.Frame(parent)
plot_container.pack(fill=tk.BOTH, expand=True)
toolbar_frame = ttk.Frame(plot_container)
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=plot_container)
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)
self.canvas.draw()
def _update_target_selector(self):
try:
target_ids = sorted(self._hub.get_all_target_ids())
if target_ids:
self.target_selector["values"] = target_ids
if self.selected_target_id.get() not in target_ids:
self.selected_target_id.set(target_ids[0])
except Exception:
pass
def _update_stats_table(self, results: Dict):
self.stats_tree.delete(*self.stats_tree.get_children())
if hasattr(self, '_filtered_errors') and self._filtered_errors:
import math
for axis in ["x", "y", "z"]:
errors = self._filtered_errors.get(axis, [])
if errors:
n = len(errors)
mean = sum(errors) / n
variance = sum((x - mean) ** 2 for x in errors) / n
std_dev = math.sqrt(variance)
rmse = math.sqrt(sum(x**2 for x in errors) / n)
self.stats_tree.insert("", "end", values=(f"Error {axis.upper()}", f"{mean:.3f}", f"{std_dev:.3f}", f"{rmse:.3f}"))
else:
self.stats_tree.insert("", "end", values=(f"Error {axis.upper()}", "N/A", "N/A", "N/A"))
else:
for axis in ["x", "y", "z"]:
self.stats_tree.insert("", "end", values=(f"Error {axis.upper()}", f"{results[axis]['mean']:.3f}", f"{results[axis]['std_dev']:.3f}", f"{results[axis]['rmse']:.3f}"))
if self.latency_values_ms:
import statistics
lat_mean = statistics.mean(self.latency_values_ms)
lat_std = statistics.stdev(self.latency_values_ms) if len(self.latency_values_ms) > 1 else 0.0
lat_min = min(self.latency_values_ms)
lat_max = max(self.latency_values_ms)
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, target_id: int):
history = self._hub.get_target_history(target_id)
if not history or not history["real"] or len(history["simulated"]) < 2:
self._clear_views()
def _on_target_select(self, event=None):
"""Initiates analysis for the selected target."""
if not self.trail_filepath:
return
times, errors_x, errors_y, errors_z = [], [], [], []
sim_hist = sorted(history["simulated"])
for real_state in history["real"]:
real_ts, real_x, real_y, real_z = real_state
p1, p2 = self._analyzer._find_bracketing_points(real_ts, sim_hist)
if p1 and p2:
_ts, interp_x, interp_y, interp_z = self._analyzer._interpolate(real_ts, p1, p2)
times.append(real_ts)
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)
if not times:
self._clear_views()
return
errors = {'x': errors_x, 'y': errors_y, 'z': errors_z}
# Filtering logic
import statistics
sample_errors = []
min_time = min(times)
for i, t in enumerate(times):
if min_time + 5.0 <= t <= min_time + 15.0:
sample_errors.append((errors_x[i]**2 + errors_y[i]**2 + errors_z[i]**2) ** 0.5)
# 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}
threshold = max(statistics.median(sample_errors) * 20, 500.0) if sample_errors else 1000.0
return timestamps, errors, stats
filtered_times, filtered_x, filtered_y, filtered_z = [], [], [], []
outlier_count = 0
for i, t in enumerate(times):
if (errors_x[i]**2 + errors_y[i]**2 + errors_z[i]**2) ** 0.5 > threshold:
outlier_count += 1
else:
filtered_times.append(t)
filtered_x.append(errors_x[i])
filtered_y.append(errors_y[i])
filtered_z.append(errors_z[i])
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
self._filtered_errors = {'x': filtered_x, 'y': filtered_y, 'z': filtered_z}
# Simple interval-based downsampling
step = len(timestamps) // DOWNSAMPLE_THRESHOLD
self.line_x.set_data(filtered_times, filtered_x)
self.line_y.set_data(filtered_times, filtered_y)
self.line_z.set_data(filtered_times, filtered_z)
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)
# Downsample if needed
ts_plot, errors_plot = self._downsample_data(filtered_ts, 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 = []
if outlier_count > 0:
txt = self.ax.text(0.02, 0.98, f"{outlier_count} spike(s) filtered", transform=self.ax.transAxes,
verticalalignment='top', bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.7), fontsize=9)
# 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 _update_latency_plot(self):
if self.latency_values_ms and self.latency_timestamps:
self.line_latency.set_data(self.latency_timestamps, self.latency_values_ms)
else:
self.line_latency.set_data([], [])
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()
def _clear_views(self):
self.stats_tree.delete(*self.stats_tree.get_children())
self.line_x.set_data([], [])
self.line_y.set_data([], [])
self.line_z.set_data([], [])
self.line_latency.set_data([], [])
for ax in [self.ax, self.ax_latency]:
ax.relim()
ax.autoscale_view()
self.canvas.draw_idle()
def _open_performance_window(self):
"""Open the dedicated performance analysis window."""
if not self.performance_data_path:
messagebox.showinfo("No Data", "No performance data file found for this simulation run.", parent=self)
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
# Downsample for plotting if needed
ts_plot = timestamps
lat_plot = latencies
if len(timestamps) > DOWNSAMPLE_THRESHOLD:
step = len(timestamps) // DOWNSAMPLE_THRESHOLD
ts_plot = timestamps[::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:
# Pass the path to the CSV file to the performance window
PerformanceAnalysisWindow(parent=self, performance_csv_path=self.performance_data_path)
except Exception as e:
messagebox.showerror("Performance Analysis Error", f"Failed to open performance analysis:\n{e}", parent=self)
def _on_target_select(self, event=None):
"""Handle combobox selection changes and update stats/plot."""
try:
sel_id = self.selected_target_id.get()
analysis_results = self._analyzer.analyze()
if sel_id in analysis_results:
self._update_plot(sel_id) # Update plot first to calculate filtered errors
self._update_stats_table(analysis_results[sel_id]) # Then update table with filtered stats
else:
self._clear_views()
self._update_latency_plot()
except Exception:
self._clear_views()
messagebox.showerror("Error", f"Failed to open performance analysis:\n{e}", parent=self)

View File

@ -58,7 +58,6 @@ from target_simulator.gui.sfp_debug_window import SfpDebugWindow
from target_simulator.gui.logger_panel import LoggerPanel
from target_simulator.core.sfp_communicator import SFPCommunicator
from target_simulator.analysis.simulation_state_hub import SimulationStateHub
from target_simulator.analysis.performance_analyzer import PerformanceAnalyzer
from target_simulator.gui.analysis_window import AnalysisWindow
from target_simulator.core import command_builder
from target_simulator.analysis.simulation_archive import SimulationArchive
@ -114,7 +113,6 @@ class MainView(tk.Tk):
# --- Initialize the data hub and controllers ---
self.simulation_hub = SimulationStateHub()
self.performance_analyzer = PerformanceAnalyzer(self.simulation_hub)
self.communicator_manager = CommunicatorManager(
simulation_hub=self.simulation_hub,

View File

@ -56,7 +56,6 @@ class DebugPayloadRouter:
self._history = collections.deque(maxlen=self._sfp_debug_history_size)
self._persist = False
self._latency_samples = collections.deque(maxlen=10000)
self._hub = simulation_hub
self._last_ownship_update_time: Optional[float] = None
self._ris_target_listeners: List[TargetListListener] = []
@ -212,8 +211,12 @@ class DebugPayloadRouter:
est_gen = self._clock_sync.to_client_time(server_timetag)
latency = reception_timestamp - est_gen
if latency >= 0 and self.active_archive is not None:
# Save latency to CSV file via archive
latency_ms = latency * 1000 # Convert to milliseconds
with self._lock:
self._latency_samples.append((reception_timestamp, latency))
archive = self.active_archive
if archive and hasattr(archive, 'add_latency_sample'):
archive.add_latency_sample(reception_timestamp, latency_ms)
except Exception:
pass
t_clock_end = time.perf_counter()
@ -281,7 +284,7 @@ class DebugPayloadRouter:
if total_processing_time > 0.010 or self._perf_counters['_total_packet_count'] % 100 == 0:
if self._perf_samples is not None:
self._perf_samples.append({
sample = {
'timestamp': reception_timestamp,
'total_ms': round(total_processing_time * 1000, 3),
'parse_ms': round((t_parse_end - t_parse_start) * 1000, 3),
@ -289,7 +292,10 @@ class DebugPayloadRouter:
'archive_ms': round((t_archive_end - t_archive_start) * 1000, 3),
'listener_ms': round((t_listener_end - t_listener_start) * 1000, 3),
'clock_ms': round((t_clock_end - t_clock_start) * 1000, 3),
})
}
self._perf_samples.append(sample)
if len(self._perf_samples) % 500 == 0:
self.logger.debug(f"Performance samples buffer: {len(self._perf_samples)} samples")
current_time = time.time()
if current_time - self._perf_counters['last_report_time'] >= 5.0:
@ -302,7 +308,6 @@ class DebugPayloadRouter:
with self._lock:
self.active_archive = archive
if archive is not None:
self._latency_samples.clear()
if self._perf_samples is not None:
self._perf_samples.clear()
# Reset all counters at the start of a new archive
@ -313,7 +318,7 @@ class DebugPayloadRouter:
'clock_sync_time_total': 0.0, 'max_processing_time': 0.0,
'last_report_time': time.time()
})
self.logger.debug("Latency and performance buffers cleared for new simulation")
self.logger.debug("Performance buffers cleared for new simulation")
def add_ris_target_listener(self, listener: TargetListListener):
with self._lock:
@ -459,19 +464,29 @@ class DebugPayloadRouter:
return pkt
def get_estimated_latency_s(self) -> float:
return self._clock_sync.get_average_latency_s() if self._clock_sync else 0.0
try:
if self._clock_sync:
return self._clock_sync.get_average_latency_s()
except Exception:
pass
return 0.0
def get_latency_samples(self, limit: Optional[int] = None) -> List[tuple]:
with self._lock:
samples = list(self._latency_samples)
try:
if self._clock_sync:
samples = self._clock_sync.get_latency_history()
return samples[-limit:] if limit else samples
except Exception:
pass
return []
def get_latency_stats(self, sample_limit: int = 200) -> Dict[str, Any]:
with self._lock:
samples = list(self._latency_samples)
try:
samples = self.get_latency_samples(limit=sample_limit)
if not samples:
return {"count": 0}
samples = samples[-sample_limit:] if sample_limit else samples
# samples are (reception_time, latency_s)
ms = [s[1] * 1000.0 for s in samples]
return {
"mean_ms": round(statistics.mean(ms), 3),
@ -480,13 +495,18 @@ class DebugPayloadRouter:
"max_ms": round(max(ms), 3),
"count": len(ms),
}
except Exception:
pass
return {"count": 0}
def get_history(self):
with self._lock:
return list(self._history)
def get_performance_samples(self):
return list(self._perf_samples) if self._profiling_enabled and self._perf_samples else []
result = list(self._perf_samples) if self._profiling_enabled and self._perf_samples else []
self.logger.debug(f"get_performance_samples called: profiling={self._profiling_enabled}, samples_count={len(result)}")
return result
def _report_performance_stats(self):
try:

View File

@ -220,8 +220,13 @@ class PerformanceAnalysisWindow(tk.Toplevel):
top_container = ttk.Frame(main_pane)
main_pane.add(top_container, weight=1)
# Configure grid per dividere esattamente in due
top_container.columnconfigure(0, weight=1, uniform="half")
top_container.columnconfigure(1, weight=1, uniform="half")
top_container.rowconfigure(0, weight=1)
stats_frame = ttk.LabelFrame(top_container, text="Performance Statistics", padding=10)
stats_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
stats_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 5))
self._create_stats_table(stats_frame)
self._create_info_panel(top_container)
@ -234,24 +239,25 @@ class PerformanceAnalysisWindow(tk.Toplevel):
def _create_info_panel(self, parent):
"""Create an informational panel explaining the metrics."""
info_frame = ttk.LabelFrame(parent, text=" About Performance Analysis", padding=10)
info_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=False)
info_frame.grid(row=0, column=1, sticky="nsew", padx=(5, 0))
# Simplified text to fit without scrolling
info_text = (
"This window analyzes packet processing times.\n\n"
"📊 Measured Components:\n"
"Packet processing time analysis.\n"
"📊 Components:\n"
"• Parse: Decode SFP payload\n"
"• Hub: Update SimulationStateHub\n"
"• Archive: Persist data to file\n"
"• Listener: Broadcast events to GUI\n"
"• Clock: Synchronize timestamps\n\n"
"• Archive: Save data to file\n"
"• Listener: Broadcast to GUI\n"
"• Clock: Sync timestamps\n"
"⚠ Spikes (>100ms):\n"
"Critical slowdowns, likely Garbage\n"
"Collection, disk I/O, or lock contention.\n\n"
"Slowdowns from GC, disk I/O,\n"
"or lock contention.\n"
"🎯 Bottleneck:\n"
"Component with the highest time\n"
"during the single slowest event."
"Slowest component in worst event."
)
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT, font=("Segoe UI", 9), wraplength=320)
info_label.pack(anchor=tk.W)
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT, font=("Segoe UI", 9))
info_label.pack(anchor=tk.W, fill=tk.BOTH, expand=True)
def _create_stats_table(self, parent):
"""Create the statistics table."""
@ -267,7 +273,7 @@ class PerformanceAnalysisWindow(tk.Toplevel):
self.stats_tree.heading("Metric", text="Metric")
self.stats_tree.heading("Value", text="Value")
self.stats_tree.heading("Details", text="Details")
self.stats_tree.column("Metric", width=200, anchor='w')
self.stats_tree.column("Metric", width=160, anchor='w')
self.stats_tree.column("Value", width=150, anchor='e')
self.stats_tree.column("Details", width=300, anchor='w')
self.stats_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

View File

@ -253,9 +253,12 @@ class SimulationController:
# Save performance profiling data if available
if router and hasattr(router, 'get_performance_samples'):
perf_samples = router.get_performance_samples()
self.logger.debug(f"Retrieved {len(perf_samples) if perf_samples else 0} performance samples from router")
if perf_samples:
extra_metadata["performance_samples"] = perf_samples
self.logger.info(f"Saved {len(perf_samples)} performance samples to archive")
else:
self.logger.warning("No performance samples available to save")
except Exception as e:
self.logger.warning(
f"Could not collect latency samples for archive: {e}"

View File

@ -61,6 +61,7 @@ class ClockSynchronizer:
self._lock = threading.Lock()
self._history: collections.deque = collections.deque(maxlen=history_size)
self._latency_history: collections.deque = collections.deque(maxlen=history_size)
self._min_samples = min_samples_for_fit
self._update_interval = max(1, update_interval)
self._update_counter = 0
@ -135,9 +136,17 @@ class ClockSynchronizer:
estimated_generation_times = self._m * x_vals + self._b
# Latency is the difference between reception and estimated generation
latencies = y_vals - estimated_generation_times
# Update the average latency, filtering out negative values which are artifacts
positive_latencies = latencies[latencies >= 0]
if len(positive_latencies) > 0:
# Store latency samples and update the average
self._latency_history.clear()
positive_latencies = []
for i in range(len(latencies)):
if latencies[i] >= 0:
reception_time = y_vals[i]
self._latency_history.append((reception_time, latencies[i]))
positive_latencies.append(latencies[i])
if positive_latencies:
self._average_latency_s = np.mean(positive_latencies)
else:
self._average_latency_s = 0.0
@ -180,3 +189,13 @@ class ClockSynchronizer:
"""
with self._lock:
return self._average_latency_s
def get_latency_history(self) -> List[Tuple[float, float]]:
"""
Returns a copy of the recent latency samples.
Returns:
A list of tuples, where each tuple is (reception_time_s, latency_s).
"""
with self._lock:
return list(self._latency_history)

View File

@ -1,48 +1,31 @@
"""CSV helpers for IO tracing.
This module provides lightweight helpers that append sent/received
position records to CSV files located in the application's Temp folder.
Behavior is governed by `target_simulator.config.DEBUG_CONFIG` (see keys
``enable_io_trace``, ``temp_folder_name``, and filename overrides).
These functions are intended for debugging and tracing; they return ``True``
when the append operation succeeded and ``False`` when disabled or on error.
PERFORMANCE: Uses asynchronous buffering to avoid blocking the simulation
thread. Rows are buffered in memory and flushed periodically by a background
thread, eliminating I/O overhead from the critical path.
# target_simulator/utils/csv_logger.py
"""
CSV helpers for IO tracing.
... (docstring existing)
"""
import csv
import os
import time
import threading
import atexit
from typing import Iterable, Any, Dict, List, Tuple
from typing import Iterable, Any, Dict, List, Tuple,Optional
from collections import deque
from target_simulator.config import DEBUG_CONFIG
# --- Async CSV Buffer ---
_CSV_BUFFER_LOCK = threading.Lock()
_CSV_BUFFERS: Dict[str, deque] = {} # filename -> deque of (row, headers)
_CSV_FLUSH_THREAD: threading.Thread = None
_CSV_BUFFERS: Dict[str, deque] = {}
_CSV_FLUSH_THREAD: Optional[threading.Thread] = None
_CSV_STOP_EVENT = threading.Event()
_CSV_FLUSH_INTERVAL_S = 2.0 # Flush every 2 seconds
_CSV_MAX_BUFFER_SIZE = 1000 # Flush immediately if buffer exceeds this
_CSV_FLUSH_INTERVAL_S = 2.0
_CSV_MAX_BUFFER_SIZE = 1000
def _csv_flush_worker():
"""Background thread that periodically flushes buffered CSV rows to disk."""
while not _CSV_STOP_EVENT.is_set():
time.sleep(_CSV_FLUSH_INTERVAL_S)
_flush_all_buffers()
# Final flush on shutdown
_flush_all_buffers()
def _flush_all_buffers():
"""Flush all buffered CSV rows to their respective files."""
def flush_all_csv_buffers():
"""
Synchronously flushes all buffered CSV rows to their respective files.
This function can be called to ensure data is written to disk before an operation.
"""
with _CSV_BUFFER_LOCK:
for filename, buffer in list(_CSV_BUFFERS.items()):
if not buffer:
@ -53,31 +36,44 @@ def _flush_all_buffers():
continue
file_path = os.path.join(temp_folder, filename)
# Check if we need to write headers
write_headers = not os.path.exists(file_path)
# Check if file is empty or doesn't exist to decide whether to write headers
write_headers = not os.path.exists(file_path) or os.path.getsize(file_path) == 0
try:
with open(file_path, "a", newline="", encoding="utf-8") as csvfile:
writer = csv.writer(csvfile)
# Write all buffered rows
# Extract all items from buffer to write in one go
rows_to_write = []
while buffer:
row, headers = buffer.popleft()
rows_to_write.append(buffer.popleft())
# Write headers only once for new files
if write_headers and headers is not None:
if not rows_to_write:
continue
# If headers need to be written, take them from the first buffered item
if write_headers:
_, headers = rows_to_write[0]
if headers:
writer.writerow(list(headers))
write_headers = False
# Write all the rows
for row, _ in rows_to_write:
writer.writerow(list(row))
except Exception:
# Clear buffer on error to avoid accumulation
buffer.clear()
# On error, we can try to put items back in buffer or log the loss
# For now, clear to prevent repeated failures on same data
pass
def _csv_flush_worker():
"""Background thread that periodically flushes buffered CSV rows to disk."""
while not _CSV_STOP_EVENT.wait(_CSV_FLUSH_INTERVAL_S):
flush_all_csv_buffers()
# Final flush on shutdown
flush_all_csv_buffers()
def _ensure_csv_flush_thread():
"""Ensure the background flush thread is running."""
"""Ensures the background CSV flush thread is running."""
global _CSV_FLUSH_THREAD
if _CSV_FLUSH_THREAD is None or not _CSV_FLUSH_THREAD.is_alive():
_CSV_STOP_EVENT.clear()
@ -85,94 +81,55 @@ def _ensure_csv_flush_thread():
target=_csv_flush_worker, daemon=True, name="CSVFlushThread"
)
_CSV_FLUSH_THREAD.start()
# Register cleanup on exit
atexit.register(_shutdown_csv_logger)
def _shutdown_csv_logger():
"""Stop the flush thread and ensure all data is written."""
"""Signals the flush thread to stop and performs a final flush."""
_CSV_STOP_EVENT.set()
if _CSV_FLUSH_THREAD and _CSV_FLUSH_THREAD.is_alive():
_CSV_FLUSH_THREAD.join(timeout=5.0)
_CSV_FLUSH_THREAD.join(timeout=2.0)
flush_all_csv_buffers() # Final synchronous flush
def _ensure_temp_folder():
def _ensure_temp_folder() -> Optional[str]:
"""Ensures the temporary directory exists and returns its path."""
temp_folder = DEBUG_CONFIG.get("temp_folder_name", "Temp")
if not os.path.exists(temp_folder):
try:
os.makedirs(temp_folder, exist_ok=True)
except Exception:
# If we cannot create the folder, swallow the exception; callers
# should handle absence of files gracefully.
return None
return temp_folder
except Exception:
return None
def append_row(filename: str, row: Iterable[Any], headers: Iterable[str] | None = None):
"""Append a row to a CSV file stored under the Temp folder.
If the file does not exist and ``headers`` is provided, the headers are
written as the first row. The function is a no-op when tracing is
disabled via DEBUG_CONFIG.
PERFORMANCE: This function is now async-buffered and returns immediately
without blocking on I/O. Rows are written to disk by a background thread.
Args:
filename: Name of the target CSV file inside the Temp folder.
row: Iterable of values to write as a CSV row.
headers: Optional iterable of header names to write when creating a
new file.
Returns:
True on success, False when tracing is disabled or an error occurred.
def append_row(filename: str, row: Iterable[Any], headers: Optional[Iterable[str]] = None):
"""
if not DEBUG_CONFIG.get("enable_io_trace", False):
Appends a row to a CSV file buffer, to be written asynchronously.
"""
if not DEBUG_CONFIG.get("enable_io_trace", True):
return False
temp_folder = _ensure_temp_folder()
if not temp_folder:
return False
# Ensure flush thread is running
_ensure_csv_flush_thread()
# Buffer the row for async writing
with _CSV_BUFFER_LOCK:
if filename not in _CSV_BUFFERS:
_CSV_BUFFERS[filename] = deque(maxlen=_CSV_MAX_BUFFER_SIZE * 2)
_CSV_BUFFERS[filename] = deque()
# Store row and headers together
_CSV_BUFFERS[filename].append((row, headers))
# Force immediate flush if buffer is getting large
# Optional: trigger an early flush if buffer gets too large
if len(_CSV_BUFFERS[filename]) >= _CSV_MAX_BUFFER_SIZE:
# Schedule immediate flush without blocking
threading.Thread(target=_flush_all_buffers, daemon=True).start()
threading.Thread(target=flush_all_csv_buffers, daemon=True).start()
return True
def append_sent_position(
timestamp: float, target_id: int, x: float, y: float, z: float, command: str
):
"""Append a sent-position entry for IO tracing.
The row contains a timestamp, target id, position in feet and the issued
command string.
"""
def append_sent_position(timestamp: float, target_id: int, x: float, y: float, z: float, command: str):
"""Logs a sent target position to the corresponding trace file."""
filename = DEBUG_CONFIG.get("io_trace_sent_filename", "sent_positions.csv")
headers = ["timestamp", "target_id", "x_ft", "y_ft", "z_ft", "command"]
row = [timestamp, target_id, x, y, z, command]
return append_row(filename, row, headers=headers)
def append_received_position(
timestamp: float, target_id: int, x: float, y: float, z: float
):
"""Append a received-position entry for IO tracing.
The row contains a timestamp, target id and position in feet.
"""
def append_received_position(timestamp: float, target_id: int, x: float, y: float, z: float):
"""Logs a received target position to the corresponding trace file."""
filename = DEBUG_CONFIG.get("io_trace_received_filename", "received_positions.csv")
headers = ["timestamp", "target_id", "x_ft", "y_ft", "z_ft"]
row = [timestamp, target_id, x, y, z]