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,
|
"scan_limit": 60,
|
||||||
"max_range": 100,
|
"max_range": 100,
|
||||||
"geometry": "1599x1089+587+179",
|
"geometry": "1599x1089+587+179",
|
||||||
"last_selected_scenario": "corto",
|
"last_selected_scenario": "scenario1",
|
||||||
"connection": {
|
"connection": {
|
||||||
"target": {
|
"target": {
|
||||||
"type": "sfp",
|
"type": "sfp",
|
||||||
|
|||||||
@ -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,28 +45,38 @@ 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
|
||||||
|
|||||||
@ -16,6 +16,7 @@ class SimulationArchive:
|
|||||||
"""
|
"""
|
||||||
Gestisce la raccolta dei dati per una singola esecuzione di simulazione e la salva su file.
|
Gestisce la raccolta dei dati per una singola esecuzione di simulazione e la salva su file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ARCHIVE_FOLDER = "archive_simulations"
|
ARCHIVE_FOLDER = "archive_simulations"
|
||||||
|
|
||||||
def __init__(self, scenario: Scenario):
|
def __init__(self, scenario: Scenario):
|
||||||
@ -41,7 +42,9 @@ class SimulationArchive:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"Errore nella creazione della directory di archivio: {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."""
|
"""Aggiunge uno stato simulato all'archivio."""
|
||||||
if target_id not in self.recorded_data:
|
if target_id not in self.recorded_data:
|
||||||
self.recorded_data[target_id] = {"simulated": [], "real": []}
|
self.recorded_data[target_id] = {"simulated": [], "real": []}
|
||||||
@ -49,7 +52,9 @@ class SimulationArchive:
|
|||||||
full_state: RecordedState = (timestamp, state[0], state[1], state[2])
|
full_state: RecordedState = (timestamp, state[0], state[1], state[2])
|
||||||
self.recorded_data[target_id]["simulated"].append(full_state)
|
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."""
|
"""Aggiunge uno stato reale (dal server) all'archivio."""
|
||||||
if target_id not in self.recorded_data:
|
if target_id not in self.recorded_data:
|
||||||
self.recorded_data[target_id] = {"simulated": [], "real": []}
|
self.recorded_data[target_id] = {"simulated": [], "real": []}
|
||||||
@ -78,12 +83,14 @@ class SimulationArchive:
|
|||||||
}
|
}
|
||||||
|
|
||||||
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
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"
|
filename = f"{ts_str}_{safe_scenario_name}.json"
|
||||||
filepath = os.path.join(self.ARCHIVE_FOLDER, filename)
|
filepath = os.path.join(self.ARCHIVE_FOLDER, filename)
|
||||||
|
|
||||||
try:
|
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)
|
json.dump(archive_content, f, indent=4)
|
||||||
print(f"Archivio di simulazione salvato in: {filepath}")
|
print(f"Archivio di simulazione salvato in: {filepath}")
|
||||||
return filepath
|
return filepath
|
||||||
|
|||||||
@ -395,3 +395,22 @@ class SimulationStateHub:
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Silently ignore errors (e.g., invalid target_id type)
|
# Silently ignore errors (e.g., invalid target_id type)
|
||||||
pass
|
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
|
# Unified payload router
|
||||||
self.payload_router = DebugPayloadRouter(
|
self.payload_router = DebugPayloadRouter(simulation_hub=simulation_hub)
|
||||||
simulation_hub=simulation_hub
|
|
||||||
)
|
|
||||||
|
|
||||||
def _save_json_payload_to_temp(self, content: str, prefix: str):
|
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."""
|
"""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,
|
self,
|
||||||
communicator: Optional[CommunicatorInterface],
|
communicator: Optional[CommunicatorInterface],
|
||||||
simulation_hub: Optional[SimulationStateHub] = None,
|
simulation_hub: Optional[SimulationStateHub] = None,
|
||||||
archive: Optional[str] = None
|
archive: Optional[str] = None,
|
||||||
):
|
):
|
||||||
super().__init__(daemon=True, name="SimulationEngineThread")
|
super().__init__(daemon=True, name="SimulationEngineThread")
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
|
|
||||||
self.communicator = communicator
|
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.simulation_hub = simulation_hub # Hub for data analysis
|
||||||
self.archive = archive # Archive path if needed
|
self.archive = archive # Archive path if needed
|
||||||
self.time_multiplier = 1.0
|
self.time_multiplier = 1.0
|
||||||
|
|||||||
@ -49,10 +49,13 @@ class AnalysisWindow(tk.Toplevel):
|
|||||||
|
|
||||||
def _load_data_and_setup(self, filepath: str):
|
def _load_data_and_setup(self, filepath: str):
|
||||||
try:
|
try:
|
||||||
with open(filepath, 'r', encoding='utf-8') as f:
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
archive_data = json.load(f)
|
archive_data = json.load(f)
|
||||||
except Exception as e:
|
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()
|
self.destroy()
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -140,7 +143,9 @@ class AnalysisWindow(tk.Toplevel):
|
|||||||
self.stats_tree.pack(fill=tk.BOTH, expand=True)
|
self.stats_tree.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
# Right side: explanatory legend box (compact)
|
# 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_title.pack(anchor=tk.NW, padx=(6, 6), pady=(4, 2))
|
||||||
|
|
||||||
legend_text = (
|
legend_text = (
|
||||||
@ -149,9 +154,13 @@ class AnalysisWindow(tk.Toplevel):
|
|||||||
"> 0 => real is ahead of simulated.\n< 0 => real lags simulated.\n\nUnits: feet"
|
"> 0 => real is ahead of simulated.\n< 0 => real lags simulated.\n\nUnits: feet"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
ttk.Label(right, text=legend_text, foreground="gray", justify=tk.LEFT, wraplength=260).pack(
|
ttk.Label(
|
||||||
anchor=tk.NW, padx=(6, 6)
|
right,
|
||||||
)
|
text=legend_text,
|
||||||
|
foreground="gray",
|
||||||
|
justify=tk.LEFT,
|
||||||
|
wraplength=260,
|
||||||
|
).pack(anchor=tk.NW, padx=(6, 6))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -181,7 +190,6 @@ class AnalysisWindow(tk.Toplevel):
|
|||||||
self.canvas.draw()
|
self.canvas.draw()
|
||||||
self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
|
self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
|
||||||
def _update_target_selector(self):
|
def _update_target_selector(self):
|
||||||
# Only update the combobox values when the hub reports target ids.
|
# Only update the combobox values when the hub reports target ids.
|
||||||
# This prevents the selector from being emptied when the hub is cleared
|
# 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
|
# 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%.
|
# so the Logs pane has a small fixed minimum height (~80px) or ~18%.
|
||||||
try:
|
try:
|
||||||
|
|
||||||
def _shrink_log_pane_once(event=None):
|
def _shrink_log_pane_once(event=None):
|
||||||
# Run only once
|
# Run only once
|
||||||
if getattr(self, "_log_pane_shrunk", False):
|
if getattr(self, "_log_pane_shrunk", False):
|
||||||
@ -441,6 +442,18 @@ class MainView(tk.Tk):
|
|||||||
# Prefer the paned window's current height if available
|
# Prefer the paned window's current height if available
|
||||||
total_h = v_pane.winfo_height() or self.winfo_height() or 800
|
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)
|
# Determine desired log pane height (min 60px, or ~18% of window)
|
||||||
desired_log_h = max(60, int(total_h * 0.18))
|
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)
|
# Apply sash position (index 0 for the only sash in vertical pane)
|
||||||
try:
|
try:
|
||||||
v_pane.sashpos(0, desired_top_h)
|
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:
|
except Exception:
|
||||||
# Some platforms may not support sashpos until fully realized;
|
# 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
|
pass
|
||||||
|
return
|
||||||
# Mark as done so we don't repeatedly adjust
|
|
||||||
setattr(self, "_log_pane_shrunk", True)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -474,6 +495,7 @@ class MainView(tk.Tk):
|
|||||||
# slightly longer delay to allow theme/layout to stabilise (some
|
# slightly longer delay to allow theme/layout to stabilise (some
|
||||||
# nested widgets can delay final geometry on certain platforms).
|
# nested widgets can delay final geometry on certain platforms).
|
||||||
self.after(500, _shrink_log_pane_once)
|
self.after(500, _shrink_log_pane_once)
|
||||||
|
|
||||||
# Also bind to a single Configure event in case geometry wasn't ready
|
# Also bind to a single Configure event in case geometry wasn't ready
|
||||||
def _on_config_once(ev):
|
def _on_config_once(ev):
|
||||||
_shrink_log_pane_once()
|
_shrink_log_pane_once()
|
||||||
@ -487,7 +509,9 @@ class MainView(tk.Tk):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _create_analysis_tab_widgets(self, parent):
|
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("datetime", text="Date/Time")
|
||||||
self.analysis_tree.heading("scenario", text="Scenario Name")
|
self.analysis_tree.heading("scenario", text="Scenario Name")
|
||||||
self.analysis_tree.heading("duration", text="Duration (s)")
|
self.analysis_tree.heading("duration", text="Duration (s)")
|
||||||
@ -498,8 +522,12 @@ class MainView(tk.Tk):
|
|||||||
|
|
||||||
btn_frame = ttk.Frame(parent)
|
btn_frame = ttk.Frame(parent)
|
||||||
btn_frame.pack(fill=tk.X, padx=5, pady=5)
|
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(
|
||||||
ttk.Button(btn_frame, text="Analyze Selected", command=self._on_analyze_run).pack(side=tk.RIGHT)
|
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
|
# Popola la lista all'avvio
|
||||||
self.after(100, self._refresh_analysis_list)
|
self.after(100, self._refresh_analysis_list)
|
||||||
@ -712,9 +740,7 @@ class MainView(tk.Tk):
|
|||||||
config_data = config.get("tftp", {})
|
config_data = config.get("tftp", {})
|
||||||
elif comm_type == "sfp":
|
elif comm_type == "sfp":
|
||||||
# --- MODIFICATION: Do not pass update_queue ---
|
# --- MODIFICATION: Do not pass update_queue ---
|
||||||
communicator = SFPCommunicator(
|
communicator = SFPCommunicator(simulation_hub=self.simulation_hub)
|
||||||
simulation_hub=self.simulation_hub
|
|
||||||
)
|
|
||||||
communicator.add_connection_state_callback(self._on_connection_state_change)
|
communicator.add_connection_state_callback(self._on_connection_state_change)
|
||||||
config_data = config.get("sfp", {})
|
config_data = config.get("sfp", {})
|
||||||
if self.defer_sfp_connection:
|
if self.defer_sfp_connection:
|
||||||
@ -839,21 +865,28 @@ class MainView(tk.Tk):
|
|||||||
use_json = False
|
use_json = False
|
||||||
|
|
||||||
if use_json:
|
if use_json:
|
||||||
# Use the same Reset IDs JSON payload sequence as provided by the
|
# Send both the per-target zeroing payloads (if available) followed
|
||||||
# SFP debug window: build one-or-more compact JSON payloads that
|
# by a minimal {'CMD':'reset'} line. The per-target payloads clear
|
||||||
# explicitly disable all target IDs and send them in order.
|
# active flags on individual IDs; the simple reset is a noop on
|
||||||
|
# servers that don't implement it but is harmless when supported.
|
||||||
try:
|
try:
|
||||||
json_payloads = command_builder.build_json_reset_ids()
|
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(
|
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),
|
len(json_payloads),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception(
|
self.logger.exception(
|
||||||
"Failed to build Reset IDs JSON payloads; falling back to simple JSON reset."
|
"Failed to build Reset IDs JSON payloads; falling back to simple JSON reset."
|
||||||
)
|
)
|
||||||
json_payloads = ['{"CMD":"reset"}\n']
|
commands_to_send = ['{"CMD":"reset"}\n']
|
||||||
commands_to_send = json_payloads
|
|
||||||
else:
|
else:
|
||||||
# Legacy textual reset sequence (no leading $; newline-terminated strings)
|
# Legacy textual reset sequence (no leading $; newline-terminated strings)
|
||||||
commands_to_send = [prep_command, reset_command + "\n"]
|
commands_to_send = [prep_command, reset_command + "\n"]
|
||||||
@ -878,7 +911,9 @@ class MainView(tk.Tk):
|
|||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
if not self.target_communicator.send_commands(commands_to_send):
|
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(
|
messagebox.showerror(
|
||||||
"Reset Error", "Failed to send reset command to the radar."
|
"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.")
|
self.logger.info("Simulation is already running.")
|
||||||
return
|
return
|
||||||
# Require explicit connection before starting live. Do NOT auto-connect.
|
# 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
|
# Friendly English reminder to connect first
|
||||||
messagebox.showwarning(
|
messagebox.showwarning(
|
||||||
"Not Connected",
|
"Not Connected",
|
||||||
@ -1091,7 +1129,9 @@ class MainView(tk.Tk):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# As a fallback, log the info (no blocking GUI popup).
|
# 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:
|
except Exception:
|
||||||
# Ensure we never raise from UI-notice handling
|
# Ensure we never raise from UI-notice handling
|
||||||
try:
|
try:
|
||||||
@ -1123,7 +1163,9 @@ class MainView(tk.Tk):
|
|||||||
self._on_simulation_finished()
|
self._on_simulation_finished()
|
||||||
try:
|
try:
|
||||||
self.sim_elapsed_time = self.total_sim_time
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._update_simulation_progress_display()
|
self._update_simulation_progress_display()
|
||||||
@ -1132,17 +1174,27 @@ class MainView(tk.Tk):
|
|||||||
if len(update) == 0:
|
if len(update) == 0:
|
||||||
# Hub refresh notification (real data arrived).
|
# Hub refresh notification (real data arrived).
|
||||||
display_data = self._build_display_data_from_hub()
|
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:
|
try:
|
||||||
if (
|
if (
|
||||||
hasattr(self, "simulation_hub")
|
hasattr(self, "simulation_hub")
|
||||||
and self.simulation_hub is not None
|
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"):
|
if hasattr(
|
||||||
az_deg, az_ts = self.simulation_hub.get_antenna_azimuth()
|
self.simulation_hub, "get_antenna_azimuth"
|
||||||
|
):
|
||||||
|
az_deg, az_ts = (
|
||||||
|
self.simulation_hub.get_antenna_azimuth()
|
||||||
|
)
|
||||||
else:
|
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:
|
if az_deg is not None:
|
||||||
self.ppi_widget.update_antenna_azimuth(
|
self.ppi_widget.update_antenna_azimuth(
|
||||||
@ -1161,7 +1213,10 @@ class MainView(tk.Tk):
|
|||||||
|
|
||||||
# Update simulation progress bar
|
# Update simulation progress bar
|
||||||
try:
|
try:
|
||||||
if self.simulation_engine and self.simulation_engine.scenario:
|
if (
|
||||||
|
self.simulation_engine
|
||||||
|
and self.simulation_engine.scenario
|
||||||
|
):
|
||||||
times = [
|
times = [
|
||||||
getattr(t, "_sim_time_s", 0.0)
|
getattr(t, "_sim_time_s", 0.0)
|
||||||
for t in self.simulation_engine.scenario.get_all_targets()
|
for t in self.simulation_engine.scenario.get_all_targets()
|
||||||
@ -1173,13 +1228,18 @@ class MainView(tk.Tk):
|
|||||||
):
|
):
|
||||||
progress_frac = min(
|
progress_frac = min(
|
||||||
1.0,
|
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.sim_slider_var.set(progress_frac)
|
||||||
|
|
||||||
self._update_simulation_progress_display()
|
self._update_simulation_progress_display()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.debug("Progress UI update failed", exc_info=True)
|
self.logger.debug(
|
||||||
|
"Progress UI update failed", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
except Empty:
|
except Empty:
|
||||||
# Queue is empty, we can stop processing for this cycle.
|
# 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.
|
# Determine if analysis data exists. Ensure boolean type.
|
||||||
has_data_to_analyze = (
|
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
|
# 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).
|
# 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
|
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)
|
scenario_data = self.config_manager.get_scenario(scenario_name)
|
||||||
if scenario_data:
|
if scenario_data:
|
||||||
try:
|
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.scenario = Scenario.from_dict(scenario_data)
|
||||||
self.current_scenario_name = scenario_name
|
self.current_scenario_name = scenario_name
|
||||||
# Update target list UI with loaded scenario's targets
|
# Update target list UI with loaded scenario's targets
|
||||||
@ -1397,7 +1479,8 @@ class MainView(tk.Tk):
|
|||||||
# and planned paths before starting the simulation.
|
# and planned paths before starting the simulation.
|
||||||
try:
|
try:
|
||||||
connected = bool(
|
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:
|
except Exception:
|
||||||
connected = False
|
connected = False
|
||||||
@ -1469,7 +1552,9 @@ class MainView(tk.Tk):
|
|||||||
|
|
||||||
# Update scenario list UI and select the new scenario
|
# Update scenario list UI and select the new scenario
|
||||||
try:
|
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:
|
except Exception:
|
||||||
# Fallback for older UI: directly set combobox values
|
# Fallback for older UI: directly set combobox values
|
||||||
try:
|
try:
|
||||||
@ -1840,7 +1925,9 @@ class MainView(tk.Tk):
|
|||||||
|
|
||||||
# Update antenna azimuth
|
# Update antenna azimuth
|
||||||
try:
|
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()
|
az_deg, az_ts = self.simulation_hub.get_antenna_azimuth()
|
||||||
if az_deg is not None:
|
if az_deg is not None:
|
||||||
self.ppi_widget.update_antenna_azimuth(az_deg, timestamp=az_ts)
|
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)
|
filepath = os.path.join(archive_folder, filename)
|
||||||
try:
|
try:
|
||||||
# Leggiamo solo i metadati per non caricare tutto il file
|
# 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)
|
data = json.load(f)
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
# Usiamo il timestamp del nome del file per l'ordinamento
|
# Usiamo il timestamp del nome del file per l'ordinamento
|
||||||
dt_str = filename.split('_')[0]
|
dt_str = filename.split("_")[0]
|
||||||
run_info = {
|
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"),
|
"scenario": metadata.get("scenario_name", "N/A"),
|
||||||
"duration": f"{metadata.get('duration_seconds', 0):.1f}",
|
"duration": f"{metadata.get('duration_seconds', 0):.1f}",
|
||||||
"filepath": filepath
|
"filepath": filepath,
|
||||||
}
|
}
|
||||||
runs.append(run_info)
|
runs.append(run_info)
|
||||||
except Exception as e:
|
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
|
# Ordina dal più recente al più vecchio
|
||||||
for run in sorted(runs, key=lambda r: r['datetime'], reverse=True):
|
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'])
|
self.analysis_tree.insert(
|
||||||
|
"",
|
||||||
|
tk.END,
|
||||||
|
values=(run["datetime"], run["scenario"], run["duration"]),
|
||||||
|
iid=run["filepath"],
|
||||||
|
)
|
||||||
|
|
||||||
def _on_analyze_run(self):
|
def _on_analyze_run(self):
|
||||||
selected_item = self.analysis_tree.focus()
|
selected_item = self.analysis_tree.focus()
|
||||||
if not selected_item:
|
if not selected_item:
|
||||||
messagebox.showinfo("Nessuna Selezione", "Seleziona una simulazione da analizzare.")
|
messagebox.showinfo(
|
||||||
|
"Nessuna Selezione", "Seleziona una simulazione da analizzare."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
archive_filepath = selected_item # L'IID è il filepath
|
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,
|
||||||
ord("r"): self._handle_ris_status,
|
ord("r"): self._handle_ris_status,
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(f"{self._log_prefix} Initialized (Hub: {self._hub is not None}).")
|
||||||
f"{self._log_prefix} Initialized (Hub: {self._hub is not None})."
|
|
||||||
)
|
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
|
||||||
def set_archive(self, archive):
|
def set_archive(self, archive):
|
||||||
@ -220,7 +218,7 @@ class DebugPayloadRouter:
|
|||||||
"Failed to propagate heading to hub", exc_info=True
|
"Failed to propagate heading to hub", exc_info=True
|
||||||
)
|
)
|
||||||
|
|
||||||
#if self._update_queue:
|
# if self._update_queue:
|
||||||
# try:
|
# try:
|
||||||
# self._update_queue.put_nowait([])
|
# self._update_queue.put_nowait([])
|
||||||
# except Full:
|
# except Full:
|
||||||
@ -246,7 +244,7 @@ class DebugPayloadRouter:
|
|||||||
archive.add_real_state(
|
archive.add_real_state(
|
||||||
target_id=target.target_id,
|
target_id=target.target_id,
|
||||||
timestamp=reception_timestamp,
|
timestamp=reception_timestamp,
|
||||||
state=state_tuple
|
state=state_tuple,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- BROADCAST to all registered listeners ---
|
# --- BROADCAST to all registered listeners ---
|
||||||
|
|||||||
@ -327,16 +327,26 @@ class PPIDisplay(ttk.Frame):
|
|||||||
|
|
||||||
# Draw active targets as before
|
# Draw active targets as before
|
||||||
if active_targets:
|
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
|
# Draw inactive simulated targets with a yellow 'X' marker overlay
|
||||||
if inactive_targets and category == "simulated":
|
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:
|
if show_trail:
|
||||||
self._draw_trails(trail_data, trail_color, trail_artists)
|
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
|
"""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.
|
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
|
r_nm = target.current_range_nm
|
||||||
theta_rad_plot = np.deg2rad(target.current_azimuth_deg)
|
theta_rad_plot = np.deg2rad(target.current_azimuth_deg)
|
||||||
# plot a subdued point
|
# 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)
|
artist_list.append(dot)
|
||||||
|
|
||||||
# overlay a yellow X at the same position
|
# overlay a yellow X at the same position
|
||||||
@ -545,14 +557,16 @@ class PPIDisplay(ttk.Frame):
|
|||||||
waypoints = target.trajectory
|
waypoints = target.trajectory
|
||||||
if not waypoints:
|
if not waypoints:
|
||||||
continue
|
continue
|
||||||
path, _ = Target.generate_path_from_waypoints(waypoints, target.use_spline)
|
path, _ = Target.generate_path_from_waypoints(
|
||||||
|
waypoints, target.use_spline
|
||||||
|
)
|
||||||
if not path:
|
if not path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
path_thetas, path_rs = [], []
|
path_thetas, path_rs = [], []
|
||||||
for point in path:
|
for point in path:
|
||||||
x_ft, y_ft = point[1], point[2]
|
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)
|
az_rad_plot = math.atan2(x_ft, y_ft)
|
||||||
path_rs.append(r_ft / NM_TO_FT)
|
path_rs.append(r_ft / NM_TO_FT)
|
||||||
path_thetas.append(az_rad_plot)
|
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_r = path_rs[0] if path_rs else None
|
||||||
start_art = None
|
start_art = None
|
||||||
if start_theta is not 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]
|
arts = [a for a in (line_art, start_art) if a is not None]
|
||||||
if arts:
|
if arts:
|
||||||
self.preview_path_artists[target.target_id] = arts
|
self.preview_path_artists[target.target_id] = arts
|
||||||
except Exception:
|
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:
|
if self.canvas:
|
||||||
self.canvas.draw()
|
self.canvas.draw()
|
||||||
|
|||||||
@ -62,7 +62,12 @@ class ConfigManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.filepath = os.path.join(application_path, filename)
|
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()
|
self._settings = self._load_or_initialize_settings()
|
||||||
# Load scenarios from separate file if present, otherwise keep any scenarios
|
# Load scenarios from separate file if present, otherwise keep any scenarios
|
||||||
# found inside settings.json (fallback).
|
# found inside settings.json (fallback).
|
||||||
@ -93,6 +98,11 @@ class ConfigManager:
|
|||||||
# `_load_or_initialize_scenarios()`.
|
# `_load_or_initialize_scenarios()`.
|
||||||
|
|
||||||
if not os.path.exists(self.filepath):
|
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": {}}
|
return {"general": {}, "scenarios": {}}
|
||||||
try:
|
try:
|
||||||
with open(self.filepath, "r", encoding="utf-8") as f:
|
with open(self.filepath, "r", encoding="utf-8") as f:
|
||||||
@ -102,8 +112,19 @@ class ConfigManager:
|
|||||||
return {"general": settings, "scenarios": {}}
|
return {"general": settings, "scenarios": {}}
|
||||||
# Keep scenarios key if present; actual source of truth for scenarios
|
# Keep scenarios key if present; actual source of truth for scenarios
|
||||||
# will be the separate scenarios file when available.
|
# 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
|
return settings
|
||||||
except (json.JSONDecodeError, IOError):
|
except (json.JSONDecodeError, IOError):
|
||||||
|
try:
|
||||||
|
self._scenarios = self._load_or_initialize_scenarios()
|
||||||
|
except Exception:
|
||||||
|
self._scenarios = {}
|
||||||
return {"general": {}, "scenarios": {}}
|
return {"general": {}, "scenarios": {}}
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
@ -126,7 +147,35 @@ class ConfigManager:
|
|||||||
|
|
||||||
Falls back to scenarios inside settings.json when scenarios.json is absent.
|
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):
|
if os.path.exists(self.scenarios_filepath):
|
||||||
try:
|
try:
|
||||||
with open(self.scenarios_filepath, "r", encoding="utf-8") as f:
|
with open(self.scenarios_filepath, "r", encoding="utf-8") as f:
|
||||||
@ -135,6 +184,9 @@ class ConfigManager:
|
|||||||
return scenarios
|
return scenarios
|
||||||
except (json.JSONDecodeError, IOError):
|
except (json.JSONDecodeError, IOError):
|
||||||
return {}
|
return {}
|
||||||
|
except Exception:
|
||||||
|
# If path computations fail, fall back silently to other options.
|
||||||
|
pass
|
||||||
|
|
||||||
# Fallback: try to read scenarios stored inside settings.json
|
# Fallback: try to read scenarios stored inside settings.json
|
||||||
try:
|
try:
|
||||||
@ -201,14 +253,18 @@ class ConfigManager:
|
|||||||
shutil.copy2(self.scenarios_filepath, prebackup)
|
shutil.copy2(self.scenarios_filepath, prebackup)
|
||||||
logger.debug("Created pre-write backup: %s", prebackup)
|
logger.debug("Created pre-write backup: %s", prebackup)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Pre-write backup failed, continuing", exc_info=True)
|
logger.debug(
|
||||||
|
"Pre-write backup failed, continuing", exc_info=True
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Non-fatal
|
# Non-fatal
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Write atomically: write to a temp file then validate and replace the target.
|
# Write atomically: write to a temp file then validate and replace the target.
|
||||||
dirpath = os.path.dirname(self.scenarios_filepath) or "."
|
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:
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
json.dump(self._scenarios, f, indent=4, cls=EnumEncoder)
|
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:
|
with open(tmp_path, "r", encoding="utf-8") as tf:
|
||||||
validated = json.load(tf)
|
validated = json.load(tf)
|
||||||
if not isinstance(validated, dict):
|
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:
|
try:
|
||||||
os.remove(tmp_path)
|
os.remove(tmp_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
except Exception as e:
|
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:
|
try:
|
||||||
os.remove(tmp_path)
|
os.remove(tmp_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -246,7 +306,9 @@ class ConfigManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
new_count = len(validated) if isinstance(validated, dict) else 0
|
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 (
|
if (
|
||||||
existing_count > 1
|
existing_count > 1
|
||||||
and new_count <= 1
|
and new_count <= 1
|
||||||
@ -359,10 +421,18 @@ class ConfigManager:
|
|||||||
try:
|
try:
|
||||||
# Basic sanity check: expect a mapping for scenario data
|
# Basic sanity check: expect a mapping for scenario data
|
||||||
if not isinstance(data, dict):
|
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
|
return
|
||||||
self._scenarios[name] = data
|
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()))
|
logger.debug("Scenario keys: %s", list(self._scenarios.keys()))
|
||||||
self._save_scenarios()
|
self._save_scenarios()
|
||||||
except Exception:
|
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."""
|
"""Create a ConfigManager but point its filepath to a temp file and reload settings."""
|
||||||
cm = ConfigManager(filename=filename)
|
cm = ConfigManager(filename=filename)
|
||||||
cm.filepath = str(tmp_path / 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()
|
cm._settings = cm._load_or_initialize_settings()
|
||||||
return cm
|
return cm
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user