fine ottimizzazione

This commit is contained in:
VALLONGOL 2025-11-13 10:57:10 +01:00
parent 9823a294b2
commit 14c0501451
29 changed files with 852 additions and 304 deletions

View File

@ -4,6 +4,7 @@ import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext from tkinter import filedialog, messagebox, scrolledtext
from pathlib import Path from pathlib import Path
class MarkdownToPDFApp: class MarkdownToPDFApp:
def __init__(self, root): def __init__(self, root):
self.root = root self.root = root
@ -18,25 +19,53 @@ class MarkdownToPDFApp:
self.generate_pdf = tk.BooleanVar(value=True) self.generate_pdf = tk.BooleanVar(value=True)
# --- UI --- # --- UI ---
tk.Label(root, text="Cartella Markdown:").pack(anchor="w", padx=10, pady=(10, 0)) tk.Label(root, text="Cartella Markdown:").pack(
anchor="w", padx=10, pady=(10, 0)
)
frame1 = tk.Frame(root) frame1 = tk.Frame(root)
frame1.pack(fill="x", padx=10) frame1.pack(fill="x", padx=10)
tk.Entry(frame1, textvariable=self.folder_path, width=50).pack(side="left", fill="x", expand=True) tk.Entry(frame1, textvariable=self.folder_path, width=50).pack(
tk.Button(frame1, text="Sfoglia...", command=self.choose_folder).pack(side="right", padx=5) side="left", fill="x", expand=True
)
tk.Button(frame1, text="Sfoglia...", command=self.choose_folder).pack(
side="right", padx=5
)
tk.Label(root, text="Nome base file output (senza estensione):").pack(anchor="w", padx=10, pady=(10, 0)) tk.Label(root, text="Nome base file output (senza estensione):").pack(
anchor="w", padx=10, pady=(10, 0)
)
tk.Entry(root, textvariable=self.output_name, width=40).pack(fill="x", padx=10) tk.Entry(root, textvariable=self.output_name, width=40).pack(fill="x", padx=10)
tk.Checkbutton(root, text="Usa template DOCX", variable=self.use_template, command=self.toggle_template).pack(anchor="w", padx=10, pady=(10, 0)) tk.Checkbutton(
root,
text="Usa template DOCX",
variable=self.use_template,
command=self.toggle_template,
).pack(anchor="w", padx=10, pady=(10, 0))
frame2 = tk.Frame(root) frame2 = tk.Frame(root)
frame2.pack(fill="x", padx=10) frame2.pack(fill="x", padx=10)
tk.Entry(frame2, textvariable=self.template_path, width=50, state="disabled").pack(side="left", fill="x", expand=True) tk.Entry(
tk.Button(frame2, text="Seleziona template", command=self.choose_template, state="disabled").pack(side="right", padx=5) frame2, textvariable=self.template_path, width=50, state="disabled"
).pack(side="left", fill="x", expand=True)
tk.Button(
frame2,
text="Seleziona template",
command=self.choose_template,
state="disabled",
).pack(side="right", padx=5)
self.template_frame = frame2 self.template_frame = frame2
tk.Checkbutton(root, text="Genera anche PDF finale", variable=self.generate_pdf).pack(anchor="w", padx=10, pady=(10, 0)) tk.Checkbutton(
root, text="Genera anche PDF finale", variable=self.generate_pdf
).pack(anchor="w", padx=10, pady=(10, 0))
tk.Button(root, text="Genera Documento", command=self.generate_output, bg="#3c9", fg="white").pack(pady=10) tk.Button(
root,
text="Genera Documento",
command=self.generate_output,
bg="#3c9",
fg="white",
).pack(pady=10)
tk.Label(root, text="Log:").pack(anchor="w", padx=10) tk.Label(root, text="Log:").pack(anchor="w", padx=10)
self.log_box = scrolledtext.ScrolledText(root, height=13, state="disabled") self.log_box = scrolledtext.ScrolledText(root, height=13, state="disabled")
@ -61,7 +90,9 @@ class MarkdownToPDFApp:
widget.configure(state=state) widget.configure(state=state)
def choose_template(self): def choose_template(self):
file = filedialog.askopenfilename(title="Seleziona template DOCX", filetypes=[("Word Template", "*.docx")]) file = filedialog.askopenfilename(
title="Seleziona template DOCX", filetypes=[("Word Template", "*.docx")]
)
if file: if file:
self.template_path.set(file) self.template_path.set(file)
@ -73,7 +104,9 @@ class MarkdownToPDFApp:
make_pdf = self.generate_pdf.get() make_pdf = self.generate_pdf.get()
if not folder: if not folder:
messagebox.showwarning("Attenzione", "Seleziona una cartella contenente i file Markdown.") messagebox.showwarning(
"Attenzione", "Seleziona una cartella contenente i file Markdown."
)
return return
folder_path = Path(folder) folder_path = Path(folder)
@ -83,7 +116,9 @@ class MarkdownToPDFApp:
# Trova i file Markdown numerati # Trova i file Markdown numerati
md_files = sorted(folder_path.glob("[0-9][0-9]_*.md")) md_files = sorted(folder_path.glob("[0-9][0-9]_*.md"))
if not md_files: if not md_files:
messagebox.showerror("Errore", "Nessun file Markdown numerato trovato nella cartella.") messagebox.showerror(
"Errore", "Nessun file Markdown numerato trovato nella cartella."
)
return return
self.log(f"Trovati {len(md_files)} file Markdown:") self.log(f"Trovati {len(md_files)} file Markdown:")
@ -108,7 +143,9 @@ class MarkdownToPDFApp:
cmd_docx = ["pandoc", str(combined_md), "-o", str(output_docx)] cmd_docx = ["pandoc", str(combined_md), "-o", str(output_docx)]
if use_template: if use_template:
if not Path(template).exists(): if not Path(template).exists():
messagebox.showerror("Template non trovato", f"Il file {template} non esiste.") messagebox.showerror(
"Template non trovato", f"Il file {template} non esiste."
)
return return
cmd_docx.extend(["--reference-doc", str(template)]) cmd_docx.extend(["--reference-doc", str(template)])
@ -141,6 +178,7 @@ class MarkdownToPDFApp:
if combined_md.exists(): if combined_md.exists():
combined_md.unlink() combined_md.unlink()
if __name__ == "__main__": if __name__ == "__main__":
root = tk.Tk() root = tk.Tk()
app = MarkdownToPDFApp(root) app = MarkdownToPDFApp(root)

View File

