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.
Files changed (37) hide show
  1. alchemist_core/__init__.py +14 -7
  2. alchemist_core/acquisition/botorch_acquisition.py +14 -6
  3. alchemist_core/audit_log.py +594 -0
  4. alchemist_core/data/experiment_manager.py +69 -5
  5. alchemist_core/models/botorch_model.py +6 -4
  6. alchemist_core/models/sklearn_model.py +44 -6
  7. alchemist_core/session.py +600 -8
  8. alchemist_core/utils/doe.py +200 -0
  9. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/METADATA +57 -40
  10. alchemist_nrel-0.3.0.dist-info/RECORD +66 -0
  11. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/entry_points.txt +1 -0
  12. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/top_level.txt +1 -0
  13. api/main.py +19 -3
  14. api/models/requests.py +71 -0
  15. api/models/responses.py +144 -0
  16. api/routers/experiments.py +117 -5
  17. api/routers/sessions.py +329 -10
  18. api/routers/visualizations.py +10 -5
  19. api/services/session_store.py +210 -54
  20. api/static/NEW_ICON.ico +0 -0
  21. api/static/NEW_ICON.png +0 -0
  22. api/static/NEW_LOGO_DARK.png +0 -0
  23. api/static/NEW_LOGO_LIGHT.png +0 -0
  24. api/static/assets/api-vcoXEqyq.js +1 -0
  25. api/static/assets/index-C0_glioA.js +4084 -0
  26. api/static/assets/index-CB4V1LI5.css +1 -0
  27. api/static/index.html +14 -0
  28. api/static/vite.svg +1 -0
  29. run_api.py +55 -0
  30. ui/gpr_panel.py +7 -2
  31. ui/notifications.py +197 -10
  32. ui/ui.py +1117 -68
  33. ui/variables_setup.py +47 -2
  34. ui/visualizations.py +60 -3
  35. alchemist_nrel-0.2.1.dist-info/RECORD +0 -54
  36. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/WHEEL +0 -0
  37. {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
- self.cluster_switch = ctk.CTkSwitch(self.frame_viz_options, text='Clustering', command=self.update_pool_plot, state='disabled')
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
- self.fig, self.ax = plt.subplots(figsize=(5, 5)) # Square figure
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=5, pady=5)
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
- if self.exp_df is not None:
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
- self.kmeans = None
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
- self.exp_df = pd.DataFrame(sheet_data, columns=self.exp_sheet.headers())
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
- file_path = filedialog.asksaveasfilename(
516
- title='Save Experiments CSV',
517
- defaultextension='.csv',
518
- filetypes=[('CSV Files', '*.csv')]
519
- )
520
- if file_path:
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(file_path, index=False)
523
- print('Experiments saved successfully.')
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
- if self.cluster_switch.get():
562
- # DEPRECATED: cluster_pool functionality is deprecated
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
- self.exp_df = pd.DataFrame(data)
756
- self.exp_sheet.set_sheet_data(self.exp_df.values.tolist())
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
- print('Initial points generated.')
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 New Point")
769
- self.add_point_window.geometry("400x450") # Made taller for the new field
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(self.add_point_window, text=var.name).pack(pady=5)
775
- entry = ctk.CTkEntry(self.add_point_window)
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
- ctk.CTkLabel(self.add_point_window, text='Output').pack(pady=5)
780
- self.output_entry = ctk.CTkEntry(self.add_point_window)
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(self.add_point_window, text='Noise (optional)').pack(pady=5)
785
- self.noise_entry = ctk.CTkEntry(self.add_point_window)
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
- self.add_point_window,
791
- text='Noise value represents measurement uncertainty\nand helps prevent overfitting.',
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=5)
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.add_point_window, text='Save & Close', command=self.save_new_point).pack(pady=10)
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 to the tksheet, exp_df, and optionally to a file.'''
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
- # Add the new point to the exp_df
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
- self.exp_df = pd.concat([self.exp_df, new_point_df], ignore_index=True)
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
- self.load_experiments(self.exp_file_path)
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
- # Copy experiment data to session's experiment manager
1181
- # IMPORTANT: ExperimentManager uses 'df' attribute, not 'experiments_df'
1182
- self.session.experiment_manager.df = self.exp_df.copy()
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
+