sistemato salvataggio dati simulazione, e meccanizzazione processi di selezione dello scenario
This commit is contained in:
parent
1a223215c4
commit
8a4f621f0c
@ -3,7 +3,7 @@
|
||||
"scan_limit": 60,
|
||||
"max_range": 100,
|
||||
"geometry": "1599x1089+587+179",
|
||||
"last_selected_scenario": "corto",
|
||||
"last_selected_scenario": "scenario1",
|
||||
"connection": {
|
||||
"target": {
|
||||
"type": "sfp",
|
||||
|
||||
@ -17,6 +17,7 @@ DEFAULT_VERSION = "0.0.0+unknown"
|
||||
DEFAULT_COMMIT = "Unknown"
|
||||
DEFAULT_BRANCH = "Unknown"
|
||||
|
||||
|
||||
# --- Helper Function ---
|
||||
def get_version_string(format_string=None):
|
||||
"""
|
||||
@ -44,28 +45,38 @@ def get_version_string(format_string=None):
|
||||
|
||||
replacements = {}
|
||||
try:
|
||||
replacements['version'] = __version__ if __version__ else DEFAULT_VERSION
|
||||
replacements['commit'] = GIT_COMMIT_HASH if GIT_COMMIT_HASH else DEFAULT_COMMIT
|
||||
replacements['commit_short'] = GIT_COMMIT_HASH[:7] if GIT_COMMIT_HASH and len(GIT_COMMIT_HASH) >= 7 else DEFAULT_COMMIT
|
||||
replacements['branch'] = GIT_BRANCH if GIT_BRANCH else DEFAULT_BRANCH
|
||||
replacements['timestamp'] = BUILD_TIMESTAMP if BUILD_TIMESTAMP else "Unknown"
|
||||
replacements['timestamp_short'] = BUILD_TIMESTAMP.split('T')[0] if BUILD_TIMESTAMP and 'T' in BUILD_TIMESTAMP else "Unknown"
|
||||
replacements['is_git'] = "Git" if IS_GIT_REPO else "Unknown"
|
||||
replacements['dirty'] = "-dirty" if __version__ and __version__.endswith('-dirty') else ""
|
||||
replacements["version"] = __version__ if __version__ else DEFAULT_VERSION
|
||||
replacements["commit"] = GIT_COMMIT_HASH if GIT_COMMIT_HASH else DEFAULT_COMMIT
|
||||
replacements["commit_short"] = (
|
||||
GIT_COMMIT_HASH[:7]
|
||||
if GIT_COMMIT_HASH and len(GIT_COMMIT_HASH) >= 7
|
||||
else DEFAULT_COMMIT
|
||||
)
|
||||
replacements["branch"] = GIT_BRANCH if GIT_BRANCH else DEFAULT_BRANCH
|
||||
replacements["timestamp"] = BUILD_TIMESTAMP if BUILD_TIMESTAMP else "Unknown"
|
||||
replacements["timestamp_short"] = (
|
||||
BUILD_TIMESTAMP.split("T")[0]
|
||||
if BUILD_TIMESTAMP and "T" in BUILD_TIMESTAMP
|
||||
else "Unknown"
|
||||
)
|
||||
replacements["is_git"] = "Git" if IS_GIT_REPO else "Unknown"
|
||||
replacements["dirty"] = (
|
||||
"-dirty" if __version__ and __version__.endswith("-dirty") else ""
|
||||
)
|
||||
|
||||
tag = DEFAULT_VERSION
|
||||
if __version__ and IS_GIT_REPO:
|
||||
match = re.match(r'^(v?([0-9]+(?:\.[0-9]+)*))', __version__)
|
||||
match = re.match(r"^(v?([0-9]+(?:\.[0-9]+)*))", __version__)
|
||||
if match:
|
||||
tag = match.group(1)
|
||||
replacements['tag'] = tag
|
||||
replacements["tag"] = tag
|
||||
|
||||
output_string = format_string
|
||||
for placeholder, value in replacements.items():
|
||||
pattern = re.compile(r'{{\s*' + re.escape(placeholder) + r'\s*}}')
|
||||
pattern = re.compile(r"{{\s*" + re.escape(placeholder) + r"\s*}}")
|
||||
output_string = pattern.sub(str(value), output_string)
|
||||
|
||||
if re.search(r'{\s*\w+\s*}', output_string):
|
||||
if re.search(r"{\s*\w+\s*}", output_string):
|
||||
pass # Or log a warning: print(f"Warning: Unreplaced placeholders found: {output_string}")
|
||||
|
||||
return output_string
|
||||
|
||||
@ -16,6 +16,7 @@ class SimulationArchive:
|
||||
"""
|
||||
Gestisce la raccolta dei dati per una singola esecuzione di simulazione e la salva su file.
|
||||
"""
|
||||
|
||||
ARCHIVE_FOLDER = "archive_simulations"
|
||||
|
||||
def __init__(self, scenario: Scenario):
|
||||
@ -41,7 +42,9 @@ class SimulationArchive:
|
||||
except OSError as e:
|
||||
print(f"Errore nella creazione della directory di archivio: {e}")
|
||||
|
||||
def add_simulated_state(self, target_id: int, timestamp: float, state: Tuple[float, ...]):
|
||||
def add_simulated_state(
|
||||
self, target_id: int, timestamp: float, state: Tuple[float, ...]
|
||||
):
|
||||
"""Aggiunge uno stato simulato all'archivio."""
|
||||
if target_id not in self.recorded_data:
|
||||
self.recorded_data[target_id] = {"simulated": [], "real": []}
|
||||
@ -49,7 +52,9 @@ class SimulationArchive:
|
||||
full_state: RecordedState = (timestamp, state[0], state[1], state[2])
|
||||
self.recorded_data[target_id]["simulated"].append(full_state)
|
||||
|
||||
def add_real_state(self, target_id: int, timestamp: float, state: Tuple[float, ...]):
|
||||
def add_real_state(
|
||||
self, target_id: int, timestamp: float, state: Tuple[float, ...]
|
||||
):
|
||||
"""Aggiunge uno stato reale (dal server) all'archivio."""
|
||||
if target_id not in self.recorded_data:
|
||||
self.recorded_data[target_id] = {"simulated": [], "real": []}
|
||||
@ -78,12 +83,14 @@ class SimulationArchive:
|
||||
}
|
||||
|
||||
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_scenario_name = "".join(c for c in self.scenario_name if c.isalnum() or c in (' ', '_')).rstrip()
|
||||
safe_scenario_name = "".join(
|
||||
c for c in self.scenario_name if c.isalnum() or c in (" ", "_")
|
||||
).rstrip()
|
||||
filename = f"{ts_str}_{safe_scenario_name}.json"
|
||||
filepath = os.path.join(self.ARCHIVE_FOLDER, filename)
|
||||
|
||||
try:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(archive_content, f, indent=4)
|
||||
print(f"Archivio di simulazione salvato in: {filepath}")
|
||||
return filepath
|
||||
|
||||
@ -395,3 +395,22 @@ class SimulationStateHub:
|
||||
except Exception:
|
||||
# Silently ignore errors (e.g., invalid target_id type)
|
||||
pass
|
||||
|
||||
def clear_simulated_data(self, target_id: Optional[int] = None):
|
||||
"""
|
||||
Clears simulated data history for a specific target or for all targets
|
||||
if target_id is None. This preserves any real data so analysis of
|
||||
real trajectories is not affected when replacing simulations.
|
||||
"""
|
||||
with self._lock:
|
||||
try:
|
||||
if target_id is None:
|
||||
for tid, info in self._target_data.items():
|
||||
info.get("simulated", collections.deque()).clear()
|
||||
else:
|
||||
tid = int(target_id)
|
||||
if tid in self._target_data:
|
||||
self._target_data[tid]["simulated"].clear()
|
||||
except Exception:
|
||||
# Silently ignore errors to preserve hub stability
|
||||
pass
|
||||
|
||||
@ -51,9 +51,7 @@ class SFPCommunicator(CommunicatorInterface):
|
||||
)
|
||||
|
||||
# Unified payload router
|
||||
self.payload_router = DebugPayloadRouter(
|
||||
simulation_hub=simulation_hub
|
||||
)
|
||||
self.payload_router = DebugPayloadRouter(simulation_hub=simulation_hub)
|
||||
|
||||
def _save_json_payload_to_temp(self, content: str, prefix: str):
|
||||
"""Saves the JSON payload to a timestamped file in the Temp folder if debug is enabled."""
|
||||
|
||||
@ -27,12 +27,20 @@ class SimulationEngine(threading.Thread):
|
||||
self,
|
||||
communicator: Optional[CommunicatorInterface],
|
||||
simulation_hub: Optional[SimulationStateHub] = None,
|
||||
archive: Optional[str] = None
|
||||
archive: Optional[str] = None,
|
||||
):
|
||||
super().__init__(daemon=True, name="SimulationEngineThread")
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
self.communicator = communicator
|
||||
# Backwards-compat: older callers passed an update_queue as the
|
||||
# second positional argument. Detect a Queue and treat it as the
|
||||
# update queue while leaving simulation_hub unset.
|
||||
self.update_queue: Optional[Queue] = None
|
||||
if isinstance(simulation_hub, Queue):
|
||||
self.update_queue = simulation_hub
|
||||
self.simulation_hub = None
|
||||
else:
|
||||
self.simulation_hub = simulation_hub # Hub for data analysis
|
||||
self.archive = archive # Archive path if needed
|
||||
self.time_multiplier = 1.0
|
||||
|
||||
@ -49,10 +49,13 @@ class AnalysisWindow(tk.Toplevel):
|
||||
|
||||
def _load_data_and_setup(self, filepath: str):
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
archive_data = json.load(f)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Errore di Caricamento", f"Impossibile caricare il file di archivio.\n{e}")
|
||||
messagebox.showerror(
|
||||
"Errore di Caricamento",
|
||||
f"Impossibile caricare il file di archivio.\n{e}",
|
||||
)
|
||||
self.destroy()
|
||||
return
|
||||
|
||||
@ -140,7 +143,9 @@ class AnalysisWindow(tk.Toplevel):
|
||||
self.stats_tree.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# Right side: explanatory legend box (compact)
|
||||
legend_title = ttk.Label(right, text="How to read these results:", font=(None, 9, "bold"))
|
||||
legend_title = ttk.Label(
|
||||
right, text="How to read these results:", font=(None, 9, "bold")
|
||||
)
|
||||
legend_title.pack(anchor=tk.NW, padx=(6, 6), pady=(4, 2))
|
||||
|
||||
legend_text = (
|
||||
@ -149,9 +154,13 @@ class AnalysisWindow(tk.Toplevel):
|
||||
"> 0 => real is ahead of simulated.\n< 0 => real lags simulated.\n\nUnits: feet"
|
||||
)
|
||||
try:
|
||||
ttk.Label(right, text=legend_text, foreground="gray", justify=tk.LEFT, wraplength=260).pack(
|
||||
anchor=tk.NW, padx=(6, 6)
|
||||
)
|
||||
ttk.Label(
|
||||
right,
|
||||
text=legend_text,
|
||||
foreground="gray",
|
||||
justify=tk.LEFT,
|
||||
wraplength=260,
|
||||
).pack(anchor=tk.NW, padx=(6, 6))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -181,7 +190,6 @@ class AnalysisWindow(tk.Toplevel):
|
||||
self.canvas.draw()
|
||||
self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
|
||||
def _update_target_selector(self):
|
||||
# Only update the combobox values when the hub reports target ids.
|
||||
# This prevents the selector from being emptied when the hub is cleared
|
||||
|
||||
@ -427,6 +427,7 @@ class MainView(tk.Tk):
|
||||
# idle tasks have run (so sizes are realistic) and then move the sash
|
||||
# so the Logs pane has a small fixed minimum height (~80px) or ~18%.
|
||||
try:
|
||||
|
||||
def _shrink_log_pane_once(event=None):
|
||||
# Run only once
|
||||
if getattr(self, "_log_pane_shrunk", False):
|
||||
@ -441,6 +442,18 @@ class MainView(tk.Tk):
|
||||
# Prefer the paned window's current height if available
|
||||
total_h = v_pane.winfo_height() or self.winfo_height() or 800
|
||||
|
||||
# If geometry isn't yet realized (very small), try again shortly
|
||||
# instead of forcing a potentially incorrect sash position that
|
||||
# could collapse the top pane. A small threshold avoids acting
|
||||
# on transient values produced during window manager setup.
|
||||
if total_h < 200:
|
||||
try:
|
||||
# Schedule a retry and do not mark as shrunk yet
|
||||
self.after(300, _shrink_log_pane_once)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# Determine desired log pane height (min 60px, or ~18% of window)
|
||||
desired_log_h = max(60, int(total_h * 0.18))
|
||||
|
||||
@ -460,13 +473,21 @@ class MainView(tk.Tk):
|
||||
# Apply sash position (index 0 for the only sash in vertical pane)
|
||||
try:
|
||||
v_pane.sashpos(0, desired_top_h)
|
||||
# Only mark successful shrink once sashpos applied without
|
||||
# raising an exception. Some platforms may not support
|
||||
# sashpos until fully realized, so if sashpos raises we
|
||||
# leave the flag unset and allow the Configure event to
|
||||
# retry.
|
||||
setattr(self, "_log_pane_shrunk", True)
|
||||
except Exception:
|
||||
# Some platforms may not support sashpos until fully realized;
|
||||
# ignore and rely on later Configure event.
|
||||
# ignore and rely on later Configure event to attempt again.
|
||||
try:
|
||||
# Retry shortly instead of marking as done.
|
||||
self.after(300, _shrink_log_pane_once)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Mark as done so we don't repeatedly adjust
|
||||
setattr(self, "_log_pane_shrunk", True)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -474,6 +495,7 @@ class MainView(tk.Tk):
|
||||
# slightly longer delay to allow theme/layout to stabilise (some
|
||||
# nested widgets can delay final geometry on certain platforms).
|
||||
self.after(500, _shrink_log_pane_once)
|
||||
|
||||
# Also bind to a single Configure event in case geometry wasn't ready
|
||||
def _on_config_once(ev):
|
||||
_shrink_log_pane_once()
|
||||
@ -487,7 +509,9 @@ class MainView(tk.Tk):
|
||||
pass
|
||||
|
||||
def _create_analysis_tab_widgets(self, parent):
|
||||
self.analysis_tree = ttk.Treeview(parent, columns=("datetime", "scenario", "duration"), show="headings")
|
||||
self.analysis_tree = ttk.Treeview(
|
||||
parent, columns=("datetime", "scenario", "duration"), show="headings"
|
||||
)
|
||||
self.analysis_tree.heading("datetime", text="Date/Time")
|
||||
self.analysis_tree.heading("scenario", text="Scenario Name")
|
||||
self.analysis_tree.heading("duration", text="Duration (s)")
|
||||
@ -498,8 +522,12 @@ class MainView(tk.Tk):
|
||||
|
||||
btn_frame = ttk.Frame(parent)
|
||||
btn_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
ttk.Button(btn_frame, text="Refresh List", command=self._refresh_analysis_list).pack(side=tk.LEFT)
|
||||
ttk.Button(btn_frame, text="Analyze Selected", command=self._on_analyze_run).pack(side=tk.RIGHT)
|
||||
ttk.Button(
|
||||
btn_frame, text="Refresh List", command=self._refresh_analysis_list
|
||||
).pack(side=tk.LEFT)
|
||||
ttk.Button(
|
||||
btn_frame, text="Analyze Selected", command=self._on_analyze_run
|
||||
).pack(side=tk.RIGHT)
|
||||
|
||||
# Popola la lista all'avvio
|
||||
self.after(100, self._refresh_analysis_list)
|
||||
@ -712,9 +740,7 @@ class MainView(tk.Tk):
|
||||
config_data = config.get("tftp", {})
|
||||
elif comm_type == "sfp":
|
||||
# --- MODIFICATION: Do not pass update_queue ---
|
||||
communicator = SFPCommunicator(
|
||||
simulation_hub=self.simulation_hub
|
||||
)
|
||||
communicator = SFPCommunicator(simulation_hub=self.simulation_hub)
|
||||
communicator.add_connection_state_callback(self._on_connection_state_change)
|
||||
config_data = config.get("sfp", {})
|
||||
if self.defer_sfp_connection:
|
||||
@ -839,21 +865,28 @@ class MainView(tk.Tk):
|
||||
use_json = False
|
||||
|
||||
if use_json:
|
||||
# Use the same Reset IDs JSON payload sequence as provided by the
|
||||
# SFP debug window: build one-or-more compact JSON payloads that
|
||||
# explicitly disable all target IDs and send them in order.
|
||||
# Send both the per-target zeroing payloads (if available) followed
|
||||
# by a minimal {'CMD':'reset'} line. The per-target payloads clear
|
||||
# active flags on individual IDs; the simple reset is a noop on
|
||||
# servers that don't implement it but is harmless when supported.
|
||||
try:
|
||||
json_payloads = command_builder.build_json_reset_ids()
|
||||
# Ensure all payloads are newline-terminated and then append
|
||||
# the simple reset as a final step so servers that process
|
||||
# the reset command can react accordingly.
|
||||
final_reset = '{"CMD":"reset"}\n'
|
||||
commands_to_send = [
|
||||
p if p.endswith("\n") else p + "\n" for p in json_payloads
|
||||
] + [final_reset]
|
||||
self.logger.info(
|
||||
"Using JSON Reset IDs payloads for radar reset (parts=%d).",
|
||||
"Using JSON Reset IDs payloads for radar reset (parts=%d + reset).",
|
||||
len(json_payloads),
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception(
|
||||
"Failed to build Reset IDs JSON payloads; falling back to simple JSON reset."
|
||||
)
|
||||
json_payloads = ['{"CMD":"reset"}\n']
|
||||
commands_to_send = json_payloads
|
||||
commands_to_send = ['{"CMD":"reset"}\n']
|
||||
else:
|
||||
# Legacy textual reset sequence (no leading $; newline-terminated strings)
|
||||
commands_to_send = [prep_command, reset_command + "\n"]
|
||||
@ -878,7 +911,9 @@ class MainView(tk.Tk):
|
||||
return False
|
||||
else:
|
||||
if not self.target_communicator.send_commands(commands_to_send):
|
||||
self.logger.error("Failed to send preparatory/reset commands to the radar.")
|
||||
self.logger.error(
|
||||
"Failed to send preparatory/reset commands to the radar."
|
||||
)
|
||||
messagebox.showerror(
|
||||
"Reset Error", "Failed to send reset command to the radar."
|
||||
)
|
||||
@ -914,7 +949,10 @@ class MainView(tk.Tk):
|
||||
self.logger.info("Simulation is already running.")
|
||||
return
|
||||
# Require explicit connection before starting live. Do NOT auto-connect.
|
||||
if not (self.target_communicator and getattr(self.target_communicator, "is_open", False)):
|
||||
if not (
|
||||
self.target_communicator
|
||||
and getattr(self.target_communicator, "is_open", False)
|
||||
):
|
||||
# Friendly English reminder to connect first
|
||||
messagebox.showwarning(
|
||||
"Not Connected",
|
||||
@ -1091,7 +1129,9 @@ class MainView(tk.Tk):
|
||||
)
|
||||
else:
|
||||
# As a fallback, log the info (no blocking GUI popup).
|
||||
self.logger.info("Live simulation completed (notice widget unavailable).")
|
||||
self.logger.info(
|
||||
"Live simulation completed (notice widget unavailable)."
|
||||
)
|
||||
except Exception:
|
||||
# Ensure we never raise from UI-notice handling
|
||||
try:
|
||||
@ -1123,7 +1163,9 @@ class MainView(tk.Tk):
|
||||
self._on_simulation_finished()
|
||||
try:
|
||||
self.sim_elapsed_time = self.total_sim_time
|
||||
self.sim_slider_var.set(1.0 if self.total_sim_time > 0 else 0.0)
|
||||
self.sim_slider_var.set(
|
||||
1.0 if self.total_sim_time > 0 else 0.0
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._update_simulation_progress_display()
|
||||
@ -1132,17 +1174,27 @@ class MainView(tk.Tk):
|
||||
if len(update) == 0:
|
||||
# Hub refresh notification (real data arrived).
|
||||
display_data = self._build_display_data_from_hub()
|
||||
self.ppi_widget.update_real_targets(display_data.get("real", []))
|
||||
self.ppi_widget.update_real_targets(
|
||||
display_data.get("real", [])
|
||||
)
|
||||
try:
|
||||
if (
|
||||
hasattr(self, "simulation_hub")
|
||||
and self.simulation_hub is not None
|
||||
and hasattr(self.ppi_widget, "update_antenna_azimuth")
|
||||
and hasattr(
|
||||
self.ppi_widget, "update_antenna_azimuth"
|
||||
)
|
||||
):
|
||||
if hasattr(self.simulation_hub, "get_antenna_azimuth"):
|
||||
az_deg, az_ts = self.simulation_hub.get_antenna_azimuth()
|
||||
if hasattr(
|
||||
self.simulation_hub, "get_antenna_azimuth"
|
||||
):
|
||||
az_deg, az_ts = (
|
||||
self.simulation_hub.get_antenna_azimuth()
|
||||
)
|
||||
else:
|
||||
az_deg, az_ts = self.simulation_hub.get_platform_azimuth()
|
||||
az_deg, az_ts = (
|
||||
self.simulation_hub.get_platform_azimuth()
|
||||
)
|
||||
|
||||
if az_deg is not None:
|
||||
self.ppi_widget.update_antenna_azimuth(
|
||||
@ -1161,7 +1213,10 @@ class MainView(tk.Tk):
|
||||
|
||||
# Update simulation progress bar
|
||||
try:
|
||||
if self.simulation_engine and self.simulation_engine.scenario:
|
||||
if (
|
||||
self.simulation_engine
|
||||
and self.simulation_engine.scenario
|
||||
):
|
||||
times = [
|
||||
getattr(t, "_sim_time_s", 0.0)
|
||||
for t in self.simulation_engine.scenario.get_all_targets()
|
||||
@ -1173,13 +1228,18 @@ class MainView(tk.Tk):
|
||||
):
|
||||
progress_frac = min(
|
||||
1.0,
|
||||
max(0.0, self.sim_elapsed_time / self.total_sim_time),
|
||||
max(
|
||||
0.0,
|
||||
self.sim_elapsed_time / self.total_sim_time,
|
||||
),
|
||||
)
|
||||
self.sim_slider_var.set(progress_frac)
|
||||
|
||||
self._update_simulation_progress_display()
|
||||
except Exception:
|
||||
self.logger.debug("Progress UI update failed", exc_info=True)
|
||||
self.logger.debug(
|
||||
"Progress UI update failed", exc_info=True
|
||||
)
|
||||
|
||||
except Empty:
|
||||
# Queue is empty, we can stop processing for this cycle.
|
||||
@ -1197,12 +1257,16 @@ class MainView(tk.Tk):
|
||||
|
||||
# Determine if analysis data exists. Ensure boolean type.
|
||||
has_data_to_analyze = (
|
||||
bool(self.simulation_hub.get_all_target_ids()) if self.simulation_hub else False
|
||||
bool(self.simulation_hub.get_all_target_ids())
|
||||
if self.simulation_hub
|
||||
else False
|
||||
)
|
||||
|
||||
# Enable Analysis only when simulation is NOT running and there is data
|
||||
# to analyze (i.e., after a completed run or after receiving real data).
|
||||
analysis_state = tk.NORMAL if (not is_running and has_data_to_analyze) else tk.DISABLED
|
||||
analysis_state = (
|
||||
tk.NORMAL if (not is_running and has_data_to_analyze) else tk.DISABLED
|
||||
)
|
||||
|
||||
state = tk.DISABLED if is_running else tk.NORMAL
|
||||
|
||||
@ -1386,6 +1450,24 @@ class MainView(tk.Tk):
|
||||
scenario_data = self.config_manager.get_scenario(scenario_name)
|
||||
if scenario_data:
|
||||
try:
|
||||
# Clear any previously simulated-only data so the new scenario's
|
||||
# simulated targets are shown cleanly. We intentionally preserve
|
||||
# 'real' target data if present (do not call hub.reset()).
|
||||
try:
|
||||
if hasattr(self, "simulation_hub") and self.simulation_hub:
|
||||
self.simulation_hub.clear_simulated_data()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also clear PPI trails and simulated target visual state before
|
||||
# loading the new scenario to avoid leaving stale markers.
|
||||
try:
|
||||
if hasattr(self, "ppi_widget") and self.ppi_widget:
|
||||
self.ppi_widget.clear_trails()
|
||||
self.ppi_widget.update_simulated_targets([])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.scenario = Scenario.from_dict(scenario_data)
|
||||
self.current_scenario_name = scenario_name
|
||||
# Update target list UI with loaded scenario's targets
|
||||
@ -1397,7 +1479,8 @@ class MainView(tk.Tk):
|
||||
# and planned paths before starting the simulation.
|
||||
try:
|
||||
connected = bool(
|
||||
self.target_communicator and getattr(self.target_communicator, "is_open", False)
|
||||
self.target_communicator
|
||||
and getattr(self.target_communicator, "is_open", False)
|
||||
)
|
||||
except Exception:
|
||||
connected = False
|
||||
@ -1469,7 +1552,9 @@ class MainView(tk.Tk):
|
||||
|
||||
# Update scenario list UI and select the new scenario
|
||||
try:
|
||||
self.scenario_controls.update_scenario_list(names, select_scenario=scenario_name)
|
||||
self.scenario_controls.update_scenario_list(
|
||||
names, select_scenario=scenario_name
|
||||
)
|
||||
except Exception:
|
||||
# Fallback for older UI: directly set combobox values
|
||||
try:
|
||||
@ -1840,7 +1925,9 @@ class MainView(tk.Tk):
|
||||
|
||||
# Update antenna azimuth
|
||||
try:
|
||||
if self.simulation_hub and hasattr(self.simulation_hub, "get_antenna_azimuth"):
|
||||
if self.simulation_hub and hasattr(
|
||||
self.simulation_hub, "get_antenna_azimuth"
|
||||
):
|
||||
az_deg, az_ts = self.simulation_hub.get_antenna_azimuth()
|
||||
if az_deg is not None:
|
||||
self.ppi_widget.update_antenna_azimuth(az_deg, timestamp=az_ts)
|
||||
@ -1880,29 +1967,42 @@ class MainView(tk.Tk):
|
||||
filepath = os.path.join(archive_folder, filename)
|
||||
try:
|
||||
# Leggiamo solo i metadati per non caricare tutto il file
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
metadata = data.get("metadata", {})
|
||||
# Usiamo il timestamp del nome del file per l'ordinamento
|
||||
dt_str = filename.split('_')[0]
|
||||
dt_str = filename.split("_")[0]
|
||||
run_info = {
|
||||
"datetime": datetime.strptime(dt_str, "%Y%m%d").strftime("%Y-%m-%d") + " " + filename.split('_')[1].replace(".json",""),
|
||||
"datetime": datetime.strptime(dt_str, "%Y%m%d").strftime(
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
+ " "
|
||||
+ filename.split("_")[1].replace(".json", ""),
|
||||
"scenario": metadata.get("scenario_name", "N/A"),
|
||||
"duration": f"{metadata.get('duration_seconds', 0):.1f}",
|
||||
"filepath": filepath
|
||||
"filepath": filepath,
|
||||
}
|
||||
runs.append(run_info)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Impossibile leggere l'archivio {filename}: {e}")
|
||||
self.logger.warning(
|
||||
f"Impossibile leggere l'archivio {filename}: {e}"
|
||||
)
|
||||
|
||||
# Ordina dal più recente al più vecchio
|
||||
for run in sorted(runs, key=lambda r: r['datetime'], reverse=True):
|
||||
self.analysis_tree.insert("", tk.END, values=(run['datetime'], run['scenario'], run['duration']), iid=run['filepath'])
|
||||
for run in sorted(runs, key=lambda r: r["datetime"], reverse=True):
|
||||
self.analysis_tree.insert(
|
||||
"",
|
||||
tk.END,
|
||||
values=(run["datetime"], run["scenario"], run["duration"]),
|
||||
iid=run["filepath"],
|
||||
)
|
||||
|
||||
def _on_analyze_run(self):
|
||||
selected_item = self.analysis_tree.focus()
|
||||
if not selected_item:
|
||||
messagebox.showinfo("Nessuna Selezione", "Seleziona una simulazione da analizzare.")
|
||||
messagebox.showinfo(
|
||||
"Nessuna Selezione", "Seleziona una simulazione da analizzare."
|
||||
)
|
||||
return
|
||||
|
||||
archive_filepath = selected_item # L'IID è il filepath
|
||||
|
||||
@ -71,9 +71,7 @@ class DebugPayloadRouter:
|
||||
ord("R"): self._handle_ris_status,
|
||||
ord("r"): self._handle_ris_status,
|
||||
}
|
||||
logger.info(
|
||||
f"{self._log_prefix} Initialized (Hub: {self._hub is not None})."
|
||||
)
|
||||
logger.info(f"{self._log_prefix} Initialized (Hub: {self._hub is not None}).")
|
||||
self._logger = logger
|
||||
|
||||
def set_archive(self, archive):
|
||||
@ -220,7 +218,7 @@ class DebugPayloadRouter:
|
||||
"Failed to propagate heading to hub", exc_info=True
|
||||
)
|
||||
|
||||
#if self._update_queue:
|
||||
# if self._update_queue:
|
||||
# try:
|
||||
# self._update_queue.put_nowait([])
|
||||
# except Full:
|
||||
@ -246,7 +244,7 @@ class DebugPayloadRouter:
|
||||
archive.add_real_state(
|
||||
target_id=target.target_id,
|
||||
timestamp=reception_timestamp,
|
||||
state=state_tuple
|
||||
state=state_tuple,
|
||||
)
|
||||
|
||||
# --- BROADCAST to all registered listeners ---
|
||||
|
||||
@ -327,16 +327,26 @@ class PPIDisplay(ttk.Frame):
|
||||
|
||||
# Draw active targets as before
|
||||
if active_targets:
|
||||
self._draw_target_visuals(active_targets, color, target_artists, label_artists)
|
||||
self._draw_target_visuals(
|
||||
active_targets, color, target_artists, label_artists
|
||||
)
|
||||
|
||||
# Draw inactive simulated targets with a yellow 'X' marker overlay
|
||||
if inactive_targets and category == "simulated":
|
||||
self._draw_inactive_markers(inactive_targets, color, target_artists, label_artists)
|
||||
self._draw_inactive_markers(
|
||||
inactive_targets, color, target_artists, label_artists
|
||||
)
|
||||
|
||||
if show_trail:
|
||||
self._draw_trails(trail_data, trail_color, trail_artists)
|
||||
|
||||
def _draw_inactive_markers(self, targets: List[Target], color: str, artist_list: List, label_artist_list: List):
|
||||
def _draw_inactive_markers(
|
||||
self,
|
||||
targets: List[Target],
|
||||
color: str,
|
||||
artist_list: List,
|
||||
label_artist_list: List,
|
||||
):
|
||||
"""Draw a small stationary marker for targets that are no longer simulated and
|
||||
overlay a yellow 'X' to indicate the target is not being updated by the simulator.
|
||||
"""
|
||||
@ -345,7 +355,9 @@ class PPIDisplay(ttk.Frame):
|
||||
r_nm = target.current_range_nm
|
||||
theta_rad_plot = np.deg2rad(target.current_azimuth_deg)
|
||||
# plot a subdued point
|
||||
(dot,) = self.ax.plot(theta_rad_plot, r_nm, "o", markersize=6, color=color, alpha=0.6)
|
||||
(dot,) = self.ax.plot(
|
||||
theta_rad_plot, r_nm, "o", markersize=6, color=color, alpha=0.6
|
||||
)
|
||||
artist_list.append(dot)
|
||||
|
||||
# overlay a yellow X at the same position
|
||||
@ -545,14 +557,16 @@ class PPIDisplay(ttk.Frame):
|
||||
waypoints = target.trajectory
|
||||
if not waypoints:
|
||||
continue
|
||||
path, _ = Target.generate_path_from_waypoints(waypoints, target.use_spline)
|
||||
path, _ = Target.generate_path_from_waypoints(
|
||||
waypoints, target.use_spline
|
||||
)
|
||||
if not path:
|
||||
continue
|
||||
|
||||
path_thetas, path_rs = [], []
|
||||
for point in path:
|
||||
x_ft, y_ft = point[1], point[2]
|
||||
r_ft = math.sqrt(x_ft ** 2 + y_ft ** 2)
|
||||
r_ft = math.sqrt(x_ft**2 + y_ft**2)
|
||||
az_rad_plot = math.atan2(x_ft, y_ft)
|
||||
path_rs.append(r_ft / NM_TO_FT)
|
||||
path_thetas.append(az_rad_plot)
|
||||
@ -572,13 +586,18 @@ class PPIDisplay(ttk.Frame):
|
||||
start_r = path_rs[0] if path_rs else None
|
||||
start_art = None
|
||||
if start_theta is not None:
|
||||
(start_art,) = self.ax.plot([start_theta], [start_r], "go", markersize=6)
|
||||
(start_art,) = self.ax.plot(
|
||||
[start_theta], [start_r], "go", markersize=6
|
||||
)
|
||||
|
||||
arts = [a for a in (line_art, start_art) if a is not None]
|
||||
if arts:
|
||||
self.preview_path_artists[target.target_id] = arts
|
||||
except Exception:
|
||||
logger.exception("Failed to draw preview for target %s", getattr(target, 'target_id', '?'))
|
||||
logger.exception(
|
||||
"Failed to draw preview for target %s",
|
||||
getattr(target, "target_id", "?"),
|
||||
)
|
||||
|
||||
if self.canvas:
|
||||
self.canvas.draw()
|
||||
|
||||
@ -62,7 +62,12 @@ class ConfigManager:
|
||||
)
|
||||
|
||||
self.filepath = os.path.join(application_path, filename)
|
||||
self.scenarios_filepath = os.path.join(application_path, scenarios_filename)
|
||||
# Persist scenarios next to the settings file so tests that override
|
||||
# `filepath` do not accidentally read/write the application's
|
||||
# scenarios.json in the repo root.
|
||||
self.scenarios_filepath = os.path.join(
|
||||
os.path.dirname(self.filepath), scenarios_filename
|
||||
)
|
||||
self._settings = self._load_or_initialize_settings()
|
||||
# Load scenarios from separate file if present, otherwise keep any scenarios
|
||||
# found inside settings.json (fallback).
|
||||
@ -93,6 +98,11 @@ class ConfigManager:
|
||||
# `_load_or_initialize_scenarios()`.
|
||||
|
||||
if not os.path.exists(self.filepath):
|
||||
# Ensure in-memory scenarios reflect the (missing) settings location
|
||||
try:
|
||||
self._scenarios = self._load_or_initialize_scenarios()
|
||||
except Exception:
|
||||
self._scenarios = {}
|
||||
return {"general": {}, "scenarios": {}}
|
||||
try:
|
||||
with open(self.filepath, "r", encoding="utf-8") as f:
|
||||
@ -102,8 +112,19 @@ class ConfigManager:
|
||||
return {"general": settings, "scenarios": {}}
|
||||
# Keep scenarios key if present; actual source of truth for scenarios
|
||||
# will be the separate scenarios file when available.
|
||||
# Refresh in-memory scenarios based on the possibly-updated
|
||||
# settings filepath so callers that override `filepath` and
|
||||
# re-run this loader get a consistent view.
|
||||
try:
|
||||
self._scenarios = self._load_or_initialize_scenarios()
|
||||
except Exception:
|
||||
self._scenarios = {}
|
||||
return settings
|
||||
except (json.JSONDecodeError, IOError):
|
||||
try:
|
||||
self._scenarios = self._load_or_initialize_scenarios()
|
||||
except Exception:
|
||||
self._scenarios = {}
|
||||
return {"general": {}, "scenarios": {}}
|
||||
|
||||
def _save_settings(self):
|
||||
@ -126,7 +147,35 @@ class ConfigManager:
|
||||
|
||||
Falls back to scenarios inside settings.json when scenarios.json is absent.
|
||||
"""
|
||||
# If scenarios file exists, load from it.
|
||||
# Prefer a scenarios file colocated with the settings file (useful
|
||||
# for tests which set `filepath` to a temp dir). Construct the local
|
||||
# scenarios path by combining the settings directory with the
|
||||
# scenarios filename.
|
||||
try:
|
||||
settings_dir = os.path.dirname(self.filepath) or "."
|
||||
local_scenarios_path = os.path.join(
|
||||
settings_dir, os.path.basename(self.scenarios_filepath)
|
||||
)
|
||||
if os.path.exists(local_scenarios_path):
|
||||
with open(local_scenarios_path, "r", encoding="utf-8") as f:
|
||||
scenarios = json.load(f)
|
||||
if isinstance(scenarios, dict):
|
||||
return scenarios
|
||||
except Exception:
|
||||
# If local scenarios file is unreadable, fall through to other
|
||||
# fallback mechanisms rather than raising.
|
||||
pass
|
||||
|
||||
# Only load the application-level scenarios file when the settings
|
||||
# filepath resides inside the application directory. Tests often
|
||||
# override `filepath` to a temporary location; in that case we must
|
||||
# avoid pulling in global scenarios from the repo.
|
||||
try:
|
||||
app_root = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
)
|
||||
settings_abs = os.path.abspath(self.filepath)
|
||||
if os.path.commonpath([app_root, settings_abs]) == app_root:
|
||||
if os.path.exists(self.scenarios_filepath):
|
||||
try:
|
||||
with open(self.scenarios_filepath, "r", encoding="utf-8") as f:
|
||||
@ -135,6 +184,9 @@ class ConfigManager:
|
||||
return scenarios
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {}
|
||||
except Exception:
|
||||
# If path computations fail, fall back silently to other options.
|
||||
pass
|
||||
|
||||
# Fallback: try to read scenarios stored inside settings.json
|
||||
try:
|
||||
@ -201,14 +253,18 @@ class ConfigManager:
|
||||
shutil.copy2(self.scenarios_filepath, prebackup)
|
||||
logger.debug("Created pre-write backup: %s", prebackup)
|
||||
except Exception:
|
||||
logger.debug("Pre-write backup failed, continuing", exc_info=True)
|
||||
logger.debug(
|
||||
"Pre-write backup failed, continuing", exc_info=True
|
||||
)
|
||||
except Exception:
|
||||
# Non-fatal
|
||||
pass
|
||||
|
||||
# Write atomically: write to a temp file then validate and replace the target.
|
||||
dirpath = os.path.dirname(self.scenarios_filepath) or "."
|
||||
tmp_path = os.path.join(dirpath, f".{os.path.basename(self.scenarios_filepath)}.tmp")
|
||||
tmp_path = os.path.join(
|
||||
dirpath, f".{os.path.basename(self.scenarios_filepath)}.tmp"
|
||||
)
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._scenarios, f, indent=4, cls=EnumEncoder)
|
||||
|
||||
@ -217,14 +273,18 @@ class ConfigManager:
|
||||
with open(tmp_path, "r", encoding="utf-8") as tf:
|
||||
validated = json.load(tf)
|
||||
if not isinstance(validated, dict):
|
||||
logger.error("Temporary scenarios file did not contain a JSON object; aborting write")
|
||||
logger.error(
|
||||
"Temporary scenarios file did not contain a JSON object; aborting write"
|
||||
)
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("Failed to validate temporary scenarios file: %s", e, exc_info=True)
|
||||
logger.error(
|
||||
"Failed to validate temporary scenarios file: %s", e, exc_info=True
|
||||
)
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except Exception:
|
||||
@ -246,7 +306,9 @@ class ConfigManager:
|
||||
|
||||
try:
|
||||
new_count = len(validated) if isinstance(validated, dict) else 0
|
||||
existing_count = len(existing) if isinstance(existing, dict) else 0
|
||||
existing_count = (
|
||||
len(existing) if isinstance(existing, dict) else 0
|
||||
)
|
||||
if (
|
||||
existing_count > 1
|
||||
and new_count <= 1
|
||||
@ -359,10 +421,18 @@ class ConfigManager:
|
||||
try:
|
||||
# Basic sanity check: expect a mapping for scenario data
|
||||
if not isinstance(data, dict):
|
||||
logger.error("Attempted to save scenario '%s' with non-dict data: %r", name, type(data))
|
||||
logger.error(
|
||||
"Attempted to save scenario '%s' with non-dict data: %r",
|
||||
name,
|
||||
type(data),
|
||||
)
|
||||
return
|
||||
self._scenarios[name] = data
|
||||
logger.info("Saving scenario '%s' (total scenarios after save: %d)", name, len(self._scenarios))
|
||||
logger.info(
|
||||
"Saving scenario '%s' (total scenarios after save: %d)",
|
||||
name,
|
||||
len(self._scenarios),
|
||||
)
|
||||
logger.debug("Scenario keys: %s", list(self._scenarios.keys()))
|
||||
self._save_scenarios()
|
||||
except Exception:
|
||||
|
||||
@ -11,6 +11,9 @@ def _new_cm_with_tmpfile(tmp_path, filename="settings.json"):
|
||||
"""Create a ConfigManager but point its filepath to a temp file and reload settings."""
|
||||
cm = ConfigManager(filename=filename)
|
||||
cm.filepath = str(tmp_path / filename)
|
||||
# Ensure scenarios are stored next to the temporary settings file so tests
|
||||
# do not accidentally write the application's scenarios.json in the repo.
|
||||
cm.scenarios_filepath = os.path.join(os.path.dirname(cm.filepath), "scenarios.json")
|
||||
cm._settings = cm._load_or_initialize_settings()
|
||||
return cm
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user