@ -3,7 +3,7 @@
"scan_limit": 60, "scan_limit": 60,
"max_range": 100, "max_range": 100,
"geometry": "1492x992+113+61", "geometry": "1492x992+113+61",
"last_selected_scenario": "corto", "last_selected_scenario": "scenario3",
"connection": { "connection": {
"target": { "target": {
"type": "sfp", "type": "sfp",

View File

@ -5,4 +5,3 @@ This package contains the main application modules (GUI, core, utils,
and analysis). It is intentionally lightweight here; see submodules for and analysis). It is intentionally lightweight here; see submodules for
details (e.g., `gui.main_view`, `core.simulation_engine`). details (e.g., `gui.main_view`, `core.simulation_engine`).
""" """

View File

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

View File

@ -29,14 +29,14 @@ class SimulationStateHub:
""" """
A thread-safe hub to store and manage the history of simulated and real A thread-safe hub to store and manage the history of simulated and real
target states for performance analysis. target states for performance analysis.
Thread Safety - Optimized Locking Strategy: Thread Safety - Optimized Locking Strategy:
- Uses fine-grained locking to minimize contention - Uses fine-grained locking to minimize contention
- Critical write paths (add_simulated_state, add_real_state) use minimal lock time - Critical write paths (add_simulated_state, add_real_state) use minimal lock time
- Bulk operations are atomic but quick - Bulk operations are atomic but quick
- Designed to handle high-frequency updates from simulation/network threads - Designed to handle high-frequency updates from simulation/network threads
while GUI reads concurrently without blocking while GUI reads concurrently without blocking
Performance Notes: Performance Notes:
- With 32 targets at 20Hz simulation + network updates: lock contention <5% - With 32 targets at 20Hz simulation + network updates: lock contention <5%
- Lock is held for <0.1ms per operation (append to deque) - Lock is held for <0.1ms per operation (append to deque)
@ -86,7 +86,6 @@ class SimulationStateHub:
self._antenna_azimuth_deg = None self._antenna_azimuth_deg = None
self._antenna_azimuth_ts = None self._antenna_azimuth_ts = None
def add_simulated_state( def add_simulated_state(
self, target_id: int, timestamp: float, state: Tuple[float, ...] self, target_id: int, timestamp: float, state: Tuple[float, ...]
): ):
@ -179,9 +178,7 @@ class SimulationStateHub:
and (now - self._last_real_summary_time) and (now - self._last_real_summary_time)
>= self._real_summary_interval_s >= self._real_summary_interval_s
): ):
rate = self.get_real_rate( rate = self.get_real_rate(window_seconds=self._real_summary_interval_s)
window_seconds=self._real_summary_interval_s
)
# try: # try:
# logger.info( # logger.info(
# "[SimulationStateHub] real states: recent_rate=%.1f ev/s total_targets=%d", # "[SimulationStateHub] real states: recent_rate=%.1f ev/s total_targets=%d",
@ -515,4 +512,4 @@ class SimulationStateHub:
A dictionary containing the ownship state at T=0 for the current simulation. A dictionary containing the ownship state at T=0 for the current simulation.
""" """
with self._lock: with self._lock:
return self._simulation_origin_state.copy() return self._simulation_origin_state.copy()

View File

@ -9,15 +9,14 @@ lightweight package marker with a descriptive module docstring.
""" """
__all__ = [ __all__ = [
"command_builder", "command_builder",
"communicator_interface", "communicator_interface",
"models", "models",
"payload_router", "payload_router",
"sfp_communicator", "sfp_communicator",
"sfp_structures", "sfp_structures",
"sfp_transport", "sfp_transport",
"serial_communicator", "serial_communicator",
"simulation_engine", "simulation_engine",
"tftp_communicator", "tftp_communicator",
] ]

View File

@ -494,9 +494,13 @@ class Scenario:
wp_data.setdefault("vertical_acceleration_g", 0.0) wp_data.setdefault("vertical_acceleration_g", 0.0)
wp_data["maneuver_type"] = ManeuverType(wp_data["maneuver_type"]) wp_data["maneuver_type"] = ManeuverType(wp_data["maneuver_type"])
if "turn_direction" in wp_data and wp_data["turn_direction"]: if "turn_direction" in wp_data and wp_data["turn_direction"]:
wp_data["turn_direction"] = TurnDirection(wp_data["turn_direction"]) wp_data["turn_direction"] = TurnDirection(
wp_data["turn_direction"]
)
valid_keys = {f.name for f in fields(Waypoint)} valid_keys = {f.name for f in fields(Waypoint)}
filtered_wp_data = {k: v for k, v in wp_data.items() if k in valid_keys} filtered_wp_data = {
k: v for k, v in wp_data.items() if k in valid_keys
}
waypoints.append(Waypoint(**filtered_wp_data)) waypoints.append(Waypoint(**filtered_wp_data))
target = Target( target = Target(
target_id=target_data["target_id"], target_id=target_data["target_id"],

View File

@ -4,6 +4,7 @@
Handles all serial communication with the target device. Handles all serial communication with the target device.
""" """
import time import time
try: try:
import serial import serial
import serial.tools.list_ports import serial.tools.list_ports

View File

@ -340,15 +340,10 @@ class SFPCommunicator(CommunicatorInterface):
Ingressi: command (str) Ingressi: command (str)
Uscite: bool - True if transport.send_script_command returned success Uscite: bool - True if transport.send_script_command returned success
Commento: compacts JSON payloads when appropriate before sending. Commento: Assumes command is already compacted if needed (done in send_commands).
""" """
if not self.transport or not self._destination: if not self.transport or not self._destination:
return False return False
# As a final safeguard, compact JSON payloads here as well
try:
command = self._compact_json_if_needed(command)
except Exception:
pass
return self.transport.send_script_command(command, self._destination) return self.transport.send_script_command(command, self._destination)
def _compact_json_if_needed(self, command: str) -> str: def _compact_json_if_needed(self, command: str) -> str:

View File

@ -14,6 +14,7 @@ import logging
import threading import threading
import time import time
import ctypes import ctypes
import itertools
from typing import Dict, Callable, Optional, List from typing import Dict, Callable, Optional, List
from target_simulator.utils.network import create_udp_socket, close_udp_socket from target_simulator.utils.network import create_udp_socket, close_udp_socket
@ -52,8 +53,10 @@ class SfpTransport:
self._socket: Optional[socket.socket] = None self._socket: Optional[socket.socket] = None
self._receiver_thread: Optional[threading.Thread] = None self._receiver_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._tid_counter = 0
self._send_lock = threading.Lock() # Lock-free atomic TID counter using itertools.count (thread-safe)
# next() on itertools.count is atomic in CPython due to GIL
self._tid_counter = itertools.count(start=0, step=1)
self._fragments: Dict[tuple, Dict[int, int]] = {} self._fragments: Dict[tuple, Dict[int, int]] = {}
self._buffers: Dict[tuple, bytearray] = {} self._buffers: Dict[tuple, bytearray] = {}
@ -209,9 +212,8 @@ class SfpTransport:
payload_bytes = payload_bytes[:actual_payload_size] payload_bytes = payload_bytes[:actual_payload_size]
header = SFPHeader() header = SFPHeader()
with self._send_lock: # Lock-free atomic TID increment (GIL guarantees atomicity of next())
self._tid_counter = (self._tid_counter + 1) % 256 header.SFP_TID = next(self._tid_counter) % 256
header.SFP_TID = self._tid_counter
header.SFP_DIRECTION = ord(">") header.SFP_DIRECTION = ord(">")
header.SFP_FLOW = flow_id header.SFP_FLOW = flow_id
@ -224,15 +226,20 @@ class SfpTransport:
full_packet = bytes(header) + payload_bytes full_packet = bytes(header) + payload_bytes
self._socket.sendto(full_packet, destination) self._socket.sendto(full_packet, destination)
try:
sent_preview = ( # Only format debug string if DEBUG logging is enabled
cs if isinstance(cs, str) else cs.decode("utf-8", errors="replace") if logger.isEnabledFor(logging.DEBUG):
try:
sent_preview = (
cs
if isinstance(cs, str)
else cs.decode("utf-8", errors="replace")
)
except Exception:
sent_preview = repr(cs)
logger.debug(
f"{log_prefix} Sent command to {destination} (TID: {header.SFP_TID}): {sent_preview!r}"
) )
except Exception:
sent_preview = repr(cs)
logger.debug(
f"{log_prefix} Sent command to {destination} (TID: {header.SFP_TID}): {sent_preview!r}"
)
return True return True
except Exception as e: except Exception as e:

View File

@ -6,7 +6,7 @@ broadcast target states, supporting different operational modes.
""" """
import threading import threading
import time import time
import copy import math
from queue import Queue from queue import Queue
from typing import Optional from typing import Optional
@ -23,6 +23,70 @@ TICK_RATE_HZ = 20.0
TICK_INTERVAL_S = 1.0 / TICK_RATE_HZ TICK_INTERVAL_S = 1.0 / TICK_RATE_HZ
class PredictedTarget:
"""Lightweight wrapper for predicted target state.
Avoids expensive deepcopy by computing only the predicted position/velocity
needed for command generation. This is 10-15x faster than deepcopy for
prediction horizons.
"""
__slots__ = (
"target_id",
"active",
"traceable",
"restart",
"_pos_x_ft",
"_pos_y_ft",
"_pos_z_ft",
"current_velocity_fps",
"current_vertical_velocity_fps",
"current_heading_deg",
"current_pitch_deg",
"current_range_nm",
"current_azimuth_deg",
"current_altitude_ft",
)
def __init__(self, target: Target, horizon_s: float):
"""Create a predicted target state by advancing the target by horizon_s.
Args:
target: The original target to predict from
horizon_s: Prediction horizon in seconds
"""
# Copy identity and flags
self.target_id = target.target_id
self.active = target.active
self.traceable = target.traceable
self.restart = target.restart
# Copy current velocities
self.current_velocity_fps = target.current_velocity_fps
self.current_vertical_velocity_fps = target.current_vertical_velocity_fps
self.current_heading_deg = target.current_heading_deg
self.current_pitch_deg = target.current_pitch_deg
# Predict position using simple kinematic model
# x = x0 + vx * t, y = y0 + vy * t, z = z0 + vz * t
heading_rad = math.radians(target.current_heading_deg)
vx = target.current_velocity_fps * math.sin(heading_rad)
vy = target.current_velocity_fps * math.cos(heading_rad)
vz = target.current_vertical_velocity_fps
self._pos_x_ft = target._pos_x_ft + vx * horizon_s
self._pos_y_ft = target._pos_y_ft + vy * horizon_s
self._pos_z_ft = target._pos_z_ft + vz * horizon_s
# Recompute polar coordinates from predicted position
dist_2d = math.sqrt(self._pos_x_ft**2 + self._pos_y_ft**2)
self.current_range_nm = dist_2d / 6076.12 # Convert feet to nautical miles
self.current_azimuth_deg = (
math.degrees(math.atan2(self._pos_x_ft, self._pos_y_ft)) % 360
)
self.current_altitude_ft = self._pos_z_ft
class SimulationEngine(threading.Thread): class SimulationEngine(threading.Thread):
def __init__( def __init__(
self, self,
@ -256,13 +320,11 @@ class SimulationEngine(threading.Thread):
# Create a list of targets to be sent, potentially predicted # Create a list of targets to be sent, potentially predicted
targets_to_send = [] targets_to_send = []
if self.prediction_horizon_s > 0.0 and active_targets: if self.prediction_horizon_s > 0.0 and active_targets:
# Apply prediction # Apply lightweight prediction (avoids expensive deepcopy)
for target in active_targets: targets_to_send = [
# Create a deep copy to avoid altering the main simulation state PredictedTarget(target, self.prediction_horizon_s)
predicted_target = copy.deepcopy(target) for target in active_targets
# Advance its state by the prediction horizon ]
predicted_target.update_state(self.prediction_horizon_s)
targets_to_send.append(predicted_target)
else: else:
# No prediction, use current state # No prediction, use current state
targets_to_send = active_targets targets_to_send = active_targets

View File

@ -3,6 +3,7 @@
This module defines the `AddTargetWindow` class, which provides a dialog This module defines the `AddTargetWindow` class, which provides a dialog
for users to input the initial parameters of a new target. for users to input the initial parameters of a new target.
""" """
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox from tkinter import ttk, messagebox
from target_simulator.core.models import Target, MIN_TARGET_ID, MAX_TARGET_ID, Waypoint from target_simulator.core.models import Target, MIN_TARGET_ID, MAX_TARGET_ID, Waypoint
@ -162,4 +163,3 @@ class AddTargetWindow(tk.Toplevel):
self.destroy() self.destroy()
except ValueError as e: except ValueError as e:
messagebox.showerror("Validation Error", str(e), parent=self) messagebox.showerror("Validation Error", str(e), parent=self)

View File

@ -63,7 +63,7 @@ class AnalysisWindow(tk.Toplevel):
metadata = archive_data.get("metadata", {}) metadata = archive_data.get("metadata", {})
self.estimated_latency_ms = metadata.get("estimated_latency_ms") self.estimated_latency_ms = metadata.get("estimated_latency_ms")
self.prediction_offset_ms = metadata.get("prediction_offset_ms") self.prediction_offset_ms = metadata.get("prediction_offset_ms")
# Load latency samples (new format only: [[timestamp, latency_ms], ...]) # Load latency samples (new format only: [[timestamp, latency_ms], ...])
latency_samples = metadata.get("latency_samples", []) latency_samples = metadata.get("latency_samples", [])
if latency_samples and isinstance(latency_samples[0], list): if latency_samples and isinstance(latency_samples[0], list):
@ -107,7 +107,7 @@ class AnalysisWindow(tk.Toplevel):
# produced for the selected target (common cause: no # produced for the selected target (common cause: no
# overlapping timestamps between simulated and real samples). # overlapping timestamps between simulated and real samples).
self._show_insufficient_data_info(sel_id) self._show_insufficient_data_info(sel_id)
# Update the latency plot regardless of target selection # Update the latency plot regardless of target selection
self._update_latency_plot() self._update_latency_plot()
@ -229,10 +229,10 @@ class AnalysisWindow(tk.Toplevel):
parent (tk.Widget): Parent container where the plot canvas will be packed. parent (tk.Widget): Parent container where the plot canvas will be packed.
""" """
fig = Figure(figsize=(5, 6), dpi=100) fig = Figure(figsize=(5, 6), dpi=100)
# Use GridSpec for aligned subplots with shared x-axis alignment # Use GridSpec for aligned subplots with shared x-axis alignment
gs = fig.add_gridspec(2, 1, height_ratios=[2, 1], hspace=0.3) gs = fig.add_gridspec(2, 1, height_ratios=[2, 1], hspace=0.3)
# Top subplot: Instantaneous Error # Top subplot: Instantaneous Error
self.ax = fig.add_subplot(gs[0, 0]) self.ax = fig.add_subplot(gs[0, 0])
self.ax.set_title("Instantaneous Error") self.ax.set_title("Instantaneous Error")
@ -251,21 +251,25 @@ class AnalysisWindow(tk.Toplevel):
self.ax.legend(loc="upper right", fontsize=9) self.ax.legend(loc="upper right", fontsize=9)
except Exception: except Exception:
pass pass
# Bottom subplot: Latency over time # Bottom subplot: Latency over time
self.ax_latency = fig.add_subplot(gs[1, 0], sharex=None) self.ax_latency = fig.add_subplot(gs[1, 0], sharex=None)
self.ax_latency.set_title("Latency Evolution") self.ax_latency.set_title("Latency Evolution")
self.ax_latency.set_xlabel("Time (s)") # Will be updated if no timestamps available self.ax_latency.set_xlabel(
"Time (s)"
) # Will be updated if no timestamps available
self.ax_latency.set_ylabel("Latency (ms)") self.ax_latency.set_ylabel("Latency (ms)")
(self.line_latency,) = self.ax_latency.plot([], [], lw=2, color='orange', label="Latency") (self.line_latency,) = self.ax_latency.plot(
[], [], lw=2, color="orange", label="Latency"
)
try: try:
self.ax_latency.grid(True) self.ax_latency.grid(True)
self.ax_latency.legend(loc="upper right", fontsize=9) self.ax_latency.legend(loc="upper right", fontsize=9)
except Exception: except Exception:
pass pass
fig.tight_layout() fig.tight_layout()
self.canvas = FigureCanvasTkAgg(fig, master=parent) self.canvas = FigureCanvasTkAgg(fig, master=parent)
@ -308,7 +312,7 @@ class AnalysisWindow(tk.Toplevel):
self.stats_tree.delete(*self.stats_tree.get_children()) self.stats_tree.delete(*self.stats_tree.get_children())
# Add rows for each error axis (X, Y, Z) # Add rows for each error axis (X, Y, Z)
for axis in ['x', 'y', 'z']: for axis in ["x", "y", "z"]:
self.stats_tree.insert( self.stats_tree.insert(
"", "",
"end", "end",
@ -319,17 +323,22 @@ class AnalysisWindow(tk.Toplevel):
f"{results[axis]['rmse']:.3f}", f"{results[axis]['rmse']:.3f}",
), ),
) )
# Add latency row if available # Add latency row if available
if self.estimated_latency_ms is not None: if self.estimated_latency_ms is not None:
# Calculate latency stats from samples if available # Calculate latency stats from samples if available
if self.latency_values_ms: if self.latency_values_ms:
import statistics import statistics
lat_mean = statistics.mean(self.latency_values_ms) 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_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_min = min(self.latency_values_ms)
lat_max = max(self.latency_values_ms) lat_max = max(self.latency_values_ms)
self.stats_tree.insert( self.stats_tree.insert(
"", "",
"end", "end",
@ -395,7 +404,7 @@ class AnalysisWindow(tk.Toplevel):
def _update_latency_plot(self): def _update_latency_plot(self):
"""Update the latency subplot with the latency samples from the archive. """Update the latency subplot with the latency samples from the archive.
Plots latency measurements over time to show how latency evolved Plots latency measurements over time to show how latency evolved
during the simulation, aligned with the error plot above. during the simulation, aligned with the error plot above.
""" """
@ -406,10 +415,10 @@ class AnalysisWindow(tk.Toplevel):
self.ax_latency.autoscale_view() self.ax_latency.autoscale_view()
self.canvas.draw_idle() self.canvas.draw_idle()
return return
# Plot latencies - they are already filtered to simulation time range # Plot latencies - they are already filtered to simulation time range
self.line_latency.set_data(self.latency_timestamps, self.latency_values_ms) self.line_latency.set_data(self.latency_timestamps, self.latency_values_ms)
self.ax_latency.relim() self.ax_latency.relim()
self.ax_latency.autoscale_view() self.ax_latency.autoscale_view()
self.canvas.draw_idle() self.canvas.draw_idle()

View File

@ -3,6 +3,7 @@
This module provides the `ConnectionSettingsWindow` dialog used by the This module provides the `ConnectionSettingsWindow` dialog used by the
main UI to configure Target and LRU communication settings. main UI to configure Target and LRU communication settings.
""" """
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox from tkinter import ttk, messagebox
@ -432,6 +433,8 @@ class ConnectionSettingsWindow(tk.Toplevel):
Close the dialog without saving any changes. Close the dialog without saving any changes.
""" """
self.destroy() self.destroy()
# target_simulator/gui/connection_settings_window.py # target_simulator/gui/connection_settings_window.py
""" """
Toplevel window for configuring Target and LRU connections. Toplevel window for configuring Target and LRU connections.
@ -443,14 +446,14 @@ from tkinter import ttk, messagebox
class ConnectionSettingsWindow(tk.Toplevel): class ConnectionSettingsWindow(tk.Toplevel):
"""A dialog for configuring connection settings. """A dialog for configuring connection settings.
Inputs: Inputs:
- master: parent Tk widget - master: parent Tk widget
- config_manager: ConfigManager instance for persistence - config_manager: ConfigManager instance for persistence
- connection_config: dict with initial connection settings - connection_config: dict with initial connection settings
Side-effects: Side-effects:
- creates a modal Toplevel window and writes settings via - creates a modal Toplevel window and writes settings via
master_view.update_connection_settings on Save. master_view.update_connection_settings on Save.
""" """
def __init__(self, master, config_manager, connection_config): def __init__(self, master, config_manager, connection_config):
@ -599,14 +602,14 @@ class ConnectionSettingsWindow(tk.Toplevel):
def _create_connection_panel(self, parent_frame): def _create_connection_panel(self, parent_frame):
"""Create the per-connection-type panel (SFP/TFTP/Serial). """Create the per-connection-type panel (SFP/TFTP/Serial).
Inputs: Inputs:
- parent_frame: ttk.Frame to populate - parent_frame: ttk.Frame to populate
Returns: Returns:
- dict of Tk variable objects used by the panel widgets - dict of Tk variable objects used by the panel widgets
""" """
vars = {} vars = {}
# Top row: label + combobox to choose connection type # Top row: label + combobox to choose connection type
type_row = ttk.Frame(parent_frame) type_row = ttk.Frame(parent_frame)
type_row.pack(fill=tk.X, padx=5, pady=(5, 10)) type_row.pack(fill=tk.X, padx=5, pady=(5, 10))

View File

@ -3,4 +3,3 @@ GUI helpers module.
Placeholder module for shared GUI utilities. See package submodules for widgets. Placeholder module for shared GUI utilities. See package submodules for widgets.
""" """

View File

@ -78,23 +78,24 @@ GUI_REFRESH_RATE_MS = 40
class MainView(tk.Tk): class MainView(tk.Tk):
""" """
Main application window and controller. Main application window and controller.
This class composes the primary UI and wires it to application logic: This class composes the primary UI and wires it to application logic:
communicators, the SimulationController and SimulationEngine. It owns communicators, the SimulationController and SimulationEngine. It owns
a :class:`SimulationStateHub` instance used application-wide. a :class:`SimulationStateHub` instance used application-wide.
Key responsibilities: Key responsibilities:
- build and layout widgets (PPI display, scenario controls, simulation controls), - build and layout widgets (PPI display, scenario controls, simulation controls),
- initialize communicators via :class:`CommunicatorManager`, - initialize communicators via :class:`CommunicatorManager`,
- start/stop simulations via :class:`SimulationController`, - start/stop simulations via :class:`SimulationController`,
- periodically refresh GUI elements from the simulation hub. - periodically refresh GUI elements from the simulation hub.
Threading/side-effects: Threading/side-effects:
- Instantiating MainView will start Tk's mainloop when ``mainloop()`` is - Instantiating MainView will start Tk's mainloop when ``mainloop()`` is
called; GUI updates must run on the main thread. called; GUI updates must run on the main thread.
- Many methods update application state and widgets; they return ``None``. - Many methods update application state and widgets; they return ``None``.
""" """
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.logger = get_logger(__name__) self.logger = get_logger(__name__)

View File

@ -259,7 +259,9 @@ class DebugPayloadRouter:
# Store latencies only during active simulation (when archive is set) # Store latencies only during active simulation (when archive is set)
if latency >= 0 and self.active_archive is not None: if latency >= 0 and self.active_archive is not None:
with self._lock: with self._lock:
self._latency_samples.append((reception_timestamp, latency)) self._latency_samples.append(
(reception_timestamp, latency)
)
except Exception: except Exception:
pass pass
except Exception: except Exception:
@ -527,7 +529,7 @@ class DebugPayloadRouter:
Args: Args:
limit: maximum number of samples to return (None = all available) limit: maximum number of samples to return (None = all available)
Returns: Returns:
List of (timestamp, latency_s) tuples List of (timestamp, latency_s) tuples
""" """

View File

@ -61,7 +61,9 @@ def build_display_data(
last_sim_state = history["simulated"][-1] last_sim_state = history["simulated"][-1]
if len(last_sim_state) >= 6: if len(last_sim_state) >= 6:
_ts, x_sim_ft, y_sim_ft, z_sim_ft, vel_fps, vert_vel_fps = last_sim_state[:6] _ts, x_sim_ft, y_sim_ft, z_sim_ft, vel_fps, vert_vel_fps = (
last_sim_state[:6]
)
else: else:
_ts, x_sim_ft, y_sim_ft, z_sim_ft = last_sim_state _ts, x_sim_ft, y_sim_ft, z_sim_ft = last_sim_state
vel_fps, vert_vel_fps = 0.0, 0.0 vel_fps, vert_vel_fps = 0.0, 0.0
@ -98,9 +100,7 @@ def build_display_data(
sim_target = Target(target_id=tid, trajectory=[]) sim_target = Target(target_id=tid, trajectory=[])
setattr(sim_target, "_pos_x_ft", rel_x_ft) setattr(sim_target, "_pos_x_ft", rel_x_ft)
setattr(sim_target, "_pos_y_ft", rel_y_ft) setattr(sim_target, "_pos_y_ft", rel_y_ft)
setattr( setattr(sim_target, "_pos_z_ft", z_sim_ft)
sim_target, "_pos_z_ft", z_sim_ft
)
sim_target.current_velocity_fps = vel_fps sim_target.current_velocity_fps = vel_fps
sim_target.current_vertical_velocity_fps = vert_vel_fps sim_target.current_vertical_velocity_fps = vert_vel_fps
sim_target._update_current_polar_coords() sim_target._update_current_polar_coords()
@ -113,16 +113,20 @@ def build_display_data(
# The target's heading is also in the simulation frame. # The target's heading is also in the simulation frame.
# It must be rotated by the origin heading to be in the world frame. # It must be rotated by the origin heading to be in the world frame.
sim_heading_deg = getattr(t, "current_heading_deg", 0.0) sim_heading_deg = getattr(t, "current_heading_deg", 0.0)
world_heading_deg = (sim_heading_deg + math.degrees(heading_origin_rad)) % 360 world_heading_deg = (
sim_heading_deg + math.degrees(heading_origin_rad)
) % 360
heading = world_heading_deg heading = world_heading_deg
if heading is None and scenario: if heading is None and scenario:
t2 = scenario.get_target(tid) t2 = scenario.get_target(tid)
if t2: if t2:
sim_heading_deg = getattr(t2, "current_heading_deg", 0.0) sim_heading_deg = getattr(t2, "current_heading_deg", 0.0)
world_heading_deg = (sim_heading_deg + math.degrees(heading_origin_rad)) % 360 world_heading_deg = (
sim_heading_deg + math.degrees(heading_origin_rad)
) % 360
heading = world_heading_deg heading = world_heading_deg
if heading is not None: if heading is not None:
sim_target.current_heading_deg = float(heading) sim_target.current_heading_deg = float(heading)
@ -170,4 +174,4 @@ def build_display_data(
len(real_targets_for_ppi), len(real_targets_for_ppi),
) )
return {"simulated": simulated_targets_for_ppi, "real": real_targets_for_ppi} return {"simulated": simulated_targets_for_ppi, "real": real_targets_for_ppi}

View File

@ -86,7 +86,6 @@ class PPIDisplay(ttk.Frame):
self._last_update_summary_time = time.monotonic() self._last_update_summary_time = time.monotonic()
self._update_summary_interval_s = 1.0 self._update_summary_interval_s = 1.0
def _on_display_options_changed(self, *args): def _on_display_options_changed(self, *args):
"""Handler invoked when display options (points/trails) change. """Handler invoked when display options (points/trails) change.
@ -233,7 +232,7 @@ class PPIDisplay(ttk.Frame):
fig.subplots_adjust(left=0.05, right=0.95, top=0.9, bottom=0.05) fig.subplots_adjust(left=0.05, right=0.95, top=0.9, bottom=0.05)
self.ax = fig.add_subplot(111, projection="polar", facecolor="#2E2E2E") self.ax = fig.add_subplot(111, projection="polar", facecolor="#2E2E2E")
self.ax.set_theta_zero_location("N") self.ax.set_theta_zero_location("N")
self.ax.set_theta_direction(1) # Set to CCW explicitly self.ax.set_theta_direction(1) # Set to CCW explicitly
self.ax.set_rlabel_position(90) self.ax.set_rlabel_position(90)
self.ax.set_ylim(0, self.range_var.get()) self.ax.set_ylim(0, self.range_var.get())
@ -246,14 +245,14 @@ class PPIDisplay(ttk.Frame):
self.ax.grid(color="white", linestyle="--", linewidth=0.5, alpha=0.5) self.ax.grid(color="white", linestyle="--", linewidth=0.5, alpha=0.5)
self.ax.spines["polar"].set_color("white") self.ax.spines["polar"].set_color("white")
self.ax.set_title("PPI Display", color="white") self.ax.set_title("PPI Display", color="white")
# Define ownship as a patch (triangle) that we can rotate # Define ownship as a patch (triangle) that we can rotate
self._ownship_artist = mpl.patches.Polygon( self._ownship_artist = mpl.patches.Polygon(
[[-1, -1]], # Placeholder [[-1, -1]], # Placeholder
closed=True, closed=True,
facecolor="cyan", facecolor="cyan",
edgecolor="black", edgecolor="black",
zorder=10 zorder=10,
) )
self.ax.add_patch(self._ownship_artist) self.ax.add_patch(self._ownship_artist)
@ -282,7 +281,7 @@ class PPIDisplay(ttk.Frame):
self.canvas.draw() self.canvas.draw()
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True) self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self.range_selector.bind("<<ComboboxSelected>>", self._on_range_selected) self.range_selector.bind("<<ComboboxSelected>>", self._on_range_selected)
self._update_plot_orientation() # Initial draw self._update_plot_orientation() # Initial draw
def update_ownship_state(self, heading_deg: float): def update_ownship_state(self, heading_deg: float):
"""Updates the ownship's visual representation on the PPI.""" """Updates the ownship's visual representation on the PPI."""
@ -301,29 +300,33 @@ class PPIDisplay(ttk.Frame):
# With zero_location="N", theta becomes 0=North, positive=CCW. # With zero_location="N", theta becomes 0=North, positive=CCW.
heading_rad = np.deg2rad(self.ownship_heading_deg) heading_rad = np.deg2rad(self.ownship_heading_deg)
max_r = self.ax.get_ylim()[1] max_r = self.ax.get_ylim()[1]
# Define ownship triangle shape in polar coordinates (theta, r) # Define ownship triangle shape in polar coordinates (theta, r)
r_scale = max_r * 0.04 r_scale = max_r * 0.04
nose = (0, r_scale) nose = (0, r_scale)
wing_angle = np.deg2rad(140) wing_angle = np.deg2rad(140)
left_wing = (wing_angle, r_scale * 0.8) left_wing = (wing_angle, r_scale * 0.8)
right_wing = (-wing_angle, r_scale * 0.8) right_wing = (-wing_angle, r_scale * 0.8)
base_verts_polar = np.array([nose, left_wing, right_wing]) base_verts_polar = np.array([nose, left_wing, right_wing])
if mode == "Heading-Up": if mode == "Heading-Up":
# Rotate the entire grid by the heading angle # Rotate the entire grid by the heading angle
self.ax.set_theta_offset(np.pi / 2 - heading_rad) self.ax.set_theta_offset(np.pi / 2 - heading_rad)
# To make ownship and scan lines appear fixed, we must "counter-rotate" # To make ownship and scan lines appear fixed, we must "counter-rotate"
# them by drawing them at an angle equal to the heading. # them by drawing them at an angle equal to the heading.
verts_polar = base_verts_polar.copy() verts_polar = base_verts_polar.copy()
verts_polar[:, 0] += heading_rad verts_polar[:, 0] += heading_rad
self._ownship_artist.set_xy(verts_polar) self._ownship_artist.set_xy(verts_polar)
limit_rad = np.deg2rad(self.scan_limit_deg) limit_rad = np.deg2rad(self.scan_limit_deg)
self._scan_line_1.set_data([heading_rad + limit_rad, heading_rad + limit_rad], [0, max_r]) self._scan_line_1.set_data(
self._scan_line_2.set_data([heading_rad - limit_rad, heading_rad - limit_rad], [0, max_r]) [heading_rad + limit_rad, heading_rad + limit_rad], [0, max_r]
)
self._scan_line_2.set_data(
[heading_rad - limit_rad, heading_rad - limit_rad], [0, max_r]
)
else: # North-Up else: # North-Up
# Keep grid fixed with North up # Keep grid fixed with North up
@ -334,8 +337,12 @@ class PPIDisplay(ttk.Frame):
self._ownship_artist.set_xy(verts_polar) self._ownship_artist.set_xy(verts_polar)
# Rotate scan lines by adding heading to theta # Rotate scan lines by adding heading to theta
limit_rad = np.deg2rad(self.scan_limit_deg) limit_rad = np.deg2rad(self.scan_limit_deg)
self._scan_line_1.set_data([heading_rad + limit_rad, heading_rad + limit_rad], [0, max_r]) self._scan_line_1.set_data(
self._scan_line_2.set_data([heading_rad - limit_rad, heading_rad - limit_rad], [0, max_r]) [heading_rad + limit_rad, heading_rad + limit_rad], [0, max_r]
)
self._scan_line_2.set_data(
[heading_rad - limit_rad, heading_rad - limit_rad], [0, max_r]
)
if self.canvas: if self.canvas:
self.canvas.draw_idle() self.canvas.draw_idle()
@ -688,7 +695,7 @@ class PPIDisplay(ttk.Frame):
self._antenna_line_artist.set_visible(False) self._antenna_line_artist.set_visible(False)
else: else:
az_float = float(az_deg) az_float = float(az_deg)
final_az_for_plot = az_float final_az_for_plot = az_float
if self.display_mode_var.get() == "Heading-Up": if self.display_mode_var.get() == "Heading-Up":
# The incoming az_deg is absolute. To display it relative to the # The incoming az_deg is absolute. To display it relative to the
@ -698,11 +705,11 @@ class PPIDisplay(ttk.Frame):
# Convert final angle to theta for Matplotlib (0=N, positive=CCW) # Convert final angle to theta for Matplotlib (0=N, positive=CCW)
theta = np.deg2rad(final_az_for_plot) theta = np.deg2rad(final_az_for_plot)
max_r = self.ax.get_ylim()[1] max_r = self.ax.get_ylim()[1]
#logger.debug( # logger.debug(
# f"Rendering antenna: az_in={az_deg}, mode={self.display_mode_var.get()}, " # f"Rendering antenna: az_in={az_deg}, mode={self.display_mode_var.get()}, "
# f"own_hdg={self.ownship_heading_deg}, final_az={final_az_for_plot}, theta={theta}" # f"own_hdg={self.ownship_heading_deg}, final_az={final_az_for_plot}, theta={theta}"
#) # )
self._antenna_line_artist.set_data([theta, theta], [0, max_r]) self._antenna_line_artist.set_data([theta, theta], [0, max_r])
self._antenna_line_artist.set_visible(True) self._antenna_line_artist.set_visible(True)
@ -710,4 +717,4 @@ class PPIDisplay(ttk.Frame):
if self.canvas: if self.canvas:
self.canvas.draw_idle() self.canvas.draw_idle()
except Exception: except Exception:
logger.exception("Error rendering antenna line") logger.exception("Error rendering antenna line")

View File

@ -283,18 +283,18 @@ class SimulationControls(ttk.LabelFrame):
): ):
""" """
Updates the active targets table using a diff-based approach. Updates the active targets table using a diff-based approach.
Performance optimization: Instead of destroying and recreating all rows Performance optimization: Instead of destroying and recreating all rows
every frame, this method: every frame, this method:
1. Removes only targets that are no longer active 1. Removes only targets that are no longer active
2. Updates existing rows in-place 2. Updates existing rows in-place
3. Adds only new targets 3. Adds only new targets
This reduces widget operations by ~70% compared to full rebuild. This reduces widget operations by ~70% compared to full rebuild.
""" """
# Build set of current target IDs in the incoming data # Build set of current target IDs in the incoming data
incoming_target_ids = {t.target_id for t in targets if t.active} incoming_target_ids = {t.target_id for t in targets if t.active}
# Get existing items in the tree (mapping iid -> target_id) # Get existing items in the tree (mapping iid -> target_id)
existing_items = {} existing_items = {}
for item_iid in self.targets_tree.get_children(): for item_iid in self.targets_tree.get_children():
@ -305,40 +305,40 @@ class SimulationControls(ttk.LabelFrame):
except (IndexError, KeyError): except (IndexError, KeyError):
# Malformed item, schedule for removal # Malformed item, schedule for removal
self.targets_tree.delete(item_iid) self.targets_tree.delete(item_iid)
existing_target_ids = set(existing_items.keys()) existing_target_ids = set(existing_items.keys())
# 1. Remove targets that are no longer in the incoming set # 1. Remove targets that are no longer in the incoming set
targets_to_remove = existing_target_ids - incoming_target_ids targets_to_remove = existing_target_ids - incoming_target_ids
for target_id in targets_to_remove: for target_id in targets_to_remove:
item_iid = existing_items[target_id] item_iid = existing_items[target_id]
self.targets_tree.delete(item_iid) self.targets_tree.delete(item_iid)
# Get ownship data needed for conversion # Get ownship data needed for conversion
own_lat = ownship_state.get("latitude") own_lat = ownship_state.get("latitude")
own_lon = ownship_state.get("longitude") own_lon = ownship_state.get("longitude")
own_pos_xy_ft = ownship_state.get("position_xy_ft") own_pos_xy_ft = ownship_state.get("position_xy_ft")
# 2. Update existing targets and insert new ones # 2. Update existing targets and insert new ones
for target in sorted(targets, key=lambda t: t.target_id): for target in sorted(targets, key=lambda t: t.target_id):
if not target.active: if not target.active:
continue continue
# Calculate display values # Calculate display values
lat_str, lon_str = self._calculate_geo_position( lat_str, lon_str = self._calculate_geo_position(
target, own_lat, own_lon, own_pos_xy_ft target, own_lat, own_lon, own_pos_xy_ft
) )
alt_str = f"{target.current_altitude_ft:.1f}" alt_str = f"{target.current_altitude_ft:.1f}"
hdg_str = f"{target.current_heading_deg:.2f}" hdg_str = f"{target.current_heading_deg:.2f}"
# Use the now-correct velocity values from the Target object # Use the now-correct velocity values from the Target object
gnd_speed_kn = target.current_velocity_fps * FPS_TO_KNOTS gnd_speed_kn = target.current_velocity_fps * FPS_TO_KNOTS
gnd_speed_str = f"{gnd_speed_kn:.1f}" gnd_speed_str = f"{gnd_speed_kn:.1f}"
vert_speed_fps = target.current_vertical_velocity_fps vert_speed_fps = target.current_vertical_velocity_fps
vert_speed_str = f"{vert_speed_fps:+.1f}" vert_speed_str = f"{vert_speed_fps:+.1f}"
values = ( values = (
target.target_id, target.target_id,
lat_str, lat_str,
@ -348,7 +348,7 @@ class SimulationControls(ttk.LabelFrame):
gnd_speed_str, gnd_speed_str,
vert_speed_str, vert_speed_str,
) )
# Check if target already exists in tree # Check if target already exists in tree
if target.target_id in existing_items: if target.target_id in existing_items:
# UPDATE: Modify existing row in-place (much faster than delete+insert) # UPDATE: Modify existing row in-place (much faster than delete+insert)
@ -356,41 +356,43 @@ class SimulationControls(ttk.LabelFrame):
self.targets_tree.item(item_iid, values=values) self.targets_tree.item(item_iid, values=values)
else: else:
# INSERT: Add new target (use target_id as iid for fast lookup) # INSERT: Add new target (use target_id as iid for fast lookup)
self.targets_tree.insert("", tk.END, iid=str(target.target_id), values=values) self.targets_tree.insert(
"", tk.END, iid=str(target.target_id), values=values
)
def _calculate_geo_position( def _calculate_geo_position(
self, target: Target, own_lat, own_lon, own_pos_xy_ft self, target: Target, own_lat, own_lon, own_pos_xy_ft
) -> tuple: ) -> tuple:
""" """
Helper method to calculate geographic position (lat/lon) for a target. Helper method to calculate geographic position (lat/lon) for a target.
Returns: Returns:
tuple: (lat_str, lon_str) formatted strings, or ("N/A", "N/A") tuple: (lat_str, lon_str) formatted strings, or ("N/A", "N/A")
""" """
if own_lat is None or own_lon is None or not own_pos_xy_ft: if own_lat is None or own_lon is None or not own_pos_xy_ft:
return ("N/A", "N/A") return ("N/A", "N/A")
target_x_ft = getattr(target, "_pos_x_ft", 0.0) target_x_ft = getattr(target, "_pos_x_ft", 0.0)
target_y_ft = getattr(target, "_pos_y_ft", 0.0) target_y_ft = getattr(target, "_pos_y_ft", 0.0)
own_x_ft, own_y_ft = own_pos_xy_ft own_x_ft, own_y_ft = own_pos_xy_ft
# Delta from ownship's current position in meters # Delta from ownship's current position in meters
delta_east_m = (target_x_ft - own_x_ft) * 0.3048 delta_east_m = (target_x_ft - own_x_ft) * 0.3048
delta_north_m = (target_y_ft - own_y_ft) * 0.3048 delta_north_m = (target_y_ft - own_y_ft) * 0.3048
# Equirectangular approximation for lat/lon calculation # Equirectangular approximation for lat/lon calculation
earth_radius_m = 6378137.0 earth_radius_m = 6378137.0
dlat = (delta_north_m / earth_radius_m) * (180.0 / math.pi) dlat = (delta_north_m / earth_radius_m) * (180.0 / math.pi)
dlon = ( dlon = (delta_east_m / (earth_radius_m * math.cos(math.radians(own_lat)))) * (
delta_east_m / (earth_radius_m * math.cos(math.radians(own_lat))) 180.0 / math.pi
) * (180.0 / math.pi) )
target_lat = own_lat + dlat target_lat = own_lat + dlat
target_lon = own_lon + dlon target_lon = own_lon + dlon
lat_str = f"{abs(target_lat):.5f}° {'N' if target_lat >= 0 else 'S'}" lat_str = f"{abs(target_lat):.5f}° {'N' if target_lat >= 0 else 'S'}"
lon_str = f"{abs(target_lon):.5f}° {'E' if target_lon >= 0 else 'W'}" lon_str = f"{abs(target_lon):.5f}° {'E' if target_lon >= 0 else 'W'}"
return (lat_str, lon_str) return (lat_str, lon_str)
def show_notice(self, message: str): def show_notice(self, message: str):

View File

@ -4,4 +4,3 @@ Simulation helpers package.
Contains controllers and helpers that run or coordinate the simulation loop Contains controllers and helpers that run or coordinate the simulation loop
and related utilities (e.g., SimulationController). and related utilities (e.g., SimulationController).
""" """

View File

@ -239,11 +239,13 @@ class SimulationController:
if stats and stats.get("count", 0) > 0: if stats and stats.get("count", 0) > 0:
extra_metadata["latency_summary"] = stats extra_metadata["latency_summary"] = stats
if router and hasattr(router, "get_latency_samples"): if router and hasattr(router, "get_latency_samples"):
samples = router.get_latency_samples(limit=None) # Get all available samples samples = router.get_latency_samples(
limit=None
) # Get all available samples
if samples: if samples:
# Convert to [timestamp, latency_ms] format # Convert to [timestamp, latency_ms] format
samples_with_time = [ samples_with_time = [
[round(ts, 3), round(lat * 1000.0, 3)] [round(ts, 3), round(lat * 1000.0, 3)]
for ts, lat in samples for ts, lat in samples
] ]
extra_metadata["latency_samples"] = samples_with_time extra_metadata["latency_samples"] = samples_with_time
@ -312,4 +314,4 @@ class SimulationController:
if not main_view.is_simulation_running.get(): if not main_view.is_simulation_running.get():
return return
self.logger.info("Simulation engine finished execution.") self.logger.info("Simulation engine finished execution.")
self._stop_or_finish_simulation(main_view, was_stopped_by_user=False) self._stop_or_finish_simulation(main_view, was_stopped_by_user=False)

View File

@ -3,4 +3,3 @@ Utilities package for Target Simulator.
Contains helpers for logging, config management, CSV logging, networking, etc. Contains helpers for logging, config management, CSV logging, networking, etc.
""" """

View File

@ -32,6 +32,70 @@ _CSV_FLUSH_INTERVAL_S = 2.0 # Flush every 2 seconds
_CSV_MAX_BUFFER_SIZE = 1000 # Flush immediately if buffer exceeds this _CSV_MAX_BUFFER_SIZE = 1000 # Flush immediately if buffer exceeds this
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."""
with _CSV_BUFFER_LOCK:
for filename, buffer in list(_CSV_BUFFERS.items()):
if not buffer:
continue
temp_folder = _ensure_temp_folder()
if not temp_folder:
continue
file_path = os.path.join(temp_folder, filename)
# Check if we need to write headers
write_headers = not os.path.exists(file_path)
try:
with open(file_path, "a", newline="", encoding="utf-8") as csvfile:
writer = csv.writer(csvfile)
# Write all buffered rows
while buffer:
row, headers = buffer.popleft()
# Write headers only once for new files
if write_headers and headers is not None:
writer.writerow(list(headers))
write_headers = False
writer.writerow(list(row))
except Exception:
# Clear buffer on error to avoid accumulation
buffer.clear()
def _ensure_csv_flush_thread():
"""Ensure the background 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()
_CSV_FLUSH_THREAD = threading.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."""
_CSV_STOP_EVENT.set()
if _CSV_FLUSH_THREAD and _CSV_FLUSH_THREAD.is_alive():
_CSV_FLUSH_THREAD.join(timeout=5.0)
def _ensure_temp_folder(): def _ensure_temp_folder():
temp_folder = DEBUG_CONFIG.get("temp_folder_name", "Temp") temp_folder = DEBUG_CONFIG.get("temp_folder_name", "Temp")
if not os.path.exists(temp_folder): if not os.path.exists(temp_folder):
@ -51,6 +115,9 @@ def append_row(filename: str, row: Iterable[Any], headers: Iterable[str] | None
written as the first row. The function is a no-op when tracing is written as the first row. The function is a no-op when tracing is
disabled via DEBUG_CONFIG. 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: Args:
filename: Name of the target CSV file inside the Temp folder. filename: Name of the target CSV file inside the Temp folder.
row: Iterable of values to write as a CSV row. row: Iterable of values to write as a CSV row.
@ -67,17 +134,20 @@ def append_row(filename: str, row: Iterable[Any], headers: Iterable[str] | None
if not temp_folder: if not temp_folder:
return False return False
file_path = os.path.join(temp_folder, filename) # Ensure flush thread is running
write_headers = not os.path.exists(file_path) and headers is not None _ensure_csv_flush_thread()
try: # Buffer the row for async writing
with open(file_path, "a", newline="", encoding="utf-8") as csvfile: with _CSV_BUFFER_LOCK:
writer = csv.writer(csvfile) if filename not in _CSV_BUFFERS:
if write_headers: _CSV_BUFFERS[filename] = deque(maxlen=_CSV_MAX_BUFFER_SIZE * 2)
writer.writerow(list(headers))
writer.writerow(list(row)) _CSV_BUFFERS[filename].append((row, headers))
except Exception:
return False # Force immediate flush if buffer is getting large
if len(_CSV_BUFFERS[filename]) >= _CSV_MAX_BUFFER_SIZE:
# Schedule immediate flush without blocking
threading.Thread(target=_flush_all_buffers, daemon=True).start()
return True return True

View File

@ -47,14 +47,16 @@ class TkinterTextHandler(logging.Handler):
""" """
A logging handler that directs log messages to a Tkinter Text widget. A logging handler that directs log messages to a Tkinter Text widget.
This handler is called directly from the GUI thread's processing loop. This handler is called directly from the GUI thread's processing loop.
Optimizations: Optimizations:
- Batches multiple log entries to reduce Tkinter widget operations - Batches multiple log entries to reduce Tkinter widget operations
- Limits total widget size to prevent memory bloat - Limits total widget size to prevent memory bloat
- Only scrolls to end if user hasn't scrolled up manually - Only scrolls to end if user hasn't scrolled up manually
""" """
def __init__(self, text_widget: tk.Text, level_colors: Dict[int, str], max_lines: int = 1000): def __init__(
self, text_widget: tk.Text, level_colors: Dict[int, str], max_lines: int = 1000
):
super().__init__() super().__init__()
self.text_widget = text_widget self.text_widget = text_widget
self.level_colors = level_colors self.level_colors = level_colors
@ -80,44 +82,44 @@ class TkinterTextHandler(logging.Handler):
self._pending_records.append(record) self._pending_records.append(record)
except Exception as e: except Exception as e:
print(f"Error in TkinterTextHandler.emit: {e}", flush=True) print(f"Error in TkinterTextHandler.emit: {e}", flush=True)
def flush_pending(self): def flush_pending(self):
"""Flush all pending log records to the widget in a single operation.""" """Flush all pending log records to the widget in a single operation."""
if not self._pending_records: if not self._pending_records:
return return
try: try:
if not self.text_widget.winfo_exists(): if not self.text_widget.winfo_exists():
self._pending_records.clear() self._pending_records.clear()
return return
# Check if user has scrolled away from bottom # Check if user has scrolled away from bottom
yview = self.text_widget.yview() yview = self.text_widget.yview()
user_at_bottom = yview[1] >= 0.98 # Within 2% of bottom user_at_bottom = yview[1] >= 0.98 # Within 2% of bottom
# Single state change for all inserts # Single state change for all inserts
self.text_widget.configure(state=tk.NORMAL) self.text_widget.configure(state=tk.NORMAL)
# Batch insert all pending records # Batch insert all pending records
for record in self._pending_records: for record in self._pending_records:
msg = self.format(record) msg = self.format(record)
level_name = record.levelname level_name = record.levelname
self.text_widget.insert(tk.END, msg + "\n", (level_name,)) self.text_widget.insert(tk.END, msg + "\n", (level_name,))
# Trim old lines if exceeded max # Trim old lines if exceeded max
line_count = int(self.text_widget.index('end-1c').split('.')[0]) line_count = int(self.text_widget.index("end-1c").split(".")[0])
if line_count > self.max_lines: if line_count > self.max_lines:
excess = line_count - self.max_lines excess = line_count - self.max_lines
self.text_widget.delete('1.0', f'{excess}.0') self.text_widget.delete("1.0", f"{excess}.0")
self.text_widget.configure(state=tk.DISABLED) self.text_widget.configure(state=tk.DISABLED)
# Only auto-scroll if user was at bottom # Only auto-scroll if user was at bottom
if user_at_bottom: if user_at_bottom:
self.text_widget.see(tk.END) self.text_widget.see(tk.END)
self._pending_records.clear() self._pending_records.clear()
except Exception as e: except Exception as e:
print(f"Error in TkinterTextHandler.flush_pending: {e}", flush=True) print(f"Error in TkinterTextHandler.flush_pending: {e}", flush=True)
self._pending_records.clear() self._pending_records.clear()
@ -140,7 +142,7 @@ def _process_global_log_queue():
""" """
GUI Thread: Periodically processes LogRecords from the _global_log_queue GUI Thread: Periodically processes LogRecords from the _global_log_queue
and dispatches them to the actual configured handlers. and dispatches them to the actual configured handlers.
Optimizations: Optimizations:
- Processes logs in batches (max LOG_BATCH_SIZE per cycle) - Processes logs in batches (max LOG_BATCH_SIZE per cycle)
- Adaptive polling: faster when logs are active, slower when idle - Adaptive polling: faster when logs are active, slower when idle
@ -159,35 +161,41 @@ def _process_global_log_queue():
processed_count = 0 processed_count = 0
try: try:
# Process up to LOG_BATCH_SIZE records per cycle to avoid GUI freezes # Process up to LOG_BATCH_SIZE records per cycle to avoid GUI freezes
while _global_log_queue and not _global_log_queue.empty() and processed_count < LOG_BATCH_SIZE: while (
_global_log_queue
and not _global_log_queue.empty()
and processed_count < LOG_BATCH_SIZE
):
record = _global_log_queue.get_nowait() record = _global_log_queue.get_nowait()
# Console and file handlers write immediately (fast, non-blocking) # Console and file handlers write immediately (fast, non-blocking)
if _actual_console_handler: if _actual_console_handler:
_actual_console_handler.handle(record) _actual_console_handler.handle(record)
if _actual_file_handler: if _actual_file_handler:
_actual_file_handler.handle(record) _actual_file_handler.handle(record)
# Tkinter handler buffers the record (no widget operations yet) # Tkinter handler buffers the record (no widget operations yet)
if _actual_tkinter_handler: if _actual_tkinter_handler:
_actual_tkinter_handler.handle(record) _actual_tkinter_handler.handle(record)
_global_log_queue.task_done() _global_log_queue.task_done()
processed_count += 1 processed_count += 1
_last_log_time = time.time() _last_log_time = time.time()
except QueueEmpty: except QueueEmpty:
pass pass
except Exception as e: except Exception as e:
print(f"Error in log processing queue: {e}", flush=True) print(f"Error in log processing queue: {e}", flush=True)
# Flush all pending Tkinter records in a single batch operation # Flush all pending Tkinter records in a single batch operation
try: try:
if _actual_tkinter_handler and hasattr(_actual_tkinter_handler, 'flush_pending'): if _actual_tkinter_handler and hasattr(
_actual_tkinter_handler, "flush_pending"
):
_actual_tkinter_handler.flush_pending() _actual_tkinter_handler.flush_pending()
except Exception as e: except Exception as e:
print(f"Error flushing Tkinter logs: {e}", flush=True) print(f"Error flushing Tkinter logs: {e}", flush=True)
# Adaptive polling: faster interval if logs are recent, slower when idle # Adaptive polling: faster interval if logs are recent, slower when idle
try: try:
time_since_last_log = time.time() - _last_log_time time_since_last_log = time.time() - _last_log_time
@ -202,7 +210,7 @@ def _process_global_log_queue():
next_interval = GLOBAL_LOG_QUEUE_POLL_INTERVAL_MS * 5 next_interval = GLOBAL_LOG_QUEUE_POLL_INTERVAL_MS * 5
except Exception: except Exception:
next_interval = GLOBAL_LOG_QUEUE_POLL_INTERVAL_MS next_interval = GLOBAL_LOG_QUEUE_POLL_INTERVAL_MS
# Schedule next processing cycle # Schedule next processing cycle
if _logging_system_active: if _logging_system_active:
_log_processor_after_id = _tk_root_instance_for_processing.after( _log_processor_after_id = _tk_root_instance_for_processing.after(

View File

@ -14,9 +14,13 @@ import sys
import os import os
# Add project root to path # Add project root to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from target_simulator.utils.logger import setup_basic_logging, add_tkinter_handler, get_logger from target_simulator.utils.logger import (
setup_basic_logging,
add_tkinter_handler,
get_logger,
)
from target_simulator.config import LOGGING_CONFIG from target_simulator.config import LOGGING_CONFIG
@ -24,7 +28,7 @@ def stress_test_logging(logger, num_messages=1000, delay_ms=0):
"""Generate log messages to stress-test the system.""" """Generate log messages to stress-test the system."""
print(f"\n=== Stress Test: {num_messages} messages ===") print(f"\n=== Stress Test: {num_messages} messages ===")
start = time.perf_counter() start = time.perf_counter()
for i in range(num_messages): for i in range(num_messages):
logger.debug(f"Debug message {i}") logger.debug(f"Debug message {i}")
if i % 100 == 0: if i % 100 == 0:
@ -33,7 +37,7 @@ def stress_test_logging(logger, num_messages=1000, delay_ms=0):
logger.warning(f"Warning at {i}") logger.warning(f"Warning at {i}")
if delay_ms > 0: if delay_ms > 0:
time.sleep(delay_ms / 1000.0) time.sleep(delay_ms / 1000.0)
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
rate = num_messages / elapsed if elapsed > 0 else 0 rate = num_messages / elapsed if elapsed > 0 else 0
print(f"Generated {num_messages} logs in {elapsed:.3f}s ({rate:.1f} msg/s)") print(f"Generated {num_messages} logs in {elapsed:.3f}s ({rate:.1f} msg/s)")
@ -43,54 +47,54 @@ def stress_test_logging(logger, num_messages=1000, delay_ms=0):
def test_batch_performance(): def test_batch_performance():
"""Test che il batching funzioni correttamente.""" """Test che il batching funzioni correttamente."""
print("\n=== Test Batch Performance ===") print("\n=== Test Batch Performance ===")
root = tk.Tk() root = tk.Tk()
root.title("Logging Performance Test") root.title("Logging Performance Test")
root.geometry("800x600") root.geometry("800x600")
# Create log widget # Create log widget
log_widget = ScrolledText(root, state=tk.DISABLED, wrap=tk.WORD) log_widget = ScrolledText(root, state=tk.DISABLED, wrap=tk.WORD)
log_widget.pack(fill=tk.BOTH, expand=True) log_widget.pack(fill=tk.BOTH, expand=True)
# Setup logging system # Setup logging system
setup_basic_logging(root, LOGGING_CONFIG) setup_basic_logging(root, LOGGING_CONFIG)
add_tkinter_handler(log_widget, LOGGING_CONFIG) add_tkinter_handler(log_widget, LOGGING_CONFIG)
logger = get_logger("test_logger") logger = get_logger("test_logger")
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
# Test 1: Rapid fire logging # Test 1: Rapid fire logging
print("\nTest 1: 500 rapid messages (no delay)") print("\nTest 1: 500 rapid messages (no delay)")
elapsed1 = stress_test_logging(logger, num_messages=500, delay_ms=0) elapsed1 = stress_test_logging(logger, num_messages=500, delay_ms=0)
# Allow GUI to process # Allow GUI to process
print("Waiting for GUI to catch up...") print("Waiting for GUI to catch up...")
for _ in range(30): # 3 seconds at 100ms poll interval for _ in range(30): # 3 seconds at 100ms poll interval
root.update() root.update()
time.sleep(0.1) time.sleep(0.1)
# Test 2: Moderate rate logging # Test 2: Moderate rate logging
print("\nTest 2: 200 messages with 10ms delay") print("\nTest 2: 200 messages with 10ms delay")
elapsed2 = stress_test_logging(logger, num_messages=200, delay_ms=10) elapsed2 = stress_test_logging(logger, num_messages=200, delay_ms=10)
# Allow GUI to process # Allow GUI to process
print("Waiting for GUI to catch up...") print("Waiting for GUI to catch up...")
for _ in range(30): for _ in range(30):
root.update() root.update()
time.sleep(0.1) time.sleep(0.1)
# Test 3: Check widget line count (should be capped at max_lines) # Test 3: Check widget line count (should be capped at max_lines)
widget_lines = int(log_widget.index('end-1c').split('.')[0]) widget_lines = int(log_widget.index("end-1c").split(".")[0])
print(f"\nWidget line count: {widget_lines}") print(f"\nWidget line count: {widget_lines}")
print(f"Expected max: 1000 (may be less if not enough messages)") print(f"Expected max: 1000 (may be less if not enough messages)")
print("\n=== Test Complete ===") print("\n=== Test Complete ===")
print("Check the GUI window to verify:") print("Check the GUI window to verify:")
print(" 1. All messages appeared (may be trimmed to last 1000)") print(" 1. All messages appeared (may be trimmed to last 1000)")
print(" 2. Colors are correct (DEBUG=gray, INFO=black, WARNING=orange)") print(" 2. Colors are correct (DEBUG=gray, INFO=black, WARNING=orange)")
print(" 3. Window remained responsive during logging") print(" 3. Window remained responsive during logging")
print(" 4. Auto-scroll worked (if you were at bottom)") print(" 4. Auto-scroll worked (if you were at bottom)")
# Keep window open # Keep window open
print("\nClose the window to exit...") print("\nClose the window to exit...")
root.mainloop() root.mainloop()
@ -99,49 +103,49 @@ def test_batch_performance():
def test_adaptive_polling(): def test_adaptive_polling():
"""Test che il polling adattivo funzioni.""" """Test che il polling adattivo funzioni."""
print("\n=== Test Adaptive Polling ===") print("\n=== Test Adaptive Polling ===")
root = tk.Tk() root = tk.Tk()
root.withdraw() # Hide window for this test root.withdraw() # Hide window for this test
setup_basic_logging(root, LOGGING_CONFIG) setup_basic_logging(root, LOGGING_CONFIG)
logger = get_logger("adaptive_test") logger = get_logger("adaptive_test")
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
# Simulate activity burst followed by idle # Simulate activity burst followed by idle
print("\nPhase 1: Activity burst (should poll fast)") print("\nPhase 1: Activity burst (should poll fast)")
for i in range(50): for i in range(50):
logger.debug(f"Active message {i}") logger.debug(f"Active message {i}")
root.update() root.update()
time.sleep(0.05) # 50ms between messages time.sleep(0.05) # 50ms between messages
print("\nPhase 2: Idle period (should slow down polling)") print("\nPhase 2: Idle period (should slow down polling)")
print("Monitoring for 15 seconds...") print("Monitoring for 15 seconds...")
start = time.time() start = time.time()
while (time.time() - start) < 15: while (time.time() - start) < 15:
root.update() root.update()
time.sleep(0.1) time.sleep(0.1)
print("\nPhase 3: Re-activate (should speed up again)") print("\nPhase 3: Re-activate (should speed up again)")
for i in range(20): for i in range(20):
logger.debug(f"Reactivated message {i}") logger.debug(f"Reactivated message {i}")
root.update() root.update()
time.sleep(0.05) time.sleep(0.05)
print("\n=== Test Complete ===") print("\n=== Test Complete ===")
print("Check console output for timing variations (not visible in this test)") print("Check console output for timing variations (not visible in this test)")
print("In production, you can add debug logging to _process_global_log_queue()") print("In production, you can add debug logging to _process_global_log_queue()")
root.destroy() root.destroy()
def benchmark_comparison(): def benchmark_comparison():
"""Benchmark old vs new approach (simulated).""" """Benchmark old vs new approach (simulated)."""
print("\n=== Benchmark Comparison (Simulated) ===") print("\n=== Benchmark Comparison (Simulated) ===")
# Simulate old approach: write each log individually # Simulate old approach: write each log individually
print("\nOLD APPROACH (individual writes):") print("\nOLD APPROACH (individual writes):")
messages = [f"Message {i}" for i in range(1000)] messages = [f"Message {i}" for i in range(1000)]
start = time.perf_counter() start = time.perf_counter()
simulated_widget_ops = 0 simulated_widget_ops = 0
for msg in messages: for msg in messages:
@ -150,22 +154,22 @@ def benchmark_comparison():
elapsed_old = time.perf_counter() - start elapsed_old = time.perf_counter() - start
print(f" Simulated {simulated_widget_ops} widget operations") print(f" Simulated {simulated_widget_ops} widget operations")
print(f" Estimated time (at 0.5ms/op): {simulated_widget_ops * 0.0005:.3f}s") print(f" Estimated time (at 0.5ms/op): {simulated_widget_ops * 0.0005:.3f}s")
# Simulate new approach: batch writes # Simulate new approach: batch writes
print("\nNEW APPROACH (batched writes):") print("\nNEW APPROACH (batched writes):")
BATCH_SIZE = 50 BATCH_SIZE = 50
num_batches = len(messages) // BATCH_SIZE + (1 if len(messages) % BATCH_SIZE else 0) num_batches = len(messages) // BATCH_SIZE + (1 if len(messages) % BATCH_SIZE else 0)
start = time.perf_counter() start = time.perf_counter()
simulated_widget_ops = 0 simulated_widget_ops = 0
for batch_idx in range(num_batches): for batch_idx in range(num_batches):
# Simulate: configure(NORMAL) + N*insert + configure(DISABLED) + see(END) # Simulate: configure(NORMAL) + N*insert + configure(DISABLED) + see(END)
batch_size = min(BATCH_SIZE, len(messages) - batch_idx * BATCH_SIZE) batch_size = min(BATCH_SIZE, len(messages) - batch_idx * BATCH_SIZE)
simulated_widget_ops += (2 + batch_size + 1) # NORMAL + inserts + DISABLED + see simulated_widget_ops += 2 + batch_size + 1 # NORMAL + inserts + DISABLED + see
elapsed_new = time.perf_counter() - start elapsed_new = time.perf_counter() - start
print(f" Simulated {simulated_widget_ops} widget operations") print(f" Simulated {simulated_widget_ops} widget operations")
print(f" Estimated time (at 0.5ms/op): {simulated_widget_ops * 0.0005:.3f}s") print(f" Estimated time (at 0.5ms/op): {simulated_widget_ops * 0.0005:.3f}s")
improvement = ((4000 - simulated_widget_ops) / 4000) * 100 improvement = ((4000 - simulated_widget_ops) / 4000) * 100
print(f"\n=== Improvement: {improvement:.1f}% fewer widget operations ===") print(f"\n=== Improvement: {improvement:.1f}% fewer widget operations ===")
@ -177,9 +181,9 @@ if __name__ == "__main__":
print(" 2. Adaptive Polling Test") print(" 2. Adaptive Polling Test")
print(" 3. Benchmark Comparison (simulation)") print(" 3. Benchmark Comparison (simulation)")
print(" 4. Run all tests") print(" 4. Run all tests")
choice = input("\nEnter choice (1-4): ").strip() choice = input("\nEnter choice (1-4): ").strip()
if choice == "1": if choice == "1":
test_batch_performance() test_batch_performance()
elif choice == "2": elif choice == "2":

View File

@ -0,0 +1,182 @@
"""Test performance comparison: deepcopy vs PredictedTarget for prediction."""
import sys
import time
import copy
import math
from dataclasses import dataclass, field
from typing import List, Tuple
# Mock minimal Target class for testing
@dataclass
class Target:
target_id: int
active: bool = True
traceable: bool = True
restart: bool = False
current_velocity_fps: float = field(default=0.0)
current_vertical_velocity_fps: float = field(default=0.0)
current_heading_deg: float = field(default=0.0)
current_pitch_deg: float = field(default=0.0)
current_range_nm: float = field(default=0.0)
current_azimuth_deg: float = field(default=0.0)
current_altitude_ft: float = field(default=0.0)
_pos_x_ft: float = field(default=0.0)
_pos_y_ft: float = field(default=0.0)
_pos_z_ft: float = field(default=0.0)
trajectory: List = field(default_factory=list)
_path: List[Tuple] = field(default_factory=list)
def update_state(self, dt: float):
"""Simple kinematic update."""
heading_rad = math.radians(self.current_heading_deg)
vx = self.current_velocity_fps * math.sin(heading_rad)
vy = self.current_velocity_fps * math.cos(heading_rad)
self._pos_x_ft += vx * dt
self._pos_y_ft += vy * dt
self._pos_z_ft += self.current_vertical_velocity_fps * dt
class PredictedTarget:
"""Lightweight wrapper for predicted target state."""
__slots__ = (
"target_id",
"active",
"traceable",
"restart",
"_pos_x_ft",
"_pos_y_ft",
"_pos_z_ft",
"current_velocity_fps",
"current_vertical_velocity_fps",
"current_heading_deg",
"current_pitch_deg",
"current_range_nm",
"current_azimuth_deg",
"current_altitude_ft",
)
def __init__(self, target: Target, horizon_s: float):
self.target_id = target.target_id
self.active = target.active
self.traceable = target.traceable
self.restart = target.restart
self.current_velocity_fps = target.current_velocity_fps
self.current_vertical_velocity_fps = target.current_vertical_velocity_fps
self.current_heading_deg = target.current_heading_deg
self.current_pitch_deg = target.current_pitch_deg
heading_rad = math.radians(target.current_heading_deg)
vx = target.current_velocity_fps * math.sin(heading_rad)
vy = target.current_velocity_fps * math.cos(heading_rad)
vz = target.current_vertical_velocity_fps
self._pos_x_ft = target._pos_x_ft + vx * horizon_s
self._pos_y_ft = target._pos_y_ft + vy * horizon_s
self._pos_z_ft = target._pos_z_ft + vz * horizon_s
dist_2d = math.sqrt(self._pos_x_ft**2 + self._pos_y_ft**2)
self.current_range_nm = dist_2d / 6076.12
self.current_azimuth_deg = (
math.degrees(math.atan2(self._pos_x_ft, self._pos_y_ft)) % 360
)
self.current_altitude_ft = self._pos_z_ft
def benchmark_deepcopy(targets: List[Target], horizon_s: float, iterations: int):
"""OLD approach: deepcopy + update_state."""
start = time.perf_counter()
for _ in range(iterations):
predicted = []
for target in targets:
pred = copy.deepcopy(target)
pred.update_state(horizon_s)
predicted.append(pred)
elapsed = time.perf_counter() - start
return elapsed
def benchmark_lightweight(targets: List[Target], horizon_s: float, iterations: int):
"""NEW approach: PredictedTarget lightweight wrapper."""
start = time.perf_counter()
for _ in range(iterations):
predicted = [PredictedTarget(t, horizon_s) for t in targets]
elapsed = time.perf_counter() - start
return elapsed
def main():
print("=" * 70)
print("Prediction Performance Comparison: deepcopy vs PredictedTarget")
print("=" * 70)
# Create test targets
num_targets = 32
targets = []
for i in range(num_targets):
t = Target(
target_id=i,
current_velocity_fps=300.0,
current_heading_deg=45.0,
current_vertical_velocity_fps=10.0,
_pos_x_ft=10000.0 + i * 1000,
_pos_y_ft=20000.0 + i * 500,
_pos_z_ft=5000.0 + i * 100,
)
# Add some complex data to make deepcopy slower
t.trajectory = [f"waypoint_{j}" for j in range(10)]
t._path = [
(i, j, k, l)
for i, j, k, l in zip(range(100), range(100), range(100), range(100))
]
targets.append(t)
horizon_s = 0.2 # 200ms prediction horizon
iterations = 1000 # Simulate 1000 prediction cycles
print(f"\nTest configuration:")
print(f" Targets: {num_targets}")
print(f" Prediction horizon: {horizon_s}s")
print(f" Iterations: {iterations}")
print(f" Total predictions: {num_targets * iterations}")
# Warm-up
benchmark_deepcopy(targets[:2], horizon_s, 10)
benchmark_lightweight(targets[:2], horizon_s, 10)
# Benchmark OLD approach
print(f"\n{'OLD (deepcopy + update_state)':<40}", end="")
old_time = benchmark_deepcopy(targets, horizon_s, iterations)
print(f"{old_time*1000:>8.2f} ms")
# Benchmark NEW approach
print(f"{'NEW (PredictedTarget lightweight)':<40}", end="")
new_time = benchmark_lightweight(targets, horizon_s, iterations)
print(f"{new_time*1000:>8.2f} ms")
# Results
speedup = old_time / new_time
reduction_pct = ((old_time - new_time) / old_time) * 100
print(f"\n{'='*70}")
print(f"Speedup: {speedup:.1f}x faster")
print(f"Time reduction: {reduction_pct:.1f}%")
print(f"Time saved per cycle: {(old_time - new_time) / iterations * 1000:.3f} ms")
print(f"\nAt 20Hz simulation rate:")
print(f" OLD overhead: {old_time / iterations * 1000:.2f} ms/frame")
print(f" NEW overhead: {new_time / iterations * 1000:.2f} ms/frame")
print(
f" Saved per second: {(old_time - new_time) / iterations * 20 * 1000:.2f} ms"
)
print(f"{'='*70}")
if __name__ == "__main__":
main()

View File

@ -17,23 +17,23 @@ import os
import random import random
# Add project root to path # Add project root to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from target_simulator.core.models import Target from target_simulator.core.models import Target
class OldApproachSimulator: class OldApproachSimulator:
"""Simula l'approccio vecchio: delete tutto + insert tutto.""" """Simula l'approccio vecchio: delete tutto + insert tutto."""
def __init__(self, tree: ttk.Treeview): def __init__(self, tree: ttk.Treeview):
self.tree = tree self.tree = tree
def update_table(self, targets): def update_table(self, targets):
"""OLD: Distrugge e ricrea tutto.""" """OLD: Distrugge e ricrea tutto."""
# DELETE ALL # DELETE ALL
for item in self.tree.get_children(): for item in self.tree.get_children():
self.tree.delete(item) self.tree.delete(item)
# INSERT ALL # INSERT ALL
for target in targets: for target in targets:
values = ( values = (
@ -50,14 +50,14 @@ class OldApproachSimulator:
class NewApproachSimulator: class NewApproachSimulator:
"""Simula l'approccio nuovo: diff-based update.""" """Simula l'approccio nuovo: diff-based update."""
def __init__(self, tree: ttk.Treeview): def __init__(self, tree: ttk.Treeview):
self.tree = tree self.tree = tree
def update_table(self, targets): def update_table(self, targets):
"""NEW: Update solo le modifiche.""" """NEW: Update solo le modifiche."""
incoming_target_ids = {t.target_id for t in targets} incoming_target_ids = {t.target_id for t in targets}
# Get existing # Get existing
existing_items = {} existing_items = {}
for item_iid in self.tree.get_children(): for item_iid in self.tree.get_children():
@ -66,15 +66,15 @@ class NewApproachSimulator:
existing_items[target_id] = item_iid existing_items[target_id] = item_iid
except (IndexError, KeyError): except (IndexError, KeyError):
self.tree.delete(item_iid) self.tree.delete(item_iid)
existing_target_ids = set(existing_items.keys()) existing_target_ids = set(existing_items.keys())
# 1. REMOVE only missing targets # 1. REMOVE only missing targets
targets_to_remove = existing_target_ids - incoming_target_ids targets_to_remove = existing_target_ids - incoming_target_ids
for target_id in targets_to_remove: for target_id in targets_to_remove:
item_iid = existing_items[target_id] item_iid = existing_items[target_id]
self.tree.delete(item_iid) self.tree.delete(item_iid)
# 2. UPDATE existing or INSERT new # 2. UPDATE existing or INSERT new
for target in targets: for target in targets:
values = ( values = (
@ -86,7 +86,7 @@ class NewApproachSimulator:
f"{target.current_velocity_fps:.1f}", f"{target.current_velocity_fps:.1f}",
f"{target.current_vertical_velocity_fps:+.1f}", f"{target.current_vertical_velocity_fps:+.1f}",
) )
if target.target_id in existing_items: if target.target_id in existing_items:
# UPDATE # UPDATE
item_iid = existing_items[target.target_id] item_iid = existing_items[target.target_id]
@ -115,14 +115,14 @@ def benchmark_approach(approach_name, simulator, targets_list, iterations=100):
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(f"Benchmark: {approach_name}") print(f"Benchmark: {approach_name}")
print(f"{'='*60}") print(f"{'='*60}")
times = [] times = []
operations = [] operations = []
for i in range(iterations): for i in range(iterations):
# Simula piccole variazioni nei target (il caso reale più comune) # Simula piccole variazioni nei target (il caso reale più comune)
targets = targets_list.copy() targets = targets_list.copy()
# 80% delle volte: stessi target, valori leggermente diversi # 80% delle volte: stessi target, valori leggermente diversi
# 10% delle volte: aggiungi un target # 10% delle volte: aggiungi un target
# 10% delle volte: rimuovi un target # 10% delle volte: rimuovi un target
@ -136,120 +136,121 @@ def benchmark_approach(approach_name, simulator, targets_list, iterations=100):
op = "ADD" op = "ADD"
else: else:
op = "UPDATE" op = "UPDATE"
operations.append(op) operations.append(op)
# Benchmark # Benchmark
start = time.perf_counter() start = time.perf_counter()
simulator.update_table(targets) simulator.update_table(targets)
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
times.append(elapsed * 1000) # Convert to ms times.append(elapsed * 1000) # Convert to ms
# Allow Tkinter to process # Allow Tkinter to process
simulator.tree.update_idletasks() simulator.tree.update_idletasks()
# Statistics # Statistics
avg_time = sum(times) / len(times) avg_time = sum(times) / len(times)
min_time = min(times) min_time = min(times)
max_time = max(times) max_time = max(times)
print(f"Iterations: {iterations}") print(f"Iterations: {iterations}")
print(f"Average time: {avg_time:.3f} ms") print(f"Average time: {avg_time:.3f} ms")
print(f"Min time: {min_time:.3f} ms") print(f"Min time: {min_time:.3f} ms")
print(f"Max time: {max_time:.3f} ms") print(f"Max time: {max_time:.3f} ms")
print(f"Total time: {sum(times):.1f} ms") print(f"Total time: {sum(times):.1f} ms")
# Operation breakdown # Operation breakdown
add_count = operations.count("ADD") add_count = operations.count("ADD")
remove_count = operations.count("REMOVE") remove_count = operations.count("REMOVE")
update_count = operations.count("UPDATE") update_count = operations.count("UPDATE")
print(f"\nOperations: {add_count} adds, {remove_count} removes, {update_count} updates") print(
f"\nOperations: {add_count} adds, {remove_count} removes, {update_count} updates"
return { )
"avg": avg_time,
"min": min_time, return {"avg": avg_time, "min": min_time, "max": max_time, "total": sum(times)}
"max": max_time,
"total": sum(times)
}
def run_comparison_test(): def run_comparison_test():
"""Esegue test comparativo tra vecchio e nuovo approccio.""" """Esegue test comparativo tra vecchio e nuovo approccio."""
print("="*60) print("=" * 60)
print("VIRTUALIZZAZIONE TABELLA TARGET - BENCHMARK") print("VIRTUALIZZAZIONE TABELLA TARGET - BENCHMARK")
print("="*60) print("=" * 60)
root = tk.Tk() root = tk.Tk()
root.title("Table Virtualization Test") root.title("Table Virtualization Test")
root.geometry("1000x600") root.geometry("1000x600")
# Create two side-by-side frames # Create two side-by-side frames
left_frame = ttk.LabelFrame(root, text="OLD APPROACH (Delete All + Insert All)") left_frame = ttk.LabelFrame(root, text="OLD APPROACH (Delete All + Insert All)")
left_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) left_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
right_frame = ttk.LabelFrame(root, text="NEW APPROACH (Diff-based Update)") right_frame = ttk.LabelFrame(root, text="NEW APPROACH (Diff-based Update)")
right_frame.grid(row=0, column=1, sticky="nsew", padx=5, pady=5) right_frame.grid(row=0, column=1, sticky="nsew", padx=5, pady=5)
root.grid_columnconfigure(0, weight=1) root.grid_columnconfigure(0, weight=1)
root.grid_columnconfigure(1, weight=1) root.grid_columnconfigure(1, weight=1)
root.grid_rowconfigure(0, weight=1) root.grid_rowconfigure(0, weight=1)
# Create trees # Create trees
columns = ("id", "lat", "lon", "alt", "hdg", "speed", "vspeed") columns = ("id", "lat", "lon", "alt", "hdg", "speed", "vspeed")
old_tree = ttk.Treeview(left_frame, columns=columns, show="headings") old_tree = ttk.Treeview(left_frame, columns=columns, show="headings")
for col in columns: for col in columns:
old_tree.heading(col, text=col.upper()) old_tree.heading(col, text=col.upper())
old_tree.column(col, width=80) old_tree.column(col, width=80)
old_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) old_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
new_tree = ttk.Treeview(right_frame, columns=columns, show="headings") new_tree = ttk.Treeview(right_frame, columns=columns, show="headings")
for col in columns: for col in columns:
new_tree.heading(col, text=col.upper()) new_tree.heading(col, text=col.upper())
new_tree.column(col, width=80) new_tree.column(col, width=80)
new_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) new_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# Results frame # Results frame
results_frame = ttk.Frame(root) results_frame = ttk.Frame(root)
results_frame.grid(row=1, column=0, columnspan=2, sticky="ew", padx=5, pady=5) results_frame.grid(row=1, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
results_text = tk.Text(results_frame, height=8, wrap=tk.WORD) results_text = tk.Text(results_frame, height=8, wrap=tk.WORD)
results_text.pack(fill=tk.BOTH, expand=True) results_text.pack(fill=tk.BOTH, expand=True)
# Test function # Test function
def run_test(): def run_test():
results_text.delete("1.0", tk.END) results_text.delete("1.0", tk.END)
results_text.insert(tk.END, "Running benchmark...\n\n") results_text.insert(tk.END, "Running benchmark...\n\n")
results_text.update() results_text.update()
# Create test data # Create test data
target_counts = [10, 20, 32] # Test with realistic counts target_counts = [10, 20, 32] # Test with realistic counts
iterations = 50 iterations = 50
for count in target_counts: for count in target_counts:
targets = create_fake_targets(count) targets = create_fake_targets(count)
results_text.insert(tk.END, f"\n{'='*60}\n") results_text.insert(tk.END, f"\n{'='*60}\n")
results_text.insert(tk.END, f"Test with {count} targets ({iterations} iterations)\n") results_text.insert(
tk.END, f"Test with {count} targets ({iterations} iterations)\n"
)
results_text.insert(tk.END, f"{'='*60}\n\n") results_text.insert(tk.END, f"{'='*60}\n\n")
results_text.update() results_text.update()
# Test old approach # Test old approach
old_sim = OldApproachSimulator(old_tree) old_sim = OldApproachSimulator(old_tree)
old_results = benchmark_approach( old_results = benchmark_approach(
f"OLD ({count} targets)", old_sim, targets, iterations f"OLD ({count} targets)", old_sim, targets, iterations
) )
# Test new approach # Test new approach
new_sim = NewApproachSimulator(new_tree) new_sim = NewApproachSimulator(new_tree)
new_results = benchmark_approach( new_results = benchmark_approach(
f"NEW ({count} targets)", new_sim, targets, iterations f"NEW ({count} targets)", new_sim, targets, iterations
) )
# Calculate improvement # Calculate improvement
improvement = ((old_results["avg"] - new_results["avg"]) / old_results["avg"]) * 100 improvement = (
(old_results["avg"] - new_results["avg"]) / old_results["avg"]
) * 100
speedup = old_results["avg"] / new_results["avg"] speedup = old_results["avg"] / new_results["avg"]
summary = f"\n{'='*60}\n" summary = f"\n{'='*60}\n"
summary += f"RESULTS for {count} targets:\n" summary += f"RESULTS for {count} targets:\n"
summary += f"{'='*60}\n" summary += f"{'='*60}\n"
@ -258,36 +259,44 @@ def run_comparison_test():
summary += f"Improvement: {improvement:.1f}% faster\n" summary += f"Improvement: {improvement:.1f}% faster\n"
summary += f"Speedup: {speedup:.2f}x\n" summary += f"Speedup: {speedup:.2f}x\n"
summary += f"Time saved per update: {old_results['avg'] - new_results['avg']:.3f} ms\n" summary += f"Time saved per update: {old_results['avg'] - new_results['avg']:.3f} ms\n"
# Calculate time saved over 1 minute at 25 FPS # Calculate time saved over 1 minute at 25 FPS
updates_per_minute = 25 * 60 # 1500 updates updates_per_minute = 25 * 60 # 1500 updates
time_saved_per_minute = (old_results['avg'] - new_results['avg']) * updates_per_minute / 1000 time_saved_per_minute = (
summary += f"Time saved per minute (25 FPS): {time_saved_per_minute:.2f} seconds\n" (old_results["avg"] - new_results["avg"]) * updates_per_minute / 1000
)
summary += (
f"Time saved per minute (25 FPS): {time_saved_per_minute:.2f} seconds\n"
)
results_text.insert(tk.END, summary) results_text.insert(tk.END, summary)
results_text.insert(tk.END, "\n") results_text.insert(tk.END, "\n")
results_text.see(tk.END) results_text.see(tk.END)
results_text.update() results_text.update()
results_text.insert(tk.END, "\n✅ BENCHMARK COMPLETE\n") results_text.insert(tk.END, "\n✅ BENCHMARK COMPLETE\n")
results_text.insert(tk.END, "\nKey Findings:\n") results_text.insert(tk.END, "\nKey Findings:\n")
results_text.insert(tk.END, "- Diff-based approach is 50-70% faster\n") results_text.insert(tk.END, "- Diff-based approach is 50-70% faster\n")
results_text.insert(tk.END, "- Improvement scales with target count\n") results_text.insert(tk.END, "- Improvement scales with target count\n")
results_text.insert(tk.END, "- At 25 FPS, saves 5-15 seconds per minute!\n") results_text.insert(tk.END, "- At 25 FPS, saves 5-15 seconds per minute!\n")
# Control buttons # Control buttons
control_frame = ttk.Frame(root) control_frame = ttk.Frame(root)
control_frame.grid(row=2, column=0, columnspan=2, pady=5) control_frame.grid(row=2, column=0, columnspan=2, pady=5)
ttk.Button(control_frame, text="Run Benchmark", command=run_test).pack(side=tk.LEFT, padx=5) ttk.Button(control_frame, text="Run Benchmark", command=run_test).pack(
ttk.Button(control_frame, text="Close", command=root.destroy).pack(side=tk.LEFT, padx=5) side=tk.LEFT, padx=5
)
ttk.Button(control_frame, text="Close", command=root.destroy).pack(
side=tk.LEFT, padx=5
)
results_text.insert(tk.END, "Click 'Run Benchmark' to start the test.\n\n") results_text.insert(tk.END, "Click 'Run Benchmark' to start the test.\n\n")
results_text.insert(tk.END, "This will compare OLD vs NEW approach with:\n") results_text.insert(tk.END, "This will compare OLD vs NEW approach with:\n")
results_text.insert(tk.END, "- 10, 20, and 32 targets\n") results_text.insert(tk.END, "- 10, 20, and 32 targets\n")
results_text.insert(tk.END, "- 50 iterations each\n") results_text.insert(tk.END, "- 50 iterations each\n")
results_text.insert(tk.END, "- Mix of add/remove/update operations\n") results_text.insert(tk.END, "- Mix of add/remove/update operations\n")
root.mainloop() root.mainloop()

View File

@ -0,0 +1,135 @@
"""Test performance comparison: Lock-based vs Lock-free TID counter."""
import threading
import time
import itertools
class OldTIDCounter:
"""OLD approach: Lock-based counter."""
def __init__(self):
self._tid_counter = 0
self._send_lock = threading.Lock()
def get_next_tid(self):
with self._send_lock:
self._tid_counter = (self._tid_counter + 1) % 256
return self._tid_counter
class NewTIDCounter:
"""NEW approach: Lock-free counter using itertools.count."""
def __init__(self):
self._tid_counter = itertools.count(start=0, step=1)
def get_next_tid(self):
# GIL guarantees atomicity of next() on itertools.count
return next(self._tid_counter) % 256
def benchmark_counter(counter, num_operations: int, num_threads: int = 1):
"""Benchmark counter with optional multi-threading."""
def worker(operations_per_thread):
for _ in range(operations_per_thread):
counter.get_next_tid()
operations_per_thread = num_operations // num_threads
start = time.perf_counter()
if num_threads == 1:
worker(num_operations)
else:
threads = []
for _ in range(num_threads):
t = threading.Thread(target=worker, args=(operations_per_thread,))
threads.append(t)
t.start()
for t in threads:
t.join()
elapsed = time.perf_counter() - start
return elapsed
def main():
print("=" * 70)
print("TID Counter Performance Comparison: Lock-based vs Lock-free")
print("=" * 70)
num_operations = 100_000
# Single-threaded test
print(f"\n{'Test: SINGLE-THREADED':<40}")
print(f"Operations: {num_operations:,}")
print("-" * 70)
old_counter = OldTIDCounter()
old_time = benchmark_counter(old_counter, num_operations, num_threads=1)
print(f"{'OLD (Lock-based)':<40} {old_time*1000:>8.2f} ms")
new_counter = NewTIDCounter()
new_time = benchmark_counter(new_counter, num_operations, num_threads=1)
print(f"{'NEW (Lock-free itertools.count)':<40} {new_time*1000:>8.2f} ms")
speedup = old_time / new_time
print(f"\n{'Speedup:':<40} {speedup:.2f}x faster")
print(
f"{'Time per operation (OLD):':<40} {old_time/num_operations*1_000_000:.3f} µs"
)
print(
f"{'Time per operation (NEW):':<40} {new_time/num_operations*1_000_000:.3f} µs"
)
# Multi-threaded test (simulating contention)
print(f"\n{'='*70}")
print(f"{'Test: MULTI-THREADED (4 threads)':<40}")
print(f"Operations: {num_operations:,}")
print("-" * 70)
old_counter = OldTIDCounter()
old_time_mt = benchmark_counter(old_counter, num_operations, num_threads=4)
print(f"{'OLD (Lock-based with contention)':<40} {old_time_mt*1000:>8.2f} ms")
new_counter = NewTIDCounter()
new_time_mt = benchmark_counter(new_counter, num_operations, num_threads=4)
print(f"{'NEW (Lock-free itertools.count)':<40} {new_time_mt*1000:>8.2f} ms")
speedup_mt = old_time_mt / new_time_mt
print(f"\n{'Speedup:':<40} {speedup_mt:.2f}x faster")
print(f"{'Lock contention overhead:':<40} {(old_time_mt/old_time - 1)*100:.1f}%")
# Real-world simulation
print(f"\n{'='*70}")
print("Real-world impact at 20Hz with 32 targets:")
print("-" * 70)
# JSON protocol: 1 TID per frame = 20 ops/sec
json_ops_per_sec = 20
json_overhead_old = (old_time / num_operations) * json_ops_per_sec * 1000
json_overhead_new = (new_time / num_operations) * json_ops_per_sec * 1000
print(f"JSON protocol (1 packet/frame @ 20Hz):")
print(f" OLD overhead: {json_overhead_old:.3f} ms/sec")
print(f" NEW overhead: {json_overhead_new:.3f} ms/sec")
print(f" Saved: {json_overhead_old - json_overhead_new:.3f} ms/sec")
# Legacy protocol: 32 TID per frame = 640 ops/sec
legacy_ops_per_sec = 32 * 20
legacy_overhead_old = (old_time / num_operations) * legacy_ops_per_sec * 1000
legacy_overhead_new = (new_time / num_operations) * legacy_ops_per_sec * 1000
print(f"\nLegacy protocol (32 packets/frame @ 20Hz):")
print(f" OLD overhead: {legacy_overhead_old:.3f} ms/sec")
print(f" NEW overhead: {legacy_overhead_new:.3f} ms/sec")
print(f" Saved: {legacy_overhead_old - legacy_overhead_new:.3f} ms/sec")
print(f"{'='*70}")
if __name__ == "__main__":
main()