alchemist-nrel 0.2.1__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alchemist_core/__init__.py +14 -7
- alchemist_core/acquisition/botorch_acquisition.py +14 -6
- alchemist_core/audit_log.py +594 -0
- alchemist_core/data/experiment_manager.py +69 -5
- alchemist_core/models/botorch_model.py +6 -4
- alchemist_core/models/sklearn_model.py +44 -6
- alchemist_core/session.py +600 -8
- alchemist_core/utils/doe.py +200 -0
- {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/METADATA +57 -40
- alchemist_nrel-0.3.0.dist-info/RECORD +66 -0
- {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/entry_points.txt +1 -0
- {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/top_level.txt +1 -0
- api/main.py +19 -3
- api/models/requests.py +71 -0
- api/models/responses.py +144 -0
- api/routers/experiments.py +117 -5
- api/routers/sessions.py +329 -10
- api/routers/visualizations.py +10 -5
- api/services/session_store.py +210 -54
- api/static/NEW_ICON.ico +0 -0
- api/static/NEW_ICON.png +0 -0
- api/static/NEW_LOGO_DARK.png +0 -0
- api/static/NEW_LOGO_LIGHT.png +0 -0
- api/static/assets/api-vcoXEqyq.js +1 -0
- api/static/assets/index-C0_glioA.js +4084 -0
- api/static/assets/index-CB4V1LI5.css +1 -0
- api/static/index.html +14 -0
- api/static/vite.svg +1 -0
- run_api.py +55 -0
- ui/gpr_panel.py +7 -2
- ui/notifications.py +197 -10
- ui/ui.py +1117 -68
- ui/variables_setup.py +47 -2
- ui/visualizations.py +60 -3
- alchemist_nrel-0.2.1.dist-info/RECORD +0 -54
- {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/WHEEL +0 -0
- {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/licenses/LICENSE +0 -0
ui/ui.py
CHANGED
|
@@ -10,6 +10,7 @@ from skopt.space import Categorical, Integer, Real
|
|
|
10
10
|
import numpy as np
|
|
11
11
|
from skopt.sampler import Lhs, Sobol, Hammersly
|
|
12
12
|
import tkinter as tk
|
|
13
|
+
import os
|
|
13
14
|
|
|
14
15
|
from ui.variables_setup import SpaceSetupWindow
|
|
15
16
|
from ui.gpr_panel import GaussianProcessPanel
|
|
@@ -34,6 +35,7 @@ from alchemist_core.session import OptimizationSession
|
|
|
34
35
|
from alchemist_core.events import EventEmitter
|
|
35
36
|
|
|
36
37
|
plt.rcParams['savefig.dpi'] = 600
|
|
38
|
+
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
# ============================================================
|
|
@@ -61,6 +63,8 @@ class ALchemistApp(ctk.CTk):
|
|
|
61
63
|
self.var_df = None
|
|
62
64
|
self.exp_df = pd.DataFrame()
|
|
63
65
|
self.search_space = None
|
|
66
|
+
self.pool = None # DEPRECATED: Pool visualization (initialized when variables loaded)
|
|
67
|
+
# Clustering removed; no kmeans placeholder
|
|
64
68
|
|
|
65
69
|
# NEW: Create OptimizationSession for session-based API
|
|
66
70
|
# This provides a parallel code path alongside the existing direct logic calls
|
|
@@ -69,6 +73,7 @@ class ALchemistApp(ctk.CTk):
|
|
|
69
73
|
# Connect session events to UI updates
|
|
70
74
|
self.session.events.on('progress', self._on_session_progress)
|
|
71
75
|
self.session.events.on('model_trained', self._on_session_model_trained)
|
|
76
|
+
self.session.events.on('model_retrained', self._on_session_model_retrained)
|
|
72
77
|
self.session.events.on('suggestions_ready', self._on_session_suggestions)
|
|
73
78
|
|
|
74
79
|
# Build essential UI sections
|
|
@@ -105,6 +110,10 @@ class ALchemistApp(ctk.CTk):
|
|
|
105
110
|
# Initialize the experiment logger
|
|
106
111
|
self.experiment_logger = ExperimentLogger()
|
|
107
112
|
self.experiment_logger.start_experiment("ALchemist_Experiment")
|
|
113
|
+
|
|
114
|
+
# UI state: pending acquisition suggestions (persist across dialogs)
|
|
115
|
+
self.pending_suggestions = []
|
|
116
|
+
self.current_suggestion_index = 0
|
|
108
117
|
|
|
109
118
|
def _configure_window(self):
|
|
110
119
|
ctk.set_appearance_mode('dark')
|
|
@@ -116,6 +125,19 @@ class ALchemistApp(ctk.CTk):
|
|
|
116
125
|
|
|
117
126
|
def _create_menu_bar(self):
|
|
118
127
|
menu_bar = tk.Menu(self)
|
|
128
|
+
|
|
129
|
+
# File menu - NEW: Session management
|
|
130
|
+
file_menu = tk.Menu(menu_bar, tearoff=0)
|
|
131
|
+
file_menu.add_command(label="New Session", command=self.new_session, accelerator="Cmd+N")
|
|
132
|
+
file_menu.add_command(label="Open Session...", command=self.open_session, accelerator="Cmd+O")
|
|
133
|
+
file_menu.add_command(label="Save Session", command=self.save_session_cmd, accelerator="Cmd+S")
|
|
134
|
+
file_menu.add_command(label="Save Session As...", command=self.save_session_as)
|
|
135
|
+
file_menu.add_separator()
|
|
136
|
+
file_menu.add_command(label="Export Audit Log...", command=self.export_audit_log)
|
|
137
|
+
file_menu.add_separator()
|
|
138
|
+
file_menu.add_command(label="Session Metadata...", command=self.edit_session_metadata)
|
|
139
|
+
menu_bar.add_cascade(label="File", menu=file_menu)
|
|
140
|
+
|
|
119
141
|
# Help menu
|
|
120
142
|
help_menu = tk.Menu(menu_bar, tearoff=0)
|
|
121
143
|
help_menu.add_command(label="Help", command=self.show_help)
|
|
@@ -134,6 +156,15 @@ class ALchemistApp(ctk.CTk):
|
|
|
134
156
|
pref_menu.add_separator()
|
|
135
157
|
# Removed: Toggle Session API menu item (session API is now always enabled)
|
|
136
158
|
menu_bar.add_cascade(label="Preferences", menu=pref_menu)
|
|
159
|
+
|
|
160
|
+
# Bind keyboard shortcuts
|
|
161
|
+
self.bind_all("<Command-s>", lambda e: self.save_session_cmd())
|
|
162
|
+
self.bind_all("<Command-n>", lambda e: self.new_session())
|
|
163
|
+
self.bind_all("<Control-n>", lambda e: self.new_session())
|
|
164
|
+
# Open session with Cmd+O (macOS) or Ctrl+O (Windows/Linux)
|
|
165
|
+
self.bind_all("<Command-o>", lambda e: self.open_session())
|
|
166
|
+
self.bind_all("<Control-o>", lambda e: self.open_session())
|
|
167
|
+
|
|
137
168
|
self.config(menu=menu_bar)
|
|
138
169
|
|
|
139
170
|
def show_help(self):
|
|
@@ -151,14 +182,7 @@ class ALchemistApp(ctk.CTk):
|
|
|
151
182
|
def show_settings(self):
|
|
152
183
|
tk.messagebox.showinfo("Settings", "This is the settings dialog.")
|
|
153
184
|
|
|
154
|
-
def _quit(self):
|
|
155
|
-
# Cancel all pending "after" tasks
|
|
156
|
-
for task_id in self.tk.call('after', 'info'):
|
|
157
|
-
self.after_cancel(task_id)
|
|
158
185
|
|
|
159
|
-
# Now safely destroy the window
|
|
160
|
-
self.quit()
|
|
161
|
-
self.destroy()
|
|
162
186
|
|
|
163
187
|
# ============================================================
|
|
164
188
|
# Session Event Handlers
|
|
@@ -253,15 +277,44 @@ class ALchemistApp(ctk.CTk):
|
|
|
253
277
|
self._create_variables_dropdown()
|
|
254
278
|
|
|
255
279
|
# Clustering switch
|
|
256
|
-
|
|
257
|
-
self.cluster_switch.pack(side='left', padx=5, pady=5)
|
|
280
|
+
# NOTE: Clustering switch removed (deprecated)
|
|
258
281
|
|
|
259
282
|
# Visualization canvas - use square figure for better aspect ratio
|
|
260
|
-
|
|
283
|
+
# Fix macOS Retina scaling by using the Tk root pixels-per-inch as DPI
|
|
284
|
+
try:
|
|
285
|
+
dpi = int(self.winfo_fpixels('1i'))
|
|
286
|
+
except Exception:
|
|
287
|
+
dpi = plt.rcParams.get('figure.dpi', 100)
|
|
288
|
+
|
|
289
|
+
# Apply DPI to matplotlib runtime settings so text and markers scale correctly
|
|
290
|
+
plt.rcParams['figure.dpi'] = dpi
|
|
291
|
+
|
|
292
|
+
# Create figure with explicit DPI and cap font sizes to avoid oversized rendering
|
|
293
|
+
try:
|
|
294
|
+
plt.rcParams['axes.titlesize'] = min(12, plt.rcParams.get('axes.titlesize', 12))
|
|
295
|
+
plt.rcParams['axes.labelsize'] = min(10, plt.rcParams.get('axes.labelsize', 10))
|
|
296
|
+
plt.rcParams['legend.fontsize'] = min(9, plt.rcParams.get('legend.fontsize', 9))
|
|
297
|
+
plt.rcParams['xtick.labelsize'] = min(9, plt.rcParams.get('xtick.labelsize', 9))
|
|
298
|
+
plt.rcParams['ytick.labelsize'] = min(9, plt.rcParams.get('ytick.labelsize', 9))
|
|
299
|
+
plt.rcParams['lines.markersize'] = min(6, plt.rcParams.get('lines.markersize', 6))
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
self.fig, self.ax = plt.subplots(figsize=(5, 5), dpi=dpi)
|
|
305
|
+
# Ensure compact layout
|
|
306
|
+
try:
|
|
307
|
+
self.fig.tight_layout(pad=0.6)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
except Exception:
|
|
311
|
+
# Fallback to defaults
|
|
312
|
+
self.fig, self.ax = plt.subplots(figsize=(5, 5))
|
|
313
|
+
|
|
261
314
|
self.canvas = FigureCanvasTkAgg(self.fig, master=self.frame_viz)
|
|
262
315
|
self.toolbar = NavigationToolbar2Tk(self.canvas, self.frame_viz)
|
|
263
316
|
self.toolbar.update()
|
|
264
|
-
self.canvas.get_tk_widget().pack(fill='both', expand=True, padx=
|
|
317
|
+
self.canvas.get_tk_widget().pack(fill='both', expand=True, padx=2, pady=2)
|
|
265
318
|
|
|
266
319
|
def _create_variables_dropdown(self):
|
|
267
320
|
'''Creates dropdowns for selecting variables for 2D visualization.'''
|
|
@@ -319,10 +372,7 @@ class ALchemistApp(ctk.CTk):
|
|
|
319
372
|
else:
|
|
320
373
|
self.load_exp_button.configure(state='disabled')
|
|
321
374
|
self.gen_template_button.configure(state='disabled')
|
|
322
|
-
|
|
323
|
-
self.cluster_switch.configure(state='normal')
|
|
324
|
-
else:
|
|
325
|
-
self.cluster_switch.configure(state='disabled')
|
|
375
|
+
# Clustering functionality is deprecated and removed from the UI
|
|
326
376
|
|
|
327
377
|
def load_variables(self):
|
|
328
378
|
"""Loads a search space from a file using a file dialog."""
|
|
@@ -395,7 +445,7 @@ class ALchemistApp(ctk.CTk):
|
|
|
395
445
|
self.pool = generate_pool(self.search_space, lhs_iterations=20)
|
|
396
446
|
|
|
397
447
|
# Reset kmeans and update plot
|
|
398
|
-
|
|
448
|
+
# Clustering removed; just update plot
|
|
399
449
|
self.update_pool_plot()
|
|
400
450
|
except Exception as e:
|
|
401
451
|
print('Error loading search space:', e)
|
|
@@ -506,26 +556,268 @@ class ALchemistApp(ctk.CTk):
|
|
|
506
556
|
def update_exp_df_from_sheet(self):
|
|
507
557
|
'''Updates the exp_df DataFrame with the current data from the exp_sheet.'''
|
|
508
558
|
sheet_data = self.exp_sheet.get_sheet_data(get_header=False)
|
|
509
|
-
|
|
559
|
+
headers = self.exp_sheet.headers()
|
|
560
|
+
|
|
561
|
+
# If headers are empty, use exp_df columns if available
|
|
562
|
+
if not headers and self.exp_df is not None:
|
|
563
|
+
headers = self.exp_df.columns.tolist()
|
|
564
|
+
|
|
565
|
+
if headers:
|
|
566
|
+
self.exp_df = pd.DataFrame(sheet_data, columns=headers)
|
|
567
|
+
else:
|
|
568
|
+
print("Warning: No headers available for sheet data")
|
|
569
|
+
self.exp_df = pd.DataFrame(sheet_data)
|
|
510
570
|
|
|
511
571
|
def save_experiments(self):
|
|
512
572
|
'''Saves the experimental data to a CSV file using a file dialog.'''
|
|
513
573
|
self.update_exp_df_from_sheet() # Update the DataFrame with the current data from the sheet
|
|
514
574
|
if self.exp_df is not None:
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
)
|
|
520
|
-
|
|
575
|
+
# If we have an associated experiments file path, overwrite it silently
|
|
576
|
+
target_path = None
|
|
577
|
+
if hasattr(self, 'exp_file_path') and self.exp_file_path:
|
|
578
|
+
target_path = self.exp_file_path
|
|
579
|
+
elif hasattr(self.experiment_manager, 'filepath') and self.experiment_manager.filepath:
|
|
580
|
+
target_path = self.experiment_manager.filepath
|
|
581
|
+
|
|
582
|
+
if target_path:
|
|
521
583
|
try:
|
|
522
|
-
self.exp_df.to_csv(
|
|
523
|
-
print('Experiments saved
|
|
584
|
+
self.exp_df.to_csv(target_path, index=False)
|
|
585
|
+
print(f'Experiments auto-saved to {target_path}')
|
|
524
586
|
except Exception as e:
|
|
525
|
-
print('Error saving experiments:', e)
|
|
587
|
+
print('Error saving experiments to existing path:', e)
|
|
588
|
+
else:
|
|
589
|
+
file_path = filedialog.asksaveasfilename(
|
|
590
|
+
title='Save Experiments CSV',
|
|
591
|
+
defaultextension='.csv',
|
|
592
|
+
filetypes=[('CSV Files', '*.csv')]
|
|
593
|
+
)
|
|
594
|
+
if file_path:
|
|
595
|
+
try:
|
|
596
|
+
self.exp_df.to_csv(file_path, index=False)
|
|
597
|
+
self.exp_file_path = file_path
|
|
598
|
+
print('Experiments saved successfully.')
|
|
599
|
+
except Exception as e:
|
|
600
|
+
print('Error saving experiments:', e)
|
|
526
601
|
else:
|
|
527
602
|
print('No experimental data to save.')
|
|
528
603
|
|
|
604
|
+
def save_and_commit_all_pending(self):
|
|
605
|
+
"""Commit all pending suggestions to the experiment table and session.
|
|
606
|
+
|
|
607
|
+
This method persists any user edits that were made while navigating suggestions,
|
|
608
|
+
appends all pending suggestions to `self.exp_df` and to the session's ExperimentManager,
|
|
609
|
+
optionally retrains once, and clears the pending list.
|
|
610
|
+
"""
|
|
611
|
+
# Save current dialog state first
|
|
612
|
+
try:
|
|
613
|
+
self._save_current_dialog_state()
|
|
614
|
+
except Exception:
|
|
615
|
+
pass
|
|
616
|
+
|
|
617
|
+
if not self.pending_suggestions:
|
|
618
|
+
# Nothing staged; behave like save_new_point
|
|
619
|
+
self.save_new_point()
|
|
620
|
+
if hasattr(self, 'add_point_window'):
|
|
621
|
+
self.add_point_window.destroy()
|
|
622
|
+
return
|
|
623
|
+
|
|
624
|
+
# Build DataFrame from pending suggestions
|
|
625
|
+
pending_df = pd.DataFrame(self.pending_suggestions)
|
|
626
|
+
|
|
627
|
+
# Ensure we have Iteration and Reason columns
|
|
628
|
+
# If there are acquisition suggestions (not initial design), we should
|
|
629
|
+
# increment the iteration counter first so committed rows receive the
|
|
630
|
+
# next iteration number. This keeps the experiment table in-sync with
|
|
631
|
+
# the audit log which increments on lock_acquisition.
|
|
632
|
+
if 'Iteration' not in pending_df.columns:
|
|
633
|
+
pending_df['Iteration'] = int(getattr(self.experiment_manager, '_current_iteration', 0))
|
|
634
|
+
if 'Reason' not in pending_df.columns:
|
|
635
|
+
pending_df['Reason'] = pending_df.get('_reason', 'Acquisition')
|
|
636
|
+
|
|
637
|
+
# If committing acquisition suggestions, advance the iteration counter
|
|
638
|
+
try:
|
|
639
|
+
# Determine if any pending suggestion is not an initial design
|
|
640
|
+
has_acquisition = any(not str(row.get('_reason', row.get('Reason', ''))).lower().startswith('initial')
|
|
641
|
+
for _, row in pending_df.iterrows())
|
|
642
|
+
except Exception:
|
|
643
|
+
has_acquisition = False
|
|
644
|
+
|
|
645
|
+
if has_acquisition:
|
|
646
|
+
# Use the session's ExperimentManager iteration as authoritative.
|
|
647
|
+
# `lock_acquisition` should have already incremented it during
|
|
648
|
+
# audit logging; do not modify `_current_iteration` here to avoid
|
|
649
|
+
# double increments.
|
|
650
|
+
try:
|
|
651
|
+
sess_iter = int(getattr(self.session.experiment_manager, '_current_iteration',
|
|
652
|
+
getattr(self.experiment_manager, '_current_iteration', 0)))
|
|
653
|
+
except Exception:
|
|
654
|
+
sess_iter = int(getattr(self.experiment_manager, '_current_iteration', 0))
|
|
655
|
+
|
|
656
|
+
# Overwrite Iteration column for all non-initial-design pending rows
|
|
657
|
+
def assign_iter(row):
|
|
658
|
+
r_reason = str(row.get('_reason', row.get('Reason', ''))).lower()
|
|
659
|
+
if r_reason.startswith('initial'):
|
|
660
|
+
return int(row.get('Iteration', 0))
|
|
661
|
+
return int(sess_iter)
|
|
662
|
+
|
|
663
|
+
pending_df['Iteration'] = pending_df.apply(assign_iter, axis=1).astype(int)
|
|
664
|
+
|
|
665
|
+
# Now that Iteration column has been finalized, append to the visible experiment
|
|
666
|
+
# table so the UI shows the correct (post-increment) iteration numbers.
|
|
667
|
+
if self.exp_df is None or self.exp_df.empty:
|
|
668
|
+
self.exp_df = pending_df.drop(columns=['_reason'], errors='ignore').copy()
|
|
669
|
+
else:
|
|
670
|
+
self.exp_df = pd.concat([self.exp_df, pending_df.drop(columns=['_reason'], errors='ignore')], ignore_index=True)
|
|
671
|
+
|
|
672
|
+
# Update experiment manager and session
|
|
673
|
+
for _, row in pending_df.iterrows():
|
|
674
|
+
inputs = {c: row[c] for c in pending_df.columns if c not in ['Output', 'Noise', 'Iteration', 'Reason', '_reason']}
|
|
675
|
+
output_val = row.get('Output', None)
|
|
676
|
+
noise_val = row.get('Noise', None)
|
|
677
|
+
# Determine reason (prefer internal _reason tag)
|
|
678
|
+
reason_val = row.get('Reason', row.get('_reason', 'Acquisition'))
|
|
679
|
+
|
|
680
|
+
# If this is an acquisition (committed after lock_acquisition),
|
|
681
|
+
# prefer the experiment manager's current iteration which was
|
|
682
|
+
# incremented when the acquisition was locked. For initial design
|
|
683
|
+
# keep the recorded iteration (usually 0).
|
|
684
|
+
if str(reason_val).lower().startswith('initial'):
|
|
685
|
+
iter_val = int(row.get('Iteration', 0))
|
|
686
|
+
else:
|
|
687
|
+
iter_val = int(getattr(self.experiment_manager, '_current_iteration', 0))
|
|
688
|
+
try:
|
|
689
|
+
self.session.add_experiment(inputs=inputs, output=float(output_val) if output_val is not None else None,
|
|
690
|
+
noise=noise_val, iteration=iter_val, reason=reason_val)
|
|
691
|
+
except Exception:
|
|
692
|
+
# Fallback: add without casting
|
|
693
|
+
self.session.add_experiment(inputs=inputs, output=output_val, noise=noise_val, iteration=iter_val, reason=reason_val)
|
|
694
|
+
|
|
695
|
+
# Update sheet and headers
|
|
696
|
+
headers = self.exp_df.columns.tolist()
|
|
697
|
+
self.exp_sheet.set_sheet_data(self.exp_df.values.tolist())
|
|
698
|
+
try:
|
|
699
|
+
self.exp_sheet.set_header_data(headers)
|
|
700
|
+
except Exception:
|
|
701
|
+
pass
|
|
702
|
+
|
|
703
|
+
# Clear pending suggestions
|
|
704
|
+
n_committed = len(self.pending_suggestions)
|
|
705
|
+
self.pending_suggestions = []
|
|
706
|
+
self.current_suggestion_index = 0
|
|
707
|
+
|
|
708
|
+
# Optionally retrain model once if user checked retrain
|
|
709
|
+
if hasattr(self, 'retrain_checkbox') and self.retrain_checkbox.get():
|
|
710
|
+
try:
|
|
711
|
+
self.retrain_model()
|
|
712
|
+
except Exception as e:
|
|
713
|
+
print('Warning: retrain failed after committing pending suggestions:', e)
|
|
714
|
+
|
|
715
|
+
# Auto-save experiments if we have a path
|
|
716
|
+
if hasattr(self, 'exp_file_path') and self.exp_file_path:
|
|
717
|
+
try:
|
|
718
|
+
self.exp_df.to_csv(self.exp_file_path, index=False)
|
|
719
|
+
print(f'Committed {n_committed} pending suggestions and auto-saved to {self.exp_file_path}')
|
|
720
|
+
except Exception as e:
|
|
721
|
+
print('Warning: failed to auto-save committed suggestions:', e)
|
|
722
|
+
|
|
723
|
+
# Auto-save session if available
|
|
724
|
+
if hasattr(self, 'current_session_file') and self.current_session_file:
|
|
725
|
+
try:
|
|
726
|
+
self.session.save_session(self.current_session_file)
|
|
727
|
+
print(f'Session auto-saved to {self.current_session_file}')
|
|
728
|
+
except Exception as e:
|
|
729
|
+
print('Warning: Failed to auto-save session:', e)
|
|
730
|
+
|
|
731
|
+
# Close dialog
|
|
732
|
+
if hasattr(self, 'add_point_window'):
|
|
733
|
+
self.add_point_window.destroy()
|
|
734
|
+
|
|
735
|
+
# ============================================================
|
|
736
|
+
# Lock-in Methods (Audit Trail)
|
|
737
|
+
# ============================================================
|
|
738
|
+
|
|
739
|
+
def log_optimization_to_audit(self, next_point_df=None, strategy_info=None):
|
|
740
|
+
"""Log complete optimization decision (data + model + acquisition) to audit trail.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
next_point_df: DataFrame with suggested points
|
|
744
|
+
strategy_info: Dict with strategy details (name, params, notes, etc.)
|
|
745
|
+
"""
|
|
746
|
+
try:
|
|
747
|
+
# Ensure data is synced to session
|
|
748
|
+
self._sync_data_to_session()
|
|
749
|
+
|
|
750
|
+
# Extract notes from strategy_info
|
|
751
|
+
notes = strategy_info.get('notes', '') if strategy_info else ''
|
|
752
|
+
|
|
753
|
+
# Log data snapshot (include initial design metadata if present in session.config)
|
|
754
|
+
extra = {}
|
|
755
|
+
if hasattr(self.session, 'config'):
|
|
756
|
+
method = self.session.config.get('initial_design_method')
|
|
757
|
+
n_pts = self.session.config.get('initial_design_n_points')
|
|
758
|
+
if method:
|
|
759
|
+
extra['initial_design_method'] = method
|
|
760
|
+
extra['initial_design_n_points'] = n_pts
|
|
761
|
+
|
|
762
|
+
try:
|
|
763
|
+
data_entry = self.session.lock_data(notes=notes, extra_parameters=extra)
|
|
764
|
+
except TypeError:
|
|
765
|
+
# Older session API may not accept extra_parameters; fall back and merge metadata into last entry
|
|
766
|
+
data_entry = self.session.lock_data(notes=notes)
|
|
767
|
+
try:
|
|
768
|
+
if extra and hasattr(self.session.audit_log, 'entries') and len(self.session.audit_log.entries) > 0:
|
|
769
|
+
last = self.session.audit_log.entries[-1]
|
|
770
|
+
if last.entry_type == 'data_locked':
|
|
771
|
+
last.parameters.update(extra)
|
|
772
|
+
except Exception:
|
|
773
|
+
pass
|
|
774
|
+
|
|
775
|
+
print(f"Data logged to audit trail: {len(self.session.experiment_manager.df)} experiments")
|
|
776
|
+
|
|
777
|
+
# Log model
|
|
778
|
+
if hasattr(self, 'gpr_model') and self.gpr_model is not None:
|
|
779
|
+
self.session.model = self.gpr_model
|
|
780
|
+
model_entry = self.session.lock_model(notes=notes)
|
|
781
|
+
print(f"Model logged to audit trail")
|
|
782
|
+
|
|
783
|
+
# Log acquisition
|
|
784
|
+
if next_point_df is not None and strategy_info is not None:
|
|
785
|
+
# Convert DataFrame to list of dicts
|
|
786
|
+
suggestions = next_point_df.to_dict('records')
|
|
787
|
+
|
|
788
|
+
# Extract strategy information
|
|
789
|
+
strategy_type = strategy_info.get('type', 'Unknown')
|
|
790
|
+
parameters = strategy_info.get('params', {})
|
|
791
|
+
|
|
792
|
+
# Add goal to parameters
|
|
793
|
+
if 'maximize' in strategy_info:
|
|
794
|
+
parameters['goal'] = 'maximize' if strategy_info['maximize'] else 'minimize'
|
|
795
|
+
|
|
796
|
+
acq_entry = self.session.lock_acquisition(
|
|
797
|
+
strategy=strategy_type,
|
|
798
|
+
parameters=parameters,
|
|
799
|
+
suggestions=suggestions,
|
|
800
|
+
notes=notes
|
|
801
|
+
)
|
|
802
|
+
print(f"Acquisition logged to audit trail: {strategy_type}")
|
|
803
|
+
|
|
804
|
+
# Auto-save session after audit log update
|
|
805
|
+
if hasattr(self, 'current_session_file') and self.current_session_file:
|
|
806
|
+
try:
|
|
807
|
+
self.session.save_session(self.current_session_file)
|
|
808
|
+
print(f"Session auto-saved to {self.current_session_file}")
|
|
809
|
+
except Exception as e:
|
|
810
|
+
print(f"Warning: Failed to auto-save session: {e}")
|
|
811
|
+
|
|
812
|
+
return True
|
|
813
|
+
|
|
814
|
+
except Exception as e:
|
|
815
|
+
print(f"Error logging to audit trail: {e}")
|
|
816
|
+
import traceback
|
|
817
|
+
traceback.print_exc()
|
|
818
|
+
return False
|
|
819
|
+
|
|
820
|
+
|
|
529
821
|
def generate_template(self):
|
|
530
822
|
'''Generates a blank template with 10 starter points based on loaded variables.'''
|
|
531
823
|
if self.var_df is not None:
|
|
@@ -557,23 +849,15 @@ class ALchemistApp(ctk.CTk):
|
|
|
557
849
|
|
|
558
850
|
var1 = self.var1_dropdown.get()
|
|
559
851
|
var2 = self.var2_dropdown.get()
|
|
852
|
+
|
|
853
|
+
# Safety check: pool visualization is deprecated and may not be initialized
|
|
854
|
+
if self.pool is None:
|
|
855
|
+
print("Pool not initialized - skipping pool visualization (deprecated feature)")
|
|
856
|
+
self.canvas.draw()
|
|
857
|
+
return
|
|
560
858
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
print("WARNING: Clustering visualization is deprecated")
|
|
564
|
-
# Compute kmeans if it hasn't been computed already.
|
|
565
|
-
# if not hasattr(self, 'kmeans') or self.kmeans is None:
|
|
566
|
-
# # Use skopt-compatible version
|
|
567
|
-
# skopt_space = self._get_skopt_space()
|
|
568
|
-
# _, _, self.kmeans = cluster_pool(self.pool, self.exp_df, skopt_space, add_cluster=False)
|
|
569
|
-
# # If a next point exists, enable the highlighting of the largest empty cluster.
|
|
570
|
-
# add_cluster_flag = True if self.next_point is not None else False
|
|
571
|
-
# plot_pool(self.pool, var1, var2, self.ax, kmeans=self.kmeans,
|
|
572
|
-
# add_cluster=add_cluster_flag, experiments=self.exp_df)
|
|
573
|
-
# Fallback to non-clustered visualization
|
|
574
|
-
plot_pool(self.pool, var1, var2, self.ax, kmeans=None, experiments=self.exp_df)
|
|
575
|
-
else:
|
|
576
|
-
plot_pool(self.pool, var1, var2, self.ax, kmeans=None, experiments=self.exp_df)
|
|
859
|
+
# Clustering was deprecated — always use non-clustered visualization
|
|
860
|
+
plot_pool(self.pool, var1, var2, self.ax, kmeans=None, experiments=self.exp_df)
|
|
577
861
|
|
|
578
862
|
if self.exp_df is not None and not self.exp_df.empty:
|
|
579
863
|
self.ax.plot(self.exp_df[var1], self.exp_df[var2], 'go', markeredgecolor='k')
|
|
@@ -751,50 +1035,179 @@ class ALchemistApp(ctk.CTk):
|
|
|
751
1035
|
|
|
752
1036
|
# Build a DataFrame with the generated points and an 'Output' column.
|
|
753
1037
|
data = {dim.name: samples[:, i].tolist() for i, dim in enumerate(self.search_space)}
|
|
1038
|
+
# Add workflow metadata columns: Output (empty), Iteration, Reason
|
|
1039
|
+
current_iter = getattr(self.experiment_manager, '_current_iteration', 0)
|
|
754
1040
|
data['Output'] = [None] * num_points
|
|
755
|
-
|
|
756
|
-
|
|
1041
|
+
data['Iteration'] = [int(current_iter)] * num_points
|
|
1042
|
+
data['Reason'] = ['Initial Design'] * num_points
|
|
1043
|
+
|
|
1044
|
+
# Stage as pending suggestions (do not commit to session until user confirms)
|
|
1045
|
+
pending_df = pd.DataFrame(data)
|
|
1046
|
+
|
|
1047
|
+
# Store pending suggestions as list of dicts (used by Add Point dialog)
|
|
1048
|
+
pending = []
|
|
1049
|
+
for _, row in pending_df.iterrows():
|
|
1050
|
+
rec = {col: row[col] for col in pending_df.columns}
|
|
1051
|
+
# Tag with internal reason key for dialog logic
|
|
1052
|
+
rec['_reason'] = 'Initial Design'
|
|
1053
|
+
pending.append(rec)
|
|
1054
|
+
|
|
1055
|
+
# Replace current pending suggestions and reset index
|
|
1056
|
+
self.pending_suggestions = pending
|
|
1057
|
+
self.current_suggestion_index = 0
|
|
1058
|
+
|
|
1059
|
+
# Display pending suggestions in the experiment sheet (so user can edit or save)
|
|
1060
|
+
headers = pending_df.columns.tolist()
|
|
1061
|
+
self.exp_sheet.set_sheet_data(pending_df.values.tolist())
|
|
1062
|
+
try:
|
|
1063
|
+
self.exp_sheet.set_header_data(headers)
|
|
1064
|
+
except Exception:
|
|
1065
|
+
pass
|
|
1066
|
+
try:
|
|
1067
|
+
self.exp_sheet.set_all_column_widths()
|
|
1068
|
+
except Exception:
|
|
1069
|
+
pass
|
|
1070
|
+
|
|
1071
|
+
# Do not commit to self.exp_df yet; user must confirm via Add Point or Save
|
|
757
1072
|
self._update_ui_state()
|
|
758
|
-
|
|
1073
|
+
# Record initial design metadata in the audit log as a snapshot of planned points
|
|
1074
|
+
try:
|
|
1075
|
+
extra = {'initial_design_method': strategy, 'initial_design_n_points': num_points}
|
|
1076
|
+
# Use a copy to avoid accidental mutation
|
|
1077
|
+
self.session.audit_log.lock_data(pending_df.copy(), notes=f"Initial design staged ({strategy})", extra_parameters=extra)
|
|
1078
|
+
except Exception as e:
|
|
1079
|
+
print(f"Warning: failed to record initial design in audit log: {e}")
|
|
1080
|
+
|
|
1081
|
+
print(f'Initial points generated and staged as {len(pending)} pending suggestions.')
|
|
759
1082
|
self.initial_points_window.destroy()
|
|
760
1083
|
|
|
761
1084
|
def add_point(self):
|
|
762
|
-
'''Opens a window to add a new experiment point.'''
|
|
1085
|
+
'''Opens a window to add a new experiment point, with support for pending suggestions.'''
|
|
763
1086
|
if not self.search_space:
|
|
764
1087
|
print('Please load variables before adding a point.')
|
|
765
1088
|
return
|
|
766
1089
|
|
|
767
1090
|
self.add_point_window = ctk.CTkToplevel(self)
|
|
768
|
-
self.add_point_window.title("Add
|
|
769
|
-
|
|
1091
|
+
self.add_point_window.title("Add Experimental Result")
|
|
1092
|
+
# Taller default to avoid vertical clipping; constrain min size
|
|
1093
|
+
try:
|
|
1094
|
+
screen_h = self.add_point_window.winfo_screenheight()
|
|
1095
|
+
default_h = min(800, int(screen_h * 0.75))
|
|
1096
|
+
self.add_point_window.geometry(f"560x{default_h}")
|
|
1097
|
+
self.add_point_window.minsize(520, 520)
|
|
1098
|
+
except Exception:
|
|
1099
|
+
self.add_point_window.geometry("560x700")
|
|
770
1100
|
self.add_point_window.grab_set()
|
|
1101
|
+
|
|
1102
|
+
# Header with suggestion info
|
|
1103
|
+
header_frame = ctk.CTkFrame(self.add_point_window)
|
|
1104
|
+
header_frame.pack(pady=10, padx=10, fill='x')
|
|
1105
|
+
|
|
1106
|
+
if self.pending_suggestions and self.current_suggestion_index < len(self.pending_suggestions):
|
|
1107
|
+
# Show which suggestion we're on
|
|
1108
|
+
suggestion_label = ctk.CTkLabel(
|
|
1109
|
+
header_frame,
|
|
1110
|
+
text=f"Pending Suggestion {self.current_suggestion_index + 1} of {len(self.pending_suggestions)}",
|
|
1111
|
+
font=('Arial', 14, 'bold'),
|
|
1112
|
+
text_color='#2B8A3E'
|
|
1113
|
+
)
|
|
1114
|
+
suggestion_label.pack(pady=5)
|
|
1115
|
+
|
|
1116
|
+
# Navigation buttons
|
|
1117
|
+
nav_frame = ctk.CTkFrame(header_frame)
|
|
1118
|
+
nav_frame.pack(pady=5)
|
|
1119
|
+
|
|
1120
|
+
prev_btn = ctk.CTkButton(
|
|
1121
|
+
nav_frame,
|
|
1122
|
+
text="← Previous",
|
|
1123
|
+
width=100,
|
|
1124
|
+
command=lambda: self._save_current_and_load(self.current_suggestion_index - 1)
|
|
1125
|
+
)
|
|
1126
|
+
if self.current_suggestion_index > 0:
|
|
1127
|
+
prev_btn.pack(side='left', padx=5)
|
|
1128
|
+
|
|
1129
|
+
next_btn = ctk.CTkButton(
|
|
1130
|
+
nav_frame,
|
|
1131
|
+
text="Next →",
|
|
1132
|
+
width=100,
|
|
1133
|
+
command=lambda: self._save_current_and_load(self.current_suggestion_index + 1)
|
|
1134
|
+
)
|
|
1135
|
+
if self.current_suggestion_index < len(self.pending_suggestions) - 1:
|
|
1136
|
+
next_btn.pack(side='left', padx=5)
|
|
1137
|
+
else:
|
|
1138
|
+
# Manual entry (no suggestions)
|
|
1139
|
+
manual_label = ctk.CTkLabel(
|
|
1140
|
+
header_frame,
|
|
1141
|
+
text="Manual Entry",
|
|
1142
|
+
font=('Arial', 14, 'bold')
|
|
1143
|
+
)
|
|
1144
|
+
manual_label.pack(pady=5)
|
|
771
1145
|
|
|
1146
|
+
# Variable entries
|
|
1147
|
+
entries_frame = ctk.CTkFrame(self.add_point_window)
|
|
1148
|
+
entries_frame.pack(pady=10, padx=10, fill='both', expand=True)
|
|
1149
|
+
|
|
772
1150
|
self.var_entries = {}
|
|
773
1151
|
for var in self.search_space:
|
|
774
|
-
ctk.CTkLabel(
|
|
775
|
-
entry = ctk.CTkEntry(
|
|
1152
|
+
ctk.CTkLabel(entries_frame, text=var.name).pack(pady=5)
|
|
1153
|
+
entry = ctk.CTkEntry(entries_frame)
|
|
776
1154
|
entry.pack(pady=5)
|
|
777
1155
|
self.var_entries[var.name] = entry
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
self.
|
|
1156
|
+
|
|
1157
|
+
# Pre-fill if we have a pending suggestion
|
|
1158
|
+
if self.pending_suggestions and self.current_suggestion_index < len(self.pending_suggestions):
|
|
1159
|
+
current_suggestion = self.pending_suggestions[self.current_suggestion_index]
|
|
1160
|
+
for var_name, entry in self.var_entries.items():
|
|
1161
|
+
if var_name in current_suggestion:
|
|
1162
|
+
entry.insert(0, str(current_suggestion[var_name]))
|
|
1163
|
+
|
|
1164
|
+
ctk.CTkLabel(entries_frame, text='Output').pack(pady=5)
|
|
1165
|
+
self.output_entry = ctk.CTkEntry(entries_frame)
|
|
781
1166
|
self.output_entry.pack(pady=5)
|
|
1167
|
+
self.output_entry.focus() # Focus on output field
|
|
1168
|
+
|
|
1169
|
+
# Display iteration and reason (read-only)
|
|
1170
|
+
iter_frame = ctk.CTkFrame(entries_frame)
|
|
1171
|
+
iter_frame.pack(fill='x', pady=(6, 2))
|
|
1172
|
+
ctk.CTkLabel(iter_frame, text='Iteration:', width=100, anchor='w').pack(side='left', padx=5)
|
|
1173
|
+
self.iteration_label = ctk.CTkLabel(iter_frame, text='N/A')
|
|
1174
|
+
self.iteration_label.pack(side='left', padx=5)
|
|
1175
|
+
|
|
1176
|
+
reason_frame = ctk.CTkFrame(entries_frame)
|
|
1177
|
+
reason_frame.pack(fill='x', pady=(2, 8))
|
|
1178
|
+
ctk.CTkLabel(reason_frame, text='Reason:', width=100, anchor='w').pack(side='left', padx=5)
|
|
1179
|
+
self.reason_label = ctk.CTkLabel(reason_frame, text='Manual')
|
|
1180
|
+
self.reason_label.pack(side='left', padx=5)
|
|
1181
|
+
|
|
1182
|
+
# If pending suggestion exists, populate iteration and reason
|
|
1183
|
+
if self.pending_suggestions and self.current_suggestion_index < len(self.pending_suggestions):
|
|
1184
|
+
cs = self.pending_suggestions[self.current_suggestion_index]
|
|
1185
|
+
# Prefer the session's experiment manager as the authoritative source
|
|
1186
|
+
iter_val = cs.get('Iteration', getattr(self.session.experiment_manager, '_current_iteration',
|
|
1187
|
+
getattr(self.experiment_manager, '_current_iteration', 0))) + 1
|
|
1188
|
+
self.iteration_label.configure(text=str(int(iter_val)))
|
|
1189
|
+
self.reason_label.configure(text=str(cs.get('_reason', cs.get('Reason', 'Acquisition'))))
|
|
1190
|
+
else:
|
|
1191
|
+
# Default values for manual entry
|
|
1192
|
+
self.iteration_label.configure(text=str(getattr(self.experiment_manager, '_current_iteration', 0)))
|
|
1193
|
+
self.reason_label.configure(text='Manual')
|
|
782
1194
|
|
|
783
1195
|
# Add noise field
|
|
784
|
-
ctk.CTkLabel(
|
|
785
|
-
self.noise_entry = ctk.CTkEntry(
|
|
1196
|
+
ctk.CTkLabel(entries_frame, text='Noise (optional)').pack(pady=5)
|
|
1197
|
+
self.noise_entry = ctk.CTkEntry(entries_frame)
|
|
786
1198
|
self.noise_entry.pack(pady=5)
|
|
787
1199
|
|
|
788
1200
|
# Add info tooltip about noise
|
|
789
1201
|
ctk.CTkLabel(
|
|
790
|
-
|
|
791
|
-
text='Noise
|
|
1202
|
+
entries_frame,
|
|
1203
|
+
text='Noise represents measurement uncertainty',
|
|
792
1204
|
font=('Arial', 10),
|
|
793
1205
|
text_color='grey'
|
|
794
1206
|
).pack(pady=0)
|
|
795
1207
|
|
|
1208
|
+
# Options
|
|
796
1209
|
self.add_point_button_frame = ctk.CTkFrame(self.add_point_window)
|
|
797
|
-
self.add_point_button_frame.pack(pady=
|
|
1210
|
+
self.add_point_button_frame.pack(pady=10, padx=10, fill='x')
|
|
798
1211
|
|
|
799
1212
|
self.save_checkbox = ctk.CTkCheckBox(self.add_point_button_frame, text='Save to file')
|
|
800
1213
|
self.save_checkbox.select()
|
|
@@ -804,10 +1217,58 @@ class ALchemistApp(ctk.CTk):
|
|
|
804
1217
|
self.retrain_checkbox.select()
|
|
805
1218
|
self.retrain_checkbox.pack(side='left', padx=5, pady=5)
|
|
806
1219
|
|
|
807
|
-
ctk.CTkButton(self.
|
|
1220
|
+
save_btn = ctk.CTkButton(self.add_point_button_frame, text='Save & Close', command=self.save_and_commit_all_pending)
|
|
1221
|
+
save_btn.pack(side='right', padx=5, pady=5)
|
|
1222
|
+
|
|
1223
|
+
def load_suggestion(self, index):
|
|
1224
|
+
'''Load a specific suggestion into the add point dialog.'''
|
|
1225
|
+
if 0 <= index < len(self.pending_suggestions):
|
|
1226
|
+
self.current_suggestion_index = index
|
|
1227
|
+
# Close and reopen dialog with new suggestion
|
|
1228
|
+
if hasattr(self, 'add_point_window'):
|
|
1229
|
+
self.add_point_window.destroy()
|
|
1230
|
+
self.add_point()
|
|
1231
|
+
|
|
1232
|
+
def _save_current_and_load(self, index):
|
|
1233
|
+
"""Save current dialog edits back to pending_suggestions then load another suggestion."""
|
|
1234
|
+
try:
|
|
1235
|
+
# Save current edits
|
|
1236
|
+
self._save_current_dialog_state()
|
|
1237
|
+
except Exception:
|
|
1238
|
+
pass
|
|
1239
|
+
# Load requested suggestion
|
|
1240
|
+
self.load_suggestion(index)
|
|
1241
|
+
|
|
1242
|
+
def _save_current_dialog_state(self):
|
|
1243
|
+
"""Persist current Add Point dialog fields into pending_suggestions[current_index]."""
|
|
1244
|
+
if not (hasattr(self, 'var_entries') and self.var_entries):
|
|
1245
|
+
return
|
|
1246
|
+
if not (self.pending_suggestions and 0 <= self.current_suggestion_index < len(self.pending_suggestions)):
|
|
1247
|
+
return
|
|
1248
|
+
|
|
1249
|
+
# Read current values
|
|
1250
|
+
cs = self.pending_suggestions[self.current_suggestion_index]
|
|
1251
|
+
for var_name, entry in self.var_entries.items():
|
|
1252
|
+
try:
|
|
1253
|
+
cs[var_name] = entry.get()
|
|
1254
|
+
except Exception:
|
|
1255
|
+
cs[var_name] = ''
|
|
1256
|
+
|
|
1257
|
+
# Output and noise
|
|
1258
|
+
try:
|
|
1259
|
+
cs['Output'] = self.output_entry.get()
|
|
1260
|
+
except Exception:
|
|
1261
|
+
pass
|
|
1262
|
+
try:
|
|
1263
|
+
noise_val = self.noise_entry.get().strip()
|
|
1264
|
+
if noise_val:
|
|
1265
|
+
cs['Noise'] = float(noise_val)
|
|
1266
|
+
except Exception:
|
|
1267
|
+
# leave as-is if parse fails
|
|
1268
|
+
pass
|
|
808
1269
|
|
|
809
1270
|
def save_new_point(self):
|
|
810
|
-
'''Saves the new point
|
|
1271
|
+
'''Saves the new point with proper iteration and reason tracking.'''
|
|
811
1272
|
new_point = {var: entry.get() for var, entry in self.var_entries.items()}
|
|
812
1273
|
new_point['Output'] = self.output_entry.get()
|
|
813
1274
|
|
|
@@ -823,19 +1284,71 @@ class ALchemistApp(ctk.CTk):
|
|
|
823
1284
|
# If noise column exists but no value provided, use default
|
|
824
1285
|
new_point['Noise'] = 1e-6
|
|
825
1286
|
|
|
826
|
-
#
|
|
1287
|
+
# If this corresponds to a pending suggestion, preserve Iteration and Reason
|
|
1288
|
+
if self.pending_suggestions and self.current_suggestion_index < len(self.pending_suggestions):
|
|
1289
|
+
ps = self.pending_suggestions[self.current_suggestion_index]
|
|
1290
|
+
new_point['Iteration'] = int(ps.get('Iteration', getattr(self.experiment_manager, '_current_iteration', 0)))
|
|
1291
|
+
new_point['Reason'] = ps.get('_reason', ps.get('Reason', 'Acquisition'))
|
|
1292
|
+
# Remove this suggestion from pending list
|
|
1293
|
+
self.pending_suggestions.pop(self.current_suggestion_index)
|
|
1294
|
+
if self.current_suggestion_index >= len(self.pending_suggestions):
|
|
1295
|
+
self.current_suggestion_index = max(0, len(self.pending_suggestions) - 1)
|
|
1296
|
+
else:
|
|
1297
|
+
# Manual entry uses current iteration and Manual reason
|
|
1298
|
+
new_point['Iteration'] = int(getattr(self.experiment_manager, '_current_iteration', 0))
|
|
1299
|
+
new_point['Reason'] = 'Manual'
|
|
1300
|
+
|
|
1301
|
+
# Add the new point to the exp_df and update sheet
|
|
827
1302
|
new_point_df = pd.DataFrame([new_point])
|
|
828
|
-
|
|
1303
|
+
# Ensure exp_df has the right columns (merge if empty)
|
|
1304
|
+
if self.exp_df is None or self.exp_df.empty:
|
|
1305
|
+
self.exp_df = new_point_df.copy()
|
|
1306
|
+
else:
|
|
1307
|
+
self.exp_df = pd.concat([self.exp_df, new_point_df], ignore_index=True)
|
|
829
1308
|
|
|
830
|
-
# Update the tksheet
|
|
1309
|
+
# Update the tksheet and headers
|
|
1310
|
+
headers = self.exp_df.columns.tolist()
|
|
831
1311
|
self.exp_sheet.set_sheet_data(self.exp_df.values.tolist())
|
|
1312
|
+
try:
|
|
1313
|
+
self.exp_sheet.set_header_data(headers)
|
|
1314
|
+
except Exception:
|
|
1315
|
+
pass
|
|
1316
|
+
|
|
1317
|
+
# Sync to session (adds iteration/reason and saves session)
|
|
1318
|
+
if hasattr(self, 'session') and self.session:
|
|
1319
|
+
try:
|
|
1320
|
+
# Extract inputs dict (remove Output and Noise)
|
|
1321
|
+
inputs = {k: v for k, v in new_point.items() if k not in ['Output', 'Noise']}
|
|
1322
|
+
output_val = float(new_point['Output'])
|
|
1323
|
+
noise_val = new_point.get('Noise', None)
|
|
1324
|
+
|
|
1325
|
+
# Add to session with determined reason
|
|
1326
|
+
self.session.add_experiment(
|
|
1327
|
+
inputs=inputs,
|
|
1328
|
+
output=output_val,
|
|
1329
|
+
noise=noise_val,
|
|
1330
|
+
reason=new_point.get('Reason', 'Manual')
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
# Auto-save session if we have a file path
|
|
1334
|
+
if hasattr(self, 'current_session_file') and self.current_session_file:
|
|
1335
|
+
try:
|
|
1336
|
+
self.session.save_session(self.current_session_file)
|
|
1337
|
+
print(f"Point added (Iteration {self.session.experiment_manager._current_iteration}, Reason: {new_point.get('Reason', 'Manual')})")
|
|
1338
|
+
print(f"Session auto-saved to {self.current_session_file}")
|
|
1339
|
+
except Exception as e:
|
|
1340
|
+
print(f"Warning: Failed to auto-save session: {e}")
|
|
1341
|
+
except Exception as e:
|
|
1342
|
+
print(f"Warning: Failed to sync point to session: {e}")
|
|
832
1343
|
|
|
833
1344
|
# Save to file if checkbox is checked
|
|
834
1345
|
if self.save_checkbox.get():
|
|
835
1346
|
if hasattr(self, 'exp_file_path') and self.exp_file_path:
|
|
1347
|
+
# Auto-save to existing file without prompting
|
|
836
1348
|
self.exp_df.to_csv(self.exp_file_path, index=False)
|
|
837
|
-
|
|
1349
|
+
print(f"Experiments saved to {self.exp_file_path}")
|
|
838
1350
|
else:
|
|
1351
|
+
# Ask for file path if not set
|
|
839
1352
|
file_path = filedialog.asksaveasfilename(
|
|
840
1353
|
title='Save Experiments CSV',
|
|
841
1354
|
defaultextension='.csv',
|
|
@@ -843,6 +1356,8 @@ class ALchemistApp(ctk.CTk):
|
|
|
843
1356
|
)
|
|
844
1357
|
if file_path:
|
|
845
1358
|
self.exp_df.to_csv(file_path, index=False)
|
|
1359
|
+
self.exp_file_path = file_path
|
|
1360
|
+
print(f"Experiments saved to {file_path}")
|
|
846
1361
|
|
|
847
1362
|
if self.retrain_checkbox.get():
|
|
848
1363
|
self.retrain_model()
|
|
@@ -1177,9 +1692,61 @@ class ALchemistApp(ctk.CTk):
|
|
|
1177
1692
|
|
|
1178
1693
|
# Sync experiment data
|
|
1179
1694
|
if hasattr(self, 'exp_df') and len(self.exp_df) > 0:
|
|
1180
|
-
#
|
|
1181
|
-
|
|
1182
|
-
|
|
1695
|
+
# Ensure metadata columns have correct types
|
|
1696
|
+
exp_df_clean = self.exp_df.copy()
|
|
1697
|
+
|
|
1698
|
+
# Define metadata columns
|
|
1699
|
+
metadata_cols = {'Output', 'Noise', 'Iteration', 'Reason'}
|
|
1700
|
+
|
|
1701
|
+
# Ensure Iteration is numeric
|
|
1702
|
+
if 'Iteration' in exp_df_clean.columns:
|
|
1703
|
+
exp_df_clean['Iteration'] = pd.to_numeric(exp_df_clean['Iteration'], errors='coerce').fillna(0).astype(int)
|
|
1704
|
+
|
|
1705
|
+
# Ensure Reason is string
|
|
1706
|
+
if 'Reason' in exp_df_clean.columns:
|
|
1707
|
+
exp_df_clean['Reason'] = exp_df_clean['Reason'].astype(str).replace('nan', 'Manual')
|
|
1708
|
+
|
|
1709
|
+
# Ensure Output is numeric
|
|
1710
|
+
if 'Output' in exp_df_clean.columns:
|
|
1711
|
+
exp_df_clean['Output'] = pd.to_numeric(exp_df_clean['Output'], errors='coerce')
|
|
1712
|
+
|
|
1713
|
+
# Ensure Noise is numeric if present
|
|
1714
|
+
if 'Noise' in exp_df_clean.columns:
|
|
1715
|
+
exp_df_clean['Noise'] = pd.to_numeric(exp_df_clean['Noise'], errors='coerce')
|
|
1716
|
+
|
|
1717
|
+
# Get categorical variable names from search space
|
|
1718
|
+
categorical_vars = []
|
|
1719
|
+
if hasattr(self.session, 'search_space') and hasattr(self.session.search_space, 'variables'):
|
|
1720
|
+
categorical_vars = [v['name'] for v in self.session.search_space.variables if v.get('type') == 'categorical']
|
|
1721
|
+
|
|
1722
|
+
# Ensure all feature columns are numeric (except categoricals)
|
|
1723
|
+
for col in exp_df_clean.columns:
|
|
1724
|
+
if col not in metadata_cols:
|
|
1725
|
+
if col in categorical_vars:
|
|
1726
|
+
# Keep as string for categorical variables
|
|
1727
|
+
exp_df_clean[col] = exp_df_clean[col].astype(str)
|
|
1728
|
+
else:
|
|
1729
|
+
# Convert to numeric for real/integer variables
|
|
1730
|
+
exp_df_clean[col] = pd.to_numeric(exp_df_clean[col], errors='coerce')
|
|
1731
|
+
|
|
1732
|
+
# Verify no NaN in non-nullable columns and drop bad rows
|
|
1733
|
+
required_cols = [col for col in exp_df_clean.columns if col not in ['Noise', 'Reason']]
|
|
1734
|
+
n_before = len(exp_df_clean)
|
|
1735
|
+
exp_df_clean = exp_df_clean.dropna(subset=required_cols)
|
|
1736
|
+
n_after = len(exp_df_clean)
|
|
1737
|
+
|
|
1738
|
+
if n_after < n_before:
|
|
1739
|
+
print(f"WARNING: Dropped {n_before - n_after} rows with invalid/missing data")
|
|
1740
|
+
|
|
1741
|
+
if len(exp_df_clean) == 0:
|
|
1742
|
+
print("ERROR: No valid experiment data after cleaning!")
|
|
1743
|
+
return
|
|
1744
|
+
|
|
1745
|
+
# Copy cleaned data to session's experiment manager
|
|
1746
|
+
self.session.experiment_manager.df = exp_df_clean
|
|
1747
|
+
|
|
1748
|
+
# Update local exp_df with cleaned version
|
|
1749
|
+
self.exp_df = exp_df_clean
|
|
1183
1750
|
|
|
1184
1751
|
# Set search space in experiment manager
|
|
1185
1752
|
self.session.experiment_manager.set_search_space(self.session.search_space)
|
|
@@ -1189,4 +1756,486 @@ class ALchemistApp(ctk.CTk):
|
|
|
1189
1756
|
except Exception as e:
|
|
1190
1757
|
print(f"Error syncing data to session: {e}")
|
|
1191
1758
|
import traceback
|
|
1192
|
-
traceback.print_exc()
|
|
1759
|
+
traceback.print_exc()
|
|
1760
|
+
|
|
1761
|
+
# ============================================================
|
|
1762
|
+
# Session Management Methods
|
|
1763
|
+
# ============================================================
|
|
1764
|
+
|
|
1765
|
+
def new_session(self):
|
|
1766
|
+
"""Create a new session."""
|
|
1767
|
+
# Ask if user wants to save current session
|
|
1768
|
+
if hasattr(self.session, 'audit_log') and len(self.session.audit_log.entries) > 0:
|
|
1769
|
+
response = tk.messagebox.askyesnocancel("Save Current Session?",
|
|
1770
|
+
"Would you like to save the current session before creating a new one?")
|
|
1771
|
+
if response is None: # Cancel
|
|
1772
|
+
return
|
|
1773
|
+
elif response: # Yes
|
|
1774
|
+
self.save_session_cmd()
|
|
1775
|
+
|
|
1776
|
+
# Reset session
|
|
1777
|
+
self.session = OptimizationSession()
|
|
1778
|
+
self.session.events.on('progress', self._on_session_progress)
|
|
1779
|
+
self.session.events.on('model_trained', self._on_session_model_trained)
|
|
1780
|
+
self.session.events.on('model_retrained', self._on_session_model_retrained)
|
|
1781
|
+
self.session.events.on('suggestions_ready', self._on_session_suggestions)
|
|
1782
|
+
|
|
1783
|
+
# Clear UI
|
|
1784
|
+
self.search_space_manager = SearchSpace()
|
|
1785
|
+
self.experiment_manager = ExperimentManager()
|
|
1786
|
+
self.exp_df = pd.DataFrame()
|
|
1787
|
+
self.var_sheet.set_sheet_data([])
|
|
1788
|
+
self.exp_sheet.set_sheet_data([])
|
|
1789
|
+
|
|
1790
|
+
print("New session created")
|
|
1791
|
+
# Prompt user to name the session immediately
|
|
1792
|
+
dialog = SessionMetadataDialog(self, self.session)
|
|
1793
|
+
dialog.grab_set()
|
|
1794
|
+
self.wait_window(dialog)
|
|
1795
|
+
|
|
1796
|
+
if getattr(dialog, 'saved', False):
|
|
1797
|
+
tk.messagebox.showinfo("New Session", "New session created successfully.")
|
|
1798
|
+
|
|
1799
|
+
def open_session(self):
|
|
1800
|
+
"""Open a session from a JSON file."""
|
|
1801
|
+
filepath = filedialog.askopenfilename(
|
|
1802
|
+
title="Open Session",
|
|
1803
|
+
filetypes=[("JSON Session Files", "*.json"), ("All Files", "*.*")],
|
|
1804
|
+
defaultextension=".json"
|
|
1805
|
+
)
|
|
1806
|
+
|
|
1807
|
+
if not filepath:
|
|
1808
|
+
return
|
|
1809
|
+
|
|
1810
|
+
try:
|
|
1811
|
+
# Load session from file
|
|
1812
|
+
self.session = OptimizationSession.load_session(filepath)
|
|
1813
|
+
|
|
1814
|
+
# Reconnect event handlers
|
|
1815
|
+
self.session.events.on('progress', self._on_session_progress)
|
|
1816
|
+
self.session.events.on('model_trained', self._on_session_model_trained)
|
|
1817
|
+
self.session.events.on('model_retrained', self._on_session_model_retrained)
|
|
1818
|
+
self.session.events.on('suggestions_ready', self._on_session_suggestions)
|
|
1819
|
+
|
|
1820
|
+
# Sync session data to UI
|
|
1821
|
+
self._sync_session_to_ui()
|
|
1822
|
+
|
|
1823
|
+
# Store the current filepath for "Save" command
|
|
1824
|
+
self.current_session_file = filepath
|
|
1825
|
+
|
|
1826
|
+
# If the loaded session has no meaningful name, default to the
|
|
1827
|
+
# filename (basename without extension).
|
|
1828
|
+
current_name = (self.session.metadata.name or "").strip()
|
|
1829
|
+
if not current_name or current_name.lower().startswith("untitled"):
|
|
1830
|
+
try:
|
|
1831
|
+
basename = os.path.splitext(os.path.basename(filepath))[0]
|
|
1832
|
+
# Update session metadata via provided API (keeps modified timestamp)
|
|
1833
|
+
self.session.update_metadata(name=basename)
|
|
1834
|
+
except Exception:
|
|
1835
|
+
# Fall back to leaving whatever the session provided
|
|
1836
|
+
pass
|
|
1837
|
+
|
|
1838
|
+
# Update application title to reflect the opened session
|
|
1839
|
+
try:
|
|
1840
|
+
self.title(f"ALchemist - {self.session.metadata.name}")
|
|
1841
|
+
except Exception:
|
|
1842
|
+
pass
|
|
1843
|
+
|
|
1844
|
+
print(f"Session loaded from {filepath}")
|
|
1845
|
+
tk.messagebox.showinfo("Session Loaded",
|
|
1846
|
+
f"Session '{self.session.metadata.name}' loaded successfully.")
|
|
1847
|
+
|
|
1848
|
+
except Exception as e:
|
|
1849
|
+
tk.messagebox.showerror("Error Loading Session",
|
|
1850
|
+
f"Failed to load session:\n{str(e)}")
|
|
1851
|
+
import traceback
|
|
1852
|
+
traceback.print_exc()
|
|
1853
|
+
|
|
1854
|
+
def _on_session_model_retrained(self, event_data):
|
|
1855
|
+
"""Handle model retraining completion after session load."""
|
|
1856
|
+
backend = event_data.get('backend', 'unknown')
|
|
1857
|
+
print(f"Session: Model retrained successfully ({backend})")
|
|
1858
|
+
|
|
1859
|
+
# Sync session model to main_app.gpr_model
|
|
1860
|
+
self.gpr_model = self.session.model
|
|
1861
|
+
|
|
1862
|
+
# Enable UI elements
|
|
1863
|
+
if hasattr(self, 'gpr_panel'):
|
|
1864
|
+
self.gpr_panel.visualize_button.configure(state="normal")
|
|
1865
|
+
|
|
1866
|
+
if hasattr(self, 'acq_panel'):
|
|
1867
|
+
self.acq_panel.enable()
|
|
1868
|
+
|
|
1869
|
+
# Show notification
|
|
1870
|
+
tk.messagebox.showinfo("Model Retrained",
|
|
1871
|
+
f"Model retrained successfully using {backend} backend.")
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def save_session_cmd(self):
|
|
1875
|
+
"""Save the current session (use existing file or prompt for new)."""
|
|
1876
|
+
# Sync current UI state to session
|
|
1877
|
+
self._sync_data_to_session()
|
|
1878
|
+
|
|
1879
|
+
if hasattr(self, 'current_session_file') and self.current_session_file:
|
|
1880
|
+
# Use existing file
|
|
1881
|
+
try:
|
|
1882
|
+
self.session.save_session(self.current_session_file)
|
|
1883
|
+
print(f"Session saved to {self.current_session_file}")
|
|
1884
|
+
tk.messagebox.showinfo("Session Saved",
|
|
1885
|
+
f"Session saved successfully.")
|
|
1886
|
+
except Exception as e:
|
|
1887
|
+
tk.messagebox.showerror("Error Saving Session",
|
|
1888
|
+
f"Failed to save session:\n{str(e)}")
|
|
1889
|
+
else:
|
|
1890
|
+
# No existing file: this is effectively an autosave point. If the
|
|
1891
|
+
# session is unnamed, ask the user if they'd like to create/save a
|
|
1892
|
+
# named session first.
|
|
1893
|
+
if self.maybe_prompt_create_session():
|
|
1894
|
+
# User either created/confirmed a session name; prompt for save-as
|
|
1895
|
+
self.save_session_as()
|
|
1896
|
+
else:
|
|
1897
|
+
# User declined to create a session; still offer Save As as optional
|
|
1898
|
+
# but don't force it. We return early.
|
|
1899
|
+
return
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
def save_session_as(self):
|
|
1903
|
+
"""Save the current session to a new file."""
|
|
1904
|
+
# Sync current UI state to session
|
|
1905
|
+
self._sync_data_to_session()
|
|
1906
|
+
|
|
1907
|
+
# Suggest filename from session metadata
|
|
1908
|
+
default_name = self.session.metadata.name or "alchemist_session"
|
|
1909
|
+
# Sanitize filename
|
|
1910
|
+
import re
|
|
1911
|
+
default_name = re.sub(r'[^\w\s-]', '', default_name).strip().replace(' ', '_')
|
|
1912
|
+
default_name = f"{default_name}.json"
|
|
1913
|
+
|
|
1914
|
+
filepath = filedialog.asksaveasfilename(
|
|
1915
|
+
title="Save Session As",
|
|
1916
|
+
filetypes=[("JSON Session Files", "*.json"), ("All Files", "*.*")],
|
|
1917
|
+
defaultextension=".json",
|
|
1918
|
+
initialfile=default_name
|
|
1919
|
+
)
|
|
1920
|
+
|
|
1921
|
+
if not filepath:
|
|
1922
|
+
return
|
|
1923
|
+
|
|
1924
|
+
try:
|
|
1925
|
+
self.session.save_session(filepath)
|
|
1926
|
+
self.current_session_file = filepath
|
|
1927
|
+
print(f"Session saved to {filepath}")
|
|
1928
|
+
tk.messagebox.showinfo("Session Saved",
|
|
1929
|
+
f"Session saved successfully to:\n{filepath}")
|
|
1930
|
+
except Exception as e:
|
|
1931
|
+
tk.messagebox.showerror("Error Saving Session",
|
|
1932
|
+
f"Failed to save session:\n{str(e)}")
|
|
1933
|
+
import traceback
|
|
1934
|
+
traceback.print_exc()
|
|
1935
|
+
|
|
1936
|
+
def export_audit_log(self):
|
|
1937
|
+
"""Export the audit log to a markdown file."""
|
|
1938
|
+
if not hasattr(self.session, 'audit_log') or len(self.session.audit_log.entries) == 0:
|
|
1939
|
+
tk.messagebox.showwarning("No Audit Log",
|
|
1940
|
+
"No audit log entries to export.")
|
|
1941
|
+
return
|
|
1942
|
+
|
|
1943
|
+
# Suggest filename
|
|
1944
|
+
default_name = f"audit_log_{self.session.metadata.session_id[:8]}.md"
|
|
1945
|
+
|
|
1946
|
+
filepath = filedialog.asksaveasfilename(
|
|
1947
|
+
title="Export Audit Log",
|
|
1948
|
+
filetypes=[("Markdown Files", "*.md"), ("All Files", "*.*")],
|
|
1949
|
+
defaultextension=".md",
|
|
1950
|
+
initialfile=default_name
|
|
1951
|
+
)
|
|
1952
|
+
|
|
1953
|
+
if not filepath:
|
|
1954
|
+
return
|
|
1955
|
+
|
|
1956
|
+
try:
|
|
1957
|
+
markdown = self.session.audit_log.to_markdown()
|
|
1958
|
+
with open(filepath, 'w') as f:
|
|
1959
|
+
f.write(markdown)
|
|
1960
|
+
print(f"Audit log exported to {filepath}")
|
|
1961
|
+
tk.messagebox.showinfo("Audit Log Exported",
|
|
1962
|
+
f"Audit log exported successfully to:\n{filepath}")
|
|
1963
|
+
except Exception as e:
|
|
1964
|
+
tk.messagebox.showerror("Error Exporting Audit Log",
|
|
1965
|
+
f"Failed to export audit log:\n{str(e)}")
|
|
1966
|
+
|
|
1967
|
+
def edit_session_metadata(self):
|
|
1968
|
+
"""Open a dialog to edit session metadata."""
|
|
1969
|
+
dialog = SessionMetadataDialog(self, self.session)
|
|
1970
|
+
dialog.grab_set() # Make modal
|
|
1971
|
+
self.wait_window(dialog)
|
|
1972
|
+
|
|
1973
|
+
def _sync_session_to_ui(self):
|
|
1974
|
+
"""Sync session data to UI components."""
|
|
1975
|
+
try:
|
|
1976
|
+
# Sync search space
|
|
1977
|
+
if self.session.search_space and len(self.session.search_space.variables) > 0:
|
|
1978
|
+
self.search_space_manager = self.session.search_space
|
|
1979
|
+
self.search_space = self.session.search_space.to_skopt()
|
|
1980
|
+
|
|
1981
|
+
# Update variable sheet
|
|
1982
|
+
var_data = []
|
|
1983
|
+
for var_dict in self.session.search_space.variables:
|
|
1984
|
+
if var_dict['type'] in ['real', 'integer']:
|
|
1985
|
+
var_data.append([
|
|
1986
|
+
var_dict['name'],
|
|
1987
|
+
var_dict['type'],
|
|
1988
|
+
var_dict['min'],
|
|
1989
|
+
var_dict['max'],
|
|
1990
|
+
''
|
|
1991
|
+
])
|
|
1992
|
+
else: # categorical
|
|
1993
|
+
var_data.append([
|
|
1994
|
+
var_dict['name'],
|
|
1995
|
+
var_dict['type'],
|
|
1996
|
+
'',
|
|
1997
|
+
'',
|
|
1998
|
+
', '.join(map(str, var_dict['values']))
|
|
1999
|
+
])
|
|
2000
|
+
|
|
2001
|
+
self.var_sheet.set_sheet_data(var_data)
|
|
2002
|
+
self.var_sheet.set_all_column_widths()
|
|
2003
|
+
|
|
2004
|
+
# Sync experiment data
|
|
2005
|
+
if hasattr(self.session.experiment_manager, 'df') and len(self.session.experiment_manager.df) > 0:
|
|
2006
|
+
self.experiment_manager = self.session.experiment_manager
|
|
2007
|
+
self.exp_df = self.session.experiment_manager.df.copy()
|
|
2008
|
+
|
|
2009
|
+
# Update experiment sheet
|
|
2010
|
+
self.exp_sheet.set_sheet_data(self.exp_df.values.tolist())
|
|
2011
|
+
self.exp_sheet.set_header_data(self.exp_df.columns.tolist())
|
|
2012
|
+
self.exp_sheet.set_all_column_widths()
|
|
2013
|
+
|
|
2014
|
+
# Update UI state
|
|
2015
|
+
self._update_ui_state()
|
|
2016
|
+
|
|
2017
|
+
except Exception as e:
|
|
2018
|
+
print(f"Error syncing session to UI: {e}")
|
|
2019
|
+
import traceback
|
|
2020
|
+
traceback.print_exc()
|
|
2021
|
+
|
|
2022
|
+
def maybe_prompt_create_session(self) -> bool:
|
|
2023
|
+
"""If the current session is unnamed, prompt the user to create session metadata.
|
|
2024
|
+
|
|
2025
|
+
Returns True if the user created/confirmed a session name (or one already existed),
|
|
2026
|
+
False if the user declined.
|
|
2027
|
+
"""
|
|
2028
|
+
try:
|
|
2029
|
+
name = (self.session.metadata.name or "").strip()
|
|
2030
|
+
if name and not name.lower().startswith('untitled'):
|
|
2031
|
+
return True
|
|
2032
|
+
|
|
2033
|
+
resp = tk.messagebox.askyesno(
|
|
2034
|
+
"Create Session?",
|
|
2035
|
+
"This workspace is not associated with a saved session. Would you like to create a session now?"
|
|
2036
|
+
)
|
|
2037
|
+
if not resp:
|
|
2038
|
+
return False
|
|
2039
|
+
|
|
2040
|
+
dialog = SessionMetadataDialog(self, self.session)
|
|
2041
|
+
dialog.grab_set()
|
|
2042
|
+
self.wait_window(dialog)
|
|
2043
|
+
# Return True if the session now has a name
|
|
2044
|
+
return bool((self.session.metadata.name or "").strip())
|
|
2045
|
+
except Exception:
|
|
2046
|
+
return False
|
|
2047
|
+
|
|
2048
|
+
def _quit(self):
|
|
2049
|
+
"""Handle window close with Save / Don't Save / Cancel dialog.
|
|
2050
|
+
|
|
2051
|
+
- Yes: prompt Save As if no file, then quit on success
|
|
2052
|
+
- No: quit without saving
|
|
2053
|
+
- Cancel: abort close
|
|
2054
|
+
"""
|
|
2055
|
+
try:
|
|
2056
|
+
if hasattr(self.session, 'audit_log') and len(self.session.audit_log.entries) > 0:
|
|
2057
|
+
resp = tk.messagebox.askyesnocancel(
|
|
2058
|
+
"Save Session?",
|
|
2059
|
+
"Would you like to save the current session before quitting?"
|
|
2060
|
+
)
|
|
2061
|
+
if resp is None:
|
|
2062
|
+
# Cancel
|
|
2063
|
+
return
|
|
2064
|
+
if resp:
|
|
2065
|
+
# Yes -> save
|
|
2066
|
+
if hasattr(self, 'current_session_file') and self.current_session_file:
|
|
2067
|
+
try:
|
|
2068
|
+
self._sync_data_to_session()
|
|
2069
|
+
self.session.save_session(self.current_session_file)
|
|
2070
|
+
except Exception as e:
|
|
2071
|
+
tk.messagebox.showerror("Save Failed", f"Failed to save session:\n{e}")
|
|
2072
|
+
return
|
|
2073
|
+
else:
|
|
2074
|
+
# No existing file -> prompt for save-as
|
|
2075
|
+
if not self.maybe_prompt_create_session():
|
|
2076
|
+
# User declined to create a session, abort quit
|
|
2077
|
+
return
|
|
2078
|
+
self.save_session_as()
|
|
2079
|
+
# If we get here, either user chose No or save succeeded
|
|
2080
|
+
self.destroy()
|
|
2081
|
+
except Exception:
|
|
2082
|
+
# Fallback: destroy the window
|
|
2083
|
+
try:
|
|
2084
|
+
self.destroy()
|
|
2085
|
+
except Exception:
|
|
2086
|
+
pass
|
|
2087
|
+
|
|
2088
|
+
|
|
2089
|
+
# ============================================================
|
|
2090
|
+
# Session Metadata Dialog
|
|
2091
|
+
# ============================================================
|
|
2092
|
+
|
|
2093
|
+
class SessionMetadataDialog(ctk.CTkToplevel):
|
|
2094
|
+
"""Dialog for editing session metadata."""
|
|
2095
|
+
|
|
2096
|
+
def __init__(self, parent, session):
|
|
2097
|
+
super().__init__(parent)
|
|
2098
|
+
self.session = session
|
|
2099
|
+
self.title("Session Metadata")
|
|
2100
|
+
# Taller dialog to accommodate fields on smaller displays
|
|
2101
|
+
self.geometry("560x560")
|
|
2102
|
+
self.minsize(480, 420)
|
|
2103
|
+
self.saved = False
|
|
2104
|
+
|
|
2105
|
+
# Name field
|
|
2106
|
+
ctk.CTkLabel(self, text="Session Name:", font=('Arial', 12)).pack(pady=(10, 0))
|
|
2107
|
+
self.name_entry = ctk.CTkEntry(self, width=400)
|
|
2108
|
+
self.name_entry.pack(pady=5)
|
|
2109
|
+
self.name_entry.insert(0, session.metadata.name or "")
|
|
2110
|
+
|
|
2111
|
+
# Description field
|
|
2112
|
+
ctk.CTkLabel(self, text="Description:", font=('Arial', 12)).pack(pady=(10, 0))
|
|
2113
|
+
self.desc_text = ctk.CTkTextbox(self, width=400, height=100)
|
|
2114
|
+
self.desc_text.pack(pady=5)
|
|
2115
|
+
self.desc_text.insert("1.0", session.metadata.description or "")
|
|
2116
|
+
|
|
2117
|
+
# Tags field
|
|
2118
|
+
ctk.CTkLabel(self, text="Tags (comma-separated):", font=('Arial', 12)).pack(pady=(10, 0))
|
|
2119
|
+
self.tags_entry = ctk.CTkEntry(self, width=400)
|
|
2120
|
+
self.tags_entry.pack(pady=5)
|
|
2121
|
+
if session.metadata.tags:
|
|
2122
|
+
self.tags_entry.insert(0, ", ".join(session.metadata.tags))
|
|
2123
|
+
|
|
2124
|
+
# Author field (editable)
|
|
2125
|
+
ctk.CTkLabel(self, text="Author:", font=('Arial', 12)).pack(pady=(10, 0))
|
|
2126
|
+
self.author_entry = ctk.CTkEntry(self, width=400)
|
|
2127
|
+
self.author_entry.pack(pady=5)
|
|
2128
|
+
author_text = getattr(session.metadata, 'author', None)
|
|
2129
|
+
if author_text:
|
|
2130
|
+
self.author_entry.insert(0, author_text)
|
|
2131
|
+
|
|
2132
|
+
# Session ID (read-only)
|
|
2133
|
+
ctk.CTkLabel(self, text="Session ID:", font=('Arial', 12)).pack(pady=(10, 0))
|
|
2134
|
+
session_id = getattr(getattr(session, 'metadata', None), 'session_id', None)
|
|
2135
|
+
id_text = (session_id[:16] + "...") if session_id else "(no id)"
|
|
2136
|
+
id_label = ctk.CTkLabel(self, text=id_text)
|
|
2137
|
+
id_label.pack(pady=5)
|
|
2138
|
+
|
|
2139
|
+
# Buttons
|
|
2140
|
+
button_frame = ctk.CTkFrame(self)
|
|
2141
|
+
button_frame.pack(pady=20)
|
|
2142
|
+
|
|
2143
|
+
save_btn = ctk.CTkButton(button_frame, text="Save", command=self.save_metadata)
|
|
2144
|
+
save_btn.pack(side='left', padx=5)
|
|
2145
|
+
|
|
2146
|
+
cancel_btn = ctk.CTkButton(button_frame, text="Cancel", command=self._on_cancel)
|
|
2147
|
+
cancel_btn.pack(side='left', padx=5)
|
|
2148
|
+
|
|
2149
|
+
def save_metadata(self):
|
|
2150
|
+
"""Save the metadata changes."""
|
|
2151
|
+
name = self.name_entry.get().strip()
|
|
2152
|
+
description = self.desc_text.get("1.0", "end-1c").strip()
|
|
2153
|
+
tags_str = self.tags_entry.get().strip()
|
|
2154
|
+
tags = [t.strip() for t in tags_str.split(',') if t.strip()]
|
|
2155
|
+
|
|
2156
|
+
# Update session metadata
|
|
2157
|
+
self.session.update_metadata(
|
|
2158
|
+
name=name if name else None,
|
|
2159
|
+
description=description if description else None,
|
|
2160
|
+
tags=tags if tags else None,
|
|
2161
|
+
author=self.author_entry.get().strip() if hasattr(self, 'author_entry') else None
|
|
2162
|
+
)
|
|
2163
|
+
|
|
2164
|
+
print(f"Session metadata updated: {name}")
|
|
2165
|
+
self.saved = True
|
|
2166
|
+
self.destroy()
|
|
2167
|
+
|
|
2168
|
+
def _on_cancel(self):
|
|
2169
|
+
"""Handle cancel/close without saving."""
|
|
2170
|
+
self.saved = False
|
|
2171
|
+
self.destroy()
|
|
2172
|
+
|
|
2173
|
+
|
|
2174
|
+
# ============================================================
|
|
2175
|
+
# Lock Decision Confirmation Dialog
|
|
2176
|
+
# ============================================================
|
|
2177
|
+
|
|
2178
|
+
class LockDecisionDialog(ctk.CTkToplevel):
|
|
2179
|
+
"""Dialog for confirming lock decisions with optional notes."""
|
|
2180
|
+
|
|
2181
|
+
def __init__(self, parent, decision_type: str):
|
|
2182
|
+
super().__init__(parent)
|
|
2183
|
+
self.result = None # Will be None (cancelled) or notes string (confirmed)
|
|
2184
|
+
self.decision_type = decision_type
|
|
2185
|
+
|
|
2186
|
+
self.title(f"Lock {decision_type}")
|
|
2187
|
+
self.geometry("450x300")
|
|
2188
|
+
|
|
2189
|
+
# Message
|
|
2190
|
+
message_text = f"Lock this {decision_type.lower()} decision to the audit log?\n\n" \
|
|
2191
|
+
f"This will create an immutable record of your {decision_type.lower()} configuration."
|
|
2192
|
+
|
|
2193
|
+
msg_label = ctk.CTkLabel(
|
|
2194
|
+
self,
|
|
2195
|
+
text=message_text,
|
|
2196
|
+
font=('Arial', 12),
|
|
2197
|
+
wraplength=400
|
|
2198
|
+
)
|
|
2199
|
+
msg_label.pack(pady=(20, 10))
|
|
2200
|
+
|
|
2201
|
+
# Notes field
|
|
2202
|
+
ctk.CTkLabel(self, text="Optional Notes:", font=('Arial', 12, 'bold')).pack(pady=(10, 5))
|
|
2203
|
+
|
|
2204
|
+
self.notes_text = ctk.CTkTextbox(self, width=400, height=100)
|
|
2205
|
+
self.notes_text.pack(pady=5)
|
|
2206
|
+
self.notes_text.focus()
|
|
2207
|
+
|
|
2208
|
+
# Buttons
|
|
2209
|
+
button_frame = ctk.CTkFrame(self)
|
|
2210
|
+
button_frame.pack(pady=20)
|
|
2211
|
+
|
|
2212
|
+
ok_btn = ctk.CTkButton(
|
|
2213
|
+
button_frame,
|
|
2214
|
+
text="Lock Decision",
|
|
2215
|
+
command=self.confirm,
|
|
2216
|
+
fg_color="#2B8A3E", # Green
|
|
2217
|
+
hover_color="#228B22"
|
|
2218
|
+
)
|
|
2219
|
+
ok_btn.pack(side='left', padx=5)
|
|
2220
|
+
|
|
2221
|
+
cancel_btn = ctk.CTkButton(
|
|
2222
|
+
button_frame,
|
|
2223
|
+
text="Cancel",
|
|
2224
|
+
command=self.cancel
|
|
2225
|
+
)
|
|
2226
|
+
cancel_btn.pack(side='left', padx=5)
|
|
2227
|
+
|
|
2228
|
+
# Make modal
|
|
2229
|
+
self.transient(parent)
|
|
2230
|
+
self.grab_set()
|
|
2231
|
+
|
|
2232
|
+
def confirm(self):
|
|
2233
|
+
"""Confirm the lock decision."""
|
|
2234
|
+
self.result = self.notes_text.get("1.0", "end-1c").strip()
|
|
2235
|
+
self.destroy()
|
|
2236
|
+
|
|
2237
|
+
def cancel(self):
|
|
2238
|
+
"""Cancel the lock decision."""
|
|
2239
|
+
self.result = None
|
|
2240
|
+
self.destroy()
|
|
2241
|
+
|